Merge pull request #1710 from MichaelHoughtonDeBox/feat/workspace-multi-repo
Workspace mode: open a folder of git repos as one project (foundation + design Q)
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,31 @@ 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";
|
||||
|
||||
/*
|
||||
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`);
|
||||
detectedSubRepos = subRepos;
|
||||
// workspace.json is written below, only after a confirmed store.init() succeeds.
|
||||
}
|
||||
// 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);
|
||||
@@ -634,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(
|
||||
|
||||
@@ -98,3 +98,99 @@ 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[] = [];
|
||||
/*
|
||||
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 childDir = join(dir, entry);
|
||||
// Cheap pre-filter: skip children with no `.git` marker at all before spawning git.
|
||||
try {
|
||||
const s = await stat(join(childDir, ".git"));
|
||||
if (!s.isDirectory() && !s.isFile()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (await isInsideGitWorkTree(childDir, runGitCommand, DEFAULT_GIT_TIMEOUT_MS)) {
|
||||
found.push(entry);
|
||||
}
|
||||
}
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
repos: string[];
|
||||
}
|
||||
|
||||
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<WorkspaceConfig | null> {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
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");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
"repos" in parsed &&
|
||||
Array.isArray((parsed as { repos: unknown }).repos)
|
||||
) {
|
||||
const rawRepos = (parsed as { repos: unknown[] }).repos;
|
||||
const repos = rawRepos.filter((entry): entry is string => isInRootRelativePath(entry, pathMod));
|
||||
return { ...(parsed as object), repos };
|
||||
}
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,12 +153,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) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -7949,7 +7949,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));
|
||||
@@ -8365,6 +8365,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) {
|
||||
|
||||
@@ -2241,6 +2241,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.",
|
||||
@@ -3868,3 +3878,65 @@ 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;
|
||||
audit?: Pick<RunAuditor, "git" | "filesystem">;
|
||||
// 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, audit, runConfiguredCommand, taskEnv } = 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,
|
||||
runContext,
|
||||
audit,
|
||||
runConfiguredCommand,
|
||||
taskEnv,
|
||||
});
|
||||
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";
|
||||
@@ -195,6 +197,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";
|
||||
@@ -1570,6 +1573,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);
|
||||
@@ -7474,7 +7478,18 @@ export class TaskExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await isGitRepository(this.rootDir)) {
|
||||
if (this.workspaceConfig === undefined) {
|
||||
this.workspaceConfig = await loadWorkspaceConfig(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.",
|
||||
@@ -8436,6 +8451,24 @@ export class TaskExecutor {
|
||||
...getEnabledPluginTools(this.options.pluginRunner),
|
||||
];
|
||||
|
||||
if (this.workspaceConfig && this.workspaceConfig.repos.length > 0) {
|
||||
customTools.push(createAcquireRepoWorktreeTool({
|
||||
workspaceRootDir: this.rootDir,
|
||||
workspaceRepos: this.workspaceConfig.repos,
|
||||
task,
|
||||
store: this.store,
|
||||
settings,
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -8686,6 +8719,7 @@ export class TaskExecutor {
|
||||
worktreePath,
|
||||
this.options.pluginRunner,
|
||||
customFieldDefs,
|
||||
this.workspaceConfig,
|
||||
);
|
||||
await promptWithFallback(session, agentPrompt);
|
||||
}
|
||||
@@ -9076,7 +9110,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 = [
|
||||
@@ -9086,7 +9120,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");
|
||||
}
|
||||
|
||||
@@ -15862,6 +15896,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);
|
||||
@@ -15995,7 +16030,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}**` : ""}
|
||||
@@ -16048,6 +16083,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 && workspaceConfig.repos.length > 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -655,3 +655,124 @@ 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">;
|
||||
runContext?: RunMutationContext;
|
||||
audit?: Pick<RunAuditor, "git" | "filesystem">;
|
||||
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, runContext, audit, runConfiguredCommand, taskEnv } = opts;
|
||||
const { join, isAbsolute, normalize, sep } = await import("node:path");
|
||||
|
||||
// FNXC:WorkspaceWorktree 2026-06-22-00:00: 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
/*
|
||||
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.
|
||||
|
||||
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 },
|
||||
rootDir: repoAbsPath,
|
||||
store,
|
||||
settings,
|
||||
logger,
|
||||
secretsStore,
|
||||
runContext,
|
||||
audit,
|
||||
runConfiguredCommand,
|
||||
taskEnv,
|
||||
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<string, { worktreePath: string; branch: string }> = {
|
||||
...(freshTask.workspaceWorktrees ?? {}),
|
||||
[repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch },
|
||||
};
|
||||
/*
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user