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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user