Files
fusion/packages/engine/src/executor/worktree-git-refs.ts
gsxdsm 1cf86baa1c refactor: package code organization wave 18 (executor pure peels) (#3317)
## Summary

Wave 18 continues the package code-organization program after wave 17
domain folders (U4 Slice A from
`docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md`).

### What changed
Peel **pure, behavior-preserving** helpers out of
`packages/engine/src/executor.ts` into domain modules under
`packages/engine/src/executor/`, with **stable re-exports** from
`executor.ts` so deep imports and `vi.mock("../executor.js")` keep
working.

| New module | Symbols |
|------------|---------|
| `executor/task-done-refusal.ts` | `evaluateTaskDoneRefusal`,
`determineRevisionResetStart`, skip-bypass refusal helper |
| `executor/workflow-feedback-paths.ts` |
`extractReferencedPathsFromWorkflowFeedback`,
`isAlwaysAllowedScopeLeakPath`, `workflowPathMatchesDeclaredScope` |
| `executor/workflow-step-verdict.ts` |
`FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE`, `parseWorkflowStepVerdict`
/ `parseWorkflowStepOutput`, step outcome types |
| `executor/await-input-parse.ts` | `parseAwaitInputSentinel`,
`parseAwaitInputQuestionToolCall` |
| `executor/no-commit-eligibility.ts` | `getNoCommitEligibilityReason`
(+ prompt heuristics) |

`executor.ts` live LOC ~**22817 → ~22427** (first pure-peel batch; more
peels needed to approach the 2k cap).

### Shims
- `old path` `executor.ts` public exports → `new path` `executor/*.ts` →
delete-when consumer deep-imports are re-pointed (not this PR)

### Test plan
- [x] `@fusion/engine` typecheck
- [x] Oracle: task-done refusal, skip-bypass, workflow malformed
verdict, scope-leak allowlist, executor-step-session, executor-prompt
- [x] `vitest --project=engine-core` (merge-gate curated suite)
- [ ] CI merge gate

**Stack:** wave17 (merged) → **this PR**

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved recognition of workflow outcomes from structured and
conversational responses.
* Added support for extracting questions from await-input responses and
tool calls.
* Improved workflow feedback handling for referenced files and declared
scope patterns.
* Added clearer guidance for task execution, approvals, verification,
and available tools.

* **Bug Fixes**
* Prevented completion when required review approvals are missing or
revisions remain pending.
* Improved handling of workflows that legitimately require no code
changes.
  * Added clearer refusal messages and more reliable revision restarts.
  * Sanitized repository paths in Git remediation instructions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-09 15:46:09 -10:00

157 lines
6.9 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-14:00:
* Worktree git base-ref helpers peeled from TaskExecutor (U4 Slice B start).
* Pure relative to executor instance state — only need cwd + git exec.
*/
import { exec, execFile, execSync } from "node:child_process";
import { promisify } from "node:util";
import type { Task } from "@fusion/core";
import { resolveCapturedBaseCommitSha } from "../execution/base-commit-capture.js";
import { executorLog } from "../logger.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
/** True when a pre-execution worktree holds commits past its base or any uncommitted change. */
export async function preExecutionWorktreeHasWork(worktreePath: string): Promise<boolean> {
try {
const { stdout: dirty } = await execFileAsync("git", ["status", "--porcelain"], { cwd: worktreePath, timeout: 30_000 });
if (dirty.trim()) return true;
const { stdout: ahead } = await execFileAsync("git", ["log", "--oneline", "@{upstream}..HEAD"], { cwd: worktreePath, timeout: 30_000 })
.catch(async () => await execFileAsync("git", ["log", "--oneline", "-1", "HEAD", "--not", "--remotes", "--branches=main", "--branches=master"], { cwd: worktreePath, timeout: 30_000 }));
return Boolean(ahead.trim());
} catch {
// Cannot prove the worktree is clean → treat it as holding work and keep it.
return true;
}
}
/**
* Resolve a fresh merge-base against the integration branch for use as a
* contamination check reference. Unlike {@link resolveDiffBaseRef}, this
* NEVER falls back to `task.baseCommitSha`, because a stale stored base
* would make the contamination check flag every legitimately-merged commit
* since that snapshot as "foreign" (FN-4417). It also never falls back to
* `HEAD~1`, because for a newly force-reset pooled branch HEAD~1 is a
* commit on main itself, which would yield the same false positive on a
* smaller scale.
*
* Returns `undefined` when neither `origin/main` nor `main` is resolvable;
* the caller is expected to treat that as "contamination check skipped".
*/
export async function resolveContaminationBaseRef(worktreePath: string): Promise<string | undefined> {
// Prefer LOCAL main over origin/main. origin/main is a tracking ref that
// is only as fresh as the last `git fetch` — on dev machines that haven't
// pushed in a while it can lag local main by hundreds of commits, which
// re-introduces the FN-4417 false positive at a smaller scale (the
// merge-base falls back to the last common ancestor between HEAD and the
// stale origin/main, and every commit on local main since then looks
// "foreign"). Local main is the canonical integration target for Fusion.
try {
const { stdout } = await execAsync(
"git merge-base HEAD main 2>/dev/null || git merge-base HEAD origin/main",
{ cwd: worktreePath, encoding: "utf-8" },
);
const ref = stdout.trim();
return ref || undefined;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
executorLog.warn(`Failed merge-base lookup for contamination check in ${worktreePath}: ${errorMessage}`);
return undefined;
}
}
/**
* Capture the list of files modified during agent execution.
* Uses git diff against the stored baseCommitSha to determine what changed.
* Returns an empty array if no changes or if git commands fail.
*/
export async function resolveDiffBaseRef(worktreePath: string, baseCommitSha?: string): Promise<string | undefined> {
if (baseCommitSha) return baseCommitSha;
try {
const { stdout } = await execAsync(
"git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main",
{ cwd: worktreePath, encoding: "utf-8" },
);
const ref = stdout.trim();
if (ref) return ref;
} catch (mergeBaseErr: unknown) {
const mergeBaseMsg = mergeBaseErr instanceof Error ? mergeBaseErr.message : String(mergeBaseErr);
executorLog.warn(`Failed merge-base lookup for diff base in ${worktreePath}, trying HEAD~1 fallback: ${mergeBaseMsg}`);
}
try {
const { stdout } = await execAsync("git rev-parse HEAD~1", {
cwd: worktreePath,
encoding: "utf-8",
});
return stdout.trim() || undefined;
} catch {
executorLog.debug(`Could not determine base commit for diff in ${worktreePath}`);
return undefined;
}
}
export type CaptureBaseCommitShaStore = {
updateTask: (taskId: string, patch: { baseCommitSha: string }) => Promise<unknown>;
};
/**
* Persist a baseCommitSha for the task, preserving a still-valid resume base.
* Needs store.updateTask — inject the store rather than TaskExecutor.
*/
export async function captureBaseCommitSha(
store: CaptureBaseCommitShaStore,
task: Task,
worktreePath: string,
audit: { git: (event: { type: "commit:create"; target: string; metadata: Record<string, unknown> }) => Promise<void> },
options: { isResume: boolean } = { isResume: false },
): Promise<void> {
try {
// Preserve an existing baseCommitSha only on RESUME of the same
// worktree, where diff-base stability across sessions of the same task
// matters. On fresh/pooled acquisitions the branch was just
// force-reset to current main, so any stored baseCommitSha is by
// definition behind the new merge-base — preserving it would yield
// stale diff math and (when reused as a contamination reference) the
// FN-4417 false-positive cascade. Always recapture on non-resume.
if (options.isResume && task.baseCommitSha) {
try {
execSync(`git merge-base --is-ancestor ${task.baseCommitSha} HEAD`, {
cwd: worktreePath,
stdio: "pipe",
});
executorLog.log(`${task.id}: preserved baseCommitSha ${task.baseCommitSha.slice(0, 7)} (resume)`);
await audit.git({
type: "commit:create",
target: task.baseCommitSha,
metadata: { purpose: "base", preserved: true },
});
return;
} catch {
// Existing baseCommitSha is stale or invalid. Recapture below.
}
}
const baseCommitSha = await resolveCapturedBaseCommitSha(worktreePath, {
warn: (msg) => executorLog.warn(`${task.id}: ${msg}`),
});
if (!baseCommitSha) {
throw new Error("could not resolve base commit SHA");
}
await store.updateTask(task.id, { baseCommitSha });
/*
FNXC:EngineDiagnostics 2026-08-03-05:54:
Base-SHA capture is per-task setup bookkeeping (also in run-audit). Worktree created stays info.
*/
executorLog.debug(`${task.id}: captured baseCommitSha ${baseCommitSha.slice(0, 7)}`);
await audit.git({ type: "commit:create", target: baseCommitSha, metadata: { purpose: "base", preserved: false } });
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
executorLog.debug(`Failed to capture baseCommitSha for ${task.id}: ${errorMessage}`);
// Non-fatal: task can continue without baseCommitSha
}
}