FN-9043: fix workspace completion commit detection

Resolve workspace configuration consistently so task completion observes commits across acquired repositories.

- Add a host-scoped, memoized workspace configuration resolver and preserve live configuration through executor dependency bags.
- Resolve workspace configuration before planning, worktree, graph, cleanup, and task-done paths.
- Aggregate workspace commit observations and cover partial-repository completion cases.

Files changed:
 .changeset/fn-9043-workspace-no-commits.md         |   7 ++
 .../executor-workspace-config-propagation.test.ts  | 116 +++++++++++++++++++++
 .../engine/src/executor/cleanup-task-worktree.ts   |   6 +-
 .../create-authoritative-workflow-seams.ts         |   6 +-
 packages/engine/src/executor/deps-bags.ts          |  74 ++++++++++---
 .../executor/ensure-graph-custom-node-worktree.ts  |  14 ++-
 .../executor/ensure-task-worktree-for-planning.ts  |   8 +-
 packages/engine/src/executor/get-worktree-path.ts  |   3 +
 packages/engine/src/executor/mark-stuck-aborted.ts |   6 +-
 .../engine/src/executor/run-graph-custom-node.ts   |   8 +-
 packages/engine/src/executor/run-implementation.ts |   6 +-
 .../src/executor/workspace-config-resolver.ts      |  40 +++++++
 .../src/executor/worktree-task-done-scope-leak.ts  |   8 +-
 .../src/executor/worktree-verify-invariants.ts     |  44 ++++----
 14 files changed, 287 insertions(+), 59 deletions(-)

Fusion-Task-Id: FN-9043

Fusion-Task-Lineage: fc012b8e-c9cb-47e8-81a7-c9590a63a980

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-14 14:24:26 -07:00
parent ba643fc384
commit 25b03d6e70
14 changed files with 287 additions and 59 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix workspace task completion when changes land in only one repository.
category: fix
dev: Adds a per-host workspace resolver, resolves before executor workspace branches, normalizes empty configs, and aggregates commit counts across acquired repositories.

View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from "vitest";
import { execSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Task, TaskStore } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { resolveWorkspaceConfigOnce } from "../executor/workspace-config-resolver.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const describeIfGit = hasGit ? describe : describe.skip;
const TASK_ID = "FN-9043";
const BRANCH = "fusion/fn-9043";
function createStore(): TaskStore {
return Object.assign(new EventEmitter(), { getSettings: async () => ({}) }) as unknown as TaskStore;
}
function makeTask(worktrees: Task["workspaceWorktrees"]): Task {
return {
id: TASK_ID, title: "workspace verification", description: "", column: "in-progress",
dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), branch: BRANCH, workspaceWorktrees: worktrees,
} as Task;
}
function addWorktree(fx: WorkspaceFixture, repo: string, commit: boolean): { worktreePath: string; baseCommitSha: string } {
const repoPath = fx.repoPath(repo);
const baseCommitSha = fx.git(repo, "git rev-parse HEAD");
const worktreePath = path.join(repoPath, ".worktrees", TASK_ID);
fx.git(repo, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`);
if (commit) {
execSync('git config user.email "test@example.com"', { cwd: worktreePath });
execSync('git config user.name "Test"', { cwd: worktreePath });
mkdirSync(path.join(worktreePath, "src"), { recursive: true });
writeFileSync(path.join(worktreePath, "src", "change.ts"), "export {}\n");
execSync("git add src/change.ts && git commit -m workspace-change", { cwd: worktreePath });
}
return { worktreePath, baseCommitSha };
}
/**
* FNXC:Workspace 2026-08-14-21:06:
* Completion must load workspace mode from its natural undefined host state; injecting the field
* masks the non-git-root regression reported by issue #3435.
*/
describeIfGit("FN-9043 workspace config propagation", () => {
let fixture: WorkspaceFixture | undefined;
afterEach(() => fixture?.cleanup());
it("normalizes an empty workspace config to cached single-repo mode", async () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), "fusion-empty-workspace-"));
try {
mkdirSync(path.join(rootDir, ".fusion"));
writeFileSync(path.join(rootDir, ".fusion", "workspace.json"), '{"repos":[]}');
const host: { workspaceConfig: unknown } = { workspaceConfig: undefined };
const deps = {
rootDir,
workspaceConfigOwner: host,
getWorkspaceConfig: () => host.workspaceConfig as null | undefined,
setWorkspaceConfig: (config: unknown) => { host.workspaceConfig = config; },
};
expect(await resolveWorkspaceConfigOnce(deps)).toBeNull();
expect(host.workspaceConfig).toBeNull();
expect(await resolveWorkspaceConfigOnce(deps)).toBeNull();
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
it("uses sub-repo invariants when only one acquired repository has commits", async () => {
fixture = await createWorkspaceFixture();
const a = addWorktree(fixture, "repo-a", false);
const b = addWorktree(fixture, "repo-b", true);
const store = createStore();
const executor = new TaskExecutor(store, fixture.rootDir);
expect((executor as any).workspaceConfig).toBeUndefined();
const result = await (executor as any).verifyWorktreeInvariants(makeTask({
"repo-a": { ...a, branch: BRANCH },
"repo-b": { ...b, branch: BRANCH },
}));
expect(result).toEqual({ ok: true });
expect((executor as any).workspaceConfig?.repos).toEqual(["repo-a", "repo-b"]);
});
it("accepts commits in the first acquired repository too", async () => {
fixture = await createWorkspaceFixture();
const a = addWorktree(fixture, "repo-a", true);
const b = addWorktree(fixture, "repo-b", false);
const executor = new TaskExecutor(createStore(), fixture.rootDir);
await expect((executor as any).verifyWorktreeInvariants(makeTask({
"repo-a": { ...a, branch: BRANCH },
"repo-b": { ...b, branch: BRANCH },
}))).resolves.toEqual({ ok: true });
});
it("keeps no_commits blocking when every inspected sub-repo is empty", async () => {
fixture = await createWorkspaceFixture();
const a = addWorktree(fixture, "repo-a", false);
const b = addWorktree(fixture, "repo-b", false);
const executor = new TaskExecutor(createStore(), fixture.rootDir);
const result = await (executor as any).verifyWorktreeInvariants(makeTask({
"repo-a": { ...a, branch: BRANCH },
"repo-b": { ...b, branch: BRANCH },
}));
expect(result).toMatchObject({ ok: false, reason: "no_commits", expected: "> 0" });
if (!result.ok) expect(result.observed).toBe("repo-a=0, repo-b=0");
});
});

View File

@@ -16,6 +16,7 @@ type AnyFn = (...args: any[]) => any;
export type CleanupTaskWorktreeDeps = {
store: TaskStore;
workspaceConfig: WorkspaceConfig | null | undefined;
ensureWorkspaceConfig?: () => Promise<WorkspaceConfig | null>;
activeWorktrees: Map<string, Set<string>>;
getActiveWorktreePaths: (taskId: string) => string[];
removeOwnWorktreeWithReconcile: AnyFn;
@@ -25,13 +26,16 @@ export async function cleanupTaskWorktree(
deps: CleanupTaskWorktreeDeps,
taskId: string,
): Promise<void> {
const workspaceConfig = deps.ensureWorkspaceConfig
? await deps.ensureWorkspaceConfig()
: deps.workspaceConfig;
const worktreePaths = deps.getActiveWorktreePaths(taskId);
if (worktreePaths.length === 0) return;
deps.activeWorktrees.delete(taskId);
// FNXC:Workspace 2026-06-21-12:00: KTD1 — in workspace mode the tracked path is the non-git workspace root (browse-only), never a removable worktree. Drop the in-memory tracking above but never remove the root. Per-repo worktree teardown returns in Phase B.
if (deps.workspaceConfig) {
if (workspaceConfig) {
return;
}
// Non-workspace tasks hold a one-element set — preserve the original single-path removal semantics.

View File

@@ -55,6 +55,7 @@ export type CreateAuthoritativeWorkflowSeamsDeps = {
[k: string]: unknown;
};
workspaceConfig: WorkspaceConfig | null | undefined;
ensureWorkspaceConfig?: () => Promise<WorkspaceConfig | null>;
activeWorkflowPrincipals: Map<string, { agentId: string; nodeInstanceId: string; agent?: import("@fusion/core").Agent }>;
graphSeamGoverningNodeId: Map<string, string>;
graphSeamThinkingLevel: Map<string, ThinkingLevel>;
@@ -423,8 +424,11 @@ export function createAuthoritativeWorkflowSeams(
const invoke = () => invokeReviewerForCwd(cwd);
return sem ? sem.runNested(invoke) : invoke();
};
const workspaceConfig = deps.ensureWorkspaceConfig
? await deps.ensureWorkspaceConfig()
: deps.workspaceConfig;
const invokeReviewer = () =>
deps.workspaceConfig && reviewCwd === worktreePath
workspaceConfig && reviewCwd === worktreePath
? deps.reviewWorkspacePerRepo(detail, (cwd: string) => runForCwd(cwd))
: runForCwd(reviewCwd);

View File

@@ -17,6 +17,7 @@ import type { WorktreeInvariantDeps } from "./worktree-verify-invariants.js";
import type { NonContinuableSessionDeps } from "./non-continuable-session.js";
import { facadeFields, facadeMethods } from "./facade-methods.js";
import * as pure from "./pure-bindings.js";
import { resolveWorkspaceConfigOnce } from "./workspace-config-resolver.js";
import {
MAX_WORKTREE_RETRIES,
WORKTREE_RETRY_DELAYS,
@@ -89,20 +90,23 @@ export type WorktreeInvariantDepsSource = {
rootDir: string;
store: TaskStore;
workspaceConfig: unknown | null | undefined;
ensureWorkspaceConfig?: () => Promise<unknown | null>;
getActiveWorktreePaths: (taskId: string) => string[];
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
emitWorktreeReanchoredAudit: WorktreeInvariantDeps["emitWorktreeReanchoredAudit"];
};
export function buildWorktreeInvariantDeps(src: WorktreeInvariantDepsSource): WorktreeInvariantDeps {
return {
const bag = {
rootDir: src.rootDir,
store: src.store,
workspaceConfig: src.workspaceConfig,
ensureWorkspaceConfig: src.ensureWorkspaceConfig,
getActiveWorktreePaths: src.getActiveWorktreePaths,
getRunContextFor: src.getRunContextFor,
emitWorktreeReanchoredAudit: src.emitWorktreeReanchoredAudit,
};
// FNXC:Workspace 2026-08-14-21:06: Workspace mode must remain live through every bag re-projection; a getter/setter preserves host writes in strict-mode callers.
return defineLiveWorkspaceConfig(bag, src);
}
export type NonContinuableSessionDepsSource = NonContinuableSessionDeps;
@@ -185,12 +189,32 @@ export function buildHandleGraphFailureDeps(host: any): any {
* runImplementation deps bag peeled from TaskExecutor (U4). Constants are injected by the
* façade so the free builder stays free of executor-constants coupling.
*/
function defineLiveWorkspaceConfig<T extends object>(bag: T, owner: { workspaceConfig: unknown }): T & { workspaceConfig: unknown } {
Object.defineProperty(bag, "workspaceConfig", {
enumerable: true,
configurable: true,
get: () => owner.workspaceConfig,
set: (value: unknown) => { owner.workspaceConfig = value; },
});
return bag as T & { workspaceConfig: unknown };
}
function withWorkspaceResolver(host: any): () => Promise<unknown | null> {
return () => resolveWorkspaceConfigOnce({
rootDir: host.rootDir,
workspaceConfigOwner: host,
getWorkspaceConfig: () => host.workspaceConfig,
setWorkspaceConfig: (config) => { host.workspaceConfig = config; },
});
}
export function buildRunImplementationDeps(
host: any,
constants: { BRANCH_CONFLICT_TRIPWIRE_THRESHOLD: number; MAX_AUTO_RECOVERY_ATTEMPTS: number },
): any {
return {
...facadeFields(host, ["store", "rootDir", "workspaceConfig"]),
const bag = {
...facadeFields(host, ["store", "rootDir"]),
ensureWorkspaceConfig: withWorkspaceResolver(host),
options: host.options as any,
BRANCH_CONFLICT_TRIPWIRE_THRESHOLD: constants.BRANCH_CONFLICT_TRIPWIRE_THRESHOLD,
MAX_AUTO_RECOVERY_ATTEMPTS: constants.MAX_AUTO_RECOVERY_ATTEMPTS,
@@ -228,11 +252,13 @@ export function buildRunImplementationDeps(
]),
sharedWorkerTools: buildSharedWorkerToolsDeps(host),
};
return defineLiveWorkspaceConfig(bag, host);
}
export function buildRunGraphCustomNodeDeps(host: any): any {
return {
...facadeFields(host, ["store", "rootDir", "workspaceConfig"]),
const bag = {
...facadeFields(host, ["store", "rootDir"]),
ensureWorkspaceConfig: withWorkspaceResolver(host),
options: host.options as { pluginRunner?: unknown; [k: string]: unknown },
graphUnattendedRuns: host.graphUnattendedRuns,
...facadeMethods(host, [
@@ -243,15 +269,17 @@ export function buildRunGraphCustomNodeDeps(host: any): any {
"runRawCliCommand",
]),
};
return defineLiveWorkspaceConfig(bag, host);
}
export function buildCreateAuthoritativeWorkflowSeamsDeps(host: any): any {
return {
const bag = {
store: host.store,
rootDir: host.rootDir,
ensureWorkspaceConfig: withWorkspaceResolver(host),
options: host.options as { mergeRequester?: unknown; pluginRunner?: unknown; [k: string]: unknown },
...facadeFields(host, [
"workspaceConfig", "activeWorkflowPrincipals", "graphSeamGoverningNodeId", "graphSeamThinkingLevel",
"activeWorkflowPrincipals", "graphSeamGoverningNodeId", "graphSeamThinkingLevel",
"graphStepActiveContext", "graphRethinkNarrations", "pausedAborted",
"mergeRequester",
]),
@@ -263,6 +291,7 @@ export function buildCreateAuthoritativeWorkflowSeamsDeps(host: any): any {
"unregisterSubagentSession",
]),
};
return defineLiveWorkspaceConfig(bag, host);
}
export function buildCreateSpawnAgentToolDeps(host: any): any {
@@ -320,9 +349,9 @@ export function buildFinalizeAcceptedNoOpCompletionDeps(host: any): any {
}
export function buildMarkStuckAbortedDeps(host: any): any {
return {
const bag = {
...facadeFields(host, [
"store", "rootDir", "workspaceConfig",
"store", "rootDir",
"activeStepExecutors", "stuckAborted", "executing",
"activeWorktrees", "loopRecoveryState",
]),
@@ -331,7 +360,9 @@ export function buildMarkStuckAbortedDeps(host: any): any {
"awaitAbortInFlightTaskWork", "clearPausedAborted", "resetStepsIfWorkLost",
"hasActiveWorktreeBinding",
]),
ensureWorkspaceConfig: withWorkspaceResolver(host),
};
return defineLiveWorkspaceConfig(bag, host);
}
export function buildRunGraphTaskStepDeps(host: any): any {
@@ -377,6 +408,7 @@ export function buildEnsureGraphCustomNodeWorktreeDeps(host: any, runConfiguredC
return {
store: host.store,
rootDir: host.rootDir,
workspaceConfigOwner: host,
getWorkspaceConfig: () => host.workspaceConfig,
setWorkspaceConfig: (c: unknown) => { host.workspaceConfig = c; },
...facadeMethods(host, [
@@ -422,12 +454,14 @@ export function buildRunRawCliCommandDeps(host: any, runConfiguredCommand: any =
}
export function buildEvaluateTaskDoneScopeLeakDeps(host: any): any {
return {
...facadeFields(host, ["store", "workspaceConfig"]),
const bag = {
...facadeFields(host, ["store"]),
ensureWorkspaceConfig: withWorkspaceResolver(host),
...facadeMethods(host, [
"getRunContextFor", "captureUncommittedModifiedFiles", "captureModifiedFiles",
]),
};
return defineLiveWorkspaceConfig(bag, host);
}
export function buildScheduleCompletedTaskWatchdogDeps(
@@ -652,11 +686,13 @@ export function buildHandleImplicitTaskDoneRefusalDeps(host: any): any {
}
export function buildCleanupTaskWorktreeDeps(host: any): any {
return {
...facadeFields(host, ["store", "workspaceConfig", "activeWorktrees"]),
const bag = {
...facadeFields(host, ["store", "activeWorktrees"]),
ensureWorkspaceConfig: withWorkspaceResolver(host),
getActiveWorktreePaths: (id: string) => host.getActiveWorktreePaths(id),
removeOwnWorktreeWithReconcile: (...args: unknown[]) => host.removeOwnWorktreeWithReconcile(...args),
};
return defineLiveWorkspaceConfig(bag, host);
}
export function buildResumeTaskForAgentDeps(host: any): any {
@@ -1059,6 +1095,7 @@ export function buildEnsureTaskWorktreeForPlanningDeps(host: any): any {
return {
store: host.store,
rootDir: host.rootDir,
workspaceConfigOwner: host,
getWorkspaceConfig: () => host.workspaceConfig,
setWorkspaceConfig: (cfg: unknown) => { host.workspaceConfig = cfg; },
ensureGraphCustomNodeWorktree: (t: unknown, s: unknown, nodeId: string, refresh?: boolean) =>
@@ -1191,12 +1228,15 @@ export function buildAdoptColumnAgentForNodeDeps(host: any): any {
}
export function buildWorktreeInvariantFacadeDeps(host: any): any {
return buildWorktreeInvariantDeps({
...facadeFields(host, ["rootDir", "store", "workspaceConfig"]),
const facade = {
...facadeFields(host, ["rootDir", "store"]),
ensureWorkspaceConfig: withWorkspaceResolver(host),
...facadeMethods(host, [
"getActiveWorktreePaths", "getRunContextFor", "emitWorktreeReanchoredAudit",
]),
});
};
// FNXC:Workspace 2026-08-14-21:06: Object spread snapshots accessors, so the invariant's two-hop facade explicitly re-projects the live getter/setter.
return buildWorktreeInvariantDeps(defineLiveWorkspaceConfig(facade, host));
}
export function buildHandleDepAbortCleanupDeps(host: any): any {

View File

@@ -9,19 +9,21 @@
* Per-node worktree acquisition is expected graph plumbing once the task has a worktree.
*/
import type { Settings, Task, TaskDetail, TaskStore } from "@fusion/core";
import { loadWorkspaceConfig, type RunCommandResult } from "@fusion/core";
import { type RunCommandResult, type WorkspaceConfig } from "@fusion/core";
import { executorLog } from "../logger.js";
import { generateSyntheticRunId, createRunAuditor, type EngineRunContext, type RunAuditor } from "../util/run-audit.js";
import { acquireTaskWorktree } from "../worktree/worktree-acquisition.js";
import { captureBaseCommitSha } from "./worktree-git-refs.js";
import { createConfiguredCommandAbortError } from "./task-predicates.js";
import type { WorktreePool } from "../worktree/worktree-pool.js";
import { resolveWorkspaceConfigOnce } from "./workspace-config-resolver.js";
export type EnsureGraphCustomNodeWorktreeDeps = {
store: TaskStore;
rootDir: string;
getWorkspaceConfig: () => Awaited<ReturnType<typeof loadWorkspaceConfig>> | undefined;
setWorkspaceConfig: (config: Awaited<ReturnType<typeof loadWorkspaceConfig>>) => void;
workspaceConfigOwner: object;
getWorkspaceConfig: () => WorkspaceConfig | null | undefined;
setWorkspaceConfig: (config: WorkspaceConfig | null) => void;
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
pool?: WorktreePool;
secretsStore?: Parameters<typeof acquireTaskWorktree>[0]["secretsStore"];
@@ -53,11 +55,7 @@ export async function ensureGraphCustomNodeWorktree(
nodeId: string,
refreshStaleBase = false,
): Promise<TaskDetail> {
let workspaceConfig = deps.getWorkspaceConfig();
if (workspaceConfig === undefined) {
workspaceConfig = await loadWorkspaceConfig(deps.rootDir);
deps.setWorkspaceConfig(workspaceConfig);
}
const workspaceConfig = await resolveWorkspaceConfigOnce(deps);
if (workspaceConfig && (workspaceConfig.repos.length ?? 0) > 0) {
return task;
}

View File

@@ -15,13 +15,14 @@
*/
import { existsSync } from "node:fs";
import type { Settings, TaskDetail, TaskStore, WorkspaceConfig } from "@fusion/core";
import { loadWorkspaceConfig } from "@fusion/core";
import { executorLog, formatError } from "../logger.js";
import { resolveWorkspaceConfigOnce } from "./workspace-config-resolver.js";
export type EnsureTaskWorktreeForPlanningDeps = {
store: TaskStore;
rootDir: string;
/** Mutable holder so lazy load updates TaskExecutor.workspaceConfig. */
workspaceConfigOwner: object;
getWorkspaceConfig: () => WorkspaceConfig | null | undefined;
setWorkspaceConfig: (cfg: WorkspaceConfig | null) => void;
ensureGraphCustomNodeWorktree: (
@@ -37,10 +38,7 @@ export async function ensureTaskWorktreeForPlanning(
taskId: string,
): Promise<string | null> {
try {
if (deps.getWorkspaceConfig() === undefined) {
deps.setWorkspaceConfig(await loadWorkspaceConfig(deps.rootDir));
}
const workspaceConfig = deps.getWorkspaceConfig();
const workspaceConfig = await resolveWorkspaceConfigOnce(deps);
if (workspaceConfig && (workspaceConfig.repos.length ?? 0) > 0) return null;
const live = await deps.store.getTask(taskId);

View File

@@ -5,6 +5,9 @@
* FNXC:Workspace 2026-06-21-12:00: KTD2 single-path-getter contract.
* Returns the sole worktree path for single-repo tasks; undefined in workspace mode
* (callers must use per-repo workspaceWorktrees).
*
* FNXC:Workspace 2026-08-14-21:06: Synchronous workspace readers never resolve config;
* their async calling lane must resolve it before reading this pure path selector.
*/
export function getWorktreePath(
workspaceConfig: unknown | null | undefined,

View File

@@ -19,6 +19,7 @@ export type MarkStuckAbortedDeps = {
store: TaskStore;
rootDir: string;
workspaceConfig: unknown;
ensureWorkspaceConfig?: () => Promise<unknown | null>;
activeStepExecutors: Map<string, { terminateAllSessions(): Promise<void> }>;
stuckAborted: Map<string, boolean>;
executing: Set<string>;
@@ -105,7 +106,10 @@ export function markStuckAborted(
block below silently no-ops. Per-repo teardown is Phase B; until then make
the skip visible rather than silent. Behavior is unchanged.
*/
if (deps.workspaceConfig && !worktreePath) {
const workspaceConfig = deps.ensureWorkspaceConfig
? await deps.ensureWorkspaceConfig()
: deps.workspaceConfig;
if (workspaceConfig && !worktreePath) {
await deps.store.logEntry(
taskId,
`workspace task ${taskId}: no singular worktree to force-requeue (per-repo teardown is Phase B)`,

View File

@@ -43,6 +43,7 @@ export type RunGraphCustomNodeDeps = {
store: TaskStore;
rootDir: string;
workspaceConfig: WorkspaceConfig | null | undefined;
ensureWorkspaceConfig?: () => Promise<WorkspaceConfig | null>;
options: { pluginRunner?: unknown; agentStore?: AgentStore | null; [k: string]: unknown };
graphUnattendedRuns: Set<string>;
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
@@ -188,6 +189,9 @@ export async function runGraphCustomNode(
optionalGroupId,
reviewerInlineFixes: (settings as Settings & { reviewerInlineFixes?: boolean }).reviewerInlineFixes,
});
const workspaceConfig = deps.ensureWorkspaceConfig
? await deps.ensureWorkspaceConfig()
: deps.workspaceConfig;
let executionTarget = writeCapable ? await deps.store.getTask(live.id) : live;
/*
@@ -209,7 +213,7 @@ export async function runGraphCustomNode(
*/
const nodeDisplayName = typeof cfg.name === "string" && cfg.name.trim() ? cfg.name.trim() : node.id;
const isPlanReviewNode = node.id === "plan-review-step" || nodeDisplayName === "Plan Review" || optionalGroupId === "plan-review";
if (!deps.workspaceConfig) {
if (!workspaceConfig) {
const recordedWorktreeMissing = Boolean(executionTarget.worktree) && !existsSync(executionTarget.worktree!);
/*
A node with NO recorded worktree is pre-execution (planning / Plan Review): acquire one.
@@ -236,7 +240,7 @@ export async function runGraphCustomNode(
}
}
if (writeCapable && !executionTarget.worktree && !deps.workspaceConfig) {
if (writeCapable && !executionTarget.worktree && !workspaceConfig) {
return { outcome: "failure", value: "no-worktree-for-write-node" };
}

View File

@@ -52,7 +52,6 @@ import {
RetryStormError,
columnsWithFlag,
isEphemeralAgent,
loadWorkspaceConfig,
resolveEphemeralTaskCreationPolicy,
resolveExecutorFallbackModel,
resolvePersistAgentThinkingLog,
@@ -216,6 +215,7 @@ export type RunImplementationDeps = {
store: TaskStore;
rootDir: string;
workspaceConfig: WorkspaceConfig | null | undefined;
ensureWorkspaceConfig: () => Promise<WorkspaceConfig | null>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TaskExecutorOptions is large and only partially used here
options: any;
stuckAborted: Map<string, boolean>;
@@ -608,9 +608,7 @@ export async function runImplementation(
return;
}
if (deps.workspaceConfig === undefined) {
deps.workspaceConfig = await loadWorkspaceConfig(deps.rootDir);
}
await deps.ensureWorkspaceConfig();
/*
FNXC:Workspace 2026-06-22-00:00:
Workspace mode is only meaningful with at least one usable sub-repo. An empty `{ repos: [] }`

View File

@@ -0,0 +1,40 @@
import { loadWorkspaceConfig, type WorkspaceConfig } from "@fusion/core";
/**
* FNXC:Workspace 2026-08-14-21:06:
* Workspace detection has one host-owned writer: a per-lane copy silently routes a multi-repo
* project through its non-git root. Memoization is per host so concurrent projects and tests
* cannot share configuration, and a config with no usable repositories is single-repo mode.
*/
const inFlightWorkspaceConfigLoads = new WeakMap<object, Promise<WorkspaceConfig | null>>();
export type WorkspaceConfigResolverDeps = {
rootDir: string;
workspaceConfigOwner: object;
getWorkspaceConfig: () => WorkspaceConfig | null | undefined;
setWorkspaceConfig: (config: WorkspaceConfig | null) => void;
};
export async function resolveWorkspaceConfigOnce(
deps: WorkspaceConfigResolverDeps,
): Promise<WorkspaceConfig | null> {
const current = deps.getWorkspaceConfig();
if (current !== undefined) return current;
const existing = inFlightWorkspaceConfigLoads.get(deps.workspaceConfigOwner);
if (existing) return existing;
const promise = loadWorkspaceConfig(deps.rootDir).then((config) => {
const normalized = config && config.repos.length > 0 ? config : null;
deps.setWorkspaceConfig(normalized);
return normalized;
});
inFlightWorkspaceConfigLoads.set(deps.workspaceConfigOwner, promise);
try {
return await promise;
} finally {
if (inFlightWorkspaceConfigLoads.get(deps.workspaceConfigOwner) === promise) {
inFlightWorkspaceConfigLoads.delete(deps.workspaceConfigOwner);
}
}
}

View File

@@ -16,6 +16,7 @@ import {
export type TaskDoneScopeLeakDeps = {
store: TaskStore;
workspaceConfig: unknown | null | undefined;
ensureWorkspaceConfig?: () => Promise<unknown | null>;
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
captureUncommittedModifiedFiles: (worktreePath: string) => Promise<string[]>;
captureModifiedFiles: (
@@ -83,9 +84,12 @@ export async function evaluateTaskDoneScopeLeak(
// 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.
const workspaceConfig = deps.ensureWorkspaceConfig
? await deps.ensureWorkspaceConfig()
: deps.workspaceConfig;
let touchedFiles: string[];
let offendingRepo: string | undefined;
if (deps.workspaceConfig) {
if (workspaceConfig) {
const workspaceWorktrees = task.workspaceWorktrees ?? {};
const repoKeys = Object.keys(workspaceWorktrees).sort();
// F2: declaredScope is non-empty here (the `declaredScope.length === 0` early-return above
@@ -146,7 +150,7 @@ export async function evaluateTaskDoneScopeLeak(
}
}
const offScopeFiles = (deps.workspaceConfig
const offScopeFiles = (workspaceConfig
// In workspace mode `touchedFiles` is already the off-scope set (filtered per repo above).
? touchedFiles
: touchedFiles

View File

@@ -35,6 +35,7 @@ export type WorktreeInvariantDeps = {
rootDir: string;
store: TaskStore;
workspaceConfig: unknown | null | undefined;
ensureWorkspaceConfig?: () => Promise<unknown | null>;
getActiveWorktreePaths: (taskId: string) => string[];
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
emitWorktreeReanchoredAudit: (
@@ -52,10 +53,13 @@ export async function verifyWorktreeInvariants(
allowReanchor = true,
options?: { noOpCompletion?: boolean; noOpCompletionReason?: string },
): Promise<WorktreeInvariantResult> {
const workspaceConfig = deps.ensureWorkspaceConfig
? await deps.ensureWorkspaceConfig()
: deps.workspaceConfig;
const settings = await deps.store.getSettings();
// 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 (deps.workspaceConfig) {
if (workspaceConfig) {
const workspaceWorktrees = task.workspaceWorktrees ?? {};
// FNXC:Workspace 2026-06-22-00:00: KTD2 — resolve the SAME task-wide no-commit eligibility the singular path
// uses (getNoCommitEligibilityReason / no-op-completion sentinel / prompt-derived), once, before the per-repo
@@ -79,6 +83,8 @@ export async function verifyWorktreeInvariants(
}
// FNXC:Workspace 2026-06-21-15:00: F6 — iterate sorted repo keys so the FIRST failing repo
// returned here is deterministic across runs/rehydrate (the value is surfaced to the operator).
const commitCounts: string[] = [];
let totalCommitCount = 0;
for (const repoRel of Object.keys(workspaceWorktrees).sort()) {
const repo = workspaceWorktrees[repoRel];
const expectedBranch = repo.branch || canonicalFusionBranchName(task.id);
@@ -154,12 +160,6 @@ export async function verifyWorktreeInvariants(
expected: expectedBranch,
};
}
// FNXC:Workspace 2026-06-22-00:00: KTD2 — per-repo no_commits guard (parity with the singular path at :10821).
// Phase B originally returned {ok:true} after the toplevel/branch checks, so a workspace task could call
// fn_task_done having committed NOTHING in any sub-repo (scope-leak sees zero touched files, branch names match)
// and still advance to in-review. Enforce the same `git rev-list --count <base>..HEAD > 0` invariant per repo,
// gated by the SAME task-wide no-commit eligibility below so Plan-Only / no-op-sentinel tasks stay exempt.
// The first sub-repo with zero commits fails with reason:'no_commits' (consumer-stable union).
if (!workspaceNoCommitEligibilityReason) {
const repoBaseRef = await resolveDiffBaseRef(repo.worktreePath, repo.baseCommitSha);
if (repoBaseRef) {
@@ -171,18 +171,18 @@ export async function verifyWorktreeInvariants(
maxBuffer: 1024 * 1024,
});
const trimmedCount = stdout.trim();
if (trimmedCount) {
const count = Number.parseInt(trimmedCount, 10);
if (!Number.isFinite(count) || count <= 0) {
return {
ok: false,
reason: "no_commits",
repo: repoRel,
observed: Number.isFinite(count) ? String(count) : trimmedCount,
expected: "> 0",
};
}
const count = Number.parseInt(trimmedCount, 10);
if (!Number.isFinite(count) || count < 0) {
return {
ok: false,
reason: "no_commits",
repo: repoRel,
observed: trimmedCount,
expected: `git rev-list --count ${repoBaseRef}..HEAD > 0`,
};
}
commitCounts.push(`${repoRel}=${count}`);
totalCommitCount += count;
} catch (error) {
return {
ok: false,
@@ -197,6 +197,14 @@ export async function verifyWorktreeInvariants(
}
}
}
/*
FNXC:Workspace 2026-08-14-21:06:
A workspace task may legitimately change only a subset of acquired repositories. The commit
invariant is task-wide, because rejecting the first empty repository blocked committed work (issue #3435).
*/
if (!workspaceNoCommitEligibilityReason && commitCounts.length > 0 && totalCommitCount === 0) {
return { ok: false, reason: "no_commits", observed: commitCounts.join(", "), expected: "> 0" };
}
return { ok: true };
}
/*