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 `