diff --git a/.changeset/fn-9043-workspace-no-commits.md b/.changeset/fn-9043-workspace-no-commits.md new file mode 100644 index 0000000000..0f871371a9 --- /dev/null +++ b/.changeset/fn-9043-workspace-no-commits.md @@ -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. diff --git a/packages/engine/src/__tests__/executor-workspace-config-propagation.test.ts b/packages/engine/src/__tests__/executor-workspace-config-propagation.test.ts new file mode 100644 index 0000000000..61595a82c7 --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-config-propagation.test.ts @@ -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"); + }); +}); diff --git a/packages/engine/src/executor/cleanup-task-worktree.ts b/packages/engine/src/executor/cleanup-task-worktree.ts index c6e8ce9830..0e87e613cf 100644 --- a/packages/engine/src/executor/cleanup-task-worktree.ts +++ b/packages/engine/src/executor/cleanup-task-worktree.ts @@ -16,6 +16,7 @@ type AnyFn = (...args: any[]) => any; export type CleanupTaskWorktreeDeps = { store: TaskStore; workspaceConfig: WorkspaceConfig | null | undefined; + ensureWorkspaceConfig?: () => Promise; activeWorktrees: Map>; getActiveWorktreePaths: (taskId: string) => string[]; removeOwnWorktreeWithReconcile: AnyFn; @@ -25,13 +26,16 @@ export async function cleanupTaskWorktree( deps: CleanupTaskWorktreeDeps, taskId: string, ): Promise { + 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. diff --git a/packages/engine/src/executor/create-authoritative-workflow-seams.ts b/packages/engine/src/executor/create-authoritative-workflow-seams.ts index e0aebab5e6..cecfcd6cdd 100644 --- a/packages/engine/src/executor/create-authoritative-workflow-seams.ts +++ b/packages/engine/src/executor/create-authoritative-workflow-seams.ts @@ -55,6 +55,7 @@ export type CreateAuthoritativeWorkflowSeamsDeps = { [k: string]: unknown; }; workspaceConfig: WorkspaceConfig | null | undefined; + ensureWorkspaceConfig?: () => Promise; activeWorkflowPrincipals: Map; graphSeamGoverningNodeId: Map; graphSeamThinkingLevel: Map; @@ -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); diff --git a/packages/engine/src/executor/deps-bags.ts b/packages/engine/src/executor/deps-bags.ts index 20387b5367..60b0b39b06 100644 --- a/packages/engine/src/executor/deps-bags.ts +++ b/packages/engine/src/executor/deps-bags.ts @@ -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; 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(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 { + 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 { diff --git a/packages/engine/src/executor/ensure-graph-custom-node-worktree.ts b/packages/engine/src/executor/ensure-graph-custom-node-worktree.ts index 05cf4c9389..5ea81d479c 100644 --- a/packages/engine/src/executor/ensure-graph-custom-node-worktree.ts +++ b/packages/engine/src/executor/ensure-graph-custom-node-worktree.ts @@ -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> | undefined; - setWorkspaceConfig: (config: Awaited>) => void; + workspaceConfigOwner: object; + getWorkspaceConfig: () => WorkspaceConfig | null | undefined; + setWorkspaceConfig: (config: WorkspaceConfig | null) => void; getRunContextFor: (taskId: string) => EngineRunContext | undefined; pool?: WorktreePool; secretsStore?: Parameters[0]["secretsStore"]; @@ -53,11 +55,7 @@ export async function ensureGraphCustomNodeWorktree( nodeId: string, refreshStaleBase = false, ): Promise { - 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; } diff --git a/packages/engine/src/executor/ensure-task-worktree-for-planning.ts b/packages/engine/src/executor/ensure-task-worktree-for-planning.ts index d7d61da90d..9d5b835e14 100644 --- a/packages/engine/src/executor/ensure-task-worktree-for-planning.ts +++ b/packages/engine/src/executor/ensure-task-worktree-for-planning.ts @@ -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 { 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); diff --git a/packages/engine/src/executor/get-worktree-path.ts b/packages/engine/src/executor/get-worktree-path.ts index 06dc38fbba..af1b1db1bd 100644 --- a/packages/engine/src/executor/get-worktree-path.ts +++ b/packages/engine/src/executor/get-worktree-path.ts @@ -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, diff --git a/packages/engine/src/executor/mark-stuck-aborted.ts b/packages/engine/src/executor/mark-stuck-aborted.ts index cb15020bae..702d7a7537 100644 --- a/packages/engine/src/executor/mark-stuck-aborted.ts +++ b/packages/engine/src/executor/mark-stuck-aborted.ts @@ -19,6 +19,7 @@ export type MarkStuckAbortedDeps = { store: TaskStore; rootDir: string; workspaceConfig: unknown; + ensureWorkspaceConfig?: () => Promise; activeStepExecutors: Map }>; stuckAborted: Map; executing: Set; @@ -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)`, diff --git a/packages/engine/src/executor/run-graph-custom-node.ts b/packages/engine/src/executor/run-graph-custom-node.ts index 358ec3f454..b849fe5850 100644 --- a/packages/engine/src/executor/run-graph-custom-node.ts +++ b/packages/engine/src/executor/run-graph-custom-node.ts @@ -43,6 +43,7 @@ export type RunGraphCustomNodeDeps = { store: TaskStore; rootDir: string; workspaceConfig: WorkspaceConfig | null | undefined; + ensureWorkspaceConfig?: () => Promise; options: { pluginRunner?: unknown; agentStore?: AgentStore | null; [k: string]: unknown }; graphUnattendedRuns: Set; 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" }; } diff --git a/packages/engine/src/executor/run-implementation.ts b/packages/engine/src/executor/run-implementation.ts index 69296ada32..7100c9f551 100644 --- a/packages/engine/src/executor/run-implementation.ts +++ b/packages/engine/src/executor/run-implementation.ts @@ -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; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TaskExecutorOptions is large and only partially used here options: any; stuckAborted: Map; @@ -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: [] }` diff --git a/packages/engine/src/executor/workspace-config-resolver.ts b/packages/engine/src/executor/workspace-config-resolver.ts new file mode 100644 index 0000000000..25821a4af8 --- /dev/null +++ b/packages/engine/src/executor/workspace-config-resolver.ts @@ -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>(); + +export type WorkspaceConfigResolverDeps = { + rootDir: string; + workspaceConfigOwner: object; + getWorkspaceConfig: () => WorkspaceConfig | null | undefined; + setWorkspaceConfig: (config: WorkspaceConfig | null) => void; +}; + +export async function resolveWorkspaceConfigOnce( + deps: WorkspaceConfigResolverDeps, +): Promise { + 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); + } + } +} diff --git a/packages/engine/src/executor/worktree-task-done-scope-leak.ts b/packages/engine/src/executor/worktree-task-done-scope-leak.ts index 27d59dd65c..914c909647 100644 --- a/packages/engine/src/executor/worktree-task-done-scope-leak.ts +++ b/packages/engine/src/executor/worktree-task-done-scope-leak.ts @@ -16,6 +16,7 @@ import { export type TaskDoneScopeLeakDeps = { store: TaskStore; workspaceConfig: unknown | null | undefined; + ensureWorkspaceConfig?: () => Promise; getRunContextFor: (taskId: string) => EngineRunContext | undefined; captureUncommittedModifiedFiles: (worktreePath: string) => Promise; 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 diff --git a/packages/engine/src/executor/worktree-verify-invariants.ts b/packages/engine/src/executor/worktree-verify-invariants.ts index 0fb459c5fe..19b3196325 100644 --- a/packages/engine/src/executor/worktree-verify-invariants.ts +++ b/packages/engine/src/executor/worktree-verify-invariants.ts @@ -35,6 +35,7 @@ export type WorktreeInvariantDeps = { rootDir: string; store: TaskStore; workspaceConfig: unknown | null | undefined; + ensureWorkspaceConfig?: () => Promise; 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 { + 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/` 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 ..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 }; } /*