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) <noreply@anthropic.com>
This commit is contained in:
MichaelHoughtonDeBox
2026-06-21 22:04:37 +01:00
parent 04f266d647
commit 8f4098e5b1
11 changed files with 357 additions and 6 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);

View File

@@ -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<string[]> {
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<WorkspaceConfig | null> {
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<void> {
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",
);
}

View File

@@ -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) ─────────────────────────────────────────────────

View File

@@ -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<string, unknown>; 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<string, unknown> | 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<string, unknown>; 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<string, unknown> | 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<Task> {
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) {

View File

@@ -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<string, { worktreePath: string; branch: string }>;
steps: TaskStep[];
currentStep: number;
/**

View File

@@ -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<typeof import("@fusion/core")>();
return {
...actual,
loadWorkspaceConfig: vi.fn(),
};
});
vi.mock("../worktree-acquisition.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../worktree-acquisition.js")>();
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"]);
});
});

View File

@@ -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<Settings>;
logger?: { log: (m: string) => void; warn: (m: string) => void };
secretsStore?: Pick<import("@fusion/core").SecretsStore, "listEnvExportable">;
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<typeof acquireRepoWorktreeParams>) => {
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,
};
},
};
}

View File

@@ -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<string, ReturnType<typeof setTimeout>>();
/** Set of ephemeral spawned agent IDs with in-flight cleanup (prevents duplicate deletion attempts). */
private pendingEphemeralDeletions = new Set<string>();
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;
}
/**

View File

@@ -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<Settings>;
logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void };
secretsStore?: Pick<SecretsStore, "listEnvExportable">;
}
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<string, { worktreePath: string; branch: string }> = {
...(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 };
}