feat(workspace): Phase B U1 — per-repo change capture, contamination, and verify
In workspace mode the executor now captures changes and verifies worktree
invariants per acquired sub-repo instead of degrading to empty against the
non-git root. Post-session capture (:7898) gains a workspace branch that loops
task.workspaceWorktrees and reuses captureModifiedFiles(repo.worktreePath,
repo.baseCommitSha, …) per repo — inheriting resolveDiffBaseRef's merge-base
fallback (repo baseCommitSha may be undefined) and the filterFilesToOwnTaskCommits
contamination/divergence audit — then prefixes each repo's files with the repo
path into task.modifiedFiles. Branch attribution runs per sub-repo (cwd), never
against the root. The no-op assertCleanBranchAtBase is not iterated.
verifyWorktreeInvariants is un-stubbed for workspace mode: it iterates every
workspaceWorktrees entry asserting toplevel match + HEAD on fusion/<id>, and
returns the FIRST failing repo while preserving the exact discriminated union
{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits';
observed; expected} (the :10889 consumer switches on reason for requeue/handoff)
— the new repo field is additive. Singular non-workspace path unchanged.
Real two-repo fixture tests (capture A+B repo-prefixed vs own base, undefined-base
fallback, foreign-commit contamination audit, wrong_branch verify failure,
single-repo regression). Gate green: typecheck, lint, test:gate (649+58).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Workspace mode (Phase B, U1): per-repo post-session change capture, contamination detection, and worktree-invariant verification. In workspace mode the executor now loops `task.workspaceWorktrees`, reusing `captureModifiedFiles` per sub-repo (diffing each against its own `baseCommitSha`, with a merge-base fallback when undefined) to aggregate repo-prefixed `task.modifiedFiles` and surface per-repo contamination, and un-stubs `verifyWorktreeInvariants` to assert each acquired worktree's git toplevel and `fusion/<id>` branch. Single-repo behavior is unchanged.
|
||||
244
packages/engine/src/__tests__/executor-workspace-capture.test.ts
Normal file
244
packages/engine/src/__tests__/executor-workspace-capture.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
FNXC:Workspace 2026-06-21-23:30:
|
||||
U1 per-repo capture + contamination + worktree-invariant tests (KTD1/KTD2). These drive the REAL TaskExecutor methods against a REAL two-repo git fixture under a NON-git workspace root (createWorkspaceFixture), so any leaked rootDir git preflight would actually fail and a hand-built `git diff` against an undefined base would blow up.
|
||||
|
||||
Seam choice (FN-5048): we set `(executor as any).workspaceConfig` directly (loadWorkspaceConfig has its own unit) and create real `fusion/<id>` worktrees per sub-repo with real commits — no mock-the-world child_process. Capture is exercised through `captureWorkspaceModifiedFiles` (the helper the post-session path at executor.ts:7900 calls) and verification through `verifyWorktreeInvariants`. Real git is used only where the invariant requires it.
|
||||
|
||||
Coverage:
|
||||
- happy: edits in repo A + B → aggregated modifiedFiles carry repo-prefixed paths from BOTH, each diffed against its own baseCommitSha.
|
||||
- edge: a repo with baseCommitSha undefined → capture still works via resolveDiffBaseRef's merge-base fallback (no `git diff undefined..HEAD`).
|
||||
- contamination: a foreign commit (feat(FN-OTHER):) in a sub-repo's range → the filterFilesToOwnTaskCommits divergence audit fires (task:worktree-contamination-detected) for that repo, and the foreign file is excluded from attributed files.
|
||||
- error: a worktree HEAD off fusion/<id> → verifyWorktreeInvariants returns {ok:false, reason:'wrong_branch', repo, observed, expected} (NOT {ok:true}); the reason enum is preserved for the :10889 consumer.
|
||||
- regression: a single-repo (non-workspace) task → capture/verify identical to today.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Task, TaskStore, WorkspaceConfig } from "@fusion/core";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
|
||||
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function createStore(overrides: Partial<Record<string, unknown>> = {}): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: vi.fn().mockResolvedValue({ autoMerge: false }),
|
||||
getRunContextFor: vi.fn(),
|
||||
on: emitter.on.bind(emitter),
|
||||
...overrides,
|
||||
}) as unknown as TaskStore & EventEmitter;
|
||||
}
|
||||
|
||||
function makeTask(id = "FN-WS-1", overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id,
|
||||
title: "Workspace task",
|
||||
description: "",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
// Capture attribution requires a digit-form task id (`FN-\d+`); the branch-attribution
|
||||
// subject parser only attributes `feat(FN-1001):` style subjects, so the KTD2-era
|
||||
// `FN-WS-1` placeholder would never attribute a commit. Use a real numeric id here.
|
||||
const TASK_ID = "FN-1001";
|
||||
const BRANCH = "fusion/fn-1001";
|
||||
|
||||
/** Configure git identity in a freshly-created worktree (worktrees don't inherit user.* on all platforms). */
|
||||
function configureIdentity(dir: string): void {
|
||||
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a real fusion/<id> worktree to a sub-repo, commit one own-attributed edit
|
||||
* onto that branch, and return { worktreePath, baseCommitSha } for task.workspaceWorktrees.
|
||||
* baseCommitSha is the sub-repo's pre-edit HEAD so the diff range is base..HEAD.
|
||||
*/
|
||||
function addRepoWorktreeWithOwnEdit(
|
||||
fx: WorkspaceFixture,
|
||||
repoRel: string,
|
||||
fileName: string,
|
||||
): { worktreePath: string; baseCommitSha: string } {
|
||||
const repoDir = fx.repoPath(repoRel);
|
||||
const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD");
|
||||
const worktreePath = path.join(repoDir, ".worktrees", "fn-ws-1");
|
||||
fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
|
||||
configureIdentity(worktreePath);
|
||||
mkdirSync(path.dirname(path.join(worktreePath, fileName)), { recursive: true });
|
||||
writeFileSync(path.join(worktreePath, fileName), "// own change\n", "utf-8");
|
||||
execSync(`git add ${fileName}`, { cwd: worktreePath, stdio: "pipe" });
|
||||
execSync(`git commit -m "feat(${TASK_ID}): edit ${fileName}"`, { cwd: worktreePath, stdio: "pipe" });
|
||||
return { worktreePath, baseCommitSha };
|
||||
}
|
||||
|
||||
function workspaceExecutor(fx: WorkspaceFixture, store = createStore()): TaskExecutor {
|
||||
const executor = new TaskExecutor(store, fx.rootDir);
|
||||
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
|
||||
return executor;
|
||||
}
|
||||
|
||||
describeIfGit("U1 KTD1 — per-repo capture aggregates repo-prefixed paths", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("happy: edits in repo A + B are diffed against their own base and repo-prefixed", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts");
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const files = await (executor as any).captureWorkspaceModifiedFiles(task);
|
||||
expect(files).toContain("repo-a/src/a.ts");
|
||||
expect(files).toContain("repo-b/src/b.ts");
|
||||
expect(files).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("edge: a repo with undefined baseCommitSha still captures via merge-base fallback (no `git diff undefined..HEAD`)", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
// baseCommitSha intentionally undefined → resolveDiffBaseRef merge-base(HEAD, main).
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH },
|
||||
},
|
||||
});
|
||||
|
||||
const files = await (executor as any).captureWorkspaceModifiedFiles(task);
|
||||
expect(files).toEqual(["repo-a/src/a.ts"]);
|
||||
});
|
||||
|
||||
it("contamination: a foreign commit in a sub-repo range fires the divergence audit and is excluded from attributed files", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
// Land a FOREIGN commit (different FN-id) onto the same fusion/<id> branch range.
|
||||
const foreignFile = "src/foreign.ts";
|
||||
writeFileSync(path.join(a.worktreePath, "src", "foreign.ts"), "// foreign\n", "utf-8");
|
||||
execSync(`git add ${foreignFile}`, { cwd: a.worktreePath, stdio: "pipe" });
|
||||
execSync('git commit -m "feat(FN-OTHER): sneaky foreign change"', { cwd: a.worktreePath, stdio: "pipe" });
|
||||
|
||||
const dbAudit = vi.fn().mockResolvedValue(undefined);
|
||||
const audit = {
|
||||
database: dbAudit,
|
||||
filesystem: vi.fn().mockResolvedValue(undefined),
|
||||
git: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const files = await (executor as any).captureWorkspaceModifiedFiles(task, audit as any, "post-session");
|
||||
// Own file attributed, foreign file excluded from the attributed set.
|
||||
expect(files).toEqual(["repo-a/src/a.ts"]);
|
||||
expect(files).not.toContain("repo-a/src/foreign.ts");
|
||||
// The contamination/divergence audit fired for this repo (raw 2 files vs attributed 1).
|
||||
const contaminationCall = dbAudit.mock.calls.find(
|
||||
([evt]) => evt?.type === "task:worktree-contamination-detected",
|
||||
);
|
||||
expect(contaminationCall).toBeTruthy();
|
||||
expect(contaminationCall![0].metadata.rawDiffFileCount).toBeGreaterThan(contaminationCall![0].metadata.attributedFileCount);
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U1 KTD2 — verifyWorktreeInvariants iterates per worktree, preserving the result union", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("happy: every worktree on fusion/<id> with matching toplevel → {ok:true}", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts");
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("error: a worktree HEAD off fusion/<id> → {ok:false, reason:'wrong_branch', repo, observed, expected} (NOT {ok:true})", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts");
|
||||
const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts");
|
||||
// Drift repo-b's worktree off fusion/<id> onto a different branch.
|
||||
execSync("git checkout -b some-other-branch", { cwd: b.worktreePath, stdio: "pipe" });
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, {
|
||||
branch: BRANCH,
|
||||
workspaceWorktrees: {
|
||||
"repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha },
|
||||
"repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("wrong_branch");
|
||||
expect(result.repo).toBe("repo-b");
|
||||
expect(result.observed).toBe("some-other-branch");
|
||||
expect(result.expected).toBe(BRANCH);
|
||||
});
|
||||
|
||||
it("regression: a zero-acquire workspace task (empty map) verifies vacuously → {ok:true}", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
const executor = workspaceExecutor(fx);
|
||||
const task = makeTask(TASK_ID, { branch: BRANCH, workspaceWorktrees: {} });
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describeIfGit("U1 — single-repo (non-workspace) task: capture/verify unchanged", () => {
|
||||
let fx: WorkspaceFixture;
|
||||
afterEach(() => fx?.cleanup());
|
||||
|
||||
it("regression: non-workspace verifyWorktreeInvariants still runs the singular path and passes for a real worktree", async () => {
|
||||
fx = await createWorkspaceFixture();
|
||||
// Single-repo executor rooted at repo-a itself (no workspaceConfig).
|
||||
const repoDir = fx.repoPath("repo-a");
|
||||
const worktreePath = path.join(repoDir, ".worktrees", "fn-001");
|
||||
const base = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8" }).trim();
|
||||
execSync(`git worktree add -b fusion/fn-001 ${worktreePath} HEAD`, { cwd: repoDir, stdio: "pipe" });
|
||||
configureIdentity(worktreePath);
|
||||
writeFileSync(path.join(worktreePath, "single.ts"), "// x\n", "utf-8");
|
||||
execSync("git add single.ts", { cwd: worktreePath, stdio: "pipe" });
|
||||
execSync('git commit -m "feat(FN-001): single"', { cwd: worktreePath, stdio: "pipe" });
|
||||
|
||||
const store = createStore();
|
||||
const executor = new TaskExecutor(store, repoDir); // no workspaceConfig → singular path
|
||||
const task = makeTask("FN-001", { branch: "fusion/fn-001", worktree: worktreePath, baseCommitSha: base });
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
VERIFICATION_LOG_MAX_CHARS,
|
||||
type VerificationResult,
|
||||
} from "./verification-utils.js";
|
||||
import { canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js";
|
||||
import { canonicalFusionBranchName, canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js";
|
||||
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
|
||||
import { Type, type Static } from "@earendil-works/pi-ai";
|
||||
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
@@ -7895,6 +7895,47 @@ export class TaskExecutor {
|
||||
const allSuccess = results.every(r => r.success);
|
||||
if (allSuccess) {
|
||||
const updatedTask = await this.store.getTask(task.id);
|
||||
// FNXC:Workspace 2026-06-21-23:30: KTD1 — per-repo post-session capture.
|
||||
// The singular call below runs UNGATED with worktreePath = the browse-only non-git workspace root and silently returns [] (resolveDiffBaseRef swallows the git failure at the root). In workspace mode there is nothing to diff at the root; the real changes live in each acquired sub-repo worktree. So we ADD (not replace) a workspace branch that loops `task.workspaceWorktrees` and reuses the EXISTING captureModifiedFiles per repo — reusing it (rather than hand-building `git diff <base>..HEAD`) gives us the merge-base fallback for an undefined repo.baseCommitSha (resolveDiffBaseRef) AND restores the contamination/divergence audit (filterFilesToOwnTaskCommits) for free per repo. Returned files are repo-prefixed (e.g. `repo-a/src/foo.ts`) and aggregated into task.modifiedFiles.
|
||||
if (this.workspaceConfig) {
|
||||
const workspaceWorktrees = updatedTask.workspaceWorktrees ?? {};
|
||||
const aggregated = await this.captureWorkspaceModifiedFiles(updatedTask, audit, "post-session");
|
||||
for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) {
|
||||
// Per-repo branch-attribution audit (cwd = sub-repo). Run against repo.worktreePath/repo.branch, NOT the non-git root (a root call would fail and surface nothing). The contamination signal already rides on captureWorkspaceModifiedFiles above; this is the supplementary commit-attribution surface (FN-5233 pattern).
|
||||
try {
|
||||
const attributionBase = await this.resolveContaminationBaseRef(repo.worktreePath);
|
||||
if (attributionBase && repo.branch) {
|
||||
const attribution = await reportBranchAttribution(repo.worktreePath, repo.branch, attributionBase, task.id);
|
||||
const hasAnomaly = attribution.foreign.length > 0 || attribution.unattributed.length > 0 || attribution.ownUntrailed.length > 0;
|
||||
if (hasAnomaly) {
|
||||
const summary = `branch-attribution anomalies on ${repoRel}@${repo.branch}: foreign=${attribution.foreign.length}, unattributed=${attribution.unattributed.length}, ownUntrailed=${attribution.ownUntrailed.length}, ownTrailed=${attribution.ownTrailed}`;
|
||||
executorLog.warn(`${task.id}: ${summary}`);
|
||||
await this.store.logEntry(task.id, `[branch-attribution] ${summary}`, undefined, this.getRunContextFor(task.id));
|
||||
await audit.git({
|
||||
type: "branch:attribution-anomaly",
|
||||
target: repo.branch,
|
||||
metadata: {
|
||||
taskId: task.id,
|
||||
repo: repoRel,
|
||||
baseSha: attributionBase,
|
||||
ownTrailed: attribution.ownTrailed,
|
||||
foreign: attribution.foreign,
|
||||
unattributed: attribution.unattributed,
|
||||
ownUntrailed: attribution.ownUntrailed,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (attributionErr: unknown) {
|
||||
executorLog.warn(`${task.id}: post-session per-repo branch-attribution audit failed for ${repoRel}: ${attributionErr instanceof Error ? attributionErr.message : String(attributionErr)}`);
|
||||
}
|
||||
}
|
||||
if (aggregated.length > 0) {
|
||||
await this.store.updateTask(task.id, { modifiedFiles: aggregated });
|
||||
executorLog.log(`${task.id}: captured ${aggregated.length} modified files across ${Object.keys(workspaceWorktrees).length} sub-repo(s)`);
|
||||
await audit.filesystem({ type: "file:capture-modified", target: task.id, metadata: { files: aggregated } });
|
||||
}
|
||||
} else {
|
||||
const modifiedFiles = await this.captureModifiedFiles(worktreePath, updatedTask.baseCommitSha, task.id, audit, "post-session");
|
||||
if (modifiedFiles.length > 0) {
|
||||
await this.store.updateTask(task.id, { modifiedFiles });
|
||||
@@ -7936,6 +7977,7 @@ export class TaskExecutor {
|
||||
} catch (attributionErr: unknown) {
|
||||
executorLog.warn(`${task.id}: post-session branch-attribution audit failed: ${attributionErr instanceof Error ? attributionErr.message : String(attributionErr)}`);
|
||||
}
|
||||
} // end !this.workspaceConfig singular capture (FNXC:Workspace KTD1)
|
||||
|
||||
this.scheduleCompletedTaskWatchdog(task.id, "step-session completion");
|
||||
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after step-session completion")) {
|
||||
@@ -10502,10 +10544,87 @@ export class TaskExecutor {
|
||||
worktreePathOverride?: string,
|
||||
allowReanchor = true,
|
||||
options?: { noOpCompletion?: boolean; noOpCompletionReason?: string },
|
||||
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> {
|
||||
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string; repo?: string }> {
|
||||
const settings = await this.store.getSettings();
|
||||
// FNXC:Workspace 2026-06-21-12:00: KTD1/KTD2 — workspace tasks have no root worktree and no single `task.worktree`; the singular per-task invariant is meaningless against the non-git root. Phase B (master U3) iterates this check per sub-repo worktree. Until then it is gated OFF in workspace mode so fn_task_done (its only caller path) does not requeue a zero-acquire workspace task for "missing task.worktree".
|
||||
// FNXC:Workspace 2026-06-21-23:30: KTD2 — un-stubbed per-repo worktree-invariant verification.
|
||||
// Phase A returned a flat {ok:true} stub here (no root worktree to verify against the non-git root). Phase B iterates every `task.workspaceWorktrees` entry, asserting (a) the sub-repo worktree's git toplevel matches the recorded repo.worktreePath and (b) its HEAD is on the recorded `fusion/<id>` branch (repo.branch). The result union is PRESERVED EXACTLY — `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` — because the :10889 consumer switches on `reason` to drive requeue/handoff (:10894-10936). We ADD an optional `repo` field to the failure shape (purely additive; the consumer only reads reason/observed/expected) and return the FIRST failing repo. A zero-acquire workspace task (empty map) verifies vacuously → {ok:true}, matching Phase A so fn_task_done does not requeue it.
|
||||
if (this.workspaceConfig) {
|
||||
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
||||
for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) {
|
||||
const expectedBranch = repo.branch || canonicalFusionBranchName(task.id);
|
||||
// Skip git checks if the worktree dir is gone (mirrors the singular FN-009 carve-out below): completion does not require a live worktree on disk.
|
||||
if (!existsSync(repo.worktreePath)) {
|
||||
executorLog.log(`${task.id}: workspace worktree for ${repoRel} not found at ${repo.worktreePath} — skipping git validation`);
|
||||
continue;
|
||||
}
|
||||
let expectedWorktreeRealpath: string;
|
||||
try {
|
||||
expectedWorktreeRealpath = canonicalizePath(repo.worktreePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "wrong_toplevel",
|
||||
repo: repoRel,
|
||||
observed: `unresolvable repo worktree (${repo.worktreePath}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
expected: `resolvable worktree for ${repoRel}`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
|
||||
cwd: repo.worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const observedTopLevelRaw = stdout.trim();
|
||||
if (observedTopLevelRaw) {
|
||||
const observedTopLevel = canonicalizePath(observedTopLevelRaw);
|
||||
if (observedTopLevel !== expectedWorktreeRealpath) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "wrong_toplevel",
|
||||
repo: repoRel,
|
||||
observed: observedTopLevel,
|
||||
expected: expectedWorktreeRealpath,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "wrong_toplevel",
|
||||
repo: repoRel,
|
||||
observed: error instanceof Error ? error.message : String(error),
|
||||
expected: expectedWorktreeRealpath,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: repo.worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const observedBranch = stdout.trim();
|
||||
if (observedBranch && observedBranch !== expectedBranch) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "wrong_branch",
|
||||
repo: repoRel,
|
||||
observed: observedBranch,
|
||||
expected: expectedBranch,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "wrong_branch",
|
||||
repo: repoRel,
|
||||
observed: error instanceof Error ? error.message : String(error),
|
||||
expected: expectedBranch,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
const branchName = resolveTaskWorkingBranch(task);
|
||||
@@ -12264,6 +12383,26 @@ ${failureFeedback}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private async captureWorkspaceModifiedFiles(
|
||||
task: Task,
|
||||
audit?: RunAuditor,
|
||||
source = "post-session",
|
||||
): Promise<string[]> {
|
||||
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
||||
const aggregated: string[] = [];
|
||||
for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) {
|
||||
const repoFiles = await this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source);
|
||||
for (const file of repoFiles) {
|
||||
aggregated.push(`${repoRel}/${file}`);
|
||||
}
|
||||
}
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
private async captureUncommittedModifiedFiles(worktreePath: string): Promise<string[]> {
|
||||
try {
|
||||
const [unstaged, staged] = await Promise.all([
|
||||
|
||||
Reference in New Issue
Block a user