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>
289 lines
15 KiB
TypeScript
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 };
|
|
}
|
|
|