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:
7
.changeset/fn-9043-workspace-no-commits.md
Normal file
7
.changeset/fn-9043-workspace-no-commits.md
Normal 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.
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ type AnyFn = (...args: any[]) => any;
|
|||||||
export type CleanupTaskWorktreeDeps = {
|
export type CleanupTaskWorktreeDeps = {
|
||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
workspaceConfig: WorkspaceConfig | null | undefined;
|
workspaceConfig: WorkspaceConfig | null | undefined;
|
||||||
|
ensureWorkspaceConfig?: () => Promise<WorkspaceConfig | null>;
|
||||||
activeWorktrees: Map<string, Set<string>>;
|
activeWorktrees: Map<string, Set<string>>;
|
||||||
getActiveWorktreePaths: (taskId: string) => string[];
|
getActiveWorktreePaths: (taskId: string) => string[];
|
||||||
removeOwnWorktreeWithReconcile: AnyFn;
|
removeOwnWorktreeWithReconcile: AnyFn;
|
||||||
@@ -25,13 +26,16 @@ export async function cleanupTaskWorktree(
|
|||||||
deps: CleanupTaskWorktreeDeps,
|
deps: CleanupTaskWorktreeDeps,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const workspaceConfig = deps.ensureWorkspaceConfig
|
||||||
|
? await deps.ensureWorkspaceConfig()
|
||||||
|
: deps.workspaceConfig;
|
||||||
const worktreePaths = deps.getActiveWorktreePaths(taskId);
|
const worktreePaths = deps.getActiveWorktreePaths(taskId);
|
||||||
if (worktreePaths.length === 0) return;
|
if (worktreePaths.length === 0) return;
|
||||||
|
|
||||||
deps.activeWorktrees.delete(taskId);
|
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.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
// Non-workspace tasks hold a one-element set — preserve the original single-path removal semantics.
|
// Non-workspace tasks hold a one-element set — preserve the original single-path removal semantics.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type CreateAuthoritativeWorkflowSeamsDeps = {
|
|||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
workspaceConfig: WorkspaceConfig | null | undefined;
|
workspaceConfig: WorkspaceConfig | null | undefined;
|
||||||
|
ensureWorkspaceConfig?: () => Promise<WorkspaceConfig | null>;
|
||||||
activeWorkflowPrincipals: Map<string, { agentId: string; nodeInstanceId: string; agent?: import("@fusion/core").Agent }>;
|
activeWorkflowPrincipals: Map<string, { agentId: string; nodeInstanceId: string; agent?: import("@fusion/core").Agent }>;
|
||||||
graphSeamGoverningNodeId: Map<string, string>;
|
graphSeamGoverningNodeId: Map<string, string>;
|
||||||
graphSeamThinkingLevel: Map<string, ThinkingLevel>;
|
graphSeamThinkingLevel: Map<string, ThinkingLevel>;
|
||||||
@@ -423,8 +424,11 @@ export function createAuthoritativeWorkflowSeams(
|
|||||||
const invoke = () => invokeReviewerForCwd(cwd);
|
const invoke = () => invokeReviewerForCwd(cwd);
|
||||||
return sem ? sem.runNested(invoke) : invoke();
|
return sem ? sem.runNested(invoke) : invoke();
|
||||||
};
|
};
|
||||||
|
const workspaceConfig = deps.ensureWorkspaceConfig
|
||||||
|
? await deps.ensureWorkspaceConfig()
|
||||||
|
: deps.workspaceConfig;
|
||||||
const invokeReviewer = () =>
|
const invokeReviewer = () =>
|
||||||
deps.workspaceConfig && reviewCwd === worktreePath
|
workspaceConfig && reviewCwd === worktreePath
|
||||||
? deps.reviewWorkspacePerRepo(detail, (cwd: string) => runForCwd(cwd))
|
? deps.reviewWorkspacePerRepo(detail, (cwd: string) => runForCwd(cwd))
|
||||||
: runForCwd(reviewCwd);
|
: runForCwd(reviewCwd);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { WorktreeInvariantDeps } from "./worktree-verify-invariants.js";
|
|||||||
import type { NonContinuableSessionDeps } from "./non-continuable-session.js";
|
import type { NonContinuableSessionDeps } from "./non-continuable-session.js";
|
||||||
import { facadeFields, facadeMethods } from "./facade-methods.js";
|
import { facadeFields, facadeMethods } from "./facade-methods.js";
|
||||||
import * as pure from "./pure-bindings.js";
|
import * as pure from "./pure-bindings.js";
|
||||||
|
import { resolveWorkspaceConfigOnce } from "./workspace-config-resolver.js";
|
||||||
import {
|
import {
|
||||||
MAX_WORKTREE_RETRIES,
|
MAX_WORKTREE_RETRIES,
|
||||||
WORKTREE_RETRY_DELAYS,
|
WORKTREE_RETRY_DELAYS,
|
||||||
@@ -89,20 +90,23 @@ export type WorktreeInvariantDepsSource = {
|
|||||||
rootDir: string;
|
rootDir: string;
|
||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
workspaceConfig: unknown | null | undefined;
|
workspaceConfig: unknown | null | undefined;
|
||||||
|
ensureWorkspaceConfig?: () => Promise<unknown | null>;
|
||||||
getActiveWorktreePaths: (taskId: string) => string[];
|
getActiveWorktreePaths: (taskId: string) => string[];
|
||||||
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
||||||
emitWorktreeReanchoredAudit: WorktreeInvariantDeps["emitWorktreeReanchoredAudit"];
|
emitWorktreeReanchoredAudit: WorktreeInvariantDeps["emitWorktreeReanchoredAudit"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildWorktreeInvariantDeps(src: WorktreeInvariantDepsSource): WorktreeInvariantDeps {
|
export function buildWorktreeInvariantDeps(src: WorktreeInvariantDepsSource): WorktreeInvariantDeps {
|
||||||
return {
|
const bag = {
|
||||||
rootDir: src.rootDir,
|
rootDir: src.rootDir,
|
||||||
store: src.store,
|
store: src.store,
|
||||||
workspaceConfig: src.workspaceConfig,
|
ensureWorkspaceConfig: src.ensureWorkspaceConfig,
|
||||||
getActiveWorktreePaths: src.getActiveWorktreePaths,
|
getActiveWorktreePaths: src.getActiveWorktreePaths,
|
||||||
getRunContextFor: src.getRunContextFor,
|
getRunContextFor: src.getRunContextFor,
|
||||||
emitWorktreeReanchoredAudit: src.emitWorktreeReanchoredAudit,
|
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;
|
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
|
* runImplementation deps bag peeled from TaskExecutor (U4). Constants are injected by the
|
||||||
* façade so the free builder stays free of executor-constants coupling.
|
* 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(
|
export function buildRunImplementationDeps(
|
||||||
host: any,
|
host: any,
|
||||||
constants: { BRANCH_CONFLICT_TRIPWIRE_THRESHOLD: number; MAX_AUTO_RECOVERY_ATTEMPTS: number },
|
constants: { BRANCH_CONFLICT_TRIPWIRE_THRESHOLD: number; MAX_AUTO_RECOVERY_ATTEMPTS: number },
|
||||||
): any {
|
): any {
|
||||||
return {
|
const bag = {
|
||||||
...facadeFields(host, ["store", "rootDir", "workspaceConfig"]),
|
...facadeFields(host, ["store", "rootDir"]),
|
||||||
|
ensureWorkspaceConfig: withWorkspaceResolver(host),
|
||||||
options: host.options as any,
|
options: host.options as any,
|
||||||
BRANCH_CONFLICT_TRIPWIRE_THRESHOLD: constants.BRANCH_CONFLICT_TRIPWIRE_THRESHOLD,
|
BRANCH_CONFLICT_TRIPWIRE_THRESHOLD: constants.BRANCH_CONFLICT_TRIPWIRE_THRESHOLD,
|
||||||
MAX_AUTO_RECOVERY_ATTEMPTS: constants.MAX_AUTO_RECOVERY_ATTEMPTS,
|
MAX_AUTO_RECOVERY_ATTEMPTS: constants.MAX_AUTO_RECOVERY_ATTEMPTS,
|
||||||
@@ -228,11 +252,13 @@ export function buildRunImplementationDeps(
|
|||||||
]),
|
]),
|
||||||
sharedWorkerTools: buildSharedWorkerToolsDeps(host),
|
sharedWorkerTools: buildSharedWorkerToolsDeps(host),
|
||||||
};
|
};
|
||||||
|
return defineLiveWorkspaceConfig(bag, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRunGraphCustomNodeDeps(host: any): any {
|
export function buildRunGraphCustomNodeDeps(host: any): any {
|
||||||
return {
|
const bag = {
|
||||||
...facadeFields(host, ["store", "rootDir", "workspaceConfig"]),
|
...facadeFields(host, ["store", "rootDir"]),
|
||||||
|
ensureWorkspaceConfig: withWorkspaceResolver(host),
|
||||||
options: host.options as { pluginRunner?: unknown; [k: string]: unknown },
|
options: host.options as { pluginRunner?: unknown; [k: string]: unknown },
|
||||||
graphUnattendedRuns: host.graphUnattendedRuns,
|
graphUnattendedRuns: host.graphUnattendedRuns,
|
||||||
...facadeMethods(host, [
|
...facadeMethods(host, [
|
||||||
@@ -243,15 +269,17 @@ export function buildRunGraphCustomNodeDeps(host: any): any {
|
|||||||
"runRawCliCommand",
|
"runRawCliCommand",
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
return defineLiveWorkspaceConfig(bag, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCreateAuthoritativeWorkflowSeamsDeps(host: any): any {
|
export function buildCreateAuthoritativeWorkflowSeamsDeps(host: any): any {
|
||||||
return {
|
const bag = {
|
||||||
store: host.store,
|
store: host.store,
|
||||||
rootDir: host.rootDir,
|
rootDir: host.rootDir,
|
||||||
|
ensureWorkspaceConfig: withWorkspaceResolver(host),
|
||||||
options: host.options as { mergeRequester?: unknown; pluginRunner?: unknown; [k: string]: unknown },
|
options: host.options as { mergeRequester?: unknown; pluginRunner?: unknown; [k: string]: unknown },
|
||||||
...facadeFields(host, [
|
...facadeFields(host, [
|
||||||
"workspaceConfig", "activeWorkflowPrincipals", "graphSeamGoverningNodeId", "graphSeamThinkingLevel",
|
"activeWorkflowPrincipals", "graphSeamGoverningNodeId", "graphSeamThinkingLevel",
|
||||||
"graphStepActiveContext", "graphRethinkNarrations", "pausedAborted",
|
"graphStepActiveContext", "graphRethinkNarrations", "pausedAborted",
|
||||||
"mergeRequester",
|
"mergeRequester",
|
||||||
]),
|
]),
|
||||||
@@ -263,6 +291,7 @@ export function buildCreateAuthoritativeWorkflowSeamsDeps(host: any): any {
|
|||||||
"unregisterSubagentSession",
|
"unregisterSubagentSession",
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
return defineLiveWorkspaceConfig(bag, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCreateSpawnAgentToolDeps(host: any): any {
|
export function buildCreateSpawnAgentToolDeps(host: any): any {
|
||||||
@@ -320,9 +349,9 @@ export function buildFinalizeAcceptedNoOpCompletionDeps(host: any): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildMarkStuckAbortedDeps(host: any): any {
|
export function buildMarkStuckAbortedDeps(host: any): any {
|
||||||
return {
|
const bag = {
|
||||||
...facadeFields(host, [
|
...facadeFields(host, [
|
||||||
"store", "rootDir", "workspaceConfig",
|
"store", "rootDir",
|
||||||
"activeStepExecutors", "stuckAborted", "executing",
|
"activeStepExecutors", "stuckAborted", "executing",
|
||||||
"activeWorktrees", "loopRecoveryState",
|
"activeWorktrees", "loopRecoveryState",
|
||||||
]),
|
]),
|
||||||
@@ -331,7 +360,9 @@ export function buildMarkStuckAbortedDeps(host: any): any {
|
|||||||
"awaitAbortInFlightTaskWork", "clearPausedAborted", "resetStepsIfWorkLost",
|
"awaitAbortInFlightTaskWork", "clearPausedAborted", "resetStepsIfWorkLost",
|
||||||
"hasActiveWorktreeBinding",
|
"hasActiveWorktreeBinding",
|
||||||
]),
|
]),
|
||||||
|
ensureWorkspaceConfig: withWorkspaceResolver(host),
|
||||||
};
|
};
|
||||||
|
return defineLiveWorkspaceConfig(bag, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRunGraphTaskStepDeps(host: any): any {
|
export function buildRunGraphTaskStepDeps(host: any): any {
|
||||||
@@ -377,6 +408,7 @@ export function buildEnsureGraphCustomNodeWorktreeDeps(host: any, runConfiguredC
|
|||||||
return {
|
return {
|
||||||
store: host.store,
|
store: host.store,
|
||||||
rootDir: host.rootDir,
|
rootDir: host.rootDir,
|
||||||
|
workspaceConfigOwner: host,
|
||||||
getWorkspaceConfig: () => host.workspaceConfig,
|
getWorkspaceConfig: () => host.workspaceConfig,
|
||||||
setWorkspaceConfig: (c: unknown) => { host.workspaceConfig = c; },
|
setWorkspaceConfig: (c: unknown) => { host.workspaceConfig = c; },
|
||||||
...facadeMethods(host, [
|
...facadeMethods(host, [
|
||||||
@@ -422,12 +454,14 @@ export function buildRunRawCliCommandDeps(host: any, runConfiguredCommand: any =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildEvaluateTaskDoneScopeLeakDeps(host: any): any {
|
export function buildEvaluateTaskDoneScopeLeakDeps(host: any): any {
|
||||||
return {
|
const bag = {
|
||||||
...facadeFields(host, ["store", "workspaceConfig"]),
|
...facadeFields(host, ["store"]),
|
||||||
|
ensureWorkspaceConfig: withWorkspaceResolver(host),
|
||||||
...facadeMethods(host, [
|
...facadeMethods(host, [
|
||||||
"getRunContextFor", "captureUncommittedModifiedFiles", "captureModifiedFiles",
|
"getRunContextFor", "captureUncommittedModifiedFiles", "captureModifiedFiles",
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
return defineLiveWorkspaceConfig(bag, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildScheduleCompletedTaskWatchdogDeps(
|
export function buildScheduleCompletedTaskWatchdogDeps(
|
||||||
@@ -652,11 +686,13 @@ export function buildHandleImplicitTaskDoneRefusalDeps(host: any): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildCleanupTaskWorktreeDeps(host: any): any {
|
export function buildCleanupTaskWorktreeDeps(host: any): any {
|
||||||
return {
|
const bag = {
|
||||||
...facadeFields(host, ["store", "workspaceConfig", "activeWorktrees"]),
|
...facadeFields(host, ["store", "activeWorktrees"]),
|
||||||
|
ensureWorkspaceConfig: withWorkspaceResolver(host),
|
||||||
getActiveWorktreePaths: (id: string) => host.getActiveWorktreePaths(id),
|
getActiveWorktreePaths: (id: string) => host.getActiveWorktreePaths(id),
|
||||||
removeOwnWorktreeWithReconcile: (...args: unknown[]) => host.removeOwnWorktreeWithReconcile(...args),
|
removeOwnWorktreeWithReconcile: (...args: unknown[]) => host.removeOwnWorktreeWithReconcile(...args),
|
||||||
};
|
};
|
||||||
|
return defineLiveWorkspaceConfig(bag, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildResumeTaskForAgentDeps(host: any): any {
|
export function buildResumeTaskForAgentDeps(host: any): any {
|
||||||
@@ -1059,6 +1095,7 @@ export function buildEnsureTaskWorktreeForPlanningDeps(host: any): any {
|
|||||||
return {
|
return {
|
||||||
store: host.store,
|
store: host.store,
|
||||||
rootDir: host.rootDir,
|
rootDir: host.rootDir,
|
||||||
|
workspaceConfigOwner: host,
|
||||||
getWorkspaceConfig: () => host.workspaceConfig,
|
getWorkspaceConfig: () => host.workspaceConfig,
|
||||||
setWorkspaceConfig: (cfg: unknown) => { host.workspaceConfig = cfg; },
|
setWorkspaceConfig: (cfg: unknown) => { host.workspaceConfig = cfg; },
|
||||||
ensureGraphCustomNodeWorktree: (t: unknown, s: unknown, nodeId: string, refresh?: boolean) =>
|
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 {
|
export function buildWorktreeInvariantFacadeDeps(host: any): any {
|
||||||
return buildWorktreeInvariantDeps({
|
const facade = {
|
||||||
...facadeFields(host, ["rootDir", "store", "workspaceConfig"]),
|
...facadeFields(host, ["rootDir", "store"]),
|
||||||
|
ensureWorkspaceConfig: withWorkspaceResolver(host),
|
||||||
...facadeMethods(host, [
|
...facadeMethods(host, [
|
||||||
"getActiveWorktreePaths", "getRunContextFor", "emitWorktreeReanchoredAudit",
|
"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 {
|
export function buildHandleDepAbortCleanupDeps(host: any): any {
|
||||||
|
|||||||
@@ -9,19 +9,21 @@
|
|||||||
* Per-node worktree acquisition is expected graph plumbing once the task has a worktree.
|
* Per-node worktree acquisition is expected graph plumbing once the task has a worktree.
|
||||||
*/
|
*/
|
||||||
import type { Settings, Task, TaskDetail, TaskStore } from "@fusion/core";
|
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 { executorLog } from "../logger.js";
|
||||||
import { generateSyntheticRunId, createRunAuditor, type EngineRunContext, type RunAuditor } from "../util/run-audit.js";
|
import { generateSyntheticRunId, createRunAuditor, type EngineRunContext, type RunAuditor } from "../util/run-audit.js";
|
||||||
import { acquireTaskWorktree } from "../worktree/worktree-acquisition.js";
|
import { acquireTaskWorktree } from "../worktree/worktree-acquisition.js";
|
||||||
import { captureBaseCommitSha } from "./worktree-git-refs.js";
|
import { captureBaseCommitSha } from "./worktree-git-refs.js";
|
||||||
import { createConfiguredCommandAbortError } from "./task-predicates.js";
|
import { createConfiguredCommandAbortError } from "./task-predicates.js";
|
||||||
import type { WorktreePool } from "../worktree/worktree-pool.js";
|
import type { WorktreePool } from "../worktree/worktree-pool.js";
|
||||||
|
import { resolveWorkspaceConfigOnce } from "./workspace-config-resolver.js";
|
||||||
|
|
||||||
export type EnsureGraphCustomNodeWorktreeDeps = {
|
export type EnsureGraphCustomNodeWorktreeDeps = {
|
||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
getWorkspaceConfig: () => Awaited<ReturnType<typeof loadWorkspaceConfig>> | undefined;
|
workspaceConfigOwner: object;
|
||||||
setWorkspaceConfig: (config: Awaited<ReturnType<typeof loadWorkspaceConfig>>) => void;
|
getWorkspaceConfig: () => WorkspaceConfig | null | undefined;
|
||||||
|
setWorkspaceConfig: (config: WorkspaceConfig | null) => void;
|
||||||
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
||||||
pool?: WorktreePool;
|
pool?: WorktreePool;
|
||||||
secretsStore?: Parameters<typeof acquireTaskWorktree>[0]["secretsStore"];
|
secretsStore?: Parameters<typeof acquireTaskWorktree>[0]["secretsStore"];
|
||||||
@@ -53,11 +55,7 @@ export async function ensureGraphCustomNodeWorktree(
|
|||||||
nodeId: string,
|
nodeId: string,
|
||||||
refreshStaleBase = false,
|
refreshStaleBase = false,
|
||||||
): Promise<TaskDetail> {
|
): Promise<TaskDetail> {
|
||||||
let workspaceConfig = deps.getWorkspaceConfig();
|
const workspaceConfig = await resolveWorkspaceConfigOnce(deps);
|
||||||
if (workspaceConfig === undefined) {
|
|
||||||
workspaceConfig = await loadWorkspaceConfig(deps.rootDir);
|
|
||||||
deps.setWorkspaceConfig(workspaceConfig);
|
|
||||||
}
|
|
||||||
if (workspaceConfig && (workspaceConfig.repos.length ?? 0) > 0) {
|
if (workspaceConfig && (workspaceConfig.repos.length ?? 0) > 0) {
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,14 @@
|
|||||||
*/
|
*/
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import type { Settings, TaskDetail, TaskStore, WorkspaceConfig } from "@fusion/core";
|
import type { Settings, TaskDetail, TaskStore, WorkspaceConfig } from "@fusion/core";
|
||||||
import { loadWorkspaceConfig } from "@fusion/core";
|
|
||||||
import { executorLog, formatError } from "../logger.js";
|
import { executorLog, formatError } from "../logger.js";
|
||||||
|
import { resolveWorkspaceConfigOnce } from "./workspace-config-resolver.js";
|
||||||
|
|
||||||
export type EnsureTaskWorktreeForPlanningDeps = {
|
export type EnsureTaskWorktreeForPlanningDeps = {
|
||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
/** Mutable holder so lazy load updates TaskExecutor.workspaceConfig. */
|
/** Mutable holder so lazy load updates TaskExecutor.workspaceConfig. */
|
||||||
|
workspaceConfigOwner: object;
|
||||||
getWorkspaceConfig: () => WorkspaceConfig | null | undefined;
|
getWorkspaceConfig: () => WorkspaceConfig | null | undefined;
|
||||||
setWorkspaceConfig: (cfg: WorkspaceConfig | null) => void;
|
setWorkspaceConfig: (cfg: WorkspaceConfig | null) => void;
|
||||||
ensureGraphCustomNodeWorktree: (
|
ensureGraphCustomNodeWorktree: (
|
||||||
@@ -37,10 +38,7 @@ export async function ensureTaskWorktreeForPlanning(
|
|||||||
taskId: string,
|
taskId: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
if (deps.getWorkspaceConfig() === undefined) {
|
const workspaceConfig = await resolveWorkspaceConfigOnce(deps);
|
||||||
deps.setWorkspaceConfig(await loadWorkspaceConfig(deps.rootDir));
|
|
||||||
}
|
|
||||||
const workspaceConfig = deps.getWorkspaceConfig();
|
|
||||||
if (workspaceConfig && (workspaceConfig.repos.length ?? 0) > 0) return null;
|
if (workspaceConfig && (workspaceConfig.repos.length ?? 0) > 0) return null;
|
||||||
|
|
||||||
const live = await deps.store.getTask(taskId);
|
const live = await deps.store.getTask(taskId);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
* FNXC:Workspace 2026-06-21-12:00: KTD2 single-path-getter contract.
|
* 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
|
* Returns the sole worktree path for single-repo tasks; undefined in workspace mode
|
||||||
* (callers must use per-repo workspaceWorktrees).
|
* (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(
|
export function getWorktreePath(
|
||||||
workspaceConfig: unknown | null | undefined,
|
workspaceConfig: unknown | null | undefined,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type MarkStuckAbortedDeps = {
|
|||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
workspaceConfig: unknown;
|
workspaceConfig: unknown;
|
||||||
|
ensureWorkspaceConfig?: () => Promise<unknown | null>;
|
||||||
activeStepExecutors: Map<string, { terminateAllSessions(): Promise<void> }>;
|
activeStepExecutors: Map<string, { terminateAllSessions(): Promise<void> }>;
|
||||||
stuckAborted: Map<string, boolean>;
|
stuckAborted: Map<string, boolean>;
|
||||||
executing: Set<string>;
|
executing: Set<string>;
|
||||||
@@ -105,7 +106,10 @@ export function markStuckAborted(
|
|||||||
block below silently no-ops. Per-repo teardown is Phase B; until then make
|
block below silently no-ops. Per-repo teardown is Phase B; until then make
|
||||||
the skip visible rather than silent. Behavior is unchanged.
|
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(
|
await deps.store.logEntry(
|
||||||
taskId,
|
taskId,
|
||||||
`workspace task ${taskId}: no singular worktree to force-requeue (per-repo teardown is Phase B)`,
|
`workspace task ${taskId}: no singular worktree to force-requeue (per-repo teardown is Phase B)`,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type RunGraphCustomNodeDeps = {
|
|||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
workspaceConfig: WorkspaceConfig | null | undefined;
|
workspaceConfig: WorkspaceConfig | null | undefined;
|
||||||
|
ensureWorkspaceConfig?: () => Promise<WorkspaceConfig | null>;
|
||||||
options: { pluginRunner?: unknown; agentStore?: AgentStore | null; [k: string]: unknown };
|
options: { pluginRunner?: unknown; agentStore?: AgentStore | null; [k: string]: unknown };
|
||||||
graphUnattendedRuns: Set<string>;
|
graphUnattendedRuns: Set<string>;
|
||||||
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
||||||
@@ -188,6 +189,9 @@ export async function runGraphCustomNode(
|
|||||||
optionalGroupId,
|
optionalGroupId,
|
||||||
reviewerInlineFixes: (settings as Settings & { reviewerInlineFixes?: boolean }).reviewerInlineFixes,
|
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;
|
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 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";
|
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!);
|
const recordedWorktreeMissing = Boolean(executionTarget.worktree) && !existsSync(executionTarget.worktree!);
|
||||||
/*
|
/*
|
||||||
A node with NO recorded worktree is pre-execution (planning / Plan Review): acquire one.
|
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" };
|
return { outcome: "failure", value: "no-worktree-for-write-node" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
RetryStormError,
|
RetryStormError,
|
||||||
columnsWithFlag,
|
columnsWithFlag,
|
||||||
isEphemeralAgent,
|
isEphemeralAgent,
|
||||||
loadWorkspaceConfig,
|
|
||||||
resolveEphemeralTaskCreationPolicy,
|
resolveEphemeralTaskCreationPolicy,
|
||||||
resolveExecutorFallbackModel,
|
resolveExecutorFallbackModel,
|
||||||
resolvePersistAgentThinkingLog,
|
resolvePersistAgentThinkingLog,
|
||||||
@@ -216,6 +215,7 @@ export type RunImplementationDeps = {
|
|||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
workspaceConfig: WorkspaceConfig | null | undefined;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TaskExecutorOptions is large and only partially used here
|
||||||
options: any;
|
options: any;
|
||||||
stuckAborted: Map<string, boolean>;
|
stuckAborted: Map<string, boolean>;
|
||||||
@@ -608,9 +608,7 @@ export async function runImplementation(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deps.workspaceConfig === undefined) {
|
await deps.ensureWorkspaceConfig();
|
||||||
deps.workspaceConfig = await loadWorkspaceConfig(deps.rootDir);
|
|
||||||
}
|
|
||||||
/*
|
/*
|
||||||
FNXC:Workspace 2026-06-22-00:00:
|
FNXC:Workspace 2026-06-22-00:00:
|
||||||
Workspace mode is only meaningful with at least one usable sub-repo. An empty `{ repos: [] }`
|
Workspace mode is only meaningful with at least one usable sub-repo. An empty `{ repos: [] }`
|
||||||
|
|||||||
40
packages/engine/src/executor/workspace-config-resolver.ts
Normal file
40
packages/engine/src/executor/workspace-config-resolver.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
export type TaskDoneScopeLeakDeps = {
|
export type TaskDoneScopeLeakDeps = {
|
||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
workspaceConfig: unknown | null | undefined;
|
workspaceConfig: unknown | null | undefined;
|
||||||
|
ensureWorkspaceConfig?: () => Promise<unknown | null>;
|
||||||
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
||||||
captureUncommittedModifiedFiles: (worktreePath: string) => Promise<string[]>;
|
captureUncommittedModifiedFiles: (worktreePath: string) => Promise<string[]>;
|
||||||
captureModifiedFiles: (
|
captureModifiedFiles: (
|
||||||
@@ -83,9 +84,12 @@ export async function evaluateTaskDoneScopeLeak(
|
|||||||
// off-scope files and would silently pass; we block it (scope is declared but unverifiable).
|
// 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
|
// F6 (deterministic ordering): iterate sorted repo keys so the reported offending repo is stable
|
||||||
// across runs/rehydrate.
|
// across runs/rehydrate.
|
||||||
|
const workspaceConfig = deps.ensureWorkspaceConfig
|
||||||
|
? await deps.ensureWorkspaceConfig()
|
||||||
|
: deps.workspaceConfig;
|
||||||
let touchedFiles: string[];
|
let touchedFiles: string[];
|
||||||
let offendingRepo: string | undefined;
|
let offendingRepo: string | undefined;
|
||||||
if (deps.workspaceConfig) {
|
if (workspaceConfig) {
|
||||||
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
||||||
const repoKeys = Object.keys(workspaceWorktrees).sort();
|
const repoKeys = Object.keys(workspaceWorktrees).sort();
|
||||||
// F2: declaredScope is non-empty here (the `declaredScope.length === 0` early-return above
|
// 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).
|
// In workspace mode `touchedFiles` is already the off-scope set (filtered per repo above).
|
||||||
? touchedFiles
|
? touchedFiles
|
||||||
: touchedFiles
|
: touchedFiles
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type WorktreeInvariantDeps = {
|
|||||||
rootDir: string;
|
rootDir: string;
|
||||||
store: TaskStore;
|
store: TaskStore;
|
||||||
workspaceConfig: unknown | null | undefined;
|
workspaceConfig: unknown | null | undefined;
|
||||||
|
ensureWorkspaceConfig?: () => Promise<unknown | null>;
|
||||||
getActiveWorktreePaths: (taskId: string) => string[];
|
getActiveWorktreePaths: (taskId: string) => string[];
|
||||||
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
||||||
emitWorktreeReanchoredAudit: (
|
emitWorktreeReanchoredAudit: (
|
||||||
@@ -52,10 +53,13 @@ export async function verifyWorktreeInvariants(
|
|||||||
allowReanchor = true,
|
allowReanchor = true,
|
||||||
options?: { noOpCompletion?: boolean; noOpCompletionReason?: string },
|
options?: { noOpCompletion?: boolean; noOpCompletionReason?: string },
|
||||||
): Promise<WorktreeInvariantResult> {
|
): Promise<WorktreeInvariantResult> {
|
||||||
|
const workspaceConfig = deps.ensureWorkspaceConfig
|
||||||
|
? await deps.ensureWorkspaceConfig()
|
||||||
|
: deps.workspaceConfig;
|
||||||
const settings = await deps.store.getSettings();
|
const settings = await deps.store.getSettings();
|
||||||
// FNXC:Workspace 2026-06-21-23:30: KTD2 — un-stubbed per-repo worktree-invariant verification.
|
// 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.
|
// 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 ?? {};
|
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
||||||
// FNXC:Workspace 2026-06-22-00:00: KTD2 — resolve the SAME task-wide no-commit eligibility the singular path
|
// 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
|
// 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
|
// 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).
|
// 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()) {
|
for (const repoRel of Object.keys(workspaceWorktrees).sort()) {
|
||||||
const repo = workspaceWorktrees[repoRel];
|
const repo = workspaceWorktrees[repoRel];
|
||||||
const expectedBranch = repo.branch || canonicalFusionBranchName(task.id);
|
const expectedBranch = repo.branch || canonicalFusionBranchName(task.id);
|
||||||
@@ -154,12 +160,6 @@ export async function verifyWorktreeInvariants(
|
|||||||
expected: expectedBranch,
|
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) {
|
if (!workspaceNoCommitEligibilityReason) {
|
||||||
const repoBaseRef = await resolveDiffBaseRef(repo.worktreePath, repo.baseCommitSha);
|
const repoBaseRef = await resolveDiffBaseRef(repo.worktreePath, repo.baseCommitSha);
|
||||||
if (repoBaseRef) {
|
if (repoBaseRef) {
|
||||||
@@ -171,18 +171,18 @@ export async function verifyWorktreeInvariants(
|
|||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
});
|
});
|
||||||
const trimmedCount = stdout.trim();
|
const trimmedCount = stdout.trim();
|
||||||
if (trimmedCount) {
|
const count = Number.parseInt(trimmedCount, 10);
|
||||||
const count = Number.parseInt(trimmedCount, 10);
|
if (!Number.isFinite(count) || count < 0) {
|
||||||
if (!Number.isFinite(count) || count <= 0) {
|
return {
|
||||||
return {
|
ok: false,
|
||||||
ok: false,
|
reason: "no_commits",
|
||||||
reason: "no_commits",
|
repo: repoRel,
|
||||||
repo: repoRel,
|
observed: trimmedCount,
|
||||||
observed: Number.isFinite(count) ? String(count) : trimmedCount,
|
expected: `git rev-list --count ${repoBaseRef}..HEAD > 0`,
|
||||||
expected: "> 0",
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
commitCounts.push(`${repoRel}=${count}`);
|
||||||
|
totalCommitCount += count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
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 };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user