Files
fusion/packages/engine/src/executor/worktree-task-done-scope-leak.ts
Fusion Agent bbca7a1ffc FN-094: add mono- and multi-repository lifecycle parity
Extend workspace lifecycle behavior so mono-repository and multi-repository tasks share the same durable scope, workflow, review, and merge semantics.

- Add repository-scope persistence, migrations, serialization, and task mutation support.
- Align dashboard task creation, detail, and workspace worktree views with repository-aware lifecycle state.
- Extend executor, reviewer, merger, triage, and self-healing paths with parity coverage and tests.
- Document the lifecycle contract and publish the operator-facing changeset.

Files changed:
 .changeset/fn-094-multi-repository-lifecycle.md    |   7 +
 docs/agents.md                                     |   8 +
 docs/architecture.md                               |   6 +
 docs/dashboard-guide.md                            |   4 +
 docs/multi-project.md                              |   6 +
 docs/settings-reference.md                         |   2 +-
 docs/testing.md                                    |  12 ++
 docs/workspaces.md                                 |   6 +
 ...workspace-worktrees-concurrent-merge.pg.test.ts |  19 +++
 packages/core/src/index.ts                         |   2 +-
 .../core/src/postgres/migrations/0000_initial.sql  |   1 +
 .../0064_fn_094_task_repository_scope.sql          |   2 +
 packages/core/src/postgres/schema-applier.ts       |  13 +-
 packages/core/src/postgres/schema/project.ts       |   2 +
 packages/core/src/store.ts                         |  21 ++-
 .../core/src/task-store/branch-and-pr-entities.ts  |   2 +-
 packages/core/src/task-store/persistence.ts        |   4 +-
 packages/core/src/task-store/serialization.ts      |   2 +
 packages/core/src/task-store/task-creation.ts      |  31 ++++
 packages/core/src/task-store/task-mutation-ops.ts  | 131 +++++++++++++++-
 packages/core/src/task-store/task-row-mappers.ts   |   2 +-
 packages/core/src/task-store/task-update.ts        |  55 +++++--
 packages/core/src/tasks/repository-scope.ts        |  29 ++++
 packages/core/src/types.ts                         |   4 +
 packages/core/src/types/task/task-core.ts          |  35 ++++-
 packages/core/src/types/workflow/workflow-steps.ts |  21 +++
 packages/dashboard/app/api/tasks/tasks.ts          |  14 ++
 packages/dashboard/app/components/NewTaskModal.tsx |  16 +-
 .../dashboard/app/components/TaskDetailModal.css   |  27 ++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  15 +-
 .../app/components/WorkspaceWorktreesSummary.tsx   |  68 ++++++++-
 .../app/components/__tests__/NewTaskModal.test.tsx |   1 +
 .../__tests__/WorkspaceWorktreesSummary.test.tsx   |  27 +++-
 .../src/routes/register-task-workflow-routes.ts    |  48 ++++++
 .../__tests__/executor-workspace-taskdone.test.ts  |  20 +++
 .../src/__tests__/reviewer-workspace.test.ts       | 170 ++++++++++++++++++++-
 .../src/__tests__/self-healing-workspace.test.ts   |   4 +
 .../workflow-graph-optional-group.test.ts          |  32 ++++
 .../workflow-graph-optional-step-fix.test.ts       |  58 +++++++
 .../engine/src/__tests__/workspace-e2e.test.ts     |  91 ++++++++++-
 .../__tests__/workspace-lifecycle-parity.test.ts   |  61 ++++++++
 .../src/__tests__/workspace-merger-lease.test.ts   |  25 +++
 .../__tests__/workspace-merger-scope-gates.test.ts |  58 +++++++
 .../engine/src/__tests__/workspace-merger.test.ts  |  59 ++++++-
 packages/engine/src/agent-tools.ts                 |  92 ++++++++++-
 packages/engine/src/execution/reviewer.ts          |  10 +-
 .../create-authoritative-workflow-seams.ts         |  95 ++++++++++--
 .../engine/src/executor/execute-workflow-graph.ts  |  37 ++++-
 .../request-pre-merge-optional-step-fix.ts         |  70 +++++++++
 .../engine/src/executor/run-graph-custom-node.ts   | 124 ++++++++++-----
 .../engine/src/executor/workflow-step-verdict.ts   |   6 +-
 .../src/executor/workspace-review-per-repo.ts      | 118 +++++++++++++-
 .../src/executor/worktree-task-done-scope-leak.ts  | 109 ++++++++++++-
 packages/engine/src/merge/merger-ai.ts             |  88 ++++++++++-
 packages/engine/src/self-healing.ts                |  19 ++-
 packages/engine/src/triage.ts                      |   9 ++
 .../src/workflows/workflow-graph-executor.ts       |  73 +++++++--
 .../engine/src/workflows/workflow-node-handlers.ts |  12 +-
 58 files changed, 1962 insertions(+), 121 deletions(-)

Fusion-Task-Id: FN-094

Fusion-Task-Lineage: 483f958f-cc67-474e-9383-26856c329543

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-21 04:35:58 +00:00

289 lines
15 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-16:35:
* evaluateTaskDoneScopeLeak peeled from TaskExecutor (U4 Slice B).
* fn_task_done File Scope leak guard (workspace multi-repo + singular checkout).
*/
import { execFile } from "node:child_process";
import { access } from "node:fs/promises";
import { promisify } from "node:util";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { resolveRepoDeclaredScope } from "../worktree/workspace-paths.js";
import { executorLog } from "../logger.js";
import type { EngineRunContext, RunAuditor } from "../util/run-audit.js";
import { parseReviewLevelFromPrompt } from "./prompt-derived-eligibility.js";
import {
isAlwaysAllowedScopeLeakPath,
workflowPathMatchesDeclaredScope,
} from "./workflow-feedback-paths.js";
const execFileAsync = promisify(execFile);
/**
* FNXC:RepositoryScope 2026-08-21-00:58:
* Scope capture helpers intentionally degrade Git errors to an empty list for ordinary telemetry.
* Completion cannot use that lossy result for an acquired out-of-scope checkout: establish that
* the path is readable and Git-addressable first, or fail closed instead of treating unknown work
* as clean.
*/
async function verifyRepositoryCaptureEvidence(worktreePath: string): Promise<void> {
await access(worktreePath);
const { stdout } = await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
cwd: worktreePath,
encoding: "utf8",
});
if (stdout.trim() !== "true") throw new Error("path is not a Git worktree");
}
/**
* FNXC:RepositoryScope 2026-08-21-01:18:
* Completion must distinguish a clean checkout from a lossy capture failure. The historical
* capture helpers intentionally return [] for telemetry continuity, so this direct Git evidence
* probe executes every diff needed by the scope guard and throws when a base or diff is unreadable.
*/
async function captureRepositoryChangeEvidence(
worktreePath: string,
baseCommitSha: string | undefined,
): Promise<string[]> {
const commands: string[][] = [
["diff", "--name-only"],
["diff", "--name-only", "--cached"],
];
if (baseCommitSha) {
commands.push(["merge-base", baseCommitSha, "HEAD"]);
commands.push(["diff", "--name-only", `${baseCommitSha}..HEAD`]);
} else {
commands.push(["rev-parse", "--verify", "HEAD"]);
}
const results = await Promise.all(commands.map((args) => execFileAsync("git", args, {
cwd: worktreePath,
encoding: "utf8",
})));
return [...new Set(results
.filter((_, index) => !commands[index]?.includes("merge-base") && !commands[index]?.includes("rev-parse"))
.flatMap(({ stdout }) => stdout.split("\n").map((file) => file.trim()).filter(Boolean)))];
}
export type TaskDoneScopeLeakDeps = {
store: TaskStore;
workspaceConfig: unknown | null | undefined;
ensureWorkspaceConfig?: () => Promise<unknown | null>;
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
captureUncommittedModifiedFiles: (worktreePath: string) => Promise<string[]>;
captureModifiedFiles: (
worktreePath: string,
baseCommitSha: string | undefined,
taskId: string,
audit?: RunAuditor,
source?: string,
) => Promise<string[]>;
};
export async function evaluateTaskDoneScopeLeak(
deps: TaskDoneScopeLeakDeps,
task: Task,
worktreePath: string,
promptContent: string,
settings: Settings,
audit?: RunAuditor,
): Promise<{ blocked: false } | { blocked: true; message: string }> {
if (task.scopeOverride === true) {
executorLog.debug(`${task.id}: scope-leak guard bypassed (scopeOverride=true)`);
await deps.store.logEntry(task.id, "[scope-leak] scope guard bypassed via task.scopeOverride", undefined, deps.getRunContextFor(task.id));
return { blocked: false };
}
const declaredScope = await deps.store.parseFileScopeFromPrompt(task.id).catch(() => [] as string[]);
// FNXC:RepositoryScope 2026-08-21-00:44:
// Empty File Scope disables only declared-path matching. It must not bypass the independent
// completion fence for dirty acquired repositories outside confirmed task intent.
const workspaceConfig = deps.ensureWorkspaceConfig
? await deps.ensureWorkspaceConfig()
: deps.workspaceConfig;
if (declaredScope.length === 0 && workspaceConfig) {
const scope = new Set(task.repositoryScope?.repositories ?? []);
for (const repoRel of Object.keys(task.workspaceWorktrees ?? {}).sort()) {
if (scope.has(repoRel)) continue;
const repo = task.workspaceWorktrees?.[repoRel];
if (!repo) continue;
try {
await verifyRepositoryCaptureEvidence(repo.worktreePath);
} catch (_error) {
const message = `workspace repository ${repoRel} cannot establish out-of-scope change evidence; completion is blocked until its checkout is readable`;
await deps.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, deps.getRunContextFor(task.id));
return { blocked: true, message };
}
const [uncommitted, committed, strictEvidence] = await Promise.all([
deps.captureUncommittedModifiedFiles(repo.worktreePath),
deps.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha ?? undefined, task.id, audit, "scope-leak-out-of-scope"),
captureRepositoryChangeEvidence(repo.worktreePath, repo.baseCommitSha ?? undefined),
]);
if (uncommitted.length > 0 || committed.length > 0 || strictEvidence.length > 0) {
const message = `workspace repository ${repoRel} has modified out-of-scope work; approve the repository scope or clean the checkout before completing`;
await deps.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, deps.getRunContextFor(task.id));
return { blocked: true, message };
}
}
return { blocked: false };
}
if (declaredScope.length === 0) return { blocked: false };
const reviewLevel = parseReviewLevelFromPrompt(promptContent);
const configuredMode = settings.planOnlyScopeLeakEnforcement ?? "warn";
const enforcementMode: "off" | "warn" | "block" = reviewLevel === 1
? configuredMode
: "warn";
if (enforcementMode === "off") {
return { blocked: false };
}
// FNXC:Workspace 2026-06-22-00:30: KTD4 — per-repo scope-leak guard.
// The singular capture below runs `captureUncommittedModifiedFiles` + `captureModifiedFiles`
// against `worktreePath`. In workspace mode `worktreePath` is the browse-only non-git workspace
// root, so both silently return [] (git failures swallowed) and the uncommitted-in-scope block
// never fires — a workspace task could complete with off-scope changes in any sub-repo. So we
// ITERATE every acquired sub-repo (cwd = repo.worktreePath, base = repo.baseCommitSha) and block
// on the FIRST repo carrying off-scope changes — naming the repo. The task-level preamble above
// (scopeOverride / declaredScope / enforcementMode) is shared and runs once. Return shape is
// preserved: `{blocked:false} | {blocked:true; message}`.
//
// FNXC:Workspace 2026-06-21-15:00: F1/F2/F5/F6 hardening of the per-repo scope-leak guard.
// F5 (false-block fix + dead-code wiring + single filter surface): we previously repo-prefixed each
// touched file (`${repoRel}/${file}`) BEFORE filtering, so `isAlwaysAllowedScopeLeakPath`'s
// `startsWith(".changeset/")` carve-out never matched a sub-repo changeset (`repo-a/.changeset/x.md`)
// and a legit per-repo changeset was wrongly flagged off-scope → fn_task_done wrongly REFUSED. Now we
// derive each repo's repo-LOCAL declared-scope subset (`deriveRepoScopeSubset`) and run the SAME
// `workflowPathMatchesDeclaredScope` + `isAlwaysAllowedScopeLeakPath` filter the non-workspace path
// uses against the repo-LOCAL touched file — one filter surface, not two. This wires in the formerly
// dead `deriveRepoScopeSubset`/`splitRepoScopedPath` helpers.
// F1 (fail CLOSED on throw): each repo iteration is wrapped in its own try/catch (like the
// attribution-audit loop). A thrown capture/diff error in workspace mode surfaces as a BLOCK naming
// the repo instead of bubbling to the outer `.catch()` that fails OPEN — an incomplete scope check
// must never let fn_task_done proceed.
// F2 (scoped-but-zero-acquire): a scoped task that acquired NO sub-repo worktrees aggregates zero
// off-scope files and would silently pass; we block it (scope is declared but unverifiable).
// F6 (deterministic ordering): iterate sorted repo keys so the reported offending repo is stable
// across runs/rehydrate.
let touchedFiles: string[];
let offendingRepo: string | undefined;
if (workspaceConfig) {
const workspaceWorktrees = task.workspaceWorktrees ?? {};
/*
FNXC:RepositoryScope 2026-08-21-00:29:
Review authority is limited to explicit scope, but completion must inspect every acquired
checkout. A dirty acquired-out-of-scope repository is evidence that cannot be silently
delivered or ignored; it blocks until the operator approves it into scope or the work is
cleaned. Clean acquired-out-of-scope repositories remain non-reviewable.
*/
const scope = new Set(task.repositoryScope?.repositories ?? []);
const repoKeys = Object.keys(workspaceWorktrees).sort();
// F2: declaredScope is non-empty here (the `declaredScope.length === 0` early-return above
// handled the unscoped case). A scoped task that acquired no sub-repo worktrees cannot have its
// scope verified at all — refuse rather than silently passing scope enforcement.
if (repoKeys.length === 0) {
const message = "workspace task declares File Scope but acquired no sub-repo worktrees — cannot verify scope";
executorLog.warn(`${task.id}: [scope-leak] ${message}`);
await deps.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, deps.getRunContextFor(task.id));
return { blocked: true, message };
}
const aggregatedOffScope: string[] = [];
for (const repoRel of repoKeys) {
const repo = workspaceWorktrees[repoRel];
try {
await verifyRepositoryCaptureEvidence(repo.worktreePath);
const [repoUncommitted, repoCommitted, strictEvidence] = await Promise.all([
deps.captureUncommittedModifiedFiles(repo.worktreePath),
deps.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha ?? undefined, task.id, audit, "scope-leak-guard"),
captureRepositoryChangeEvidence(repo.worktreePath, repo.baseCommitSha ?? undefined),
]);
// Repo-LOCAL touched files (no `${repoRel}/` prefix) so the always-allowed `.changeset/`
// carve-out and the scope match operate as the reviewer/cwd=repo sees them (F5).
// The direct evidence cannot degrade a later Git failure to [] like telemetry capture does.
const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted, ...strictEvidence])];
if (scope.size > 0 && !scope.has(repoRel) && repoTouched.length > 0) {
const message = `workspace repository ${repoRel} has modified out-of-scope work; approve the repository scope or clean the checkout before completing`;
executorLog.warn(`${task.id}: [scope-leak] ${message}`);
await deps.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, deps.getRunContextFor(task.id));
return { blocked: true, message };
}
// Repo-LOCAL declared-scope subset for THIS repo (prefix stripped). Same filter as the
// non-workspace branch below — one surface. Clean acquired-out-of-scope repositories have
// no declared scope and are intentionally informational only.
const repoScopeSubset = resolveRepoDeclaredScope(declaredScope, repoRel, repoKeys).scope;
const repoOffScope = repoTouched
.filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, repoScopeSubset))
.filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath))
// Re-prefix the surviving off-scope files for the operator-facing message/attribution.
.map((filePath) => `${repoRel}/${filePath}`);
if (repoOffScope.length > 0) {
// First offending repo wins (mirrors verifyWorktreeInvariants' first-failing-repo return).
if (!offendingRepo) offendingRepo = repoRel;
aggregatedOffScope.push(...repoOffScope);
}
} catch (repoErr: unknown) {
// F1: fail CLOSED. A capture/diff throw means scope is UNVERIFIED for this repo; refuse
// fn_task_done as a precaution rather than letting the outer `.catch()` fail open.
const errMessage = repoErr instanceof Error ? repoErr.message : String(repoErr);
const message = `workspace scope-leak guard failed to evaluate (${repoRel}/${errMessage}) — refusing fn_task_done as a precaution`;
executorLog.warn(`${task.id}: [scope-leak] ${message}`);
await deps.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, deps.getRunContextFor(task.id));
return { blocked: true, message };
}
}
touchedFiles = aggregatedOffScope;
if (touchedFiles.length === 0) {
return { blocked: false };
}
} else {
const [uncommittedTouchedFiles, branchCommittedFiles] = await Promise.all([
deps.captureUncommittedModifiedFiles(worktreePath),
deps.captureModifiedFiles(worktreePath, task.baseCommitSha ?? undefined, task.id, audit, "scope-leak-guard"),
]);
touchedFiles = [...new Set([...uncommittedTouchedFiles, ...branchCommittedFiles])];
if (touchedFiles.length === 0) {
return { blocked: false };
}
}
const offScopeFiles = (workspaceConfig
// In workspace mode `touchedFiles` is already the off-scope set (filtered per repo above).
? touchedFiles
: touchedFiles
.filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope))
// FN-4811 follow-up: by convention every task may add its own changeset entry
// under `.changeset/`, so changeset files are always considered in-scope and
// never flagged by the scope-leak guard. The file-scope invariant at squash and
// the broader contamination guards still catch cross-task changeset leakage at
// a higher signal-to-noise ratio than the per-execution scope-leak warning.
.filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)));
if (offScopeFiles.length === 0) {
return { blocked: false };
}
const renderListPreview = (items: string[], cap = 10): string => {
if (items.length <= cap) {
return items.join(", ");
}
const remaining = items.length - cap;
return `${items.slice(0, cap).join(", ")}, … (+${remaining} more)`;
};
const offScopePreview = renderListPreview(offScopeFiles);
const declaredScopePreview = renderListPreview(declaredScope);
// Name the offending sub-repo in workspace mode so the operator/agent knows where to revert.
const repoTag = offendingRepo ? ` repo=${offendingRepo}` : "";
const message = `[scope-leak] reviewLevel=${reviewLevel} enforcement=${enforcementMode}${repoTag} off-scope touched files [${offScopePreview}]; declared scope [${declaredScopePreview}]; total off-scope=${offScopeFiles.length} total scope=${declaredScope.length}`;
executorLog.warn(`${task.id}: ${message}`);
await deps.store.logEntry(task.id, message, undefined, deps.getRunContextFor(task.id));
if (enforcementMode === "block") {
return {
blocked: true,
message: `Plan-Only scope-leak guard refused fn_task_done${offendingRepo ? ` (sub-repo ${offendingRepo})` : ""}. Off-scope paths: [${offScopePreview}]. Revert them before retrying (for example: git checkout -- <paths>).`,
};
}
return { blocked: false };
}