From 00c7600ecc7609815961ecf1b06b02d6121fcade Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:39:59 +0000 Subject: [PATCH] feat: preserve sub-agent uncommitted work via worktree diff artifact Fork-isolated exec children that never commit lose their working tree when the fork worktree is removed after the report: the patch artifact is commit-only (zero commits = skipped, no mbox). Generation now captures dirty state (tracked edits + untracked non-ignored files) as a binary diff against HEAD using a temporary index, so the child's real index and worktree are never mutated. The diff is stored next to the mbox and recorded in artifact metadata (hadUncommittedChanges, worktreePatchPath/Bytes, worktreePatchSkippedReason above the 10 MB cap). Zero-commit dirty artifacts become ready instead of skipped. task_apply_git_patch applies the mbox via git am first, then the worktree diff via git apply --3way --binary, leaving it as uncommitted changes. Failures are surfaced with conflict paths and the repo left recoverable. Artifact rollup for nested subagents carries the new patch file too. --- docs/hooks/tools.mdx | 22 +- src/common/utils/tools/toolDefinitions.ts | 41 + src/constants/subagentPatch.ts | 9 + .../builtInSkillContent.generated.ts | 22 +- .../gitPatchArtifactService.generate.test.ts | 718 ++++ src/node/services/gitPatchArtifactService.ts | 600 +++- src/node/services/gitPatchPathParsing.ts | 202 ++ .../subagentGitPatchArtifacts.test.ts | 580 +++ .../services/subagentGitPatchArtifacts.ts | 583 ++- src/node/services/taskService.test.ts | 187 + src/node/services/taskService.ts | 49 + .../tools/task_apply_git_patch.test.ts | 3200 ++++++++++++++++- .../services/tools/task_apply_git_patch.ts | 2214 ++++++++++-- src/node/services/tools/task_await.test.ts | 14 +- .../WorkflowTaskServiceAdapter.test.ts | 492 +++ .../workflows/WorkflowTaskServiceAdapter.ts | 150 +- src/node/services/workspaceService.test.ts | 1648 ++++++++- src/node/services/workspaceService.ts | 617 +++- 18 files changed, 10736 insertions(+), 612 deletions(-) create mode 100644 src/constants/subagentPatch.ts create mode 100644 src/node/services/gitPatchArtifactService.generate.test.ts create mode 100644 src/node/services/gitPatchPathParsing.ts diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index af5d5c7396..db7b6b992d 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -675,16 +675,18 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
-task_apply_git_patch (6) - -| Env var | JSON path | Type | Description | -| ---------------------------------- | ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `MUX_TOOL_INPUT_DRY_RUN` | `dry_run` | boolean | When true, attempt to apply the patch in a temporary git worktree and then discard it (does not modify the current workspace). | -| `MUX_TOOL_INPUT_EXPECTED_HEAD_SHA` | `expected_head_sha` | string | When provided, refuse to apply unless the target repository HEAD matches this SHA. | -| `MUX_TOOL_INPUT_FORCE` | `force` | boolean | When true, allow apply even if the patch was previously applied. | -| `MUX_TOOL_INPUT_PROJECT_PATH` | `project_path` | string | When provided, apply only the patch artifact for this project path. | -| `MUX_TOOL_INPUT_TASK_ID` | `task_id` | string | Child task ID whose patch artifact should be applied | -| `MUX_TOOL_INPUT_THREE_WAY` | `three_way` | boolean | When true, run git am with --3way | +task_apply_git_patch (8) + +| Env var | JSON path | Type | Description | +| ----------------------------------------------- | -------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MUX_TOOL_INPUT_ACKNOWLEDGE_PARTIAL_RECOVERY` | `acknowledge_partial_recovery` | boolean | When true, acknowledge that a PARTIALLY applied artifact (commit series landed, uncommitted-changes patch failed) was completed manually: clears the partial marker and records the artifact as applied without applying anything. Use after resolving the remaining changes by hand, e.g. a merged conflict resolution the automatic already-present detection cannot recognize. | +| `MUX_TOOL_INPUT_ACKNOWLEDGE_UNCAPTURED_CHANGES` | `acknowledge_uncaptured_changes` | boolean | When true, apply the captured content even though the child ended with uncommitted changes that were NOT captured into the artifact (see worktreePatchSkippedReason). Without this flag such applies fail so the uncaptured work cannot be silently omitted. Use after recovering the uncaptured changes manually from the preserved child workspace, or after deciding they are not needed. | +| `MUX_TOOL_INPUT_DRY_RUN` | `dry_run` | boolean | When true, attempt to apply the patch in a temporary git worktree and then discard it (does not modify the current workspace). | +| `MUX_TOOL_INPUT_EXPECTED_HEAD_SHA` | `expected_head_sha` | string | When provided, refuse to apply unless the target repository HEAD matches this SHA. | +| `MUX_TOOL_INPUT_FORCE` | `force` | boolean | When true, allow apply even if the patch was previously applied. | +| `MUX_TOOL_INPUT_PROJECT_PATH` | `project_path` | string | When provided, apply only the patch artifact for this project path. | +| `MUX_TOOL_INPUT_TASK_ID` | `task_id` | string | Child task ID whose patch artifact should be applied | +| `MUX_TOOL_INPUT_THREE_WAY` | `three_way` | boolean | When true, run git am with --3way |
diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 5ecac34078..40e674331f 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -732,8 +732,36 @@ export const SubagentGitProjectPatchArtifactSchema = z headCommitSha: z.string().optional(), commitCount: z.number().int().nonnegative().optional(), mboxPath: z.string().optional(), + hadUncommittedChanges: z.boolean().optional(), + worktreePatchPath: z.string().optional(), + worktreePatchBytes: z.number().int().nonnegative().optional(), + worktreePatchSkippedReason: z.string().optional(), error: z.string().optional(), appliedAtMs: z.number().int().nonnegative().optional(), + // Target HEAD after the full application; replay-safe retries verify the + // applied work is still present against it before skipping. + appliedHeadSha: z.string().optional(), + // Application asserted via acknowledge_partial_recovery: the manual + // recovery may not be reverse-applicable, so replay-safe validation + // skips the content check for it (ancestry still applies). + appliedAcknowledged: z.boolean().optional(), + // True when only the commit series landed (worktree patch failed); replay + // integrations must not treat this as a completed application. + appliedPartial: z.boolean().optional(), + // Target HEAD when the partial application was recorded. Completion + // requires it to still be an ancestor of HEAD, so a reset/rebased target + // cannot clear the marker while the applied commit series is missing. + appliedPartialHeadSha: z.string().optional(), + // "am-started": persisted BEFORE git am runs (fence = pre-am HEAD), so a + // crash after git am cannot leave applied commits unrecorded. Recovery + // retries fresh only when HEAD still equals the fence, else fails + // closed. "commits-applied" (or absent, for markers written before this + // field existed): the series landed; retries complete the worktree + // patch only. "unknown" is never written by an apply: sanitizers map a + // present-but-unreadable stage to it (dropping the field would misread + // an interrupted am-started record as commits-applied and skip git am), + // and recovery fails closed until acknowledged. + appliedPartialStage: z.enum(["am-started", "commits-applied", "unknown"]).optional(), }) .strict(); @@ -920,6 +948,18 @@ export const TaskApplyGitPatchToolArgsSchema = z .boolean() .nullish() .describe("When true, allow apply even if the patch was previously applied."), + acknowledge_partial_recovery: z + .boolean() + .nullish() + .describe( + "When true, acknowledge that a PARTIALLY applied artifact (commit series landed, uncommitted-changes patch failed) was completed manually: clears the partial marker and records the artifact as applied without applying anything. Use after resolving the remaining changes by hand, e.g. a merged conflict resolution the automatic already-present detection cannot recognize." + ), + acknowledge_uncaptured_changes: z + .boolean() + .nullish() + .describe( + "When true, apply the captured content even though the child ended with uncommitted changes that were NOT captured into the artifact (see worktreePatchSkippedReason). Without this flag such applies fail so the uncaptured work cannot be silently omitted. Use after recovering the uncaptured changes manually from the preserved child workspace, or after deciding they are not needed." + ), }) .strict(); @@ -2058,6 +2098,7 @@ export const TOOL_DEFINITIONS = { task_apply_git_patch: { description: "Apply a completed sub-agent task's git-format-patch artifact to the current workspace using `git am`. " + + "If the child ended with uncommitted changes, they were captured as a worktree diff and are applied after the commits, landing as uncommitted changes. " + "This is an explicit integration step: mux will not auto-apply patches.", schema: TaskApplyGitPatchToolArgsSchema, }, diff --git a/src/constants/subagentPatch.ts b/src/constants/subagentPatch.ts new file mode 100644 index 0000000000..d747ff96ca --- /dev/null +++ b/src/constants/subagentPatch.ts @@ -0,0 +1,9 @@ +// Untracked files may be arbitrarily large, so capture needs a hard disk-usage bound. +export const SUBAGENT_WORKTREE_PATCH_MAX_BYTES = 10 * 1024 * 1024; + +// `git add` materializes whole blobs into the capture's temporary object dir +// before the capped diff produces any output, so staging needs its own bound: +// the diff cap alone would let a multi-gigabyte dirty file fill /tmp. Larger +// than the diff cap because a big tracked file with a small change stages a +// big blob yet can still produce a small, capturable diff. +export const SUBAGENT_WORKTREE_PATCH_MAX_STAGED_BYTES = 256 * 1024 * 1024; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index bd2a188ac5..fff0e09fee 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -5494,16 +5494,18 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "", "
", - "task_apply_git_patch (6)", - "", - "| Env var | JSON path | Type | Description |", - "| ---------------------------------- | ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ |", - "| `MUX_TOOL_INPUT_DRY_RUN` | `dry_run` | boolean | When true, attempt to apply the patch in a temporary git worktree and then discard it (does not modify the current workspace). |", - "| `MUX_TOOL_INPUT_EXPECTED_HEAD_SHA` | `expected_head_sha` | string | When provided, refuse to apply unless the target repository HEAD matches this SHA. |", - "| `MUX_TOOL_INPUT_FORCE` | `force` | boolean | When true, allow apply even if the patch was previously applied. |", - "| `MUX_TOOL_INPUT_PROJECT_PATH` | `project_path` | string | When provided, apply only the patch artifact for this project path. |", - "| `MUX_TOOL_INPUT_TASK_ID` | `task_id` | string | Child task ID whose patch artifact should be applied |", - "| `MUX_TOOL_INPUT_THREE_WAY` | `three_way` | boolean | When true, run git am with --3way |", + "task_apply_git_patch (8)", + "", + "| Env var | JSON path | Type | Description |", + "| ----------------------------------------------- | -------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", + "| `MUX_TOOL_INPUT_ACKNOWLEDGE_PARTIAL_RECOVERY` | `acknowledge_partial_recovery` | boolean | When true, acknowledge that a PARTIALLY applied artifact (commit series landed, uncommitted-changes patch failed) was completed manually: clears the partial marker and records the artifact as applied without applying anything. Use after resolving the remaining changes by hand, e.g. a merged conflict resolution the automatic already-present detection cannot recognize. |", + "| `MUX_TOOL_INPUT_ACKNOWLEDGE_UNCAPTURED_CHANGES` | `acknowledge_uncaptured_changes` | boolean | When true, apply the captured content even though the child ended with uncommitted changes that were NOT captured into the artifact (see worktreePatchSkippedReason). Without this flag such applies fail so the uncaptured work cannot be silently omitted. Use after recovering the uncaptured changes manually from the preserved child workspace, or after deciding they are not needed. |", + "| `MUX_TOOL_INPUT_DRY_RUN` | `dry_run` | boolean | When true, attempt to apply the patch in a temporary git worktree and then discard it (does not modify the current workspace). |", + "| `MUX_TOOL_INPUT_EXPECTED_HEAD_SHA` | `expected_head_sha` | string | When provided, refuse to apply unless the target repository HEAD matches this SHA. |", + "| `MUX_TOOL_INPUT_FORCE` | `force` | boolean | When true, allow apply even if the patch was previously applied. |", + "| `MUX_TOOL_INPUT_PROJECT_PATH` | `project_path` | string | When provided, apply only the patch artifact for this project path. |", + "| `MUX_TOOL_INPUT_TASK_ID` | `task_id` | string | Child task ID whose patch artifact should be applied |", + "| `MUX_TOOL_INPUT_THREE_WAY` | `three_way` | boolean | When true, run git am with --3way |", "", "
", "", diff --git a/src/node/services/gitPatchArtifactService.generate.test.ts b/src/node/services/gitPatchArtifactService.generate.test.ts new file mode 100644 index 0000000000..adc1662be8 --- /dev/null +++ b/src/node/services/gitPatchArtifactService.generate.test.ts @@ -0,0 +1,718 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import * as fsPromises from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { execSync } from "node:child_process"; + +import { Config } from "@/node/config"; +import { GitPatchArtifactService } from "@/node/services/gitPatchArtifactService"; +import { readSubagentGitPatchArtifact } from "@/node/services/subagentGitPatchArtifacts"; +import * as runtimeHelpers from "@/node/utils/runtime/helpers"; + +function initGitRepo(repoPath: string): void { + execSync("git init -b main", { cwd: repoPath, stdio: "ignore" }); + execSync('git config user.email "test@example.com"', { cwd: repoPath, stdio: "ignore" }); + execSync('git config user.name "test"', { cwd: repoPath, stdio: "ignore" }); + execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" }); +} + +async function commitFile( + repoPath: string, + fileName: string, + content: string, + message: string +): Promise { + await fsPromises.writeFile(path.join(repoPath, fileName), content, "utf-8"); + execSync(`git add -- ${fileName}`, { cwd: repoPath, stdio: "ignore" }); + // Inline identity: repos not created via initGitRepo (e.g. submodule + // clones) have no local identity, and CI runners have no global one. + execSync( + `git -c user.email="test@example.com" -c user.name="test" -c commit.gpgsign=false commit -m ${JSON.stringify(message)}`, + { cwd: repoPath, stdio: "ignore" } + ); + return execSync("git rev-parse HEAD", { cwd: repoPath, encoding: "utf-8" }).trim(); +} + +describe("GitPatchArtifactService worktree capture", () => { + let rootDir: string; + let config: Config; + let projectPath: string; + let childRepo: string; + let baseSha: string; + + const parentId = "parent-1111"; + const childId = "child-2222"; + + async function saveChildWorkspace(): Promise { + await config.editConfig(() => ({ + projects: new Map([ + [ + projectPath, + { + trusted: true, + workspaces: [ + { path: projectPath, id: parentId, name: "parent" }, + { + path: childRepo, + id: childId, + name: "child", + parentWorkspaceId: parentId, + runtimeConfig: { type: "local" as const }, + taskBaseCommitSha: baseSha, + }, + ], + }, + ], + ]), + })); + } + + async function runGenerate(service: GitPatchArtifactService): Promise { + await ( + service as unknown as { + generate( + parentWorkspaceId: string, + childWorkspaceId: string, + onComplete: (childWorkspaceId: string) => Promise + ): Promise; + } + ).generate(parentId, childId, () => Promise.resolve()); + } + + beforeEach(async () => { + rootDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-git-patch-generate-test-")); + config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + projectPath = path.join(rootDir, "repo"); + childRepo = path.join(projectPath, "child"); + await fsPromises.mkdir(childRepo, { recursive: true }); + initGitRepo(childRepo); + baseSha = await commitFile(childRepo, "base.txt", "base\n", "base commit"); + await saveChildWorkspace(); + }); + + afterEach(async () => { + await fsPromises.rm(rootDir, { recursive: true, force: true }); + }); + + it("keeps a clean zero-commit tree skipped with no worktree fields", async () => { + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("skipped"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.status).toBe("skipped"); + // The clean branch must not record dirty-capture results. + expect(projectArtifact?.hadUncommittedChanges).toBeUndefined(); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchBytes).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toBeUndefined(); + }); + + it("removes a stale canonical worktree patch when a rerun finds a clean tree", async () => { + // A capture writes the canonical patch, then the work is committed and + // generation re-runs (e.g. startup recovery after a crash that lost the + // metadata write): the regenerated artifact has no worktree fields, so + // the stale file must not survive for apply-side canonical probing to + // re-apply outdated changes after git am. + await fsPromises.writeFile(path.join(childRepo, "dirty.txt"), "dirty\n", "utf-8"); + await runGenerate(new GitPatchArtifactService(config)); + const firstArtifact = await readSubagentGitPatchArtifact( + config.getSessionDir(parentId), + childId + ); + const canonicalPatchPath = firstArtifact?.projectArtifacts[0]?.worktreePatchPath; + expect(canonicalPatchPath).toBeDefined(); + + execSync("git add -A && git commit -m 'commit the dirty work'", { + cwd: childRepo, + stdio: "ignore", + }); + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("ready"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + const staleFileExists = await fsPromises + .access(canonicalPatchPath!) + .then(() => true) + .catch(() => false); + expect(staleFileExists).toBe(false); + }); + + it("captures dirty tracked changes for a zero-commit tree as a ready worktree patch", async () => { + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("ready"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.commitCount).toBe(0); + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.mboxPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + expect(projectArtifact?.worktreePatchBytes).toBeGreaterThan(0); + + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("modified"); + }); + + it("captures standard a/ b/ prefixes even under diff.noprefix=true", async () => { + execSync("git config diff.noprefix true", { cwd: childRepo, stdio: "ignore" }); + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + // Default `git apply` rejects prefix-less headers, so the capture must + // not inherit the repo's noprefix setting. + expect(patch).toContain("diff --git a/base.txt b/base.txt"); + // The captured patch stays applyable with a default-config git. + execSync("git stash --include-untracked", { cwd: childRepo, stdio: "ignore" }); + execSync(`git apply --3way --binary ${JSON.stringify(projectArtifact!.worktreePatchPath!)}`, { + cwd: childRepo, + stdio: "ignore", + }); + expect(await fsPromises.readFile(path.join(childRepo, "base.txt"), "utf-8")).toBe("modified\n"); + }); + + it("reports untracked embedded git repositories as uncaptured and excludes their gitlinks", async () => { + // Untracked nested repo with a commit that exists only in the child. + const nestedRepo = path.join(childRepo, "nested-repo"); + await fsPromises.mkdir(nestedRepo, { recursive: true }); + initGitRepo(nestedRepo); + await commitFile(nestedRepo, "inner.txt", "inner\n", "inner commit"); + // A regular dirty file so a root patch still gets captured alongside. + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("nested-repo"); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("NOT captured"); + // The root patch exists but must not carry the embedded repo's gitlink, + // whose commit no target can resolve. + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("base.txt"); + expect(patch).not.toContain("Subproject commit"); + }); + + it("detects an embedded repo whose name forces porcelain quoting", async () => { + // A space makes line-oriented `git status --porcelain` quote the path + // (`?? "nested repo/"`), which a line/regex probe would miss. + const nestedRepo = path.join(childRepo, "nested repo"); + await fsPromises.mkdir(nestedRepo, { recursive: true }); + initGitRepo(nestedRepo); + await commitFile(nestedRepo, "inner.txt", "inner\n", "inner commit"); + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("nested repo"); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("NOT captured"); + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("base.txt"); + expect(patch).not.toContain("Subproject commit"); + }); + + it("keeps dirty sibling paths that an embedded repo's glob-like name would match", async () => { + // The exclude pathspec must be literal: `nested*repo` as a glob would + // also exclude the dirty sibling `nestedXrepo`, silently dropping it + // from the patch before child cleanup discards it. + const nestedRepo = path.join(childRepo, "nested*repo"); + await fsPromises.mkdir(nestedRepo, { recursive: true }); + initGitRepo(nestedRepo); + await commitFile(nestedRepo, "inner.txt", "inner\n", "inner commit"); + await fsPromises.writeFile(path.join(childRepo, "nestedXrepo"), "sibling\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("nested*repo"); + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("nestedXrepo"); + expect(patch).not.toContain("Subproject commit"); + }); + + it("reports an embedded-repo-only dirty tree as uncaptured instead of empty", async () => { + const nestedRepo = path.join(childRepo, "nested-only"); + await fsPromises.mkdir(nestedRepo, { recursive: true }); + initGitRepo(nestedRepo); + await commitFile(nestedRepo, "inner.txt", "inner\n", "inner commit"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("nested-only"); + }); + + it("converts a rejected capture into conservative uncaptured metadata", async () => { + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + // A FILE at the patch directory's ancestor makes the capture's local + // file write reject (ENOTDIR), simulating a runtime/filesystem failure. + const sessionDir = config.getSessionDir(parentId); + await fsPromises.mkdir(sessionDir, { recursive: true }); + await fsPromises.writeFile(path.join(sessionDir, "subagent-patches"), "not a dir", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + // The artifacts file itself lives outside subagent-patches/, so the + // conservative metadata is still recorded. + const artifact = await readSubagentGitPatchArtifact(sessionDir, childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchSkippedReason).toContain( + "Could not capture uncommitted changes" + ); + }); + + it("removes a leftover patch file when capture fails after writing it", async () => { + await fsPromises.writeFile(path.join(childRepo, "dirty.txt"), "dirty\n", "utf-8"); + + // Transient I/O failure after the diff stream already wrote the patch + // file: the capture exception path must not leave the file behind at + // the canonical location, where apply-side probing would pick it up + // despite the skip metadata. + const realStat = fsPromises.stat; + let failedStat = false; + const statSpy = spyOn(fsPromises, "stat").mockImplementation((( + statPath: Parameters[0], + options?: Parameters[1] + ) => { + if (typeof statPath === "string" && statPath.endsWith("worktree.patch")) { + failedStat = true; + return Promise.reject(new Error("EIO: simulated stat failure")); + } + return realStat(statPath, options); + }) as typeof fsPromises.stat); + try { + await runGenerate(new GitPatchArtifactService(config)); + } finally { + statSpy.mockRestore(); + } + expect(failedStat).toBe(true); + + const sessionDir = config.getSessionDir(parentId); + const artifact = await readSubagentGitPatchArtifact(sessionDir, childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain( + "Could not capture uncommitted changes" + ); + + const patchDir = path.join(sessionDir, "subagent-patches"); + const leftoverPatches = ( + await fsPromises.readdir(patchDir, { recursive: true }).catch(() => [] as string[]) + ).filter((entry) => String(entry).endsWith("worktree.patch")); + expect(leftoverPatches).toEqual([]); + }); + + it("captures untracked non-ignored files", async () => { + await fsPromises.writeFile(path.join(childRepo, "untracked.txt"), "new file\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(artifact?.status).toBe("ready"); + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("untracked.txt"); + expect(patch).toContain("new file"); + }); + + it("keeps capture blobs out of the repo's permanent object store", async () => { + await fsPromises.writeFile(path.join(childRepo, "untracked.txt"), "new file\n", "utf-8"); + const looseObjectCount = async (): Promise => { + const entries = await fsPromises.readdir(path.join(childRepo, ".git", "objects"), { + recursive: true, + withFileTypes: true, + }); + return entries.filter((entry) => entry.isFile()).length; + }; + const before = await looseObjectCount(); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + // Staged blobs went to the throwaway object dir, not .git/objects. + expect(await looseObjectCount()).toBe(before); + }); + + it("warns about unknown dirty state when the worktree cannot be inspected", async () => { + // Corrupt the repo so git status (and everything after) fails. + await fsPromises.rm(path.join(childRepo, ".git"), { recursive: true, force: true }); + await fsPromises.writeFile(path.join(childRepo, ".git"), "gitdir: /nonexistent\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.status).toBe("failed"); + // Unknown dirty state must not read as clean. + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("Could not inspect"); + }); + + it("records dirty work even when commit metadata resolution fails", async () => { + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + // Drop taskBaseCommitSha: the merge-base fallback resolves trunk to the + // parent workspace name ("parent"), which is not a ref, so metadata + // resolution fails after capture. + await config.editConfig((cfg) => { + const workspaces = cfg.projects.get(projectPath)?.workspaces ?? []; + const child = workspaces.find((workspace) => workspace.id === childId); + if (child) { + delete child.taskBaseCommitSha; + } + return cfg; + }); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.status).toBe("failed"); + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("modified"); + }); + + async function addDirtySubmodule(name = "sub"): Promise { + const subRepo = path.join(rootDir, "subrepo"); + await fsPromises.mkdir(subRepo, { recursive: true }); + initGitRepo(subRepo); + await commitFile(subRepo, "inner.txt", "inner\n", "sub base"); + + execSync( + `git -c protocol.file.allow=always submodule add ${JSON.stringify(subRepo)} ${JSON.stringify(name)}`, + { + cwd: childRepo, + stdio: "ignore", + } + ); + execSync('git commit -m "add submodule"', { cwd: childRepo, stdio: "ignore" }); + baseSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + await saveChildWorkspace(); + + // Edit inside the submodule without committing: the superproject diff is + // empty (the gitlink is unchanged), so capture cannot represent this work. + await fsPromises.writeFile(path.join(childRepo, name, "inner.txt"), "edited\n", "utf-8"); + } + + it("reports dirty submodule contents as uncaptured instead of an empty diff", async () => { + await addDirtySubmodule(); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("skipped"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("submodule(s) sub"); + }); + + it("detects a dirty submodule whose name core.quotePath would quote", async () => { + // Non-ASCII names are C-quoted on git's line-oriented output; probing + // the quoted literal would miss the submodule entirely. + await addDirtySubmodule("s\u00fcb-m\u00f6dule"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("skipped"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("s\u00fcb-m\u00f6dule"); + }); + + it("flags uncaptured submodule work alongside a captured superproject patch", async () => { + await addDirtySubmodule(); + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("ready"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("submodule(s) sub"); + }); + + it("keeps a moved submodule gitlink out of the captured worktree patch", async () => { + await addDirtySubmodule(); + // Commit inside the submodule: the superproject gitlink now points at a + // commit that exists only in the child's clone, so capturing it would + // produce a patch referencing an unfetchable object after cleanup. + await commitFile(path.join(childRepo, "sub"), "inner.txt", "committed inner\n", "sub move"); + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("ready"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("submodule(s) sub"); + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("base.txt"); + expect(patch).not.toContain("Subproject commit"); + }); + + it("skips worktree capture when the submodule probe fails", async () => { + await addDirtySubmodule(); + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + + // A failed probe leaves gitlink discovery unknown, so no exclusion list + // exists: capturing anyway could stage a moved gitlink and emit a patch + // referencing a commit that vanishes with child cleanup. Capture must be + // skipped and reported, not attempted. + const realExecBuffered = runtimeHelpers.execBuffered; + const execSpy = spyOn(runtimeHelpers, "execBuffered").mockImplementation( + (runtime, command, options) => { + if (command === "git ls-files -s -z") { + return Promise.resolve({ + stdout: "", + stderr: "simulated probe failure", + exitCode: 128, + duration: 0, + }); + } + return realExecBuffered(runtime, command, options); + } + ); + try { + await runGenerate(new GitPatchArtifactService(config)); + } finally { + execSpy.mockRestore(); + } + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("skipped"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain( + "Could not determine whether submodules contain uncommitted changes" + ); + expect(projectArtifact?.worktreePatchSkippedReason).toContain( + "The uncommitted changes were not captured." + ); + }); + + it("skips worktree capture when the staging-size probe fails", async () => { + await fsPromises.writeFile(path.join(childRepo, "base.txt"), "modified\n", "utf-8"); + + // A failed du probe must not read as "small": partial or empty stdout + // would let an arbitrarily large dirty file through to `git add`, which + // materializes unbounded blobs before the diff byte cap can trigger. + const realExecBuffered = runtimeHelpers.execBuffered; + const execSpy = spyOn(runtimeHelpers, "execBuffered").mockImplementation( + (runtime, command, options) => { + if (command.includes("du -sk")) { + return Promise.resolve({ + stdout: "", + stderr: "du: command not found", + exitCode: 127, + duration: 0, + }); + } + return realExecBuffered(runtime, command, options); + } + ); + try { + await runGenerate(new GitPatchArtifactService(config)); + } finally { + execSpy.mockRestore(); + } + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("skipped"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("staging-size preflight failed"); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("NOT captured"); + }); + + it("produces both mbox and worktree patch when commits and dirty changes coexist", async () => { + await commitFile(childRepo, "committed.txt", "committed\n", "child commit"); + await fsPromises.writeFile(path.join(childRepo, "dirty.txt"), "dirty\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("ready"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.commitCount).toBe(1); + expect(projectArtifact?.mboxPath).toBeDefined(); + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + + const mbox = await fsPromises.readFile(projectArtifact!.mboxPath!, "utf-8"); + expect(mbox).toContain("child commit"); + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("dirty.txt"); + }); + + it("preserves dirty-worktree metadata when mbox generation fails", async () => { + await commitFile(childRepo, "committed.txt", "committed\n", "child commit"); + await fsPromises.writeFile(path.join(childRepo, "dirty.txt"), "dirty\n", "utf-8"); + // Force git format-patch to fail after worktree capture succeeds. + execSync("git config format.signatureFile /nonexistent-signature", { + cwd: childRepo, + stdio: "ignore", + }); + + await runGenerate(new GitPatchArtifactService(config)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.status).toBe("failed"); + expect(projectArtifact?.error).toContain("git format-patch failed"); + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + }); + + it("records a skip reason instead of capturing when the diff exceeds the size cap", async () => { + await fsPromises.writeFile(path.join(childRepo, "big.txt"), "x".repeat(4096), "utf-8"); + + await runGenerate(new GitPatchArtifactService(config, 100)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("skipped"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("capture cap"); + }); + + it("skips capture before staging when dirty files exceed the staged-bytes bound", async () => { + // A dirty file whose on-disk size exceeds the staging bound but whose + // path-quoting needs the porcelain -z parse (space in the name). + await fsPromises.writeFile(path.join(childRepo, "big file.txt"), "x".repeat(64 * 1024)); + + await runGenerate(new GitPatchArtifactService(config, 10 * 1024 * 1024, 4096)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("skipped"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toContain("staging bound"); + }); + + it("captures normally when dirty files stay under the staged-bytes bound", async () => { + await fsPromises.writeFile(path.join(childRepo, "small.txt"), "small change\n", "utf-8"); + + await runGenerate(new GitPatchArtifactService(config, 10 * 1024 * 1024, 1024 * 1024)); + + const artifact = await readSubagentGitPatchArtifact(config.getSessionDir(parentId), childId); + expect(artifact?.status).toBe("ready"); + const projectArtifact = artifact?.projectArtifacts[0]; + expect(projectArtifact?.worktreePatchPath).toBeDefined(); + const patch = await fsPromises.readFile(projectArtifact!.worktreePatchPath!, "utf-8"); + expect(patch).toContain("small.txt"); + }); + + it("aborts before capture when the pending marker cannot be persisted", async () => { + // If the pending write silently failed, generation would capture files + // no index entry references, every later metadata write would also + // fail, and cleanup would read "no artifact" and delete the child with + // its dirty work. maybeStartGeneration must propagate the failure and + // never reach capture or onComplete. + await config.editConfig((cfg) => { + for (const project of cfg.projects.values()) { + for (const workspace of project.workspaces) { + if (workspace.id === childId) { + (workspace as { agentType?: string }).agentType = "exec"; + } + } + } + return cfg; + }); + await fsPromises.writeFile(path.join(childRepo, "dirty.txt"), "dirty\n", "utf-8"); + const parentSessionDir = config.getSessionDir(parentId); + // Directory at the artifacts-file path: reads self-heal to empty but + // the atomic write's rename fails. + await fsPromises.mkdir(path.join(parentSessionDir, "subagent-patches.json"), { + recursive: true, + }); + + const service = new GitPatchArtifactService(config); + let onCompleteCalls = 0; + let startError: unknown; + try { + await service.maybeStartGeneration(parentId, childId, () => { + onCompleteCalls += 1; + return Promise.resolve(); + }); + // Old behavior started a background job; wait for it so the capture + // assertion below cannot race. + await ( + service as unknown as { pendingJobsByTaskId: Map> } + ).pendingJobsByTaskId.get(childId); + } catch (error) { + startError = error; + } + expect(startError).toBeDefined(); + expect(onCompleteCalls).toBe(0); + // No capture output at all: the whole task patch dir stays absent + // regardless of storage key. + const taskPatchDir = path.join(parentSessionDir, "subagent-patches", childId); + expect( + await fsPromises + .readdir(taskPatchDir, { recursive: true }) + .then((entries) => entries.join(",")) + .catch(() => "ENOENT") + ).toBe("ENOENT"); + }); + + it("shouldGeneratePatchForTask distinguishes exec-like from read-only tasks", async () => { + const service = new GitPatchArtifactService(config); + // The harness child has no persisted agent identity. + expect(await service.shouldGeneratePatchForTask(parentId, childId)).toBe(false); + + const setAgentType = async (agentType: string): Promise => { + await config.editConfig((cfg) => { + for (const project of cfg.projects.values()) { + for (const workspace of project.workspaces) { + if (workspace.id === childId) { + (workspace as { agentType?: string }).agentType = agentType; + } + } + } + return cfg; + }); + }; + await setAgentType("exec"); + expect(await service.shouldGeneratePatchForTask(parentId, childId)).toBe(true); + await setAgentType("explore"); + expect(await service.shouldGeneratePatchForTask(parentId, childId)).toBe(false); + }); +}); diff --git a/src/node/services/gitPatchArtifactService.ts b/src/node/services/gitPatchArtifactService.ts index e1d8624882..0231098ecc 100644 --- a/src/node/services/gitPatchArtifactService.ts +++ b/src/node/services/gitPatchArtifactService.ts @@ -15,6 +15,7 @@ import { findWorkspaceEntry, } from "@/node/services/taskUtils"; import { log } from "@/node/services/log"; +import { parseGitStatusPorcelainZ } from "@/node/services/gitPatchPathParsing"; import { readAgentDefinition } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; @@ -28,13 +29,20 @@ import { AgentIdSchema } from "@/common/orpc/schemas"; import { resolvePersistedAgentIdCandidates } from "@/common/utils/agentIds"; import { getSubagentGitPatchMboxPath, + getSubagentGitPatchWorktreePatchPath, matchesProjectArtifactProjectPathForUpdate, upsertSubagentGitPatchArtifact, } from "@/node/services/subagentGitPatchArtifacts"; +import { + SUBAGENT_WORKTREE_PATCH_MAX_BYTES, + SUBAGENT_WORKTREE_PATCH_MAX_STAGED_BYTES, +} from "@/constants/subagentPatch"; import { shellQuote } from "@/common/utils/shell"; import { streamToString } from "@/node/runtime/streamUtils"; import { getErrorMessage } from "@/common/utils/errors"; import { PlatformPaths } from "@/common/utils/paths"; +import { isPathInsideDir } from "@/node/utils/pathUtils"; +import type { Runtime } from "@/node/runtime/Runtime"; import { getWorkspaceProjectRepos, getWorkspaceProjectStorageKeys, @@ -43,24 +51,17 @@ import { /** Callback invoked after patch generation completes (success or failure). */ export type OnPatchGenerationComplete = (childWorkspaceId: string) => Promise; -function isPathInsideDir(dirPath: string, filePath: string): boolean { - const resolvedDir = path.resolve(dirPath); - const resolvedFile = path.resolve(filePath); - const relativePath = path.relative(resolvedDir, resolvedFile); - return ( - relativePath.length > 0 && !relativePath.startsWith("..") && !path.isAbsolute(relativePath) - ); -} - async function writeReadableStreamToLocalFile( stream: ReadableStream, - filePath: string -): Promise { + filePath: string, + maxBytes?: number +): Promise<{ truncated: boolean }> { assert(filePath.length > 0, "writeReadableStreamToLocalFile: filePath must be non-empty"); await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); const fileHandle = await fsPromises.open(filePath, "w"); + let bytesWritten = 0; try { const reader = stream.getReader(); try { @@ -68,7 +69,14 @@ async function writeReadableStreamToLocalFile( const { done, value } = await reader.read(); if (done) break; if (value) { + // Stop consuming once the cap is exceeded so an oversized stream + // never lands on local disk, even transiently. + if (maxBytes != null && bytesWritten + value.length > maxBytes) { + await reader.cancel(); + return { truncated: true }; + } await fileHandle.write(value); + bytesWritten += value.length; } } } finally { @@ -77,6 +85,431 @@ async function writeReadableStreamToLocalFile( } finally { await fileHandle.close(); } + return { truncated: false }; +} + +async function writeRuntimeCommandOutputToLocalFile(params: { + runtime: Runtime; + command: string; + cwd: string; + timeout: number; + filePath: string; + maxBytes?: number; +}): Promise<{ exitCode: number; stderr: string; truncated: boolean }> { + const stream = await params.runtime.exec(params.command, { + cwd: params.cwd, + timeout: params.timeout, + }); + await stream.stdin.close(); + + const stderrPromise = streamToString(stream.stderr); + const writePromise = writeReadableStreamToLocalFile( + stream.stdout, + params.filePath, + params.maxBytes + ); + const [exitCode, stderr, writeResult] = await Promise.all([ + stream.exitCode, + stderrPromise, + writePromise, + ]); + return { exitCode, stderr, truncated: writeResult.truncated }; +} + +type WorktreeCaptureFields = Pick< + SubagentGitProjectPatchArtifact, + | "hadUncommittedChanges" + | "worktreePatchPath" + | "worktreePatchBytes" + | "worktreePatchSkippedReason" +>; + +/** + * Best-effort list of submodule paths with uncommitted changes. Uncommitted + * work inside a submodule cannot be represented in a superproject patch (the + * temp-index capture stages only the unchanged gitlink), so callers report it + * as uncaptured instead of claiming the diff was empty. Returns null when a + * probe fails: the repository is already known dirty at that point, so an + * unknown submodule state must surface as possibly-uncaptured work rather + * than pass as "no dirty submodules". + */ +async function listDirtySubmodulePaths(params: { + runtime: ReturnType; + repoCwd: string; +}): Promise { + // -z keeps paths verbatim: on line-oriented output core.quotePath wraps + // exotic names (e.g. non-ASCII) in C-style quotes, and probing that quoted + // literal matches nothing, silently missing the dirty submodule. + const lsFilesResult = await execBuffered(params.runtime, "git ls-files -s -z", { + cwd: params.repoCwd, + timeout: 60, + }); + if (lsFilesResult.exitCode !== 0) { + log.debug("listDirtySubmodulePaths: ls-files probe failed", { + repoCwd: params.repoCwd, + stderr: lsFilesResult.stderr.trim(), + }); + return null; + } + // Records are ` \t`; gitlinks have mode 160000. + const submodulePaths = lsFilesResult.stdout + .split("\0") + .filter((record) => record.startsWith("160000 ")) + .map((record) => record.slice(record.indexOf("\t") + 1)); + if (submodulePaths.length === 0) { + return []; + } + const diffResult = await execBuffered( + params.runtime, + "git diff --name-only -z --ignore-submodules=none HEAD", + { cwd: params.repoCwd, timeout: 60 } + ); + if (diffResult.exitCode !== 0) { + log.debug("listDirtySubmodulePaths: diff probe failed", { + repoCwd: params.repoCwd, + stderr: diffResult.stderr.trim(), + }); + return null; + } + const changedPaths = new Set(diffResult.stdout.split("\0").filter((line) => line.length > 0)); + return submodulePaths.filter((subPath) => changedPaths.has(subPath)); +} + +/** + * Untracked embedded git repositories show up in status as `?? dir/` and + * would be staged by `git add -A` as a bare gitlink whose commit exists only + * in the child's checkout, so their contents must be reported as uncaptured + * and the gitlink kept out of the patch. Status is parsed from `-z` records + * because core.quotePath wraps exotic names (e.g. containing spaces) in + * quotes on the line-oriented output, which would hide them from the probe. + * Returns null when a probe fails, so callers warn about possibly-uncaptured + * embedded repositories instead of treating the failure as "none exist". + */ +async function listUntrackedEmbeddedRepoPaths(params: { + runtime: ReturnType; + repoCwd: string; +}): Promise { + const statusResult = await execBuffered( + params.runtime, + "git status --porcelain -z --untracked-files=all", + { cwd: params.repoCwd, timeout: 60 } + ); + if (statusResult.exitCode !== 0) { + log.debug("listUntrackedEmbeddedRepoPaths: status probe failed", { + repoCwd: params.repoCwd, + stderr: statusResult.stderr.trim(), + }); + return null; + } + // Even with --untracked-files=all, git will not descend into a directory + // that is itself a repository, so embedded repos surface as `?? dir/`. + const candidateDirs = parseGitStatusPorcelainZ(statusResult.stdout) + .filter((entry) => entry.status === "??" && entry.path.endsWith("/")) + .map((entry) => entry.path); + if (candidateDirs.length === 0) { + return []; + } + + const probeCommand = candidateDirs + .map( + (dir) => + `if [ -e ${shellQuote(`${dir}.git`)} ]; then printf '%s\\0' ${shellQuote(dir.replace(/\/+$/, ""))}; fi` + ) + .join("\n"); + const result = await execBuffered(params.runtime, probeCommand, { + cwd: params.repoCwd, + timeout: 60, + }); + if (result.exitCode !== 0) { + log.debug("listUntrackedEmbeddedRepoPaths: .git probe failed", { + repoCwd: params.repoCwd, + stderr: result.stderr.trim(), + }); + return null; + } + return result.stdout.split("\0").filter((line) => line.length > 0); +} + +/** + * Never rejects: a runtime/filesystem failure mid-capture must not bubble to + * the generate error path with empty capture fields, because cleanup would + * then remove a possibly-dirty child with no warning recorded. + */ +async function captureWorktreeDiff(params: { + runtime: ReturnType; + repoCwd: string; + localPatchPath: string; + maxBytes: number; + maxStagedBytes: number; +}): Promise { + try { + return await captureWorktreeDiffUnsafe(params); + } catch (error: unknown) { + log.warn("captureWorktreeDiff: capture failed", { + repoCwd: params.repoCwd, + error: getErrorMessage(error), + }); + // The exception may have fired after the patch file was created (e.g. a + // stream failure mid-write), and the apply path probes this canonical + // location even without metadata, so a leftover incomplete file would + // be applied as if it were a complete capture. Removal is best-effort: + // this recovery path must never reject. + await fsPromises.rm(params.localPatchPath, { force: true }).catch(() => undefined); + return { + hadUncommittedChanges: true, + worktreePatchSkippedReason: `Could not capture uncommitted changes (${getErrorMessage(error)}); any uncommitted work was NOT captured.`, + }; + } +} + +/** Uses a temporary index so capture never mutates the child's index or worktree. */ +async function captureWorktreeDiffUnsafe(params: { + runtime: ReturnType; + repoCwd: string; + localPatchPath: string; + maxBytes: number; + maxStagedBytes: number; +}): Promise { + const statusResult = await execBuffered( + params.runtime, + "git status --porcelain -z --untracked-files=all", + { cwd: params.repoCwd, timeout: 60 } + ); + if (statusResult.exitCode !== 0) { + log.warn("captureWorktreeDiff: git status failed; skipping worktree capture", { + repoCwd: params.repoCwd, + stderr: statusResult.stderr.trim(), + }); + // Unknown dirty state must not read as clean: cleanup would silently + // discard any uncommitted work, so warn the parent explicitly. + // Invariant: every exit that records no worktreePatchPath leaves no file + // at the canonical path. A stale patch from a capture that crashed + // before recording metadata would otherwise be found by apply-side + // canonical probing and re-apply outdated changes. + await fsPromises.rm(params.localPatchPath, { force: true }); + return { + hadUncommittedChanges: true, + worktreePatchSkippedReason: `Could not inspect the worktree for uncommitted changes (git status failed: ${statusResult.stderr.trim() || "unknown error"}); any uncommitted work was NOT captured.`, + }; + } + if (statusResult.stdout.trim().length === 0) { + // Same invariant: the work may have been committed since a crashed + // capture wrote the file, and the regenerated commit series already + // contains it. + await fsPromises.rm(params.localPatchPath, { force: true }); + return {}; + } + + const dirtySubmodulePaths = await listDirtySubmodulePaths({ + runtime: params.runtime, + repoCwd: params.repoCwd, + }); + const embeddedRepoPaths = await listUntrackedEmbeddedRepoPaths({ + runtime: params.runtime, + repoCwd: params.repoCwd, + }); + // Unknown gitlink discovery must skip capture entirely: with no exclusion + // list, the later `git add -A` would stage any moved submodule or embedded + // repo gitlink, emitting a patch that references a commit no target can + // fetch once child cleanup removes the only repository containing it. + if (dirtySubmodulePaths == null || embeddedRepoPaths == null) { + await fsPromises.rm(params.localPatchPath, { force: true }); + const probeFailureReasons = [ + dirtySubmodulePaths == null + ? "Could not determine whether submodules contain uncommitted changes (probe failed)." + : undefined, + embeddedRepoPaths == null + ? "Could not determine whether untracked embedded git repositories exist (probe failed)." + : undefined, + ].filter((reason): reason is string => reason != null); + return { + hadUncommittedChanges: true, + worktreePatchSkippedReason: [ + ...probeFailureReasons, + "The uncommitted changes were not captured.", + ].join(" "), + }; + } + const skipReasons = [ + dirtySubmodulePaths.length > 0 + ? `Uncommitted changes inside submodule(s) ${dirtySubmodulePaths.join(", ")} were NOT captured: submodule contents cannot be represented in a superproject patch.` + : undefined, + embeddedRepoPaths.length > 0 + ? `Untracked embedded git repository(ies) ${embeddedRepoPaths.join(", ")} were NOT captured: their contents cannot be represented in a superproject patch.` + : undefined, + ].filter((reason): reason is string => reason != null); + const submoduleSkipReason = skipReasons.length > 0 ? skipReasons.join(" ") : undefined; + + // `git add` writes whole blobs before the capped diff stream produces any + // output, so the diff byte cap alone does not bound disk usage: a + // multi-gigabyte dirty file would fill the temporary object dir (and /tmp) + // before truncation is detected. Preflight the on-disk size of every dirty + // path and skip capture when staging would exceed the bound. Excluded + // submodule/embedded-repo paths are directories and are not staged, so + // they do not count. A preflight exec failure throws into the outer + // captureWorktreeDiff handler, which reports a conservative + // uncaptured-work skip. + const excludedRoots = [...dirtySubmodulePaths, ...embeddedRepoPaths].map((repoPath) => + repoPath.replace(/\/+$/, "") + ); + const isExcludedPath = (filePath: string): boolean => { + const normalized = filePath.replace(/\/+$/, ""); + return excludedRoots.some((root) => normalized === root || normalized.startsWith(`${root}/`)); + }; + const candidatePaths = [ + ...new Set( + parseGitStatusPorcelainZ(statusResult.stdout) + .map((entry) => entry.path) + .filter((filePath) => !isExcludedPath(filePath)) + ), + ]; + let stagedBytes = 0; + let batch: string[] = []; + let batchLength = 0; + const runDuBatch = async (): Promise => { + if (batch.length === 0) { + return; + } + // du stats rather than reads, so the preflight itself is cheap. -s + // prints one line per argument (no subdirectory lines to double count). + const duResult = await execBuffered(params.runtime, `du -sk -- ${batch.join(" ")}`, { + cwd: params.repoCwd, + timeout: 60, + }); + let probeStdout = duResult.stdout; + if (duResult.exitCode !== 0) { + // Missing paths (deletions, races) are the only tolerated failure: + // rerun per path, skipping paths that no longer exist. Any du failure + // on a path that still exists (du unavailable, unreadable dirs) must + // throw into the outer captureWorktreeDiff handler, which reports an + // uncaptured-work skip; reading a failed probe as "small" would let a + // multi-gigabyte dirty file through to `git add`. + const fallbackResult = await execBuffered( + params.runtime, + `ex=0; for p in ${batch.join(" ")}; do if [ -e "$p" ] || [ -L "$p" ]; then du -sk -- "$p" || ex=1; fi; done; exit $ex`, + { cwd: params.repoCwd, timeout: 60 } + ); + if (fallbackResult.exitCode !== 0) { + throw new Error( + `staging-size preflight failed: ${ + fallbackResult.stderr.trim() || duResult.stderr.trim() || "du failed" + }` + ); + } + probeStdout = fallbackResult.stdout; + } + for (const line of probeStdout.split("\n")) { + const sizeMatch = /^(\d+)\s/.exec(line); + if (sizeMatch != null) { + stagedBytes += Number(sizeMatch[1]) * 1024; + } + } + batch = []; + batchLength = 0; + }; + for (const filePath of candidatePaths) { + const quoted = shellQuote(filePath); + if (batchLength + quoted.length + 1 > 60_000) { + await runDuBatch(); + if (stagedBytes > params.maxStagedBytes) { + break; + } + } + batch.push(quoted); + batchLength += quoted.length + 1; + } + if (stagedBytes <= params.maxStagedBytes) { + await runDuBatch(); + } + if (stagedBytes > params.maxStagedBytes) { + await fsPromises.rm(params.localPatchPath, { force: true }); + return { + hadUncommittedChanges: true, + worktreePatchSkippedReason: [ + `Uncommitted files total ${stagedBytes} bytes on disk, exceeding the ${params.maxStagedBytes}-byte staging bound; the uncommitted changes were not captured.`, + submoduleSkipReason, + ] + .filter((reason): reason is string => reason != null) + .join(" "), + }; + } + + // Excluding embedded repos keeps their gitlinks (which reference commits + // that exist only in the child's checkout) out of the patch. Dirty tracked + // submodules are excluded for the same reason: `git add -A` would stage + // their moved gitlink, and a submodule commit that exists only in the + // child's clone becomes unfetchable once cleanup removes the child, so the + // applied patch would point the target at an unavailable commit. `literal` + // magic keeps a name like `nested*repo` from glob-matching dirty sibling + // paths, which would silently drop them from the patch. + const gitlinkExcludes = [...dirtySubmodulePaths, ...embeddedRepoPaths] + .map((repoPath) => ` ${shellQuote(`:(exclude,literal)${repoPath}`)}`) + .join(""); + const diffCommand = [ + "set -e", + 'TMP_INDEX="$(mktemp)"', + // `git add` writes whole blobs before the diff stream (and its byte cap) + // produces any output, so staged objects go to a throwaway object dir: + // a huge dirty file must not permanently grow the repo's object store. + 'TMP_OBJECTS="$(mktemp -d)"', + 'trap \'rm -f "$TMP_INDEX"; rm -rf "$TMP_OBJECTS"\' EXIT', + 'REAL_OBJECTS="$(cd "$(git rev-parse --git-path objects)" && pwd)"', + 'export GIT_OBJECT_DIRECTORY="$TMP_OBJECTS"', + 'export GIT_ALTERNATE_OBJECT_DIRECTORIES="$REAL_OBJECTS"', + 'GIT_INDEX_FILE="$TMP_INDEX" git read-tree HEAD', + `GIT_INDEX_FILE="$TMP_INDEX" git add -A -- .${gitlinkExcludes}`, + // Explicit prefixes: user config like diff.noprefix=true would emit + // headers the later default `git apply` cannot consume. + 'GIT_INDEX_FILE="$TMP_INDEX" git diff --src-prefix=a/ --dst-prefix=b/ --cached --binary HEAD --', + ].join("\n"); + + const { exitCode, stderr, truncated } = await writeRuntimeCommandOutputToLocalFile({ + runtime: params.runtime, + command: diffCommand, + cwd: params.repoCwd, + timeout: 120, + filePath: params.localPatchPath, + maxBytes: params.maxBytes, + }); + + // Check truncation before the exit code: cancelling the stdout stream can + // make the diff command exit non-zero (EPIPE), and the size skip is the + // more accurate report in that case. + if (truncated) { + await fsPromises.rm(params.localPatchPath, { force: true }); + return { + hadUncommittedChanges: true, + worktreePatchSkippedReason: `Uncommitted worktree diff exceeds the ${params.maxBytes}-byte capture cap; the uncommitted changes were not captured.`, + }; + } + + if (exitCode !== 0) { + await fsPromises.rm(params.localPatchPath, { force: true }); + return { + hadUncommittedChanges: true, + worktreePatchSkippedReason: `git diff for uncommitted changes failed (exitCode=${exitCode}): ${stderr.trim() || "unknown error"}`, + }; + } + + const stat = await fsPromises.stat(params.localPatchPath); + if (stat.size === 0) { + await fsPromises.rm(params.localPatchPath, { force: true }); + return { + hadUncommittedChanges: true, + worktreePatchSkippedReason: + submoduleSkipReason ?? + "Worktree reported uncommitted changes but the diff against HEAD was empty.", + }; + } + + return { + hadUncommittedChanges: true, + worktreePatchPath: params.localPatchPath, + worktreePatchBytes: stat.size, + // Partial capture: the superproject patch exists, but dirty submodule + // contents are still lost; surface that alongside the patch. + ...(submoduleSkipReason != null ? { worktreePatchSkippedReason: submoduleSkipReason } : {}), + }; } function getPrimaryProjectName(projectPath: string, projects?: ProjectRef[]): string { @@ -307,44 +740,35 @@ function failPendingProjectArtifacts(params: { export class GitPatchArtifactService { private readonly pendingJobsByTaskId = new Map>(); - constructor(private readonly config: Config) {} + constructor( + private readonly config: Config, + private readonly worktreePatchMaxBytes: number = SUBAGENT_WORKTREE_PATCH_MAX_BYTES, + private readonly worktreePatchMaxStagedBytes: number = SUBAGENT_WORKTREE_PATCH_MAX_STAGED_BYTES + ) {} /** - * If the child workspace is an exec-like agent, write a pending patch artifact - * marker and kick off background `git format-patch` generation. - * - * @param onComplete - called after generation finishes (success *or* failure), - * typically used to trigger reported-leaf-task cleanup. + * Whether this child task is expected to produce a patch artifact. Only + * exec-like subagents are expected to make commits that should be handed + * back to the parent. NOTE: Custom agents can inherit from exec + * (base: exec). Those should also generate patches, but read-only + * subagents (e.g. explore) should not. Cleanup uses the same predicate: + * a patch-eligible task with NO artifact on disk means the pending-marker + * write failed, and deleting the workspace would destroy unrecorded work. */ - async maybeStartGeneration( + async shouldGeneratePatchForTask( parentWorkspaceId: string, - childWorkspaceId: string, - onComplete: OnPatchGenerationComplete - ): Promise { - assert( - parentWorkspaceId.length > 0, - "maybeStartGeneration: parentWorkspaceId must be non-empty" - ); - assert(childWorkspaceId.length > 0, "maybeStartGeneration: childWorkspaceId must be non-empty"); - - const parentSessionDir = this.config.getSessionDir(parentWorkspaceId); - - // Write a pending marker before we attempt cleanup, so the reported task workspace isn't deleted - // while we're still reading commits from it. - const nowMs = Date.now(); + childWorkspaceId: string + ): Promise { const cfg = this.config.loadConfigOrDefault(); const childEntry = findWorkspaceEntry(cfg, childWorkspaceId); - if (childEntry?.workspace.kind === "scratch") { - return; + if (!childEntry || childEntry.workspace.kind === "scratch") { + return false; } - // Only exec-like subagents are expected to make commits that should be handed back to the parent. - // NOTE: Custom agents can inherit from exec (base: exec). Those should also generate patches, - // but read-only subagents (e.g. explore) should not. - const childAgentIds = resolvePersistedAgentIdCandidates(childEntry?.workspace); + const childAgentIds = resolvePersistedAgentIdCandidates(childEntry.workspace); if (childAgentIds.length === 0) { - return; + return false; } const discoveryContexts = [ @@ -352,7 +776,6 @@ export class GitPatchArtifactService { createAgentDiscoveryContext(findWorkspaceEntry(cfg, parentWorkspaceId)), ].filter((context): context is WorkspaceRuntimeContext => context != null); - let shouldGeneratePatch = false; for (const childAgentId of childAgentIds) { const editingCapability = await resolveAgentEditingCapability({ discoveryContexts, @@ -362,11 +785,41 @@ export class GitPatchArtifactService { if (editingCapability == null) { continue; } - shouldGeneratePatch = editingCapability.editingCapable; - break; + return editingCapability.editingCapable; } + return false; + } + + /** + * If the child workspace is an exec-like agent, write a pending patch artifact + * marker and kick off background `git format-patch` generation. + * + * @param onComplete - called after generation finishes (success *or* failure), + * typically used to trigger reported-leaf-task cleanup. + */ + async maybeStartGeneration( + parentWorkspaceId: string, + childWorkspaceId: string, + onComplete: OnPatchGenerationComplete + ): Promise { + assert( + parentWorkspaceId.length > 0, + "maybeStartGeneration: parentWorkspaceId must be non-empty" + ); + assert(childWorkspaceId.length > 0, "maybeStartGeneration: childWorkspaceId must be non-empty"); - if (!shouldGeneratePatch || !childEntry) { + const parentSessionDir = this.config.getSessionDir(parentWorkspaceId); + + // Write a pending marker before we attempt cleanup, so the reported task workspace isn't deleted + // while we're still reading commits from it. + const nowMs = Date.now(); + const cfg = this.config.loadConfigOrDefault(); + const childEntry = findWorkspaceEntry(cfg, childWorkspaceId); + + if ( + !childEntry || + !(await this.shouldGeneratePatchForTask(parentWorkspaceId, childWorkspaceId)) + ) { return; } @@ -377,10 +830,18 @@ export class GitPatchArtifactService { taskBaseCommitShaByProjectPath: childEntry.workspace.taskBaseCommitShaByProjectPath, }); + // The pending marker is what defers cleanup while generation runs, and + // it is the index entry every later metadata write updates. If it never + // lands on disk, generation must not proceed: capture would write files + // no index entry references, and cleanup would read "no artifact" and + // delete the child workspace with its work unrecorded. Propagate the + // failure so callers log it and cleanup stays deferred (the eligibility + // gate in canCleanupReportedTask fails closed on a missing artifact). const artifact = await upsertSubagentGitPatchArtifact({ workspaceId: parentWorkspaceId, workspaceSessionDir: parentSessionDir, childTaskId: childWorkspaceId, + propagateWriteErrors: true, updater: (existing) => { if (existing && existing.status !== "pending") { return existing; @@ -652,7 +1113,27 @@ export class GitPatchArtifactService { }; for (const projectRepo of projectRepos) { + // Hoisted so failure branches below still surface dirty-worktree metadata. + let worktreeCapture: WorktreeCaptureFields = {}; try { + // Capture dirty work FIRST: commit-metadata resolution below can + // fail, and cleanup runs after generate() regardless, so a failed + // artifact must still record (and preserve) uncommitted changes. + const worktreePatchPath = getSubagentGitPatchWorktreePatchPath( + parentSessionDir, + childWorkspaceId, + projectRepo.storageKey + ); + worktreeCapture = isPathInsideDir(parentSessionDir, worktreePatchPath) + ? await captureWorktreeDiff({ + runtime, + repoCwd: projectRepo.repoCwd, + localPatchPath: worktreePatchPath, + maxBytes: this.worktreePatchMaxBytes, + maxStagedBytes: this.worktreePatchMaxStagedBytes, + }) + : {}; + let baseCommitSha = coerceNonEmptyString( taskBaseCommitShaByProjectPath[projectRepo.projectPath] ); @@ -669,6 +1150,7 @@ export class GitPatchArtifactService { status: "failed", error: "taskBaseCommitSha missing and could not determine trunk branch for merge-base fallback.", + ...worktreeCapture, }); continue; } @@ -685,6 +1167,7 @@ export class GitPatchArtifactService { storageKey: projectRepo.storageKey, status: "failed", error: `git merge-base failed: ${mergeBaseResult.stderr.trim() || "unknown error"}`, + ...worktreeCapture, }); continue; } @@ -701,6 +1184,7 @@ export class GitPatchArtifactService { status: "failed", baseCommitSha, error: "git rev-parse HEAD failed.", + ...worktreeCapture, }); continue; } @@ -719,6 +1203,7 @@ export class GitPatchArtifactService { baseCommitSha, headCommitSha, error: `git rev-list failed: ${countResult.stderr.trim() || "unknown error"}`, + ...worktreeCapture, }); continue; } @@ -733,6 +1218,7 @@ export class GitPatchArtifactService { baseCommitSha, headCommitSha, error: `Invalid commit count: ${countResult.stdout.trim()}`, + ...worktreeCapture, }); continue; } @@ -742,10 +1228,11 @@ export class GitPatchArtifactService { projectPath: projectRepo.projectPath, projectName: projectRepo.projectName, storageKey: projectRepo.storageKey, - status: "skipped", + status: worktreeCapture.worktreePatchPath != null ? "ready" : "skipped", baseCommitSha, headCommitSha, commitCount, + ...worktreeCapture, }); continue; } @@ -766,24 +1253,18 @@ export class GitPatchArtifactService { headCommitSha, commitCount, error: `Refusing to write patch outside session dir for storage key ${projectRepo.storageKey}.`, + ...worktreeCapture, }); continue; } - const formatPatchStream = await runtime.exec( - `git format-patch --stdout --binary ${baseCommitSha}..${headCommitSha}`, - { cwd: projectRepo.repoCwd, timeout: 120 } - ); - await formatPatchStream.stdin.close(); - - const stderrPromise = streamToString(formatPatchStream.stderr); - const writePromise = writeReadableStreamToLocalFile(formatPatchStream.stdout, patchPath); - - const [exitCode, stderr] = await Promise.all([ - formatPatchStream.exitCode, - stderrPromise, - writePromise, - ]); + const { exitCode, stderr } = await writeRuntimeCommandOutputToLocalFile({ + runtime, + command: `git format-patch --stdout --binary ${baseCommitSha}..${headCommitSha}`, + cwd: projectRepo.repoCwd, + timeout: 120, + filePath: patchPath, + }); if (exitCode !== 0) { await fsPromises.rm(patchPath, { force: true }); @@ -796,6 +1277,7 @@ export class GitPatchArtifactService { headCommitSha, commitCount, error: `git format-patch failed (exitCode=${exitCode}): ${stderr.trim() || "unknown error"}`, + ...worktreeCapture, }); continue; } @@ -809,6 +1291,7 @@ export class GitPatchArtifactService { headCommitSha, commitCount, mboxPath: patchPath, + ...worktreeCapture, }); } catch (error: unknown) { await ensureProjectArtifact({ @@ -817,6 +1300,7 @@ export class GitPatchArtifactService { storageKey: projectRepo.storageKey, status: "failed", error: getErrorMessage(error), + ...worktreeCapture, }); } } diff --git a/src/node/services/gitPatchPathParsing.ts b/src/node/services/gitPatchPathParsing.ts new file mode 100644 index 0000000000..e95fdefa24 --- /dev/null +++ b/src/node/services/gitPatchPathParsing.ts @@ -0,0 +1,202 @@ +/** + * Parsers for file paths in git patch text. Git quotes paths containing + * "unusual" bytes (C-style, octal escapes) but leaves ordinary spaces + * unquoted, so naive whitespace splitting misreads `diff --git` headers. + * Shared by the workflow allowlist validation and the apply-tool preflight; + * both must over-approximate (extra candidate paths are safe, missed paths + * are not). + */ + +export interface GitStatusPorcelainEntry { + path: string; + status: string; +} + +/** + * Parses `git status --porcelain -z` records. NUL termination keeps exotic + * paths unquoted; rename/copy records carry the source path as a second + * NUL-separated field. + */ +export function parseGitStatusPorcelainZ(stdout: string): GitStatusPorcelainEntry[] { + const entriesByPath: GitStatusPorcelainEntry[] = []; + const entries = stdout.split("\0"); + for (let i = 0; i < entries.length; i += 1) { + const entry = entries[i]; + if (entry.length < 4) continue; + + const status = entry.slice(0, 2); + const filePath = entry.slice(3); + if (filePath.length > 0) { + entriesByPath.push({ path: filePath, status }); + } + + if (status.includes("R") || status.includes("C")) { + i += 1; + const sourcePath = entries[i]; + if (sourcePath != null && sourcePath.length > 0) { + entriesByPath.push({ path: sourcePath, status }); + } + } + } + return entriesByPath; +} + +export function parseDiffGitHeaderPaths(stdout: string): string[] { + const paths = new Set(); + for (const line of stdout.split(/\r?\n/)) { + if (!line.startsWith("diff --git ")) continue; + for (const filePath of parseDiffGitHeaderLine(line.slice("diff --git ".length))) { + paths.add(filePath); + } + } + return [...paths].filter((filePath) => filePath.length > 0); +} + +/** + * Parses `a/ b/` with `diff --git ` already removed. Unquoted paths + * may contain spaces, making the split point ambiguous; every candidate split + * is returned so callers over-approximate rather than miss a path. + */ +export function parseDiffGitHeaderLine(line: string): string[] { + if (line.startsWith('"')) { + const first = parseGitQuotedPath(line, 0); + if (first == null) return []; + let secondStartOffset = first.nextOffset; + while (line[secondStartOffset] === " ") { + secondStartOffset += 1; + } + // Git quotes each side independently, so a quoted source can pair with an + // unquoted destination (which may itself contain spaces). + const rest = line.slice(secondStartOffset); + const second = rest.startsWith('"') ? parseGitQuotedPath(line, secondStartOffset)?.path : rest; + return [stripDiffPathPrefix(first.path), stripDiffPathPrefix(second)].filter( + (filePath): filePath is string => filePath != null && filePath.length > 0 + ); + } + + if (!line.startsWith("a/")) { + return []; + } + + const paths = new Set(); + let separatorIndex = line.indexOf(" b/", "a/".length); + while (separatorIndex !== -1) { + paths.add(line.slice("a/".length, separatorIndex)); + paths.add(line.slice(separatorIndex + " b/".length)); + separatorIndex = line.indexOf(" b/", separatorIndex + 1); + } + // Git quotes each side independently, so an unquoted source can pair with + // a quoted destination (e.g. a rename onto a name needing C-quoting). + // Every candidate quote start is tried, over-approximating like the + // separator loop above. + let quoteIndex = line.indexOf(' "', "a/".length); + while (quoteIndex !== -1) { + const second = parseGitQuotedPath(line, quoteIndex + 1); + if (second != null) { + const source = line.slice("a/".length, quoteIndex); + const destination = stripDiffPathPrefix(second.path); + if (source.length > 0) { + paths.add(source); + } + if (destination != null && destination.length > 0) { + paths.add(destination); + } + } + quoteIndex = line.indexOf(' "', quoteIndex + 1); + } + return [...paths]; +} + +export function stripDiffPathPrefix(filePath: string | undefined): string | undefined { + if (filePath == null) return undefined; + return filePath.startsWith("a/") || filePath.startsWith("b/") ? filePath.slice(2) : filePath; +} + +export function parsePatchMetadataPath(value: string): string { + if (!value.startsWith('"')) { + return value; + } + return parseGitQuotedPath(value, 0)?.path ?? ""; +} + +export function parseGitQuotedPath( + value: string, + startOffset: number +): { path: string; nextOffset: number } | undefined { + if (value[startOffset] !== '"') { + return undefined; + } + + const bytes: number[] = []; + const encoder = new TextEncoder(); + let offset = startOffset + 1; + while (offset < value.length) { + const char = value[offset]; + if (char === '"') { + return { path: new TextDecoder().decode(Uint8Array.from(bytes)), nextOffset: offset + 1 }; + } + + if (char !== "\\") { + const codePoint = value.codePointAt(offset); + if (codePoint == null) { + return undefined; + } + const codePointString = String.fromCodePoint(codePoint); + bytes.push(...encoder.encode(codePointString)); + offset += codePointString.length; + continue; + } + + offset += 1; + if (offset >= value.length) { + return undefined; + } + + const escaped = value[offset]; + if (/[0-7]/.test(escaped)) { + let octal = escaped; + offset += 1; + while (offset < value.length && octal.length < 3 && /[0-7]/.test(value[offset])) { + octal += value[offset]; + offset += 1; + } + bytes.push(Number.parseInt(octal, 8)); + continue; + } + + const escapedByte = decodeGitQuotedEscapedByte(escaped); + if (escapedByte == null) { + bytes.push(...encoder.encode(escaped)); + } else { + bytes.push(escapedByte); + } + offset += 1; + } + + return undefined; +} + +function decodeGitQuotedEscapedByte(char: string): number | undefined { + switch (char) { + case "a": + return 0x07; + case "b": + return 0x08; + case "t": + return 0x09; + case "n": + return 0x0a; + case "v": + return 0x0b; + case "f": + return 0x0c; + case "r": + return 0x0d; + case '"': + return 0x22; + case "\\": + return 0x5c; + default: + return undefined; + } +} diff --git a/src/node/services/subagentGitPatchArtifacts.test.ts b/src/node/services/subagentGitPatchArtifacts.test.ts index d5e256ed6b..53445adec7 100644 --- a/src/node/services/subagentGitPatchArtifacts.test.ts +++ b/src/node/services/subagentGitPatchArtifacts.test.ts @@ -7,6 +7,7 @@ import { getSubagentGitPatchArtifactsFilePath, getSubagentGitPatchMboxPath, markSubagentGitPatchArtifactApplied, + readLocalPatchPartialApply, readSubagentGitPatchArtifactsFile, upsertSubagentGitPatchArtifact, } from "@/node/services/subagentGitPatchArtifacts"; @@ -28,6 +29,201 @@ describe("subagentGitPatchArtifacts", () => { expect(file.artifactsByChildTaskId).toEqual({}); }); + test("readLocalPatchPartialApply fails closed on malformed state JSON", async () => { + // A truncated write (or corruption) may hide a recorded partial + // application; returning empty state would let a retry replay commits. + await fsPromises.writeFile( + path.join(testDir, "subagent-patches-local-apply.json"), + '{"version":1,"partialsByChildTaskId":{', + "utf-8" + ); + + let thrownMessage = ""; + try { + await readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_x", + projectPath: "/repo", + }); + } catch (error) { + thrownMessage = error instanceof Error ? error.message : String(error); + } + expect(thrownMessage).toContain("Could not read local patch apply state"); + }); + + test("readLocalPatchPartialApply degrades a corrupted record to a conservative marker", async () => { + // The entry's presence is the evidence of partial application (clears + // delete the key); dropping it for a corrupt appliedAtMs would let the + // retry rerun the already-landed commit series through git am. + await fsPromises.writeFile( + path.join(testDir, "subagent-patches-local-apply.json"), + JSON.stringify({ + version: 1, + partialsByChildTaskId: { + task_x: { "/repo": { appliedAtMs: "corrupt", headCommitSha: 42 } }, + task_y: { "/repo": { appliedAtMs: 1234 } }, + }, + }), + "utf-8" + ); + + const record = await readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_x", + projectPath: "/repo", + }); + expect(record).not.toBeNull(); + // The corrupt fence SHA is discarded; fence-less completion is supported. + expect(record?.headCommitSha).toBeUndefined(); + // Valid sibling records survive untouched. + expect( + await readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_y", + projectPath: "/repo", + }) + ).toEqual({ appliedAtMs: 1234 }); + }); + + test("fails closed on present-but-malformed containers", async () => { + // A malformed container (null, array, or primitive) may hide recorded + // partials or completions for any project; treating it as empty would + // let a retry replay an already-applied commit series. + const stateFilePath = path.join(testDir, "subagent-patches-local-apply.json"); + const readPartial = () => + readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_x", + projectPath: "/repo", + }); + const expectThrows = async () => { + let thrownMessage = ""; + try { + await readPartial(); + } catch (error) { + thrownMessage = error instanceof Error ? error.message : String(error); + } + expect(thrownMessage).toContain("is malformed"); + }; + + for (const corrupted of [ + { version: 1, partialsByChildTaskId: "corrupt" }, + { version: 1, partialsByChildTaskId: { task_x: null } }, + { version: 1, partialsByChildTaskId: { task_x: [1, 2] } }, + { version: 1, partialsByChildTaskId: {}, completionsByChildTaskId: null }, + { version: 1, partialsByChildTaskId: {}, completionsByChildTaskId: { task_x: null } }, + ]) { + await fsPromises.writeFile(stateFilePath, JSON.stringify(corrupted), "utf-8"); + await expectThrows(); + } + + // Absent containers stay valid (legacy files predate completions). + await fsPromises.writeFile(stateFilePath, JSON.stringify({ version: 1 }), "utf-8"); + expect(await readPartial()).toBeNull(); + }); + + test("degrades structurally empty partial records to unknown stage", async () => { + // {} and [] prove nothing about the commit series; the legacy + // absent-stage default (commits-applied) is reserved for records with a + // valid appliedAtMs, or an interrupted am-started apply would skip git am. + await fsPromises.writeFile( + path.join(testDir, "subagent-patches-local-apply.json"), + JSON.stringify({ + version: 1, + partialsByChildTaskId: { + task_x: { "/repo": {}, "/repo2": [] }, + task_y: { "/repo": { appliedAtMs: 1234 } }, + }, + }), + "utf-8" + ); + + for (const projectPath of ["/repo", "/repo2"]) { + expect( + await readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_x", + projectPath, + }) + ).toEqual({ appliedAtMs: 0, stage: "unknown" }); + } + // A valid legacy record keeps the absent-stage default. + expect( + await readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_y", + projectPath: "/repo", + }) + ).toEqual({ appliedAtMs: 1234 }); + }); + + test("degrades out-of-range partial timestamps to unknown stage", async () => { + // -1 and 1.5 are corruption, not evidence of an applied series: reading + // them as valid would give the record the legacy absent-stage + // commits-applied default and skip git am on retry. + await fsPromises.writeFile( + path.join(testDir, "subagent-patches-local-apply.json"), + JSON.stringify({ + version: 1, + partialsByChildTaskId: { + task_x: { "/repo": { appliedAtMs: -1 }, "/repo2": { appliedAtMs: 1.5 } }, + }, + }), + "utf-8" + ); + + for (const projectPath of ["/repo", "/repo2"]) { + expect( + await readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_x", + projectPath, + }) + ).toEqual({ appliedAtMs: 0, stage: "unknown" }); + } + }); + + test("degrades a fully malformed partial record to an unknown-stage marker", async () => { + // The record's contents are unreadable, so its stage cannot default to + // the legacy commits-applied: that would skip git am for what may be an + // interrupted am-started apply. + await fsPromises.writeFile( + path.join(testDir, "subagent-patches-local-apply.json"), + JSON.stringify({ + version: 1, + partialsByChildTaskId: { task_x: { "/repo": "corrupt" } }, + }), + "utf-8" + ); + + expect( + await readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_x", + projectPath: "/repo", + }) + ).toEqual({ appliedAtMs: 0, stage: "unknown" }); + }); + + test("readLocalPatchPartialApply fails closed when the state file is unreadable", async () => { + // A directory at the state file path forces a non-ENOENT read error + // (EISDIR). Returning an empty state here would let a retry replay an + // already-applied commit series. + await fsPromises.mkdir(path.join(testDir, "subagent-patches-local-apply.json")); + + let thrownMessage = ""; + try { + await readLocalPatchPartialApply({ + workspaceSessionDir: testDir, + childTaskId: "task_x", + projectPath: "/repo", + }); + } catch (error) { + thrownMessage = error instanceof Error ? error.message : String(error); + } + expect(thrownMessage).toContain("Could not read local patch apply state"); + }); + test("upsertSubagentGitPatchArtifact writes normalized task-scoped artifacts", async () => { const workspaceId = "parent-1"; const childTaskId = "child-1"; @@ -224,6 +420,390 @@ describe("subagentGitPatchArtifacts", () => { expect(file.artifactsByChildTaskId.broken).toBeUndefined(); }); + test("sanitizes corrupted dirty-capture and partial-application fields on read", async () => { + const childTaskId = "child-corrupt-fields"; + const artifactsPath = getSubagentGitPatchArtifactsFilePath(testDir); + await fsPromises.writeFile( + artifactsPath, + JSON.stringify( + { + version: 2, + artifactsByChildTaskId: { + [childTaskId]: { + childTaskId, + parentWorkspaceId: "parent-1", + createdAtMs: 123, + status: "ready", + projectArtifacts: [ + { + projectPath: "/tmp/project-a", + projectName: "project-a", + storageKey: "project-a", + status: "ready", + commitCount: 1, + appliedAtMs: 456, + // Corruption: the fence must be a SHA string (it is shell + // quoted on retry), appliedPartial a boolean. + appliedPartial: 1, + appliedPartialHeadSha: 12345, + hadUncommittedChanges: "yes", + worktreePatchPath: 42, + worktreePatchBytes: "big", + worktreePatchSkippedReason: { reason: "x" }, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }, + null, + 2 + ), + "utf-8" + ); + + const file = await readSubagentGitPatchArtifactsFile(testDir); + const projectArtifact = file.artifactsByChildTaskId[childTaskId]?.projectArtifacts[0]; + expect(projectArtifact).toBeDefined(); + // Truthy corruption keeps the fail-closed partial marker; the unusable + // fence and capture metadata are dropped rather than bricking retries. + expect(projectArtifact?.appliedPartial).toBe(true); + expect(projectArtifact?.appliedPartialHeadSha).toBeUndefined(); + expect(projectArtifact?.hadUncommittedChanges).toBe(true); + expect(projectArtifact?.worktreePatchPath).toBeUndefined(); + expect(projectArtifact?.worktreePatchBytes).toBeUndefined(); + expect(projectArtifact?.worktreePatchSkippedReason).toBeUndefined(); + expect(projectArtifact?.appliedAtMs).toBe(456); + }); + + test("drops one side of an aliased mboxPath/worktreePatchPath pair", async () => { + const childTaskId = "child-aliased-paths"; + const artifactsPath = getSubagentGitPatchArtifactsFilePath(testDir); + const aliasedPath = path.join(testDir, "subagent-patches", childTaskId, "repo", "some.patch"); + const projectArtifact = (name: string, commitCount: number) => ({ + projectPath: `/tmp/${name}`, + projectName: name, + storageKey: name, + status: "ready", + commitCount, + mboxPath: aliasedPath, + worktreePatchPath: aliasedPath, + }); + await fsPromises.writeFile( + artifactsPath, + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + [childTaskId]: { + childTaskId, + parentWorkspaceId: "parent-1", + createdAtMs: 123, + status: "ready", + projectArtifacts: [ + projectArtifact("with-commits", 1), + projectArtifact("commit-free", 0), + ], + readyProjectCount: 2, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }), + "utf-8" + ); + + const file = await readSubagentGitPatchArtifactsFile(testDir); + const [withCommits, commitFree] = + file.artifactsByChildTaskId[childTaskId]?.projectArtifacts ?? []; + // One file cannot be both kinds; the surviving field matches the + // artifact's shape so the bytes are consumed exactly once. + expect(withCommits?.mboxPath).toBe(aliasedPath); + expect(withCommits?.worktreePatchPath).toBeUndefined(); + expect(commitFree?.mboxPath).toBeUndefined(); + expect(commitFree?.worktreePatchPath).toBe(aliasedPath); + }); + + test("keeps a falsey-corrupt appliedPartial as a partial marker", async () => { + const childTaskId = "child-falsey-partial"; + const artifactsPath = getSubagentGitPatchArtifactsFilePath(testDir); + const projectArtifact = (name: string, appliedPartial: unknown) => ({ + projectPath: `/tmp/${name}`, + projectName: name, + storageKey: name, + status: "ready", + commitCount: 1, + appliedAtMs: 456, + appliedPartial, + appliedPartialStage: "am-started", + }); + await fsPromises.writeFile( + artifactsPath, + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + [childTaskId]: { + childTaskId, + parentWorkspaceId: "parent-1", + createdAtMs: 123, + status: "ready", + projectArtifacts: [ + projectArtifact("project-a", 0), + projectArtifact("project-b", null), + projectArtifact("project-c", ""), + ], + readyProjectCount: 3, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 3, + }, + }, + }), + "utf-8" + ); + + const file = await readSubagentGitPatchArtifactsFile(testDir); + // A corrupt value cannot prove the apply completed: coercing by + // truthiness would erase the marker and let an already-applied check + // skip the pending recovery. The independently valid stage survives. + for (const artifact of file.artifactsByChildTaskId[childTaskId]?.projectArtifacts ?? []) { + expect(artifact.appliedPartial).toBe(true); + expect(artifact.appliedPartialStage).toBe("am-started"); + expect(artifact.appliedAtMs).toBe(456); + } + }); + + test("keeps falsey-corrupt hadUncommittedChanges as dirty", async () => { + const childTaskId = "child-falsey-dirty"; + const artifactsPath = getSubagentGitPatchArtifactsFilePath(testDir); + const projectArtifact = (name: string, hadUncommittedChanges: unknown) => ({ + projectPath: `/tmp/${name}`, + projectName: name, + storageKey: name, + status: "ready", + commitCount: 1, + hadUncommittedChanges, + worktreePatchSkippedReason: "diff exceeded the capture cap", + }); + await fsPromises.writeFile( + artifactsPath, + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + [childTaskId]: { + childTaskId, + parentWorkspaceId: "parent-1", + createdAtMs: 123, + status: "ready", + projectArtifacts: [ + projectArtifact("project-a", 0), + projectArtifact("project-b", null), + projectArtifact("project-c", ""), + ], + readyProjectCount: 3, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 3, + }, + }, + }), + "utf-8" + ); + + const file = await readSubagentGitPatchArtifactsFile(testDir); + // Coercing falsey corruption to false would erase the dirty-work + // evidence the apply gate and cleanup deferral key on, while the skip + // reason still records uncaptured work. + for (const artifact of file.artifactsByChildTaskId[childTaskId]?.projectArtifacts ?? []) { + expect(artifact.hadUncommittedChanges).toBe(true); + expect(artifact.worktreePatchSkippedReason).toBe("diff exceeded the capture cap"); + } + }); + + test("degrades a zero appliedAtMs to an unknown partial marker", async () => { + const childTaskId = "child-zero-applied"; + const artifactsPath = getSubagentGitPatchArtifactsFilePath(testDir); + await fsPromises.writeFile( + artifactsPath, + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + [childTaskId]: { + childTaskId, + parentWorkspaceId: "parent-1", + createdAtMs: 123, + status: "ready", + projectArtifacts: [ + { + projectPath: "/tmp/project-a", + projectName: "project-a", + storageKey: "project-a", + status: "ready", + commitCount: 1, + appliedAtMs: 0, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }), + "utf-8" + ); + + const file = await readSubagentGitPatchArtifactsFile(testDir); + const projectArtifact = file.artifactsByChildTaskId[childTaskId]?.projectArtifacts[0]; + // Consumers test the timestamp by truthiness, so an accepted 0 would + // neither prove application nor fail closed, and a retry could replay + // the commit series. + expect(projectArtifact?.appliedAtMs).toBeUndefined(); + expect(projectArtifact?.appliedPartial).toBe(true); + expect(projectArtifact?.appliedPartialStage).toBe("unknown"); + }); + + test("drops finite numeric fields that violate the schema's integer/nonnegative bounds", async () => { + const childTaskId = "child-numeric-corruption"; + const artifactsPath = getSubagentGitPatchArtifactsFilePath(testDir); + await fsPromises.writeFile( + artifactsPath, + JSON.stringify( + { + version: 2, + artifactsByChildTaskId: { + [childTaskId]: { + childTaskId, + parentWorkspaceId: "parent-1", + createdAtMs: 123, + status: "ready", + projectArtifacts: [ + { + projectPath: "/tmp/project-a", + projectName: "project-a", + storageKey: "project-a", + status: "ready", + // Finite but out of schema bounds: strict result validation + // would reject the whole artifact on task_await retrieval. + worktreePatchBytes: -1, + appliedAtMs: 1.5, + }, + { + projectPath: "/tmp/project-b", + projectName: "project-b", + storageKey: "project-b", + status: "ready", + worktreePatchBytes: 1.5, + appliedAtMs: "yesterday", + }, + ], + readyProjectCount: 2, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }, + null, + 2 + ), + "utf-8" + ); + + const file = await readSubagentGitPatchArtifactsFile(testDir); + const [first, second] = file.artifactsByChildTaskId[childTaskId]?.projectArtifacts ?? []; + expect(first?.worktreePatchBytes).toBeUndefined(); + expect(first?.appliedAtMs).toBeUndefined(); + expect(second?.worktreePatchBytes).toBeUndefined(); + expect(second?.appliedAtMs).toBeUndefined(); + // A present appliedAtMs was the only record that an application + // happened: dropping the corrupt timestamp alone would let a retry + // replay the already-landed series, so it degrades to partial/unknown. + for (const artifact of [first, second]) { + expect(artifact?.appliedPartial).toBe(true); + expect(artifact?.appliedPartialStage).toBe("unknown"); + } + }); + + test("readSubagentGitPatchArtifactsFile with propagateReadErrors throws on corruption, not on ENOENT", async () => { + const missing = await readSubagentGitPatchArtifactsFile(testDir, { + propagateReadErrors: true, + }); + expect(missing.artifactsByChildTaskId).toEqual({}); + + await fsPromises.writeFile( + getSubagentGitPatchArtifactsFilePath(testDir), + '{"version":2,"artifactsByChildTaskId":{', + "utf-8" + ); + let readError: unknown; + try { + await readSubagentGitPatchArtifactsFile(testDir, { propagateReadErrors: true }); + } catch (error) { + readError = error; + } + expect(readError).toBeDefined(); + // The default read stays self-healing for non-cleanup callers. + const lenient = await readSubagentGitPatchArtifactsFile(testDir); + expect(lenient.artifactsByChildTaskId).toEqual({}); + }); + + test("readSubagentGitPatchArtifactsFile with propagateReadErrors throws on a malformed entry", async () => { + // Entry-level normalization failures are dropped by the default read; + // during cleanup a dropped entry reads as absent and its patch files + // get deleted, so propagation must surface them too. + await fsPromises.writeFile( + getSubagentGitPatchArtifactsFilePath(testDir), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_ok: { + childTaskId: "task_ok", + parentWorkspaceId: "parent-1", + createdAtMs: 123, + status: "ready", + projectArtifacts: [ + { + projectPath: "/tmp/project-a", + projectName: "project-a", + storageKey: "project-a", + status: "ready", + commitCount: 1, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + task_bad: { + childTaskId: "task_bad", + parentWorkspaceId: "parent-1", + createdAtMs: 123, + status: "ready", + projectArtifacts: "corrupt", + }, + }, + }), + "utf-8" + ); + + let readError: unknown; + try { + await readSubagentGitPatchArtifactsFile(testDir, { propagateReadErrors: true }); + } catch (error) { + readError = error; + } + expect(readError).toBeDefined(); + expect(String(readError)).toContain("task_bad"); + // The default read still skips the bad entry and keeps the good one. + const lenient = await readSubagentGitPatchArtifactsFile(testDir); + expect(Object.keys(lenient.artifactsByChildTaskId)).toEqual(["task_ok"]); + }); + test("normalizes version 1 artifacts into one-project patch sets", async () => { const childTaskId = "child-1"; const artifactsPath = getSubagentGitPatchArtifactsFilePath(testDir); diff --git a/src/node/services/subagentGitPatchArtifacts.ts b/src/node/services/subagentGitPatchArtifacts.ts index 4e5e0d1bc8..fc676b683b 100644 --- a/src/node/services/subagentGitPatchArtifacts.ts +++ b/src/node/services/subagentGitPatchArtifacts.ts @@ -7,6 +7,7 @@ import type { SubagentGitPatchArtifact, SubagentGitProjectPatchArtifact, } from "@/common/utils/tools/toolDefinitions"; +import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; @@ -33,7 +34,8 @@ const SUBAGENT_GIT_PATCH_ARTIFACTS_FILE_VERSION = 2 as const; const SUBAGENT_GIT_PATCH_ARTIFACTS_FILE_NAME = "subagent-patches.json"; const SUBAGENT_GIT_PATCH_DIR_NAME = "subagent-patches"; -const SUBAGENT_GIT_PATCH_MBOX_FILE_NAME = "series.mbox"; +export const SUBAGENT_GIT_PATCH_MBOX_FILE_NAME = "series.mbox"; +export const SUBAGENT_GIT_PATCH_WORKTREE_FILE_NAME = "worktree.patch"; const LEGACY_SINGLE_PROJECT_NAME = "project"; const LEGACY_SINGLE_PROJECT_PATH = ""; const LEGACY_SINGLE_PROJECT_STORAGE_KEY = "legacy-single-project"; @@ -155,6 +157,130 @@ function summarizeProjectArtifacts( }; } +/** + * The dirty-capture and partial-application fields flow into shell commands + * and file reads on retry paths, so a corrupted persisted value (e.g. a + * number where the fence SHA belongs) would otherwise throw on every retry + * until the file is edited by hand. Coerce or drop invalid values instead. + * Corrupt application evidence must fail closed: a corrupt appliedPartial + * stays a partial marker (coercing falsey corruption to false would erase + * it and let an already-applied check skip the pending worktree patch), and + * a corrupt appliedAtMs degrades to a partial/unknown marker (dropping the + * only record that an application happened would let a retry replay the + * already-landed commit series). A dropped corrupt fence falls back to + * fence-less completion, a state the schema already allows because + * recording the fence is best-effort. + */ +function sanitizeDirtyCaptureFields( + artifact: SubagentGitProjectPatchArtifact +): SubagentGitProjectPatchArtifact { + const sanitized = { ...artifact }; + if ( + sanitized.hadUncommittedChanges !== undefined && + typeof sanitized.hadUncommittedChanges !== "boolean" + ) { + // Corrupt dirty-work evidence degrades to dirty, never clean: falsey + // corruption (null, 0, "") coerced to false would let the apply gate + // and cleanup treat explicitly-recorded uncaptured work as absent. + sanitized.hadUncommittedChanges = true; + } + if ( + sanitized.worktreePatchPath !== undefined && + (typeof sanitized.worktreePatchPath !== "string" || sanitized.worktreePatchPath.length === 0) + ) { + delete sanitized.worktreePatchPath; + } + // One file cannot be both patch kinds: an aliased pair would be consumed + // twice on apply (git am, then again as a raw worktree diff) and bypasses + // the roll-up's basename-collision renaming. Keep the kind matching the + // artifact's shape: the mbox for a commit-bearing artifact, the worktree + // patch for a commit-free one. + if ( + typeof sanitized.mboxPath === "string" && + sanitized.mboxPath.length > 0 && + typeof sanitized.worktreePatchPath === "string" && + sanitized.worktreePatchPath.length > 0 && + path.resolve(sanitized.mboxPath) === path.resolve(sanitized.worktreePatchPath) + ) { + if (sanitized.commitCount !== 0) { + delete sanitized.worktreePatchPath; + } else { + delete sanitized.mboxPath; + } + } + // Match the schema's integer/nonnegative constraints: a corrupt -1 or 1.5 + // would otherwise fail result validation on every task_await retrieval. + if ( + sanitized.worktreePatchBytes !== undefined && + !(Number.isInteger(sanitized.worktreePatchBytes) && sanitized.worktreePatchBytes >= 0) + ) { + delete sanitized.worktreePatchBytes; + } + if ( + sanitized.worktreePatchSkippedReason !== undefined && + typeof sanitized.worktreePatchSkippedReason !== "string" + ) { + delete sanitized.worktreePatchSkippedReason; + } + if (sanitized.appliedPartial !== undefined && typeof sanitized.appliedPartial !== "boolean") { + sanitized.appliedPartial = true; + } + // Positive integer, matching completion records: consumers test the + // timestamp by truthiness, so an accepted 0 would neither prove + // application nor leave a fail-closed marker, and a retry could replay + // the commit series. + if ( + sanitized.appliedAtMs !== undefined && + !(Number.isInteger(sanitized.appliedAtMs) && sanitized.appliedAtMs > 0) + ) { + delete sanitized.appliedAtMs; + if (sanitized.appliedPartial !== true) { + sanitized.appliedPartial = true; + sanitized.appliedPartialStage = "unknown"; + } + } + if ( + sanitized.appliedPartialHeadSha !== undefined && + (typeof sanitized.appliedPartialHeadSha !== "string" || + sanitized.appliedPartialHeadSha.trim().length === 0) + ) { + delete sanitized.appliedPartialHeadSha; + } + // Same policy as a corrupt appliedAtMs: dropping just the field would + // silently change what replay-safe validation checks, so the record + // degrades to a fail-closed partial/unknown marker instead. + if ( + sanitized.appliedHeadSha !== undefined && + (typeof sanitized.appliedHeadSha !== "string" || sanitized.appliedHeadSha.trim().length === 0) + ) { + delete sanitized.appliedHeadSha; + if (sanitized.appliedPartial !== true) { + sanitized.appliedPartial = true; + sanitized.appliedPartialStage = "unknown"; + } + } + if (sanitized.appliedAcknowledged !== undefined && sanitized.appliedAcknowledged !== true) { + delete sanitized.appliedAcknowledged; + if (sanitized.appliedPartial !== true) { + sanitized.appliedPartial = true; + sanitized.appliedPartialStage = "unknown"; + } + } + if ( + sanitized.appliedPartialStage !== undefined && + sanitized.appliedPartialStage !== "am-started" && + sanitized.appliedPartialStage !== "commits-applied" && + sanitized.appliedPartialStage !== "unknown" + ) { + // Absent means legacy "commits-applied", so a corrupted stage must not + // be dropped: an interrupted am-started record would then skip git am + // and clear the marker with the commit series missing. "unknown" makes + // recovery fail closed instead. + sanitized.appliedPartialStage = "unknown"; + } + return sanitized; +} + function normalizeProjectArtifacts( projectArtifacts: SubagentGitProjectPatchArtifact[] ): SubagentGitProjectPatchArtifact[] { @@ -162,7 +288,7 @@ function normalizeProjectArtifacts( const storageKey = (artifact.storageKey || artifact.projectName).trim(); assertSafeSubagentGitPatchPathComponent(storageKey, "storageKey"); return { - ...artifact, + ...sanitizeDirtyCaptureFields(artifact), projectName: artifact.projectName.trim(), storageKey, }; @@ -229,7 +355,15 @@ export function normalizeSubagentGitPatchArtifact( function normalizeArtifactsByChildTaskId( artifactsByChildTaskId: Record, - version: number | undefined + version: number | undefined, + options?: { + /** + * Rethrow entry-level normalization failures instead of skipping the + * entry. Cleanup reads need this: a malformed entry read as absent + * would let removal delete that task's only patch files. + */ + propagateEntryErrors?: boolean; + } ): Record { const normalizedEntries: Array = []; @@ -246,6 +380,11 @@ function normalizeArtifactsByChildTaskId( normalizedEntries.push([childTaskId, { ...normalizedArtifact, childTaskId }] as const); } catch (error) { + if (options?.propagateEntryErrors === true) { + throw new Error( + `Invalid subagent git patch artifact entry for task ${childTaskId}: ${getErrorMessage(error)}` + ); + } log.error("Skipping invalid subagent git patch artifact entry", { childTaskId, error, @@ -286,8 +425,29 @@ export function getSubagentGitPatchMboxPath( ); } +export function getSubagentGitPatchWorktreePatchPath( + workspaceSessionDir: string, + childTaskId: string, + storageKey = LEGACY_SINGLE_PROJECT_STORAGE_KEY +): string { + return path.join( + getSubagentGitPatchProjectDir(workspaceSessionDir, childTaskId, storageKey), + SUBAGENT_GIT_PATCH_WORKTREE_FILE_NAME + ); +} + export async function readSubagentGitPatchArtifactsFile( - workspaceSessionDir: string + workspaceSessionDir: string, + options?: { + /** + * Throw instead of self-healing to an empty file when the file exists + * but is unreadable or malformed. Cleanup decisions must use this: an + * unreadable index read as empty would let deletion proceed and destroy + * the only copies of the patch files it references. A missing file + * (ENOENT) still reads as empty because it genuinely has no artifacts. + */ + propagateReadErrors?: boolean; + } ): Promise { try { const filePath = getSubagentGitPatchArtifactsFilePath(workspaceSessionDir); @@ -295,7 +455,7 @@ export async function readSubagentGitPatchArtifactsFile( const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object") { - return createEmptyArtifactsFile(); + throw new Error("subagent git patch artifacts file is not an object"); } const obj = parsed as { @@ -307,24 +467,33 @@ export async function readSubagentGitPatchArtifactsFile( const artifactsByChildTaskId = obj.artifactsByChildTaskId; if (version !== 1 && version !== SUBAGENT_GIT_PATCH_ARTIFACTS_FILE_VERSION) { - return createEmptyArtifactsFile(); + throw new Error( + `subagent git patch artifacts file has unsupported version ${String(obj.version)}` + ); } if (!artifactsByChildTaskId || typeof artifactsByChildTaskId !== "object") { - return createEmptyArtifactsFile(); + throw new Error("subagent git patch artifacts file has no artifact map"); } return { version: SUBAGENT_GIT_PATCH_ARTIFACTS_FILE_VERSION, artifactsByChildTaskId: normalizeArtifactsByChildTaskId( artifactsByChildTaskId as Record, - version + version, + // Entry-level failures must also propagate: a malformed entry + // silently skipped here would read as absent, and cleanup would + // delete that task's only patch files. + { propagateEntryErrors: options?.propagateReadErrors === true } ), }; } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return createEmptyArtifactsFile(); } + if (options?.propagateReadErrors === true) { + throw error; + } log.error("Failed to read subagent git patch artifacts file", { error }); return createEmptyArtifactsFile(); @@ -342,11 +511,32 @@ export async function readSubagentGitPatchArtifact( export async function updateSubagentGitPatchArtifactsFile(params: { workspaceId: string; workspaceSessionDir: string; - update: (file: SubagentGitPatchArtifactsFile) => void; + /** + * Runs under the workspace file lock, so async side effects that must be + * serialized with the freshly read state (e.g. roll-up file replication) + * can happen here without a read-then-act race. + */ + update: (file: SubagentGitPatchArtifactsFile) => void | Promise; + /** + * Write failures are best-effort (logged) by default. Safety-critical + * markers (partial application) must fail loudly instead: a silently + * missing marker lets a retry replay the already-applied commit series. + */ + propagateWriteErrors?: boolean; + /** + * See readSubagentGitPatchArtifactsFile: updates that must not persist a + * reduced map when the existing file (or one entry) is malformed, e.g. + * the roll-up's parent-index update, need the strict read; the default + * self-healing read would silently drop the malformed state and this + * write would make the loss durable. + */ + propagateReadErrors?: boolean; }): Promise { return workspaceFileLocks.withLock(params.workspaceId, async () => { - const file = await readSubagentGitPatchArtifactsFile(params.workspaceSessionDir); - params.update(file); + const file = await readSubagentGitPatchArtifactsFile(params.workspaceSessionDir, { + propagateReadErrors: params.propagateReadErrors, + }); + await params.update(file); file.version = SUBAGENT_GIT_PATCH_ARTIFACTS_FILE_VERSION; file.artifactsByChildTaskId = Object.fromEntries( Object.entries(file.artifactsByChildTaskId).map(([childTaskId, artifact]) => [ @@ -360,6 +550,9 @@ export async function updateSubagentGitPatchArtifactsFile(params: { await writeFileAtomic(filePath, JSON.stringify(file, null, 2)); } catch (error) { log.error("Failed to write subagent git patch artifacts file", { error }); + if (params.propagateWriteErrors === true) { + throw new Error(`Could not persist patch artifact state: ${getErrorMessage(error)}`); + } } return file; }); @@ -370,12 +563,15 @@ export async function upsertSubagentGitPatchArtifact(params: { workspaceSessionDir: string; childTaskId: string; updater: (existing: SubagentGitPatchArtifact | null) => SubagentGitPatchArtifact; + /** See updateSubagentGitPatchArtifactsFile: safety-critical markers must fail loudly. */ + propagateWriteErrors?: boolean; }): Promise { let updated: SubagentGitPatchArtifact | null = null; await updateSubagentGitPatchArtifactsFile({ workspaceId: params.workspaceId, workspaceSessionDir: params.workspaceSessionDir, + propagateWriteErrors: params.propagateWriteErrors, update: (file) => { const existing = file.artifactsByChildTaskId[params.childTaskId] ?? null; updated = normalizeSubagentGitPatchArtifact(params.updater(existing)); @@ -396,12 +592,37 @@ export async function markSubagentGitPatchArtifactApplied(params: { childTaskId: string; projectPath: string; appliedAtMs: number; + /** + * Target HEAD after the full application. Replay-safe retries verify the + * applied work is still present against it before skipping. Ignored for + * partial stamps (the partial marker gates those retries instead). + */ + appliedHeadSha?: string; + /** + * The application was asserted via acknowledge_partial_recovery: the + * user's manual recovery may not be reverse-applicable, so replay-safe + * validation must not re-run the content check against it. + */ + appliedAcknowledged?: boolean; + /** Only the commit series landed; the uncommitted-changes patch failed. */ + partial?: boolean; + /** Target HEAD at partial recording time (ancestry fence for completion). */ + partialHeadSha?: string; + /** Only meaningful with partial: true; absent means "commits-applied". */ + partialStage?: SubagentGitPatchPartialStage; }): Promise { let updated: SubagentGitPatchArtifact | null = null; await updateSubagentGitPatchArtifactsFile({ workspaceId: params.workspaceId, workspaceSessionDir: params.workspaceSessionDir, + // Every write here records application evidence, so all failures must + // surface: a silently unpersisted partial marker would let a retry + // replay the already-applied commit series, and a silently unpersisted + // completion (which also clears the marker) would report success while + // the durable state still says partial/am-started, leaving later + // retries unable to reconcile the advanced HEAD. + propagateWriteErrors: true, update: (file) => { const existing = file.artifactsByChildTaskId[params.childTaskId] ?? null; if (!existing) { @@ -412,14 +633,43 @@ export async function markSubagentGitPatchArtifactApplied(params: { updated = normalizeSubagentGitPatchArtifact({ ...existing, updatedAtMs: params.appliedAtMs, - projectArtifacts: existing.projectArtifacts.map((artifact) => - matchesProjectArtifactProjectPathForUpdate(artifact, params.projectPath) - ? { - ...artifact, - appliedAtMs: params.appliedAtMs, - } - : artifact - ), + projectArtifacts: existing.projectArtifacts.map((artifact) => { + if (!matchesProjectArtifactProjectPathForUpdate(artifact, params.projectPath)) { + return artifact; + } + const { + appliedPartial: _cleared, + appliedPartialHeadSha: _clearedSha, + appliedPartialStage: _clearedStage, + // Always cleared so a stale post-apply HEAD (or acknowledged + // flag) from an earlier full application cannot validate a + // newer one. + appliedHeadSha: _clearedAppliedSha, + appliedAcknowledged: _clearedAcknowledged, + ...rest + } = artifact; + return { + ...rest, + // An am-started record is in-progress state, not an application: + // stamping appliedAtMs would misread as applied (and trip + // already-applied gates) if the attempt is interrupted. + ...(params.partialStage === "am-started" ? {} : { appliedAtMs: params.appliedAtMs }), + ...(params.partial !== true && params.appliedHeadSha != null + ? { appliedHeadSha: params.appliedHeadSha } + : {}), + ...(params.partial !== true && params.appliedAcknowledged === true + ? { appliedAcknowledged: true } + : {}), + // A full apply clears any earlier partial marker. + ...(params.partial === true ? { appliedPartial: true } : {}), + ...(params.partial === true && params.partialHeadSha != null + ? { appliedPartialHeadSha: params.partialHeadSha } + : {}), + ...(params.partial === true && params.partialStage != null + ? { appliedPartialStage: params.partialStage } + : {}), + }; + }), }); file.artifactsByChildTaskId[params.childTaskId] = updated; }, @@ -427,3 +677,298 @@ export async function markSubagentGitPatchArtifactApplied(params: { return updated; } + +/** + * Target-local partial-application state for REPLAY targets. When a + * descendant or reconciliation workspace applies an ancestor's artifact, the + * shared artifact file must stay untouched (other targets replay it too), so + * the "commits landed but the worktree patch failed" state is recorded in the + * applying workspace's own session dir. Without it, a retry would replay the + * already-applied commit series through git am. + */ +export type SubagentGitPatchPartialStage = "am-started" | "commits-applied"; + +export interface LocalPatchPartialApplyRecord { + appliedAtMs: number; + /** Target HEAD when the partial application was recorded (ancestry fence). */ + headCommitSha?: string; + /** + * Absent means "commits-applied" (markers written before this field + * existed). "unknown" is read-side only: a present-but-unreadable stage + * degrades to it so recovery fails closed instead of skipping git am. + */ + stage?: SubagentGitPatchPartialStage | "unknown"; +} + +export interface LocalPatchApplyCompletionRecord { + appliedAtMs: number; + /** + * Target HEAD when the full application completed. Replay-safe retries + * (allowAlreadyApplied) verify the applied work is still present against + * it before skipping; absent on records written before the field existed. + */ + headCommitSha?: string; + /** + * Written by acknowledge_partial_recovery: the user asserted the child's + * work is present in a form the reverse check cannot recognize (e.g. a + * merged conflict resolution), so replay-safe validation must not re-run + * the content check against it. The ancestry check still applies. + */ + acknowledged?: true; + /** + * Read-side only: a present-but-malformed record degrades to unknown. + * Consumers must fail closed instead of treating it as proof that this + * target applied the project. + */ + unknown?: true; +} + +interface LocalPatchApplyStateFile { + version: 1; + partialsByChildTaskId: Record>; + // Full applications by THIS replay target. The shared ancestor artifact's + // appliedAtMs cannot record them (other targets replay it too), and + // acknowledgment sweeps need proof a sibling was applied to skip it. + completionsByChildTaskId: Record>; +} + +const SUBAGENT_GIT_PATCH_LOCAL_APPLY_FILE_NAME = "subagent-patches-local-apply.json"; + +function getLocalPatchApplyStateFilePath(workspaceSessionDir: string): string { + return path.join(workspaceSessionDir, SUBAGENT_GIT_PATCH_LOCAL_APPLY_FILE_NAME); +} + +async function readLocalPatchApplyStateFile( + workspaceSessionDir: string +): Promise { + const empty: LocalPatchApplyStateFile = { + version: 1, + partialsByChildTaskId: {}, + completionsByChildTaskId: {}, + }; + let raw: string; + try { + raw = await fsPromises.readFile(getLocalPatchApplyStateFilePath(workspaceSessionDir), "utf-8"); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return empty; + } + // Fail closed: an unreadable file (EACCES, transient I/O) may hide a + // recorded partial application, and treating it as empty would let a + // retry replay an already-applied commit series. + throw new Error(`Could not read local patch apply state: ${getErrorMessage(error)}`); + } + let parsed: Partial | null; + try { + parsed = JSON.parse(raw) as Partial | null; + } catch (error) { + // Fail closed like the read failure above: malformed or truncated JSON + // may hide a recorded partial application. + throw new Error(`Could not read local patch apply state: ${getErrorMessage(error)}`); + } + if (parsed == null || typeof parsed !== "object") { + throw new Error("Could not read local patch apply state: not a JSON object"); + } + // Fail closed on present-but-malformed containers at every level (like + // the file-level corruption above): silently treating one as empty would + // hide recorded partial applications and completions, letting a retry + // replay an already-applied commit series. Absence stays valid (clears + // delete keys entirely; legacy files predate completionsByChildTaskId). + const requirePlainObjectContainer = (value: unknown, what: string): Record => { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Could not read local patch apply state: ${what} is malformed`); + } + return value as Record; + }; + // A corrupted record degrades to a conservative marker instead of + // vanishing. Its contents are unreadable, so the stage is "unknown" + // (fails closed) rather than the legacy commits-applied default. + const partialsByChildTaskId: LocalPatchApplyStateFile["partialsByChildTaskId"] = {}; + if (parsed.partialsByChildTaskId !== undefined) { + const byChildTask = requirePlainObjectContainer( + parsed.partialsByChildTaskId, + "partialsByChildTaskId" + ); + for (const [childTaskId, byProjectRaw] of Object.entries(byChildTask)) { + const byProject = requirePlainObjectContainer( + byProjectRaw, + `partial records for task ${childTaskId}` + ); + const validByProject: Record = {}; + for (const [projectPath, record] of Object.entries(byProject)) { + if (record == null || typeof record !== "object" || Array.isArray(record)) { + validByProject[projectPath] = { appliedAtMs: 0, stage: "unknown" }; + continue; + } + const candidate = record as Partial; + const appliedAtMs = candidate.appliedAtMs; + const headCommitSha = candidate.headCommitSha; + const stage = candidate.stage; + // Positive integer, like completion records: an out-of-range value + // (e.g. -1, 1.5) is corruption and must not qualify the record for + // the legacy absent-stage commits-applied default below. + const hasValidAppliedAtMs = + typeof appliedAtMs === "number" && Number.isInteger(appliedAtMs) && appliedAtMs > 0; + validByProject[projectPath] = { + appliedAtMs: hasValidAppliedAtMs ? appliedAtMs : 0, + ...(typeof headCommitSha === "string" && headCommitSha.length > 0 + ? { headCommitSha } + : {}), + // A present-but-invalid stage degrades to "unknown", not absence: + // absent means legacy "commits-applied", which would skip git am + // for what may be an interrupted am-started record. The legacy + // absent-stage default is reserved for otherwise valid records + // (valid appliedAtMs): a structurally empty record like {} proves + // nothing about the commit series and must also fail closed. + ...(stage === "am-started" || stage === "commits-applied" || stage === "unknown" + ? { stage } + : stage !== undefined || !hasValidAppliedAtMs + ? { stage: "unknown" as const } + : {}), + }; + } + if (Object.keys(validByProject).length > 0) { + partialsByChildTaskId[childTaskId] = validByProject; + } + } + } + const completionsByChildTaskId: LocalPatchApplyStateFile["completionsByChildTaskId"] = {}; + if (parsed.completionsByChildTaskId !== undefined) { + const byChildTask = requirePlainObjectContainer( + parsed.completionsByChildTaskId, + "completionsByChildTaskId" + ); + for (const [childTaskId, byProjectRaw] of Object.entries(byChildTask)) { + const byProject = requirePlainObjectContainer( + byProjectRaw, + `completion records for task ${childTaskId}` + ); + const validByProject: Record = {}; + for (const [projectPath, record] of Object.entries(byProject)) { + const candidate = + record != null && typeof record === "object" + ? (record as Partial) + : undefined; + const appliedAtMs = candidate?.appliedAtMs; + const headCommitSha = candidate?.headCommitSha; + const acknowledged = candidate?.acknowledged; + const hasValidAppliedAtMs = + typeof appliedAtMs === "number" && Number.isInteger(appliedAtMs) && appliedAtMs > 0; + // A present-but-invalid headCommitSha or acknowledged flag is + // corruption like an invalid appliedAtMs: dropping just the field + // would silently change what replay-safe validation checks. + const hasInvalidHeadSha = + headCommitSha !== undefined && + !(typeof headCommitSha === "string" && headCommitSha.trim().length > 0); + const hasInvalidAcknowledged = acknowledged !== undefined && acknowledged !== true; + // A malformed completion must not manufacture proof of application + // (sweeps skip proven-applied siblings) nor vanish (a retry would + // replay the series): degrade to unknown, which fails closed. + validByProject[projectPath] = + hasValidAppliedAtMs && !hasInvalidHeadSha && !hasInvalidAcknowledged + ? { + appliedAtMs, + ...(typeof headCommitSha === "string" ? { headCommitSha } : {}), + ...(acknowledged === true ? { acknowledged: true as const } : {}), + } + : { appliedAtMs: 0, unknown: true }; + } + if (Object.keys(validByProject).length > 0) { + completionsByChildTaskId[childTaskId] = validByProject; + } + } + } + return { version: 1, partialsByChildTaskId, completionsByChildTaskId }; +} + +export async function readLocalPatchPartialApply(params: { + workspaceSessionDir: string; + childTaskId: string; + projectPath: string; +}): Promise { + const file = await readLocalPatchApplyStateFile(params.workspaceSessionDir); + return file.partialsByChildTaskId[params.childTaskId]?.[params.projectPath] ?? null; +} + +export async function readLocalPatchApplyCompletion(params: { + workspaceSessionDir: string; + childTaskId: string; + projectPath: string; +}): Promise { + const file = await readLocalPatchApplyStateFile(params.workspaceSessionDir); + return file.completionsByChildTaskId[params.childTaskId]?.[params.projectPath] ?? null; +} + +export async function setLocalPatchPartialApply(params: { + workspaceId: string; + workspaceSessionDir: string; + childTaskId: string; + projectPath: string; + /** A record marks the partial application; null clears it after a full apply. */ + record: LocalPatchPartialApplyRecord | null; + /** + * Only meaningful with record: null. Records, in the same atomic write, + * that this target fully applied the project, so acknowledgment sweeps can + * prove a marker-free replay sibling needs no re-apply. + */ + completedAtMs?: number; + /** Target HEAD at completion time; see LocalPatchApplyCompletionRecord. */ + completedHeadSha?: string; + /** Completion was asserted via acknowledge_partial_recovery, not applied. */ + completedAcknowledged?: boolean; +}): Promise { + // Marker SETS and completion writes must surface failures: an undurable + // marker lets a retry replay the already-applied commit series, and an + // undurable completion reports success while the stale partial marker + // survives. Only a bare CLEAR (no completion) stays best-effort, because + // a stale marker there only blocks a redundant retry. + const mustSurfaceFailures = params.record != null || params.completedAtMs != null; + await workspaceFileLocks.withLock(params.workspaceId, async () => { + let file: LocalPatchApplyStateFile; + try { + file = await readLocalPatchApplyStateFile(params.workspaceSessionDir); + } catch (error) { + if (mustSurfaceFailures) { + throw new Error( + `Could not persist partial-application state for task ${params.childTaskId}: ${getErrorMessage(error)}` + ); + } + log.error("Failed to read local patch apply state file", { error }); + return; + } + const byProject = file.partialsByChildTaskId[params.childTaskId] ?? {}; + if (params.record == null) { + delete byProject[params.projectPath]; + } else { + byProject[params.projectPath] = params.record; + } + if (Object.keys(byProject).length === 0) { + delete file.partialsByChildTaskId[params.childTaskId]; + } else { + file.partialsByChildTaskId[params.childTaskId] = byProject; + } + if (params.record == null && params.completedAtMs != null) { + const completionsByProject = file.completionsByChildTaskId[params.childTaskId] ?? {}; + completionsByProject[params.projectPath] = { + appliedAtMs: params.completedAtMs, + ...(params.completedHeadSha != null ? { headCommitSha: params.completedHeadSha } : {}), + ...(params.completedAcknowledged === true ? { acknowledged: true as const } : {}), + }; + file.completionsByChildTaskId[params.childTaskId] = completionsByProject; + } + try { + await fsPromises.mkdir(params.workspaceSessionDir, { recursive: true }); + await writeFileAtomic( + getLocalPatchApplyStateFilePath(params.workspaceSessionDir), + JSON.stringify(file, null, 2) + ); + } catch (error) { + log.error("Failed to write local patch apply state file", { error }); + if (mustSurfaceFailures) { + throw new Error( + `Could not persist partial-application state for task ${params.childTaskId}: ${getErrorMessage(error)}` + ); + } + } + }); +} diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 01a31e3005..faab519a99 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -19,6 +19,7 @@ import * as subagentGitPatchArtifacts from "@/node/services/subagentGitPatchArti import { getSubagentGitPatchMboxPath, readSubagentGitPatchArtifact, + upsertSubagentGitPatchArtifact, } from "@/node/services/subagentGitPatchArtifacts"; import { readSubagentReportArtifact, @@ -19788,6 +19789,178 @@ describe("TaskService", () => { await config.editConfig(() => cfg); } + // Cleanup fails closed for a patch-eligible (exec-like) task with no + // artifact on disk, so tests deleting exec tasks must seed a terminal + // artifact to represent completed generation. + async function seedSkippedPatchArtifact( + config: Config, + parentWorkspaceId: string, + childTaskId: string, + projectPath: string + ): Promise { + await upsertSubagentGitPatchArtifact({ + workspaceId: parentWorkspaceId, + workspaceSessionDir: config.getSessionDir(parentWorkspaceId), + childTaskId, + updater: () => ({ + childTaskId, + parentWorkspaceId, + createdAtMs: Date.now(), + status: "skipped", + projectArtifacts: [ + { + projectPath, + projectName: path.basename(projectPath), + storageKey: path.basename(projectPath), + status: "skipped", + commitCount: 0, + }, + ], + readyProjectCount: 0, + failedProjectCount: 0, + skippedProjectCount: 1, + totalCommitCount: 0, + }), + }); + } + + test("reported leaf cleanup defers a patch-eligible task whose artifact is missing", async () => { + const config = await createTestConfig(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const childTaskId = "child-exec-333"; + + await config.editConfig(() => ({ + projects: new Map([ + [ + projectPath, + { + trusted: true, + workspaces: [ + projectWorkspace(projectPath, "root", rootWorkspaceId), + projectWorkspace(projectPath, "child-task", childTaskId, { + name: "agent_exec_child", + parentWorkspaceId: rootWorkspaceId, + agentType: "exec", + taskStatus: "reported", + }), + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + })); + + const remove = mock(async (workspaceId: string, _force?: boolean): Promise> => { + await removeWorkspaceFromTestConfig(config, workspaceId); + return Ok(undefined); + }); + const { aiService } = createAIServiceMocks(config, { isStreaming: mock(() => false) }); + const { workspaceService } = createWorkspaceServiceMocks({ remove }); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); + + const internal = taskService as unknown as { + cleanupReportedLeafTask: (workspaceId: string) => Promise; + }; + + // An exec task with NO patch artifact means the pending-marker write + // failed: deleting the workspace would destroy work nothing records. + await internal.cleanupReportedLeafTask(childTaskId); + expect(remove).toHaveBeenCalledTimes(0); + + // Once generation succeeds (terminal artifact on disk), cleanup proceeds. + await seedSkippedPatchArtifact(config, rootWorkspaceId, childTaskId, projectPath); + await internal.cleanupReportedLeafTask(childTaskId); + expect(remove).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledWith(childTaskId, true); + }); + + test("reported leaf cleanup defers a task with unrecovered uncaptured changes", async () => { + const config = await createTestConfig(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const childTaskId = "child-exec-uncaptured"; + + await config.editConfig(() => ({ + projects: new Map([ + [ + projectPath, + { + trusted: true, + workspaces: [ + projectWorkspace(projectPath, "root", rootWorkspaceId), + projectWorkspace(projectPath, "child-task", childTaskId, { + name: "agent_exec_child", + parentWorkspaceId: rootWorkspaceId, + agentType: "exec", + taskStatus: "reported", + }), + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + })); + + const seedArtifact = async (appliedAtMs?: number): Promise => { + await upsertSubagentGitPatchArtifact({ + workspaceId: rootWorkspaceId, + workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + childTaskId, + updater: () => ({ + childTaskId, + parentWorkspaceId: rootWorkspaceId, + createdAtMs: Date.now(), + status: "ready", + projectArtifacts: [ + { + projectPath, + projectName: path.basename(projectPath), + storageKey: path.basename(projectPath), + status: "ready", + commitCount: 1, + hadUncommittedChanges: true, + worktreePatchSkippedReason: "diff exceeded the capture cap", + ...(appliedAtMs != null ? { appliedAtMs } : {}), + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }), + }); + }; + + const remove = mock(async (workspaceId: string, _force?: boolean): Promise> => { + await removeWorkspaceFromTestConfig(config, workspaceId); + return Ok(undefined); + }); + const { aiService } = createAIServiceMocks(config, { isStreaming: mock(() => false) }); + const { workspaceService } = createWorkspaceServiceMocks({ remove }); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); + + const internal = taskService as unknown as { + cleanupReportedLeafTask: (workspaceId: string) => Promise; + }; + + // The child worktree holds the only copy of the uncaptured changes; + // cleanup must not delete it while nothing records recovery. + await seedArtifact(); + await internal.cleanupReportedLeafTask(childTaskId); + expect(remove).toHaveBeenCalledTimes(0); + + // An applied project means the user acknowledged the omission (the + // apply gate requires acknowledge_uncaptured_changes), so cleanup + // proceeds. + await seedArtifact(Date.now()); + await internal.cleanupReportedLeafTask(childTaskId); + expect(remove).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledWith(childTaskId, true); + }); + test("reported leaf cleanup deletes the finished leaf but keeps siblings and parents", async () => { const config = await createTestConfig(rootDir); @@ -19914,6 +20087,8 @@ describe("TaskService", () => { cleanupReportedLeafTask: (workspaceId: string) => Promise; }; + await seedSkippedPatchArtifact(config, grandparentTaskId, parentTaskId, projectPath); + await seedSkippedPatchArtifact(config, rootWorkspaceId, grandparentTaskId, projectPath); await internal.cleanupReportedLeafTask(childTaskId); const isStreamingCalls = (isStreaming as unknown as { mock: { calls: Array<[string]> } }).mock @@ -19996,6 +20171,8 @@ describe("TaskService", () => { cleanupReportedLeafTask: (workspaceId: string) => Promise; }; + await seedSkippedPatchArtifact(config, grandparentTaskId, parentTaskId, projectPath); + await seedSkippedPatchArtifact(config, rootWorkspaceId, grandparentTaskId, projectPath); await internal.cleanupReportedLeafTask(childTaskId); const isStreamingCalls = (isStreaming as unknown as { mock: { calls: Array<[string]> } }).mock @@ -20114,6 +20291,16 @@ describe("TaskService", () => { }, }); + // Patch-eligible (exec) tasks need a terminal artifact on disk or the + // cleanup gate defers them as generation-write failures. + let seedParentWorkspaceId = rootWorkspaceId; + for (const task of taskChain) { + if (task.agentType === "exec") { + await seedSkippedPatchArtifact(config, seedParentWorkspaceId, task.id, projectPath); + } + seedParentWorkspaceId = task.id; + } + const isStreaming = mock(() => false); const remove = mock(async (workspaceId: string, _force?: boolean): Promise> => { await removeWorkspaceFromTestConfig(config, workspaceId); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 2ca686c834..0651c4d2b3 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -11478,6 +11478,55 @@ export class TaskService { }); return { ok: false, reason: "patch_pending" }; } + if (patchArtifact == null) { + // A patch-eligible task with no artifact means the pending-marker + // write failed (or a corrupt artifacts file self-healed to empty): + // deleting the workspace would destroy commits and uncommitted work + // nothing records. Fail closed; generation is retried on startup. + let patchEligible: boolean; + try { + patchEligible = await this.gitPatchArtifactService.shouldGeneratePatchForTask( + parentWorkspaceId, + workspaceId + ); + } catch (error: unknown) { + log.error("cleanupReportedLeafTask: patch eligibility check failed; deferring", { + workspaceId, + parentWorkspaceId, + error, + }); + patchEligible = true; + } + if (patchEligible) { + log.debug("cleanupReportedLeafTask: deferring auto-delete; patch artifact missing", { + workspaceId, + parentWorkspaceId, + }); + return { ok: false, reason: "patch_artifact_missing" }; + } + } + // Uncaptured uncommitted changes (size-bound skip, failed probe, dirty + // submodule) exist ONLY in the child worktree; deleting it would destroy + // the sole copy. Applying such an artifact requires + // acknowledge_uncaptured_changes, which stamps appliedAtMs, so an applied + // project means the user accepted (or manually recovered) the omission. + // Never-appliable artifacts keep the workspace until it is removed + // manually. + if (patchArtifact != null) { + const hasUnrecoveredUncapturedChanges = patchArtifact.projectArtifacts.some( + (projectArtifact) => + projectArtifact.hadUncommittedChanges === true && + projectArtifact.worktreePatchSkippedReason != null && + !projectArtifact.appliedAtMs + ); + if (hasUnrecoveredUncapturedChanges) { + log.debug("cleanupReportedLeafTask: deferring auto-delete; uncaptured changes", { + workspaceId, + parentWorkspaceId, + }); + return { ok: false, reason: "uncaptured_changes_unrecovered" }; + } + } // Workflow task results are persisted in the workflow run/report artifacts before cleanup, // so the user-level "preserve subagents until archive" setting should not keep those diff --git a/src/node/services/tools/task_apply_git_patch.test.ts b/src/node/services/tools/task_apply_git_patch.test.ts index 650fe18ef1..1eef0b5d66 100644 --- a/src/node/services/tools/task_apply_git_patch.test.ts +++ b/src/node/services/tools/task_apply_git_patch.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; import * as fsPromises from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -6,6 +6,7 @@ import { execSync } from "node:child_process"; import type { ToolExecutionOptions } from "ai"; +import type { SubagentGitProjectPatchArtifact } from "@/common/utils/tools/toolDefinitions"; import { applyTaskGitPatchArtifact, createTaskApplyGitPatchTool, @@ -13,9 +14,14 @@ import { import { getSubagentGitPatchArtifactsFilePath, getSubagentGitPatchMboxPath, + getSubagentGitPatchWorktreePatchPath, + readLocalPatchApplyCompletion, + readLocalPatchPartialApply, readSubagentGitPatchArtifact, + setLocalPatchPartialApply, upsertSubagentGitPatchArtifact, } from "@/node/services/subagentGitPatchArtifacts"; +import * as subagentGitPatchArtifactsModule from "@/node/services/subagentGitPatchArtifacts"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { getTestDeps } from "@/node/services/tools/testHelpers"; @@ -88,17 +94,7 @@ async function writePatchArtifact(params: { sessionDir: string; workspaceId: string; childTaskId: string; - projectArtifacts: Array< - | Awaited> - | { - projectPath: string; - projectName: string; - storageKey: string; - status: "pending" | "skipped" | "failed"; - error?: string; - commitCount?: number; - } - >; + projectArtifacts: SubagentGitProjectPatchArtifact[]; }) { await upsertSubagentGitPatchArtifact({ workspaceId: params.workspaceId, @@ -1947,3 +1943,3183 @@ describe("task_apply_git_patch tool", () => { expect(await readSubagentGitPatchArtifact(currentSessionDir, childTaskId)).toBeNull(); }, 20_000); }); + +describe("task_apply_git_patch uncommitted-changes (worktree) patches", () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-task-apply-worktree-patch-")); + }); + + afterEach(async () => { + await fsPromises.rm(rootDir, { recursive: true, force: true }); + }); + + async function setupSingleRepoHarness(): Promise<{ + childRepo: string; + targetRepo: string; + sessionDir: string; + currentWorkspaceId: string; + childTaskId: string; + baseSha: string; + }> { + const childRepo = path.join(rootDir, "child"); + const targetRepo = path.join(rootDir, "target"); + for (const repo of [childRepo, targetRepo]) { + await fsPromises.mkdir(repo, { recursive: true }); + initGitRepo(repo); + } + await commitFile(childRepo, "README.md", "hello\n", "base"); + await commitFile(targetRepo, "README.md", "hello\n", "base"); + const baseSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + + const muxRoot = path.join(rootDir, "mux"); + const currentWorkspaceId = "current-workspace"; + const sessionDir = path.join(muxRoot, "sessions", currentWorkspaceId); + await fsPromises.mkdir(sessionDir, { recursive: true }); + await writeWorkspaceConfig({ + muxRoot, + workspaceId: currentWorkspaceId, + workspaceName: "current", + primaryProjectPath: targetRepo, + projects: [{ projectPath: targetRepo, projectName: "project" }], + }); + + return { + childRepo, + targetRepo, + sessionDir, + currentWorkspaceId, + childTaskId: "child-task-1", + baseSha, + }; + } + + function createApplyTool(harness: Awaited>) { + return createTaskApplyGitPatchTool({ + ...getTestDeps(), + workspaceId: harness.currentWorkspaceId, + cwd: harness.targetRepo, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: harness.sessionDir, + }); + } + + async function writeWorktreePatchFile(params: { + sessionDir: string; + childTaskId: string; + storageKey: string; + childRepo: string; + }): Promise<{ worktreePatchPath: string; worktreePatchBytes: number }> { + const worktreePatchPath = getSubagentGitPatchWorktreePatchPath( + params.sessionDir, + params.childTaskId, + params.storageKey + ); + const diff = execSync("git add -A -- . && git diff --cached --binary HEAD --", { + cwd: params.childRepo, + encoding: "buffer", + }); + execSync("git reset", { cwd: params.childRepo, stdio: "ignore" }); + await fsPromises.mkdir(path.dirname(worktreePatchPath), { recursive: true }); + await fsPromises.writeFile(worktreePatchPath, diff); + return { worktreePatchPath, worktreePatchBytes: diff.length }; + } + + it("applies a worktree-only artifact as uncommitted changes", async () => { + const harness = await setupSingleRepoHarness(); + + await fsPromises.writeFile(path.join(harness.childRepo, "README.md"), "modified\n", "utf-8"); + await fsPromises.writeFile(path.join(harness.childRepo, "new.txt"), "untracked\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + baseCommitSha: harness.baseSha, + headCommitSha: harness.baseSha, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string; note?: string }>; + }; + + expect(result.success).toBe(true); + expect(result.projectResults[0]?.status).toBe("applied"); + + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("base"); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "README.md"), "utf-8")).toBe( + "modified\n" + ); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "new.txt"), "utf-8")).toBe( + "untracked\n" + ); + expect( + execSync("git status --porcelain", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + .length + ).toBeGreaterThan(0); + + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedAtMs).toBeDefined(); + }, 20_000); + + it("leaves applied uncommitted changes unstaged and preserves disjoint staged entries", async () => { + const harness = await setupSingleRepoHarness(); + + await fsPromises.writeFile(path.join(harness.childRepo, "README.md"), "modified\n", "utf-8"); + await fsPromises.writeFile(path.join(harness.childRepo, "new.txt"), "untracked\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + baseCommitSha: harness.baseSha, + headCommitSha: harness.baseSha, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + // A disjoint staged entry must survive the post-apply unstaging. + await fsPromises.writeFile(path.join(harness.targetRepo, "other.txt"), "staged\n", "utf-8"); + execSync("git add other.txt", { cwd: harness.targetRepo }); + + const tool = createApplyTool(harness); + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + }; + expect(result.success).toBe(true); + + // `git apply --3way` implies --index; the applied changes must not stay + // staged or the next commit-bearing apply fails its clean-index + // preflight. + const stagedPaths = execSync("git diff --cached --name-only", { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + .filter((line) => line.length > 0); + expect(stagedPaths).toEqual(["other.txt"]); + const status = execSync("git status --porcelain", { + cwd: harness.targetRepo, + encoding: "utf-8", + }); + expect(status).toContain(" M README.md"); + expect(status).toContain("?? new.txt"); + }, 20_000); + + it("unstages entries left by a failed earlier attempt before completing via the reverse check", async () => { + const harness = await setupSingleRepoHarness(); + + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child update\n", + "utf-8" + ); + await fsPromises.writeFile(path.join(harness.childRepo, "new.txt"), "untracked\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + appliedPartial: true, + appliedPartialStage: "commits-applied", + ...worktreeFields, + }, + ], + }); + + // Aftermath of an attempt whose apply succeeded but whose post-apply + // unstage failed: the patch content is in the worktree AND staged + // (`git apply --3way` implies --index). + await fsPromises.writeFile( + path.join(harness.targetRepo, "README.md"), + "child update\n", + "utf-8" + ); + await fsPromises.writeFile(path.join(harness.targetRepo, "new.txt"), "untracked\n", "utf-8"); + execSync("git add README.md new.txt", { cwd: harness.targetRepo }); + // A disjoint staged entry must survive the repair. + await fsPromises.writeFile(path.join(harness.targetRepo, "other.txt"), "staged\n", "utf-8"); + execSync("git add other.txt", { cwd: harness.targetRepo }); + + const tool = createApplyTool(harness); + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string; note?: string }>; + }; + expect(result.success).toBe(true); + expect(result.projectResults[0]?.note).toContain("already present in the worktree"); + + // Completion must repair the index before clearing the marker, or the + // next commit-bearing apply fails its clean-index preflight. + const stagedPaths = execSync("git diff --cached --name-only", { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + .filter((line) => line.length > 0); + expect(stagedPaths).toEqual(["other.txt"]); + const status = execSync("git status --porcelain", { + cwd: harness.targetRepo, + encoding: "utf-8", + }); + expect(status).toContain(" M README.md"); + expect(status).toContain("?? new.txt"); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + }, 20_000); + + it("fails a replay-safe retry when the applied uncommitted changes were discarded", async () => { + const harness = await setupSingleRepoHarness(); + + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child update\n", + "utf-8" + ); + await fsPromises.writeFile(path.join(harness.childRepo, "new.txt"), "untracked\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const deps = { + ...getTestDeps(), + workspaceId: harness.currentWorkspaceId, + cwd: harness.targetRepo, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: harness.sessionDir, + }; + const first = await applyTaskGitPatchArtifact( + deps, + { task_id: harness.childTaskId, three_way: true }, + {} + ); + expect(first.success).toBe(true); + + // A crash-before-checkpoint retry with the work intact skips as applied. + const intactRetry = await applyTaskGitPatchArtifact( + deps, + { task_id: harness.childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(intactRetry.success).toBe(true); + expect(intactRetry.projectResults?.[0]?.note).toContain("already applied"); + + // Committing the applied work afterwards is a legitimate evolution of + // the target, not a discard: the patch paths changed since the recorded + // post-apply HEAD. + execSync("git add README.md new.txt && git commit -m 'landed child work'", { + cwd: harness.targetRepo, + stdio: "ignore", + }); + const committedRetry = await applyTaskGitPatchArtifact( + deps, + { task_id: harness.childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(committedRetry.success).toBe(true); + expect(committedRetry.projectResults?.[0]?.note).toContain("already applied"); + + // Once the applied changes are discarded (patch paths pristine at the + // recorded post-apply state), the completion record alone must not let + // a workflow checkpoint past the missing work. + execSync(`git reset --hard ${harness.baseSha}`, { cwd: harness.targetRepo, stdio: "ignore" }); + const staleRetry = await applyTaskGitPatchArtifact( + deps, + { task_id: harness.childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(staleRetry.success).toBe(false); + expect(staleRetry.projectResults?.[0]?.error).toContain("no longer present in the worktree"); + }, 20_000); + + it("applies a canonical worktree patch when worktreePatchPath metadata was sanitized away", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile(path.join(harness.childRepo, "extra.txt"), "extra\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + // A corrupt persisted worktreePatchPath is dropped by the sanitizer, but + // the captured patch still sits at the canonical location; applying must + // not silently omit it while marking the artifact complete. + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + worktreePatchBytes: worktreeFields.worktreePatchBytes, + }, + ], + }); + + const tool = createApplyTool(harness); + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string }>; + }; + + expect(result.success).toBe(true); + expect(result.projectResults[0]?.status).toBe("applied"); + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("child commit"); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "extra.txt"), "utf-8")).toBe( + "extra\n" + ); + }, 20_000); + + it("applies commits first and then the uncommitted-changes patch", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile( + path.join(harness.childRepo, "feature.txt"), + "feature wip\n", + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string; note?: string }>; + }; + + expect(result.success).toBe(true); + expect(result.projectResults[0]?.status).toBe("applied"); + + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("child commit"); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "feature.txt"), "utf-8")).toBe( + "feature wip\n" + ); + }, 20_000); + + it("surfaces a worktree patch conflict clearly and leaves the repo recoverable", async () => { + const harness = await setupSingleRepoHarness(); + + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child version\n", + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await commitFile(harness.targetRepo, "README.md", "target version\n", "conflicting change"); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string; error?: string }>; + }; + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.status).toBe("failed"); + expect(result.projectResults[0]?.error).toBeDefined(); + + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("conflicting change"); + // git apply --3way can leave part of a multi-file patch applied, so the + // failure records a partial marker: retries route to the reverse-check + // completion path instead of re-treating the artifact as fresh (which + // the dirty-overlap preflight would permanently reject). + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBe(true); + + // Manual recovery: resolve the conflicted file to the child's content. + await fsPromises.writeFile( + path.join(harness.targetRepo, "README.md"), + "child version\n", + "utf-8" + ); + const retryResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { + success: boolean; + projectResults: Array<{ status: string; note?: string }>; + }; + expect(retryResult.success).toBe(true); + expect(retryResult.projectResults[0]?.note).toContain("already present in the worktree"); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + // The failed --3way attempt left unmerged index entries; completion must + // repair them before clearing the marker. + expect( + execSync("git diff --cached --name-only", { + cwd: harness.targetRepo, + encoding: "utf-8", + }).trim() + ).toBe(""); + }, 20_000); + + it("records the artifact as applied when commits land but the worktree patch fails", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child version\n", + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + // Conflicting COMMIT keeps the target tree clean, so the dirty preflight + // passes, git am succeeds, and only the worktree patch fails. + await commitFile(harness.targetRepo, "README.md", "target version\n", "conflicting change"); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string; error?: string; note?: string }>; + }; + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.status).toBe("failed"); + // The commit series landed on the target. + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("child commit"); + // HEAD advanced permanently, so a retry must see the artifact as applied, + // but the partial marker keeps it from reading as a completed application. + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedAtMs).toBeDefined(); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBe(true); + + // A replay-safe workflow retry (allowAlreadyApplied) attempts to + // complete just the pending uncommitted-changes patch; the conflicting + // target content makes that completion fail without re-running git am, + // so the workflow still cannot checkpoint past the missing changes. + const retryResult = await applyTaskGitPatchArtifact( + { + ...getTestDeps(), + workspaceId: harness.currentWorkspaceId, + cwd: harness.targetRepo, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: harness.sessionDir, + }, + { task_id: harness.childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(retryResult.success).toBe(false); + // The failed first attempt left conflict markers, so the completion is + // blocked on the overlap preflight until they are resolved. + expect(retryResult.note).toContain("Completing the earlier partial application was blocked"); + // The commit series was not replayed and the marker survives the + // failed completion. + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBe(true); + }, 20_000); + + it("persists partial state when the worktree application rejects instead of failing", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile(path.join(harness.childRepo, "extra.txt"), "extra\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + // Simulate an SSH/runtime failure that REJECTS the worktree apply after + // git am already advanced HEAD. + const realRuntime = createRuntime({ type: "local", srcBaseDir: "/tmp" }); + const failingRuntime: typeof realRuntime = Object.create(realRuntime, { + exec: { + value: (command: string, options: Parameters[1]) => { + if (command.includes("git apply --3way --binary")) { + return Promise.reject(new Error("simulated runtime exec failure")); + } + return realRuntime.exec(command, options); + }, + }, + }) as typeof realRuntime; + + const deps = { + ...getTestDeps(), + workspaceId: harness.currentWorkspaceId, + cwd: harness.targetRepo, + runtimeTempDir: "/tmp", + workspaceSessionDir: harness.sessionDir, + }; + let thrownMessage = ""; + try { + await applyTaskGitPatchArtifact( + { ...deps, runtime: failingRuntime }, + { task_id: harness.childTaskId, three_way: true }, + {} + ); + } catch (error) { + thrownMessage = error instanceof Error ? error.message : String(error); + } + expect(thrownMessage).toContain("simulated runtime exec failure"); + + // The commit series landed before the rejection. + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("child commit"); + // The rejection persisted partial state; the retry completes just the + // pending uncommitted-changes patch instead of re-running git am on the + // already-applied commit series. + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBe(true); + const retryResult = await applyTaskGitPatchArtifact( + { ...deps, runtime: realRuntime }, + { task_id: harness.childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(retryResult.success).toBe(true); + expect(retryResult.note).toContain("Completed the earlier partial application"); + // The child's uncommitted change landed as uncommitted content, the + // commit series was not replayed, and the marker cleared. + expect(await fsPromises.readFile(path.join(harness.targetRepo, "extra.txt"), "utf-8")).toBe( + "extra\n" + ); + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedAtMs).toBeDefined(); + }, 20_000); + + it("rolls back the commit series when the partial marker cannot be persisted", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile(path.join(harness.childRepo, "extra.txt"), "extra\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + // Unrelated dirty work in the target: the rollback must preserve it. + await fsPromises.writeFile( + path.join(harness.targetRepo, "target-local.txt"), + "local\n", + "utf-8" + ); + + const tool = createApplyTool(harness); + + // Fail only the post-am upgrade write (commits-applied): the in-progress + // am-started marker before git am must succeed so the failure window is + // specifically "commits landed, upgrade marker unwritable". + const realMarkApplied = subagentGitPatchArtifactsModule.markSubagentGitPatchArtifactApplied; + const markAppliedSpy = spyOn( + subagentGitPatchArtifactsModule, + "markSubagentGitPatchArtifactApplied" + ).mockImplementation((markParams) => { + if (markParams.partialStage === "commits-applied") { + return Promise.reject(new Error("simulated marker persistence failure")); + } + return realMarkApplied(markParams); + }); + let result: { + success: boolean; + projectResults: Array<{ status: string; error?: string; note?: string }>; + }; + try { + result = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as typeof result; + } finally { + markAppliedSpy.mockRestore(); + } + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.error).toContain( + "Failed to persist the partial-application marker" + ); + expect(result.projectResults[0]?.note).toContain("rolled back"); + // The commit series was rolled back; unrelated dirty work survived. + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("base"); + expect( + await fsPromises.readFile(path.join(harness.targetRepo, "target-local.txt"), "utf-8") + ).toBe("local\n"); + // The pre-am in-progress marker remains (its fence matches the rolled + // back HEAD, so the retry below reconciles and proceeds fresh), and + // nothing reads as applied. + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartialStage).toBe("am-started"); + expect(artifact?.projectArtifacts[0]?.appliedAtMs).toBeUndefined(); + + // Cleanly retryable once persistence works again. + const retryResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean }; + expect(retryResult.success).toBe(true); + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "extra.txt"), "utf-8")).toBe( + "extra\n" + ); + }, 20_000); + + it("fails a worktree-only apply cleanly when the partial marker cannot be persisted", async () => { + const harness = await setupSingleRepoHarness(); + + // Worktree-only artifact: no commits, dirty child. + await fsPromises.writeFile(path.join(harness.childRepo, "wt.txt"), "dirty\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + // The marker is persisted BEFORE the irreversible apply, so a marker + // write failure must fail cleanly with nothing applied (writing it only + // after a failure could itself fail and leave no record of a partially + // applied patch). + await fsPromises.chmod(harness.sessionDir, 0o555); + let result: { + success: boolean; + projectResults: Array<{ status: string; error?: string; note?: string }>; + }; + try { + result = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as typeof result; + } finally { + await fsPromises.chmod(harness.sessionDir, 0o755); + } + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.error).toContain( + "Failed to persist the partial-application marker" + ); + expect(result.projectResults[0]?.note).toContain("Nothing was applied"); + // The apply never ran: the target holds none of the patch content. + expect( + await fsPromises + .access(path.join(harness.targetRepo, "wt.txt")) + .then(() => true) + .catch(() => false) + ).toBe(false); + + // Cleanly retryable once persistence works again. + const retryResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean }; + expect(retryResult.success).toBe(true); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "wt.txt"), "utf-8")).toBe( + "dirty\n" + ); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedAtMs).toBeDefined(); + }, 20_000); + + it("fails the apply when the completion record cannot be persisted", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile(path.join(harness.childRepo, "extra.txt"), "extra\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + // Fail only the final completion write (the partial stamps carry + // partial: true): the failure window is "everything applied, completion + // record unwritable". Success here would leave durable state saying + // commits-applied while the caller checkpoints past the apply. + const realMarkApplied = subagentGitPatchArtifactsModule.markSubagentGitPatchArtifactApplied; + const markAppliedSpy = spyOn( + subagentGitPatchArtifactsModule, + "markSubagentGitPatchArtifactApplied" + ).mockImplementation((markParams) => { + if (markParams.partial !== true) { + return Promise.reject(new Error("simulated completion persistence failure")); + } + return realMarkApplied(markParams); + }); + let result: { + success: boolean; + projectResults: Array<{ status: string; error?: string; note?: string }>; + }; + try { + result = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as typeof result; + } finally { + markAppliedSpy.mockRestore(); + } + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.error).toContain("recording the completion failed"); + expect(result.projectResults[0]?.note).toContain("Do NOT re-apply"); + // The work stayed applied; only the durable record is stale. + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("child commit"); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "extra.txt"), "utf-8")).toBe( + "extra\n" + ); + // The surviving partial marker keeps the state from reading as fully + // applied (the commits-applied stamp carries appliedAtMs by design). + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBe(true); + expect(artifact?.projectArtifacts[0]?.appliedPartialStage).toBe("commits-applied"); + + // The guidance holds: once persistence works again, the retry + // reconciles the surviving marker and completes without replaying the + // commit series. + const retryResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean }; + expect(retryResult.success).toBe(true); + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedAtMs).toBeDefined(); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + }, 20_000); + + it("fails a worktree-only apply when the completion record cannot be persisted", async () => { + const harness = await setupSingleRepoHarness(); + + await fsPromises.writeFile(path.join(harness.childRepo, "wt.txt"), "dirty\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + const realMarkApplied = subagentGitPatchArtifactsModule.markSubagentGitPatchArtifactApplied; + const markAppliedSpy = spyOn( + subagentGitPatchArtifactsModule, + "markSubagentGitPatchArtifactApplied" + ).mockImplementation((markParams) => { + if (markParams.partial !== true) { + return Promise.reject(new Error("simulated completion persistence failure")); + } + return realMarkApplied(markParams); + }); + let result: { + success: boolean; + projectResults: Array<{ status: string; error?: string; note?: string }>; + }; + try { + result = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as typeof result; + } finally { + markAppliedSpy.mockRestore(); + } + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.error).toContain("recording the completion failed"); + expect(result.projectResults[0]?.note).toContain("Do NOT re-apply"); + // The changes stayed applied; the pre-apply partial marker survives so + // the retry can tell the finished apply from an unrecovered partial. + expect(await fsPromises.readFile(path.join(harness.targetRepo, "wt.txt"), "utf-8")).toBe( + "dirty\n" + ); + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBe(true); + + // The retry detects the already-present changes and completes the record. + const retryResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean; projectResults: Array<{ note?: string }> }; + expect(retryResult.success).toBe(true); + expect(retryResult.projectResults[0]?.note).toContain("already present in the worktree"); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedAtMs).toBeDefined(); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + }, 20_000); + + it("records a durable in-progress marker before git am runs", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + }), + ], + }); + + // Simulate a crash at the git am boundary: the runtime rejects the am + // command itself, so nothing after it (including any marker write) runs. + const realRuntime = createRuntime({ type: "local", srcBaseDir: "/tmp" }); + const failingRuntime: typeof realRuntime = Object.create(realRuntime, { + exec: { + value: (command: string, options: Parameters[1]) => { + if (command.includes("git am ")) { + return Promise.reject(new Error("simulated crash at git am")); + } + return realRuntime.exec(command, options); + }, + }, + }) as typeof realRuntime; + + const deps = { + ...getTestDeps(), + workspaceId: harness.currentWorkspaceId, + cwd: harness.targetRepo, + runtimeTempDir: "/tmp", + workspaceSessionDir: harness.sessionDir, + }; + let thrownMessage = ""; + try { + await applyTaskGitPatchArtifact( + { ...deps, runtime: failingRuntime }, + { task_id: harness.childTaskId, three_way: true }, + {} + ); + } catch (error) { + thrownMessage = error instanceof Error ? error.message : String(error); + } + expect(thrownMessage).toContain("simulated crash at git am"); + + // The in-progress marker was persisted BEFORE git am, so even a hard + // crash there leaves a durable record; it does not read as applied. + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartialStage).toBe("am-started"); + expect(artifact?.projectArtifacts[0]?.appliedAtMs).toBeUndefined(); + + // HEAD never moved, so the retry reconciles and applies fresh. + const retryResult = await applyTaskGitPatchArtifact( + { ...deps, runtime: realRuntime }, + { task_id: harness.childTaskId, three_way: true }, + {} + ); + expect(retryResult.success).toBe(true); + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartialStage).toBeUndefined(); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedAtMs).toBeDefined(); + }, 20_000); + + it("fails closed when an interrupted apply may have advanced HEAD", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + const preAmHead = execSync("git rev-parse HEAD", { + cwd: harness.targetRepo, + encoding: "utf-8", + }).trim(); + + const projectArtifact = await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + }); + // Simulate the post-crash state: the interrupted attempt's git am landed + // the series, but only the pre-am in-progress marker survives. + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...projectArtifact, + appliedPartial: true, + appliedPartialStage: "am-started", + appliedPartialHeadSha: preAmHead, + }, + ], + }); + const mboxPath = getSubagentGitPatchMboxPath( + harness.sessionDir, + harness.childTaskId, + "project" + ); + execSync(`git am ${JSON.stringify(mboxPath)}`, { cwd: harness.targetRepo, stdio: "ignore" }); + + const tool = createApplyTool(harness); + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string; error?: string }>; + }; + // HEAD advanced past the recorded fence: replaying the mbox could + // duplicate the series, so the retry must fail closed. + expect(result.success).toBe(false); + expect(result.projectResults[0]?.error).toContain("interrupted"); + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + + // The user verifies the series landed and acknowledges. + const ackResult = (await tool.execute!( + { task_id: harness.childTaskId, acknowledge_partial_recovery: true }, + mockToolCallOptions + )) as { success: boolean }; + expect(ackResult.success).toBe(true); + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + expect(artifact?.projectArtifacts[0]?.appliedPartialStage).toBeUndefined(); + }, 20_000); + + it("fails closed when a recorded partial stage is unreadable", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + + const projectArtifact = await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + }); + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...projectArtifact, + appliedPartial: true, + appliedPartialStage: "am-started", + }, + ], + }); + // Corrupt the recorded stage on disk: the sanitizer must degrade it to + // "unknown" (fail closed) rather than dropping it, which would read as + // legacy commits-applied and skip git am for a series that never ran. + const artifactsFilePath = path.join(harness.sessionDir, "subagent-patches.json"); + const rawArtifacts = await fsPromises.readFile(artifactsFilePath, "utf-8"); + await fsPromises.writeFile( + artifactsFilePath, + rawArtifacts.replace('"am-started"', '"mid-flight"'), + "utf-8" + ); + + const tool = createApplyTool(harness); + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string; error?: string }>; + }; + expect(result.success).toBe(false); + expect(result.projectResults[0]?.error).toContain("stage is unreadable"); + // Nothing may be applied or cleared while the marker's meaning is unknown. + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }).trim() + ).toBe(""); + + const ackResult = (await tool.execute!( + { task_id: harness.childTaskId, acknowledge_partial_recovery: true }, + mockToolCallOptions + )) as { success: boolean }; + expect(ackResult.success).toBe(true); + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + expect(artifact?.projectArtifacts[0]?.appliedPartialStage).toBeUndefined(); + }, 20_000); + + it("enforces expected_head_sha when completing a commit-free partial application", async () => { + const harness = await setupSingleRepoHarness(); + + // Worktree-only artifact: no commits, dirty child. + await fsPromises.writeFile(path.join(harness.childRepo, "wt.txt"), "dirty\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + const originalHead = execSync("git rev-parse HEAD", { + cwd: harness.targetRepo, + encoding: "utf-8", + }).trim(); + + // First apply fails after the marker was recorded (corrupt patch file). + const realPatchBytes = await fsPromises.readFile(worktreeFields.worktreePatchPath); + await fsPromises.writeFile(worktreeFields.worktreePatchPath, "not a patch\n", "utf-8"); + const tool = createApplyTool(harness); + const firstResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean }; + expect(firstResult.success).toBe(false); + await fsPromises.writeFile(worktreeFields.worktreePatchPath, realPatchBytes); + + // HEAD advances before the retry; the caller still pins the original. + await commitFile(harness.targetRepo, "unrelated.txt", "unrelated\n", "target moved on"); + + // The earlier attempt never moved HEAD (no commit series), so the exact + // expected_head_sha check must still reject the advanced target. + const retryResult = (await tool.execute!( + { task_id: harness.childTaskId, expected_head_sha: originalHead }, + mockToolCallOptions + )) as { + success: boolean; + projectResults: Array<{ status: string; error?: string }>; + }; + expect(retryResult.success).toBe(false); + expect(retryResult.projectResults[0]?.error).toContain("HEAD"); + expect( + await fsPromises + .access(path.join(harness.targetRepo, "wt.txt")) + .then(() => true) + .catch(() => false) + ).toBe(false); + + // Without the pin the fence (an ancestor) still allows completion. + const completeResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean }; + expect(completeResult.success).toBe(true); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "wt.txt"), "utf-8")).toBe( + "dirty\n" + ); + }, 20_000); + + it("honors a partial marker without a timestamp instead of re-running git am", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile(path.join(harness.childRepo, "extra.txt"), "extra\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const realRuntime = createRuntime({ type: "local", srcBaseDir: "/tmp" }); + const failingRuntime: typeof realRuntime = Object.create(realRuntime, { + exec: { + value: (command: string, options: Parameters[1]) => { + if (command.includes("git apply --3way --binary")) { + return Promise.reject(new Error("simulated runtime exec failure")); + } + return realRuntime.exec(command, options); + }, + }, + }) as typeof realRuntime; + + const deps = { + ...getTestDeps(), + workspaceId: harness.currentWorkspaceId, + cwd: harness.targetRepo, + runtimeTempDir: "/tmp", + workspaceSessionDir: harness.sessionDir, + }; + await applyTaskGitPatchArtifact( + { ...deps, runtime: failingRuntime }, + { task_id: harness.childTaskId, three_way: true }, + {} + ).catch(() => undefined); + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBe(true); + + // Both applied fields are optional in the persisted schema, so a marker + // can legally exist without its timestamp; the boolean alone must still + // route the retry to worktree-only completion. + const artifactsFilePath = getSubagentGitPatchArtifactsFilePath(harness.sessionDir); + const rawFile = JSON.parse(await fsPromises.readFile(artifactsFilePath, "utf-8")) as { + artifactsByChildTaskId: Record> }>; + }; + delete rawFile.artifactsByChildTaskId[harness.childTaskId]?.projectArtifacts[0]?.appliedAtMs; + await fsPromises.writeFile(artifactsFilePath, JSON.stringify(rawFile), "utf-8"); + + const retryResult = await applyTaskGitPatchArtifact( + { ...deps, runtime: realRuntime }, + { task_id: harness.childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(retryResult.success).toBe(true); + expect(retryResult.note).toContain("Completed the earlier partial application"); + // The already-applied commit series was not replayed. + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + expect(await fsPromises.readFile(path.join(harness.targetRepo, "extra.txt"), "utf-8")).toBe( + "extra\n" + ); + const artifactAfterRetry = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterRetry?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + }, 20_000); + + it("refuses to complete a partial application after the target was reset past the recorded HEAD", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + // A benign (non-conflicting) worktree change: after a reset it would + // apply cleanly, which is exactly the false-success scenario the fence + // must prevent. + await fsPromises.writeFile(path.join(harness.childRepo, "extra.txt"), "extra\n", "utf-8"); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + const preApplyHead = execSync("git rev-parse HEAD", { + cwd: harness.targetRepo, + encoding: "utf-8", + }).trim(); + + // The commit series lands, then a simulated runtime failure rejects the + // worktree apply, leaving a partial marker with the post-am HEAD. + const realRuntime = createRuntime({ type: "local", srcBaseDir: "/tmp" }); + const failingRuntime: typeof realRuntime = Object.create(realRuntime, { + exec: { + value: (command: string, options: Parameters[1]) => { + if (command.includes("git apply --3way --binary")) { + return Promise.reject(new Error("simulated runtime exec failure")); + } + return realRuntime.exec(command, options); + }, + }, + }) as typeof realRuntime; + const deps = { + ...getTestDeps(), + workspaceId: harness.currentWorkspaceId, + cwd: harness.targetRepo, + runtimeTempDir: "/tmp", + workspaceSessionDir: harness.sessionDir, + }; + let thrownMessage = ""; + try { + await applyTaskGitPatchArtifact( + { ...deps, runtime: failingRuntime }, + { task_id: harness.childTaskId, three_way: true }, + {} + ); + } catch (error) { + thrownMessage = error instanceof Error ? error.message : String(error); + } + expect(thrownMessage).toContain("simulated runtime exec failure"); + + // Reset wipes the applied commit series while the marker persists. + execSync(`git reset --hard ${preApplyHead}`, { cwd: harness.targetRepo, stdio: "ignore" }); + + // Without the ancestry fence the retry would apply just extra.txt, + // clear the marker, and report success while "child commit" is missing. + const retryResult = await applyTaskGitPatchArtifact( + { ...deps, runtime: realRuntime }, + { task_id: harness.childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(retryResult.success).toBe(false); + if (!retryResult.success) { + expect(retryResult.error).toContain("no longer contains the HEAD"); + } + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBe(true); + expect( + execSync("git log --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }) + ).not.toContain("child commit"); + }, 20_000); + + it("clears partial state via acknowledge_partial_recovery after a merged manual resolution", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child version\n", + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + await commitFile(harness.targetRepo, "README.md", "target version\n", "conflicting change"); + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + const firstResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean }; + expect(firstResult.success).toBe(false); + + // A merged resolution (neither parent nor child content) is not + // patch-reversible, so the automatic completion cannot recognize it. + await fsPromises.writeFile( + path.join(harness.targetRepo, "README.md"), + "merged: target + child\n", + "utf-8" + ); + const blockedRetry = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean }; + expect(blockedRetry.success).toBe(false); + + // The explicit acknowledgement clears the marker without applying. + const ackResult = (await tool.execute!( + { task_id: harness.childTaskId, acknowledge_partial_recovery: true }, + mockToolCallOptions + )) as { success: boolean; projectResults: Array<{ status: string; note?: string }> }; + expect(ackResult.success).toBe(true); + expect(ackResult.projectResults[0]?.note).toContain("Acknowledged manual recovery"); + // The merged resolution was left untouched and the commit series intact. + expect(await fsPromises.readFile(path.join(harness.targetRepo, "README.md"), "utf-8")).toBe( + "merged: target + child\n" + ); + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + expect(artifact?.projectArtifacts[0]?.appliedAtMs).toBeDefined(); + + // Acknowledging with no partial marker recorded must fail loudly. + const badAck = (await tool.execute!( + { task_id: harness.childTaskId, acknowledge_partial_recovery: true, force: true }, + mockToolCallOptions + )) as { success: boolean; error?: string }; + expect(badAck.success).toBe(false); + expect(badAck.error).toContain("nothing to acknowledge"); + }, 20_000); + + it("acknowledges a later partial project past an already-applied sibling", async () => { + const childRepoA = path.join(rootDir, "child-a"); + const childRepoB = path.join(rootDir, "child-b"); + const targetRepoA = path.join(rootDir, "target-a"); + const targetRepoB = path.join(rootDir, "target-b"); + for (const repo of [childRepoA, childRepoB, targetRepoA, targetRepoB]) { + await fsPromises.mkdir(repo, { recursive: true }); + initGitRepo(repo); + } + await commitFile(childRepoA, "README.md", "hello a", "base a"); + await commitFile(childRepoB, "README.md", "hello b", "base b"); + await commitFile(targetRepoA, "README.md", "hello a", "base a"); + await commitFile(targetRepoB, "README.md", "hello b", "base b"); + const baseShaA = execSync("git rev-parse HEAD", { cwd: childRepoA, encoding: "utf-8" }).trim(); + const baseShaB = execSync("git rev-parse HEAD", { cwd: childRepoB, encoding: "utf-8" }).trim(); + await commitFile(childRepoA, "README.md", "hello a\nchild a", "child a change"); + await commitFile(childRepoB, "README.md", "hello b\nchild b", "child b change"); + const headShaA = execSync("git rev-parse HEAD", { cwd: childRepoA, encoding: "utf-8" }).trim(); + const headShaB = execSync("git rev-parse HEAD", { cwd: childRepoB, encoding: "utf-8" }).trim(); + + const muxRoot = path.join(rootDir, "mux"); + const currentWorkspaceId = "current-workspace"; + const sessionDir = path.join(muxRoot, "sessions", currentWorkspaceId); + await fsPromises.mkdir(sessionDir, { recursive: true }); + await writeWorkspaceConfig({ + muxRoot, + workspaceId: currentWorkspaceId, + workspaceName: "current", + primaryProjectPath: targetRepoA, + projects: [ + { projectPath: targetRepoA, projectName: "project-a" }, + { projectPath: targetRepoB, projectName: "project-b" }, + ], + }); + + // Project A applied fully (no partial marker); project B's worktree + // patch failed, leaving a recorded partial. + const childTaskId = "child-task-ack"; + await writePatchArtifact({ + sessionDir, + workspaceId: currentWorkspaceId, + childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir, + childTaskId, + storageKey: "project-a", + projectPath: targetRepoA, + projectName: "project-a", + childRepo: childRepoA, + baseSha: baseShaA, + headSha: headShaA, + })), + appliedAtMs: Date.now(), + }, + { + ...(await buildReadyProjectArtifact({ + sessionDir, + childTaskId, + storageKey: "project-b", + projectPath: targetRepoB, + projectName: "project-b", + childRepo: childRepoB, + baseSha: baseShaB, + headSha: headShaB, + })), + appliedPartial: true, + }, + ], + }); + + const tool = createTaskApplyGitPatchTool({ + ...getTestDeps(), + workspaceId: currentWorkspaceId, + cwd: targetRepoA, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: sessionDir, + }); + + // The all-project acknowledgement must skip the applied sibling and + // reach project B instead of failing on "nothing to acknowledge". + const result = (await tool.execute!( + { task_id: childTaskId, acknowledge_partial_recovery: true }, + mockToolCallOptions + )) as { success: boolean; projectResults: Array<{ status: string }> }; + expect(result.success).toBe(true); + expect(result.projectResults.map((projectResult) => projectResult.status)).toEqual([ + "skipped", + "applied", + ]); + + const artifact = await readSubagentGitPatchArtifact(sessionDir, childTaskId); + expect(artifact?.projectArtifacts[1]?.appliedPartial).toBeUndefined(); + expect(artifact?.projectArtifacts[1]?.appliedAtMs).toBeDefined(); + }, 20_000); + + it("applies an untouched project during an acknowledgment sweep", async () => { + const childRepoA = path.join(rootDir, "sweep-child-a"); + const childRepoB = path.join(rootDir, "sweep-child-b"); + const targetRepoA = path.join(rootDir, "sweep-target-a"); + const targetRepoB = path.join(rootDir, "sweep-target-b"); + for (const repo of [childRepoA, childRepoB, targetRepoA, targetRepoB]) { + await fsPromises.mkdir(repo, { recursive: true }); + initGitRepo(repo); + } + await commitFile(childRepoA, "README.md", "hello a", "base a"); + await commitFile(childRepoB, "README.md", "hello b", "base b"); + await commitFile(targetRepoA, "README.md", "hello a", "base a"); + await commitFile(targetRepoB, "README.md", "hello b", "base b"); + const baseShaA = execSync("git rev-parse HEAD", { cwd: childRepoA, encoding: "utf-8" }).trim(); + const baseShaB = execSync("git rev-parse HEAD", { cwd: childRepoB, encoding: "utf-8" }).trim(); + await commitFile(childRepoA, "README.md", "hello a\nchild a", "child a change"); + await commitFile(childRepoB, "README.md", "hello b\nchild b", "child b change"); + const headShaA = execSync("git rev-parse HEAD", { cwd: childRepoA, encoding: "utf-8" }).trim(); + const headShaB = execSync("git rev-parse HEAD", { cwd: childRepoB, encoding: "utf-8" }).trim(); + + const muxRoot = path.join(rootDir, "sweep-mux"); + const currentWorkspaceId = "current-workspace"; + const sessionDir = path.join(muxRoot, "sessions", currentWorkspaceId); + await fsPromises.mkdir(sessionDir, { recursive: true }); + await writeWorkspaceConfig({ + muxRoot, + workspaceId: currentWorkspaceId, + workspaceName: "current", + primaryProjectPath: targetRepoA, + projects: [ + { projectPath: targetRepoA, projectName: "project-a" }, + { projectPath: targetRepoB, projectName: "project-b" }, + ], + }); + + // Project A's earlier attempt left a recorded partial, which stopped the + // loop before project B was ever attempted. + const childTaskId = "child-task-sweep"; + await writePatchArtifact({ + sessionDir, + workspaceId: currentWorkspaceId, + childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir, + childTaskId, + storageKey: "project-a", + projectPath: targetRepoA, + projectName: "project-a", + childRepo: childRepoA, + baseSha: baseShaA, + headSha: headShaA, + })), + appliedPartial: true, + }, + await buildReadyProjectArtifact({ + sessionDir, + childTaskId, + storageKey: "project-b", + projectPath: targetRepoB, + projectName: "project-b", + childRepo: childRepoB, + baseSha: baseShaB, + headSha: headShaB, + }), + ], + }); + + const tool = createTaskApplyGitPatchTool({ + ...getTestDeps(), + workspaceId: currentWorkspaceId, + cwd: targetRepoA, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: sessionDir, + }); + + // The sweep must acknowledge A and still APPLY the untouched B: skipping + // B would report success while its entire artifact remains unapplied. + const result = (await tool.execute!( + { task_id: childTaskId, acknowledge_partial_recovery: true }, + mockToolCallOptions + )) as { success: boolean; projectResults: Array<{ status: string; note?: string }> }; + expect(result.success).toBe(true); + expect(result.projectResults.map((projectResult) => projectResult.status)).toEqual([ + "applied", + "applied", + ]); + expect(result.projectResults[0]?.note).toContain("Acknowledged manual recovery"); + expect( + execSync('git log --pretty=%s --grep "child b change"', { + cwd: targetRepoB, + encoding: "utf-8", + }).trim() + ).toBe("child b change"); + + const artifact = await readSubagentGitPatchArtifact(sessionDir, childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + expect(artifact?.projectArtifacts[1]?.appliedAtMs).toBeDefined(); + }, 20_000); + + it("acknowledges manual recovery during a partial-completion dry run", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child version\n", + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + await commitFile(harness.targetRepo, "README.md", "target version\n", "conflicting change"); + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + const firstResult = (await tool.execute!( + { task_id: harness.childTaskId }, + mockToolCallOptions + )) as { success: boolean }; + expect(firstResult.success).toBe(false); + + // Manual recovery without committing: the target now holds the child's + // content as uncommitted changes. + await fsPromises.writeFile( + path.join(harness.targetRepo, "README.md"), + "child version\n", + "utf-8" + ); + + // A workflow retry dry-runs first; the reverse check must acknowledge + // the recovery instead of rejecting the recovered paths as overlap, and + // a dry run must not clear the marker. + const dryRunResult = (await tool.execute!( + { task_id: harness.childTaskId, dry_run: true, three_way: true }, + mockToolCallOptions + )) as { success: boolean; projectResults: Array<{ status: string; note?: string }> }; + expect(dryRunResult.success).toBe(true); + expect(dryRunResult.projectResults[0]?.note).toContain("already present in the worktree"); + const artifactAfterDryRun = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterDryRun?.projectArtifacts[0]?.appliedPartial).toBe(true); + + // The real run then clears the marker. + const realResult = (await tool.execute!( + { task_id: harness.childTaskId, three_way: true }, + mockToolCallOptions + )) as { success: boolean }; + expect(realResult.success).toBe(true); + const artifactAfterReal = await readSubagentGitPatchArtifact( + harness.sessionDir, + harness.childTaskId + ); + expect(artifactAfterReal?.projectArtifacts[0]?.appliedPartial).toBeUndefined(); + }, 20_000); + + it("completes a fenced partial retry even though git am advanced HEAD past the fence", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child version\n", + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + await commitFile(harness.targetRepo, "README.md", "target version\n", "conflicting change"); + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + // Fence supplied by replay-safe workflow integrations: the pre-apply HEAD. + const preApplyHead = execSync("git rev-parse HEAD", { + cwd: harness.targetRepo, + encoding: "utf-8", + }).trim(); + + const tool = createApplyTool(harness); + const firstResult = (await tool.execute!( + { task_id: harness.childTaskId, three_way: true, expected_head_sha: preApplyHead }, + mockToolCallOptions + )) as { success: boolean }; + expect(firstResult.success).toBe(false); + + await fsPromises.writeFile( + path.join(harness.targetRepo, "README.md"), + "child version\n", + "utf-8" + ); + + // git am advanced HEAD past the fence; the durable partial marker proves + // the commit series landed, so the retry must not fail on the stale fence. + const retryResult = (await tool.execute!( + { task_id: harness.childTaskId, three_way: true, expected_head_sha: preApplyHead }, + mockToolCallOptions + )) as { success: boolean; projectResults: Array<{ status: string; note?: string }> }; + expect(retryResult.success).toBe(true); + expect(retryResult.projectResults[0]?.note).toContain("already present in the worktree"); + }, 20_000); + + it("records target-local partial state when a replayed ancestor artifact partially applies", async () => { + const childRepo = path.join(rootDir, "child-replay-partial"); + const targetRepo = path.join(rootDir, "target-replay-partial"); + for (const repo of [childRepo, targetRepo]) { + await fsPromises.mkdir(repo, { recursive: true }); + initGitRepo(repo); + } + await commitFile(childRepo, "README.md", "hello", "base"); + await commitFile(targetRepo, "README.md", "hello", "base"); + const baseSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + await commitFile(childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + await fsPromises.writeFile(path.join(childRepo, "README.md"), "child version\n", "utf-8"); + + const childTaskId = "child-task-replay-partial"; + const muxRoot = path.join(rootDir, "mux-replay-partial"); + const ancestorWorkspaceId = "ancestor-replay-partial"; + const currentWorkspaceId = "current-replay-partial"; + const ancestorSessionDir = path.join(muxRoot, "sessions", ancestorWorkspaceId); + const currentSessionDir = path.join(muxRoot, "sessions", currentWorkspaceId); + await fsPromises.mkdir(ancestorSessionDir, { recursive: true }); + await fsPromises.mkdir(currentSessionDir, { recursive: true }); + + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: ancestorSessionDir, + childTaskId, + storageKey: "target", + childRepo, + }); + await writePatchArtifact({ + sessionDir: ancestorSessionDir, + workspaceId: ancestorWorkspaceId, + childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: ancestorSessionDir, + childTaskId, + storageKey: "target", + projectPath: targetRepo, + projectName: "target", + childRepo, + baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + await fsPromises.writeFile( + path.join(muxRoot, "config.json"), + JSON.stringify({ + projects: [ + [ + targetRepo, + { + workspaces: [ + { + path: targetRepo, + id: ancestorWorkspaceId, + name: "ancestor", + runtimeConfig: { type: "local" }, + }, + { + path: targetRepo, + id: currentWorkspaceId, + name: "current", + runtimeConfig: { type: "local" }, + parentWorkspaceId: ancestorWorkspaceId, + }, + ], + }, + ], + ], + }), + "utf-8" + ); + + // Conflicting COMMIT keeps the target tree clean, so the dirty preflight + // passes, git am succeeds, and only the worktree patch fails. + await commitFile(targetRepo, "README.md", "target version\n", "conflicting change"); + + const deps = { + ...getTestDeps(), + workspaceId: currentWorkspaceId, + cwd: targetRepo, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: currentSessionDir, + }; + const result = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + {} + ); + expect(result.success).toBe(false); + // The commit series landed on the target. + expect(execSync("git log -1 --pretty=%s", { cwd: targetRepo, encoding: "utf-8" }).trim()).toBe( + "child commit" + ); + // The shared ancestor artifact stays untouched for other replay targets. + const artifact = await readSubagentGitPatchArtifact(ancestorSessionDir, childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedAtMs).toBeUndefined(); + + // The target-local marker routes the retry to a worktree-only + // completion (never re-running git am); the conflicting README makes + // that completion fail here. + const retryResult = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(retryResult.success).toBe(false); + expect(retryResult.note).toContain("Completing the earlier partial application was blocked"); + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + // No leftover git am recovery state from the retry. + expect( + await fsPromises + .access(path.join(targetRepo, ".git", "rebase-apply")) + .then(() => true) + .catch(() => false) + ).toBe(false); + + // Manual recovery: the user puts the child's uncommitted content in + // place. The next retry detects it as already present and clears the + // target-local marker without touching the shared ancestor artifact. + await fsPromises.writeFile(path.join(targetRepo, "README.md"), "child version\n", "utf-8"); + const recoveredResult = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(recoveredResult.success).toBe(true); + expect(recoveredResult.note).toContain("already present in the worktree"); + expect( + await readLocalPatchPartialApply({ + workspaceSessionDir: currentSessionDir, + childTaskId, + projectPath: targetRepo, + }) + ).toBeNull(); + const ancestorArtifact = await readSubagentGitPatchArtifact(ancestorSessionDir, childTaskId); + expect(ancestorArtifact?.projectArtifacts[0]?.appliedAtMs).toBeUndefined(); + }, 20_000); + + it("clears the pre-apply marker and records completion after a fresh worktree-only replay", async () => { + const childRepo = path.join(rootDir, "child-replay-wt"); + const targetRepo = path.join(rootDir, "target-replay-wt"); + for (const repo of [childRepo, targetRepo]) { + await fsPromises.mkdir(repo, { recursive: true }); + initGitRepo(repo); + } + await commitFile(childRepo, "README.md", "hello", "base"); + await commitFile(targetRepo, "README.md", "hello", "base"); + const baseSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + await fsPromises.writeFile(path.join(childRepo, "README.md"), "child version\n", "utf-8"); + + const childTaskId = "child-task-replay-wt"; + const muxRoot = path.join(rootDir, "mux-replay-wt"); + const ancestorWorkspaceId = "ancestor-replay-wt"; + const currentWorkspaceId = "current-replay-wt"; + const ancestorSessionDir = path.join(muxRoot, "sessions", ancestorWorkspaceId); + const currentSessionDir = path.join(muxRoot, "sessions", currentWorkspaceId); + await fsPromises.mkdir(ancestorSessionDir, { recursive: true }); + await fsPromises.mkdir(currentSessionDir, { recursive: true }); + + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: ancestorSessionDir, + childTaskId, + storageKey: "target", + childRepo, + }); + await writePatchArtifact({ + sessionDir: ancestorSessionDir, + workspaceId: ancestorWorkspaceId, + childTaskId, + projectArtifacts: [ + { + projectPath: targetRepo, + projectName: "target", + storageKey: "target", + status: "ready", + commitCount: 0, + baseCommitSha: baseSha, + headCommitSha: baseSha, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + await fsPromises.writeFile( + path.join(muxRoot, "config.json"), + JSON.stringify({ + projects: [ + [ + targetRepo, + { + workspaces: [ + { + path: targetRepo, + id: ancestorWorkspaceId, + name: "ancestor", + runtimeConfig: { type: "local" }, + }, + { + path: targetRepo, + id: currentWorkspaceId, + name: "current", + runtimeConfig: { type: "local" }, + parentWorkspaceId: ancestorWorkspaceId, + }, + ], + }, + ], + ], + }), + "utf-8" + ); + + const deps = { + ...getTestDeps(), + workspaceId: currentWorkspaceId, + cwd: targetRepo, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: currentSessionDir, + }; + const result = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + {} + ); + expect(result.success).toBe(true); + expect(await fsPromises.readFile(path.join(targetRepo, "README.md"), "utf-8")).toBe( + "child version\n" + ); + + // Success must not leave the pre-apply marker behind: a later retry + // would treat the finished apply as an unrecovered partial. + expect( + await readLocalPatchPartialApply({ + workspaceSessionDir: currentSessionDir, + childTaskId, + projectPath: targetRepo, + }) + ).toBeNull(); + const completion = await readLocalPatchApplyCompletion({ + workspaceSessionDir: currentSessionDir, + childTaskId, + projectPath: targetRepo, + }); + expect(completion?.appliedAtMs).toBeGreaterThan(0); + + // The applied file may be edited afterwards; a workflow retry must read + // the completion record and report already-applied instead of routing + // to partial recovery against the edited content. + await fsPromises.writeFile(path.join(targetRepo, "README.md"), "user edited\n", "utf-8"); + const retryResult = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(retryResult.success).toBe(true); + expect(retryResult.note).toContain("already applied"); + expect(await fsPromises.readFile(path.join(targetRepo, "README.md"), "utf-8")).toBe( + "user edited\n" + ); + }, 20_000); + + it("skips a replay-completed sibling during an acknowledgment sweep", async () => { + const childRepoA = path.join(rootDir, "replay-sweep-child-a"); + const childRepoB = path.join(rootDir, "replay-sweep-child-b"); + const targetRepoA = path.join(rootDir, "replay-sweep-target-a"); + const targetRepoB = path.join(rootDir, "replay-sweep-target-b"); + for (const repo of [childRepoA, childRepoB, targetRepoA, targetRepoB]) { + await fsPromises.mkdir(repo, { recursive: true }); + initGitRepo(repo); + } + await commitFile(childRepoA, "README.md", "hello a", "base a"); + await commitFile(childRepoB, "README.md", "hello b", "base b"); + await commitFile(targetRepoA, "README.md", "hello a", "base a"); + await commitFile(targetRepoB, "README.md", "hello b", "base b"); + const baseShaA = execSync("git rev-parse HEAD", { cwd: childRepoA, encoding: "utf-8" }).trim(); + const baseShaB = execSync("git rev-parse HEAD", { cwd: childRepoB, encoding: "utf-8" }).trim(); + await commitFile(childRepoA, "feature-a.txt", "feature a\n", "child a change"); + await commitFile(childRepoB, "feature-b.txt", "feature b\n", "child b change"); + const headShaA = execSync("git rev-parse HEAD", { cwd: childRepoA, encoding: "utf-8" }).trim(); + const headShaB = execSync("git rev-parse HEAD", { cwd: childRepoB, encoding: "utf-8" }).trim(); + // Only child B ends dirty; its conflicting target commit later fails the + // worktree patch after B's commit series lands. + await fsPromises.writeFile(path.join(childRepoB, "README.md"), "child version\n", "utf-8"); + + const childTaskId = "child-task-replay-sweep"; + const muxRoot = path.join(rootDir, "mux-replay-sweep"); + const ancestorWorkspaceId = "ancestor-replay-sweep"; + const currentWorkspaceId = "current-replay-sweep"; + const ancestorSessionDir = path.join(muxRoot, "sessions", ancestorWorkspaceId); + const currentSessionDir = path.join(muxRoot, "sessions", currentWorkspaceId); + await fsPromises.mkdir(ancestorSessionDir, { recursive: true }); + await fsPromises.mkdir(currentSessionDir, { recursive: true }); + + const worktreeFieldsB = await writeWorktreePatchFile({ + sessionDir: ancestorSessionDir, + childTaskId, + storageKey: "target-b", + childRepo: childRepoB, + }); + await writePatchArtifact({ + sessionDir: ancestorSessionDir, + workspaceId: ancestorWorkspaceId, + childTaskId, + projectArtifacts: [ + await buildReadyProjectArtifact({ + sessionDir: ancestorSessionDir, + childTaskId, + storageKey: "target-a", + projectPath: targetRepoA, + projectName: "target-a", + childRepo: childRepoA, + baseSha: baseShaA, + headSha: headShaA, + }), + { + ...(await buildReadyProjectArtifact({ + sessionDir: ancestorSessionDir, + childTaskId, + storageKey: "target-b", + projectPath: targetRepoB, + projectName: "target-b", + childRepo: childRepoB, + baseSha: baseShaB, + headSha: headShaB, + })), + hadUncommittedChanges: true, + ...worktreeFieldsB, + }, + ], + }); + const workspaceProjects = [ + { projectPath: targetRepoA, projectName: "target-a" }, + { projectPath: targetRepoB, projectName: "target-b" }, + ]; + await fsPromises.writeFile( + path.join(muxRoot, "config.json"), + JSON.stringify({ + projects: [ + [ + targetRepoA, + { + workspaces: [ + { + path: targetRepoA, + id: ancestorWorkspaceId, + name: "ancestor", + runtimeConfig: { type: "local" }, + projects: workspaceProjects, + }, + { + path: targetRepoA, + id: currentWorkspaceId, + name: "current", + runtimeConfig: { type: "local" }, + parentWorkspaceId: ancestorWorkspaceId, + projects: workspaceProjects, + }, + ], + }, + ], + ], + }), + "utf-8" + ); + + // Conflicting COMMIT in target B keeps its tree clean (dirty preflight + // passes), so B's git am succeeds and only its worktree patch fails. + await commitFile(targetRepoB, "README.md", "target version\n", "conflicting change"); + + const deps = { + ...getTestDeps(), + workspaceId: currentWorkspaceId, + cwd: targetRepoA, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: currentSessionDir, + }; + // First replay pass: A applies fully, B ends partial (commits landed, + // worktree patch failed). + const firstResult = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + {} + ); + expect(firstResult.success).toBe(false); + expect(execSync("git log -1 --pretty=%s", { cwd: targetRepoA, encoding: "utf-8" }).trim()).toBe( + "child a change" + ); + + // The sweep must recognize A as applied via its target-local completion + // record (the shared ancestor artifact never records replay applies) and + // acknowledge only B; re-attempting A would fail or duplicate its series. + const ackResult = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true, acknowledge_partial_recovery: true }, + {} + ); + expect(ackResult.success).toBe(true); + expect(ackResult.projectResults?.map((projectResult) => projectResult.status)).toEqual([ + "skipped", + "applied", + ]); + expect( + execSync('git log --pretty=%s --grep "child a change"', { + cwd: targetRepoA, + encoding: "utf-8", + }) + .trim() + .split("\n") + ).toHaveLength(1); + expect( + await readLocalPatchPartialApply({ + workspaceSessionDir: currentSessionDir, + childTaskId, + projectPath: targetRepoB, + }) + ).toBeNull(); + // The shared ancestor artifact stays untouched for other replay targets. + const ancestorArtifact = await readSubagentGitPatchArtifact(ancestorSessionDir, childTaskId); + expect(ancestorArtifact?.projectArtifacts[0]?.appliedAtMs).toBeUndefined(); + expect(ancestorArtifact?.projectArtifacts[1]?.appliedAtMs).toBeUndefined(); + }, 20_000); + + it("honors replay completion records on ordinary retries and fails closed when unreadable", async () => { + const childRepo = path.join(rootDir, "child-replay-retry"); + const targetRepo = path.join(rootDir, "target-replay-retry"); + for (const repo of [childRepo, targetRepo]) { + await fsPromises.mkdir(repo, { recursive: true }); + initGitRepo(repo); + } + await commitFile(childRepo, "README.md", "hello", "base"); + await commitFile(targetRepo, "README.md", "hello", "base"); + const baseSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + await commitFile(childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + + const childTaskId = "child-task-replay-retry"; + const muxRoot = path.join(rootDir, "mux-replay-retry"); + const ancestorWorkspaceId = "ancestor-replay-retry"; + const currentWorkspaceId = "current-replay-retry"; + const ancestorSessionDir = path.join(muxRoot, "sessions", ancestorWorkspaceId); + const currentSessionDir = path.join(muxRoot, "sessions", currentWorkspaceId); + await fsPromises.mkdir(ancestorSessionDir, { recursive: true }); + await fsPromises.mkdir(currentSessionDir, { recursive: true }); + + await writePatchArtifact({ + sessionDir: ancestorSessionDir, + workspaceId: ancestorWorkspaceId, + childTaskId, + projectArtifacts: [ + await buildReadyProjectArtifact({ + sessionDir: ancestorSessionDir, + childTaskId, + storageKey: "target", + projectPath: targetRepo, + projectName: "target", + childRepo, + baseSha, + headSha, + }), + ], + }); + await fsPromises.writeFile( + path.join(muxRoot, "config.json"), + JSON.stringify({ + projects: [ + [ + targetRepo, + { + workspaces: [ + { + path: targetRepo, + id: ancestorWorkspaceId, + name: "ancestor", + runtimeConfig: { type: "local" }, + }, + { + path: targetRepo, + id: currentWorkspaceId, + name: "current", + runtimeConfig: { type: "local" }, + parentWorkspaceId: ancestorWorkspaceId, + }, + ], + }, + ], + ], + }), + "utf-8" + ); + + const deps = { + ...getTestDeps(), + workspaceId: currentWorkspaceId, + cwd: targetRepo, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: currentSessionDir, + }; + const appliedCount = () => + execSync('git log --pretty=%s --grep "child commit"', { + cwd: targetRepo, + encoding: "utf-8", + }) + .trim() + .split("\n") + .filter((line) => line.length > 0).length; + + const firstResult = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + {} + ); + expect(firstResult.success).toBe(true); + expect(appliedCount()).toBe(1); + + // A crash-before-checkpoint workflow retry must see the target-local + // completion instead of replaying the mbox. + const workflowRetry = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(workflowRetry.success).toBe(true); + expect(workflowRetry.projectResults?.[0]?.note).toContain("already applied"); + expect(appliedCount()).toBe(1); + + const plainRetry = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + {} + ); + expect(plainRetry.success).toBe(false); + expect(plainRetry.projectResults?.[0]?.error).toContain("already applied"); + expect(appliedCount()).toBe(1); + + // Corrupt the completion record: it must degrade to unknown and fail + // closed instead of proving (or disproving) the earlier application. + const localStatePath = path.join(currentSessionDir, "subagent-patches-local-apply.json"); + const localState = JSON.parse(await fsPromises.readFile(localStatePath, "utf-8")) as { + completionsByChildTaskId: Record>; + }; + localState.completionsByChildTaskId[childTaskId] = { [targetRepo]: {} }; + await fsPromises.writeFile(localStatePath, JSON.stringify(localState), "utf-8"); + + const corruptedRetry = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(corruptedRetry.success).toBe(false); + expect(corruptedRetry.projectResults?.[0]?.error).toContain("unreadable"); + expect(appliedCount()).toBe(1); + + // The user verifies the work landed and acknowledges, restoring a valid + // completion record. + const ackResult = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true, acknowledge_partial_recovery: true }, + {} + ); + expect(ackResult.success).toBe(true); + const afterAckRetry = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(afterAckRetry.success).toBe(true); + expect(afterAckRetry.projectResults?.[0]?.note).toContain("already applied"); + expect(appliedCount()).toBe(1); + }, 20_000); + + it("fails a replay-safe retry when the target was reset after the recorded completion", async () => { + const childRepo = path.join(rootDir, "child-replay-reset"); + const targetRepo = path.join(rootDir, "target-replay-reset"); + for (const repo of [childRepo, targetRepo]) { + await fsPromises.mkdir(repo, { recursive: true }); + initGitRepo(repo); + } + await commitFile(childRepo, "README.md", "hello", "base"); + await commitFile(targetRepo, "README.md", "hello", "base"); + const baseSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + const targetBaseSha = execSync("git rev-parse HEAD", { + cwd: targetRepo, + encoding: "utf-8", + }).trim(); + await commitFile(childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { cwd: childRepo, encoding: "utf-8" }).trim(); + + const childTaskId = "child-task-replay-reset"; + const muxRoot = path.join(rootDir, "mux-replay-reset"); + const ancestorWorkspaceId = "ancestor-replay-reset"; + const currentWorkspaceId = "current-replay-reset"; + const ancestorSessionDir = path.join(muxRoot, "sessions", ancestorWorkspaceId); + const currentSessionDir = path.join(muxRoot, "sessions", currentWorkspaceId); + await fsPromises.mkdir(ancestorSessionDir, { recursive: true }); + await fsPromises.mkdir(currentSessionDir, { recursive: true }); + + await writePatchArtifact({ + sessionDir: ancestorSessionDir, + workspaceId: ancestorWorkspaceId, + childTaskId, + projectArtifacts: [ + await buildReadyProjectArtifact({ + sessionDir: ancestorSessionDir, + childTaskId, + storageKey: "target", + projectPath: targetRepo, + projectName: "target", + childRepo, + baseSha, + headSha, + }), + ], + }); + await fsPromises.writeFile( + path.join(muxRoot, "config.json"), + JSON.stringify({ + projects: [ + [ + targetRepo, + { + workspaces: [ + { + path: targetRepo, + id: ancestorWorkspaceId, + name: "ancestor", + runtimeConfig: { type: "local" }, + }, + { + path: targetRepo, + id: currentWorkspaceId, + name: "current", + runtimeConfig: { type: "local" }, + parentWorkspaceId: ancestorWorkspaceId, + }, + ], + }, + ], + ], + }), + "utf-8" + ); + + const deps = { + ...getTestDeps(), + workspaceId: currentWorkspaceId, + cwd: targetRepo, + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + workspaceSessionDir: currentSessionDir, + }; + + const firstResult = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + {} + ); + expect(firstResult.success).toBe(true); + + // A crash-before-checkpoint retry after the target was reset must not + // let the workflow checkpoint past the now-missing commit series. + execSync(`git reset --hard ${targetBaseSha}`, { cwd: targetRepo, stdio: "ignore" }); + const staleRetry = await applyTaskGitPatchArtifact( + deps, + { task_id: childTaskId, three_way: true }, + { allowAlreadyApplied: true } + ); + expect(staleRetry.success).toBe(false); + expect(staleRetry.projectResults?.[0]?.error).toContain( + "no longer contains the recorded post-apply HEAD" + ); + }, 20_000); + + it("applies nothing when an acknowledgment sweep has nothing to acknowledge", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + }), + ], + }); + + // A mistaken acknowledge flag with no recorded partial anywhere must + // fail before any repository is modified, not after applying projects. + const tool = createApplyTool(harness); + const result = (await tool.execute!( + { task_id: harness.childTaskId, acknowledge_partial_recovery: true }, + mockToolCallOptions + )) as { success: boolean; error?: string }; + expect(result.success).toBe(false); + expect(result.error).toContain("nothing to acknowledge"); + expect( + execSync('git log --pretty=%s --grep "child commit"', { + cwd: harness.targetRepo, + encoding: "utf-8", + }).trim() + ).toBe(""); + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedAtMs).toBeUndefined(); + }, 20_000); + + it("propagates a failure to persist the partial-apply marker", async () => { + // A FILE at the session-dir path makes the state write fail. + const blockedSessionDir = path.join(rootDir, "blocked-session-dir"); + await fsPromises.writeFile(blockedSessionDir, "not a dir", "utf-8"); + + let thrownMessage = ""; + try { + await setLocalPatchPartialApply({ + workspaceId: "ws-propagate", + workspaceSessionDir: blockedSessionDir, + childTaskId: "task-propagate", + projectPath: "/repo", + record: { appliedAtMs: Date.now() }, + }); + } catch (error) { + thrownMessage = error instanceof Error ? error.message : String(error); + } + // Setting the marker must fail loudly: a silently missing marker lets a + // retry replay the already-applied commit series. + expect(thrownMessage).toContain("Could not persist partial-application state"); + + // A completion write must fail loudly too: swallowing it would report + // success while the durable state still says partial. + thrownMessage = ""; + try { + await setLocalPatchPartialApply({ + workspaceId: "ws-propagate", + workspaceSessionDir: blockedSessionDir, + childTaskId: "task-propagate", + projectPath: "/repo", + record: null, + completedAtMs: Date.now(), + }); + } catch (error) { + thrownMessage = error instanceof Error ? error.message : String(error); + } + expect(thrownMessage).toContain("Could not persist partial-application state"); + + // A bare clear stays best-effort: a stale marker is fail-closed. + await setLocalPatchPartialApply({ + workspaceId: "ws-propagate", + workspaceSessionDir: blockedSessionDir, + childTaskId: "task-propagate", + projectPath: "/repo", + record: null, + }); + }); + + it("rejects dirty target paths overlapping the worktree patch before applying anything", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child version\n", + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + // Uncommitted local edit on a path the worktree patch touches. + await fsPromises.writeFile(path.join(harness.targetRepo, "README.md"), "local edit\n", "utf-8"); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + for (const dryRun of [true, false]) { + const result = (await tool.execute!( + { task_id: harness.childTaskId, dry_run: dryRun }, + mockToolCallOptions + )) as { + success: boolean; + projectResults: Array<{ status: string; error?: string; conflictPaths?: string[] }>; + }; + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.conflictPaths).toEqual(["README.md"]); + // Nothing was applied: the failure happened before git am could advance HEAD. + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("base"); + } + const artifact = await readSubagentGitPatchArtifact(harness.sessionDir, harness.childTaskId); + expect(artifact?.projectArtifacts[0]?.appliedAtMs).toBeUndefined(); + }, 20_000); + + it("rejects a worktree-only dry run when the target has overlapping local changes", async () => { + const harness = await setupSingleRepoHarness(); + + await fsPromises.writeFile( + path.join(harness.childRepo, "README.md"), + "child version\n", + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + + await fsPromises.writeFile(path.join(harness.targetRepo, "README.md"), "local edit\n", "utf-8"); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + const tool = createApplyTool(harness); + + const result = (await tool.execute!( + { task_id: harness.childTaskId, dry_run: true }, + mockToolCallOptions + )) as { + success: boolean; + projectResults: Array<{ status: string; conflictPaths?: string[] }>; + }; + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.conflictPaths).toEqual(["README.md"]); + // The local edit stays untouched. + expect(await fsPromises.readFile(path.join(harness.targetRepo, "README.md"), "utf-8")).toBe( + "local edit\n" + ); + }, 20_000); + + it("treats dirty copy destinations as overlapping worktree patch paths", async () => { + const harness = await setupSingleRepoHarness(); + + // Identical source content in both repos so the copy patch's pre-image + // blob exists at the target. + const sourceBase = "line1\nline2\nline3\nline4\n"; + for (const repo of [harness.childRepo, harness.targetRepo]) { + await fsPromises.writeFile(path.join(repo, "é src.txt"), sourceBase, "utf-8"); + execSync("git add -A", { cwd: repo, stdio: "ignore" }); + execSync('git commit -m "add source"', { cwd: repo, stdio: "ignore" }); + } + + // Copy detection needs the source modified alongside the copy; the child + // copies the quoted-name source to an unquoted destination. + execSync("git config diff.renames copies", { cwd: harness.childRepo, stdio: "ignore" }); + await fsPromises.writeFile( + path.join(harness.childRepo, "é src.txt"), + `${sourceBase}line5\n`, + "utf-8" + ); + await fsPromises.writeFile( + path.join(harness.childRepo, "copied dest.txt"), + `${sourceBase}line5\n`, + "utf-8" + ); + const worktreeFields = await writeWorktreePatchFile({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + childRepo: harness.childRepo, + }); + // Fixture guard: the patch must carry the quoted-source copy record. + const patchText = await fsPromises.readFile(worktreeFields.worktreePatchPath, "utf-8"); + expect(patchText).toContain("copy to copied dest.txt"); + expect(patchText).toContain('copy from "'); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + ...worktreeFields, + }, + ], + }); + + // Dirty (untracked) copy destination at the target. + await fsPromises.writeFile( + path.join(harness.targetRepo, "copied dest.txt"), + "local content\n", + "utf-8" + ); + + const tool = createApplyTool(harness); + + const result = (await tool.execute!( + { task_id: harness.childTaskId, dry_run: true }, + mockToolCallOptions + )) as { + success: boolean; + projectResults: Array<{ status: string; conflictPaths?: string[] }>; + }; + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.conflictPaths).toEqual(["copied dest.txt"]); + expect( + await fsPromises.readFile(path.join(harness.targetRepo, "copied dest.txt"), "utf-8") + ).toBe("local content\n"); + }, 20_000); + + it("reports uncaptured uncommitted changes when the artifact records a skip reason", async () => { + const harness = await setupSingleRepoHarness(); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + projectPath: harness.targetRepo, + projectName: "project", + storageKey: "project", + status: "skipped", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchSkippedReason: "diff exceeded the capture cap", + }, + ], + }); + + const tool = createApplyTool(harness); + + const result = (await tool.execute!({ task_id: harness.childTaskId }, mockToolCallOptions)) as { + success: boolean; + projectResults: Array<{ status: string; note?: string }>; + }; + + expect(result.success).toBe(false); + expect(result.projectResults[0]?.note).toContain("diff exceeded the capture cap"); + }, 20_000); + + it("rejects a commit apply that would omit uncaptured changes until acknowledged", async () => { + const harness = await setupSingleRepoHarness(); + + await commitFile(harness.childRepo, "feature.txt", "feature\n", "child commit"); + const headSha = execSync("git rev-parse HEAD", { + cwd: harness.childRepo, + encoding: "utf-8", + }).trim(); + + await writePatchArtifact({ + sessionDir: harness.sessionDir, + workspaceId: harness.currentWorkspaceId, + childTaskId: harness.childTaskId, + projectArtifacts: [ + { + ...(await buildReadyProjectArtifact({ + sessionDir: harness.sessionDir, + childTaskId: harness.childTaskId, + storageKey: "project", + projectPath: harness.targetRepo, + projectName: "project", + childRepo: harness.childRepo, + baseSha: harness.baseSha, + headSha, + })), + hadUncommittedChanges: true, + worktreePatchSkippedReason: "diff exceeded the capture cap", + }, + ], + }); + + const tool = createApplyTool(harness); + + // Success would let a workflow checkpoint past the uncaptured work; the + // dry run must predict the same failure. + for (const dryRun of [true, false]) { + const result = (await tool.execute!( + { task_id: harness.childTaskId, dry_run: dryRun }, + mockToolCallOptions + )) as { + success: boolean; + projectResults: Array<{ status: string; error?: string }>; + }; + expect(result.success).toBe(false); + expect(result.projectResults[0]?.error).toContain("diff exceeded the capture cap"); + expect(result.projectResults[0]?.error).toContain("silently omit"); + } + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("base"); + + const acknowledgedResult = (await tool.execute!( + { task_id: harness.childTaskId, acknowledge_uncaptured_changes: true }, + mockToolCallOptions + )) as { + success: boolean; + projectResults: Array<{ status: string; note?: string }>; + }; + expect(acknowledgedResult.success).toBe(true); + expect(acknowledgedResult.projectResults[0]?.status).toBe("applied"); + // The warning survives acknowledged applies. + expect(acknowledgedResult.projectResults[0]?.note).toContain("diff exceeded the capture cap"); + expect( + execSync("git log -1 --pretty=%s", { cwd: harness.targetRepo, encoding: "utf-8" }).trim() + ).toBe("child commit"); + }, 20_000); +}); diff --git a/src/node/services/tools/task_apply_git_patch.ts b/src/node/services/tools/task_apply_git_patch.ts index 3df4d3ef85..f4f4057c2d 100644 --- a/src/node/services/tools/task_apply_git_patch.ts +++ b/src/node/services/tools/task_apply_git_patch.ts @@ -6,6 +6,7 @@ import type { z } from "zod"; import { tool } from "ai"; +import { getErrorMessage } from "@/common/utils/errors"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TaskApplyGitPatchToolArgsSchema, @@ -20,15 +21,25 @@ import { gitNoHooksPrefix } from "@/node/utils/gitNoHooksEnv"; import { isPathInsideDir } from "@/node/utils/pathUtils"; import { getSubagentGitPatchMboxPath, + getSubagentGitPatchWorktreePatchPath, isSafeSubagentGitPatchPathComponent, markSubagentGitPatchArtifactApplied, matchesProjectArtifactProjectPath, + readLocalPatchApplyCompletion, + readLocalPatchPartialApply, readSubagentGitPatchArtifact, + setLocalPatchPartialApply, + type SubagentGitPatchPartialStage, } from "@/node/services/subagentGitPatchArtifacts"; import { log } from "@/node/services/log"; import { Config } from "@/node/config"; import { coerceNonEmptyString, findWorkspaceEntry } from "@/node/services/taskUtils"; import { getWorkspaceProjectRepos } from "@/node/services/workspaceProjectRepos"; +import { + parseDiffGitHeaderPaths, + parseGitStatusPorcelainZ, + parsePatchMetadataPath, +} from "@/node/services/gitPatchPathParsing"; import { parseToolResult, requireWorkspaceId } from "./toolUtils"; @@ -518,6 +529,15 @@ function toLegacyFields(projectResults: TaskApplyGitPatchProjectResult[]): { }; } +function worktreeCaptureSkippedNote( + projectArtifact: SubagentGitProjectPatchArtifact +): string | undefined { + return projectArtifact.hadUncommittedChanges === true && + projectArtifact.worktreePatchSkippedReason != null + ? `The child ended with uncommitted changes that were NOT captured: ${projectArtifact.worktreePatchSkippedReason}` + : undefined; +} + function summarizeNonReadyProjectArtifact(params: { projectArtifact: SubagentGitProjectPatchArtifact; }): TaskApplyGitPatchProjectResult { @@ -536,6 +556,7 @@ function summarizeNonReadyProjectArtifact(params: { params.projectArtifact.error ?? noteByStatus[params.projectArtifact.status] ?? `Project patch status is ${params.projectArtifact.status}.`, + note: worktreeCaptureSkippedNote(params.projectArtifact), }; } @@ -589,6 +610,16 @@ function resolveCurrentWorkspaceRepoTargets(params: { ); } +async function findExistingFile(candidates: Iterable): Promise { + for (const candidate of candidates) { + const stat = await fsPromises.stat(candidate).catch(() => null); + if (stat?.isFile()) { + return candidate; + } + } + return null; +} + async function resolvePatchPath(params: { taskId: string; artifactSessionDir: string; @@ -627,18 +658,7 @@ async function resolvePatchPath(params: { (candidate): candidate is string => typeof candidate === "string" ); - let patchPath: string | null = null; - for (const candidate of patchCandidates) { - try { - const stat = await fsPromises.stat(candidate); - if (stat.isFile()) { - patchPath = candidate; - break; - } - } catch { - // try next candidate - } - } + const patchPath = await findExistingFile(patchCandidates); if (!patchPath) { const checkedPaths = Array.from(new Set(patchCandidates)) @@ -668,6 +688,389 @@ async function resolvePatchPath(params: { return { patchPath, note: patchPathNote }; } +async function resolveWorktreePatchLocalPath(params: { + taskId: string; + artifactSessionDir: string; + projectArtifact: SubagentGitProjectPatchArtifact; +}): Promise<{ patchPath: string } | { error: string } | null> { + const metadataPath = params.projectArtifact.worktreePatchPath; + const hasMetadataPath = typeof metadataPath === "string" && metadataPath.length > 0; + + const canonicalPath = getSubagentGitPatchWorktreePatchPath( + params.artifactSessionDir, + params.taskId, + params.projectArtifact.storageKey + ); + const safeMetadataPath = + typeof metadataPath === "string" && + metadataPath.length > 0 && + isPathInsideDir(params.artifactSessionDir, metadataPath) + ? metadataPath + : undefined; + + // The canonical location is probed even without metadata, like the mbox + // resolver: sanitized-away or corrupt worktreePatchPath must not make a + // captured patch silently unapplied while the file sits on disk. + const patchPath = await findExistingFile( + new Set( + [safeMetadataPath, canonicalPath].filter( + (value): value is string => + typeof value === "string" && isPathInsideDir(params.artifactSessionDir, value) + ) + ) + ); + if (patchPath != null) { + return { patchPath }; + } + // No file and no metadata means no worktree patch was ever captured. + return hasMetadataPath ? { error: "Uncommitted-changes patch file is missing on disk." } : null; +} + +async function snapshotStagedPaths(params: { + runtime: ToolConfiguration["runtime"]; + cwd: string; +}): Promise | { error: string }> { + const result = await execBuffered( + params.runtime, + "git diff-index --cached --name-only -z HEAD --", + { cwd: params.cwd, timeout: 30 } + ); + if (result.exitCode !== 0) { + return { error: result.stderr.trim() || "git diff-index failed" }; + } + return new Set(result.stdout.split("\0").filter((filePath) => filePath.length > 0)); +} + +async function unstagePaths(params: { + runtime: ToolConfiguration["runtime"]; + cwd: string; + paths: string[]; +}): Promise<{ error: string } | undefined> { + // Chunked so a patch touching many files cannot overflow the command + // line; :(literal) keeps glob characters in file names from matching + // other paths. + const chunkSize = 200; + for (let i = 0; i < params.paths.length; i += chunkSize) { + const pathspecs = params.paths + .slice(i, i + chunkSize) + .map((filePath) => shellQuote(`:(literal)${filePath}`)) + .join(" "); + const restoreResult = await execBuffered( + params.runtime, + `git restore --staged -- ${pathspecs}`, + { + cwd: params.cwd, + timeout: 60, + } + ); + if (restoreResult.exitCode !== 0) { + return { error: restoreResult.stderr.trim() || "git restore failed" }; + } + } + return undefined; +} + +/** + * A failed earlier attempt can leave the patch's paths staged: `git apply + * --3way` implies --index, so an attempt whose post-apply unstage (or index + * snapshot) failed, or that stopped at conflicts, leaves index entries + * behind while the content already sits in the worktree. The reverse check + * only proves worktree content, so completion must repair the index before + * the recovery marker is cleared; otherwise the "applied" report leaves the + * child's changes staged and the next commit-bearing apply fails its + * clean-index preflight. Staged entries outside the patch are untouched. + */ +async function repairStagedWorktreePatchPaths(params: { + runtime: ToolConfiguration["runtime"]; + repoCwd: string; + localPatchPath: string; +}): Promise<{ error: string } | undefined> { + let patchText: string; + try { + patchText = await fsPromises.readFile(params.localPatchPath, "utf-8"); + } catch (error: unknown) { + return { error: `Could not read uncommitted-changes patch: ${getErrorMessage(error)}` }; + } + const patchPaths = new Set([ + ...parseDiffGitHeaderPaths(patchText), + ...parsePatchMetadataPaths(patchText).changedPaths, + ]); + const staged = await snapshotStagedPaths({ runtime: params.runtime, cwd: params.repoCwd }); + if (!(staged instanceof Set)) { + return { error: `Could not snapshot the index: ${staged.error}` }; + } + const toUnstage = [...staged].filter((stagedPath) => patchPaths.has(stagedPath)); + if (toUnstage.length === 0) { + return undefined; + } + return await unstagePaths({ runtime: params.runtime, cwd: params.repoCwd, paths: toUnstage }); +} + +/** Uses three-way apply so failed conflicts can be surfaced through conflictPaths. */ +async function applyWorktreeDiffPatch(params: { + runtime: ToolConfiguration["runtime"]; + runtimeTempDir: string; + repoCwd: string; + taskId: string; + storageKey: string; + workspaceId: string; + trusted: boolean; + localPatchPath: string; + abortSignal?: AbortSignal; + /** + * "reverse-check" only tests whether the patch content is already fully + * present in the worktree (a reverse application would succeed); nothing + * is modified. + */ + mode?: "apply" | "reverse-check"; +}): Promise<{ applied: true } | { applied: false; error: string; conflictPaths?: string[] }> { + const remoteWorktreePatchPath = buildRuntimeTempPath({ + runtimeTempDir: params.runtimeTempDir, + filename: `mux-task-${params.taskId}-${params.storageKey}-worktree.patch`, + purpose: "worktree patch copy", + }); + + await cleanupRuntimePatchFile({ + runtime: params.runtime, + repoCwd: params.repoCwd, + remotePatchPath: remoteWorktreePatchPath, + taskId: params.taskId, + workspaceId: params.workspaceId, + }); + + try { + await copyLocalFileToRuntime({ + runtime: params.runtime, + localPath: params.localPatchPath, + remotePath: remoteWorktreePatchPath, + abortSignal: params.abortSignal, + }); + + const noHooksPrefix = gitNoHooksPrefix(params.trusted); + const isRealApply = params.mode !== "reverse-check"; + // `git apply --3way` implies --index (git-apply(1)), so a successful + // apply stages every file it touched. The child's changes must land as + // plain worktree changes: staged entries change the dirty-state + // semantics and make the next commit-bearing apply fail its + // clean-index preflight. The exact set to unstage is diffed from + // before/after index snapshots because unrelated staged entries are + // allowed here (only the commit-bearing path requires a clean index) + // and must survive untouched. + let stagedBefore: Set | null = null; + if (isRealApply) { + const snapshot = await snapshotStagedPaths({ runtime: params.runtime, cwd: params.repoCwd }); + if (!(snapshot instanceof Set)) { + // Nothing was applied yet, so this failure is cleanly retryable. + return { + applied: false, + error: `Could not snapshot the index before applying the uncommitted-changes patch: ${snapshot.error}`, + }; + } + stagedBefore = snapshot; + } + const gitFlags = isRealApply ? "--3way" : "--reverse --check"; + const applyResult = await execBuffered( + params.runtime, + `${noHooksPrefix}git apply ${gitFlags} --binary ${shellQuote(remoteWorktreePatchPath)}`.trim(), + { cwd: params.repoCwd, timeout: 300 } + ); + + if (applyResult.exitCode !== 0) { + const errorOutput = [applyResult.stderr.trim(), applyResult.stdout.trim()] + .filter((s) => s.length > 0) + .join("\n") + .trim(); + // A failed reverse-check never touches the index, so there are no + // conflict paths to collect. + const conflictPaths = + params.mode === "reverse-check" + ? [] + : await tryGetConflictPaths({ + runtime: params.runtime, + cwd: params.repoCwd, + }); + return { + applied: false, + error: + errorOutput.length > 0 + ? errorOutput + : `git apply failed (exitCode=${applyResult.exitCode})`, + ...(conflictPaths.length > 0 ? { conflictPaths } : {}), + }; + } + + if (isRealApply && stagedBefore != null) { + const alreadyStagedNote = + "The patch content was applied to the worktree, but the entries `git apply --3way` staged could not be unstaged. Unstage them manually (`git restore --staged -- `); the applied content itself is correct."; + const stagedAfter = await snapshotStagedPaths({ + runtime: params.runtime, + cwd: params.repoCwd, + }); + if (!(stagedAfter instanceof Set)) { + return { applied: false, error: `${alreadyStagedNote} (${stagedAfter.error})` }; + } + const newlyStaged = [...stagedAfter].filter((filePath) => !stagedBefore.has(filePath)); + const unstageError = await unstagePaths({ + runtime: params.runtime, + cwd: params.repoCwd, + paths: newlyStaged, + }); + if (unstageError != null) { + return { applied: false, error: `${alreadyStagedNote} (${unstageError.error})` }; + } + } + + return { applied: true }; + } finally { + await cleanupRuntimePatchFile({ + runtime: params.runtime, + repoCwd: params.repoCwd, + remotePatchPath: remoteWorktreePatchPath, + taskId: params.taskId, + workspaceId: params.workspaceId, + }); + } +} + +async function isCommitAncestorOfHead(params: { + runtime: ToolConfiguration["runtime"]; + cwd: string; + commitSha: string; +}): Promise { + const result = await execBuffered( + params.runtime, + `git merge-base --is-ancestor ${shellQuote(params.commitSha)} HEAD`, + { cwd: params.cwd, timeout: 30 } + ); + // Exit 1 = not an ancestor; other non-zero (e.g. unknown SHA after a + // reset + gc) also means the recorded HEAD is gone. Fail closed. + return result.exitCode === 0; +} + +/** + * A completion record proves an apply finished when it was written, not that + * the work is still present: a crash after the record but before a workflow + * checkpoint leaves the record while a reset/rebase (or discarded worktree + * changes) removes the work. Replay-safe retries (allowAlreadyApplied) verify + * both components before skipping. The recorded post-apply HEAD must still be + * an ancestor of HEAD (commit series). The uncommitted-changes patch content + * must still be reverse-applicable OR its paths must deviate from the + * recorded post-apply state (dirty in the worktree, or changed between the + * recorded HEAD and the current HEAD): later legitimate edits or commits of + * the applied work are indistinguishable from each other by content, so only + * fully pristine patch paths, which is exactly what a discard restores, read + * as missing. Records without a recorded HEAD (written before the field + * existed) cannot be validated and keep the legacy skip. Returns a reason + * string when the applied work is missing, else null. + */ +async function checkAppliedWorkStillPresent(params: { + runtime: ToolConfiguration["runtime"]; + runtimeTempDir: string; + repoCwd: string; + taskId: string; + workspaceId: string; + trusted: boolean; + projectArtifact: SubagentGitProjectPatchArtifact; + artifactSessionDir: string; + recordedHeadSha: string | undefined; + /** + * The completion was asserted via acknowledge_partial_recovery: the manual + * recovery may not be reverse-applicable, so the content check must not + * re-run against it (the user's assertion would fail forever). + */ + recordedAcknowledged: boolean; + abortSignal?: AbortSignal; +}): Promise { + if (params.recordedHeadSha == null) { + return null; + } + const artifactHasCommitSeries = + params.projectArtifact.commitCount !== 0 || + (typeof params.projectArtifact.mboxPath === "string" && + params.projectArtifact.mboxPath.length > 0); + if (artifactHasCommitSeries) { + const stillAncestor = await isCommitAncestorOfHead({ + runtime: params.runtime, + cwd: params.repoCwd, + commitSha: params.recordedHeadSha, + }); + if (!stillAncestor) { + return `the target branch no longer contains the recorded post-apply HEAD (${params.recordedHeadSha}), so the applied commit series is missing (the target was likely reset or rebased)`; + } + } + if (params.recordedAcknowledged) { + return null; + } + const worktreePatch = await resolveWorktreePatchLocalPath({ + taskId: params.taskId, + artifactSessionDir: params.artifactSessionDir, + projectArtifact: params.projectArtifact, + }); + if (worktreePatch == null) { + return null; + } + if ("error" in worktreePatch) { + return `whether the child's applied uncommitted changes are still present cannot be verified (${worktreePatch.error})`; + } + const reverseCheck = await applyWorktreeDiffPatch({ + runtime: params.runtime, + runtimeTempDir: params.runtimeTempDir, + repoCwd: params.repoCwd, + taskId: params.taskId, + storageKey: params.projectArtifact.storageKey, + workspaceId: params.workspaceId, + trusted: params.trusted, + localPatchPath: worktreePatch.patchPath, + abortSignal: params.abortSignal, + mode: "reverse-check", + }); + if (reverseCheck.applied) { + return null; + } + let patchText: string; + try { + patchText = await fsPromises.readFile(worktreePatch.patchPath, "utf-8"); + } catch (error: unknown) { + return `whether the child's applied uncommitted changes are still present cannot be verified (could not read the uncommitted-changes patch: ${getErrorMessage(error)})`; + } + const patchPaths = [ + ...new Set([ + ...parseDiffGitHeaderPaths(patchText), + ...parsePatchMetadataPaths(patchText).changedPaths, + ]), + ]; + const chunkSize = 200; + for (let i = 0; i < patchPaths.length; i += chunkSize) { + const pathspecs = patchPaths + .slice(i, i + chunkSize) + .map((filePath) => shellQuote(`:(literal)${filePath}`)) + .join(" "); + const statusResult = await execBuffered( + params.runtime, + `git status --porcelain -z --untracked-files=all -- ${pathspecs}`, + { cwd: params.repoCwd, timeout: 30 } + ); + if (statusResult.exitCode !== 0) { + return `whether the child's applied uncommitted changes are still present cannot be verified (${statusResult.stderr.trim() || "git status failed"})`; + } + if (statusResult.stdout.length > 0) { + return null; + } + const diffResult = await execBuffered( + params.runtime, + `git diff --name-only -z ${shellQuote(params.recordedHeadSha)} HEAD -- ${pathspecs}`, + { cwd: params.repoCwd, timeout: 30 } + ); + if (diffResult.exitCode !== 0) { + return `whether the child's applied uncommitted changes are still present cannot be verified (${diffResult.stderr.trim() || "git diff failed"})`; + } + if (diffResult.stdout.length > 0) { + return null; + } + } + return "the child's applied uncommitted changes are no longer present in the worktree (they were likely discarded)"; +} + function validatePatchRuntimePathComponent(value: string, label: string): string | undefined { if (isSafeSubagentGitPatchPathComponent(value)) { return undefined; @@ -688,35 +1091,6 @@ function buildRuntimeTempPath(params: { return runtimePath; } -interface GitStatusPorcelainEntry { - path: string; - status: string; -} - -function parseGitStatusPorcelainZ(stdout: string): GitStatusPorcelainEntry[] { - const entriesByPath: GitStatusPorcelainEntry[] = []; - const entries = stdout.split("\0"); - for (let i = 0; i < entries.length; i += 1) { - const entry = entries[i]; - if (entry.length < 4) continue; - - const status = entry.slice(0, 2); - const filePath = entry.slice(3); - if (filePath.length > 0) { - entriesByPath.push({ path: filePath, status }); - } - - if (status.includes("R") || status.includes("C")) { - i += 1; - const sourcePath = entries[i]; - if (sourcePath != null && sourcePath.length > 0) { - entriesByPath.push({ path: sourcePath, status }); - } - } - } - return entriesByPath; -} - function parseGitApplyNumstatZ(stdout: string): string[] { return stdout .split("\0") @@ -729,23 +1103,23 @@ function parseGitApplyNumstatZ(stdout: string): string[] { } interface PatchMetadataPaths { - renamePaths: string[]; + /** Paths the patch mutates: rename sources/destinations and copy destinations. */ + changedPaths: string[]; + /** Copy sources are only read, so they conflict with dirty state selectively. */ copySourcePaths: string[]; } function parsePatchMetadataPaths(stdout: string): PatchMetadataPaths { - const renamePaths: string[] = []; + const changedPaths: string[] = []; const copySourcePaths: string[] = []; for (const line of stdout.split(/\r?\n/)) { - const renamePrefix = line.startsWith("rename from ") - ? "rename from " - : line.startsWith("rename to ") - ? "rename to " - : undefined; - if (renamePrefix != null) { - const filePath = parsePatchMetadataPath(line.slice(renamePrefix.length)); + const changedPrefix = ["rename from ", "rename to ", "copy to "].find((prefix) => + line.startsWith(prefix) + ); + if (changedPrefix != null) { + const filePath = parsePatchMetadataPath(line.slice(changedPrefix.length)); if (filePath.length > 0) { - renamePaths.push(filePath); + changedPaths.push(filePath); } continue; } @@ -757,140 +1131,7 @@ function parsePatchMetadataPaths(stdout: string): PatchMetadataPaths { } } } - return { renamePaths, copySourcePaths }; -} - -function parseDiffGitHeaderPaths(stdout: string): string[] { - const paths = new Set(); - for (const line of stdout.split(/\r?\n/)) { - if (!line.startsWith("diff --git ")) continue; - for (const filePath of parseDiffGitHeaderLine(line.slice("diff --git ".length))) { - paths.add(filePath); - } - } - return [...paths].filter((filePath) => filePath.length > 0); -} - -function parseDiffGitHeaderLine(line: string): string[] { - if (line.startsWith('"')) { - const first = parseGitQuotedPath(line, 0); - if (first == null) return []; - let secondStartOffset = first.nextOffset; - while (line[secondStartOffset] === " ") { - secondStartOffset += 1; - } - const second = parseGitQuotedPath(line, secondStartOffset); - return [stripDiffPathPrefix(first.path), stripDiffPathPrefix(second?.path)].filter( - (filePath): filePath is string => filePath != null && filePath.length > 0 - ); - } - - if (!line.startsWith("a/")) { - return []; - } - - const paths = new Set(); - let separatorIndex = line.indexOf(" b/", "a/".length); - while (separatorIndex !== -1) { - paths.add(line.slice("a/".length, separatorIndex)); - paths.add(line.slice(separatorIndex + " b/".length)); - separatorIndex = line.indexOf(" b/", separatorIndex + 1); - } - return [...paths]; -} - -function stripDiffPathPrefix(filePath: string | undefined): string | undefined { - if (filePath == null) return undefined; - return filePath.startsWith("a/") || filePath.startsWith("b/") ? filePath.slice(2) : filePath; -} - -function parsePatchMetadataPath(value: string): string { - if (!value.startsWith('"')) { - return value; - } - return parseGitQuotedPath(value, 0)?.path ?? ""; -} - -function parseGitQuotedPath( - value: string, - startOffset: number -): { path: string; nextOffset: number } | undefined { - if (value[startOffset] !== '"') { - return undefined; - } - - const bytes: number[] = []; - const encoder = new TextEncoder(); - let offset = startOffset + 1; - while (offset < value.length) { - const char = value[offset]; - if (char === '"') { - return { path: new TextDecoder().decode(Uint8Array.from(bytes)), nextOffset: offset + 1 }; - } - - if (char !== "\\") { - const codePoint = value.codePointAt(offset); - if (codePoint == null) { - return undefined; - } - const codePointString = String.fromCodePoint(codePoint); - bytes.push(...encoder.encode(codePointString)); - offset += codePointString.length; - continue; - } - - offset += 1; - if (offset >= value.length) { - return undefined; - } - - const escaped = value[offset]; - if (/[0-7]/.test(escaped)) { - let octal = escaped; - offset += 1; - while (offset < value.length && octal.length < 3 && /[0-7]/.test(value[offset])) { - octal += value[offset]; - offset += 1; - } - bytes.push(Number.parseInt(octal, 8)); - continue; - } - - const escapedByte = decodeGitQuotedEscapedByte(escaped); - if (escapedByte == null) { - bytes.push(...encoder.encode(escaped)); - } else { - bytes.push(escapedByte); - } - offset += 1; - } - - return undefined; -} - -function decodeGitQuotedEscapedByte(char: string): number | undefined { - switch (char) { - case "a": - return 0x07; - case "b": - return 0x08; - case "t": - return 0x09; - case "n": - return 0x0a; - case "v": - return 0x0b; - case "f": - return 0x0c; - case "r": - return 0x0d; - case '"': - return 0x22; - case "\\": - return 0x5c; - default: - return undefined; - } + return { changedPaths, copySourcePaths }; } function patchPathOverlapsDirtyPath(patchPath: string, dirtyPath: string): boolean { @@ -985,7 +1226,7 @@ async function checkDirtyPatchPathOverlap(params: { const metadataResult = await execBuffered( params.runtime, - `awk '/^From / { in_patch=0; in_diff=0 } /^---$/ { in_patch=1; next } in_patch && /^diff --git / { in_diff=1; next } in_patch && in_diff && (/^rename from / || /^rename to / || /^copy from /) { print }' ${shellQuote( + `awk '/^From / { in_patch=0; in_diff=0 } /^---$/ { in_patch=1; next } in_patch && /^diff --git / { in_diff=1; next } in_patch && in_diff && (/^rename from / || /^rename to / || /^copy from / || /^copy to /) { print }' ${shellQuote( params.remotePatchPath )}`, { @@ -998,7 +1239,7 @@ async function checkDirtyPatchPathOverlap(params: { ...(numstatResult.exitCode === 0 ? numstatPaths : parseDiffGitHeaderPaths(diffHeaderResult.stdout)), - ...metadataPaths.renamePaths, + ...metadataPaths.changedPaths, ]); const conflictPaths = [ ...new Set([ @@ -1027,15 +1268,64 @@ async function checkDirtyPatchPathOverlap(params: { }; } -async function checkExpectedHead(params: { +/** + * Preflights the uncommitted-changes patch against the ACTUAL target repo's + * dirty state. Dry runs apply in a clean temp worktree, so without this check + * a dry run can succeed while the real `git apply --3way` fails on local + * modifications; real applies use it for a deterministic early failure. + */ +async function checkDirtyWorktreePatchPathOverlap(params: { runtime: ToolConfiguration["runtime"]; cwd: string; - expectedHeadSha?: string; -}): Promise { - if (params.expectedHeadSha == null) { - return undefined; + localPatchPath: string; +}): Promise<{ error: string; conflictPaths?: string[] } | undefined> { + const statusResult = await execBuffered( + params.runtime, + "git status --porcelain -z --untracked-files=all", + { cwd: params.cwd, timeout: 10 } + ); + if (statusResult.exitCode !== 0) { + return { error: statusResult.stderr.trim() || "git status failed" }; } - const currentHeadSha = await tryRevParseHead({ runtime: params.runtime, cwd: params.cwd }); + const dirtyEntries = parseGitStatusPorcelainZ(statusResult.stdout); + if (dirtyEntries.length === 0) { + return undefined; + } + + let patchText: string; + try { + patchText = await fsPromises.readFile(params.localPatchPath, "utf-8"); + } catch (error: unknown) { + return { error: `Could not read uncommitted-changes patch: ${getErrorMessage(error)}` }; + } + const patchPaths = new Set([ + ...parseDiffGitHeaderPaths(patchText), + ...parsePatchMetadataPaths(patchText).changedPaths, + ]); + const conflictPaths = dirtyEntries + .map((entry) => entry.path) + .filter((dirtyPath) => + [...patchPaths].some((patchPath) => patchPathOverlapsDirtyPath(patchPath, dirtyPath)) + ) + .sort(); + if (conflictPaths.length === 0) { + return undefined; + } + return { + error: "Working tree has local changes that overlap the child's uncommitted-changes patch.", + conflictPaths: [...new Set(conflictPaths)], + }; +} + +async function checkExpectedHead(params: { + runtime: ToolConfiguration["runtime"]; + cwd: string; + expectedHeadSha?: string; +}): Promise { + if (params.expectedHeadSha == null) { + return undefined; + } + const currentHeadSha = await tryRevParseHead({ runtime: params.runtime, cwd: params.cwd }); if (currentHeadSha == null) { return "Could not determine current HEAD before applying patch."; } @@ -1045,6 +1335,32 @@ async function checkExpectedHead(params: { return undefined; } +async function createDryRunWorktree(params: { + runtime: ToolConfiguration["runtime"]; + runtimeTempDir: string; + repoCwd: string; + taskId: string; + storageKey: string; + trusted: boolean; + filenamePrefix: string; +}): Promise<{ path: string } | { error: string }> { + const dryRunId = `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`; + const dryRunWorktreePath = buildRuntimeTempPath({ + runtimeTempDir: params.runtimeTempDir, + filename: `${params.filenamePrefix}-${params.taskId}-${params.storageKey}-${dryRunId}`, + purpose: "dry-run worktree", + }); + const noHooksPrefix = gitNoHooksPrefix(params.trusted); + const addResult = await execBuffered( + params.runtime, + `${noHooksPrefix}git worktree add --detach ${shellQuote(dryRunWorktreePath)} HEAD`, + { cwd: params.repoCwd, timeout: 60 } + ); + return addResult.exitCode === 0 + ? { path: dryRunWorktreePath } + : { error: addResult.stderr.trim() || addResult.stdout.trim() || "git worktree add failed" }; +} + async function applyProjectPatch(params: { taskId: string; workspaceId: string; @@ -1055,6 +1371,8 @@ async function applyProjectPatch(params: { projectArtifact: SubagentGitProjectPatchArtifact; artifactWorkspaceId: string; artifactSessionDir: string; + /** The applying workspace's own session dir (target-local replay state). */ + workspaceSessionDir: string; artifactLookupNote?: string; dryRun: boolean; threeWay: boolean; @@ -1062,6 +1380,13 @@ async function applyProjectPatch(params: { expectedHeadSha?: string; isReplay: boolean; abortSignal?: AbortSignal; + /** + * The commit series already landed in an earlier partial application, so + * only the pending uncommitted-changes patch is applied (never `git am`). + */ + completePartialWorktreeOnly?: boolean; + /** Target HEAD recorded with the partial marker (ancestry fence). */ + partialCompletionFenceSha?: string; }): Promise<{ success: boolean; projectResult: TaskApplyGitPatchProjectResult }> { const taskIdError = validatePatchRuntimePathComponent(params.taskId, "task_id"); const storageKeyError = validatePatchRuntimePathComponent( @@ -1080,6 +1405,100 @@ async function applyProjectPatch(params: { }; } + const worktreeResolution = await resolveWorktreePatchLocalPath({ + taskId: params.taskId, + artifactSessionDir: params.artifactSessionDir, + projectArtifact: params.projectArtifact, + }); + if (worktreeResolution != null && "error" in worktreeResolution) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error: worktreeResolution.error, + note: params.artifactLookupNote, + }, + }; + } + + if (params.completePartialWorktreeOnly === true) { + if (worktreeResolution == null) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error: + "Patch was PARTIALLY applied: the commit series landed but the child's uncommitted-changes patch failed, and that patch is no longer available to complete the application.", + note: mergeNotes( + params.artifactLookupNote, + "Recover manually: apply the child's uncommitted changes by hand. Only use force=true after resetting the branch to its pre-apply state, since it replays the already-applied commit series." + ), + }, + }; + } + return applyWorktreeOnlyProjectPatch({ + taskId: params.taskId, + workspaceId: params.workspaceId, + runtime: params.runtime, + runtimeTempDir: params.runtimeTempDir, + trusted: params.trusted, + repoCwd: params.repoCwd, + projectArtifact: params.projectArtifact, + artifactWorkspaceId: params.artifactWorkspaceId, + artifactSessionDir: params.artifactSessionDir, + workspaceSessionDir: params.workspaceSessionDir, + artifactLookupNote: params.artifactLookupNote, + dryRun: params.dryRun, + expectedHeadSha: params.expectedHeadSha, + isReplay: params.isReplay, + abortSignal: params.abortSignal, + worktreePatchLocalPath: worktreeResolution.patchPath, + partialCompletion: true, + partialCompletionFenceSha: params.partialCompletionFenceSha, + }); + } + + const hasCommitPatch = + params.projectArtifact.commitCount !== 0 || + (typeof params.projectArtifact.mboxPath === "string" && + params.projectArtifact.mboxPath.length > 0); + if (!hasCommitPatch) { + if (worktreeResolution == null) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error: "Artifact has no commit patch and no uncommitted-changes patch to apply.", + note: params.artifactLookupNote, + }, + }; + } + return applyWorktreeOnlyProjectPatch({ + taskId: params.taskId, + workspaceId: params.workspaceId, + runtime: params.runtime, + runtimeTempDir: params.runtimeTempDir, + trusted: params.trusted, + repoCwd: params.repoCwd, + projectArtifact: params.projectArtifact, + artifactWorkspaceId: params.artifactWorkspaceId, + artifactSessionDir: params.artifactSessionDir, + workspaceSessionDir: params.workspaceSessionDir, + artifactLookupNote: params.artifactLookupNote, + dryRun: params.dryRun, + expectedHeadSha: params.expectedHeadSha, + isReplay: params.isReplay, + abortSignal: params.abortSignal, + worktreePatchLocalPath: worktreeResolution.patchPath, + }); + } + const remotePatchPath = buildRuntimeTempPath({ runtimeTempDir: params.runtimeTempDir, filename: `mux-task-${params.taskId}-${params.projectArtifact.storageKey}-series.mbox`, @@ -1142,7 +1561,7 @@ async function applyProjectPatch(params: { const flags: string[] = []; if (params.threeWay) flags.push("--3way"); - const nhp = gitNoHooksPrefix(params.trusted); + const noHooksPrefix = gitNoHooksPrefix(params.trusted); if (params.dryRun) { const dryRunDirtyOverlap = await checkDirtyPatchPathOverlap({ @@ -1168,6 +1587,30 @@ async function applyProjectPatch(params: { }; } + if (worktreeResolution != null) { + const worktreeDirtyOverlap = await checkDirtyWorktreePatchPathOverlap({ + runtime: params.runtime, + cwd: params.repoCwd, + localPatchPath: worktreeResolution.patchPath, + }); + if (worktreeDirtyOverlap != null) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error: worktreeDirtyOverlap.error, + conflictPaths: worktreeDirtyOverlap.conflictPaths, + note: mergeNotes( + patchResolution.note, + "Commit or stash local changes on overlapping patch paths before applying. Unrelated dirty files can remain in place." + ), + }, + }; + } + } + const dryRunHeadError = await checkExpectedHead({ runtime: params.runtime, cwd: params.repoCwd, @@ -1185,29 +1628,27 @@ async function applyProjectPatch(params: { }, }; } - const dryRunId = `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`; - const dryRunWorktreePath = buildRuntimeTempPath({ + const dryRunWorktree = await createDryRunWorktree({ + runtime: params.runtime, runtimeTempDir: params.runtimeTempDir, - filename: `mux-git-am-dry-run-${params.taskId}-${params.projectArtifact.storageKey}-${dryRunId}`, - purpose: "dry-run worktree", + repoCwd: params.repoCwd, + taskId: params.taskId, + storageKey: params.projectArtifact.storageKey, + trusted: params.trusted, + filenamePrefix: "mux-git-am-dry-run", }); - - const addResult = await execBuffered( - params.runtime, - `${nhp}git worktree add --detach ${shellQuote(dryRunWorktreePath)} HEAD`, - { cwd: params.repoCwd, timeout: 60 } - ); - if (addResult.exitCode !== 0) { + if ("error" in dryRunWorktree) { return { success: false, projectResult: { projectPath: params.projectArtifact.projectPath, projectName: params.projectArtifact.projectName, status: "failed", - error: addResult.stderr.trim() || addResult.stdout.trim() || "git worktree add failed", + error: dryRunWorktree.error, }, }; } + const dryRunWorktreePath = dryRunWorktree.path; try { const beforeHeadSha = await tryRevParseHead({ @@ -1215,7 +1656,8 @@ async function applyProjectPatch(params: { cwd: dryRunWorktreePath, }); - const amCmd = `${nhp}git am ${flags.join(" ")} ${shellQuote(remotePatchPath)}`.trim(); + const amCmd = + `${noHooksPrefix}git am ${flags.join(" ")} ${shellQuote(remotePatchPath)}`.trim(); const amResult = await execBuffered(params.runtime, amCmd, { cwd: dryRunWorktreePath, timeout: 300, @@ -1263,6 +1705,36 @@ async function applyProjectPatch(params: { includeSha: false, }); + if (worktreeResolution != null) { + const worktreeOutcome = await applyWorktreeDiffPatch({ + runtime: params.runtime, + runtimeTempDir: params.runtimeTempDir, + repoCwd: dryRunWorktreePath, + taskId: params.taskId, + storageKey: params.projectArtifact.storageKey, + workspaceId: params.workspaceId, + trusted: params.trusted, + localPatchPath: worktreeResolution.patchPath, + abortSignal: params.abortSignal, + }); + if (!worktreeOutcome.applied) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + conflictPaths: worktreeOutcome.conflictPaths, + error: worktreeOutcome.error, + note: mergeNotes( + patchResolution.note, + "Dry run failed; the commit series applies cleanly but the child's uncommitted-changes patch does not." + ), + }, + }; + } + } + return { success: true, projectResult: { @@ -1270,12 +1742,16 @@ async function applyProjectPatch(params: { projectName: params.projectArtifact.projectName, status: "applied", appliedCommits, - note: mergeNotes(patchResolution.note, "Dry run succeeded; no commits were applied."), + note: mergeNotes( + patchResolution.note, + "Dry run succeeded; no commits were applied.", + worktreeCaptureSkippedNote(params.projectArtifact) + ), }, }; } finally { try { - const abortResult = await execBuffered(params.runtime, `${nhp}git am --abort`, { + const abortResult = await execBuffered(params.runtime, `${noHooksPrefix}git am --abort`, { cwd: dryRunWorktreePath, timeout: 30, }); @@ -1300,56 +1776,14 @@ async function applyProjectPatch(params: { }); } - try { - const removeResult = await execBuffered( - params.runtime, - `${nhp}git worktree remove --force ${shellQuote(dryRunWorktreePath)}`, - { cwd: params.repoCwd, timeout: 60 } - ); - if (removeResult.exitCode !== 0) { - log.debug("task_apply_git_patch: dry-run git worktree remove failed", { - taskId: params.taskId, - workspaceId: params.workspaceId, - cwd: params.repoCwd, - dryRunWorktreePath, - exitCode: removeResult.exitCode, - stderr: removeResult.stderr.trim(), - stdout: removeResult.stdout.trim(), - }); - } - } catch (error: unknown) { - log.debug("task_apply_git_patch: dry-run git worktree remove threw", { - taskId: params.taskId, - workspaceId: params.workspaceId, - cwd: params.repoCwd, - dryRunWorktreePath, - error, - }); - } - - try { - const pruneResult = await execBuffered(params.runtime, "git worktree prune", { - cwd: params.repoCwd, - timeout: 60, - }); - if (pruneResult.exitCode !== 0) { - log.debug("task_apply_git_patch: dry-run git worktree prune failed", { - taskId: params.taskId, - workspaceId: params.workspaceId, - cwd: params.repoCwd, - exitCode: pruneResult.exitCode, - stderr: pruneResult.stderr.trim(), - stdout: pruneResult.stdout.trim(), - }); - } - } catch (error: unknown) { - log.debug("task_apply_git_patch: dry-run git worktree prune threw", { - taskId: params.taskId, - workspaceId: params.workspaceId, - cwd: params.repoCwd, - error, - }); - } + await removeDryRunWorktreeBestEffort({ + runtime: params.runtime, + repoCwd: params.repoCwd, + dryRunWorktreePath, + taskId: params.taskId, + workspaceId: params.workspaceId, + trusted: params.trusted, + }); } } @@ -1364,124 +1798,803 @@ async function applyProjectPatch(params: { }); if (dirtyOverlap != null) { return { - success: false, + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error: dirtyOverlap.error, + conflictPaths: dirtyOverlap.conflictPaths, + note: mergeNotes( + patchResolution.note, + "Commit or stash local changes on overlapping patch paths before applying. Unrelated dirty files can remain in place." + ), + }, + }; + } + + // Preflight the worktree patch too, BEFORE git am: failing it after the + // commit series lands would leave a partially applied artifact. + if (worktreeResolution != null) { + const worktreeDirtyOverlap = await checkDirtyWorktreePatchPathOverlap({ + runtime: params.runtime, + cwd: params.repoCwd, + localPatchPath: worktreeResolution.patchPath, + }); + if (worktreeDirtyOverlap != null) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error: worktreeDirtyOverlap.error, + conflictPaths: worktreeDirtyOverlap.conflictPaths, + note: mergeNotes( + patchResolution.note, + "Commit or stash local changes on overlapping patch paths before applying. Unrelated dirty files can remain in place." + ), + }, + }; + } + } + + const applyHeadError = await checkExpectedHead({ + runtime: params.runtime, + cwd: params.repoCwd, + expectedHeadSha: params.expectedHeadSha, + }); + if (applyHeadError != null) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error: applyHeadError, + note: patchResolution.note, + }, + }; + } + + const beforeHeadSha = await tryRevParseHead({ runtime: params.runtime, cwd: params.repoCwd }); + + // Durable in-progress record BEFORE the irreversible `git am`: a crash + // after git am succeeds but before any later write would otherwise leave + // the applied commits unrecorded, and a retry would replay the mbox. + // Recovery reconciles this record against HEAD: an unchanged HEAD (and + // no in-progress am session) retries fresh; anything else fails closed. + // A write failure here happens before anything was applied, so the + // failure is cleanly retryable. + try { + if (params.isReplay) { + await setLocalPatchPartialApply({ + workspaceId: params.workspaceId, + workspaceSessionDir: params.workspaceSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + record: { + appliedAtMs: Date.now(), + stage: "am-started", + ...(beforeHeadSha != null ? { headCommitSha: beforeHeadSha } : {}), + }, + }); + } else { + await markSubagentGitPatchArtifactApplied({ + workspaceId: params.artifactWorkspaceId, + workspaceSessionDir: params.artifactSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + appliedAtMs: Date.now(), + partial: true, + partialStage: "am-started", + ...(beforeHeadSha != null ? { partialHeadSha: beforeHeadSha } : {}), + }); + } + } catch (markerError: unknown) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error: `Failed to persist the in-progress apply marker: ${getErrorMessage(markerError)}`, + note: mergeNotes( + patchResolution.note, + "Nothing was applied. Fix the persistence failure (e.g. disk space) and retry." + ), + }, + }; + } + + const amCmd = `${noHooksPrefix}git am ${flags.join(" ")} ${shellQuote(remotePatchPath)}`.trim(); + const amResult = await execBuffered(params.runtime, amCmd, { + cwd: params.repoCwd, + timeout: 300, + }); + + if (amResult.exitCode !== 0) { + const stderr = amResult.stderr.trim(); + const stdout = amResult.stdout.trim(); + const errorOutput = [stderr, stdout] + .filter((s) => s.length > 0) + .join("\n") + .trim(); + + const conflictPaths = await tryGetConflictPaths({ + runtime: params.runtime, + cwd: params.repoCwd, + }); + const failedPatchSubject = parseFailedPatchSubjectFromGitAmOutput(errorOutput); + const gitAmInProgress = await isGitAmInProgress({ + runtime: params.runtime, + cwd: params.repoCwd, + }); + const conflictRecoveryNote = + conflictPaths.length > 0 || gitAmInProgress + ? "git am stopped in conflict-recovery state. Resolve conflicts/issues and run `git am --continue`, or run `git am --abort` to restore a clean working tree and delegate resolution to a sub-agent." + : "git am failed before entering conflict-recovery state. Review the error output above and fix the patch/input before retrying."; + + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + conflictPaths, + failedPatchSubject, + error: + errorOutput.length > 0 ? errorOutput : `git am failed (exitCode=${amResult.exitCode})`, + note: mergeNotes(patchResolution.note, conflictRecoveryNote), + }, + }; + } + + const headCommitSha = await tryRevParseHead({ runtime: params.runtime, cwd: params.repoCwd }); + + const appliedCommits = await getAppliedCommits({ + runtime: params.runtime, + cwd: params.repoCwd, + beforeHeadSha, + commitCountHint: params.projectArtifact.commitCount, + includeSha: true, + }); + + // git am permanently advanced HEAD, so record the artifact as applied + // even though the worktree patch failed; a retry must not replay the + // commit series on top of itself. The partial marker keeps replay + // integrations from treating this as a completed application. Replay + // targets record it locally: the ancestor's artifact is shared with + // other targets and must stay replayable for them. + const recordPartialApplication = async (): Promise => { + // The post-am HEAD fences later completion: it must still be an + // ancestor of the target HEAD or the applied commit series is gone. + if (params.isReplay) { + await setLocalPatchPartialApply({ + workspaceId: params.workspaceId, + workspaceSessionDir: params.workspaceSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + record: { + appliedAtMs: Date.now(), + stage: "commits-applied", + ...(headCommitSha != null ? { headCommitSha } : {}), + }, + }); + } else { + await markSubagentGitPatchArtifactApplied({ + workspaceId: params.artifactWorkspaceId, + workspaceSessionDir: params.artifactSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + appliedAtMs: Date.now(), + partial: true, + partialStage: "commits-applied", + ...(headCommitSha != null ? { partialHeadSha: headCommitSha } : {}), + }); + } + }; + + let worktreeNote: string | undefined; + if (worktreeResolution != null) { + // Persist the partial marker BEFORE attempting the worktree patch: + // recording it only after a failure could itself fail (ENOSPC, + // EACCES), leaving the applied commit series with no durable record + // and a retry free to replay `git am`. Persisting up front also covers + // a crash during the worktree apply. If the marker cannot be written, + // the worktree patch has not been attempted yet, so the commit series + // is rolled back (`git reset --keep` refuses to touch unrelated local + // modifications) to keep the failure cleanly retryable. + try { + await recordPartialApplication(); + } catch (markerError: unknown) { + let rolledBack = false; + if (beforeHeadSha != null) { + try { + const rollbackResult = await execBuffered( + params.runtime, + `${noHooksPrefix}git reset --keep ${shellQuote(beforeHeadSha)}`, + { cwd: params.repoCwd, timeout: 300 } + ); + rolledBack = rollbackResult.exitCode === 0; + } catch { + // Fall through to the unrecoverable message below. + } + } + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + ...(rolledBack ? {} : { appliedCommits, headCommitSha }), + error: `Failed to persist the partial-application marker: ${getErrorMessage(markerError)}`, + note: mergeNotes( + patchResolution.note, + rolledBack + ? "The commit series was rolled back; nothing from this artifact remains applied. Fix the persistence failure (e.g. disk space) and retry." + : "The commit series was applied but could NOT be recorded or rolled back. Do NOT re-apply this task's patch; verify the applied commits manually before retrying." + ), + }, + }; + } + // A rejection here (cancellation, runtime write failure) propagates + // directly: the partial marker is already durable (persisted above). + const worktreeOutcome = await applyWorktreeDiffPatch({ + runtime: params.runtime, + runtimeTempDir: params.runtimeTempDir, + repoCwd: params.repoCwd, + taskId: params.taskId, + storageKey: params.projectArtifact.storageKey, + workspaceId: params.workspaceId, + trusted: params.trusted, + localPatchPath: worktreeResolution.patchPath, + abortSignal: params.abortSignal, + }); + if (!worktreeOutcome.applied) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + appliedCommits, + headCommitSha, + conflictPaths: worktreeOutcome.conflictPaths, + error: worktreeOutcome.error, + note: mergeNotes( + patchResolution.note, + "The commit series was applied successfully and recorded as applied, but the child's uncommitted-changes patch failed. Do not re-apply this task's patch; resolve the leftover conflicts (if any) or apply the uncommitted changes manually." + ), + }, + }; + } + worktreeNote = "Also applied the child's uncommitted changes as uncommitted changes."; + } + + // A completion-write failure must not read as success: the durable + // state still says am-started or commits-applied, so a later retry + // could not reconcile the advanced HEAD against a "successful" apply. + // The work IS fully applied, so the guidance is never "re-apply". + try { + if (params.isReplay) { + // A full apply (e.g. force retry after manual recovery) clears any + // earlier target-local partial marker. + await setLocalPatchPartialApply({ + workspaceId: params.workspaceId, + workspaceSessionDir: params.workspaceSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + record: null, + completedAtMs: Date.now(), + ...(headCommitSha != null ? { completedHeadSha: headCommitSha } : {}), + }); + } else { + await markSubagentGitPatchArtifactApplied({ + workspaceId: params.artifactWorkspaceId, + workspaceSessionDir: params.artifactSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + appliedAtMs: Date.now(), + ...(headCommitSha != null ? { appliedHeadSha: headCommitSha } : {}), + }); + } + } catch (persistError: unknown) { + return { + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + appliedCommits, + headCommitSha, + error: `The child's work was applied, but recording the completion failed: ${getErrorMessage(persistError)}`, + note: mergeNotes( + patchResolution.note, + "Do NOT re-apply. Fix the persistence failure (e.g. permissions, disk space) and re-run; if the retry reports an interrupted apply, verify the work is present and use acknowledge_partial_recovery=true." + ), + }, + }; + } + + return { + success: true, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "applied", + appliedCommits, + headCommitSha, + note: mergeNotes( + patchResolution.note, + worktreeNote, + worktreeCaptureSkippedNote(params.projectArtifact) + ), + }, + }; + } finally { + await cleanupRuntimePatchFile({ + runtime: params.runtime, + repoCwd: params.repoCwd, + remotePatchPath, + taskId: params.taskId, + workspaceId: params.workspaceId, + }); + } +} + +async function removeDryRunWorktreeBestEffort(params: { + runtime: ToolConfiguration["runtime"]; + repoCwd: string; + dryRunWorktreePath: string; + taskId: string; + workspaceId: string; + trusted: boolean; +}): Promise { + const noHooksPrefix = gitNoHooksPrefix(params.trusted); + + try { + const removeResult = await execBuffered( + params.runtime, + `${noHooksPrefix}git worktree remove --force ${shellQuote(params.dryRunWorktreePath)}`, + { cwd: params.repoCwd, timeout: 60 } + ); + if (removeResult.exitCode !== 0) { + log.debug("task_apply_git_patch: dry-run git worktree remove failed", { + taskId: params.taskId, + workspaceId: params.workspaceId, + cwd: params.repoCwd, + dryRunWorktreePath: params.dryRunWorktreePath, + exitCode: removeResult.exitCode, + stderr: removeResult.stderr.trim(), + stdout: removeResult.stdout.trim(), + }); + } + } catch (error: unknown) { + log.debug("task_apply_git_patch: dry-run git worktree remove threw", { + taskId: params.taskId, + workspaceId: params.workspaceId, + cwd: params.repoCwd, + dryRunWorktreePath: params.dryRunWorktreePath, + error, + }); + } + + try { + const pruneResult = await execBuffered(params.runtime, "git worktree prune", { + cwd: params.repoCwd, + timeout: 60, + }); + if (pruneResult.exitCode !== 0) { + log.debug("task_apply_git_patch: dry-run git worktree prune failed", { + taskId: params.taskId, + workspaceId: params.workspaceId, + cwd: params.repoCwd, + exitCode: pruneResult.exitCode, + stderr: pruneResult.stderr.trim(), + stdout: pruneResult.stdout.trim(), + }); + } + } catch (error: unknown) { + log.debug("task_apply_git_patch: dry-run git worktree prune threw", { + taskId: params.taskId, + workspaceId: params.workspaceId, + cwd: params.repoCwd, + error, + }); + } +} + +async function applyWorktreeOnlyProjectPatch(params: { + taskId: string; + workspaceId: string; + runtime: ToolConfiguration["runtime"]; + runtimeTempDir: string; + trusted: boolean; + repoCwd: string; + projectArtifact: SubagentGitProjectPatchArtifact; + artifactWorkspaceId: string; + artifactSessionDir: string; + /** The applying workspace's own session dir (target-local replay state). */ + workspaceSessionDir: string; + artifactLookupNote?: string; + dryRun: boolean; + expectedHeadSha?: string; + isReplay: boolean; + abortSignal?: AbortSignal; + worktreePatchLocalPath: string; + /** + * Completing an earlier partial application: the commit series already + * landed, only the uncommitted-changes patch is pending. Success clears + * the partial marker. + */ + partialCompletion?: boolean; + /** + * Target HEAD recorded when the partial application happened. Completion + * requires it to still be an ancestor of HEAD; otherwise the target was + * reset/rebased and the applied commit series may be missing. + */ + partialCompletionFenceSha?: string; +}): Promise<{ success: boolean; projectResult: TaskApplyGitPatchProjectResult }> { + const partialCompletion = params.partialCompletion === true; + const failed = ( + error: string, + extra?: Pick + ): { success: boolean; projectResult: TaskApplyGitPatchProjectResult } => ({ + success: false, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "failed", + error, + note: extra?.note ?? params.artifactLookupNote, + ...(extra?.conflictPaths ? { conflictPaths: extra.conflictPaths } : {}), + }, + }); + + // Returns an error message instead of reporting success with an + // unpersisted completion: the durable partial marker would survive and a + // later retry could not tell the finished apply from an unrecovered + // partial. The changes stay applied either way, so the failure guidance + // is "fix persistence and re-run", never "re-apply". + const clearPartialAndMarkApplied = async ( + completionHeadSha: string | null | undefined + ): Promise => { + try { + if (!params.isReplay) { + // Marking without `partial` clears an earlier appliedPartial flag. + await markSubagentGitPatchArtifactApplied({ + workspaceId: params.artifactWorkspaceId, + workspaceSessionDir: params.artifactSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + appliedAtMs: Date.now(), + ...(completionHeadSha != null ? { appliedHeadSha: completionHeadSha } : {}), + }); + } else { + // Replay success is recorded target-locally; the shared ancestor + // artifact stays untouched for other replay targets. A fresh + // worktree-only replay lands here too: its pre-apply partial marker + // must not survive success, or a later retry would treat the finished + // apply as an unrecovered partial. + await setLocalPatchPartialApply({ + workspaceId: params.workspaceId, + workspaceSessionDir: params.workspaceSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + record: null, + completedAtMs: Date.now(), + ...(completionHeadSha != null ? { completedHeadSha: completionHeadSha } : {}), + }); + } + return undefined; + } catch (error: unknown) { + return getErrorMessage(error); + } + }; + const completionPersistenceFailed = (persistError: string) => + failed( + `The child's uncommitted changes were applied, but recording the completion failed: ${persistError}`, + { + note: mergeNotes( + params.artifactLookupNote, + "Do NOT re-apply. Fix the persistence failure (e.g. permissions, disk space) and re-run: the retry detects the already-present changes and completes the record." + ), + } + ); + + const recordPartialApplication = async (): Promise => { + // No git am ran here, so the fence HEAD is the current one: completion + // later requires it to still be an ancestor of the target HEAD. + const fenceHeadSha = await tryRevParseHead({ runtime: params.runtime, cwd: params.repoCwd }); + if (params.isReplay) { + await setLocalPatchPartialApply({ + workspaceId: params.workspaceId, + workspaceSessionDir: params.workspaceSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + record: { + appliedAtMs: Date.now(), + stage: "commits-applied", + ...(fenceHeadSha != null ? { headCommitSha: fenceHeadSha } : {}), + }, + }); + } else { + await markSubagentGitPatchArtifactApplied({ + workspaceId: params.artifactWorkspaceId, + workspaceSessionDir: params.artifactSessionDir, + childTaskId: params.taskId, + projectPath: params.projectArtifact.projectPath, + appliedAtMs: Date.now(), + partial: true, + partialStage: "commits-applied", + ...(fenceHeadSha != null ? { partialHeadSha: fenceHeadSha } : {}), + }); + } + }; + + // The exact expected_head_sha check is suppressed only for commit-bearing + // partials, where an earlier `git am` necessarily advanced HEAD and + // enforcing it would block every fenced retry. A commit-free artifact's + // earlier attempt never moved HEAD, so the caller's expected head is still + // meaningful and an advanced target must be rejected. + const artifactHasCommitSeries = + params.projectArtifact.commitCount !== 0 || + (typeof params.projectArtifact.mboxPath === "string" && + params.projectArtifact.mboxPath.length > 0); + if (!partialCompletion || !artifactHasCommitSeries) { + const expectedHeadError = await checkExpectedHead({ + runtime: params.runtime, + cwd: params.repoCwd, + expectedHeadSha: params.expectedHeadSha, + }); + if (expectedHeadError != null) { + return failed(expectedHeadError); + } + } + if (partialCompletion && params.partialCompletionFenceSha != null) { + // The marker's recorded HEAD is the real fence: it must still be + // an ancestor of HEAD, or the target was reset/rebased and the applied + // commit series may no longer be present. + const stillAncestor = await isCommitAncestorOfHead({ + runtime: params.runtime, + cwd: params.repoCwd, + commitSha: params.partialCompletionFenceSha, + }); + if (!stillAncestor) { + return failed( + `Cannot complete the earlier partial application: the target branch no longer contains the HEAD recorded when the commit series was applied (${params.partialCompletionFenceSha}). The target was likely reset or rebased, so the applied commits may be missing.`, + { + note: mergeNotes( + params.artifactLookupNote, + "Re-apply the whole artifact with force=true (after making sure the branch is at its pre-apply state), or use acknowledge_partial_recovery=true if the child's work is in fact fully present." + ), + } + ); + } + } + + // Manual recovery may already have put the patch content in place + // (committed or uncommitted). Detect that BEFORE the dirty-overlap check, + // which would otherwise reject the recovered content itself as overlap. + // Runs in dry-run mode too: a dry run against the recovered target would + // otherwise fail on overlap (uncommitted recovery) or on re-applying + // already-committed content in the temp worktree. + if (partialCompletion) { + const reverseCheck = await applyWorktreeDiffPatch({ + runtime: params.runtime, + runtimeTempDir: params.runtimeTempDir, + repoCwd: params.repoCwd, + taskId: params.taskId, + storageKey: params.projectArtifact.storageKey, + workspaceId: params.workspaceId, + trusted: params.trusted, + localPatchPath: params.worktreePatchLocalPath, + abortSignal: params.abortSignal, + mode: "reverse-check", + }); + if (reverseCheck.applied) { + if (!params.dryRun) { + // Unstage entries the failed earlier attempt left behind before the + // marker is cleared; see repairStagedWorktreePatchPaths. + const repairError = await repairStagedWorktreePatchPaths({ + runtime: params.runtime, + repoCwd: params.repoCwd, + localPatchPath: params.worktreePatchLocalPath, + }); + if (repairError != null) { + return failed( + `The child's uncommitted changes are already present in the worktree, but the index entries left by the failed earlier attempt could not be unstaged: ${repairError.error}`, + { + note: mergeNotes( + params.artifactLookupNote, + "Unstage the patch paths manually (`git restore --staged -- `) and re-run, or re-run with acknowledge_partial_recovery=true to keep the staged entries." + ), + } + ); + } + } + const headCommitSha = await tryRevParseHead({ + runtime: params.runtime, + cwd: params.repoCwd, + }); + if (!params.dryRun) { + const persistError = await clearPartialAndMarkApplied(headCommitSha); + if (persistError != null) { + return completionPersistenceFailed(persistError); + } + } + return { + success: true, projectResult: { projectPath: params.projectArtifact.projectPath, projectName: params.projectArtifact.projectName, - status: "failed", - error: dirtyOverlap.error, - conflictPaths: dirtyOverlap.conflictPaths, + status: "applied", + appliedCommits: [], + headCommitSha, note: mergeNotes( - patchResolution.note, - "Commit or stash local changes on overlapping patch paths before applying. Unrelated dirty files can remain in place." + params.artifactLookupNote, + params.dryRun + ? "Dry run succeeded: the child's uncommitted changes are already present in the worktree; a real run will clear the partial marker." + : "Completed the earlier partial application: the child's uncommitted changes are already present in the worktree, so nothing was re-applied." ), }, }; } + } - const applyHeadError = await checkExpectedHead({ - runtime: params.runtime, - cwd: params.repoCwd, - expectedHeadSha: params.expectedHeadSha, + // Preflight against the ACTUAL target's dirty state; the dry-run temp + // worktree is clean, so this is what keeps dry runs predictive. + const worktreeDirtyOverlap = await checkDirtyWorktreePatchPathOverlap({ + runtime: params.runtime, + cwd: params.repoCwd, + localPatchPath: params.worktreePatchLocalPath, + }); + if (worktreeDirtyOverlap != null) { + return failed(worktreeDirtyOverlap.error, { + conflictPaths: worktreeDirtyOverlap.conflictPaths, + note: mergeNotes( + params.artifactLookupNote, + partialCompletion + ? "Completing the earlier partial application was blocked by overlapping local changes (likely conflict markers from the failed first attempt). Resolve them to the child's intended content and re-run: a re-run detects already-present changes and clears the partial marker. If your resolution intentionally merges parent and child edits, re-run with acknowledge_partial_recovery=true instead." + : "Commit or stash local changes on overlapping patch paths before applying. Unrelated dirty files can remain in place." + ), }); - if (applyHeadError != null) { - return { - success: false, - projectResult: { - projectPath: params.projectArtifact.projectPath, - projectName: params.projectArtifact.projectName, - status: "failed", - error: applyHeadError, - note: patchResolution.note, - }, - }; - } - - const beforeHeadSha = await tryRevParseHead({ runtime: params.runtime, cwd: params.repoCwd }); + } - const amCmd = `${nhp}git am ${flags.join(" ")} ${shellQuote(remotePatchPath)}`.trim(); - const amResult = await execBuffered(params.runtime, amCmd, { - cwd: params.repoCwd, - timeout: 300, + if (params.dryRun) { + const dryRunWorktree = await createDryRunWorktree({ + runtime: params.runtime, + runtimeTempDir: params.runtimeTempDir, + repoCwd: params.repoCwd, + taskId: params.taskId, + storageKey: params.projectArtifact.storageKey, + trusted: params.trusted, + filenamePrefix: "mux-git-apply-dry-run", }); + if ("error" in dryRunWorktree) { + return failed(dryRunWorktree.error); + } + const dryRunWorktreePath = dryRunWorktree.path; - if (amResult.exitCode !== 0) { - const stderr = amResult.stderr.trim(); - const stdout = amResult.stdout.trim(); - const errorOutput = [stderr, stdout] - .filter((s) => s.length > 0) - .join("\n") - .trim(); - - const conflictPaths = await tryGetConflictPaths({ - runtime: params.runtime, - cwd: params.repoCwd, - }); - const failedPatchSubject = parseFailedPatchSubjectFromGitAmOutput(errorOutput); - const gitAmInProgress = await isGitAmInProgress({ + try { + const applyOutcome = await applyWorktreeDiffPatch({ runtime: params.runtime, - cwd: params.repoCwd, + runtimeTempDir: params.runtimeTempDir, + repoCwd: dryRunWorktreePath, + taskId: params.taskId, + storageKey: params.projectArtifact.storageKey, + workspaceId: params.workspaceId, + trusted: params.trusted, + localPatchPath: params.worktreePatchLocalPath, + abortSignal: params.abortSignal, }); - const conflictRecoveryNote = - conflictPaths.length > 0 || gitAmInProgress - ? "git am stopped in conflict-recovery state. Resolve conflicts/issues and run `git am --continue`, or run `git am --abort` to restore a clean working tree and delegate resolution to a sub-agent." - : "git am failed before entering conflict-recovery state. Review the error output above and fix the patch/input before retrying."; - + if (!applyOutcome.applied) { + return failed(applyOutcome.error, { + conflictPaths: applyOutcome.conflictPaths, + note: mergeNotes( + params.artifactLookupNote, + "Dry run failed; the uncommitted-changes patch does not apply cleanly against the current HEAD." + ), + }); + } return { - success: false, + success: true, projectResult: { projectPath: params.projectArtifact.projectPath, projectName: params.projectArtifact.projectName, - status: "failed", - conflictPaths, - failedPatchSubject, - error: - errorOutput.length > 0 ? errorOutput : `git am failed (exitCode=${amResult.exitCode})`, - note: mergeNotes(patchResolution.note, conflictRecoveryNote), + status: "applied", + appliedCommits: [], + note: mergeNotes( + params.artifactLookupNote, + partialCompletion + ? "Dry run succeeded; the uncommitted-changes patch pending from the earlier partial application applies cleanly. No changes were applied." + : "Dry run succeeded; no changes were applied. The artifact contains only uncommitted changes (no commits)." + ), }, }; - } - - const headCommitSha = await tryRevParseHead({ runtime: params.runtime, cwd: params.repoCwd }); - - const appliedCommits = await getAppliedCommits({ - runtime: params.runtime, - cwd: params.repoCwd, - beforeHeadSha, - commitCountHint: params.projectArtifact.commitCount, - includeSha: true, - }); - - if (!params.isReplay) { - await markSubagentGitPatchArtifactApplied({ - workspaceId: params.artifactWorkspaceId, - workspaceSessionDir: params.artifactSessionDir, - childTaskId: params.taskId, - projectPath: params.projectArtifact.projectPath, - appliedAtMs: Date.now(), + } finally { + await removeDryRunWorktreeBestEffort({ + runtime: params.runtime, + repoCwd: params.repoCwd, + dryRunWorktreePath, + taskId: params.taskId, + workspaceId: params.workspaceId, + trusted: params.trusted, }); } + } - return { - success: true, - projectResult: { - projectPath: params.projectArtifact.projectPath, - projectName: params.projectArtifact.projectName, - status: "applied", - appliedCommits, - headCommitSha, - note: patchResolution.note, - }, - }; - } finally { - await cleanupRuntimePatchFile({ - runtime: params.runtime, - repoCwd: params.repoCwd, - remotePatchPath, - taskId: params.taskId, - workspaceId: params.workspaceId, + // `git apply --3way` can leave some files applied while another conflicts, + // so the target may hold part of the patch after a failure OR a crash + // mid-apply. Without a durable marker a retry would treat the artifact as + // fresh and the dirty-overlap preflight would permanently reject the + // partially-applied content; the marker routes retries to the + // reverse-check completion path instead. It is persisted BEFORE the + // irreversible apply (and cleared on success below): writing it only after + // a failure could itself fail (ENOSPC, EACCES) and leave no record. A + // marker-write failure here happens before anything was applied, so the + // failure is cleanly retryable. + if (!partialCompletion) { + try { + await recordPartialApplication(); + } catch (markerError: unknown) { + return failed( + `Failed to persist the partial-application marker: ${getErrorMessage(markerError)}`, + { + note: mergeNotes( + params.artifactLookupNote, + "Nothing was applied. Fix the persistence failure (e.g. disk space) and retry." + ), + } + ); + } + } + const applyOutcome = await applyWorktreeDiffPatch({ + runtime: params.runtime, + runtimeTempDir: params.runtimeTempDir, + repoCwd: params.repoCwd, + taskId: params.taskId, + storageKey: params.projectArtifact.storageKey, + workspaceId: params.workspaceId, + trusted: params.trusted, + localPatchPath: params.worktreePatchLocalPath, + abortSignal: params.abortSignal, + }); + if (!applyOutcome.applied) { + return failed(applyOutcome.error, { + conflictPaths: applyOutcome.conflictPaths, + note: mergeNotes( + params.artifactLookupNote, + partialCompletion + ? "Completing the earlier partial application failed. Any conflict markers were left in the worktree; resolve them (or apply the changes manually), then re-run: a re-run detects already-present changes and clears the partial marker. If your resolution intentionally merges parent and child edits, re-run with acknowledge_partial_recovery=true instead." + : "Applying the child's uncommitted changes failed. Any conflict markers were left in the worktree; resolve them to the child's intended content (or discard with `git checkout -- `), then re-run: a re-run detects already-present changes and completes the application." + ), }); } + + const headCommitSha = await tryRevParseHead({ runtime: params.runtime, cwd: params.repoCwd }); + const persistError = await clearPartialAndMarkApplied(headCommitSha); + if (persistError != null) { + return completionPersistenceFailed(persistError); + } + + return { + success: true, + projectResult: { + projectPath: params.projectArtifact.projectPath, + projectName: params.projectArtifact.projectName, + status: "applied", + appliedCommits: [], + headCommitSha, + note: mergeNotes( + params.artifactLookupNote, + partialCompletion + ? "Completed the earlier partial application: applied the child's uncommitted changes as uncommitted changes (the commit series had already landed)." + : "Applied the child's uncommitted changes as uncommitted changes (the child produced no commits)." + ), + }, + }; } async function cleanupRuntimePatchFile(params: { @@ -1543,6 +2656,8 @@ export async function applyTaskGitPatchArtifact( const dryRun = parsedArgs.dry_run === true; const threeWay = parsedArgs.three_way !== false; const force = parsedArgs.force === true; + const acknowledgePartialRecovery = parsedArgs.acknowledge_partial_recovery === true; + const acknowledgeUncapturedChanges = parsedArgs.acknowledge_uncaptured_changes === true; const expectedHeadSha = parsedArgs.expected_head_sha ?? undefined; if (!isSafeSubagentGitPatchPathComponent(taskId)) { @@ -1727,7 +2842,70 @@ export async function applyTaskGitPatchArtifact( ); } + // An acknowledgment sweep with nothing to acknowledge is a mistaken flag + // and must not touch any repository: unmarked projects would otherwise be + // applied normally before the post-loop check reports the failure. force + // never reads markers, so it can never acknowledge anything. + if (acknowledgePartialRecovery) { + let anyAcknowledgeable = false; + if (!force) { + for (const projectArtifact of readyProjectArtifacts) { + if (!isReplay) { + if (projectArtifact.appliedPartial === true) { + anyAcknowledgeable = true; + break; + } + continue; + } + const partial = await readLocalPatchPartialApply({ + workspaceSessionDir, + childTaskId: taskId, + projectPath: projectArtifact.projectPath, + }); + const completion = + partial == null + ? await readLocalPatchApplyCompletion({ + workspaceSessionDir, + childTaskId: taskId, + projectPath: projectArtifact.projectPath, + }) + : null; + if (partial != null || completion?.unknown === true) { + anyAcknowledgeable = true; + break; + } + } + } + if (!anyAcknowledgeable) { + const ackProjectResults = projectArtifacts.map((projectArtifact) => + projectArtifact.status !== "ready" + ? summarizeNonReadyProjectArtifact({ projectArtifact }) + : { + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "skipped" as const, + note: "No partial application recorded for this project; nothing to acknowledge.", + } + ); + return parseToolResult( + TaskApplyGitPatchToolResultSchema, + { + success: false as const, + taskId, + dryRun, + projectResults: ackProjectResults, + error: + "acknowledge_partial_recovery was set, but no partial application is recorded for any project; nothing to acknowledge.", + note: artifactLookupNote, + ...toLegacyFields(ackProjectResults), + }, + "task_apply_git_patch" + ); + } + } + let shouldStopAfterFailure = false; + let acknowledgedPartialCount = 0; for (const projectArtifact of projectArtifacts) { if (shouldStopAfterFailure) { projectResults.push({ @@ -1747,9 +2925,221 @@ export async function applyTaskGitPatchArtifact( continue; } - if (!isReplay && projectArtifact.appliedAtMs && !force) { - const appliedAt = new Date(projectArtifact.appliedAtMs).toISOString(); + // Replay targets track partial application locally: the ancestor's + // artifact is shared with other targets, so its applied markers cannot + // represent this target's state. + const localPartial = + isReplay && !force + ? await readLocalPatchPartialApply({ + workspaceSessionDir, + childTaskId: taskId, + projectPath: projectArtifact.projectPath, + }) + : null; + const localCompletion = + isReplay && !force + ? await readLocalPatchApplyCompletion({ + workspaceSessionDir, + childTaskId: taskId, + projectPath: projectArtifact.projectPath, + }) + : null; + // A partial application (commits landed, worktree patch failed) must + // never read as success: a resumed workflow would otherwise checkpoint + // past the child's still-missing uncommitted changes. A retry completes + // just the pending uncommitted-changes patch (never re-running git am) + // and clears the marker on success. The boolean marker alone is + // authoritative: appliedAtMs is optional metadata, so a marker persisted + // without a timestamp must still route to worktree-only completion + // instead of re-running the already-applied commit series. + const hasPartialMarker = + localPartial != null || (!isReplay && !force && projectArtifact.appliedPartial === true); + // "am-started" markers were persisted before an interrupted `git am`; + // whether the commit series landed is unknown, so they are reconciled + // below instead of routing to worktree-only completion. Missing stage + // means "commits-applied" (markers written before the field existed); + // "unknown" (a corrupted recorded stage) always fails closed. + const partialStage: SubagentGitPatchPartialStage | "unknown" | undefined = !hasPartialMarker + ? undefined + : localPartial != null + ? (localPartial.stage ?? "commits-applied") + : (projectArtifact.appliedPartialStage ?? "commits-applied"); + const completePartialWorktreeOnly = hasPartialMarker && partialStage === "commits-applied"; + const partialCompletionFenceSha = + localPartial?.headCommitSha ?? + (!isReplay && !force ? projectArtifact.appliedPartialHeadSha : undefined); + + // Explicit escape hatch for manual recoveries the automatic + // already-present reverse check cannot recognize (e.g. a merged conflict + // resolution that is not patch-reversible): the user asserts the child's + // work is fully present, so the marker clears without applying anything. + if (acknowledgePartialRecovery) { + // Any recorded partial (including an interrupted am-started marker) + // or an unreadable completion record can be acknowledged: the user + // asserts the child's work is present. + const acknowledgeable = hasPartialMarker || localCompletion?.unknown === true; + if (!acknowledgeable) { + // An all-project sweep must reach later partial projects, but only a + // sibling PROVEN fully applied may be skipped: an earlier partial + // failure stops the loop before later projects are attempted, so an + // unmarked project may be entirely unapplied and must fall through + // to a normal apply below (skipping it would let a workflow + // checkpoint past a whole missing project). Replay targets prove it + // via their target-local completion record, since a successful + // replay never stamps the shared artifact's appliedAtMs. An + // explicitly targeted project keeps the hard failure. + if (requestedProjectPath == null) { + // Unknown completions took the acknowledgeable branch, so a + // present record here is valid proof. + const provenApplied = isReplay + ? localCompletion != null + : Boolean(projectArtifact.appliedAtMs); + if (provenApplied) { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "skipped", + note: "No partial application recorded for this project; nothing to acknowledge.", + }); + continue; + } + } else { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "failed", + error: + "acknowledge_partial_recovery was set, but no partial application is recorded for this project; nothing to acknowledge.", + }); + shouldStopAfterFailure = true; + continue; + } + } else { + acknowledgedPartialCount += 1; + if (!dryRun) { + // The user asserts the child's work is present RIGHT NOW, so the + // current HEAD is the post-apply fence for later replay-safe + // retries (see checkAppliedWorkStillPresent). + const acknowledgeRepoCwd = + repoTargetsByProjectPath.get(projectArtifact.projectPath)?.repoCwd ?? + (artifact.projectArtifacts.length === 1 ? config.cwd : undefined); + const acknowledgeHeadSha = + acknowledgeRepoCwd != null + ? await tryRevParseHead({ runtime: config.runtime, cwd: acknowledgeRepoCwd }) + : null; + // An unpersisted acknowledgment must not read as success: the + // partial marker would survive and keep failing later applies. + try { + if (isReplay) { + await setLocalPatchPartialApply({ + workspaceId, + workspaceSessionDir, + childTaskId: taskId, + projectPath: projectArtifact.projectPath, + record: null, + completedAtMs: Date.now(), + ...(acknowledgeHeadSha != null ? { completedHeadSha: acknowledgeHeadSha } : {}), + completedAcknowledged: true, + }); + } else { + await markSubagentGitPatchArtifactApplied({ + workspaceId: artifactWorkspaceId, + workspaceSessionDir: artifactSessionDir, + childTaskId: taskId, + projectPath: projectArtifact.projectPath, + appliedAtMs: Date.now(), + ...(acknowledgeHeadSha != null ? { appliedHeadSha: acknowledgeHeadSha } : {}), + appliedAcknowledged: true, + }); + } + } catch (persistError: unknown) { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "failed", + error: `Recording the acknowledged recovery failed: ${getErrorMessage(persistError)}`, + note: mergeNotes( + artifactLookupNote, + "Nothing was changed. Fix the persistence failure (e.g. permissions, disk space) and re-run with acknowledge_partial_recovery=true." + ), + }); + shouldStopAfterFailure = true; + continue; + } + } + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "applied", + appliedCommits: [], + note: dryRun + ? "Dry run: would acknowledge the manual recovery of the earlier partial application and clear the partial marker. Nothing was applied or cleared." + : "Acknowledged manual recovery of the earlier partial application; the partial marker was cleared. Nothing was applied.", + }); + continue; + } + } + + // Only a marker-free project may read as already applied: a stale + // appliedAtMs can coexist with a partial marker (an interrupted force + // re-apply of a previously applied artifact), and every marker stage + // must reach its own recovery handling below. Replay targets prove + // application via their target-local completion record (a successful + // replay never stamps the shared artifact's appliedAtMs), so a workflow + // retry after a crash-before-checkpoint must not replay the mbox. + const alreadyAppliedAtMs = + hasPartialMarker || force + ? undefined + : isReplay + ? localCompletion != null && localCompletion.unknown !== true + ? localCompletion.appliedAtMs + : undefined + : projectArtifact.appliedAtMs; + if (alreadyAppliedAtMs) { + const appliedAt = new Date(alreadyAppliedAtMs).toISOString(); if (options.allowAlreadyApplied === true) { + // The record proves the apply finished, not that the work survived + // until this retry (see checkAppliedWorkStillPresent). Validate + // before skipping so a workflow cannot checkpoint past missing work. + // A project that cannot be resolved in this workspace can be neither + // validated nor re-applied, so it keeps the legacy skip. + const alreadyAppliedRepoCwd = + repoTargetsByProjectPath.get(projectArtifact.projectPath)?.repoCwd ?? + (artifact.projectArtifacts.length === 1 ? config.cwd : undefined); + const staleReason = + alreadyAppliedRepoCwd != null + ? await checkAppliedWorkStillPresent({ + runtime: config.runtime, + runtimeTempDir: config.runtimeTempDir, + repoCwd: alreadyAppliedRepoCwd, + taskId, + workspaceId, + trusted: config.trusted === true, + projectArtifact, + artifactSessionDir, + recordedHeadSha: isReplay + ? localCompletion?.headCommitSha + : projectArtifact.appliedHeadSha, + recordedAcknowledged: isReplay + ? localCompletion?.acknowledged === true + : projectArtifact.appliedAcknowledged === true, + abortSignal: options.abortSignal, + }) + : null; + if (staleReason != null) { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "failed", + error: `A completion record from ${appliedAt} says this project's patch was applied, but ${staleReason}.`, + note: mergeNotes( + artifactLookupNote, + "Verify the target repository's state, then re-run with force=true to re-apply the child's work." + ), + }); + shouldStopAfterFailure = true; + continue; + } projectResults.push({ projectPath: projectArtifact.projectPath, projectName: projectArtifact.projectName, @@ -1771,6 +3161,38 @@ export async function applyTaskGitPatchArtifact( } } + // A corrupted completion record cannot prove this replay target applied + // the project, and a fresh apply could replay an already-applied series. + // Fail closed until the user verifies and acknowledges (or forces). + if (!hasPartialMarker && localCompletion?.unknown === true) { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "failed", + error: + "A previous apply on this target recorded a completion for this project, but the record is unreadable, so whether the patch was applied cannot be determined.", + note: "Inspect the target repository history for the child's commits, then re-run with acknowledge_partial_recovery=true once the child's work is confirmed present (or force=true to re-apply).", + }); + shouldStopAfterFailure = true; + continue; + } + + // A corrupted recorded stage means the marker cannot say whether the + // commit series landed: worktree-only completion could skip a never-run + // git am, and a fresh apply could replay a landed series. Fail closed. + if (hasPartialMarker && partialStage === "unknown") { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "failed", + error: + "A previous apply attempt recorded a partial application, but its recorded stage is unreadable, so whether the commit series was applied cannot be determined.", + note: "Inspect the target repository history for the child's commits, then re-run with acknowledge_partial_recovery=true once the child's work is confirmed present (or complete the recovery manually).", + }); + shouldStopAfterFailure = true; + continue; + } + const repoTarget = repoTargetsByProjectPath.get(projectArtifact.projectPath); const repoCwd = repoTarget?.repoCwd ?? (artifact.projectArtifacts.length === 1 ? config.cwd : undefined); @@ -1785,6 +3207,69 @@ export async function applyTaskGitPatchArtifact( continue; } + // Reconcile an interrupted apply (marker persisted before `git am`, but + // the attempt never recorded an outcome). Only an unchanged HEAD with no + // in-progress am session proves nothing landed; that retries fresh (the + // stale marker is refreshed by the next pre-am write or cleared by full + // success). Anything else is ambiguous between "the series landed" and + // "unrelated commits advanced HEAD", so it fails closed. + if (hasPartialMarker && partialStage === "am-started") { + const amInProgress = await isGitAmInProgress({ runtime: config.runtime, cwd: repoCwd }); + if (amInProgress) { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "failed", + error: + "A previous apply attempt was interrupted and left a git am session in progress in this repository.", + note: "Finish it with `git am --continue`, or restore the pre-apply state with `git am --abort`, then re-run.", + }); + shouldStopAfterFailure = true; + continue; + } + const currentHeadSha = await tryRevParseHead({ runtime: config.runtime, cwd: repoCwd }); + const interruptedFenceSha = + localPartial?.headCommitSha ?? projectArtifact.appliedPartialHeadSha; + if ( + interruptedFenceSha == null || + currentHeadSha == null || + currentHeadSha !== interruptedFenceSha + ) { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "failed", + error: `A previous apply attempt was interrupted after \`git am\` may have advanced HEAD${interruptedFenceSha != null ? ` (recorded pre-apply HEAD ${interruptedFenceSha})` : ""}, and the current history no longer matches the recorded pre-apply state. Cannot safely determine whether the commit series landed.`, + note: "Inspect the target history. If the child's work is fully present, re-run with acknowledge_partial_recovery=true. If the interrupted attempt applied nothing, re-run with force=true to apply the artifact on the current HEAD, or reset the branch to the recorded pre-apply HEAD and retry.", + }); + shouldStopAfterFailure = true; + continue; + } + // HEAD unchanged: nothing landed; fall through to a fresh apply. + } + + // Applying only the captured content would silently omit the child's + // uncaptured work, and workflows would checkpoint the step as applied. + // Fail (dry runs too, to stay predictive) until the user recovers the + // uncaptured changes and acknowledges. A present partial marker means an + // earlier attempt already passed this gate; blocking here would prevent + // completing its recovery. + const uncapturedNote = worktreeCaptureSkippedNote(projectArtifact); + if (uncapturedNote != null && !acknowledgeUncapturedChanges && !hasPartialMarker) { + projectResults.push({ + projectPath: projectArtifact.projectPath, + projectName: projectArtifact.projectName, + status: "failed", + error: `${uncapturedNote} Applying only the captured content would silently omit that work.`, + note: mergeNotes( + artifactLookupNote, + "Recover the uncaptured changes manually from the child workspace (preserved while they remain unrecovered), then re-run with acknowledge_uncaptured_changes=true to apply the captured content." + ), + }); + shouldStopAfterFailure = true; + continue; + } + const applyResult = await applyProjectPatch({ taskId, workspaceId, @@ -1795,6 +3280,7 @@ export async function applyTaskGitPatchArtifact( projectArtifact, artifactWorkspaceId, artifactSessionDir, + workspaceSessionDir, artifactLookupNote, dryRun, threeWay, @@ -1802,6 +3288,8 @@ export async function applyTaskGitPatchArtifact( expectedHeadSha, isReplay, abortSignal: options.abortSignal, + completePartialWorktreeOnly, + partialCompletionFenceSha, }); projectResults.push(applyResult.projectResult); if (!applyResult.success) { @@ -1849,6 +3337,26 @@ export async function applyTaskGitPatchArtifact( ); } + // Backstop for a sweep that acknowledged nothing (the precheck above + // handles the common case before any repository is touched). A sweep + // stopped by a real per-project failure reports that failure instead. + if (acknowledgePartialRecovery && acknowledgedPartialCount === 0 && !shouldStopAfterFailure) { + return parseToolResult( + TaskApplyGitPatchToolResultSchema, + { + success: false as const, + taskId, + dryRun, + projectResults, + error: + "acknowledge_partial_recovery was set, but no partial application is recorded for any project; nothing to acknowledge.", + note: overallNote, + ...legacyFields, + }, + "task_apply_git_patch" + ); + } + return parseToolResult( TaskApplyGitPatchToolResultSchema, { diff --git a/src/node/services/tools/task_await.test.ts b/src/node/services/tools/task_await.test.ts index f270737082..73205a2a19 100644 --- a/src/node/services/tools/task_await.test.ts +++ b/src/node/services/tools/task_await.test.ts @@ -404,11 +404,23 @@ describe("task_await tool", () => { status: "ready", commitCount: 1, mboxPath: "/tmp/project-a/series.mbox", + hadUncommittedChanges: true, + worktreePatchPath: "/tmp/project-a/worktree.patch", + worktreePatchBytes: 42, + }, + { + projectPath: "/tmp/project-b", + projectName: "project-b", + storageKey: "project-b", + status: "skipped", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchSkippedReason: "diff exceeded the capture cap", }, ], readyProjectCount: 1, failedProjectCount: 0, - skippedProjectCount: 0, + skippedProjectCount: 1, totalCommitCount: 1, } as const; diff --git a/src/node/services/workflows/WorkflowTaskServiceAdapter.test.ts b/src/node/services/workflows/WorkflowTaskServiceAdapter.test.ts index 184f628b54..654a025ab1 100644 --- a/src/node/services/workflows/WorkflowTaskServiceAdapter.test.ts +++ b/src/node/services/workflows/WorkflowTaskServiceAdapter.test.ts @@ -544,6 +544,498 @@ describe("WorkflowTaskServiceAdapter", () => { ]); }); + test("rejects worktree diffs outside allowed path prefixes even when the mbox passes", async () => { + using tmp = new DisposableTempDir("workflow-adapter-worktree-paths"); + const artifactDir = path.join(tmp.path, "subagent-patches", "task_impl", "repo"); + await fs.mkdir(artifactDir, { recursive: true }); + const mboxPath = path.join(artifactDir, "series.mbox"); + await fs.writeFile( + mboxPath, + [ + "diff --git a/.mux/security/runs/latest b/.mux/security/runs/latest", + "--- a/.mux/security/runs/latest", + "+++ b/.mux/security/runs/latest", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n") + ); + const worktreePatchPath = path.join(artifactDir, "worktree.patch"); + await fs.writeFile( + worktreePatchPath, + [ + "diff --git a/src/escape.ts b/src/escape.ts", + "--- a/src/escape.ts", + "+++ b/src/escape.ts", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n") + ); + await fs.writeFile( + path.join(tmp.path, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_impl: { + childTaskId: "task_impl", + parentWorkspaceId: "parent_1", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + mboxPath, + commitCount: 1, + hadUncommittedChanges: true, + worktreePatchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }) + ); + const create = mock(async () => + Ok({ taskId: "task_1", kind: "agent" as const, status: "running" as const }) + ); + const waitForAgentReport = mock(async () => ({ reportMarkdown: "unused" })); + const applyPatchArtifact = mock(async () => ({ + success: true as const, + taskId: "task_impl", + projectResults: [], + })); + const patchToolConfig: TaskApplyGitPatchConfiguration = { + cwd: "/repo", + runtime: undefined as unknown as TaskApplyGitPatchConfiguration["runtime"], + runtimeTempDir: "/tmp", + workspaceSessionDir: tmp.path, + }; + const adapter = new WorkflowTaskServiceAdapter({ + taskService: { create, waitForAgentReport }, + parentWorkspaceId: "parent_1", + workflowRunId: "wfr_123", + defaultAgentId: "explore", + getProjectTrusted: () => true, + patchToolConfig, + applyPatchArtifact, + }); + + const result = await adapter.applyPatch({ + id: "apply-security-state", + sourceTaskId: "task_impl", + target: "parent", + threeWay: true, + force: true, + allowedPathPrefixes: [".mux/security"], + }); + + expect(result).toMatchObject({ success: false, taskId: "task_impl" }); + expect(result.success ? "" : result.error).toContain("src/escape.ts"); + }); + + test("validates worktree-only patch artifacts against allowed path prefixes", async () => { + using tmp = new DisposableTempDir("workflow-adapter-worktree-only-paths"); + const artifactDir = path.join(tmp.path, "subagent-patches", "task_impl", "repo"); + await fs.mkdir(artifactDir, { recursive: true }); + const worktreePatchPath = path.join(artifactDir, "worktree.patch"); + await fs.writeFile( + worktreePatchPath, + [ + "diff --git a/.mux/security/runs/latest b/.mux/security/runs/latest", + "--- a/.mux/security/runs/latest", + "+++ b/.mux/security/runs/latest", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n") + ); + await fs.writeFile( + path.join(tmp.path, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_impl: { + childTaskId: "task_impl", + parentWorkspaceId: "parent_1", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }) + ); + const create = mock(async () => + Ok({ taskId: "task_1", kind: "agent" as const, status: "running" as const }) + ); + const waitForAgentReport = mock(async () => ({ reportMarkdown: "unused" })); + const applyPatchArtifact = mock(async (args: { dry_run?: boolean | null }) => ({ + success: true as const, + taskId: "task_impl", + dryRun: args.dry_run === true, + projectResults: [], + })); + const patchToolConfig: TaskApplyGitPatchConfiguration = { + cwd: "/repo", + runtime: undefined as unknown as TaskApplyGitPatchConfiguration["runtime"], + runtimeTempDir: "/tmp", + workspaceSessionDir: tmp.path, + }; + const adapter = new WorkflowTaskServiceAdapter({ + taskService: { create, waitForAgentReport }, + parentWorkspaceId: "parent_1", + workflowRunId: "wfr_123", + defaultAgentId: "explore", + getProjectTrusted: () => true, + patchToolConfig, + applyPatchArtifact, + }); + + // In-prefix worktree-only artifact passes validation (no "missing mbox"). + const result = await adapter.applyPatch({ + id: "apply-security-state", + sourceTaskId: "task_impl", + target: "parent", + threeWay: true, + force: true, + allowedPathPrefixes: [".mux/security"], + }); + + expect(result).toMatchObject({ success: true }); + expect(applyPatchArtifact).toHaveBeenCalled(); + }); + + test("validates a canonical worktree patch even when worktreePatchPath metadata is absent", async () => { + using tmp = new DisposableTempDir("workflow-adapter-canonical-worktree"); + const artifactDir = path.join(tmp.path, "subagent-patches", "task_impl", "repo"); + await fs.mkdir(artifactDir, { recursive: true }); + const mboxPath = path.join(artifactDir, "series.mbox"); + await fs.writeFile( + mboxPath, + [ + "diff --git a/.mux/security/runs/latest b/.mux/security/runs/latest", + "--- a/.mux/security/runs/latest", + "+++ b/.mux/security/runs/latest", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n") + ); + // Canonical worktree.patch on disk touching a disallowed path; the + // artifact metadata omits worktreePatchPath (e.g. sanitized away), but + // the apply path would still resolve and apply this file. + await fs.writeFile( + path.join(artifactDir, "worktree.patch"), + [ + "diff --git a/src/escape.ts b/src/escape.ts", + "--- a/src/escape.ts", + "+++ b/src/escape.ts", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n") + ); + await fs.writeFile( + path.join(tmp.path, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_impl: { + childTaskId: "task_impl", + parentWorkspaceId: "parent_1", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + mboxPath, + commitCount: 1, + hadUncommittedChanges: true, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }) + ); + const create = mock(async () => + Ok({ taskId: "task_1", kind: "agent" as const, status: "running" as const }) + ); + const waitForAgentReport = mock(async () => ({ reportMarkdown: "unused" })); + const applyPatchArtifact = mock(async (args: { dry_run?: boolean | null }) => ({ + success: true as const, + taskId: "task_impl", + dryRun: args.dry_run === true, + projectResults: [], + })); + const patchToolConfig: TaskApplyGitPatchConfiguration = { + cwd: "/repo", + runtime: undefined as unknown as TaskApplyGitPatchConfiguration["runtime"], + runtimeTempDir: "/tmp", + workspaceSessionDir: tmp.path, + }; + const adapter = new WorkflowTaskServiceAdapter({ + taskService: { create, waitForAgentReport }, + parentWorkspaceId: "parent_1", + workflowRunId: "wfr_123", + defaultAgentId: "explore", + getProjectTrusted: () => true, + patchToolConfig, + applyPatchArtifact, + }); + + const result = await adapter.applyPatch({ + id: "apply-security-state", + sourceTaskId: "task_impl", + target: "parent", + threeWay: true, + force: true, + allowedPathPrefixes: [".mux/security"], + }); + + expect(result).toMatchObject({ success: false, taskId: "task_impl" }); + expect(result.success ? "" : result.error).toContain("src/escape.ts"); + // Only the pre-validation dry-run ran; the violation blocked the real apply. + expect(applyPatchArtifact.mock.calls.map(([args]) => args.dry_run)).toEqual([true]); + }); + + test("accepts a mixed quoted-destination rename header inside the allowed prefix", async () => { + using tmp = new DisposableTempDir("workflow-adapter-mixed-quoted-rename"); + const artifactDir = path.join(tmp.path, "subagent-patches", "task_impl", "repo"); + await fs.mkdir(artifactDir, { recursive: true }); + const worktreePatchPath = path.join(artifactDir, "worktree.patch"); + // Rename of an ordinary name onto one needing C-quoting: git quotes only + // the destination, so the header mixes an unquoted and a quoted operand. + // Both paths sit inside the allowed prefix; the parser must not reject + // the artifact with the unparseable-header sentinel. + await fs.writeFile( + worktreePatchPath, + [ + 'diff --git a/.mux/security/foo "b/.mux/security/f\\303\\263o"', + "similarity index 100%", + "rename from .mux/security/foo", + 'rename to ".mux/security/f\\303\\263o"', + ].join("\n") + ); + await fs.writeFile( + path.join(tmp.path, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_impl: { + childTaskId: "task_impl", + parentWorkspaceId: "parent_1", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }) + ); + const create = mock(async () => + Ok({ taskId: "task_1", kind: "agent" as const, status: "running" as const }) + ); + const waitForAgentReport = mock(async () => ({ reportMarkdown: "unused" })); + const applyPatchArtifact = mock(async (args: { dry_run?: boolean | null }) => ({ + success: true as const, + taskId: "task_impl", + dryRun: args.dry_run === true, + projectResults: [], + })); + const patchToolConfig: TaskApplyGitPatchConfiguration = { + cwd: "/repo", + runtime: undefined as unknown as TaskApplyGitPatchConfiguration["runtime"], + runtimeTempDir: "/tmp", + workspaceSessionDir: tmp.path, + }; + const adapter = new WorkflowTaskServiceAdapter({ + taskService: { create, waitForAgentReport }, + parentWorkspaceId: "parent_1", + workflowRunId: "wfr_123", + defaultAgentId: "explore", + getProjectTrusted: () => true, + patchToolConfig, + applyPatchArtifact, + }); + + const result = await adapter.applyPatch({ + id: "apply-security-state", + sourceTaskId: "task_impl", + target: "parent", + threeWay: true, + force: true, + allowedPathPrefixes: [".mux/security"], + }); + + expect(result).toMatchObject({ success: true }); + expect(applyPatchArtifact).toHaveBeenCalled(); + }); + + test("rejects unquoted diff header paths with spaces that escape the allowed prefix", async () => { + using tmp = new DisposableTempDir("workflow-adapter-space-paths"); + const artifactDir = path.join(tmp.path, "subagent-patches", "task_impl", "repo"); + await fs.mkdir(artifactDir, { recursive: true }); + const escapePatchPath = path.join(artifactDir, "escape.patch"); + // Binary-only patch: no ---/+++ lines, so the diff --git header is the + // only path source. The file name contains a space and sits OUTSIDE the + // allowed prefix (a sibling of .mux/security, not inside it). + await fs.writeFile( + escapePatchPath, + [ + "diff --git a/.mux/security .mux/security b/.mux/security .mux/security", + "new file mode 100644", + "index 0000000..ce01362", + "GIT binary patch", + "literal 6", + "NcmZQzU|?i;0000L0L}mS", + "", + "literal 0", + "HcmV?d00001", + "", + ].join("\n") + ); + const insidePatchPath = path.join(artifactDir, "inside.patch"); + await fs.writeFile( + insidePatchPath, + [ + "diff --git a/.mux/security/run log.txt b/.mux/security/run log.txt", + "--- a/.mux/security/run log.txt\t", + "+++ b/.mux/security/run log.txt\t", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n") + ); + const projectArtifactFor = (worktreePatchPath: string) => ({ + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath, + }); + await fs.writeFile( + path.join(tmp.path, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_escape: { + childTaskId: "task_escape", + parentWorkspaceId: "parent_1", + createdAtMs: 1, + status: "ready", + projectArtifacts: [projectArtifactFor(escapePatchPath)], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + task_inside: { + childTaskId: "task_inside", + parentWorkspaceId: "parent_1", + createdAtMs: 1, + status: "ready", + projectArtifacts: [projectArtifactFor(insidePatchPath)], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }) + ); + const create = mock(async () => + Ok({ taskId: "task_1", kind: "agent" as const, status: "running" as const }) + ); + const waitForAgentReport = mock(async () => ({ reportMarkdown: "unused" })); + const applyPatchArtifact = mock(async (args: { dry_run?: boolean | null }) => ({ + success: true as const, + taskId: "task_escape", + dryRun: args.dry_run === true, + projectResults: [], + })); + const patchToolConfig: TaskApplyGitPatchConfiguration = { + cwd: "/repo", + runtime: undefined as unknown as TaskApplyGitPatchConfiguration["runtime"], + runtimeTempDir: "/tmp", + workspaceSessionDir: tmp.path, + }; + const adapter = new WorkflowTaskServiceAdapter({ + taskService: { create, waitForAgentReport }, + parentWorkspaceId: "parent_1", + workflowRunId: "wfr_123", + defaultAgentId: "explore", + getProjectTrusted: () => true, + patchToolConfig, + applyPatchArtifact, + }); + + const escapeResult = await adapter.applyPatch({ + id: "apply-escape", + sourceTaskId: "task_escape", + target: "parent", + threeWay: true, + force: true, + allowedPathPrefixes: [".mux/security"], + }); + expect(escapeResult.success).toBe(false); + expect(escapeResult.success ? "" : escapeResult.error).toContain(".mux/security .mux/security"); + // Only the dry-run preflight ran; validation blocked the real apply. + expect(applyPatchArtifact.mock.calls.every(([args]) => args.dry_run === true)).toBe(true); + + // A space-containing path INSIDE the prefix still validates. + const insideResult = await adapter.applyPatch({ + id: "apply-inside", + sourceTaskId: "task_inside", + target: "parent", + threeWay: true, + force: true, + allowedPathPrefixes: [".mux/security"], + }); + expect(insideResult).toMatchObject({ success: true }); + }); + test("returns dry-run conflicts without applying workflow patches", async () => { const create = mock(async () => Ok({ taskId: "task_1", kind: "agent" as const, status: "running" as const }) diff --git a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts index ebb423f56f..b6bf19f84a 100644 --- a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts +++ b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts @@ -16,6 +16,7 @@ import type { import { isPathInsideDir } from "@/node/utils/pathUtils"; import { getSubagentGitPatchMboxPath, + getSubagentGitPatchWorktreePatchPath, readSubagentGitPatchArtifact, } from "@/node/services/subagentGitPatchArtifacts"; import { @@ -25,6 +26,11 @@ import { type TaskApplyGitPatchConfiguration, type TaskApplyGitPatchResult, } from "@/node/services/tools/task_apply_git_patch"; +import { + parseDiffGitHeaderLine, + parsePatchMetadataPath, + stripDiffPathPrefix, +} from "@/node/services/gitPatchPathParsing"; export const DEFAULT_WORKFLOW_AGENT_ID = "exec"; @@ -272,19 +278,48 @@ export class WorkflowTaskServiceAdapter implements WorkflowTaskAdapter { if (projectArtifact.status !== "ready") { return `Patch artifact for ${projectArtifact.projectName} is ${projectArtifact.status}; cannot validate allowedPathPrefixes.`; } - const patchPath = await this.getProjectPatchMboxPath( + // Validate every patch file the apply would use: the commit-series mbox + // and the uncommitted-changes worktree diff. Both are attacker-controlled. + const patchFilePaths: string[] = []; + const hasCommitPatch = + projectArtifact.commitCount !== 0 || + (typeof projectArtifact.mboxPath === "string" && projectArtifact.mboxPath.length > 0); + if (hasCommitPatch) { + const mboxPath = await this.getProjectPatchMboxPath( + artifactLookup.artifactSessionDir, + spec.sourceTaskId, + projectArtifact + ); + if (mboxPath == null) { + return `Patch file is missing for task ${spec.sourceTaskId}`; + } + patchFilePaths.push(mboxPath); + } + // Probed regardless of metadata: the apply path resolves the canonical + // worktree patch even when worktreePatchPath was sanitized away, so + // skipping validation here would let that file bypass the allowlist. + const worktreePatchPath = await this.getProjectWorktreePatchPath( artifactLookup.artifactSessionDir, spec.sourceTaskId, projectArtifact ); - if (patchPath == null) { - return `Patch file is missing for task ${spec.sourceTaskId}`; + if (worktreePatchPath != null) { + patchFilePaths.push(worktreePatchPath); + } else if ( + typeof projectArtifact.worktreePatchPath === "string" && + projectArtifact.worktreePatchPath.length > 0 + ) { + return `Uncommitted-changes patch file is missing for task ${spec.sourceTaskId}`; + } + if (patchFilePaths.length === 0) { + return `Patch artifact for ${projectArtifact.projectName} has no patch files to validate.`; } - const patchText = await fs.readFile(patchPath, "utf-8"); - const patchPaths = extractGitPatchPaths(patchText); - for (const patchPath of patchPaths) { - if (!isPatchPathAllowed(patchPath, spec.allowedPathPrefixes)) { - violations.add(patchPath); + for (const patchFilePath of patchFilePaths) { + const patchText = await fs.readFile(patchFilePath, "utf-8"); + for (const patchPath of extractGitPatchPaths(patchText)) { + if (!isPatchPathAllowed(patchPath, spec.allowedPathPrefixes)) { + violations.add(patchPath); + } } } } @@ -322,7 +357,30 @@ export class WorkflowTaskServiceAdapter implements WorkflowTaskAdapter { taskId, projectArtifact.storageKey ); - const candidates = [projectArtifact.mboxPath, expectedPatchPath].filter( + return this.findPatchFile(artifactSessionDir, [projectArtifact.mboxPath, expectedPatchPath]); + } + + private async getProjectWorktreePatchPath( + artifactSessionDir: string, + taskId: string, + projectArtifact: { storageKey: string; worktreePatchPath?: string } + ): Promise { + const expectedPatchPath = getSubagentGitPatchWorktreePatchPath( + artifactSessionDir, + taskId, + projectArtifact.storageKey + ); + return this.findPatchFile(artifactSessionDir, [ + projectArtifact.worktreePatchPath, + expectedPatchPath, + ]); + } + + private async findPatchFile( + artifactSessionDir: string, + candidatePaths: Array + ): Promise { + const candidates = candidatePaths.filter( (candidate): candidate is string => typeof candidate === "string" && isPathInsideDir(artifactSessionDir, candidate) ); @@ -526,64 +584,36 @@ export class WorkflowTaskServiceAdapter implements WorkflowTaskAdapter { function extractGitPatchPaths(patchText: string): string[] { const paths = new Set(); - for (const line of patchText.split("\n")) { + for (const rawLine of patchText.split("\n")) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (line.startsWith("diff --git ")) { - const parts = splitGitPatchWords(line.slice("diff --git ".length)); - if (parts.length >= 2) { - addPatchPath(paths, parts[0]); - addPatchPath(paths, parts[1]); - } else { + const headerPaths = parseDiffGitHeaderLine(line.slice("diff --git ".length)); + if (headerPaths.length === 0) { + // Fail closed: an allowlist can never match this sentinel. paths.add(""); } + for (const headerPath of headerPaths) { + addPatchPath(paths, headerPath); + } } else if (line.startsWith("--- ") || line.startsWith("+++ ")) { - addPatchPath(paths, line.slice(4)); + addPatchPath(paths, stripDiffPathPrefix(parseFileHeaderPath(line.slice(4)))); } else if (line.startsWith("rename from ")) { - addPatchPath(paths, line.slice("rename from ".length)); + addPatchPath(paths, parsePatchMetadataPath(line.slice("rename from ".length))); } else if (line.startsWith("rename to ")) { - addPatchPath(paths, line.slice("rename to ".length)); + addPatchPath(paths, parsePatchMetadataPath(line.slice("rename to ".length))); } else if (line.startsWith("copy from ")) { - addPatchPath(paths, line.slice("copy from ".length)); + addPatchPath(paths, parsePatchMetadataPath(line.slice("copy from ".length))); } else if (line.startsWith("copy to ")) { - addPatchPath(paths, line.slice("copy to ".length)); + addPatchPath(paths, parsePatchMetadataPath(line.slice("copy to ".length))); } } return Array.from(paths); } -function splitGitPatchWords(value: string): string[] { - const words: string[] = []; - let current = ""; - let quoted = false; - let escaped = false; - for (const char of value) { - if (escaped) { - current += char; - escaped = false; - continue; - } - if (char === "\\" && quoted) { - current += char; - escaped = true; - continue; - } - if (char === '"') { - current += char; - quoted = !quoted; - continue; - } - if (!quoted && /\s/.test(char)) { - if (current.length > 0) { - words.push(current); - current = ""; - } - continue; - } - current += char; - } - if (current.length > 0) { - words.push(current); - } - return words; +// Git appends a TAB after unquoted `---`/`+++` paths containing spaces. +function parseFileHeaderPath(value: string): string { + const parsed = parsePatchMetadataPath(value); + return parsed.endsWith("\t") ? parsed.slice(0, -1) : parsed; } function addPatchPath(paths: Set, rawPath: string | undefined): void { @@ -597,20 +627,10 @@ function normalizePatchPath(rawPath: string | undefined): string | undefined { if (rawPath == null) { return undefined; } - let value = rawPath.trim(); + const value = rawPath; if (value.length === 0 || value === "/dev/null") { return undefined; } - if (value.startsWith('"') && value.endsWith('"')) { - try { - value = JSON.parse(value) as string; - } catch { - value = value.slice(1, -1); - } - } - if (value.startsWith("a/") || value.startsWith("b/")) { - value = value.slice(2); - } const segments = value.split("/"); if (path.posix.isAbsolute(value) || segments.includes("..")) { return value; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 8c73257b01..9b66e1a07c 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test, mock, beforeEach, afterEach, spyOn, type Mock } from "bun:test"; -import { WorkspaceService, generateForkBranchName, generateForkTitle } from "./workspaceService"; +import { + WorkspaceService, + archiveChildSessionArtifactsIntoParentSessionDir, + generateForkBranchName, + generateForkTitle, +} from "./workspaceService"; import type { IdleCompactionOutcome } from "./idleCompactionService"; import type { AgentSession } from "./agentSession"; import { askUserQuestionManager } from "./askUserQuestionManager"; @@ -10,6 +15,7 @@ import * as fsPromises from "fs/promises"; import { tmpdir } from "os"; import path from "path"; import { Err, Ok, type Result } from "@/common/types/result"; +import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; import type { SendMessageError } from "@/common/types/errors"; @@ -8369,6 +8375,139 @@ describe("WorkspaceService remove timing rollup", () => { }); }); +describe("WorkspaceService remove patch artifact roll-up", () => { + let historyService: HistoryService; + let cleanupHistory: () => Promise; + + beforeEach(async () => { + ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); + }); + + afterEach(async () => { + await cleanupHistory(); + }); + + test("aborts removal when the roll-up fails so a retry can replicate the preserved artifacts", async () => { + const workspaceId = "child-ws"; + const parentWorkspaceId = "parent-ws"; + + const tempRoot = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-remove-rollup-")); + try { + const sessionRoot = path.join(tempRoot, "sessions"); + const childSessionDir = path.join(sessionRoot, workspaceId); + const parentSessionDir = path.join(sessionRoot, parentWorkspaceId); + const childCanonicalDir = path.join(childSessionDir, "subagent-patches", "task_o", "repo"); + await fsPromises.mkdir(childCanonicalDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + const childPatchPath = path.join(childCanonicalDir, "worktree.patch"); + const patchBytes = "grandchild bytes\n"; + await fsPromises.writeFile(childPatchPath, patchBytes); + const childIndexPath = path.join(childSessionDir, "subagent-patches.json"); + await fsPromises.writeFile( + childIndexPath, + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_o: { + childTaskId: "task_o", + parentWorkspaceId: workspaceId, + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath: childPatchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + // A directory at the parent's artifacts-file path makes the roll-up's + // metadata write fail, so replication reports failure. + const parentIndexPath = path.join(parentSessionDir, "subagent-patches.json"); + await fsPromises.mkdir(parentIndexPath); + + const aiService = { + isStreaming: mock(() => false), + stopStream: mock(() => Promise.resolve(Ok(undefined))), + getWorkspaceMetadata: mock(() => + Promise.resolve({ + success: true as const, + data: { + id: workspaceId, + name: "child", + projectPath: "/tmp/proj", + runtimeConfig: { type: "local" }, + parentWorkspaceId, + }, + }) + ), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const removeWorkspaceMock = mock(() => Promise.resolve()); + const mockConfig: Partial = { + srcDir: "/tmp/src", + getSessionDir: mock((id: string) => path.join(sessionRoot, id)), + removeWorkspace: removeWorkspaceMock, + findWorkspace: mock(() => null), + loadConfigOrDefault: mock(() => ({ projects: new Map() })), + }; + + const workspaceService = new WorkspaceService( + mockConfig as Config, + historyService, + aiService, + mockInitStateManager as InitStateManager, + mockExtensionMetadataService as ExtensionMetadataService, + mockBackgroundProcessManager as BackgroundProcessManager + ); + + // Even a forced removal must abort: the preserved dir is only + // reachable through this workspace's config entry. + const removeResult = await workspaceService.remove(workspaceId, true); + expect(removeResult.success).toBe(false); + if (removeResult.success) return; + expect(removeResult.error).toContain("roll up"); + expect(removeWorkspaceMock).not.toHaveBeenCalled(); + expect(await fsPromises.readFile(childPatchPath, "utf-8")).toBe(patchBytes); + + // Clearing the failure and retrying completes the removal and + // replicates the artifacts into the parent. + await fsPromises.rmdir(parentIndexPath); + const retryResult = await workspaceService.remove(workspaceId, true); + expect(retryResult.success).toBe(true); + expect(removeWorkspaceMock).toHaveBeenCalledWith(workspaceId); + expect( + await fsPromises.readFile( + path.join(parentSessionDir, "subagent-patches", "task_o", "repo", "worktree.patch"), + "utf-8" + ) + ).toBe(patchBytes); + expect( + await fsPromises + .stat(childSessionDir) + .then(() => true) + .catch(() => false) + ).toBe(false); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + }); +}); + describe("WorkspaceService remove shared-workspace guard", () => { const projectPath = "/tmp/proj-shared"; const workspaceId = "child-shared"; @@ -13936,3 +14075,1510 @@ describe("WorkspaceService.getLastUserPrompt", () => { expect(prompt).toBe("newest prompt"); }); }); + +describe("archiveChildSessionArtifactsIntoParentSessionDir", () => { + test("rolls up a canonical worktree patch when patch-path metadata is missing", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-canonical-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const canonicalDir = path.join(childSessionDir, "subagent-patches", "task_g", "repo"); + await fsPromises.mkdir(canonicalDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + const patchBody = "diff --git a/dirty.txt b/dirty.txt\n"; + await fsPromises.writeFile(path.join(canonicalDir, "worktree.patch"), patchBody, "utf-8"); + // Dirty-only artifact whose worktreePatchPath was sanitized away: the + // canonical worktree.patch above is the only surviving copy of the + // child's uncommitted work. + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_g: { + childTaskId: "task_g", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + const rolledUpPatch = path.join( + parentSessionDir, + "subagent-patches", + "task_g", + "repo", + "worktree.patch" + ); + expect(await fsPromises.readFile(rolledUpPatch, "utf-8")).toBe(patchBody); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("restores swapped files when the parent metadata write fails", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-metawrite-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childCanonicalDir = path.join(childSessionDir, "subagent-patches", "task_w", "repo"); + const parentCanonicalDir = path.join(parentSessionDir, "subagent-patches", "task_w", "repo"); + await fsPromises.mkdir(childCanonicalDir, { recursive: true }); + await fsPromises.mkdir(parentCanonicalDir, { recursive: true }); + const oldBytes = "old parent bytes\n"; + const newBytes = "new child bytes\n"; + const childPatchPath = path.join(childCanonicalDir, "worktree.patch"); + await fsPromises.writeFile(childPatchPath, newBytes); + await fsPromises.writeFile(path.join(parentCanonicalDir, "worktree.patch"), oldBytes); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_w: { + childTaskId: "task_w", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath: childPatchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + // A read-only parent session dir: the (strict) index read finds no + // file, replication succeeds inside the pre-created writable + // subagent-patches subtree (the forced metadata-referenced copy + // replaces the old bytes), and only the metadata write fails (its + // temp file cannot be created). Whatever metadata survives the + // failed write describes the OLD files, so the swapped dirs must be + // restored from their backups; without propagation the roll-up would + // also report success and cleanup would delete the child. + await fsPromises.chmod(parentSessionDir, 0o555); + let result: { patchArtifactsReplicated: boolean }; + try { + result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + } finally { + await fsPromises.chmod(parentSessionDir, 0o755); + } + + expect(result.patchArtifactsReplicated).toBe(false); + // The swap happened before the metadata write failed, so the backup + // restore must have brought the old bytes back. + expect( + await fsPromises.readFile(path.join(parentCanonicalDir, "worktree.patch"), "utf-8") + ).toBe(oldBytes); + // No staging or backup dirs linger next to the destination. + const taskDirEntries = await fsPromises.readdir( + path.join(parentSessionDir, "subagent-patches", "task_w") + ); + expect(taskDirEntries).toEqual(["repo"]); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("reports replication failure when the child patch index is unreadable", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-badindex-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + // The child's patch dir holds real bytes; only the index is corrupt. + const childPatchDir = path.join(childSessionDir, "subagent-patches", "task_x", "repo"); + await fsPromises.mkdir(childPatchDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + await fsPromises.writeFile(path.join(childPatchDir, "worktree.patch"), "child bytes\n"); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + '{"version":2,"artifactsByChildTaskId":{', + "utf-8" + ); + + const result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + // An unreadable index read as empty would report the roll-up complete + // and let removal delete the only copies of the referenced patches. + expect(result.patchArtifactsReplicated).toBe(false); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("reports replication failure when a child patch index entry is malformed", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-badentry-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childPatchDir = path.join(childSessionDir, "subagent-patches", "task_m", "repo"); + await fsPromises.mkdir(childPatchDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + await fsPromises.writeFile(path.join(childPatchDir, "worktree.patch"), "child bytes\n"); + // Parseable file, malformed entry: the lenient read would drop the + // entry and report the roll-up complete with nothing replicated. + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_m: { + childTaskId: "task_m", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: "corrupt", + }, + }, + }), + "utf-8" + ); + + const result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + expect(result.patchArtifactsReplicated).toBe(false); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("reports replication failure when the parent patch index is malformed", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-badparent-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childPatchDir = path.join(childSessionDir, "subagent-patches", "task_p", "repo"); + await fsPromises.mkdir(childPatchDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + const childPatchPath = path.join(childPatchDir, "worktree.patch"); + await fsPromises.writeFile(childPatchPath, "child bytes\n"); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_p: { + childTaskId: "task_p", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath: childPatchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + // The parent index holds a sibling entry with malformed contents. The + // default self-healing read would drop it; persisting that reduced map + // during the roll-up would orphan the sibling's patches. + const parentIndexPath = path.join(parentSessionDir, "subagent-patches.json"); + const malformedParentIndex = JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_sibling: { + childTaskId: "task_sibling", + parentWorkspaceId: "parent_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: "corrupt", + }, + }, + }); + await fsPromises.writeFile(parentIndexPath, malformedParentIndex, "utf-8"); + + const result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + expect(result.patchArtifactsReplicated).toBe(false); + // The malformed parent index survives untouched for manual recovery. + expect(await fsPromises.readFile(parentIndexPath, "utf-8")).toBe(malformedParentIndex); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("preserves swap backups when the post-metadata-failure restore also fails", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-restorefail-")); + const realRename = fsPromises.rename.bind(fsPromises); + const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (src, dest) => { + // Fail only the restore rename (backup -> destination); forward-path + // renames never have a backup dir as their source. + if (String(src).includes(`${path.sep}backup-`)) { + throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + } + return realRename(src, dest); + }); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childCanonicalDir = path.join(childSessionDir, "subagent-patches", "task_b", "repo"); + const parentCanonicalDir = path.join(parentSessionDir, "subagent-patches", "task_b", "repo"); + await fsPromises.mkdir(childCanonicalDir, { recursive: true }); + await fsPromises.mkdir(parentCanonicalDir, { recursive: true }); + const oldBytes = "old parent bytes\n"; + const childPatchPath = path.join(childCanonicalDir, "worktree.patch"); + await fsPromises.writeFile(childPatchPath, "new child bytes\n"); + await fsPromises.writeFile(path.join(parentCanonicalDir, "worktree.patch"), oldBytes); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_b: { + childTaskId: "task_b", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath: childPatchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + // Read-only parent session dir: fails only the metadata write (same + // trigger as the restore-success test above). + await fsPromises.chmod(parentSessionDir, 0o555); + let result: { patchArtifactsReplicated: boolean }; + try { + result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + } finally { + await fsPromises.chmod(parentSessionDir, 0o755); + } + + expect(result.patchArtifactsReplicated).toBe(false); + // The restore failed, so the staging root holding the backup must + // survive cleanup: it is the only copy of the bytes the surviving old + // metadata points at. + const taskDir = path.join(parentSessionDir, "subagent-patches", "task_b"); + const stagingRoots = (await fsPromises.readdir(taskDir)).filter((name) => + name.startsWith(".rollup-staging-") + ); + expect(stagingRoots).toHaveLength(1); + expect( + await fsPromises.readFile( + path.join(taskDir, stagingRoots[0], "backup-0", "worktree.patch"), + "utf-8" + ) + ).toBe(oldBytes); + } finally { + renameSpy.mockRestore(); + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("preserves swap backups when a swap-failure restore fails mid-replication", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-swapfail-")); + const realRename = fsPromises.rename.bind(fsPromises); + const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (src, dest) => { + // Project B's swap fails after project A already swapped, and project + // A's backup restore then fails too. + if (String(src).endsWith(`${path.sep}repo-b`) || String(src).includes(`${path.sep}backup-`)) { + throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + } + return realRename(src, dest); + }); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childDirA = path.join(childSessionDir, "subagent-patches", "task_v", "repo-a"); + const childDirB = path.join(childSessionDir, "subagent-patches", "task_v", "repo-b"); + const parentDirA = path.join(parentSessionDir, "subagent-patches", "task_v", "repo-a"); + const parentDirB = path.join(parentSessionDir, "subagent-patches", "task_v", "repo-b"); + for (const dir of [childDirA, childDirB, parentDirA, parentDirB]) { + await fsPromises.mkdir(dir, { recursive: true }); + } + const oldBytesA = "old parent bytes for repo-a\n"; + const patchPathA = path.join(childDirA, "worktree.patch"); + const patchPathB = path.join(childDirB, "worktree.patch"); + await fsPromises.writeFile(patchPathA, "new child bytes for repo-a\n"); + await fsPromises.writeFile(patchPathB, "new child bytes for repo-b\n"); + await fsPromises.writeFile(path.join(parentDirA, "worktree.patch"), oldBytesA); + await fsPromises.writeFile(path.join(parentDirB, "worktree.patch"), "old parent bytes b\n"); + const projectArtifact = (storageKey: string, worktreePatchPath: string) => ({ + projectPath: `/${storageKey}`, + projectName: storageKey, + storageKey, + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath, + }); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_v: { + childTaskId: "task_v", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + projectArtifact("repo-a", patchPathA), + projectArtifact("repo-b", patchPathB), + ], + readyProjectCount: 2, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + const result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + expect(result.patchArtifactsReplicated).toBe(false); + // Repo A swapped, repo B's swap failed, and repo A's restore failed: + // the staging root with repo A's backup must survive cleanup. + const taskDir = path.join(parentSessionDir, "subagent-patches", "task_v"); + const stagingRoots = (await fsPromises.readdir(taskDir)).filter((name) => + name.startsWith(".rollup-staging-") + ); + expect(stagingRoots).toHaveLength(1); + expect( + await fsPromises.readFile( + path.join(taskDir, stagingRoots[0], "backup-0", "worktree.patch"), + "utf-8" + ) + ).toBe(oldBytesA); + // Failed replication keeps the parent metadata free of this task. + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { artifactsByChildTaskId: Record }; + expect(parentFile.artifactsByChildTaskId.task_v).toBeUndefined(); + } finally { + renameSpy.mockRestore(); + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("preserves existing parent patch bytes when a later copy in the task fails", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-partial-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childDirA = path.join(childSessionDir, "subagent-patches", "task_p", "repo-a"); + const childDirB = path.join(childSessionDir, "subagent-patches", "task_p", "repo-b"); + const parentDirA = path.join(parentSessionDir, "subagent-patches", "task_p", "repo-a"); + for (const dir of [childDirA, childDirB, parentDirA]) { + await fsPromises.mkdir(dir, { recursive: true }); + } + const oldBytesA = "old parent bytes for repo-a\n"; + const newBytesA = "new child bytes for repo-a\n"; + const patchPathA = path.join(childDirA, "worktree.patch"); + await fsPromises.writeFile(patchPathA, newBytesA, "utf-8"); + await fsPromises.writeFile(path.join(parentDirA, "worktree.patch"), oldBytesA, "utf-8"); + // Project B's metadata-referenced patch path is a DIRECTORY: its + // forced copy fails after project A's copy already staged. + const patchPathB = path.join(childDirB, "worktree.patch"); + await fsPromises.mkdir(patchPathB, { recursive: true }); + const projectArtifact = (storageKey: string, worktreePatchPath: string) => ({ + projectPath: `/${storageKey}`, + projectName: storageKey, + storageKey, + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath, + }); + // The parent holds an OLDER entry for the same task, so the child is + // fresher and replication is attempted. + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_p: { + childTaskId: "task_p", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + updatedAtMs: 200, + status: "ready", + projectArtifacts: [ + projectArtifact("repo-a", patchPathA), + projectArtifact("repo-b", patchPathB), + ], + readyProjectCount: 2, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + await fsPromises.writeFile( + path.join(parentSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_p: { + childTaskId: "task_p", + parentWorkspaceId: "x", + createdAtMs: 1, + updatedAtMs: 100, + status: "ready", + projectArtifacts: [ + projectArtifact("repo-a", path.join(parentDirA, "worktree.patch")), + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + const result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + // Copies are staged, so project A's earlier success must not have + // touched the bytes the retained old metadata points at. + expect(result.patchArtifactsReplicated).toBe(false); + expect(await fsPromises.readFile(path.join(parentDirA, "worktree.patch"), "utf-8")).toBe( + oldBytesA + ); + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { artifactsByChildTaskId: Record }; + expect(parentFile.artifactsByChildTaskId.task_p?.updatedAtMs).toBe(100); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("reports failed patch replication and keeps parent metadata untouched", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-replfail-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childCanonicalDir = path.join(childSessionDir, "subagent-patches", "task_r", "repo"); + await fsPromises.mkdir(childCanonicalDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + const patchPath = path.join(childCanonicalDir, "worktree.patch"); + await fsPromises.writeFile(patchPath, "diff --git a/dirty.txt b/dirty.txt\n", "utf-8"); + // A regular FILE at the parent's patches root makes every destination + // mkdir/cp fail with ENOTDIR: a persistent replication failure. + await fsPromises.writeFile(path.join(parentSessionDir, "subagent-patches"), "not a dir"); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_r: { + childTaskId: "task_r", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath: patchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + const result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + // The caller must preserve the child session dir (the only surviving + // copy), and parent metadata must not point at never-copied files. + expect(result.patchArtifactsReplicated).toBe(false); + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { artifactsByChildTaskId: Record }; + expect(parentFile.artifactsByChildTaskId.task_r).toBeUndefined(); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("serializes patch replication with the parent artifact-file lock", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-lock-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childCanonicalDir = path.join(childSessionDir, "subagent-patches", "task_t", "repo"); + const parentCanonicalDir = path.join(parentSessionDir, "subagent-patches", "task_t", "repo"); + await fsPromises.mkdir(childCanonicalDir, { recursive: true }); + await fsPromises.mkdir(parentCanonicalDir, { recursive: true }); + const parentPatchPath = path.join(parentCanonicalDir, "worktree.patch"); + await fsPromises.writeFile( + path.join(childCanonicalDir, "worktree.patch"), + "stale child bytes\n", + "utf-8" + ); + await fsPromises.writeFile(parentPatchPath, "old parent bytes\n", "utf-8"); + const artifactsFile = (updatedAtMs: number, worktreePatchPath: string) => + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_t: { + childTaskId: "task_t", + parentWorkspaceId: "x", + createdAtMs: 1, + updatedAtMs, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + artifactsFile(150, path.join(childCanonicalDir, "worktree.patch")), + "utf-8" + ); + await fsPromises.writeFile( + path.join(parentSessionDir, "subagent-patches.json"), + artifactsFile(100, parentPatchPath), + "utf-8" + ); + + // Hold the parent's artifact-file lock and, while holding it, act as a + // lock-respecting concurrent writer that lands a NEWER artifact. + let releaseLock!: () => void; + const lockHeld = new Promise((resolve) => (releaseLock = resolve)); + let lockAcquired!: () => void; + const acquired = new Promise((resolve) => (lockAcquired = resolve)); + const lockPromise = workspaceFileLocks.withLock("parent_ws", async () => { + lockAcquired(); + await lockHeld; + await fsPromises.writeFile( + path.join(parentSessionDir, "subagent-patches.json"), + artifactsFile(200, parentPatchPath), + "utf-8" + ); + await fsPromises.writeFile(parentPatchPath, "newer parent bytes\n", "utf-8"); + }); + await acquired; + + const archivePromise = archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + // Replication must not run while the lock is held: a freshness + // decision made before the concurrent write would authorize copying + // stale child bytes over the newer artifact's files. + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(await fsPromises.readFile(parentPatchPath, "utf-8")).toBe("old parent bytes\n"); + + releaseLock(); + await lockPromise; + await archivePromise; + + // The locked freshness decision saw the concurrent 200 write: the + // stale child (150) was skipped entirely. + expect(await fsPromises.readFile(parentPatchPath, "utf-8")).toBe("newer parent bytes\n"); + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { artifactsByChildTaskId: Record }; + expect(parentFile.artifactsByChildTaskId.task_t?.updatedAtMs).toBe(200); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("does not overwrite a newer parent artifact with a stale child roll-up", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-stale-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childCanonicalDir = path.join(childSessionDir, "subagent-patches", "task_s", "repo"); + const parentCanonicalDir = path.join(parentSessionDir, "subagent-patches", "task_s", "repo"); + await fsPromises.mkdir(childCanonicalDir, { recursive: true }); + await fsPromises.mkdir(parentCanonicalDir, { recursive: true }); + const staleBody = "diff --git a/stale.txt b/stale.txt\n"; + const newerBody = "diff --git a/newer.txt b/newer.txt\n"; + await fsPromises.writeFile( + path.join(childCanonicalDir, "worktree.patch"), + staleBody, + "utf-8" + ); + await fsPromises.writeFile( + path.join(parentCanonicalDir, "worktree.patch"), + newerBody, + "utf-8" + ); + const projectArtifact = (worktreePatchPath: string) => ({ + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath, + }); + const artifactsFile = (opts: { + sessionDir: string; + updatedAtMs: number; + worktreePatchPath: string; + }) => + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_s: { + childTaskId: "task_s", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + updatedAtMs: opts.updatedAtMs, + status: "ready", + projectArtifacts: [projectArtifact(opts.worktreePatchPath)], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }); + // The parent already holds a NEWER artifact for the same task and + // storage key (e.g. the task re-ran); the stale child roll-up must + // not overwrite its patch bytes while cleanup deletes the source. + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + artifactsFile({ + sessionDir: childSessionDir, + updatedAtMs: 100, + worktreePatchPath: path.join(childCanonicalDir, "worktree.patch"), + }), + "utf-8" + ); + await fsPromises.writeFile( + path.join(parentSessionDir, "subagent-patches.json"), + artifactsFile({ + sessionDir: parentSessionDir, + updatedAtMs: 200, + worktreePatchPath: path.join(parentCanonicalDir, "worktree.patch"), + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + // Patch bytes and metadata both still belong to the newer artifact. + expect( + await fsPromises.readFile(path.join(parentCanonicalDir, "worktree.patch"), "utf-8") + ).toBe(newerBody); + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { + artifactsByChildTaskId: Record; + }; + expect(parentFile.artifactsByChildTaskId.task_s?.updatedAtMs).toBe(200); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("drops obsolete canonical patches when the newer child entry lacks that kind", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-obsolete-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const childCanonicalDir = path.join(childSessionDir, "subagent-patches", "task_o", "repo"); + const parentCanonicalDir = path.join(parentSessionDir, "subagent-patches", "task_o", "repo"); + await fsPromises.mkdir(childCanonicalDir, { recursive: true }); + await fsPromises.mkdir(parentCanonicalDir, { recursive: true }); + // Earlier roll-up left both kinds in the parent; the task then re-ran + // with commits but a clean tree, so the newer entry has no worktree + // patch. + await fsPromises.writeFile( + path.join(parentCanonicalDir, "worktree.patch"), + "diff --git a/stale.txt b/stale.txt\n", + "utf-8" + ); + await fsPromises.writeFile( + path.join(parentCanonicalDir, "series.mbox"), + "old mbox bytes\n", + "utf-8" + ); + const newMboxBody = + "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n"; + const childMboxPath = path.join(childCanonicalDir, "series.mbox"); + await fsPromises.writeFile(childMboxPath, newMboxBody, "utf-8"); + const artifactsFile = (opts: { + updatedAtMs: number; + commitCount: number; + mboxPath?: string; + worktreePatchPath?: string; + hadUncommittedChanges: boolean; + }) => + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_o: { + childTaskId: "task_o", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + updatedAtMs: opts.updatedAtMs, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: opts.commitCount, + hadUncommittedChanges: opts.hadUncommittedChanges, + ...(opts.mboxPath != null ? { mboxPath: opts.mboxPath } : {}), + ...(opts.worktreePatchPath != null + ? { worktreePatchPath: opts.worktreePatchPath } + : {}), + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: opts.commitCount, + }, + }, + }); + await fsPromises.writeFile( + path.join(parentSessionDir, "subagent-patches.json"), + artifactsFile({ + updatedAtMs: 100, + commitCount: 0, + worktreePatchPath: path.join(parentCanonicalDir, "worktree.patch"), + hadUncommittedChanges: true, + }), + "utf-8" + ); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + artifactsFile({ + updatedAtMs: 200, + commitCount: 1, + mboxPath: childMboxPath, + hadUncommittedChanges: false, + }), + "utf-8" + ); + + const result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + expect(result.patchArtifactsReplicated).toBe(true); + // The mbox reflects the newer child; the stale worktree patch must be + // gone, or canonical probing would apply the old uncommitted changes + // alongside the newer artifact. + expect(await fsPromises.readFile(path.join(parentCanonicalDir, "series.mbox"), "utf-8")).toBe( + newMboxBody + ); + expect( + await fsPromises + .access(path.join(parentCanonicalDir, "worktree.patch")) + .then(() => true) + .catch(() => false) + ).toBe(false); + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { + artifactsByChildTaskId: Record< + string, + { projectArtifacts: Array<{ mboxPath?: string; worktreePatchPath?: string }> } + >; + }; + const rolledUp = parentFile.artifactsByChildTaskId.task_o?.projectArtifacts[0]; + expect(rolledUp?.mboxPath).toBe(path.join(parentCanonicalDir, "series.mbox")); + expect(rolledUp?.worktreePatchPath).toBeUndefined(); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("copies the canonical dir even when metadata points at a safe noncanonical dir", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-noncanonical-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const canonicalDir = path.join(childSessionDir, "subagent-patches", "task_g", "repo"); + const legacyDir = path.join(childSessionDir, "legacy-patches", "task_g"); + await fsPromises.mkdir(canonicalDir, { recursive: true }); + await fsPromises.mkdir(legacyDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + const patchBody = "diff --git a/dirty.txt b/dirty.txt\n"; + const mboxBody = "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n"; + await fsPromises.writeFile(path.join(canonicalDir, "worktree.patch"), patchBody, "utf-8"); + await fsPromises.writeFile(path.join(legacyDir, "series.mbox"), mboxBody, "utf-8"); + // The mboxPath is safe (inside the child session dir) but noncanonical: + // selecting only its directory would copy the mbox and let child + // cleanup delete the canonical worktree.patch, the only capture of the + // child's uncommitted work. + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_g: { + childTaskId: "task_g", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 1, + mboxPath: path.join(legacyDir, "series.mbox"), + hadUncommittedChanges: true, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + const destDir = path.join(parentSessionDir, "subagent-patches", "task_g", "repo"); + expect(await fsPromises.readFile(path.join(destDir, "worktree.patch"), "utf-8")).toBe( + patchBody + ); + expect(await fsPromises.readFile(path.join(destDir, "series.mbox"), "utf-8")).toBe(mboxBody); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("copies both metadata dirs when the mbox and worktree patch live in different safe dirs", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-split-meta-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const mboxDir = path.join(childSessionDir, "legacy-mbox", "task_h"); + const worktreeDir = path.join(childSessionDir, "legacy-worktree", "task_h"); + await fsPromises.mkdir(mboxDir, { recursive: true }); + await fsPromises.mkdir(worktreeDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + const patchBody = "diff --git a/dirty.txt b/dirty.txt\n"; + const mboxBody = "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n"; + await fsPromises.writeFile(path.join(worktreeDir, "worktree.patch"), patchBody, "utf-8"); + await fsPromises.writeFile(path.join(mboxDir, "series.mbox"), mboxBody, "utf-8"); + // Both paths are safe but point at two different noncanonical dirs: + // selecting only the mbox dir would let child cleanup delete the + // worktree patch, the only capture of the child's uncommitted work. + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_h: { + childTaskId: "task_h", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 1, + mboxPath: path.join(mboxDir, "series.mbox"), + worktreePatchPath: path.join(worktreeDir, "worktree.patch"), + hadUncommittedChanges: true, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + const destDir = path.join(parentSessionDir, "subagent-patches", "task_h", "repo"); + expect(await fsPromises.readFile(path.join(destDir, "series.mbox"), "utf-8")).toBe(mboxBody); + expect(await fsPromises.readFile(path.join(destDir, "worktree.patch"), "utf-8")).toBe( + patchBody + ); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("prefers the metadata-referenced patch over a same-named canonical file during roll-up", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-precedence-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const canonicalDir = path.join(childSessionDir, "subagent-patches", "task_j", "repo"); + const legacyDir = path.join(childSessionDir, "legacy", "task_j"); + await fsPromises.mkdir(canonicalDir, { recursive: true }); + await fsPromises.mkdir(legacyDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + // Same basename, different contents: the apply resolver prefers the + // metadata-referenced file, so roll-up must not let the canonical copy + // mask it (its source is deleted with the child). + await fsPromises.writeFile( + path.join(canonicalDir, "worktree.patch"), + "diff --git a/stale.txt b/stale.txt\n", + "utf-8" + ); + const referencedBody = "diff --git a/referenced.txt b/referenced.txt\n"; + await fsPromises.writeFile(path.join(legacyDir, "worktree.patch"), referencedBody, "utf-8"); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_j: { + childTaskId: "task_j", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + worktreePatchPath: path.join(legacyDir, "worktree.patch"), + hadUncommittedChanges: true, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + const destDir = path.join(parentSessionDir, "subagent-patches", "task_j", "repo"); + expect(await fsPromises.readFile(path.join(destDir, "worktree.patch"), "utf-8")).toBe( + referencedBody + ); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("keeps same-basename mbox and worktree patches distinct during roll-up", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-collision-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const legacyMboxDir = path.join(childSessionDir, "legacy-a", "task_k"); + const legacyWorktreeDir = path.join(childSessionDir, "legacy-b", "task_k"); + await fsPromises.mkdir(legacyMboxDir, { recursive: true }); + await fsPromises.mkdir(legacyWorktreeDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + // Two different artifact kinds share a basename in different safe + // directories; merging them into one destination file would lose one. + const mboxBody = "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n"; + const worktreeBody = "diff --git a/dirty.txt b/dirty.txt\n"; + await fsPromises.writeFile(path.join(legacyMboxDir, "patch.diff"), mboxBody, "utf-8"); + await fsPromises.writeFile(path.join(legacyWorktreeDir, "patch.diff"), worktreeBody, "utf-8"); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_k: { + childTaskId: "task_k", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 1, + mboxPath: path.join(legacyMboxDir, "patch.diff"), + worktreePatchPath: path.join(legacyWorktreeDir, "patch.diff"), + hadUncommittedChanges: true, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { + artifactsByChildTaskId: Record< + string, + { projectArtifacts: Array<{ mboxPath?: string; worktreePatchPath?: string }> } + >; + }; + const rolled = parentFile.artifactsByChildTaskId.task_k?.projectArtifacts[0]; + const rolledMboxPath = rolled?.mboxPath; + const rolledWorktreePatchPath = rolled?.worktreePatchPath; + expect(rolledMboxPath).toBeDefined(); + expect(rolledWorktreePatchPath).toBeDefined(); + expect(rolledMboxPath).not.toBe(rolledWorktreePatchPath); + expect(await fsPromises.readFile(rolledMboxPath!, "utf-8")).toBe(mboxBody); + expect(await fsPromises.readFile(rolledWorktreePatchPath!, "utf-8")).toBe(worktreeBody); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("keeps a metadata patch named like the other kind's canonical file from masking it", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-reserved-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const canonicalDir = path.join(childSessionDir, "subagent-patches", "task_m", "repo"); + const legacyDir = path.join(childSessionDir, "legacy", "task_m"); + await fsPromises.mkdir(canonicalDir, { recursive: true }); + await fsPromises.mkdir(legacyDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + // The mbox metadata points at a safe file that happens to be named + // worktree.patch; the canonical dir holds the real captured worktree + // patch under that reserved name. No worktreePatchPath metadata exists. + const capturedBody = "diff --git a/captured.txt b/captured.txt\n"; + const mboxBody = "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n"; + await fsPromises.writeFile(path.join(canonicalDir, "worktree.patch"), capturedBody, "utf-8"); + await fsPromises.writeFile(path.join(legacyDir, "worktree.patch"), mboxBody, "utf-8"); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_m: { + childTaskId: "task_m", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 1, + mboxPath: path.join(legacyDir, "worktree.patch"), + hadUncommittedChanges: true, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + const destDir = path.join(parentSessionDir, "subagent-patches", "task_m", "repo"); + // The canonical worktree patch survives under its reserved name. + expect(await fsPromises.readFile(path.join(destDir, "worktree.patch"), "utf-8")).toBe( + capturedBody + ); + // The metadata-referenced mbox stays reachable through rewritten metadata. + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { + artifactsByChildTaskId: Record }>; + }; + const rolledMboxPath = + parentFile.artifactsByChildTaskId.task_m?.projectArtifacts[0]?.mboxPath; + expect(rolledMboxPath).toBeDefined(); + expect(rolledMboxPath).not.toBe(path.join(destDir, "worktree.patch")); + expect(await fsPromises.readFile(rolledMboxPath!, "utf-8")).toBe(mboxBody); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("does not let a renamed reserved-name mbox reappear via the directory merge", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-renamed-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const legacyDir = path.join(childSessionDir, "legacy", "task_r"); + await fsPromises.mkdir(legacyDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + // The only patch file is a legacy mbox named worktree.patch; no real + // worktree patch exists anywhere. The forced copy renames it, and the + // legacy dir's merge must not re-add the original basename: canonical + // probing would apply the mbox again as the uncommitted-changes patch. + const mboxBody = "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n"; + await fsPromises.writeFile(path.join(legacyDir, "worktree.patch"), mboxBody, "utf-8"); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_r: { + childTaskId: "task_r", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 1, + mboxPath: path.join(legacyDir, "worktree.patch"), + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 1, + }, + }, + }), + "utf-8" + ); + + const result = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + expect(result.patchArtifactsReplicated).toBe(true); + const destDir = path.join(parentSessionDir, "subagent-patches", "task_r", "repo"); + // The renamed copy is the only surviving instance of the file. + const destEntries = await fsPromises.readdir(destDir); + expect(destEntries).toEqual(["mbox-worktree.patch"]); + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { + artifactsByChildTaskId: Record }>; + }; + expect(parentFile.artifactsByChildTaskId.task_r?.projectArtifacts[0]?.mboxPath).toBe( + path.join(destDir, "mbox-worktree.patch") + ); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("keeps a noncanonical patch filename reachable through rewritten parent metadata", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-basename-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const legacyDir = path.join(childSessionDir, "legacy", "task_i"); + await fsPromises.mkdir(legacyDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + const patchBody = "diff --git a/dirty.txt b/dirty.txt\n"; + // The safe legacy file keeps its basename through the directory merge, + // so metadata rewritten to the canonical worktree.patch filename would + // point at a file that does not exist. + await fsPromises.writeFile(path.join(legacyDir, "dirty.diff"), patchBody, "utf-8"); + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_i: { + childTaskId: "task_i", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + worktreePatchPath: path.join(legacyDir, "dirty.diff"), + hadUncommittedChanges: true, + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + const parentFile = JSON.parse( + await fsPromises.readFile(path.join(parentSessionDir, "subagent-patches.json"), "utf-8") + ) as { + artifactsByChildTaskId: Record< + string, + { projectArtifacts: Array<{ worktreePatchPath?: string }> } + >; + }; + const rewrittenPath = + parentFile.artifactsByChildTaskId.task_i?.projectArtifacts[0]?.worktreePatchPath; + expect(rewrittenPath).toBeDefined(); + expect(await fsPromises.readFile(rewrittenPath!, "utf-8")).toBe(patchBody); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); + + test("falls back to the canonical dir when patch-path metadata escapes the child session dir", async () => { + const base = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-rollup-unsafe-meta-")); + try { + const childSessionDir = path.join(base, "child"); + const parentSessionDir = path.join(base, "parent"); + const canonicalDir = path.join(childSessionDir, "subagent-patches", "task_g", "repo"); + await fsPromises.mkdir(canonicalDir, { recursive: true }); + await fsPromises.mkdir(parentSessionDir, { recursive: true }); + const patchBody = "diff --git a/dirty.txt b/dirty.txt\n"; + await fsPromises.writeFile(path.join(canonicalDir, "worktree.patch"), patchBody, "utf-8"); + // Unsafe metadata: the recorded path points outside the child session + // dir, but the canonical worktree.patch above still holds the child's + // uncommitted work; skipping the copy would lose it on cleanup. + await fsPromises.writeFile( + path.join(childSessionDir, "subagent-patches.json"), + JSON.stringify({ + version: 2, + artifactsByChildTaskId: { + task_g: { + childTaskId: "task_g", + parentWorkspaceId: "child_ws", + createdAtMs: 1, + status: "ready", + projectArtifacts: [ + { + projectPath: "/repo", + projectName: "repo", + storageKey: "repo", + status: "ready", + commitCount: 0, + hadUncommittedChanges: true, + worktreePatchPath: path.join(base, "elsewhere", "worktree.patch"), + }, + ], + readyProjectCount: 1, + failedProjectCount: 0, + skippedProjectCount: 0, + totalCommitCount: 0, + }, + }, + }), + "utf-8" + ); + + await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId: "parent_ws", + parentSessionDir, + childWorkspaceId: "child_ws", + childSessionDir, + }); + + const rolledUpPatch = path.join( + parentSessionDir, + "subagent-patches", + "task_g", + "repo", + "worktree.patch" + ); + expect(await fsPromises.readFile(rolledUpPatch, "utf-8")).toBe(patchBody); + } finally { + await fsPromises.rm(base, { recursive: true, force: true }); + } + }); +}); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5cdd21a04f..61c59bb718 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -280,9 +280,14 @@ import { import { taskQueueDebug } from "@/node/services/taskQueueDebug"; import { getSubagentGitPatchMboxPath, + getSubagentGitPatchProjectDir, + getSubagentGitPatchWorktreePatchPath, readSubagentGitPatchArtifactsFile, + SUBAGENT_GIT_PATCH_MBOX_FILE_NAME, + SUBAGENT_GIT_PATCH_WORKTREE_FILE_NAME, updateSubagentGitPatchArtifactsFile, } from "@/node/services/subagentGitPatchArtifacts"; +import type { SubagentGitPatchArtifact } from "@/common/utils/tools/toolDefinitions"; import { getSubagentReportArtifactPath, readSubagentReportArtifactsFile, @@ -1121,6 +1126,51 @@ async function copyDirIfMissingBestEffort(params: { } } +/** + * Merge-copies srcDir into destDir: existing destination files are kept + * (force: false), missing ones are copied. Unlike copyDirIfMissingBestEffort, + * an existing destDir does not skip the copy, so several source dirs can + * contribute files to one destination. Returns false on a real copy failure; + * a missing source dir is not one. + */ +async function mergeDirBestEffort(params: { + srcDir: string; + destDir: string; + /** Top-level basenames in srcDir to skip (renamed reserved patch files). */ + excludeBasenames?: ReadonlySet; + logContext: Record; +}): Promise { + const excludeBasenames = params.excludeBasenames; + try { + await fsPromises.mkdir(path.dirname(params.destDir), { recursive: true }); + await fsPromises.cp(params.srcDir, params.destDir, { + recursive: true, + force: false, + errorOnExist: false, + ...(excludeBasenames != null && excludeBasenames.size > 0 + ? { + filter: (source: string) => + path.dirname(source) !== params.srcDir || + !excludeBasenames.has(path.basename(source)), + } + : {}), + }); + return true; + } catch (error: unknown) { + if (isErrnoWithCode(error, "ENOENT")) { + return true; + } + + log.error("Failed to copy session artifact directory", { + ...params.logContext, + srcDir: params.srcDir, + destDir: params.destDir, + error: getErrorMessage(error), + }); + return false; + } +} + function coerceUpdatedAtMs(entry: { createdAtMs?: number; updatedAtMs?: number }): number { if (typeof entry.updatedAtMs === "number" && Number.isFinite(entry.updatedAtMs)) { return entry.updatedAtMs; @@ -1168,7 +1218,7 @@ async function collectReferencedStagedAttachmentPaths(sessionDir: string): Promi return [...paths]; } -async function archiveChildSessionArtifactsIntoParentSessionDir(params: { +export async function archiveChildSessionArtifactsIntoParentSessionDir(params: { parentWorkspaceId: string; parentSessionDir: string; childWorkspaceId: string; @@ -1177,19 +1227,29 @@ async function archiveChildSessionArtifactsIntoParentSessionDir(params: { childTaskModelString?: string; /** Task-level thinking/reasoning level for the child workspace (optional; persists into transcript artifacts). */ childTaskThinkingLevel?: ThinkingLevel; -}): Promise { +}): Promise<{ + /** + * False when patch artifact replication into the parent failed: parent + * metadata was not repointed at the missing files, and the caller must + * preserve the child session dir (the only remaining copy) instead of + * deleting it. + */ + patchArtifactsReplicated: boolean; +}> { if (params.parentWorkspaceId.length === 0) { - return; + return { patchArtifactsReplicated: true }; } if (params.childWorkspaceId.length === 0) { - return; + return { patchArtifactsReplicated: true }; } if (params.parentSessionDir.length === 0 || params.childSessionDir.length === 0) { - return; + return { patchArtifactsReplicated: true }; } + let patchArtifactsReplicated = true; + // 1) Archive the child session transcript (chat.jsonl + partial.json) into the parent session dir // BEFORE deleting ~/.mux/sessions/. try { @@ -1314,92 +1374,458 @@ async function archiveChildSessionArtifactsIntoParentSessionDir(params: { // --- subagent-patches.json + subagent-patches//... try { - const childArtifacts = await readSubagentGitPatchArtifactsFile(params.childSessionDir); + // An unreadable or malformed child index must not read as empty here: + // the catch below would never fire, patchArtifactsReplicated would stay + // true, and removal would delete the only copies of the child's patch + // files. Propagated errors land in that catch and preserve the child. + const childArtifacts = await readSubagentGitPatchArtifactsFile(params.childSessionDir, { + propagateReadErrors: true, + }); const childEntries = Object.entries(childArtifacts.artifactsByChildTaskId); - for (const [taskId, childEntry] of childEntries) { - if (!taskId) continue; + // Both metadata-referenced patch kinds roll up into one destination dir. + // When their planned basenames collide (two different safe sources + // sharing a basename, or one source named like the other kind's + // canonical fallback), the overwriting copies would merge two formats + // into one file and the rewritten metadata would point both fields at + // it, losing an artifact once child cleanup deletes the sources. A + // colliding safe source gets a kind-prefixed destination name instead; + // canonical fallbacks keep their fixed names because the always-merged + // canonical dir provides them. + const planRolledUpPatchCopy = ( + projectArtifact: { mboxPath?: string; worktreePatchPath?: string }, + kind: "mbox" | "worktree" + ): { srcPath: string; srcInsideChild: boolean; destBasename: string } | undefined => { + const plan = (k: "mbox" | "worktree") => { + const srcPath = k === "mbox" ? projectArtifact.mboxPath : projectArtifact.worktreePatchPath; + if (!srcPath) { + return undefined; + } + const srcInsideChild = isPathInsideDir(params.childSessionDir, srcPath); + const canonicalName = + k === "mbox" ? SUBAGENT_GIT_PATCH_MBOX_FILE_NAME : SUBAGENT_GIT_PATCH_WORKTREE_FILE_NAME; + return { + srcPath, + srcInsideChild, + destBasename: srcInsideChild ? path.basename(srcPath) : canonicalName, + }; + }; + const own = plan(kind); + if (own == null) { + return undefined; + } + const other = plan(kind === "mbox" ? "worktree" : "mbox"); + // The other kind's canonical basename stays reserved even when its + // metadata field is absent: the always-merged canonical dir may hold + // that file, and the forced copy would overwrite it before the + // fill-missing merge runs. + const otherCanonicalName = + kind === "mbox" ? SUBAGENT_GIT_PATCH_WORKTREE_FILE_NAME : SUBAGENT_GIT_PATCH_MBOX_FILE_NAME; + if ( + own.srcInsideChild && + (own.destBasename === otherCanonicalName || + (other != null && + other.destBasename === own.destBasename && + other.srcPath !== own.srcPath)) + ) { + return { ...own, destBasename: `${kind}-${own.destBasename}` }; + } + return own; + }; - for (const projectArtifact of childEntry.projectArtifacts) { - if (!projectArtifact.mboxPath) { - continue; + // Returns false when any patch file copy actually failed: metadata must + // not be rewritten to point at files that were never replicated, and the + // caller must preserve the child session dir as the retryable source. + // Every copy lands in a staging dir first and is swapped into place only + // after all of them succeed: copying straight into the destination would + // mutate the files the retained old metadata points at before a later + // copy could fail (e.g. ENOSPC partway through a multi-project artifact). + // Returns false when any entry failed to restore: the caller must then + // preserve the staging root, which still holds the un-restored backups + // the surviving old metadata points at. + const restoreSwappedPatchDirs = async ( + swapped: Array<{ destDir: string; backupDir: string | null }>, + logContext: Record + ): Promise => { + let allRestored = true; + for (const { destDir, backupDir } of [...swapped].reverse()) { + try { + await fsPromises.rm(destDir, { recursive: true, force: true }); + if (backupDir != null) { + await fsPromises.rename(backupDir, destDir); + } + } catch (restoreError: unknown) { + allRestored = false; + log.error("Failed to restore patch artifact dir after swap failure", { + ...logContext, + destDir, + backupDir, + error: getErrorMessage(restoreError), + }); } + } + return allRestored; + }; - const srcDir = path.dirname(projectArtifact.mboxPath); - const destDir = path.dirname( - getSubagentGitPatchMboxPath(params.parentSessionDir, taskId, projectArtifact.storageKey) - ); + interface TaskReplication { + taskId: string; + stagingRoot: string | null; + swapped: Array<{ destDir: string; backupDir: string | null }>; + } - if (!isPathInsideDir(params.childSessionDir, srcDir)) { - log.error("Refusing to roll up patch artifact outside child session dir", { - parentWorkspaceId: params.parentWorkspaceId, - childWorkspaceId: params.childWorkspaceId, + const replicateTaskPatchFiles = async ( + taskId: string, + childEntry: SubagentGitPatchArtifact + ): Promise<{ ok: false } | ({ ok: true } & Omit)> => { + const logContext = { + parentWorkspaceId: params.parentWorkspaceId, + childWorkspaceId: params.childWorkspaceId, + taskId, + }; + // Keyed by destDir so two project artifacts sharing a storage key keep + // the sequential merge semantics of a single destination. + const stagingByDestDir = new Map(); + let stagingRoot: string | null = null; + let succeeded = false; + let preserveStagingRootForRecovery = false; + try { + for (const projectArtifact of childEntry.projectArtifacts) { + // The canonical project dir is always a copy source, independent of + // recorded patch paths: metadata may be missing, sanitized, or point + // at a safe-but-noncanonical location while the canonical dir holds + // the real worktree.patch that apply-side probing discovers, and + // skipping it loses that file once the child session dir is removed. + // Each safe metadata dir is merged in as its own source: the mbox and + // the worktree patch can live in two different legacy locations. + const canonicalSrcDir = getSubagentGitPatchProjectDir( + params.childSessionDir, taskId, - childSessionDir: params.childSessionDir, - srcDir, - }); - continue; - } - - if (!isPathInsideDir(params.parentSessionDir, destDir)) { - log.error("Refusing to roll up patch artifact outside parent session dir", { - parentWorkspaceId: params.parentWorkspaceId, - childWorkspaceId: params.childWorkspaceId, + projectArtifact.storageKey + ); + const srcDirs = [canonicalSrcDir]; + for (const patchFilePath of [ + projectArtifact.mboxPath, + projectArtifact.worktreePatchPath, + ]) { + if (patchFilePath == null) { + continue; + } + const metadataDir = path.dirname(patchFilePath); + if (srcDirs.includes(metadataDir)) { + continue; + } + if (isPathInsideDir(params.childSessionDir, metadataDir)) { + srcDirs.push(metadataDir); + } else { + log.error("Refusing to roll up patch artifact outside child session dir", { + parentWorkspaceId: params.parentWorkspaceId, + childWorkspaceId: params.childWorkspaceId, + taskId, + childSessionDir: params.childSessionDir, + srcDir: metadataDir, + }); + } + } + const destDir = getSubagentGitPatchProjectDir( + params.parentSessionDir, taskId, - parentSessionDir: params.parentSessionDir, - destDir, - }); - continue; + projectArtifact.storageKey + ); + + if (!isPathInsideDir(params.parentSessionDir, destDir)) { + log.error("Refusing to roll up patch artifact outside parent session dir", { + parentWorkspaceId: params.parentWorkspaceId, + childWorkspaceId: params.childWorkspaceId, + taskId, + parentSessionDir: params.parentSessionDir, + destDir, + }); + continue; + } + + let stagingDir = stagingByDestDir.get(destDir); + if (stagingDir == null) { + if (stagingRoot == null) { + // Same filesystem as every destDir (all share the task dir), + // so the final swap is a rename. + await fsPromises.mkdir(path.dirname(destDir), { recursive: true }); + stagingRoot = await fsPromises.mkdtemp( + path.join(path.dirname(destDir), ".rollup-staging-") + ); + } + stagingDir = path.join(stagingRoot, String(stagingByDestDir.size)); + // Seed from the existing destination so the fill-missing merges + // below behave exactly as they would against the real dest. + try { + await fsPromises.cp(destDir, stagingDir, { recursive: true, force: true }); + } catch (error: unknown) { + if (!isErrnoWithCode(error, "ENOENT")) { + throw error; + } + await fsPromises.mkdir(stagingDir, { recursive: true }); + } + // Seeded canonical-kind files are always superseded: the child + // entry being replicated is strictly newer (stale children were + // skipped above), and the apply resolver probes canonical + // filenames even without metadata, so a retained older canonical + // patch from a previous roll-up would be applied alongside the + // newer artifact (e.g. stale uncommitted changes after a rerun + // whose tree was clean). The copies below re-add every kind the + // child actually provides. + for (const canonicalFileName of [ + SUBAGENT_GIT_PATCH_MBOX_FILE_NAME, + SUBAGENT_GIT_PATCH_WORKTREE_FILE_NAME, + ]) { + await fsPromises.rm(path.join(stagingDir, canonicalFileName), { force: true }); + } + stagingByDestDir.set(destDir, stagingDir); + } + + // The apply resolver prefers the metadata-referenced file over the + // canonical one, so those exact files are copied first WITH + // overwrite: the directory merges below never replace existing + // destination files, which would let a canonical file with the same + // basename mask the referenced patch once child cleanup deletes its + // source. + // A collision-renamed copy must also keep its original basename + // out of that source directory's merge below: the merged copy + // would land under the reserved canonical name and apply-side + // canonical probing would treat it as the other kind (e.g. a + // legacy mbox named worktree.patch reapplied as the + // uncommitted-changes patch after git am). + const mergeExclusionsBySrcDir = new Map>(); + for (const kind of ["mbox", "worktree"] as const) { + const copyPlan = planRolledUpPatchCopy(projectArtifact, kind); + if (!copyPlan?.srcInsideChild) { + continue; + } + const srcBasename = path.basename(copyPlan.srcPath); + if (srcBasename !== copyPlan.destBasename) { + const srcDir = path.dirname(copyPlan.srcPath); + const exclusions = mergeExclusionsBySrcDir.get(srcDir) ?? new Set(); + exclusions.add(srcBasename); + mergeExclusionsBySrcDir.set(srcDir, exclusions); + } + try { + await fsPromises.cp(copyPlan.srcPath, path.join(stagingDir, copyPlan.destBasename), { + force: true, + }); + } catch (error: unknown) { + if (!isErrnoWithCode(error, "ENOENT")) { + log.error("Failed to copy metadata-referenced patch file", { + ...logContext, + projectPath: projectArtifact.projectPath, + patchFilePath: copyPlan.srcPath, + error: getErrorMessage(error), + }); + return { ok: false }; + } + } + } + + for (const srcDir of srcDirs) { + const merged = await mergeDirBestEffort({ + srcDir, + destDir: stagingDir, + excludeBasenames: mergeExclusionsBySrcDir.get(srcDir), + logContext: { + ...logContext, + artifact: "subagent-patches", + projectPath: projectArtifact.projectPath, + }, + }); + if (!merged) { + return { ok: false }; + } + } } - await copyDirIfMissingBestEffort({ - srcDir, - destDir, - logContext: { - parentWorkspaceId: params.parentWorkspaceId, - childWorkspaceId: params.childWorkspaceId, - artifact: "subagent-patches", - taskId, - projectPath: projectArtifact.projectPath, - }, + // All copies staged; swap into place. Completed swaps are restored + // from backups when a later one fails, so the retained old metadata + // never points at partially replaced files. + const swapped: Array<{ destDir: string; backupDir: string | null }> = []; + try { + for (const [destDir, stagingDir] of stagingByDestDir) { + const backupDir = path.join(stagingRoot ?? "", `backup-${swapped.length}`); + let hadDest = true; + try { + await fsPromises.rename(destDir, backupDir); + } catch (error: unknown) { + if (!isErrnoWithCode(error, "ENOENT")) { + throw error; + } + hadDest = false; + } + swapped.push({ destDir, backupDir: hadDest ? backupDir : null }); + await fsPromises.rename(stagingDir, destDir); + } + } catch (error: unknown) { + log.error("Failed to swap staged patch artifacts into place", { + ...logContext, + error: getErrorMessage(error), + }); + const restored = await restoreSwappedPatchDirs(swapped, logContext); + if (!restored) { + preserveStagingRootForRecovery = true; + log.error("Preserving staging root: it holds backups a failed restore left behind", { + ...logContext, + stagingRoot, + }); + } + return { ok: false }; + } + // The staging root (holding the swap backups) survives a successful + // return: the metadata for these files is not persisted yet, and a + // failed persistence must restore the swapped dirs from backup or + // the surviving old metadata would point at replaced bytes. + succeeded = true; + return { ok: true, stagingRoot, swapped }; + } catch (error: unknown) { + log.error("Failed to replicate patch artifacts into parent", { + ...logContext, + error: getErrorMessage(error), }); + return { ok: false }; + } finally { + if (!succeeded && !preserveStagingRootForRecovery && stagingRoot != null) { + await fsPromises.rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined); + } } - } + }; if (childEntries.length > 0) { - await updateSubagentGitPatchArtifactsFile({ - workspaceId: params.parentWorkspaceId, - workspaceSessionDir: params.parentSessionDir, - update: (parentFile) => { - for (const [taskId, childEntry] of childEntries) { - if (!taskId) continue; - const existing = parentFile.artifactsByChildTaskId[taskId] ?? null; + // The explicit copies above place safe metadata files under their + // planned (possibly collision-renamed) basenames, so the rewritten + // path must follow the same plan or a noncanonical copy (e.g. + // legacy/dirty.diff) becomes unreachable after child cleanup. Unsafe + // (refused) paths fall back to the canonical filename, which the + // always-merged canonical dir may provide. + const rewriteRolledUpPatchPath = ( + projectArtifact: { mboxPath?: string; worktreePatchPath?: string }, + kind: "mbox" | "worktree", + canonicalPath: string + ): string | undefined => { + const copyPlan = planRolledUpPatchCopy(projectArtifact, kind); + if (copyPlan == null) { + return undefined; + } + if (!copyPlan.srcInsideChild) { + return canonicalPath; + } + return path.join(path.dirname(canonicalPath), copyPlan.destBasename); + }; + // Freshness decision, file replication, and metadata replacement all + // run under the parent's artifact-file lock: deciding from a pre-read + // outside the lock would let a newer parent artifact written in + // between end up backed by a stale child's forced file copies. A + // stale child (not newer than the parent's entry) is skipped + // entirely: copying its files would overwrite the newer content the + // retained metadata points at, and fill-missing merges could surface + // stale canonical files to apply-side probing. + const completedReplications: TaskReplication[] = []; + try { + await updateSubagentGitPatchArtifactsFile({ + workspaceId: params.parentWorkspaceId, + workspaceSessionDir: params.parentSessionDir, + // A silently unpersisted metadata write would report the roll-up + // as replicated: cleanup would then delete the child session + // while the parent has no entry pointing at the copied files, + // making them unreachable by artifact lookup. + propagateWriteErrors: true, + // A malformed parent index (or one malformed entry) must fail the + // roll-up, not self-heal: the healed read drops the malformed + // state, and persisting the reduced map would orphan previously + // retained sibling/descendant patches. + propagateReadErrors: true, + update: async (parentFile) => { + for (const [taskId, childEntry] of childEntries) { + if (!taskId) continue; + const existing = parentFile.artifactsByChildTaskId[taskId] ?? null; + + const childUpdated = coerceUpdatedAtMs(childEntry); + const existingUpdated = existing ? coerceUpdatedAtMs(existing) : -1; + if (existing && childUpdated <= existingUpdated) { + continue; + } - const childUpdated = coerceUpdatedAtMs(childEntry); - const existingUpdated = existing ? coerceUpdatedAtMs(existing) : -1; + // Metadata may only be replaced when this run also replicated + // the files, or the entry would point at content that was + // never copied; the failed task keeps its previous metadata + // and the caller preserves the child session dir for retry. + const replication = await replicateTaskPatchFiles(taskId, childEntry); + if (!replication.ok) { + patchArtifactsReplicated = false; + continue; + } + completedReplications.push({ + taskId, + stagingRoot: replication.stagingRoot, + swapped: replication.swapped, + }); - if (!existing || childUpdated > existingUpdated) { parentFile.artifactsByChildTaskId[taskId] = { ...childEntry, childTaskId: taskId, parentWorkspaceId: params.parentWorkspaceId, projectArtifacts: childEntry.projectArtifacts.map((projectArtifact) => ({ ...projectArtifact, - mboxPath: projectArtifact.mboxPath - ? getSubagentGitPatchMboxPath( - params.parentSessionDir, - taskId, - projectArtifact.storageKey - ) - : undefined, + mboxPath: rewriteRolledUpPatchPath( + projectArtifact, + "mbox", + getSubagentGitPatchMboxPath( + params.parentSessionDir, + taskId, + projectArtifact.storageKey + ) + ), + worktreePatchPath: rewriteRolledUpPatchPath( + projectArtifact, + "worktree", + getSubagentGitPatchWorktreePatchPath( + params.parentSessionDir, + taskId, + projectArtifact.storageKey + ) + ), })), }; } + }, + }); + } catch (error: unknown) { + // Metadata persistence failed after the swaps: the surviving old + // metadata would point at replaced bytes, so restore every + // completed swap from its still-alive backups before rethrowing. + for (const replication of [...completedReplications].reverse()) { + const restoreLogContext = { + parentWorkspaceId: params.parentWorkspaceId, + childWorkspaceId: params.childWorkspaceId, + taskId: replication.taskId, + }; + const restored = await restoreSwappedPatchDirs(replication.swapped, restoreLogContext); + if (!restored) { + // Nulling stagingRoot keeps the finally below from deleting the + // backups the failed restore left behind. + log.error("Preserving staging root: it holds backups a failed restore left behind", { + ...restoreLogContext, + stagingRoot: replication.stagingRoot, + }); + replication.stagingRoot = null; } - }, - }); + } + throw error; + } finally { + for (const { stagingRoot } of completedReplications) { + if (stagingRoot != null) { + await fsPromises + .rm(stagingRoot, { recursive: true, force: true }) + .catch(() => undefined); + } + } + } } } catch (error: unknown) { + patchArtifactsReplicated = false; log.error("Failed to roll up subagent patch artifacts into parent", { parentWorkspaceId: params.parentWorkspaceId, childWorkspaceId: params.childWorkspaceId, @@ -1569,6 +1995,8 @@ async function archiveChildSessionArtifactsIntoParentSessionDir(params: { error: getErrorMessage(error), }); } + + return { patchArtifactsReplicated }; } async function forEachWithConcurrencyLimit( @@ -4880,29 +5308,52 @@ export class WorkspaceService extends EventEmitter { } // Remove session data - try { - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = this.config.getSessionDir(workspaceId); + if (parentWorkspaceId) { + let patchArtifactsReplicated = true; + try { + const parentSessionDir = this.config.getSessionDir(parentWorkspaceId); + const archiveResult = await archiveChildSessionArtifactsIntoParentSessionDir({ + parentWorkspaceId, + parentSessionDir, + childWorkspaceId: workspaceId, + childSessionDir: sessionDir, + childTaskModelString, + childTaskThinkingLevel, + }); + patchArtifactsReplicated = archiveResult.patchArtifactsReplicated; + } catch (error: unknown) { + patchArtifactsReplicated = false; + log.error("Failed to roll up child session artifacts into parent", { + workspaceId, + parentWorkspaceId, + error: getErrorMessage(error), + }); + } - if (parentWorkspaceId) { - try { - const parentSessionDir = this.config.getSessionDir(parentWorkspaceId); - await archiveChildSessionArtifactsIntoParentSessionDir({ - parentWorkspaceId, - parentSessionDir, - childWorkspaceId: workspaceId, - childSessionDir: sessionDir, - childTaskModelString, - childTaskThinkingLevel, - }); - } catch (error: unknown) { - log.error("Failed to roll up child session artifacts into parent", { - workspaceId, - parentWorkspaceId, - error: getErrorMessage(error), - }); + if (!patchArtifactsReplicated) { + // The child session dir holds the only copy of patch artifacts that + // failed to replicate into the parent, and it is only reachable + // through this workspace's config entry (artifact lookup follows + // workspace lineage from config). Abort removal, even when forced, + // so a retried removal re-runs the roll-up instead of orphaning the + // child's commit series or uncommitted work; the roll-ups above are + // idempotent and runtime deletion tolerates the already-deleted + // worktree on retry. + log.error("Aborting workspace removal: patch artifact roll-up failed", { + workspaceId, + parentWorkspaceId, + sessionDir, + }); + if (timelineClosed) { + this.timelineRecorder.reopenWorkspace(workspaceId); } + return Err( + `Failed to roll up the child's patch artifacts into the parent (session dir: ${sessionDir}). The workspace was kept so removal can be retried without losing the child's work.` + ); } - + } + try { await fsPromises.rm(sessionDir, { recursive: true, force: true }); } catch (error) { log.error(`Failed to remove session directory for ${workspaceId}:`, error);