From dd1eef1523c76825293ffbd7fe57fca28b2cb21b Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:21:01 -0400 Subject: [PATCH] Keep following a finished subagent's worktree while it exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an isolated subagent finishes, its worktree survives exactly when it left changes behind — and the session usually keeps working there (review, fixes, commit) without ever announcing the move. The follower used to clear the thread's inferred cwd the moment the task ended, so the checkout chip and source-control panel snapped back to the configured checkout while all subsequent work landed in the worktree. The last task's checkout now lingers: after a short delay (skipping the harness's unchanged-worktree cleanup race), it stays followed while git still lists it as a checkout of the thread's repository. The linger ends when a new agent task starts, the session ends, or the worktree is gone. Session-reported cwds still override inference. --- .../Layers/ProviderRuntimeIngestion.test.ts | 55 +++++++++++++++++- .../Layers/SubagentWorktreeFollower.ts | Bin 12321 -> 17967 bytes 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index f6b4146c..4ff34a1d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -44,7 +44,10 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; -import { SubagentWorktreeFollowerLive } from "./SubagentWorktreeFollower.ts"; +import * as Duration from "effect/Duration"; + +import { SubagentWorktreeFollower } from "../Services/SubagentWorktreeFollower.ts"; +import { make as makeSubagentWorktreeFollower } from "./SubagentWorktreeFollower.ts"; import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -284,7 +287,14 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderRuntimeIngestionLive.pipe( - Layer.provideMerge(SubagentWorktreeFollowerLive), + Layer.provideMerge( + Layer.effect( + SubagentWorktreeFollower, + // Real delay races the harness's worktree cleanup; tests fake git + // state directly, so only the scheduling behavior matters. + makeSubagentWorktreeFollower({ lingerValidationDelay: Duration.millis(25) }), + ), + ), Layer.provideMerge(Layer.succeed(GitWorkflowService, gitWorkflow.service)), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), @@ -644,7 +654,26 @@ describe("ProviderRuntimeIngestion", () => { }); } - it("follows a subagent into its worktree and stops when the subagent finishes", async () => { + it("keeps following a finished subagent's worktree while it still exists", async () => { + // The worktree surviving the task means the agent left changes behind, + // and the session usually keeps working there (review, fixes, commit) + // without ever announcing the move. + const harness = await createClaudeHarness(); + const worktree = `${harness.workspaceRoot}/.claude/worktrees/agent-a`; + harness.setRepositoryWorktrees([harness.workspaceRoot, worktree]); + harness.setSubagentWorktree("toolu_a", worktree); + + startAgentTask(harness, "task-a", "toolu_a"); + await waitForThread(harness.readModel, (thread) => thread.effectiveCwd === worktree); + + completeTask(harness, "task-a"); + await harness.drain(); + await Effect.runPromise(Effect.sleep("100 millis")); + expect((await harness.readModel()).threads[0]?.effectiveCwd).toBe(worktree); + }); + + it("stops following when the finished subagent's worktree is gone", async () => { + // An unchanged worktree is auto-removed at task end — nothing lingers. const harness = await createClaudeHarness(); const worktree = `${harness.workspaceRoot}/.claude/worktrees/agent-a`; harness.setRepositoryWorktrees([harness.workspaceRoot, worktree]); @@ -653,10 +682,30 @@ describe("ProviderRuntimeIngestion", () => { startAgentTask(harness, "task-a", "toolu_a"); await waitForThread(harness.readModel, (thread) => thread.effectiveCwd === worktree); + harness.setRepositoryWorktrees([harness.workspaceRoot]); completeTask(harness, "task-a"); await waitForThread(harness.readModel, (thread) => thread.effectiveCwd === null); }); + it("lets a new subagent supersede a lingering worktree", async () => { + const harness = await createClaudeHarness(); + const first = `${harness.workspaceRoot}/.claude/worktrees/agent-a`; + const second = `${harness.workspaceRoot}/.claude/worktrees/agent-b`; + harness.setRepositoryWorktrees([harness.workspaceRoot, first, second]); + harness.setSubagentWorktree("toolu_a", first); + harness.setSubagentWorktree("toolu_b", second); + + startAgentTask(harness, "task-a", "toolu_a"); + await waitForThread(harness.readModel, (thread) => thread.effectiveCwd === first); + completeTask(harness, "task-a"); + await harness.drain(); + await Effect.runPromise(Effect.sleep("100 millis")); + expect((await harness.readModel()).threads[0]?.effectiveCwd).toBe(first); + + startAgentTask(harness, "task-b", "toolu_b"); + await waitForThread(harness.readModel, (thread) => thread.effectiveCwd === second); + }); + it("ignores a subagent whose checkout the provider never reports", async () => { const harness = await createClaudeHarness(); harness.setRepositoryWorktrees([harness.workspaceRoot]); diff --git a/apps/server/src/orchestration/Layers/SubagentWorktreeFollower.ts b/apps/server/src/orchestration/Layers/SubagentWorktreeFollower.ts index 76210aac48d5540785e35ddef81cfd1c1b69e92b..237b98d66b7251e3947a30f588682baae7762918 100644 GIT binary patch delta 5478 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