fix(engine): keep executor prompts scoped to worktrees

This commit is contained in:
gsxdsm
2026-04-13 00:03:16 -07:00
parent 79fdb691cf
commit 18cdeceebf
6 changed files with 131 additions and 8 deletions

View File

@@ -2006,6 +2006,32 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("**config.json** (application/json)");
});
it("rewrites project-root absolute paths to the active worktree", () => {
const task = createMockTaskDetail({
prompt: [
"# test",
"## Context to Read First",
"- `/home/user/project/web/app/page.tsx`",
"- `/home/user/project/.fusion/memory.md`",
"## Steps",
"### Step 0: Preflight",
"- [ ] inspect `/home/user/project/web/app/layout.tsx`",
].join("\n"),
});
const result = buildExecutionPrompt(
task,
"/home/user/project",
undefined,
"/home/user/project/.worktrees/happy-robin",
);
expect(result).toContain("/home/user/project/.worktrees/happy-robin/web/app/page.tsx");
expect(result).toContain("/home/user/project/.worktrees/happy-robin/web/app/layout.tsx");
expect(result).toContain("/home/user/project/.fusion/memory.md");
expect(result).not.toContain("/home/user/project/.worktrees/happy-robin/.fusion/memory.md");
});
it("omits attachment section when no attachments", () => {
const task = createMockTaskDetail({ attachments: [] });
const result = buildExecutionPrompt(task, "/home/user/project");

View File

@@ -1481,7 +1481,7 @@ export class TaskExecutor {
"Review the current state of your worktree and proceed with the next pending step.",
].join("\n"));
} else {
const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings);
const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings, worktreePath);
await promptWithFallback(session, agentPrompt);
}
@@ -1682,7 +1682,7 @@ export class TaskExecutor {
"2. If there is remaining work, finish it and then call task_done.",
"",
"Original task:",
buildExecutionPrompt(detail, this.rootDir, settings),
buildExecutionPrompt(detail, this.rootDir, settings, worktreePath),
].join("\n");
stuckDetector?.recordActivity(task.id);
@@ -3972,8 +3972,19 @@ function formatTimestamp(iso: string): string {
// Project commands are injected here (for reliability) and also in the PROMPT.md (by triage).
// This ensures the executor agent always sees the authoritative commands from settings,
// even if the PROMPT.md was written manually or before commands were configured.
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string, settings?: Settings): string {
const reviewMatch = task.prompt.match(/##\s*Review Level[:\s]*(\d)/);
function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string): string {
if (!rootDir || !worktreePath || rootDir === worktreePath || !prompt.includes(rootDir)) {
return prompt;
}
return prompt
.replaceAll(`${rootDir}/`, `${worktreePath}/`)
.replaceAll(`${worktreePath}/.fusion/`, `${rootDir}/.fusion/`);
}
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string, settings?: Settings, worktreePath?: string): string {
const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath);
const reviewMatch = prompt.match(/##\s*Review Level[:\s]*(\d)/);
const reviewLevel = reviewMatch ? parseInt(reviewMatch[1], 10) : 0;
// Build step progress for resume
@@ -4065,7 +4076,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
## PROMPT.md
${task.prompt}
${prompt}
${attachmentsSection}${commandsSection}${memorySection}${progressSection}${steeringSection}
## Review level: ${reviewLevel}

View File

@@ -377,6 +377,13 @@ describe("REVIEWER_SYSTEM_PROMPT", () => {
expect(REVIEWER_SYSTEM_PROMPT).toContain("User comment coverage");
expect(REVIEWER_SYSTEM_PROMPT).toContain("missing coverage is a blocking REVISE");
});
it("includes worktree boundary guidance for code reviews", () => {
expect(REVIEWER_SYSTEM_PROMPT).toContain("Worktree Boundary Review");
expect(REVIEWER_SYSTEM_PROMPT).toContain("assigned task worktree");
expect(REVIEWER_SYSTEM_PROMPT).toContain("blocking REVISE");
expect(REVIEWER_SYSTEM_PROMPT).toContain(".fusion/memory.md");
});
});
describe("reviewStep — user comments in spec review", () => {
@@ -498,4 +505,33 @@ describe("reviewStep — user comments in spec review", () => {
// Code reviews should not have user comment coverage checks
expect(capturedPrompt).not.toContain("User Comment Coverage");
});
it("includes assigned worktree boundary instructions for code reviews", async () => {
let capturedPrompt = "";
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
capturedPrompt = prompt;
}),
subscribe: vi.fn().mockImplementation((cb: any) => {
cb({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: "### Verdict: APPROVE\n### Summary\nOK" },
});
}),
dispose: vi.fn(),
},
} as any);
await reviewStep(
"/tmp/project/.worktrees/happy-robin", "FN-050", 1, "Implementation", "code",
"# Task: FN-050\n\n## Mission\nDo something",
"abc123",
);
expect(capturedPrompt).toContain("## Worktree Boundary");
expect(capturedPrompt).toContain("Assigned task worktree: `/tmp/project/.worktrees/happy-robin`");
expect(capturedPrompt).toContain("primary project checkout");
expect(capturedPrompt).toContain(".fusion/memory.md");
});
});

View File

@@ -46,6 +46,7 @@ access to the codebase and can run commands to inspect code.
- Backward compatibility is broken without migration
- Code outside the task's File Scope is deleted, removed, or gutted (out-of-scope removal)
- Existing functionality is removed without a corresponding changeset explaining the removal
- Code changes were made outside the assigned task worktree, unless the path is an expected exception such as project memory or task attachments
### Do NOT issue REVISE for
- STATUS/formatting preferences
@@ -154,6 +155,17 @@ not whether every function and parameter is listed.
Good plan: identifies key behavioral changes, calls out risks, has a testing strategy.
Do NOT demand function-level implementation checklists.
## Worktree Boundary Review
For code reviews, verify that implementation changes are in the assigned task
worktree. The review request includes the current worktree path. Inspect git
state and recent commits from that worktree, and treat changes outside it as a
blocking REVISE unless they are expected project-root state such as
\`.fusion/memory.md\`, task attachments, or other explicitly documented
Fusion metadata. If you see edits or commits in the primary project checkout
instead of the task worktree, call that out directly and ask the worker to move
the changes into the assigned worktree.
## Rules
- Be specific — reference actual files and line numbers
@@ -325,7 +337,7 @@ function buildReviewRequest(
stepName: string,
reviewType: ReviewType,
promptContent: string,
_cwd: string,
cwd: string,
baseline?: string,
userComments?: TaskComment[],
): string {
@@ -385,6 +397,10 @@ function buildReviewRequest(
`The worker has implemented Step ${stepNumber} (${stepName}).`,
"Review the code changes for correctness, patterns, and test coverage.",
"",
"## Worktree Boundary",
`Assigned task worktree: \`${cwd}\``,
"Verify that implementation changes are in this worktree. If you find changes or commits in the primary project checkout or any other path, issue REVISE unless the outside path is an expected project-root exception such as .fusion/memory.md, task attachments, or explicitly documented Fusion metadata.",
"",
);
if (baseline) {
parts.push(

View File

@@ -453,6 +453,28 @@ Do important work.
expect(result).toContain("Delete existing files");
});
it("rewrites project-root absolute paths to the active worktree", () => {
const prompt = fullPrompt.replace(
"`packages/engine/src/new-module.ts`",
"`/repo/project/packages/engine/src/new-module.ts`",
).replace(
"- `src/types.ts`",
"- `/repo/project/.fusion/memory.md`",
);
const task = makeTaskDetail({ prompt });
const result = buildStepPrompt(
task,
1,
"/repo/project",
undefined,
"/repo/project/.worktrees/happy-robin",
);
expect(result).toContain("/repo/project/.worktrees/happy-robin/packages/engine/src/new-module.ts");
expect(result).toContain("/repo/project/.fusion/memory.md");
expect(result).not.toContain("/repo/project/.worktrees/happy-robin/.fusion/memory.md");
});
it("handles step 0 (preflight) correctly", () => {
const task = makeTaskDetail({ prompt: fullPrompt });
const result = buildStepPrompt(task, 0);

View File

@@ -313,8 +313,10 @@ export function buildStepPrompt(
stepIndex: number,
rootDir?: string,
settings?: Settings,
worktreePath?: string,
): string {
const { prompt, id, title, attachments } = taskDetail;
const { id, title, attachments } = taskDetail;
const prompt = scopePromptToWorktree(taskDetail.prompt, rootDir, worktreePath);
// Extract step-specific section
const stepSection = extractStepSection(prompt, stepIndex);
@@ -402,6 +404,16 @@ export function buildStepPrompt(
return parts.join("\n");
}
function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string): string {
if (!rootDir || !worktreePath || rootDir === worktreePath || !prompt.includes(rootDir)) {
return prompt;
}
return prompt
.replaceAll(`${rootDir}/`, `${worktreePath}/`)
.replaceAll(`${worktreePath}/.fusion/`, `${rootDir}/.fusion/`);
}
/**
* Extract the content of a specific step from the PROMPT.md.
*/
@@ -711,7 +723,7 @@ export class StepSessionExecutor {
this.options.onStepStart?.(stepIndex);
// Build step prompt
const stepPrompt = buildStepPrompt(taskDetail, stepIndex, this.options.rootDir, settings);
const stepPrompt = buildStepPrompt(taskDetail, stepIndex, this.options.rootDir, settings, worktreePath);
// Build reduced step prompt for context-limit recovery (simpler, shorter)
const reducedStepPrompt = buildReducedStepPrompt(taskDetail, stepIndex);