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:
7
.changeset/workspace-multi-repo.md
Normal file
7
.changeset/workspace-multi-repo.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
89
packages/engine/src/__tests__/executor-workspace.test.ts
Normal file
89
packages/engine/src/__tests__/executor-workspace.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user