Files
fusion/packages/engine/src/executor/worktree-capture-modified-files.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

119 lines
5.2 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-16:50:
* captureModifiedFiles / captureWorkspaceModifiedFiles / captureUncommittedModifiedFiles
* peeled from TaskExecutor (U4 Slice B). Attribution-aware + workspace multi-repo file capture.
*/
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { Task } from "@fusion/core";
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "../execution/branch-attribution.js";
import { executorLog } from "../logger.js";
import type { RunAuditor } from "../util/run-audit.js";
import { resolveDiffBaseRef } from "./worktree-git-refs.js";
const execAsync = promisify(exec);
export async function captureModifiedFiles(
worktreePath: string,
baseCommitSha: string | undefined,
taskId: string,
audit?: RunAuditor,
source = "unspecified",
): Promise<string[]> {
try {
const baseRef = await resolveDiffBaseRef(worktreePath, baseCommitSha);
if (!baseRef) {
return [];
}
try {
const attributed = await filterFilesToOwnTaskCommits({
worktreePath,
baseRef,
taskId,
});
const divergence = attributed.rawDiffFileCount - attributed.files.length;
if (divergence > 0) {
await audit?.database({
type: "task:worktree-contamination-detected",
target: taskId,
metadata: {
rawDiffFileCount: attributed.rawDiffFileCount,
attributedFileCount: attributed.files.length,
foreignCommitCount: attributed.foreignCommits.length,
foreignCommitShas: attributed.foreignCommits.slice(0, 5).map((commit) => commit.sha),
source,
},
});
executorLog.warn(
`${taskId}: contamination detected — raw diff ${attributed.rawDiffFileCount} files, attributed ${attributed.files.length} (foreign commits: ${attributed.foreignCommits.length})`,
);
}
return attributed.files;
} catch (error) {
if (error instanceof BranchAttributionError) {
executorLog.warn(`${taskId}: branch-attribution failed (${error.message}); falling back to raw diff`);
const { stdout } = await execAsync(`git diff --name-only ${baseRef}..HEAD`, {
cwd: worktreePath,
encoding: "utf-8",
});
const output = stdout.trim();
return output ? output.split("\n").filter(Boolean) : [];
}
throw error;
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
executorLog.debug(`Failed to capture modified files: ${errorMessage}`);
return [];
}
}
/**
* FNXC:Workspace 2026-06-21-23:30: KTD1 — per-repo modified-file capture for workspace tasks.
* Loops `task.workspaceWorktrees` and REUSES `captureModifiedFiles` per sub-repo (NOT a hand-built `git diff`), so each repo gets: (a) resolveDiffBaseRef's merge-base fallback when repo.baseCommitSha is undefined, and (b) the filterFilesToOwnTaskCommits raw-vs-attributed divergence/contamination audit for free. Returned files are repo-prefixed (`<repoRel>/<file>`) and aggregated, so a downstream File-Scope check / merge can attribute each change to its sub-repo. Returns [] for a zero-acquire workspace task.
*/
export async function captureWorkspaceModifiedFiles(
task: Task,
audit?: RunAuditor,
source = "post-session",
): Promise<string[]> {
const workspaceWorktrees = task.workspaceWorktrees ?? {};
// FNXC:Workspace 2026-06-21-15:00: F4/F6 — per-repo error isolation + deterministic ordering.
// F4: an unexpected throw from one repo's `captureModifiedFiles` must NOT escape and skip the
// downstream `updateTask({modifiedFiles})` write — that would leave `task.modifiedFiles` empty and
// blind the merge file audit. Wrap each per-repo call (log + continue), mirroring the post-session
// branch-attribution loop. F6: iterate sorted repo keys so aggregation order is stable across runs.
const aggregated: string[] = [];
for (const repoRel of Object.keys(workspaceWorktrees).sort()) {
const repo = workspaceWorktrees[repoRel];
try {
const repoFiles = await captureModifiedFiles(repo.worktreePath, repo.baseCommitSha ?? undefined, task.id, audit, source);
for (const file of repoFiles) {
aggregated.push(`${repoRel}/${file}`);
}
} catch (repoErr: unknown) {
executorLog.warn(`${task.id}: per-repo modified-file capture failed for ${repoRel}: ${repoErr instanceof Error ? repoErr.message : String(repoErr)}`);
}
}
return aggregated;
}
export async function captureUncommittedModifiedFiles(worktreePath: string): Promise<string[]> {
try {
const [unstaged, staged] = await Promise.all([
execAsync("git diff --name-only", { cwd: worktreePath, encoding: "utf-8" }),
execAsync("git diff --name-only --cached", { cwd: worktreePath, encoding: "utf-8" }),
]);
const files = [...unstaged.stdout.split("\n"), ...staged.stdout.split("\n")]
.map((entry) => entry.trim())
.filter(Boolean);
return [...new Set(files)];
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
executorLog.warn(`Failed to capture uncommitted modified files: ${errorMessage}`);
return [];
}
}