feat(FN-4606): complete Step 3 — route engine call sites

Ref: Runfusion/Fusion#321
Fusion-Task-Id: FN-4606
Fusion-Task-Lineage: 94b23f37-944e-4bcd-bfa1-67d0a8c0a267
This commit is contained in:
Fusion
2026-05-15 08:15:31 -07:00
committed by gsxdsm
parent f00df58eb7
commit 2799c101ac
8 changed files with 71 additions and 41 deletions

View File

@@ -27,6 +27,7 @@ import {
type VerificationResult,
} from "./verification-utils.js";
import { generateWorktreeName } from "./worktree-names.js";
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -299,16 +300,24 @@ export function partitionWorkflowRevisionFeedback(
class NonRetryableWorktreeError extends Error {}
const SESSION_WORKTREE_PATH_REGEX = /([A-Za-z]:)?[^"'\s]*\.worktrees[\\/][^"'\s]+/g;
function buildSessionWorktreePathRegex(rootDir: string, settings: Partial<Settings>): RegExp {
const configuredBase = resolveWorktreesDir(rootDir, settings).split(/[\\/]/).filter(Boolean).pop() ?? ".worktrees";
const escapedBase = configuredBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`([A-Za-z]:)?[^"'\\s]*(?:\\.worktrees|${escapedBase})[\\\\/][^"'\\s]+`, "g");
}
function normalizeWorktreePath(pathValue: string): string {
return resolvePath(pathValue).replace(/\\/g, "/").replace(/\/+$/, "");
}
async function extractPersistedSessionWorktreePath(sessionFile: string): Promise<string | null> {
async function extractPersistedSessionWorktreePath(
sessionFile: string,
rootDir: string,
settings: Partial<Settings>,
): Promise<string | null> {
try {
const content = await readFile(sessionFile, "utf-8");
const matches = content.match(SESSION_WORKTREE_PATH_REGEX) ?? [];
const matches = content.match(buildSessionWorktreePathRegex(rootDir, settings)) ?? [];
if (matches.length === 0) return null;
const normalizedCounts = new Map<string, number>();
@@ -3423,7 +3432,7 @@ export class TaskExecutor {
// persisted session metadata still matches the task's live worktree.
let isResuming = !!task.sessionFile && existsSync(task.sessionFile);
if (isResuming) {
const persistedWorktreePath = await extractPersistedSessionWorktreePath(task.sessionFile!);
const persistedWorktreePath = await extractPersistedSessionWorktreePath(task.sessionFile!, this.rootDir, settings);
if (!isSessionWorktreeCompatible(persistedWorktreePath, worktreePath)) {
executorLog.warn(
`${task.id}: stale sessionFile worktree mismatch (session=${persistedWorktreePath}, task=${worktreePath}); starting fresh session`,
@@ -8074,7 +8083,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
}
const conflictStartPoint = branch;
const newPath = join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
const newPath = resolveTaskWorktreePath(this.rootDir, settings, generateWorktreeName(this.rootDir, settings));
for (let suffix = 2; suffix <= 6; suffix++) {
const suffixedBranch = `${branch}-${suffix}`;
try {
@@ -8877,8 +8886,8 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
});
// Create git worktree for child (branched from parent's worktree)
const childWorktreeName = generateWorktreeName(this.rootDir);
const childWorktreePath = join(this.rootDir, ".worktrees", childWorktreeName);
const childWorktreeName = generateWorktreeName(this.rootDir, settings);
const childWorktreePath = resolveTaskWorktreePath(this.rootDir, settings, childWorktreeName);
const childBranch = `fusion/spawn-${agent.id}`;
await this.createWorktree(childBranch, childWorktreePath, taskId, worktreePath);

View File

@@ -30,6 +30,7 @@ export {
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
import { hostname } from "node:os";
import {
buildTaskLineageTrailer,
@@ -5625,9 +5626,10 @@ export async function pushToRemoteAfterMerge(
async function createPostMergeWorktree(
rootDir: string,
taskId: string,
settings: Partial<Settings>,
): Promise<string | null> {
const randomSuffix = Math.random().toString(36).slice(2, 10);
const postMergeWorktree = join(rootDir, ".worktrees", `post-merge-${taskId}-${randomSuffix}`);
const postMergeWorktree = resolveTaskWorktreePath(rootDir, settings, `post-merge-${taskId}-${randomSuffix}`);
try {
await execAsync(`git worktree add ${quoteArg(postMergeWorktree)} HEAD`, { cwd: rootDir });
@@ -7285,7 +7287,7 @@ export async function aiMergeTask(
throwIfAborted(options.signal, taskId);
const hasPostMergeSteps = await hasEnabledPostMergeWorkflowSteps(store, taskId, task.enabledWorkflowSteps);
if (hasPostMergeSteps) {
const postMergeWorktree = await createPostMergeWorktree(rootDir, taskId);
const postMergeWorktree = await createPostMergeWorktree(rootDir, taskId, settings);
const postMergeCwd = postMergeWorktree || rootDir;
if (postMergeWorktree) {
mergerLog.log(`${taskId}: running post-merge workflow steps in isolated worktree: ${postMergeWorktree}`);

View File

@@ -638,8 +638,9 @@ export class Scheduler {
task: Task,
naming: string | undefined,
reservedNames: Set<string>,
settings: Partial<Settings>,
): string {
return planTaskWorktreePath(task, this.store.getRootDir(), naming, reservedNames);
return planTaskWorktreePath(task, this.store.getRootDir(), naming, reservedNames, settings);
}
/**
@@ -1078,7 +1079,7 @@ export class Scheduler {
});
await this.store.moveTask(task.id, "in-progress", {
allocateWorktree: (reservedNames) =>
this.planWorktreePath(task, settings.worktreeNaming, reservedNames),
this.planWorktreePath(task, settings.worktreeNaming, reservedNames, settings),
});
this.wasNodeBlocked.delete(task.id);
this.wasNodeDispatchValidationBlocked.delete(task.id);

View File

@@ -27,6 +27,7 @@ import { deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCo
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
import { resolveWorktreesDir } from "./worktree-paths.js";
const log = createLogger("self-healing");
const execAsync = promisify(exec);
@@ -4398,7 +4399,8 @@ export class SelfHealingManager {
* tracks registered idle worktrees, never these orphans.
*/
private async reapUnregisteredOrphans(): Promise<number> {
const worktreesDir = join(this.options.rootDir, ".worktrees");
const settings = await this.store.getSettings();
const worktreesDir = resolveWorktreesDir(this.options.rootDir, settings);
if (!existsSync(worktreesDir)) return 0;
let dirs: string[];
@@ -4652,11 +4654,10 @@ export class SelfHealingManager {
/** Remove oldest idle worktrees if total count exceeds 2× maxWorktrees. */
private async enforceWorktreeCap(): Promise<void> {
const worktreesDir = join(this.options.rootDir, ".worktrees");
if (!existsSync(worktreesDir)) return;
try {
const settings = await this.store.getSettings();
const worktreesDir = resolveWorktreesDir(this.options.rootDir, settings);
if (!existsSync(worktreesDir)) return;
const cap = (settings.maxWorktrees ?? 4) * 2;
const entries = readdirSync(worktreesDir, { withFileTypes: true });

View File

@@ -29,6 +29,7 @@ import {
import type { AgentActionGateContext } from "./agent-action-gate.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import { generateWorktreeName } from "./worktree-names.js";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
import { AgentSemaphore } from "./concurrency.js";
import { StuckTaskDetector } from "./stuck-task-detector.js";
import { AgentLogger } from "./agent-logger.js";
@@ -1275,8 +1276,8 @@ Follow instructions precisely and avoid unrelated changes.`,
*/
private async createStepWorktree(stepIndex: number): Promise<string> {
const { rootDir } = this.options;
const name = generateWorktreeName(rootDir);
const worktreePath = join(rootDir, ".worktrees", name);
const name = generateWorktreeName(rootDir, settings);
const worktreePath = resolveTaskWorktreePath(rootDir, settings, name);
const branchName = `fusion/step-${stepIndex}-${name}`;
stepExecLog.log(`Creating worktree for step ${stepIndex}: ${worktreePath} (branch: ${branchName})`);

View File

@@ -1,9 +1,9 @@
import { existsSync } from "node:fs";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { join } from "node:path";
import type { RunMutationContext, Settings, Task, TaskStore } from "@fusion/core";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
import { hydrateWorktreeDb } from "./worktree-db-hydrate.js";
import { formatError } from "./logger.js";
import { isBranchConflictError } from "./branch-conflicts.js";
@@ -135,8 +135,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
? task.id.toLowerCase()
: naming === "task-title"
? slugify(task.title || task.description.slice(0, 60))
: generateWorktreeName(rootDir);
worktreePath = join(rootDir, ".worktrees", worktreeName);
: generateWorktreeName(rootDir, settings);
worktreePath = resolveTaskWorktreePath(rootDir, settings, worktreeName);
}
let isResume = Boolean(task.worktree && existsSync(worktreePath));
@@ -144,7 +144,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
logger?.log(`${task.id}: assigned worktree is not usable; creating a fresh worktree instead: ${worktreePath}`);
await store.logEntry(task.id, "Assigned worktree is not a registered, usable git worktree; creating a fresh worktree instead", worktreePath, runContext);
await store.updateTask(task.id, { worktree: null, branch: null });
worktreePath = join(rootDir, ".worktrees", generateWorktreeName(rootDir));
worktreePath = resolveTaskWorktreePath(rootDir, settings, generateWorktreeName(rootDir, settings));
isResume = false;
}

View File

@@ -1,6 +1,7 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { Settings } from "@fusion/core";
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
export const ADJECTIVES = [
"amber", "azure", "bold", "brave", "bright",
@@ -60,8 +61,8 @@ export function slugify(str: string): string {
* @param rootDir - The project root directory (parent of `.worktrees/`)
* @returns A unique worktree directory name (not a full path)
*/
export function generateWorktreeName(rootDir: string): string {
return generateReservedWorktreeName(rootDir);
export function generateWorktreeName(rootDir: string, settings?: Pick<Settings, "worktreesDir">): string {
return generateReservedWorktreeName(rootDir, new Set(), settings);
}
/**
@@ -71,12 +72,13 @@ export function generateWorktreeName(rootDir: string): string {
export function generateReservedWorktreeName(
rootDir: string,
reservedNames: Set<string> = new Set(),
settings?: Pick<Settings, "worktreesDir">,
): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
const baseName = `${adjective}-${noun}`;
const worktreesDir = join(rootDir, ".worktrees");
const worktreesDir = resolveWorktreesDir(rootDir, settings);
const existing = getExistingWorktreeNames(worktreesDir);
for (const reserved of reservedNames) {
existing.add(reserved);
@@ -111,6 +113,7 @@ export function planTaskWorktreePath(
rootDir: string,
naming: string | undefined,
reservedNames: Set<string>,
settings?: Pick<Settings, "worktreesDir">,
): string {
if (task.worktree) {
const existingName = task.worktree.split("/").filter(Boolean).pop();
@@ -128,12 +131,12 @@ export function planTaskWorktreePath(
break;
case "random":
default:
worktreeName = generateReservedWorktreeName(rootDir, reservedNames);
worktreeName = generateReservedWorktreeName(rootDir, reservedNames, settings);
break;
}
reservedNames.add(worktreeName);
return join(rootDir, ".worktrees", worktreeName);
return resolveTaskWorktreePath(rootDir, settings, worktreeName);
}
function getExistingWorktreeNames(worktreesDir: string): Set<string> {

View File

@@ -2,9 +2,10 @@ import { exec } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, rmSync, realpathSync } from "node:fs";
import { join, relative, resolve, isAbsolute } from "node:path";
import type { Column, TaskStore } from "@fusion/core";
import type { Column, Settings, TaskStore } from "@fusion/core";
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
import { worktreePoolLog } from "./logger.js";
import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js";
const execAsync = promisify(exec);
@@ -75,11 +76,12 @@ export async function isUsableTaskWorktree(rootDir: string, worktreePath: string
hasRequiredWorktreeFiles(worktreePath);
}
export function isInsideWorktreesDir(rootDir: string, worktreePath: string): boolean {
const worktreesDir = canonicalizePath(join(rootDir, ".worktrees"));
const target = canonicalizePath(worktreePath);
const rel = relative(worktreesDir, target);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
export function isInsideWorktreesDir(
rootDir: string,
worktreePath: string,
settings?: Pick<Settings, "worktreesDir">,
): boolean {
return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath);
}
/**
@@ -352,8 +354,12 @@ export class WorktreePool {
* @param store — Task store for listing tasks and their worktree assignments
* @returns Absolute paths of idle worktree directories
*/
export async function scanIdleWorktrees(rootDir: string, store: TaskStore): Promise<string[]> {
const worktreesDir = join(rootDir, ".worktrees");
export async function scanIdleWorktrees(
rootDir: string,
store: TaskStore,
settings?: Pick<Settings, "worktreesDir">,
): Promise<string[]> {
const worktreesDir = resolveWorktreesDir(rootDir, settings);
if (!existsSync(worktreesDir)) {
return [];
@@ -409,13 +415,17 @@ export async function scanIdleWorktrees(rootDir: string, store: TaskStore): Prom
* @param store — Task store for listing tasks and their worktree assignments
* @returns Number of worktrees cleaned up
*/
export async function cleanupOrphanedWorktrees(rootDir: string, store: TaskStore): Promise<number> {
const worktreesDir = join(rootDir, ".worktrees");
export async function cleanupOrphanedWorktrees(
rootDir: string,
store: TaskStore,
settings?: Pick<Settings, "worktreesDir">,
): Promise<number> {
const worktreesDir = resolveWorktreesDir(rootDir, settings);
if (!existsSync(worktreesDir)) {
return 0;
}
const orphaned = await scanIdleWorktrees(rootDir, store);
const orphaned = await scanIdleWorktrees(rootDir, store, settings);
const registeredWorktrees = await getRegisteredWorktreePaths(rootDir);
let dirs: string[] = [];
@@ -442,7 +452,7 @@ export async function cleanupOrphanedWorktrees(rootDir: string, store: TaskStore
cwd: rootDir,
});
} else {
if (!isInsideWorktreesDir(rootDir, worktreePath)) {
if (!isInsideWorktreesDir(rootDir, worktreePath, settings)) {
throw new Error(`Refusing to remove path outside .worktrees: ${worktreePath}`);
}
rmSync(worktreePath, { recursive: true, force: true });
@@ -479,8 +489,11 @@ export async function cleanupOrphanedWorktrees(rootDir: string, store: TaskStore
* @param projectRoot - Absolute path to the project root (parent of `.worktrees/`)
* @returns Number of orphan directories removed
*/
export async function reapOrphanWorktrees(projectRoot: string): Promise<number> {
const worktreesDir = join(projectRoot, ".worktrees");
export async function reapOrphanWorktrees(
projectRoot: string,
settings?: Pick<Settings, "worktreesDir">,
): Promise<number> {
const worktreesDir = resolveWorktreesDir(projectRoot, settings);
if (!existsSync(worktreesDir)) {
return 0;