From 8f4098e5b1174bd8da1a8c5ce653c9848da5ce0a Mon Sep 17 00:00:00 2001 From: MichaelHoughtonDeBox Date: Sun, 21 Jun 2026 22:04:37 +0100 Subject: [PATCH 1/4] feat: add workspace mode foundation (multi-repo projects) Allow registering a non-git parent directory that contains multiple git repositories as a single Fusion project. The agent acquires per-repo worktrees on demand via a new `fn_acquire_repo_worktree` tool as it discovers it needs to work in each sub-repo. This commit lays the foundation: - detectWorkspaceRepos / loadWorkspaceConfig / saveWorkspaceConfig in @fusion/core (config persisted to .fusion/workspace.json) - Task.workspaceWorktrees data model + store plumbing (per-repo worktree/branch map, distinct from the singular task.worktree) - acquireWorkspaceRepoWorktree wraps acquireTaskWorktree per sub-repo, clearing the singular worktree/branch fields so each sub-repo gets a fresh worktree instead of resuming a sibling repo's worktree - fn_acquire_repo_worktree agent tool + workspace prompt injection - executor git-repository validation bypassed when a workspace config is present - CLI `fn init` detects a non-git dir containing sub-repos and writes a workspace config Known gap (intentionally left for design discussion, see PR): the executor's main worktree-acquisition path still assumes a single git root and is not yet workspace-aware. End-to-end execution (skipping the root acquisition, per-repo merge, per-repo session scoping) is a follow-on once the execution model is agreed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-multi-repo.md | 7 ++ CONCEPTS.md | 7 ++ packages/cli/src/project-resolver.ts | 19 ++++ packages/core/src/git-repository.ts | 66 ++++++++++++++ packages/core/src/index.ts | 4 + packages/core/src/store.ts | 5 +- packages/core/src/types.ts | 5 ++ .../src/__tests__/executor-workspace.test.ts | 89 +++++++++++++++++++ packages/engine/src/agent-tools.ts | 64 +++++++++++++ packages/engine/src/executor.ts | 44 +++++++-- packages/engine/src/worktree-acquisition.ts | 53 +++++++++++ 11 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 .changeset/workspace-multi-repo.md create mode 100644 packages/engine/src/__tests__/executor-workspace.test.ts diff --git a/.changeset/workspace-multi-repo.md b/.changeset/workspace-multi-repo.md new file mode 100644 index 0000000000..b005f1e477 --- /dev/null +++ b/.changeset/workspace-multi-repo.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +Add workspace mode: open a folder of git repositories as a single Fusion +project. The agent acquires per-repo worktrees on demand via +`fn_acquire_repo_worktree` as it discovers it needs to work in each sub-repo. diff --git a/CONCEPTS.md b/CONCEPTS.md index c6990d47c7..1fccc91371 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -54,6 +54,13 @@ A Feature auto-generated from a failed Validator Run to carry the remediation wo ### Project A registered workspace that Fusion can operate on: it has a canonical local path, project-scoped settings and data, and must be backed by a usable Git work tree before task execution can create worktrees from it. +A **workspace** is a special Project variant where the registered path is not +itself a Git repository, but contains multiple Git repositories as direct +sub-directories. Fusion discovers sub-repos at init time and records them in +`.fusion/workspace.json`. In workspace mode, task execution does not require a +single root-level worktree; instead, the agent acquires per-repo worktrees +on demand via `fn_acquire_repo_worktree`. + ### Project Identity The durable identity a registered Project carries locally so it can be reattached to the central registry after central state is lost or rebuilt, preserving rows keyed by the same project id instead of minting a replacement. diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index e57ee9391b..93eafa1bd9 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -16,6 +16,8 @@ import { isValidSqliteDatabaseFile, readProjectIdentity, writeProjectIdentity, + detectWorkspaceRepos, + saveWorkspaceConfig, type RegisteredProject, type TaskStore, } from "@fusion/core"; @@ -625,6 +627,23 @@ export async function registerProjectInteractive( // Check for .fusion/ directory if (!isKbProject(absPath)) { + // Check if this is a non-git directory containing sub-repos (workspace mode) + const { spawnSync } = await import("node:child_process"); + const gitCheck = spawnSync("git", ["-C", absPath, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" }); + const isGitRepo = gitCheck.status === 0 && gitCheck.stdout.trim() === "true"; + + if (!isGitRepo) { + const subRepos = await detectWorkspaceRepos(absPath); + if (subRepos.length > 0) { + console.log(`\n Found ${subRepos.length} git repositories in ${absPath}:`); + subRepos.forEach((r: string) => console.log(` • ${r}`)); + console.log(`\n Initializing as a Fusion workspace...\n`); + await saveWorkspaceConfig(absPath, { repos: subRepos }); + // Fall through to normal .fusion init + } + // else: fall through to existing error path + } + if (interactive) { console.log(`\n No .fusion/ directory found in ${absPath}`); const shouldInit = await promptConfirm("Initialize fn here first?", true); diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index e95a9740a7..974c5d12a6 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -98,3 +98,69 @@ function extractCommandErrorMessage(error: unknown): string { return String(error); } + +/** + * Scans `dir` one level deep for sub-directories that are git repositories. + * Returns relative paths of found repos, sorted alphabetically. + */ +export async function detectWorkspaceRepos(dir: string): Promise { + let entries: string[]; + try { + const { readdir } = await import("node:fs/promises"); + entries = await readdir(dir); + } catch { + return []; + } + const { stat } = await import("node:fs/promises"); + const { join } = await import("node:path"); + const found: string[] = []; + for (const entry of entries) { + const candidate = join(dir, entry, ".git"); + try { + const s = await stat(candidate); + if (s.isDirectory() || s.isFile()) found.push(entry); + } catch { + // not a git repo + } + } + return found.sort(); +} + +export interface WorkspaceConfig { + repos: string[]; +} + +const WORKSPACE_CONFIG_FILENAME = "workspace.json"; + +export async function loadWorkspaceConfig(rootDir: string): Promise { + const { readFile } = await import("node:fs/promises"); + const { join } = await import("node:path"); + const configPath = join(rootDir, ".fusion", WORKSPACE_CONFIG_FILENAME); + try { + const raw = await readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if ( + parsed !== null && + typeof parsed === "object" && + "repos" in parsed && + Array.isArray((parsed as { repos: unknown }).repos) + ) { + return parsed as WorkspaceConfig; + } + return null; + } catch { + return null; + } +} + +export async function saveWorkspaceConfig(rootDir: string, config: WorkspaceConfig): Promise { + const { mkdir, writeFile } = await import("node:fs/promises"); + const { join } = await import("node:path"); + const fusionDir = join(rootDir, ".fusion"); + await mkdir(fusionDir, { recursive: true }); + await writeFile( + join(fusionDir, WORKSPACE_CONFIG_FILENAME), + JSON.stringify(config, null, 2), + "utf-8", + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d7b24fb916..bc1650f7e0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -145,12 +145,16 @@ export { export { ensureGitRepositoryForProjectPath, GitRepositoryInitializationError, + detectWorkspaceRepos, + loadWorkspaceConfig, + saveWorkspaceConfig, } from "./git-repository.js"; export type { GitRepositoryCommandResult, GitRepositoryCommandRunner, GitRepositoryEnsureOutcome, EnsureGitRepositoryOptions, + WorkspaceConfig, } from "./git-repository.js"; // ── Trait model (U2) ───────────────────────────────────────────────── diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 115bb82384..8ab43eba61 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -7931,7 +7931,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, runContext?: RunMutationContext, ): Promise { return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext)); @@ -8265,6 +8265,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } else if (updates.worktree !== undefined) { task.worktree = updates.worktree; } + if (updates.workspaceWorktrees !== undefined) { + task.workspaceWorktrees = updates.workspaceWorktrees; + } // Detect new dependencies being added to a todo task → auto-move to triage let movedToTriage = false; if (updates.dependencies !== undefined) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 21a27ce392..4041135ae7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2231,6 +2231,11 @@ export interface Task { /** When true, this decision-only task is expected to complete without creating git commits. */ noCommitsExpected?: boolean; worktree?: string; + /** + * Workspace mode only. Keyed by repo path relative to workspace rootDir. + * Each entry records the on-disk worktree path and git branch for one sub-repo. + */ + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts new file mode 100644 index 0000000000..1916b52367 --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -0,0 +1,89 @@ +// @ts-nocheck +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { loadWorkspaceConfig } from "@fusion/core"; +import { acquireWorkspaceRepoWorktree } from "../worktree-acquisition.js"; + +vi.mock("@fusion/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadWorkspaceConfig: vi.fn(), + }; +}); + +vi.mock("../worktree-acquisition.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + acquireWorkspaceRepoWorktree: vi.fn(), + }; +}); + +const mockedLoadWorkspaceConfig = vi.mocked(loadWorkspaceConfig); +const mockedAcquireWorkspaceRepoWorktree = vi.mocked(acquireWorkspaceRepoWorktree); + +const MOCK_WORKSPACE_CONFIG = { + repos: ["wolf-server", "wolf-community-frontend-1"], +}; + +describe("acquireWorkspaceRepoWorktree", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns alreadyAcquired=false for a fresh repo", async () => { + mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({ + worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", + branch: "fusion/fn-001", + alreadyAcquired: false, + }); + + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "wolf-server", + workspaceRootDir: "/workspace", + task: { id: "FN-001", workspaceWorktrees: undefined } as never, + store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never, + settings: {}, + }); + + expect(result.alreadyAcquired).toBe(false); + expect(result.worktreePath).toContain("wolf-server"); + }); + + it("returns alreadyAcquired=true when worktree already acquired", async () => { + mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({ + worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", + branch: "fusion/fn-001", + alreadyAcquired: true, + }); + + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "wolf-server", + workspaceRootDir: "/workspace", + task: { + id: "FN-001", + workspaceWorktrees: { + "wolf-server": { worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", branch: "fusion/fn-001" }, + }, + } as never, + store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never, + settings: {}, + }); + + expect(result.alreadyAcquired).toBe(true); + }); +}); + +describe("workspace config", () => { + it("loadWorkspaceConfig returns null for non-workspace", async () => { + mockedLoadWorkspaceConfig.mockResolvedValueOnce(null); + const config = await loadWorkspaceConfig("/some/single-repo"); + expect(config).toBeNull(); + }); + + it("loadWorkspaceConfig returns config for workspace", async () => { + mockedLoadWorkspaceConfig.mockResolvedValueOnce(MOCK_WORKSPACE_CONFIG); + const config = await loadWorkspaceConfig("/some/workspace"); + expect(config?.repos).toEqual(["wolf-server", "wolf-community-frontend-1"]); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 8bde4bf2b4..99e87b2cb3 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -28,6 +28,7 @@ import { computeApprovalDedupeKey } from "./agent-action-gate.js"; import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/message-delivery.js"; import { emitGoalRetrievalAudit } from "./goal-anchoring-audit.js"; import { recordRetry } from "./retry-burned-logger.js"; +import { acquireWorkspaceRepoWorktree } from "./worktree-acquisition.js"; // ── Tool parameter schemas (canonical definitions) ──────────────────────── @@ -57,6 +58,15 @@ export const taskLogParams = Type.Object({ outcome: Type.Optional(Type.String({ description: "Result or consequence (optional)" })), }); +export const acquireRepoWorktreeParams = Type.Object({ + repo: Type.String({ + description: + "Relative path of the sub-repo within the workspace to acquire a worktree in " + + "(e.g. 'wolf-server'). Must be one of the repos listed in the workspace. " + + "If already acquired, returns the existing worktree path immediately.", + }), +}); + export const taskDocumentWriteParams = Type.Object({ key: Type.String({ description: "Document key (e.g., 'plan', 'notes', 'research'). Alphanumeric, hyphens, underscores, 1-64 chars.", @@ -3582,3 +3592,57 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri }; } +export function createAcquireRepoWorktreeTool(opts: { + workspaceRootDir: string; + workspaceRepos: string[]; + task: import("@fusion/core").Task; + store: TaskStore; + settings: Partial; + logger?: { log: (m: string) => void; warn: (m: string) => void }; + secretsStore?: Pick; + runContext?: RunMutationContext; +}): ToolDefinition { + const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext } = opts; + return { + name: "fn_acquire_repo_worktree", + label: "Acquire Repo Worktree", + description: + "Acquire an isolated git worktree for a sub-repo in this workspace. " + + "Call this before editing files in a sub-repo; work in the returned path. " + + `Available repos: ${workspaceRepos.join(", ")}.`, + parameters: acquireRepoWorktreeParams, + execute: async (_id: string, params: Static) => { + const { repo } = params; + if (!workspaceRepos.includes(repo)) { + return { + content: [{ type: "text" as const, text: `ERROR: Unknown repo: "${repo}". Available: ${workspaceRepos.join(", ")}` }], + details: {}, + isError: true, + }; + } + const freshTask = await store.getTask(task.id); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: repo, + workspaceRootDir, + task: freshTask, + store, + settings, + logger, + secretsStore, + }); + await store.logEntry( + task.id, + result.alreadyAcquired + ? `fn_acquire_repo_worktree: reusing existing worktree for ${repo} at ${result.worktreePath}` + : `fn_acquire_repo_worktree: created worktree for ${repo} at ${result.worktreePath} (branch: ${result.branch})`, + undefined, + runContext, + ); + return { + content: [{ type: "text" as const, text: `Worktree ready at: ${result.worktreePath} (branch: ${result.branch}, alreadyAcquired: ${result.alreadyAcquired})` }], + details: result, + }; + }, + }; +} + diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index c9a997913f..d849580e08 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -55,6 +55,8 @@ import { resolveEffectiveAgentPermissionPolicy, resolveProjectDefaultModel, resolveAgentMemoryInclusionMode, + loadWorkspaceConfig, + type WorkspaceConfig, type RunCommandResult, } from "@fusion/core"; import { findWorktreeUser, getConflictedFiles } from "./merger.js"; @@ -140,7 +142,7 @@ import type { PluginRunner } from "./plugin-runner.js"; import { isContextLimitError } from "./context-limit-detector.js"; import { StepSessionExecutor } from "./step-session-executor.js"; import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js"; -import { acquireTaskWorktree } from "./worktree-acquisition.js"; +import { acquireTaskWorktree, acquireWorkspaceRepoWorktree } from "./worktree-acquisition.js"; import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; import { @@ -191,6 +193,7 @@ import { createWorkflowDeleteTool as sharedCreateWorkflowDeleteTool, createWorkflowSettingsTool as sharedCreateWorkflowSettingsTool, createTraitListTool as sharedCreateTraitListTool, + createAcquireRepoWorktreeTool, } from "./agent-tools.js"; import { getTaskCompletionBlockerForStore } from "./task-completion.js"; import { createStreamingDeltaNormalizer } from "./streaming-delta.js"; @@ -1552,6 +1555,7 @@ export class TaskExecutor { private workflowRerunWatchdogs = new Map>(); /** Set of ephemeral spawned agent IDs with in-flight cleanup (prevents duplicate deletion attempts). */ private pendingEphemeralDeletions = new Set(); + private workspaceConfig: WorkspaceConfig | null | undefined = undefined; private markPausedAborted(taskId: string, provenance: "global-pause" | "merge-seam" | "hard-cancel" | "completion-finalize" = "hard-cancel"): void { this.pausedAborted.add(taskId); @@ -7409,7 +7413,10 @@ export class TaskExecutor { return; } - if (!await isGitRepository(this.rootDir)) { + if (this.workspaceConfig === undefined) { + this.workspaceConfig = await loadWorkspaceConfig(this.rootDir); + } + if (!this.workspaceConfig && !await isGitRepository(this.rootDir)) { await this.store.logEntry( task.id, "Cannot execute task: project directory is not a Git repository. Fusion requires a Git repository for worktree-based task execution.", @@ -8344,6 +8351,19 @@ export class TaskExecutor { ...getEnabledPluginTools(this.options.pluginRunner), ]; + if (this.workspaceConfig) { + customTools.push(createAcquireRepoWorktreeTool({ + workspaceRootDir: this.rootDir, + workspaceRepos: this.workspaceConfig.repos, + task, + store: this.store, + settings, + logger: executorLog, + secretsStore: this.options.secretsStore, + runContext: engineRunContext, + })); + } + // Accumulates the full assistant text output for the most recent session. // Reset to "" each time a new session begins so detectPseudoPause only // sees the last session's output, not the entire conversation history. @@ -8594,6 +8614,7 @@ export class TaskExecutor { worktreePath, this.options.pluginRunner, customFieldDefs, + this.workspaceConfig, ); await promptWithFallback(session, agentPrompt); } @@ -8983,7 +9004,7 @@ export class TaskExecutor { "Do NOT ask for permission. Do NOT write a summary. Just call a tool and keep working.", "", "Original task:", - buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner, retryCustomFieldDefs), + buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner, retryCustomFieldDefs, this.workspaceConfig), ].join("\n"); } else { retryPrompt = [ @@ -8993,7 +9014,7 @@ export class TaskExecutor { "2. If there is remaining work, finish it and then call fn_task_done.", "", "Original task:", - buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner, retryCustomFieldDefs), + buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner, retryCustomFieldDefs, this.workspaceConfig), ].join("\n"); } @@ -15731,6 +15752,7 @@ export function buildExecutionPrompt( worktreePath?: string, pluginRunner?: PluginRunner, customFieldDefs?: WorkflowFieldDefinition[], + workspaceConfig?: WorkspaceConfig | null, ): string { const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath); const reviewLevel = parseReviewLevelFromPrompt(prompt); @@ -15864,7 +15886,7 @@ git log --oneline } const pluginTaskContributions = buildPluginPromptSection("executor-task", pluginRunner); - return `Execute this task. + const executionPrompt = `Execute this task. ## Task: ${task.id} ${task.title ? `**${task.title}**` : ""} @@ -15917,6 +15939,18 @@ If the repo has a typecheck command, run it before \`fn_task_done()\` and fix an Use \`fn_task_create\` for truly separate follow-up work, including unrelated/pre-existing broad-suite failures. If lint is configured and failing, fix that too before completion. Do not repeatedly rerun a broad failing or hanging workspace command without a new hypothesis and a narrower confirming command.`; + + if (workspaceConfig) { + return executionPrompt + `\n\n## Workspace mode\n` + + `This project is a workspace containing multiple git repositories.\n` + + `Available repos:\n` + + workspaceConfig.repos.map((r: string) => `- \`${r}\``).join("\n") + + `\n\nBefore editing files in any sub-repo, call \`fn_acquire_repo_worktree\` ` + + `with the repo name to get an isolated worktree path. ` + + `Work exclusively inside that returned path — never edit the repo's main checkout directly.\n`; + } + + return executionPrompt; } /** diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 11ca2c0850..6ce4ca696b 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -595,3 +595,56 @@ async function verifyResumeBranchNotMisbound(input: { logger?.warn?.(`${taskId}: resume re-anchor failed (continuing — executor preflight will handle): ${formatError(err)}`); } } + +export interface AcquireWorkspaceRepoWorktreeOptions { + repoRelPath: string; + workspaceRootDir: string; + task: Task; + store: TaskStore; + settings: Partial; + logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void }; + secretsStore?: Pick; +} + +export async function acquireWorkspaceRepoWorktree( + opts: AcquireWorkspaceRepoWorktreeOptions, +): Promise<{ worktreePath: string; branch: string; alreadyAcquired: boolean }> { + const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore } = opts; + const { join } = await import("node:path"); + + const existing = task.workspaceWorktrees?.[repoRelPath]; + if (existing) { + return { ...existing, alreadyAcquired: true }; + } + + const repoAbsPath = join(workspaceRootDir, repoRelPath); + + /* + FNXC:WorkspaceWorktree 2026-06-21-00:00: + Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` + is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites + those singular fields on the task row after each acquisition. Passing the live task straight + through means the second repo's acquisition sees the first repo's `task.worktree` (which exists + on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo + contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo + helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in + `task.workspaceWorktrees`, not the singular column. + */ + const result = await acquireTaskWorktree({ + task: { ...task, worktree: undefined, branch: undefined }, + rootDir: repoAbsPath, + store, + settings, + logger, + secretsStore, + runInitCommand: true, + }); + + const updated: Record = { + ...(task.workspaceWorktrees ?? {}), + [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, + }; + await store.updateTask(task.id, { workspaceWorktrees: updated }); + + return { worktreePath: result.worktreePath, branch: result.branch, alreadyAcquired: false }; +} From 79e53e8d973c164491e25873fb8494825a8f1ed1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:37:12 -0700 Subject: [PATCH 2/4] fix(workspace): remove unused acquireWorkspaceRepoWorktree import The foundation imports acquireWorkspaceRepoWorktree in executor.ts but deliberately stops before wiring it into the executor lifecycle, so the import is unused and fails @typescript-eslint/no-unused-vars (the sole Lint failure on this PR). Remove the dead import; it is reintroduced with real usage in the session-scoping follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/engine/src/executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index d849580e08..fb57716b9d 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -142,7 +142,7 @@ import type { PluginRunner } from "./plugin-runner.js"; import { isContextLimitError } from "./context-limit-detector.js"; import { StepSessionExecutor } from "./step-session-executor.js"; import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js"; -import { acquireTaskWorktree, acquireWorkspaceRepoWorktree } from "./worktree-acquisition.js"; +import { acquireTaskWorktree } from "./worktree-acquisition.js"; import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; import { From 429258354da9b5957bdda7b103bd180db28b8a85 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:09:59 -0700 Subject: [PATCH 3/4] fix(workspace): address CodeRabbit review findings on the foundation Resolves the actionable CodeRabbit threads on the workspace-mode foundation: - project-resolver: defer saveWorkspaceConfig until after the user confirms init and store.init() succeeds (no partial .fusion/ on a declined/non-interactive run). - git-repository: validate each candidate with a real `git rev-parse` work-tree probe before counting it (no false-positive repos from stray .git markers); loadWorkspaceConfig now rejects absolute paths, `..` escapes, and non-string entries so a corrupt/malicious config can't resolve outside the workspace root. - executor: gate workspace mode on repos.length > 0 at all three sites so an empty { repos: [] } can't bypass the git-repo guard or enable an empty workspace. - worktree-acquisition: thread the configured-command runner through the workspace acquire path (sub-repos run their init setup); validate repoRelPath as an in-root relative path before joining; liveness-check a remembered worktree before reporting it ready (pruned paths fall through to re-acquire); clear the singular task.worktree/branch after persisting per-repo state (per-repo state lives only in workspaceWorktrees). - agent-tools: forward runContext into acquireWorkspaceRepoWorktree for log attribution. The executor-workspace test's mock-the-subject pattern is left for the session-scoping follow-up that rewrites it with a real two-repo fixture (FN-5048). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/project-resolver.ts | 15 ++++- packages/core/src/git-repository.ts | 42 ++++++++++-- packages/engine/src/agent-tools.ts | 10 ++- packages/engine/src/executor.ts | 19 +++++- packages/engine/src/worktree-acquisition.ts | 71 +++++++++++++++++++-- 5 files changed, 139 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index 93eafa1bd9..68ac2f71d1 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -632,14 +632,22 @@ export async function registerProjectInteractive( const gitCheck = spawnSync("git", ["-C", absPath, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" }); const isGitRepo = gitCheck.status === 0 && gitCheck.stdout.trim() === "true"; + /* + FNXC:Workspace 2026-06-22-00:00: + Workspace detection only reports candidate sub-repos here; persistence is deferred until + after the user confirms init AND TaskStore.init() succeeds. Writing .fusion/workspace.json + before confirmation would leave a partial .fusion/ dir when the user declines or runs + non-interactively, polluting a plain non-git directory with stray Fusion state. + */ + let detectedSubRepos: string[] | null = null; if (!isGitRepo) { const subRepos = await detectWorkspaceRepos(absPath); if (subRepos.length > 0) { console.log(`\n Found ${subRepos.length} git repositories in ${absPath}:`); subRepos.forEach((r: string) => console.log(` • ${r}`)); console.log(`\n Initializing as a Fusion workspace...\n`); - await saveWorkspaceConfig(absPath, { repos: subRepos }); - // Fall through to normal .fusion init + detectedSubRepos = subRepos; + // workspace.json is written below, only after a confirmed store.init() succeeds. } // else: fall through to existing error path } @@ -653,6 +661,9 @@ export async function registerProjectInteractive( const { TaskStore } = await import("@fusion/core"); const store = new TaskStore(absPath); await store.init(); + if (detectedSubRepos) { + await saveWorkspaceConfig(absPath, { repos: detectedSubRepos }); + } console.log(` ✓ Initialized fn at ${absPath}`); } else { throw new ProjectResolutionError( diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index 974c5d12a6..adaebeb2ed 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -114,13 +114,23 @@ export async function detectWorkspaceRepos(dir: string): Promise { const { stat } = await import("node:fs/promises"); const { join } = await import("node:path"); const found: string[] = []; + /* + FNXC:Workspace 2026-06-22-00:00: + A bare `.git` marker (e.g. a stray file copied in, or an unrelated tool's artifact) is not + proof of a git repository. Each candidate child is validated with a real `git rev-parse` + work-tree probe before it counts, so stray `.git` entries do not yield false-positive repos. + */ for (const entry of entries) { - const candidate = join(dir, entry, ".git"); + const childDir = join(dir, entry); + // Cheap pre-filter: skip children with no `.git` marker at all before spawning git. try { - const s = await stat(candidate); - if (s.isDirectory() || s.isFile()) found.push(entry); + const s = await stat(join(childDir, ".git")); + if (!s.isDirectory() && !s.isFile()) continue; } catch { - // not a git repo + continue; + } + if (await isInsideGitWorkTree(childDir, runGitCommand, DEFAULT_GIT_TIMEOUT_MS)) { + found.push(entry); } } return found.sort(); @@ -132,9 +142,27 @@ export interface WorkspaceConfig { const WORKSPACE_CONFIG_FILENAME = "workspace.json"; +/* +FNXC:Workspace 2026-06-22-00:00: +Workspace repo entries are later joined onto the workspace root to resolve worktrees, so an +attacker-controlled or corrupted workspace.json with an absolute path or a `..` escape +(`../outside-repo`) would resolve outside the workspace root. Each entry must be a normalized, +relative, in-root path; absolute paths, `..` escapes, and non-string entries are rejected. +*/ +function isInRootRelativePath(entry: unknown, pathMod: typeof import("node:path")): entry is string { + if (typeof entry !== "string" || entry.length === 0) return false; + if (pathMod.isAbsolute(entry)) return false; + const normalized = pathMod.normalize(entry); + if (normalized === ".." || normalized.startsWith(`..${pathMod.sep}`) || normalized.startsWith("../")) { + return false; + } + return true; +} + export async function loadWorkspaceConfig(rootDir: string): Promise { const { readFile } = await import("node:fs/promises"); - const { join } = await import("node:path"); + const pathMod = await import("node:path"); + const { join } = pathMod; const configPath = join(rootDir, ".fusion", WORKSPACE_CONFIG_FILENAME); try { const raw = await readFile(configPath, "utf-8"); @@ -145,7 +173,9 @@ export async function loadWorkspaceConfig(rootDir: string): Promise isInRootRelativePath(entry, pathMod)); + return { ...(parsed as object), repos }; } return null; } catch { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 99e87b2cb3..1af8949ffe 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -3601,8 +3601,12 @@ export function createAcquireRepoWorktreeTool(opts: { logger?: { log: (m: string) => void; warn: (m: string) => void }; secretsStore?: Pick; runContext?: RunMutationContext; + audit?: Pick; + // FNXC:Workspace 2026-06-22 — thread the configured worktree-init runner so sub-repo worktrees run configured setup. + runConfiguredCommand?: import("./worktree-acquisition.js").AcquireWorkspaceRepoWorktreeOptions["runConfiguredCommand"]; + taskEnv?: NodeJS.ProcessEnv; }): ToolDefinition { - const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext } = opts; + const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext, audit, runConfiguredCommand, taskEnv } = opts; return { name: "fn_acquire_repo_worktree", label: "Acquire Repo Worktree", @@ -3629,6 +3633,10 @@ export function createAcquireRepoWorktreeTool(opts: { settings, logger, secretsStore, + runContext, + audit, + runConfiguredCommand, + taskEnv, }); await store.logEntry( task.id, diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index fb57716b9d..56610e6b0c 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -7416,7 +7416,15 @@ export class TaskExecutor { if (this.workspaceConfig === undefined) { this.workspaceConfig = await loadWorkspaceConfig(this.rootDir); } - if (!this.workspaceConfig && !await isGitRepository(this.rootDir)) { + /* + FNXC:Workspace 2026-06-22-00:00: + Workspace mode is only meaningful with at least one usable sub-repo. An empty `{ repos: [] }` + must NOT bypass the git-repository guard, inject workspace instructions, or expose the + workspace tool — otherwise a non-git directory with an empty config would skip validation + and enable a workspace with nothing to work on. Gate every workspace check on repos.length > 0. + */ + const hasWorkspaceRepos = (this.workspaceConfig?.repos.length ?? 0) > 0; + if (!hasWorkspaceRepos && !await isGitRepository(this.rootDir)) { await this.store.logEntry( task.id, "Cannot execute task: project directory is not a Git repository. Fusion requires a Git repository for worktree-based task execution.", @@ -8351,7 +8359,7 @@ export class TaskExecutor { ...getEnabledPluginTools(this.options.pluginRunner), ]; - if (this.workspaceConfig) { + if (this.workspaceConfig && this.workspaceConfig.repos.length > 0) { customTools.push(createAcquireRepoWorktreeTool({ workspaceRootDir: this.rootDir, workspaceRepos: this.workspaceConfig.repos, @@ -8361,6 +8369,11 @@ export class TaskExecutor { logger: executorLog, secretsStore: this.options.secretsStore, runContext: engineRunContext, + audit, + taskEnv, + // FNXC:Workspace 2026-06-22 — forward the configured worktree-init runner so sub-repo worktrees run configured setup. + runConfiguredCommand: (command, cwd, timeoutMs, env) => + runConfiguredCommand(command, cwd, timeoutMs, env, audit), })); } @@ -15940,7 +15953,7 @@ Use \`fn_task_create\` for truly separate follow-up work, including unrelated/pr If lint is configured and failing, fix that too before completion. Do not repeatedly rerun a broad failing or hanging workspace command without a new hypothesis and a narrower confirming command.`; - if (workspaceConfig) { + if (workspaceConfig && workspaceConfig.repos.length > 0) { return executionPrompt + `\n\n## Workspace mode\n` + `This project is a workspace containing multiple git repositories.\n` + `Available repos:\n` + diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 6ce4ca696b..91fbc75bcd 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -604,21 +604,64 @@ export interface AcquireWorkspaceRepoWorktreeOptions { settings: Partial; logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void }; secretsStore?: Pick; + runContext?: RunMutationContext; + audit?: Pick; + runConfiguredCommand?: AcquireTaskWorktreeOptions["runConfiguredCommand"]; + taskEnv?: NodeJS.ProcessEnv; +} + +/* +FNXC:WorkspaceWorktree 2026-06-22-00:00: +`repoRelPath` is an exported, caller-trusted parameter that is joined onto `workspaceRootDir`. +An absolute path or a `..` escape (`../outside`) would resolve a worktree outside the workspace +root. Validate it is a normalized, relative, in-root path before resolving the absolute path. +*/ +function assertInRootRepoRelPath(repoRelPath: string, sep: string, isAbsolute: (p: string) => boolean, normalize: (p: string) => string): void { + if (typeof repoRelPath !== "string" || repoRelPath.length === 0 || isAbsolute(repoRelPath)) { + throw new Error(`Invalid workspace repo path (must be relative and in-root): ${String(repoRelPath)}`); + } + const normalized = normalize(repoRelPath); + if (normalized === ".." || normalized.startsWith(`..${sep}`) || normalized.startsWith("../")) { + throw new Error(`Invalid workspace repo path (escapes workspace root): ${repoRelPath}`); + } } export async function acquireWorkspaceRepoWorktree( opts: AcquireWorkspaceRepoWorktreeOptions, ): Promise<{ worktreePath: string; branch: string; alreadyAcquired: boolean }> { - const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore } = opts; - const { join } = await import("node:path"); + const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, runContext, audit, runConfiguredCommand, taskEnv } = opts; + const { join, isAbsolute, normalize, sep } = await import("node:path"); + // FNXC:WorkspaceWorktree 2026-06-22 — reject absolute / `..`-escaping repo paths before resolving. + assertInRootRepoRelPath(repoRelPath, sep, isAbsolute, normalize); + const repoAbsPath = join(workspaceRootDir, repoRelPath); + + /* + FNXC:WorkspaceWorktree 2026-06-22-00:00: + A remembered per-repo worktree is only reusable if it still exists and is a registered git + worktree. A pruned/deleted worktree path would otherwise be reported as "ready" without the + resume/classification checks that `acquireTaskWorktree` runs on the singular path. Verify the + remembered path passes the same liveness check (existence + git work-tree classification); + if it is dead, drop it and fall through to re-acquire a fresh worktree. + */ const existing = task.workspaceWorktrees?.[repoRelPath]; if (existing) { - return { ...existing, alreadyAcquired: true }; + let live = existsSync(existing.worktreePath); + if (live) { + try { + const classification = await classifyTaskWorktree(repoAbsPath, existing.worktreePath); + live = classification.ok; + } catch { + live = false; + } + } + if (live) { + return { ...existing, alreadyAcquired: true }; + } + logger?.warn(`${task.id}: remembered workspace worktree for ${repoRelPath} is missing/unusable (${existing.worktreePath}); re-acquiring`); + await store.logEntry(task.id, `Remembered workspace worktree for ${repoRelPath} is no longer usable; re-acquiring`, existing.worktreePath, runContext); } - const repoAbsPath = join(workspaceRootDir, repoRelPath); - /* FNXC:WorkspaceWorktree 2026-06-21-00:00: Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` @@ -629,6 +672,11 @@ export async function acquireWorkspaceRepoWorktree( contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in `task.workspaceWorktrees`, not the singular column. + + FNXC:WorkspaceWorktree 2026-06-22-00:00: + `acquireTaskWorktree` only runs the configured worktree-init command when `runConfiguredCommand` + is threaded through. Forward it (plus runContext/audit/taskEnv) so workspace sub-repos run the + same configured setup as the non-workspace acquire path instead of silently skipping it. */ const result = await acquireTaskWorktree({ task: { ...task, worktree: undefined, branch: undefined }, @@ -637,6 +685,10 @@ export async function acquireWorkspaceRepoWorktree( settings, logger, secretsStore, + runContext, + audit, + runConfiguredCommand, + taskEnv, runInitCommand: true, }); @@ -644,7 +696,14 @@ export async function acquireWorkspaceRepoWorktree( ...(task.workspaceWorktrees ?? {}), [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, }; - await store.updateTask(task.id, { workspaceWorktrees: updated }); + /* + FNXC:WorkspaceWorktree 2026-06-22-00:00: + `acquireTaskWorktree` persists the singular `task.worktree`/`task.branch` on the task row. + For a workspace task that pointer would end up referencing whichever sub-repo was acquired last, + violating the contract that per-repo state lives only in `workspaceWorktrees`. Clear the singular + fields in the same update so a workspace task never carries a misleading singular worktree pointer. + */ + await store.updateTask(task.id, { workspaceWorktrees: updated, worktree: null, branch: null }); return { worktreePath: result.worktreePath, branch: result.branch, alreadyAcquired: false }; } From f2c1a28eab81966747caa60e19db3da427bb5cda Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:26:40 -0700 Subject: [PATCH 4/4] fix(workspace): re-read task before workspaceWorktrees merge; FNXC format Addresses the follow-up review on the foundation fixes: - Re-read the task via store.getTask immediately before merging the per-repo entry, so a concurrent sibling-repo acquisition that landed since the initial read isn't clobbered by updateTask's wholesale map replace (narrows the read-modify-write window to the store lock; a fully atomic per-repo store-level merge remains a follow-up). - Normalize the inline FNXC comment to the FNXC:Area yyyy-MM-dd-hh:mm: convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/engine/src/worktree-acquisition.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 91fbc75bcd..6e8c4febb4 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -632,7 +632,7 @@ export async function acquireWorkspaceRepoWorktree( const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, runContext, audit, runConfiguredCommand, taskEnv } = opts; const { join, isAbsolute, normalize, sep } = await import("node:path"); - // FNXC:WorkspaceWorktree 2026-06-22 — reject absolute / `..`-escaping repo paths before resolving. + // FNXC:WorkspaceWorktree 2026-06-22-00:00: reject absolute / `..`-escaping repo paths before resolving. assertInRootRepoRelPath(repoRelPath, sep, isAbsolute, normalize); const repoAbsPath = join(workspaceRootDir, repoRelPath); @@ -692,8 +692,17 @@ export async function acquireWorkspaceRepoWorktree( runInitCommand: true, }); + /* + FNXC:WorkspaceWorktree 2026-06-22-00:00: + Re-read the task immediately before merging so a concurrent sibling-repo acquisition that + landed between our initial read and now is not clobbered — `updateTask` replaces the + `workspaceWorktrees` map wholesale, so we must merge onto the freshest map, not the stale + snapshot captured before `acquireTaskWorktree`. This narrows the read-modify-write window to + the store's own lock; a fully atomic per-repo store-level merge is a follow-up. + */ + const freshTask = (await store.getTask(task.id)) ?? task; const updated: Record = { - ...(task.workspaceWorktrees ?? {}), + ...(freshTask.workspaceWorktrees ?? {}), [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, }; /*