From 8f4098e5b1174bd8da1a8c5ce653c9848da5ce0a Mon Sep 17 00:00:00 2001 From: MichaelHoughtonDeBox Date: Sun, 21 Jun 2026 22:04:37 +0100 Subject: [PATCH 001/265] feat: add workspace mode foundation (multi-repo projects) Allow registering a non-git parent directory that contains multiple git repositories as a single Fusion project. The agent acquires per-repo worktrees on demand via a new `fn_acquire_repo_worktree` tool as it discovers it needs to work in each sub-repo. This commit lays the foundation: - detectWorkspaceRepos / loadWorkspaceConfig / saveWorkspaceConfig in @fusion/core (config persisted to .fusion/workspace.json) - Task.workspaceWorktrees data model + store plumbing (per-repo worktree/branch map, distinct from the singular task.worktree) - acquireWorkspaceRepoWorktree wraps acquireTaskWorktree per sub-repo, clearing the singular worktree/branch fields so each sub-repo gets a fresh worktree instead of resuming a sibling repo's worktree - fn_acquire_repo_worktree agent tool + workspace prompt injection - executor git-repository validation bypassed when a workspace config is present - CLI `fn init` detects a non-git dir containing sub-repos and writes a workspace config Known gap (intentionally left for design discussion, see PR): the executor's main worktree-acquisition path still assumes a single git root and is not yet workspace-aware. End-to-end execution (skipping the root acquisition, per-repo merge, per-repo session scoping) is a follow-on once the execution model is agreed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-multi-repo.md | 7 ++ CONCEPTS.md | 7 ++ packages/cli/src/project-resolver.ts | 19 ++++ packages/core/src/git-repository.ts | 66 ++++++++++++++ packages/core/src/index.ts | 4 + packages/core/src/store.ts | 5 +- packages/core/src/types.ts | 5 ++ .../src/__tests__/executor-workspace.test.ts | 89 +++++++++++++++++++ packages/engine/src/agent-tools.ts | 64 +++++++++++++ packages/engine/src/executor.ts | 44 +++++++-- packages/engine/src/worktree-acquisition.ts | 53 +++++++++++ 11 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 .changeset/workspace-multi-repo.md create mode 100644 packages/engine/src/__tests__/executor-workspace.test.ts diff --git a/.changeset/workspace-multi-repo.md b/.changeset/workspace-multi-repo.md new file mode 100644 index 0000000000..b005f1e477 --- /dev/null +++ b/.changeset/workspace-multi-repo.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +Add workspace mode: open a folder of git repositories as a single Fusion +project. The agent acquires per-repo worktrees on demand via +`fn_acquire_repo_worktree` as it discovers it needs to work in each sub-repo. diff --git a/CONCEPTS.md b/CONCEPTS.md index c6990d47c7..1fccc91371 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -54,6 +54,13 @@ A Feature auto-generated from a failed Validator Run to carry the remediation wo ### Project A registered workspace that Fusion can operate on: it has a canonical local path, project-scoped settings and data, and must be backed by a usable Git work tree before task execution can create worktrees from it. +A **workspace** is a special Project variant where the registered path is not +itself a Git repository, but contains multiple Git repositories as direct +sub-directories. Fusion discovers sub-repos at init time and records them in +`.fusion/workspace.json`. In workspace mode, task execution does not require a +single root-level worktree; instead, the agent acquires per-repo worktrees +on demand via `fn_acquire_repo_worktree`. + ### Project Identity The durable identity a registered Project carries locally so it can be reattached to the central registry after central state is lost or rebuilt, preserving rows keyed by the same project id instead of minting a replacement. diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index e57ee9391b..93eafa1bd9 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -16,6 +16,8 @@ import { isValidSqliteDatabaseFile, readProjectIdentity, writeProjectIdentity, + detectWorkspaceRepos, + saveWorkspaceConfig, type RegisteredProject, type TaskStore, } from "@fusion/core"; @@ -625,6 +627,23 @@ export async function registerProjectInteractive( // Check for .fusion/ directory if (!isKbProject(absPath)) { + // Check if this is a non-git directory containing sub-repos (workspace mode) + const { spawnSync } = await import("node:child_process"); + const gitCheck = spawnSync("git", ["-C", absPath, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" }); + const isGitRepo = gitCheck.status === 0 && gitCheck.stdout.trim() === "true"; + + if (!isGitRepo) { + const subRepos = await detectWorkspaceRepos(absPath); + if (subRepos.length > 0) { + console.log(`\n Found ${subRepos.length} git repositories in ${absPath}:`); + subRepos.forEach((r: string) => console.log(` • ${r}`)); + console.log(`\n Initializing as a Fusion workspace...\n`); + await saveWorkspaceConfig(absPath, { repos: subRepos }); + // Fall through to normal .fusion init + } + // else: fall through to existing error path + } + if (interactive) { console.log(`\n No .fusion/ directory found in ${absPath}`); const shouldInit = await promptConfirm("Initialize fn here first?", true); diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index e95a9740a7..974c5d12a6 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -98,3 +98,69 @@ function extractCommandErrorMessage(error: unknown): string { return String(error); } + +/** + * Scans `dir` one level deep for sub-directories that are git repositories. + * Returns relative paths of found repos, sorted alphabetically. + */ +export async function detectWorkspaceRepos(dir: string): Promise { + let entries: string[]; + try { + const { readdir } = await import("node:fs/promises"); + entries = await readdir(dir); + } catch { + return []; + } + const { stat } = await import("node:fs/promises"); + const { join } = await import("node:path"); + const found: string[] = []; + for (const entry of entries) { + const candidate = join(dir, entry, ".git"); + try { + const s = await stat(candidate); + if (s.isDirectory() || s.isFile()) found.push(entry); + } catch { + // not a git repo + } + } + return found.sort(); +} + +export interface WorkspaceConfig { + repos: string[]; +} + +const WORKSPACE_CONFIG_FILENAME = "workspace.json"; + +export async function loadWorkspaceConfig(rootDir: string): Promise { + const { readFile } = await import("node:fs/promises"); + const { join } = await import("node:path"); + const configPath = join(rootDir, ".fusion", WORKSPACE_CONFIG_FILENAME); + try { + const raw = await readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if ( + parsed !== null && + typeof parsed === "object" && + "repos" in parsed && + Array.isArray((parsed as { repos: unknown }).repos) + ) { + return parsed as WorkspaceConfig; + } + return null; + } catch { + return null; + } +} + +export async function saveWorkspaceConfig(rootDir: string, config: WorkspaceConfig): Promise { + const { mkdir, writeFile } = await import("node:fs/promises"); + const { join } = await import("node:path"); + const fusionDir = join(rootDir, ".fusion"); + await mkdir(fusionDir, { recursive: true }); + await writeFile( + join(fusionDir, WORKSPACE_CONFIG_FILENAME), + JSON.stringify(config, null, 2), + "utf-8", + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d7b24fb916..bc1650f7e0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -145,12 +145,16 @@ export { export { ensureGitRepositoryForProjectPath, GitRepositoryInitializationError, + detectWorkspaceRepos, + loadWorkspaceConfig, + saveWorkspaceConfig, } from "./git-repository.js"; export type { GitRepositoryCommandResult, GitRepositoryCommandRunner, GitRepositoryEnsureOutcome, EnsureGitRepositoryOptions, + WorkspaceConfig, } from "./git-repository.js"; // ── Trait model (U2) ───────────────────────────────────────────────── diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 115bb82384..8ab43eba61 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -7931,7 +7931,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, runContext?: RunMutationContext, ): Promise { return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext)); @@ -8265,6 +8265,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } else if (updates.worktree !== undefined) { task.worktree = updates.worktree; } + if (updates.workspaceWorktrees !== undefined) { + task.workspaceWorktrees = updates.workspaceWorktrees; + } // Detect new dependencies being added to a todo task → auto-move to triage let movedToTriage = false; if (updates.dependencies !== undefined) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 21a27ce392..4041135ae7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2231,6 +2231,11 @@ export interface Task { /** When true, this decision-only task is expected to complete without creating git commits. */ noCommitsExpected?: boolean; worktree?: string; + /** + * Workspace mode only. Keyed by repo path relative to workspace rootDir. + * Each entry records the on-disk worktree path and git branch for one sub-repo. + */ + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts new file mode 100644 index 0000000000..1916b52367 --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -0,0 +1,89 @@ +// @ts-nocheck +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { loadWorkspaceConfig } from "@fusion/core"; +import { acquireWorkspaceRepoWorktree } from "../worktree-acquisition.js"; + +vi.mock("@fusion/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadWorkspaceConfig: vi.fn(), + }; +}); + +vi.mock("../worktree-acquisition.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + acquireWorkspaceRepoWorktree: vi.fn(), + }; +}); + +const mockedLoadWorkspaceConfig = vi.mocked(loadWorkspaceConfig); +const mockedAcquireWorkspaceRepoWorktree = vi.mocked(acquireWorkspaceRepoWorktree); + +const MOCK_WORKSPACE_CONFIG = { + repos: ["wolf-server", "wolf-community-frontend-1"], +}; + +describe("acquireWorkspaceRepoWorktree", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns alreadyAcquired=false for a fresh repo", async () => { + mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({ + worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", + branch: "fusion/fn-001", + alreadyAcquired: false, + }); + + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "wolf-server", + workspaceRootDir: "/workspace", + task: { id: "FN-001", workspaceWorktrees: undefined } as never, + store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never, + settings: {}, + }); + + expect(result.alreadyAcquired).toBe(false); + expect(result.worktreePath).toContain("wolf-server"); + }); + + it("returns alreadyAcquired=true when worktree already acquired", async () => { + mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({ + worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", + branch: "fusion/fn-001", + alreadyAcquired: true, + }); + + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "wolf-server", + workspaceRootDir: "/workspace", + task: { + id: "FN-001", + workspaceWorktrees: { + "wolf-server": { worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", branch: "fusion/fn-001" }, + }, + } as never, + store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never, + settings: {}, + }); + + expect(result.alreadyAcquired).toBe(true); + }); +}); + +describe("workspace config", () => { + it("loadWorkspaceConfig returns null for non-workspace", async () => { + mockedLoadWorkspaceConfig.mockResolvedValueOnce(null); + const config = await loadWorkspaceConfig("/some/single-repo"); + expect(config).toBeNull(); + }); + + it("loadWorkspaceConfig returns config for workspace", async () => { + mockedLoadWorkspaceConfig.mockResolvedValueOnce(MOCK_WORKSPACE_CONFIG); + const config = await loadWorkspaceConfig("/some/workspace"); + expect(config?.repos).toEqual(["wolf-server", "wolf-community-frontend-1"]); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 8bde4bf2b4..99e87b2cb3 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -28,6 +28,7 @@ import { computeApprovalDedupeKey } from "./agent-action-gate.js"; import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/message-delivery.js"; import { emitGoalRetrievalAudit } from "./goal-anchoring-audit.js"; import { recordRetry } from "./retry-burned-logger.js"; +import { acquireWorkspaceRepoWorktree } from "./worktree-acquisition.js"; // ── Tool parameter schemas (canonical definitions) ──────────────────────── @@ -57,6 +58,15 @@ export const taskLogParams = Type.Object({ outcome: Type.Optional(Type.String({ description: "Result or consequence (optional)" })), }); +export const acquireRepoWorktreeParams = Type.Object({ + repo: Type.String({ + description: + "Relative path of the sub-repo within the workspace to acquire a worktree in " + + "(e.g. 'wolf-server'). Must be one of the repos listed in the workspace. " + + "If already acquired, returns the existing worktree path immediately.", + }), +}); + export const taskDocumentWriteParams = Type.Object({ key: Type.String({ description: "Document key (e.g., 'plan', 'notes', 'research'). Alphanumeric, hyphens, underscores, 1-64 chars.", @@ -3582,3 +3592,57 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri }; } +export function createAcquireRepoWorktreeTool(opts: { + workspaceRootDir: string; + workspaceRepos: string[]; + task: import("@fusion/core").Task; + store: TaskStore; + settings: Partial; + logger?: { log: (m: string) => void; warn: (m: string) => void }; + secretsStore?: Pick; + runContext?: RunMutationContext; +}): ToolDefinition { + const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext } = opts; + return { + name: "fn_acquire_repo_worktree", + label: "Acquire Repo Worktree", + description: + "Acquire an isolated git worktree for a sub-repo in this workspace. " + + "Call this before editing files in a sub-repo; work in the returned path. " + + `Available repos: ${workspaceRepos.join(", ")}.`, + parameters: acquireRepoWorktreeParams, + execute: async (_id: string, params: Static) => { + const { repo } = params; + if (!workspaceRepos.includes(repo)) { + return { + content: [{ type: "text" as const, text: `ERROR: Unknown repo: "${repo}". Available: ${workspaceRepos.join(", ")}` }], + details: {}, + isError: true, + }; + } + const freshTask = await store.getTask(task.id); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: repo, + workspaceRootDir, + task: freshTask, + store, + settings, + logger, + secretsStore, + }); + await store.logEntry( + task.id, + result.alreadyAcquired + ? `fn_acquire_repo_worktree: reusing existing worktree for ${repo} at ${result.worktreePath}` + : `fn_acquire_repo_worktree: created worktree for ${repo} at ${result.worktreePath} (branch: ${result.branch})`, + undefined, + runContext, + ); + return { + content: [{ type: "text" as const, text: `Worktree ready at: ${result.worktreePath} (branch: ${result.branch}, alreadyAcquired: ${result.alreadyAcquired})` }], + details: result, + }; + }, + }; +} + diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index c9a997913f..d849580e08 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -55,6 +55,8 @@ import { resolveEffectiveAgentPermissionPolicy, resolveProjectDefaultModel, resolveAgentMemoryInclusionMode, + loadWorkspaceConfig, + type WorkspaceConfig, type RunCommandResult, } from "@fusion/core"; import { findWorktreeUser, getConflictedFiles } from "./merger.js"; @@ -140,7 +142,7 @@ import type { PluginRunner } from "./plugin-runner.js"; import { isContextLimitError } from "./context-limit-detector.js"; import { StepSessionExecutor } from "./step-session-executor.js"; import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js"; -import { acquireTaskWorktree } from "./worktree-acquisition.js"; +import { acquireTaskWorktree, acquireWorkspaceRepoWorktree } from "./worktree-acquisition.js"; import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; import { @@ -191,6 +193,7 @@ import { createWorkflowDeleteTool as sharedCreateWorkflowDeleteTool, createWorkflowSettingsTool as sharedCreateWorkflowSettingsTool, createTraitListTool as sharedCreateTraitListTool, + createAcquireRepoWorktreeTool, } from "./agent-tools.js"; import { getTaskCompletionBlockerForStore } from "./task-completion.js"; import { createStreamingDeltaNormalizer } from "./streaming-delta.js"; @@ -1552,6 +1555,7 @@ export class TaskExecutor { private workflowRerunWatchdogs = new Map>(); /** Set of ephemeral spawned agent IDs with in-flight cleanup (prevents duplicate deletion attempts). */ private pendingEphemeralDeletions = new Set(); + private workspaceConfig: WorkspaceConfig | null | undefined = undefined; private markPausedAborted(taskId: string, provenance: "global-pause" | "merge-seam" | "hard-cancel" | "completion-finalize" = "hard-cancel"): void { this.pausedAborted.add(taskId); @@ -7409,7 +7413,10 @@ export class TaskExecutor { return; } - if (!await isGitRepository(this.rootDir)) { + if (this.workspaceConfig === undefined) { + this.workspaceConfig = await loadWorkspaceConfig(this.rootDir); + } + if (!this.workspaceConfig && !await isGitRepository(this.rootDir)) { await this.store.logEntry( task.id, "Cannot execute task: project directory is not a Git repository. Fusion requires a Git repository for worktree-based task execution.", @@ -8344,6 +8351,19 @@ export class TaskExecutor { ...getEnabledPluginTools(this.options.pluginRunner), ]; + if (this.workspaceConfig) { + customTools.push(createAcquireRepoWorktreeTool({ + workspaceRootDir: this.rootDir, + workspaceRepos: this.workspaceConfig.repos, + task, + store: this.store, + settings, + logger: executorLog, + secretsStore: this.options.secretsStore, + runContext: engineRunContext, + })); + } + // Accumulates the full assistant text output for the most recent session. // Reset to "" each time a new session begins so detectPseudoPause only // sees the last session's output, not the entire conversation history. @@ -8594,6 +8614,7 @@ export class TaskExecutor { worktreePath, this.options.pluginRunner, customFieldDefs, + this.workspaceConfig, ); await promptWithFallback(session, agentPrompt); } @@ -8983,7 +9004,7 @@ export class TaskExecutor { "Do NOT ask for permission. Do NOT write a summary. Just call a tool and keep working.", "", "Original task:", - buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner, retryCustomFieldDefs), + buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner, retryCustomFieldDefs, this.workspaceConfig), ].join("\n"); } else { retryPrompt = [ @@ -8993,7 +9014,7 @@ export class TaskExecutor { "2. If there is remaining work, finish it and then call fn_task_done.", "", "Original task:", - buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner, retryCustomFieldDefs), + buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner, retryCustomFieldDefs, this.workspaceConfig), ].join("\n"); } @@ -15731,6 +15752,7 @@ export function buildExecutionPrompt( worktreePath?: string, pluginRunner?: PluginRunner, customFieldDefs?: WorkflowFieldDefinition[], + workspaceConfig?: WorkspaceConfig | null, ): string { const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath); const reviewLevel = parseReviewLevelFromPrompt(prompt); @@ -15864,7 +15886,7 @@ git log --oneline } const pluginTaskContributions = buildPluginPromptSection("executor-task", pluginRunner); - return `Execute this task. + const executionPrompt = `Execute this task. ## Task: ${task.id} ${task.title ? `**${task.title}**` : ""} @@ -15917,6 +15939,18 @@ If the repo has a typecheck command, run it before \`fn_task_done()\` and fix an Use \`fn_task_create\` for truly separate follow-up work, including unrelated/pre-existing broad-suite failures. If lint is configured and failing, fix that too before completion. Do not repeatedly rerun a broad failing or hanging workspace command without a new hypothesis and a narrower confirming command.`; + + if (workspaceConfig) { + return executionPrompt + `\n\n## Workspace mode\n` + + `This project is a workspace containing multiple git repositories.\n` + + `Available repos:\n` + + workspaceConfig.repos.map((r: string) => `- \`${r}\``).join("\n") + + `\n\nBefore editing files in any sub-repo, call \`fn_acquire_repo_worktree\` ` + + `with the repo name to get an isolated worktree path. ` + + `Work exclusively inside that returned path — never edit the repo's main checkout directly.\n`; + } + + return executionPrompt; } /** diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 11ca2c0850..6ce4ca696b 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -595,3 +595,56 @@ async function verifyResumeBranchNotMisbound(input: { logger?.warn?.(`${taskId}: resume re-anchor failed (continuing — executor preflight will handle): ${formatError(err)}`); } } + +export interface AcquireWorkspaceRepoWorktreeOptions { + repoRelPath: string; + workspaceRootDir: string; + task: Task; + store: TaskStore; + settings: Partial; + logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void }; + secretsStore?: Pick; +} + +export async function acquireWorkspaceRepoWorktree( + opts: AcquireWorkspaceRepoWorktreeOptions, +): Promise<{ worktreePath: string; branch: string; alreadyAcquired: boolean }> { + const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore } = opts; + const { join } = await import("node:path"); + + const existing = task.workspaceWorktrees?.[repoRelPath]; + if (existing) { + return { ...existing, alreadyAcquired: true }; + } + + const repoAbsPath = join(workspaceRootDir, repoRelPath); + + /* + FNXC:WorkspaceWorktree 2026-06-21-00:00: + Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` + is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites + those singular fields on the task row after each acquisition. Passing the live task straight + through means the second repo's acquisition sees the first repo's `task.worktree` (which exists + on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo + contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo + helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in + `task.workspaceWorktrees`, not the singular column. + */ + const result = await acquireTaskWorktree({ + task: { ...task, worktree: undefined, branch: undefined }, + rootDir: repoAbsPath, + store, + settings, + logger, + secretsStore, + runInitCommand: true, + }); + + const updated: Record = { + ...(task.workspaceWorktrees ?? {}), + [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, + }; + await store.updateTask(task.id, { workspaceWorktrees: updated }); + + return { worktreePath: result.worktreePath, branch: result.branch, alreadyAcquired: false }; +} From 9c6b4dd3cd5ad7b273b4d734e018f321aaa9fb6d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 17:25:12 -0700 Subject: [PATCH 002/265] feat(FN-6879): document workflow nodes in editor detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-node Help section to the workflow editor's node detail pane: description, configuration, inputs, outputs, and edges for every node kind. Keys off the effective kind (preserved IR kind) so graph-only policy nodes — auto-merge gate, branch-group member integration / promotion, PR and recovery nodes — get specific help instead of reading as a generic merge/gate/hold, and are flagged "Engine-managed". Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workflow-node-help.md | 5 + .../app/components/WorkflowNodeEditor.css | 74 +++++ .../app/components/WorkflowNodeEditor.tsx | 37 ++- .../__tests__/WorkflowNodeEditor.test.tsx | 19 ++ .../nodes/__tests__/node-help.test.ts | 107 +++++++ .../app/components/nodes/node-help.ts | 294 ++++++++++++++++++ 6 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 .changeset/workflow-node-help.md create mode 100644 packages/dashboard/app/components/nodes/__tests__/node-help.test.ts create mode 100644 packages/dashboard/app/components/nodes/node-help.ts diff --git a/.changeset/workflow-node-help.md b/.changeset/workflow-node-help.md new file mode 100644 index 0000000000..29a99275c9 --- /dev/null +++ b/.changeset/workflow-node-help.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workflow editor: add a Help section to the node detail pane. Every node now documents what it does, how to configure it, and its inputs/outputs/edges — including the engine-managed merge-lifecycle nodes (auto-merge gate, branch-group member integration, branch-group promotion, PR and recovery nodes), which are surfaced read-only with an "Engine-managed" badge. diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index e94ada1125..5c9c173cf2 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -957,6 +957,80 @@ React Flow ships white default controls and mini-map chrome, but the workflow ed color: var(--ws-warning); } +/* ── Per-node Help (FNXC:WorkflowEditor 2026-06-21-10:00) ─────────── + * Collapsible
teaching what the selected node does, how to + * configure it, and its inputs/outputs/edges. Sits under the heading, + * collapsed by default so it never pushes config fields below the fold. */ +.wf-inspector-help { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.wf-inspector-help-summary { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + font-size: 0.78rem; + color: var(--text); + cursor: pointer; + list-style: none; + user-select: none; +} + +.wf-inspector-help-summary::-webkit-details-marker { + display: none; +} + +.wf-inspector-help-summary:hover { + background: var(--bg-tertiary); + border-radius: var(--radius-sm); +} + +/* Engine-managed badge for graph-only policy nodes (read-only lifecycle). */ +.wf-inspector-help-badge { + margin-left: auto; + padding: 1px var(--space-xs); + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-dim); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.wf-inspector-help-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0 var(--space-sm) var(--space-sm); + font-size: 0.76rem; + color: var(--text-muted); +} + +.wf-inspector-help-summary-text { + margin: 0; + color: var(--text); +} + +.wf-inspector-help-dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px var(--space-sm); + margin: 0; +} + +.wf-inspector-help-dl dt { + font-weight: 600; + color: var(--text-dim); +} + +.wf-inspector-help-dl dd { + margin: 0; + color: var(--text-muted); +} + .wf-field--checkbox { flex-direction: row; align-items: center; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 69c5017161..dc681b0401 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -53,6 +53,7 @@ import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext"; import { bareSkillName, type NodeSummaryCatalogs } from "./nodes/node-summary"; +import { nodeHelpForData } from "./nodes/node-help"; import { irToFlow, flowToIr, @@ -1968,6 +1969,8 @@ function InnerEditor({ * The structural start node needs an inspector because its entry column is editable and persisted in the workflow IR. Keep end structural-only until it has a meaningful editable property. */ const selectedNodeHasInspector = selectedNode !== null && selectedNode.data.kind !== "end"; + // FNXC:WorkflowEditor 2026-06-21-10:00: Help content for the inspector, keyed by the node's effective kind (preserved IR kind when a graph-only policy node collapsed onto a generic merge/gate/hold shape). + const selectedNodeHelp = selectedNode !== null ? nodeHelpForData(selectedNode.data) : null; const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null; const mobileNodeDetailStage = isMobileMode && selectedNodeHasInspector && !inspectorCollapsed; const mobileEdgeDetailStage = isMobileMode && selectedEdge !== null; @@ -3309,7 +3312,8 @@ function InnerEditor({ !(compactLayoutEnabled && !isMobileMode) && ( @@ -2669,7 +2633,6 @@ function InnerEditor({ ["add", t("workflowNodes.mobileAdd", "Add")], ["settings", t("workflowSettings.title", "Settings")], ["fields", t("workflowFields.title", "Fields")], - ["optional-steps", t("workflowOptionalSteps.title", "Optional steps")], ["columns", t("workflowColumns.title", "Columns")], ["actions", t("workflowNodes.mobileActions", "Actions")], ] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => ( @@ -2866,16 +2829,6 @@ function InnerEditor({ )} - {mobilePanel === "optional-steps" && ( -
- p.template)} - /> -
- )} {mobilePanel === "columns" && (
diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css b/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css deleted file mode 100644 index d458de87a4..0000000000 --- a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css +++ /dev/null @@ -1,107 +0,0 @@ -/* WorkflowOptionalStepsPanel — sibling of WorkflowFieldsPanel; mirrors its layout - * so the optional-steps panel reads consistently alongside Fields/Settings. */ - -.wf-optional-steps-panel { - display: flex; - flex-direction: column; - gap: var(--space-sm); - padding: var(--space-md); -} - -.wf-optional-steps-header h3 { - margin: 0; -} - -.wf-optional-steps-hint, -.wf-optional-steps-empty { - font-size: 0.75rem; - color: var(--text-muted); - margin: 0; -} - -.wf-optional-steps-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-sm); -} - -.wf-optional-step-item { - display: flex; - flex-direction: column; - gap: 4px; - padding: var(--space-sm); - border: 1px solid var(--border); - border-radius: var(--radius-sm, 6px); -} - -.wf-optional-step-item.is-unknown { - opacity: 0.6; -} - -.wf-optional-step-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.wf-optional-step-title { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.wf-optional-step-name { - font-weight: 600; - font-size: 0.8rem; -} - -.wf-optional-step-name--unknown { - font-style: italic; - font-weight: 400; -} - -.wf-optional-step-description { - font-size: 0.72rem; - color: var(--text-muted); - margin: 0; -} - -.wf-optional-step-default { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.75rem; -} - -.wf-optional-step-remove { - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: var(--text-muted); - cursor: pointer; -} - -.wf-optional-step-remove:hover:not(:disabled) { - color: var(--color-error); -} - -.wf-optional-steps-add { - display: flex; - flex-direction: column; - gap: 4px; -} - -.wf-optional-steps-add-label { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 0.75rem; - color: var(--text-muted); -} diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx b/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx deleted file mode 100644 index 40bdc94a0f..0000000000 --- a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * FNXC:WorkflowOptionalSteps 2026-06-21-00:00: - * Workflow authors need to declare which step templates are optional and set each - * one's defaultOn from the visual editor (persisted on the IR's `optionalSteps` - * array) so optional steps are authorable without hand-editing IR. - * - * WorkflowOptionalStepsPanel — the workflow editor's optional-step authoring - * surface. Sibling to {@link WorkflowFieldsPanel} / WorkflowSettingsPanel: lives - * alongside the canvas in {@link WorkflowNodeEditor} and mutates the IR's - * `optionalSteps` array through the same state/save flow (preserved across the - * round-trip by `flowToIr`). - * - * A declaration is just `{ templateId, defaultOn? }`. Display metadata - * (name/description/phase) is resolved from the built-in step-template catalog at - * render time — never duplicated into the IR — so the resolver stays the single - * source of truth. Unknown/stale template ids render a muted, still-removable row - * rather than being silently dropped. - */ -import { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Plus, Trash2 } from "lucide-react"; -import { WORKFLOW_STEP_TEMPLATES, type WorkflowOptionalStep, type WorkflowStepTemplate } from "@fusion/core"; -import { phaseBadge } from "./workflow-phase-badge"; -import "./WorkflowOptionalStepsPanel.css"; - -interface WorkflowOptionalStepsPanelProps { - optionalSteps: WorkflowOptionalStep[]; - onChange: (next: WorkflowOptionalStep[]) => void; - readOnly: boolean; - /** Plugin-contributed templates, merged into the catalog when available. */ - pluginTemplates?: WorkflowStepTemplate[]; -} - -export function WorkflowOptionalStepsPanel({ - optionalSteps, - onChange, - readOnly, - pluginTemplates = [], -}: WorkflowOptionalStepsPanelProps) { - const { t } = useTranslation("app"); - - const templatesById = useMemo(() => { - const map = new Map(); - for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl); - return map; - }, [pluginTemplates]); - - const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]); - - // Catalog entries not already declared — the "Add optional step" picker source. - const available = useMemo( - () => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)), - [templatesById, declaredIds], - ); - - const addStep = useCallback( - (templateId: string) => { - if (!templateId || declaredIds.has(templateId)) return; - onChange([...optionalSteps, { templateId, defaultOn: false }]); - }, - [optionalSteps, onChange, declaredIds], - ); - - const removeStep = useCallback( - (templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)), - [optionalSteps, onChange], - ); - - const toggleDefaultOn = useCallback( - (templateId: string, defaultOn: boolean) => - onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))), - [optionalSteps, onChange], - ); - - return ( - - ); -} - -export default WorkflowOptionalStepsPanel; diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index b93babdb02..f10fd6db68 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -164,13 +164,9 @@ function v2Def(): WorkflowDefinition { }; } -function v2DefWithOptional(): WorkflowDefinition { - const base = v2Def(); - return { - ...base, - ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"], - }; -} +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: `v2DefWithOptional` and its +// optional-step DECLARATION hydration/save test are removed — the declaration +// authoring panel is retired (optional-group nodes now). function builtinDef(): WorkflowDefinition { return { @@ -751,34 +747,6 @@ describe("WorkflowNodeEditor", () => { expect(start?.column).toBe("done"); }); - it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => { - vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]); - vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ - ...v2DefWithOptional(), - ...(updates as object), - })); - vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); - - render( {}} addToast={() => {}} />); - - await screen.findByText("Save"); - // The declared optional step is hydrated into the panel (optionalStepsOf). - const row = await screen.findByTestId("wf-optional-step-browser-verification"); - expect(within(row).getByText("Browser Verification")).toBeTruthy(); - - // Toggling defaultOn must mark the editor dirty (serializeGraph threading) so - // the Save button enables and persists the change. - fireEvent.click(within(row).getByRole("checkbox")); - fireEvent.click(screen.getByText("Save").closest("button")!); - - await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); - const [, updates] = vi.mocked(updateWorkflow).mock.calls[0]; - const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as { - optionalSteps?: { templateId: string; defaultOn?: boolean }[]; - }; - expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]); - }); - it("renders the start inspector without the entry-column select for v1 workflows", async () => { vi.mocked(fetchWorkflows).mockResolvedValue([def()]); diff --git a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx deleted file mode 100644 index 93398b01f9..0000000000 --- a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; -import { useState } from "react"; -import type { WorkflowOptionalStep } from "@fusion/core"; -import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel"; - -// Controlled host mirroring how WorkflowNodeEditor drives the panel. -function Host({ - initial, - readOnly = false, - onState, -}: { - initial: WorkflowOptionalStep[]; - readOnly?: boolean; - onState?: (s: WorkflowOptionalStep[]) => void; -}) { - const [optionalSteps, setOptionalSteps] = useState(initial); - return ( - { - setOptionalSteps(next); - onState?.(next); - }} - /> - ); -} - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("WorkflowOptionalStepsPanel", () => { - it("renders the empty state and an add picker when no steps are declared", () => { - render(); - expect(screen.getByText(/No optional steps/i)).toBeTruthy(); - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - // browser-verification is in the catalog and not yet declared → available. - expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy(); - }); - - it("adds a step from the picker (defaultOn false) and removes it from the picker", () => { - const onState = vi.fn(); - render(); - fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), { - target: { value: "browser-verification" }, - }); - expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]); - // The declared row is shown with the resolved template name… - const row = screen.getByTestId("wf-optional-step-browser-verification"); - expect(within(row).getByText("Browser Verification")).toBeTruthy(); - // …and the picker no longer offers it. - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull(); - }); - - it("toggles defaultOn for a declared step", () => { - const onState = vi.fn(); - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - fireEvent.click(within(row).getByRole("checkbox")); - expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]); - }); - - it("removes a declared step and returns it to the picker", () => { - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i })); - expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull(); - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy(); - }); - - it("renders an unknown/stale templateId as a muted, still-removable row", () => { - const onState = vi.fn(); - render(); - const row = screen.getByTestId("wf-optional-step-does-not-exist"); - expect(row.className).toContain("is-unknown"); - expect(within(row).getByText(/Unknown step/i)).toBeTruthy(); - fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i })); - expect(onState).toHaveBeenCalledWith([]); - }); - - it("disables editing when readOnly", () => { - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true); - expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index 864e9ddf79..d214a4da50 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -10,7 +10,6 @@ import { fragmentSeamConflicts, copyIrWithFreshIds, columnsOf, - optionalStepsOf, columnForY, bandTop, columnsToBandNodes, @@ -1636,58 +1635,12 @@ describe("copyIrWithFreshIds", () => { }); }); -describe("optionalSteps round-trip (U2)", () => { - const v2WithOptional = (optionalSteps?: { templateId: string; defaultOn?: boolean }[]) => - makeDef( - parseWorkflowIr({ - version: "v2", - name: "wf-opt", - columns: [ - { id: "triage", name: "Triage", traits: [] }, - { id: "done", name: "Done", traits: [{ trait: "complete" }] }, - ], - nodes: [ - { id: "start", kind: "start", column: "triage" }, - { id: "end", kind: "end", column: "done" }, - ], - edges: [{ from: "start", to: "end" }], - ...(optionalSteps ? { optionalSteps } : {}), - }), - ); - - it("optionalStepsOf reads declarations from a v2 IR and returns a copy", () => { - const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]); - const read = optionalStepsOf(def); - expect(read).toEqual([{ templateId: "browser-verification", defaultOn: true }]); - // mutating the result does not mutate the source IR - read[0].defaultOn = false; - expect(optionalStepsOf(def)).toEqual([{ templateId: "browser-verification", defaultOn: true }]); - }); - - it("optionalStepsOf returns [] for v1 and for v2 without optionalSteps", () => { - const v1 = makeDef({ - version: "v1", - name: "legacy", - nodes: [ - { id: "start", kind: "start" }, - { id: "end", kind: "end" }, - ], - edges: [{ from: "start", to: "end" }], - }); - expect(optionalStepsOf(v1)).toEqual([]); - expect(optionalStepsOf(v2WithOptional())).toEqual([]); - }); - - it("flowToIr preserves optionalSteps across a full irToFlow round-trip", () => { - const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]); - const { nodes, edges } = irToFlow(def); - const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], optionalStepsOf(def)); - expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([ - { templateId: "browser-verification", defaultOn: true }, - ]); - }); - - it("serializes as v2 when optionalSteps present but no custom columns/fields/settings", () => { +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: +// The legacy optional-step DECLARATION authoring surface is retired: `optionalStepsOf` +// is removed and `flowToIr` no longer accepts/emits an `optionalSteps` array. Optional +// steps are graph-native `optional-group` nodes carried by the normal node/edge mapping. +describe("optionalSteps declaration authoring removed (U7)", () => { + it("flowToIr never emits a legacy optionalSteps key", () => { const { ir: out } = flowToIr( "opt-only", [ @@ -1698,21 +1651,7 @@ describe("optionalSteps round-trip (U2)", () => { [], [], [], - [{ templateId: "browser-verification" }], ); - expect(out.version).toBe("v2"); - expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([ - { templateId: "browser-verification" }, - ]); - }); - - it("omits the optionalSteps key entirely when empty (R6 byte-identity)", () => { - const def = v2WithOptional(); - const { nodes, edges } = irToFlow(def); - const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], []); expect("optionalSteps" in out).toBe(false); - // and with the arg omitted entirely - const { ir: out2 } = flowToIr("wf-opt", nodes, edges, columnsOf(def)); - expect("optionalSteps" in out2).toBe(false); }); }); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index f889a188f2..11c718114e 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -9,7 +9,6 @@ import type { WorkflowDefinition, WorkflowFieldDefinition, WorkflowSettingDefinition, - WorkflowOptionalStep, } from "@fusion/core"; import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; @@ -453,7 +452,6 @@ export function flowToIr( columns?: WorkflowIrColumn[], fields?: WorkflowFieldDefinition[], settings?: WorkflowSettingDefinition[], - optionalSteps?: WorkflowOptionalStep[], ): { ir: WorkflowIr; layout: Record } { const realNodes = nodes.filter((n) => !isColumnBandNode(n.id)); // Partition by parentId: foreach group children reassemble into that group's @@ -475,15 +473,14 @@ export function flowToIr( ); const hasFields = Array.isArray(fields) && fields.length > 0; const hasSettings = Array.isArray(settings) && settings.length > 0; - const hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0; - // FNXC:WorkflowOptionalSteps 2026-06-21-00:00: - // Optional steps must round-trip through the node editor without data loss, yet - // must never upgrade a legacy v1 graph. Fields, settings, and optional steps are - // v2-only declarations: a workflow with any of them but no custom columns still - // serializes as v2 (with the synthesized default columns). Empty/absent → not a - // v2 signal, and the key is omitted entirely (R6 byte-identity for legacy graphs). + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + // The editor no longer AUTHORS legacy `optionalSteps` declarations — optional + // steps are graph-native `optional-group` nodes carried through the normal + // node/edge mapping. Fields and settings remain v2-only declarations: a workflow + // with either but no custom columns still serializes as v2 (with the synthesized + // default columns). Empty/absent → not a v2 signal (R6 byte-identity for legacy). const v2 = - (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps; + (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings; const layout: Record = {}; /** Project one flow node (top-level or template child) into an IR node. */ @@ -595,12 +592,6 @@ export function flowToIr( render: s.render ? { ...s.render } : undefined, })); } - if (hasOptionalSteps) { - // Optional-step DECLARATIONS round-trip through the editor opaquely (they are - // not graph nodes; the resolver + server validator are the source of truth). - // Omitted entirely when empty so legacy graphs stay byte-identical (R6). - (ir as { optionalSteps?: unknown }).optionalSteps = optionalSteps!.map((o) => ({ ...o })); - } return { ir, layout }; } @@ -1013,15 +1004,11 @@ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[] })); } -/** Extract the editor's working optional-step declaration list from a definition. - * v2 with `optionalSteps` → a shallow copy; v1 or none → empty. Display metadata - * (name/icon/phase) is NOT carried here — it is resolved from the step-template - * catalog at render time so the resolver stays the single source of truth. */ -export function optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[] { - const ir = def.ir as { optionalSteps?: WorkflowOptionalStep[] }; - if (!isV2(def.ir) || !Array.isArray(ir.optionalSteps)) return []; - return ir.optionalSteps.map((o) => ({ ...o })); -} +/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + `optionalStepsOf` (the editor's legacy `optionalSteps` declaration extractor) + is removed. Optional steps are graph-native `optional-group` nodes now; the + editor reads/writes them through the normal node/edge mapping, and the per-task + toggle surfaces resolve them via `resolveWorkflowOptionalSteps`. */ /** Seed graph for a brand-new workflow: start → end with room to insert steps. */ export function emptyWorkflowIr(name: string): WorkflowIr { From 316d2659b86b35affc618864cfe5c9eb4178d151 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 20:29:46 -0700 Subject: [PATCH 014/265] fix(review): workspace-merge park must use status:'failed' to avoid re-enqueue loop (U0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-fix verification review (correctness + adversarial + reliability, unanimous P0) found that the earlier retry-burn fix introduced an infinite loop: parking a WorkspaceTaskMergeError task with status:null + mergeRetries:0 passes every auto-merge eligibility gate (canMergeTask short-circuits only on status==='failed'), so the cooldown sweep re-enqueues it every tick → guard re-throws → re-park, forever. - Park with status:'failed' (keep mergeRetries:0). canMergeTask now blocks the auto-sweep; a human's manual merge still works because it flows through the manual-resolver branch (rejectMergeResolvers), which bypasses canMergeTask — so 'failed' does not block manual retry (the original comment's worry was wrong). - Detect the error via `err instanceof Error && err.name === "WorkspaceTaskMergeError"`, matching the VerificationError/MergeAbortedError convention and bundle-safe across the @fusion/core→@fusion/engine boundary (drops the now-unused class import). - Document that the dispatch door guard is a fast-fail only; the unconditional chokepoint guard inside runAiMerge is the authoritative enforcement. - Add a regression test asserting the auto-merge park sets status:'failed' (not null). Gate green: lint, typecheck, build, test:gate (649+58), project-engine (81). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/project-engine.test.ts | 38 +++++++++++++++++++ packages/engine/src/project-engine.ts | 29 +++++++++----- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 3821a0d3d9..a613fda48b 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1318,6 +1318,44 @@ describe("ProjectEngine U0 merge unification dispatch", () => { expect(mocks.runAiMerge).not.toHaveBeenCalled(); await engine.stop(); }); + + // Regression: the auto-merge park for a WorkspaceTaskMergeError must set status:"failed", + // not status:null. status:null + mergeRetries:0 passes every eligibility gate, so the + // cooldown sweep re-enqueues the task every tick → tight re-throw/re-park loop. status:"failed" + // makes canMergeTask short-circuit; manual retry still works (it bypasses canMergeTask). + it("R7 auto-merge park: workspace task is parked status:'failed' so it is not re-enqueued", async () => { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + mockStore.store.getTask.mockResolvedValue({ + id: "FN-WS-AUTO", + column: "in-review", + paused: false, + mergeRetries: 0, + status: "queued", + workspaceWorktrees: { + "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" }, + }, + } as any); + mocks.currentStore = mockStore.store; + + const engine = createEngine(); + await engine.start(); + // Auto-merge path (no manual resolver): the R7 door guard throws before runAiMerge, + // and the dispatch catch parks the task. + engine.enqueueMerge("FN-WS-AUTO"); + await vi.waitFor(() => { + expect(mockStore.store.updateTask).toHaveBeenCalledWith( + "FN-WS-AUTO", + expect.objectContaining({ status: "failed", mergeRetries: 0 }), + ); + }); + expect(mocks.runAiMerge).not.toHaveBeenCalled(); + // Guard against regression to the re-enqueue loop (status:null park): + expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( + "FN-WS-AUTO", + expect.objectContaining({ status: null }), + ); + await engine.stop(); + }); }); describe("ProjectEngine merge queue priority ordering", () => { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 5a343177f4..575464cc00 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -13,7 +13,7 @@ import type { ResearchSynthesisRequest, ResearchSynthesisResult, } from "@fusion/core"; -import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId, WorkspaceTaskMergeError } from "@fusion/core"; +import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -2287,11 +2287,15 @@ export class ProjectEngine { this.activeMergeSession = session; }, }; - // FNXC:Workspace 2026-06-21-19:05: + // FNXC:Workspace 2026-06-21-19:40: // R7 merge-boundary guard (master-plan U0). Reject workspace-mode // tasks BEFORE any git work — they need the per-repo merge loop that // lands in master-plan U6 (which removes this guard). Load the task // here so the dispatch shares the one predicate in @fusion/core. + // This door is a FAST-FAIL only: a getTask failure is swallowed to null + // and the guard is skipped, but the unconditional chokepoint guard inside + // runAiMerge (which re-reads the task) is the authoritative enforcement, + // so a transient read failure here cannot let a workspace task reach git work. const mergeTask = await store.getTask(taskId).catch(() => null); if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask); @@ -2358,19 +2362,24 @@ export class ProjectEngine { continue; } - // FNXC:Workspace 2026-06-21-19:05: + // FNXC:Workspace 2026-06-21-19:40: // R7 workspace merge-boundary park (master-plan U0). A WorkspaceTaskMergeError // is a PERMANENT config error (workspace task hit a merge door before the // per-repo merge loop exists — master-plan U6), NOT a transient merge failure. - // Park the task WITHOUT burning mergeRetries (set to 0) so a human can manually - // retry after addressing the config; the default failed-path below would - // otherwise pin mergeRetries to the cap and permanently block manual retry. + // Park with status:"failed" so the auto-merge cooldown sweep STOPS re-attempting: + // `canMergeTask` short-circuits on status==="failed". (Parking with status:null + + // mergeRetries:0 passes every eligibility gate, so the sweep re-enqueues every tick + // → tight WorkspaceTaskMergeError re-throw/re-park loop.) Keep mergeRetries:0 (not + // the cap) so a human's manual merge after the config is addressed is not blocked by + // exhausted retries — and manual merge flows through the manual-resolver branch + // (rejectMergeResolvers), which bypasses canMergeTask, so "failed" never blocks it. + // Detect by err.name (matches the VerificationError/MergeAbortedError convention and + // is robust across the @fusion/core→@fusion/engine package boundary). const isWorkspaceMergeError = - err instanceof WorkspaceTaskMergeError - || (err as { name?: string } | null)?.name === "WorkspaceTaskMergeError"; + err instanceof Error && err.name === "WorkspaceTaskMergeError"; if (isWorkspaceMergeError) { runtimeLog.error( - `${hasManualResolver ? "Manual" : "Auto"}-merge blocked for ${taskId}: workspace-mode tasks cannot merge until per-repo merge support (master-plan U6) lands; parking without burning mergeRetries so a human can retry after the config is addressed: ${errorMsg}`, + `${hasManualResolver ? "Manual" : "Auto"}-merge blocked for ${taskId}: workspace-mode tasks cannot merge until per-repo merge support (master-plan U6) lands; parking as failed (manual retry still works) without exhausting mergeRetries: ${errorMsg}`, ); await store .logEntry(taskId, `Merge blocked: ${errorMsg}`, "WorkspaceTaskMergeError") @@ -2379,7 +2388,7 @@ export class ProjectEngine { this.rejectMergeResolvers(taskId, err instanceof Error ? err : new Error(errorMsg)); } else { await store - .updateTask(taskId, { status: null, mergeRetries: 0, error: errorMsg }) + .updateTask(taskId, { status: "failed", mergeRetries: 0, error: errorMsg }) .catch(() => undefined); } continue; From 68d3c5820e938d1e99acd6cd21735c52bf37943c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 20:33:32 -0700 Subject: [PATCH 015/265] docs(FN-6880): capture optional-group toggle-id collision learning Document the code-review P1 as a logic-errors learning: a per-task graph toggle (enabledWorkflowSteps, keyed by optional-group node id) collided with the legacy step-template namespace and was silently remapped by the store resolver, bypassing an enabled group. Cross-references the per-task-override blast-radius cousins as the id-namespace-collision variant of that class. Seeds an "Optional step group" entry in CONCEPTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONCEPTS.md | 5 + ...toggle-id-remapped-by-step-materializer.md | 102 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md diff --git a/CONCEPTS.md b/CONCEPTS.md index c6990d47c7..5549f4ffa3 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -250,6 +250,11 @@ A workflow graph node that reads a declared Artifact and runs a registry parser ### Custom task field A workflow-declared, typed task field (`string | text | number | boolean | enum | multi-enum | date | url`, with enum options and render hints) whose values live in `tasks.customFields`, keyed by field id. The task model is thereby recast as core fields (title, description) + standard metadata + these workflow-defined fields. Writes pass through a single store authority (`updateTaskCustomFields`) that validates each value against the resolving workflow's schema and returns typed rejections (offending `fieldId` + `code`); agents write them via `fn_task_update`'s `custom_fields` patch. Editing a workflow's fields or switching a task's workflow orphans (never destroys) values for removed or type-incompatible ids — orphans are retained and surfaced under a detail disclosure, excluded from cards. Same id means the same field within a project; there is no cross-workflow shared field namespace. +### Optional step group +A workflow graph container node (alongside `foreach`/`loop`) whose template subgraph runs once when a task has enabled it and is bypassed otherwise — the graph-native way to make a step optional per task. Enablement is a per-task toggle set seeded from the group's workflow-level default; the group's own node id is the toggle key. It replaces the earlier execution-inert *declaration* model (a separate optional-step list run through a hidden seam), so optional steps are now real, placeable nodes rather than an out-of-graph facet. + +Single pass — no iteration or rework inside the template (this is what distinguishes it from `foreach`/`loop`). Because the toggle key is the node id, renaming or recreating a group resets its per-task enablement; and because that id may deliberately equal a built-in step-template id, the per-task enable set must keep group ids identity-stable rather than round-tripping them through legacy step-template materialization (which would remap the key and silently bypass the group). + ## Persistence & migrations ### Schema-Version Sweep diff --git a/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md b/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md new file mode 100644 index 0000000000..4221daeea7 --- /dev/null +++ b/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md @@ -0,0 +1,102 @@ +--- +title: "Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped" +date: 2026-06-21 +category: docs/solutions/logic-errors +module: engine (workflow store + graph executor) +problem_type: logic_error +component: service_object +symptoms: + - "Enabling a built-in optional-group (browser-verification) on a coding/stepwise task did nothing — the group's steps never ran." + - "The default-on seed path and direct graph-executor unit tests passed, masking the bug; only user-driven enable (create-with-enable or update/toggle) failed." + - "No error surfaced — the enabled group was silently bypassed." +root_cause: logic_error +resolution_type: code_fix +severity: high +related_components: + - workflow-store + - graph-executor + - optional-group +tags: + - optional-group + - enabledworkflowsteps + - per-task-override + - id-collision + - workflow-store + - silent-bypass +--- + +# Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped + +## Problem + +A graph-native `optional-group` workflow node is enabled per task via the `enabledWorkflowSteps` array, keyed by the group's **node id**. The graph executor runs the group only when `task.enabledWorkflowSteps.includes(node.id)`. But the store's `resolveEnabledWorkflowSteps` ran every id through the **legacy step-template materializer** (`getBuiltInWorkflowTemplate` → `ensureWorkflowStepForTemplate`). The built-in `browser-verification` group deliberately reused the template id `"browser-verification"` as its node id (for back-compat), so that id matched a `WORKFLOW_STEP_TEMPLATES` entry and was **remapped to a materialized `WorkflowStep` row id** (≠ the node id). The executor's membership check then never matched, and the enabled group was silently bypassed — the headline use case (turn the optional step on) did nothing, with no error. + +## Symptoms + +- Enabling `browser-verification` on a coding/stepwise task ran nothing pre-merge. +- Direct graph-executor tests (which pass a raw `enabledWorkflowSteps: ["browser-verification"]`) and the default-on **seed** path passed — masking the defect. +- Only the **user-driven** enable paths failed: create-with-explicit-enable and `updateTask({ enabledWorkflowSteps })` (the per-task toggle in the UI). + +## What Didn't Work + +- **Trusting the existing tests.** The unit tests used group ids like `og-on`/`og-off` that do **not** collide with any `WORKFLOW_STEP_TEMPLATES` id, so `getBuiltInWorkflowTemplate` returned undefined and the id passed through untouched — the tests were green precisely because they avoided the colliding id. The bug only fires when the group id equals a built-in template id. +- **Assuming the executor test covered it.** The two-task divergence test enabled the group by writing `enabledWorkflowSteps` straight onto the task, bypassing the store's resolver — so it never exercised the remap. The defect lived entirely in the create/update **resolution** path, one layer above the executor. + +## Solution + +Pass a workflow's optional-group node ids through `resolveEnabledWorkflowSteps` **untouched** — they are executor toggle keys, not legacy step-template ids to be materialized. + +```ts +// NEW: enumerate every optional-group node id (regardless of defaultOn). +export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] { + return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId); // templateId === group node id +} + +// store.ts — the resolver gains an optional pass-through set: +private async resolveEnabledWorkflowSteps( + stepIds?: string[], + optionalGroupIds?: Set, +): Promise { + // ... + // Optional-group toggle ids pass through raw — never materialized as legacy step rows. + const template = optionalGroupIds?.has(stepId) + ? undefined + : this.getBuiltInWorkflowTemplate(stepId); + const resolvedId = template ? (await this.ensureWorkflowStepForTemplate(stepId)).id : stepId; + // ... +} + +// helper resolving the task's workflow IR → its optional-group id set: +private async optionalGroupIdSet(workflowId?: string | null): Promise> { + const wfId = workflowId ?? (await this.getDefaultWorkflowId()); + if (!wfId) return new Set(); + const def = await this.getWorkflowDefinition(wfId); + if (!def || def.kind === "fragment") return new Set(); + return new Set(resolveAllOptionalGroupIds(def.ir)); +} +``` + +Both user-enable call sites supply the set: create (`optionalGroupIdSet(input.workflowId)`) and update (`optionalGroupIdSet(getTaskWorkflowSelection(task.id)?.workflowId)`). + +**Regression test** — must use a **colliding** id (`browser-verification`), since non-colliding ids never reproduce it: create-with-enable and update/toggle both assert the raw group node id survives in `enabledWorkflowSteps`. + +## Why This Works + +The bug is a **per-task override that is read correctly at the action site but rewritten en route**. The override (`enabledWorkflowSteps`) was consulted exactly where the action runs (the graph executor), but the value was mutated in the **resolution path** before it got there, because two id namespaces overlap: graph-native optional-group **node ids** and legacy **`WorkflowStep` template ids**. The materializer is meaningful only for the retired declaration/`workflow-step`-seam execution model; for a graph-native group it is pure harm. Marking group ids as pass-through keeps the key **identity-stable** from definition through every consumer, so the executor's `includes(node.id)` check matches. + +(Verified the related slim-projection trap does **not** apply: the executor reads `enabledWorkflowSteps` off the `TaskDetail` snapshot it is handed, not a column-narrowed SELECT, so the array is fully hydrated.) + +## Prevention + +- **When introducing a new identity/key that shares a namespace with an existing one, grep every reader AND every *transformer* of that key.** A silent remap in a resolver is as fatal as a missing read — the override "survives" but as the wrong value. Demand each consumer is either re-keyed or argued identity-stable. +- **Regression tests for namespace collisions must use a *colliding* value.** A test with a deliberately distinct id proves nothing about the collision; pick the id that actually overlaps the legacy namespace (here, a built-in template id reused as a node id). +- **Test the path the user actually takes, not just the layer under test.** The executor-level test bypassed the store resolver where the bug lived; a create/update round-trip through the store would have caught it. Prefer at least one end-to-end seam test per per-task facet. +- **A facet that "works on seed/default but not on toggle" is the tell.** Asymmetry between the seed path (writes raw ids) and the user-enable path (runs the resolver) localizes the defect to the resolver. + +## Related Issues + +This is the **id-namespace-collision variant** of the per-task/per-entity override blast-radius class. Same disease (override invisible to the user, no error), different organ (key rewritten in resolution vs. not consulted at a trigger gate): + +- [Per-task auto-merge override ignored by trigger-layer gates](../logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md) — sibling: override dead from the user's perspective; theirs is a missed trigger gate, ours is a resolution-path key remap. Its "consult the override everywhere between definition and action" rule covers this case too. +- [Per-entity execution-principal override: the full blast-radius checklist](../architecture-patterns/per-entity-execution-principal-override-blast-radius.md) — the generalizing checklist; closest prior art is its "validate composite node ids against the graph, never round-trip them" example. This bug is a new bullet for that checklist. +- [Workflow-native execution through runtime primitives](../architecture-patterns/workflow-native-runtime-primitives.md) — context: the legacy-`WorkflowStep`-row vs. graph-node two-control-planes tension this collision exploits. From e4a810e9b43cabef117543a06e7dd2dc7f0ad440 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 20:47:31 -0700 Subject: [PATCH 016/265] fix(FN-6880): address PR review feedback (#1712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject failure-condition edges inside optional-group templates (the single-pass walk surfaces template failures as the group's outcome, so an internal failure edge was silently dead) — Greptile P2. - flowToIr: a container/group node (foreach/loop/optional-group) is v2-only — its presence now forces v2 serialization (an inserted optional-group on a plain workflow no longer serializes as invalid v1) — CodeRabbit. - Disabled optional-group bypass routes a plain success with no distinguishing value, so an outcome:* edge can't preempt success routing (inertness) — CodeRabbit. - Downgrade heuristic: presence of a legacy optionalSteps key (incl. []) keeps v2. - Resolver docblock corrected (config-less groups resolve to a fallback entry). - Strengthen tests: assert both inserted groups + v2 round-trip; failure-edge rejection case. - Changeset: bump to major (removed exported WorkflowOptionalStep type). - Plan: record U7a as delivered in this cohort; only the workflow-step seam infra removal remains deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retire-optional-steps-declaration.md | 4 ++- ...-workflow-optional-group-subgraphs-plan.md | 26 ++++++++++--------- .../workflow-ir-optional-group.test.ts | 7 +++++ packages/core/src/workflow-ir.ts | 23 +++++++++++++--- packages/core/src/workflow-optional-steps.ts | 6 +++-- .../__tests__/workflow-flow-mapping.test.ts | 20 +++++++++----- .../app/components/workflow-flow-mapping.ts | 7 ++++- ...-coding-browser-verification-group.test.ts | 5 ++++ .../engine/src/workflow-graph-executor.ts | 10 ++++--- 9 files changed, 78 insertions(+), 30 deletions(-) diff --git a/.changeset/retire-optional-steps-declaration.md b/.changeset/retire-optional-steps-declaration.md index 72baaa6311..3ce5035347 100644 --- a/.changeset/retire-optional-steps-declaration.md +++ b/.changeset/retire-optional-steps-declaration.md @@ -1,5 +1,7 @@ --- -"@runfusion/fusion": patch +"@runfusion/fusion": major --- +**Breaking:** the `WorkflowOptionalStep` type, previously exported from `@runfusion/fusion`, is removed — any consumer importing it must migrate to `optional-group` nodes / `ResolvedWorkflowOptionalStep`. + Retire the legacy optional-step DECLARATION model now that optional steps are graph-native `optional-group` nodes. Remove the `WorkflowOptionalStep` type and the `WorkflowIrV2.optionalSteps` IR field, drop the workflow node editor's optional-step declaration authoring panel (sidebar section, mobile tab, and collapse state), and stop threading an `optionalSteps` array through `flowToIr`/`serializeGraph`. A legacy persisted `optionalSteps` key on an old v2 workflow row is now tolerated (ignored, not validated) at parse so old rows still load as v2, and the rollback-downgrade heuristic still treats such a row as v2. The per-task optional-step toggle surfaces are unchanged — they continue to list and toggle optional steps sourced from `optional-group` nodes via `resolveWorkflowOptionalSteps` (`ResolvedWorkflowOptionalStep`). diff --git a/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md b/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md index e5e89f55bd..03d9c3650d 100644 --- a/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md +++ b/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md @@ -539,19 +539,21 @@ surfaces are enumerated: `resolveWorkflowOptionalSteps`'s output shape (source re-pointed in U3). - Step→node projection (`workflow-steps-to-ir.ts`) reused to project add-ons (U5). -### Deferred to Follow-Up Work -- **Full legacy-path retirement (U7) — deferred after execution-time scope discovery.** U1–U6 shipped and - the new model is the live path (built-ins migrated, resolver + executor on optional-group nodes). The - legacy declaration surface is now inert but **not removed**, because U7 turned out far larger than scoped: - (a) `workflow-step` is a shared `WorkflowSeam` union member woven through ~9 engine runtime files +### Delivered cohort (this PR) vs. Deferred +This PR delivers **U1–U6 plus U7a** (10 commits). U7a retired the legacy declaration *model*: the core +`WorkflowOptionalStep` type + `WorkflowIrV2.optionalSteps` field + `validateOptionalSteps`, and the editor's +declaration **authoring** surface (`WorkflowOptionalStepsPanel`, `optionalStepsOf`, the `flowToIr` +`optionalSteps` threading). A code-review pass also fixed a P1 (the optional-group toggle-id collision in +enable resolution) — captured in the commit history and in +`docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md`. The per-task toggle +surfaces (`WorkflowOptionalStepsDropdown`, inline card, modal, Workflow tab) stayed — they consume the +distinct `ResolvedWorkflowOptionalStep`. + +- **Deferred: the `workflow-step` seam infrastructure removal.** What remains of "full U7" is excising the + `workflow-step` seam itself — a shared `WorkflowSeam` union member woven through ~9 engine runtime files (`runtime-primitives`, `step-session-executor`, `workflow-node-handlers`, `active-session-registry`, - `workflow-graph-task-runner`, `executor.runWorkflowSteps`, the compiler seam-anchor), not an - optional-steps-only node — excising it is its own refactor; and (b) the dashboard still carries the prior - declaration **authoring** surface (`WorkflowOptionalStepsPanel`/`WorkflowOptionalStepsDropdown`, the - `flowToIr` `optionalSteps` threading, `optionalStepsOf`) across ~10 files. Removing the core - `WorkflowOptionalStep` type without that dashboard cleanup breaks the build. Retire both surfaces in a - focused follow-up; until then the `WorkflowOptionalStepsPanel` authors declarations the resolver no longer - reads (a known dead-authoring UI to remove with it). + `workflow-graph-task-runner`, `executor.runWorkflowSteps`, the compiler seam-anchor). It is now orphaned + (no built-in graph reaches it) but inert; excising it is its own focused refactor with its own blast radius. - **Nested/conditional groups** (an optional-group inside a split/foreach, or gated by a workflow field rather than the per-task toggle) — single-level, per-task-toggle only for now. - **Plugin-contributed add-ons as optional-group presets** beyond inserting them as flat nodes. diff --git a/packages/core/src/__tests__/workflow-ir-optional-group.test.ts b/packages/core/src/__tests__/workflow-ir-optional-group.test.ts index 36590df04e..88806e3383 100644 --- a/packages/core/src/__tests__/workflow-ir-optional-group.test.ts +++ b/packages/core/src/__tests__/workflow-ir-optional-group.test.ts @@ -94,6 +94,13 @@ describe("optional-group validation", () => { expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain rework edges/); }); + it("rejects failure-condition edges inside the template (single-pass bails before routing them)", () => { + const template = groupTemplate(); + // A parallel failure edge that the single-pass walk would silently never take. + template.edges.push({ from: "verify", to: "report", condition: "failure" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain failure-condition edges/); + }); + it("rejects nested loop/foreach/optional-group regions", () => { const template = groupTemplate(); template.nodes.push({ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 93b56928a3..75d96e8968 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -668,6 +668,18 @@ function validateOptionalGroup( if (isReworkEdge(edge)) { throw new WorkflowIrError(`optional-group node '${node.id}' template may not contain rework edges`); } + // FNXC:WorkflowOptionalGroup 2026-06-22-09:00: the single-pass walk + // (runOptionalGroup) surfaces a template-node failure as the GROUP's outcome + // and bails before evaluating that node's edges — so a `failure`-condition + // edge inside the template would silently never execute. Reject it as a typed + // authoring error; failure routing belongs on the group's OUTER edges. + // (Code review: Greptile P2.) + if (edge.condition === "failure") { + throw new WorkflowIrError( + `optional-group node '${node.id}' template may not contain failure-condition edges — ` + + `a template-node failure surfaces as the group's outcome and routes the group's outer failure edge`, + ); + } } const incoming = new Map(); @@ -1466,16 +1478,19 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { // Step-inversion declarations (artifacts/fields), workflow settings (U1), and // any legacy persisted optional-step declarations are v2-only features. - // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00 (updated 2026-06-22-09:00): // `optionalSteps` is no longer a typed IR field (retired declaration model), but // a legacy v2 row may still carry the key. Read it via an untyped cast so such a - // row is still treated as v2 (kept on v2, never silently downgraded). - const legacyOptionalSteps = (ir as { optionalSteps?: unknown[] }).optionalSteps; + // row is still treated as v2 (kept on v2, never silently downgraded). The mere + // PRESENCE of the key — including an empty `[]` — is the v2 signal: an author + // who wrote the key intended v2, and downgrading an `optionalSteps: []` row to + // v1 would still mutate its persisted shape. (Code review: CodeRabbit.) + const legacyOptionalSteps = (ir as { optionalSteps?: unknown }).optionalSteps; if ( (ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0) || (ir.settings && ir.settings.length > 0) || - (Array.isArray(legacyOptionalSteps) && legacyOptionalSteps.length > 0) + legacyOptionalSteps !== undefined ) { return ir; } diff --git a/packages/core/src/workflow-optional-steps.ts b/packages/core/src/workflow-optional-steps.ts index 4cfef149e8..f3fea182cd 100644 --- a/packages/core/src/workflow-optional-steps.ts +++ b/packages/core/src/workflow-optional-steps.ts @@ -35,8 +35,10 @@ function isOptionalGroupNode( * * Source: v2 `ir.nodes` where `kind === "optional-group"` (NOT the legacy * `ir.optionalSteps` declaration). Non-v2 graphs and graphs without any - * optional-group node resolve to `[]`. Malformed group configs are skipped so a - * stale/partial node never renders a blank UI row or breaks workflow loading. + * optional-group node resolve to `[]`. A group with a missing or partial config + * still resolves to a usable entry — `name` falls back to the node id and + * `defaultOn` to false — rather than being dropped, so a stale/partial node never + * silently disappears from the toggle UI or breaks workflow loading. * * `pluginTemplates` is accepted for signature compatibility with the prior * template-backed resolver; group nodes are self-describing, so it is currently diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index d214a4da50..ab16bb6cab 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -1460,13 +1460,21 @@ describe("insertFragment", () => { const allIds = second.nodes.map((n) => n.id); expect(new Set(allIds).size).toBe(allIds.length); - // Round-trip: the group carries defaultOn + a single-node template. + // Round-trip: BOTH inserted groups carry defaultOn + a single-node template, + // so a regression that breaks the second insert can't pass on the first. const { ir: out } = flowToIr("wf", second.nodes, second.edges); - const og = out.nodes.find((n) => n.kind === "optional-group")!; - expect(og.config?.defaultOn).toBe(true); - const template = (og.config as { template?: { nodes: { config?: Record }[] } }).template; - expect(template?.nodes).toHaveLength(1); - expect(template?.nodes[0].config?.name).toBe("Security Audit"); + // An optional-group is a v2-only kind: its presence forces v2 serialization + // even with no columns/fields/settings, or it would serialize as v1 and fail + // parse. (Code review: CodeRabbit.) + expect(out.version).toBe("v2"); + const ogs = out.nodes.filter((n) => n.kind === "optional-group"); + expect(ogs).toHaveLength(2); + for (const og of ogs) { + expect(og.config?.defaultOn).toBe(true); + const template = (og.config as { template?: { nodes: { config?: Record }[] } }).template; + expect(template?.nodes).toHaveLength(1); + expect(template?.nodes[0].config?.name).toBe("Security Audit"); + } }); }); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index 11c718114e..66b5b9e67d 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -479,8 +479,13 @@ export function flowToIr( // node/edge mapping. Fields and settings remain v2-only declarations: a workflow // with either but no custom columns still serializes as v2 (with the synthesized // default columns). Empty/absent → not a v2 signal (R6 byte-identity for legacy). + // FNXC:WorkflowOptionalGroup 2026-06-22-09:00: a container/group node + // (foreach/loop/optional-group) is a v2-ONLY kind — its presence must force v2, + // or an inserted optional-group on an otherwise-plain workflow would serialize + // as v1 and fail parse (validateOptionalGroup runs only on v2). (Code review: + // CodeRabbit — corroborated by the pre-merge correctness review's residual risk.) const v2 = - (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings; + (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || groupIds.size > 0; const layout: Record = {}; /** Project one flow node (top-level or template child) into an IR node. */ diff --git a/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts b/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts index 2420815b5c..b3500f4128 100644 --- a/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts +++ b/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts @@ -88,6 +88,11 @@ describe("builtin coding browser-verification optional-group (U6)", () => { expect(result.context[`node:${GROUP_ID}:outcome`]).toBe("failure"); expect(result.visitedNodeIds).toContain(INNER_STEP_VISITED_ID); + // The group's only two outgoing edges are `success → review` and + // `failure → end`; the inner-step failure routes the failure edge, so review + // is skipped. (`end` is a terminal node the executor does not record in + // visitedNodeIds, so the routing is asserted via the group's failure outcome + // above + review being unreachable here.) expect(result.visitedNodeIds).not.toContain("review"); }); }); diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 9dc13c158f..61bf21f3c7 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -506,10 +506,12 @@ export class WorkflowGraphExecutor { // sees "success" rather than undefined — disabled is fully inert, not // just edge-routing-inert. context[`node:${node.id}:outcome`] = "success"; - return await traverseChildren(node, { - outcome: "success", - value: "optional-group-bypassed", - }); + // FNXC:WorkflowOptionalGroup 2026-06-22-09:00: route a disabled group + // as a plain success with NO distinguishing value — a non-empty value + // could let an `outcome:*` edge preempt the success edge in + // traverseChildren, breaking the "disabled == node absent" inertness + // invariant. (Code review: CodeRabbit.) + return await traverseChildren(node, { outcome: "success" }); } const groupResult = await runOptionalGroup(node, { context, From 09bd01baf0edda7cd8bb3a45d8f07606a60a78e9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:07:20 -0700 Subject: [PATCH 017/265] =?UTF-8?q?feat(workspace):=20Phase=20A=20U1=20?= =?UTF-8?q?=E2=80=94=20executor=20session=20scoping=20for=20workspace=20mo?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In workspace mode (loadWorkspaceConfig present), the executor now skips the root acquireTaskWorktree({rootDir}) and every intervening rootDir git preflight (base-commit capture, contamination, identity-guard, verifyWorktreeInvariants), runs the agent session rooted at the non-git workspace root (cwd=rootDir, browse-only; task.worktree never set), and tracks activeWorktrees as a per-task Set. scopePromptToWorktree is a no-op in workspace mode. The non-workspace path is unchanged (every change branches on this.workspaceConfig; a single-repo task holds a one-element Set). Converted every activeWorktrees consumer to membership semantics (feasibility- verified list): findActiveWorktreeOwner, hasActiveWorktreeBinding, the FN-6736 phantom-binding reclaim, listWorktreeHolders (flat-maps a Set into N holder rows — verified the FN-6782 reaper keys off taskId only, so slot accounting is unaffected), the conflict-set iteration, the three deleteActive* unregister resolvers (loop every path), cleanup, getWorktreePath (undefined for a multi-worktree workspace task), and the verifyWorktreeInvariants singular resolution (gated off in workspace mode — per-repo verify returns in Phase B). Rewrote executor-workspace.test.ts from vi.mock-the-subject to a real two-repo git fixture harness (_workspace-fixture.ts, shared with later units), 13 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ace-phase-a-u1-executor-session-scoping.md | 5 + .../src/__tests__/_workspace-fixture.ts | 66 ++++ .../executor-paused-abort-todo-benign.test.ts | 12 +- .../src/__tests__/executor-recovery.test.ts | 8 +- .../executor-workspace-session-cwd.test.ts | 120 ++++++++ .../src/__tests__/executor-workspace.test.ts | 285 +++++++++++++----- .../executor-worktree-conflict.test.ts | 2 +- .../active-worktree-removal-liveness.test.ts | 8 +- ...ompletion-stale-self-owned-binding.test.ts | 12 +- ...self-owned-active-session-recovery.test.ts | 2 +- .../stale-self-owned-session-registry.test.ts | 2 +- packages/engine/src/executor.ts | 137 ++++++--- 12 files changed, 524 insertions(+), 135 deletions(-) create mode 100644 .changeset/workspace-phase-a-u1-executor-session-scoping.md create mode 100644 packages/engine/src/__tests__/_workspace-fixture.ts create mode 100644 packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts diff --git a/.changeset/workspace-phase-a-u1-executor-session-scoping.md b/.changeset/workspace-phase-a-u1-executor-session-scoping.md new file mode 100644 index 0000000000..6fc9911756 --- /dev/null +++ b/.changeset/workspace-phase-a-u1-executor-session-scoping.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase A (U1): executor session scoping. In workspace mode the executor now skips the root worktree acquisition and every rootDir git preflight (base-commit capture, contamination, worktree-liveness), runs the agent session rooted at the browse-only workspace root, and tracks acquired sub-repo worktrees as a per-task set. Single-repo tasks are unchanged (one-element set, byte-for-byte preflight parity). diff --git a/packages/engine/src/__tests__/_workspace-fixture.ts b/packages/engine/src/__tests__/_workspace-fixture.ts new file mode 100644 index 0000000000..5e94b78982 --- /dev/null +++ b/packages/engine/src/__tests__/_workspace-fixture.ts @@ -0,0 +1,66 @@ +/* +FNXC:Workspace 2026-06-21-12:00: +Shared REAL two-repo git fixture for workspace-mode engine tests (U1 + U2 + later phases). The foundation's executor-workspace test self-mocked the functions under test, which proves nothing; this harness instead builds genuine on-disk git repos under a NON-git workspace root so that any leaked rootDir git preflight actually fails. U2 and later units import `createWorkspaceFixture` directly — keep it dependency-light (only node:child_process + node:fs + saveWorkspaceConfig). + +A workspace root is a plain directory (NOT a git repo) containing N sub-repos. Each sub-repo is a real git repo with an initial commit on a default branch. `/.fusion/workspace.json` lists the sub-repo relative paths so `loadWorkspaceConfig(root)` returns a populated config — the exact signal `this.workspaceConfig` keys off in the executor. +*/ +import { execSync, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { saveWorkspaceConfig } from "@fusion/core"; + +export const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +/** Initialize a real git repo at `repoDir` with one commit on `defaultBranch`. */ +export function initRepoWithCommit(repoDir: string, defaultBranch = "main"): void { + mkdirSync(repoDir, { recursive: true }); + git(repoDir, `git init -b ${defaultBranch}`); + git(repoDir, 'git config user.email "test@example.com"'); + git(repoDir, 'git config user.name "Test"'); + writeFileSync(path.join(repoDir, "README.md"), `# ${path.basename(repoDir)}\n`, "utf-8"); + git(repoDir, "git add README.md"); + git(repoDir, "git commit -m 'init'"); +} + +export interface WorkspaceFixture { + /** Absolute path to the non-git workspace root. */ + rootDir: string; + /** Relative sub-repo paths (workspace.json `repos`). */ + repos: string[]; + /** Absolute path to a sub-repo by relative name. */ + repoPath(rel: string): string; + /** Run a git command inside a sub-repo. */ + git(rel: string, command: string): string; + /** Remove all on-disk fixture state. */ + cleanup(): void; +} + +/** + * Create a real two-repo (by default) workspace fixture on disk. + * - `rootDir` is a plain non-git directory. + * - Each `repos[i]` is a real git repo with an initial commit. + * - `/.fusion/workspace.json` is written so loadWorkspaceConfig() resolves. + */ +export async function createWorkspaceFixture( + repos: string[] = ["repo-a", "repo-b"], + defaultBranch = "main", +): Promise { + const rootDir = mkdtempSync(path.join(os.tmpdir(), "fusion-workspace-")); + for (const rel of repos) { + initRepoWithCommit(path.join(rootDir, rel), defaultBranch); + } + await saveWorkspaceConfig(rootDir, { repos }); + + return { + rootDir, + repos, + repoPath: (rel: string) => path.join(rootDir, rel), + git: (rel: string, command: string) => git(path.join(rootDir, rel), command), + cleanup: () => rmSync(rootDir, { recursive: true, force: true }), + }; +} diff --git a/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts b/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts index 351ee343bf..a8237d0755 100644 --- a/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts +++ b/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts @@ -88,7 +88,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { // executor must retry the agent session in place rather than bouncing the // task through todo (and must not fire a failure notification). const { store, task, executor } = makeHarness({ column: "todo" }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi .spyOn(executor as any, "execute") .mockResolvedValue(undefined); @@ -137,7 +137,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { // and a retry scheduled); the task then changes state before the timer // fires, and the fire-time re-fetch must abort the dispatch. const { store, task, executor } = makeHarness({ column: "todo" }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); await invokeGraphFailure(executor, task); @@ -164,7 +164,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { status: "failed", error: "Workflow graph failure surfaced after paused engine abort during pause/resume", }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); await invokeGraphFailure(executor, task); @@ -197,7 +197,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { // pause that ended up in todo must stay parked-benign and wait for // explicit resume — auto-resuming it would override the operator's intent. const { store, task, executor } = makeHarness(overrides, provenance); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); await invokeGraphFailure(executor, task); @@ -217,7 +217,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { column: "todo", graphResumeRetryCount: 2, }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi .spyOn(executor as any, "execute") .mockResolvedValue(undefined); @@ -246,7 +246,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { status: "failed", error: "Workflow graph failure surfaced after paused engine abort during pause/resume", }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); await invokeGraphFailure(executor, task); diff --git a/packages/engine/src/__tests__/executor-recovery.test.ts b/packages/engine/src/__tests__/executor-recovery.test.ts index ae9e39343b..c6d0e8bf35 100644 --- a/packages/engine/src/__tests__/executor-recovery.test.ts +++ b/packages/engine/src/__tests__/executor-recovery.test.ts @@ -605,7 +605,7 @@ describe("TaskExecutor bounded recovery retries", () => { (executor as any).executing.add(taskId); executingTaskLock.tryClaim(taskId); - (executor as any).activeWorktrees.set(taskId, worktreePath); + (executor as any).addActiveWorktree(taskId, worktreePath); (executor as any).activeSessions.set(taskId, { session }); (executor as any).activeStepExecutors.set(taskId, stepExecutor); (executor as any).activeWorkflowStepSessions.set(taskId, workflowSession); @@ -686,7 +686,7 @@ describe("TaskExecutor bounded recovery retries", () => { }); (executor as any).executing.add("FN-001"); executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001"); (executor as any).activeSessions.set("FN-001", { session }); executor.markStuckAborted("FN-001", true); @@ -750,7 +750,7 @@ describe("TaskExecutor bounded recovery retries", () => { vi.mocked(removeWorktree).mockRejectedValue(new Error("worktree busy")); (executor as any).executing.add("FN-001"); executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001"); (executor as any).activeSessions.set("FN-001", { session }); executor.markStuckAborted("FN-001", true); @@ -797,7 +797,7 @@ describe("TaskExecutor bounded recovery retries", () => { vi.mocked(removeWorktree).mockResolvedValue(undefined as any); (executor as any).executing.add("FN-001"); executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001"); (executor as any).activeSessions.set("FN-001", { session }); executor.markStuckAborted("FN-001", true); diff --git a/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts b/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts new file mode 100644 index 0000000000..19d101f8dc --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts @@ -0,0 +1,120 @@ +/* +FNXC:Workspace 2026-06-21-12:00: +U1 session-cwd scenarios that require driving the real TaskExecutor.execute() to the agent-session boundary. Uses the shared executor-test-helpers harness — it mocks the AI/session/git/fs seams (NOT the workspace gating, NOT acquireTaskWorktree), so setting `(executor as any).workspaceConfig` exercises the genuine KTD1 gate: root acquisition is skipped, and every agent session (initial + retry) is created with `cwd === rootDir` (browse-only workspace root). The non-workspace path is the regression control (cwd === the acquired worktree path). +*/ +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import "./executor-test-helpers.js"; +import { TaskExecutor } from "../executor.js"; +import { acquireTaskWorktree } from "../worktree-acquisition.js"; +import type { WorkspaceConfig } from "@fusion/core"; +import { + createMockStore, + mockedCreateFnAgent, + mockedExecSync, + resetExecutorMocks, +} from "./executor-test-helpers.js"; + +vi.mock("../worktree-acquisition.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, acquireTaskWorktree: vi.fn(actual.acquireTaskWorktree) }; +}); + +const mockedAcquireTaskWorktree = vi.mocked(acquireTaskWorktree); + +const ROOT = "/tmp/workspace-root"; + +function inProgressTask(overrides: Record = {}) { + return { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as any; +} + +describe("U1 KTD1 — session cwd is the browse-only workspace root", () => { + beforeEach(() => { + resetExecutorMocks(); + // Make any accidental git invocation observable: empty stdout keeps real-git + // helpers from throwing, but acquireTaskWorktree assertions catch a leak. + mockedExecSync.mockReturnValue(""); + }); + afterEach(() => vi.restoreAllMocks()); + + it("skips root acquireTaskWorktree and creates every session (initial + retry) with cwd === rootDir", async () => { + const store = createMockStore(); + const mockPrompt = vi.fn().mockResolvedValue(undefined); // no fn_task_done → drives retries too + mockedCreateFnAgent.mockResolvedValue({ + session: { prompt: mockPrompt, dispose: vi.fn() }, + sessionFile: "/tmp/sessions/ws.jsonl", + } as any); + + const executor = new TaskExecutor(store, ROOT); + // Drive the genuine workspace gate (loadWorkspaceConfig is covered elsewhere). + (executor as any).workspaceConfig = { repos: ["repo-a", "repo-b"] } as WorkspaceConfig; + + await executor.execute(inProgressTask({ worktree: null })); + + // KTD1: the non-git root is never acquired as a worktree. + expect(mockedAcquireTaskWorktree).not.toHaveBeenCalled(); + + // Every agent session (initial + the retries fired because fn_task_done was + // never called) is rooted at the workspace root. + expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2); + for (const call of mockedCreateFnAgent.mock.calls) { + expect((call[0] as any).cwd).toBe(ROOT); + } + + // task.worktree is never set in workspace mode. + const worktreeWrites = (store.updateTask as any).mock.calls.filter( + (c: any[]) => c[1] && Object.prototype.hasOwnProperty.call(c[1], "worktree") && c[1].worktree, + ); + expect(worktreeWrites).toHaveLength(0); + }); +}); + +describe("U1 regression — non-workspace task acquires a worktree and roots the session there", () => { + beforeEach(() => { + resetExecutorMocks(); + mockedExecSync.mockReturnValue(""); + }); + afterEach(() => vi.restoreAllMocks()); + + it("calls acquireTaskWorktree and creates the session with cwd === the acquired worktree path", async () => { + const store = createMockStore(); + const ACQUIRED = "/tmp/test/.worktrees/swift-falcon"; + mockedAcquireTaskWorktree.mockResolvedValue({ + worktreePath: ACQUIRED, + branch: "fusion/fn-001", + source: "fresh", + hydrated: false, + isResume: false, + }); + + const mockPrompt = vi.fn().mockResolvedValue(undefined); + mockedCreateFnAgent.mockResolvedValue({ + session: { prompt: mockPrompt, dispose: vi.fn() }, + sessionFile: "/tmp/sessions/ns.jsonl", + } as any); + + const executor = new TaskExecutor(store, "/tmp/test"); + // No workspaceConfig → single-repo path. Pin the lazy-load guard so the real + // loader is never consulted (it would return null for /tmp/test anyway). + (executor as any).workspaceConfig = null; + + await executor.execute(inProgressTask({ worktree: null })); + + expect(mockedAcquireTaskWorktree).toHaveBeenCalledTimes(1); + expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of mockedCreateFnAgent.mock.calls) { + expect((call[0] as any).cwd).toBe(ACQUIRED); + } + }); +}); diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts index 1916b52367..330915e966 100644 --- a/packages/engine/src/__tests__/executor-workspace.test.ts +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -1,89 +1,220 @@ -// @ts-nocheck -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { loadWorkspaceConfig } from "@fusion/core"; -import { acquireWorkspaceRepoWorktree } from "../worktree-acquisition.js"; +/* +FNXC:Workspace 2026-06-21-12:00: +U1 executor session-scoping tests. REWRITTEN from the foundation's self-mocking version (which vi.mock'd the very functions under test and proved nothing). These tests use a REAL two-repo git fixture (`createWorkspaceFixture`) under a NON-git workspace root, so a leaked rootDir git preflight would actually fail. They drive the real TaskExecutor methods that U1 changed: the activeWorktrees Set conversion + every enumerated consumer (KTD2), the preflight gate + browse-only-root scoping (KTD1), and the synthetic-acquisition cwd. -vi.mock("@fusion/core", async (importOriginal) => { - const actual = await importOriginal(); +Seam choice (FN-5048): `(executor as any).workspaceConfig` is set directly to drive the gating with real git — loadWorkspaceConfig is covered by its own unit and is not the subject here. No mock-the-world child_process/fs shell. +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { loadWorkspaceConfig, type Task, type TaskStore, type WorkspaceConfig } from "@fusion/core"; +import { TaskExecutor, buildExecutionPrompt } from "../executor.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +function createStore(overrides: Partial> = {}): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + on: emitter.on.bind(emitter), + ...overrides, + }) as unknown as TaskStore & EventEmitter; +} + +function makeTask(id = "FN-WS-1", overrides: Partial = {}): Task { return { - ...actual, - loadWorkspaceConfig: vi.fn(), - }; -}); + id, + title: "Workspace task", + description: "", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} -vi.mock("../worktree-acquisition.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - acquireWorkspaceRepoWorktree: vi.fn(), - }; -}); +const repoAPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-a")}/.worktrees/fn-ws-1`; +const repoBPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-b")}/.worktrees/fn-ws-1`; -const mockedLoadWorkspaceConfig = vi.mocked(loadWorkspaceConfig); -const mockedAcquireWorkspaceRepoWorktree = vi.mocked(acquireWorkspaceRepoWorktree); +describeIfGit("workspace fixture", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); -const MOCK_WORKSPACE_CONFIG = { - repos: ["wolf-server", "wolf-community-frontend-1"], -}; - -describe("acquireWorkspaceRepoWorktree", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns alreadyAcquired=false for a fresh repo", async () => { - mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({ - worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", - branch: "fusion/fn-001", - alreadyAcquired: false, - }); - - const result = await acquireWorkspaceRepoWorktree({ - repoRelPath: "wolf-server", - workspaceRootDir: "/workspace", - task: { id: "FN-001", workspaceWorktrees: undefined } as never, - store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never, - settings: {}, - }); - - expect(result.alreadyAcquired).toBe(false); - expect(result.worktreePath).toContain("wolf-server"); - }); - - it("returns alreadyAcquired=true when worktree already acquired", async () => { - mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({ - worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", - branch: "fusion/fn-001", - alreadyAcquired: true, - }); - - const result = await acquireWorkspaceRepoWorktree({ - repoRelPath: "wolf-server", - workspaceRootDir: "/workspace", - task: { - id: "FN-001", - workspaceWorktrees: { - "wolf-server": { worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", branch: "fusion/fn-001" }, - }, - } as never, - store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never, - settings: {}, - }); - - expect(result.alreadyAcquired).toBe(true); + it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => { + fx = await createWorkspaceFixture(); + // Root is NOT a git repo. + expect(() => fx.git("..", "git rev-parse --git-dir")).toThrow(); + // Each sub-repo is a real git repo with a commit on main. + expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main"); + expect(fx.git("repo-b", "git rev-list --count HEAD")).toBe("1"); + // loadWorkspaceConfig resolves the on-disk config the executor keys off. + const config = await loadWorkspaceConfig(fx.rootDir); + expect(config?.repos).toEqual(["repo-a", "repo-b"]); }); }); -describe("workspace config", () => { - it("loadWorkspaceConfig returns null for non-workspace", async () => { - mockedLoadWorkspaceConfig.mockResolvedValueOnce(null); - const config = await loadWorkspaceConfig("/some/single-repo"); - expect(config).toBeNull(); +describeIfGit("U1 KTD2 — activeWorktrees Set + every enumerated consumer", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + function workspaceExecutor() { + fx ??= undefined as never; + const store = createStore(); + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + return executor; + } + + it("a workspace task holding TWO sub-repo paths is found by membership, not equality", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(); + const pA = repoAPath(fx); + const pB = repoBPath(fx); + (executor as any).addActiveWorktree("FN-WS-1", pA); + (executor as any).addActiveWorktree("FN-WS-1", pB); + + // hasActiveWorktreeBinding: both held paths match; an unheld path does not. + expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", pA)).toBe(true); + expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", pB)).toBe(true); + expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", "/nope")).toBe(false); + + // findActiveWorktreeOwner: another task asking about either held path finds FN-WS-1. + await expect((executor as any).findActiveWorktreeOwner(pA, "FN-OTHER")).resolves.toBe("FN-WS-1"); + await expect((executor as any).findActiveWorktreeOwner(pB, "FN-OTHER")).resolves.toBe("FN-WS-1"); + // The owner itself is excluded. + await expect((executor as any).findActiveWorktreeOwner(pA, "FN-WS-1")).resolves.toBeNull(); }); - it("loadWorkspaceConfig returns config for workspace", async () => { - mockedLoadWorkspaceConfig.mockResolvedValueOnce(MOCK_WORKSPACE_CONFIG); - const config = await loadWorkspaceConfig("/some/workspace"); - expect(config?.repos).toEqual(["wolf-server", "wolf-community-frontend-1"]); + it("listWorktreeHolders flat-maps the Set into N holder rows for one task", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(); + const pA = repoAPath(fx); + const pB = repoBPath(fx); + (executor as any).addActiveWorktree("FN-WS-1", pA); + (executor as any).addActiveWorktree("FN-WS-1", pB); + + const holders = executor.listWorktreeHolders(); + expect(holders).toHaveLength(2); + expect(holders).toContainEqual({ taskId: "FN-WS-1", worktreePath: pA }); + expect(holders).toContainEqual({ taskId: "FN-WS-1", worktreePath: pB }); + }); + + it("shouldGenerateNewWorktreeName iterates the Set (conflict membership)", async () => { + fx = await createWorkspaceFixture(); + const store = createStore({ listTasks: vi.fn().mockResolvedValue([]) }); + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + const pA = repoAPath(fx); + (executor as any).addActiveWorktree("FN-HOLDER", pA); + + // A different task contending for FN-HOLDER's path must be told to generate a new name. + await expect((executor as any).shouldGenerateNewWorktreeName(pA, "FN-WS-1")).resolves.toBe(true); + // The holder asking about its own path is not a conflict (excluded), and the + // DB liveness fallback returns no other user. + await expect((executor as any).shouldGenerateNewWorktreeName(pA, "FN-HOLDER")).resolves.toBe(false); + }); + + it("getWorktreePath returns undefined for a multi-worktree workspace task (Set-collapse contract)", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(); + (executor as any).addActiveWorktree("FN-WS-1", repoAPath(fx)); + (executor as any).addActiveWorktree("FN-WS-1", repoBPath(fx)); + expect(executor.getWorktreePath("FN-WS-1")).toBeUndefined(); + }); + + it("cleanup drops in-memory tracking in workspace mode but never removes the root", async () => { + fx = await createWorkspaceFixture(); + const removeSpy = vi.fn(); + const executor = workspaceExecutor(); + (executor as any).removeOwnWorktreeWithReconcile = removeSpy; + (executor as any).addActiveWorktree("FN-WS-1", repoAPath(fx)); + (executor as any).addActiveWorktree("FN-WS-1", repoBPath(fx)); + + await executor.cleanup("FN-WS-1"); + + expect(executor.getWorktreePath("FN-WS-1")).toBeUndefined(); + expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false); + // The browse-only root must never be torn down as if it were a worktree. + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it("clearPhantomExecutorBinding (FN-6736) unregisters every held path, not one", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(); + const pA = repoAPath(fx); + const pB = repoBPath(fx); + (executor as any).addActiveWorktree("FN-WS-1", pA); + (executor as any).addActiveWorktree("FN-WS-1", pB); + + const ok = (executor as any).clearPhantomExecutorBinding("FN-WS-1"); + expect(ok).toBe(true); + expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false); + }); +}); + +describeIfGit("U1 KTD2 — non-workspace task is a one-element Set (regression: unchanged)", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("getWorktreePath returns the sole path; listWorktreeHolders emits exactly one row", async () => { + fx = await createWorkspaceFixture(); + const store = createStore(); + const executor = new TaskExecutor(store, fx.repoPath("repo-a")); // single-repo root + // No workspaceConfig set → single-repo mode. + const wt = `${fx.repoPath("repo-a")}/.worktrees/fn-001`; + (executor as any).addActiveWorktree("FN-001", wt); + + expect(executor.getWorktreePath("FN-001")).toBe(wt); + expect(executor.listWorktreeHolders()).toEqual([{ taskId: "FN-001", worktreePath: wt }]); + expect((executor as any).hasActiveWorktreeBinding("FN-001", wt)).toBe(true); + }); +}); + +describeIfGit("U1 KTD1 — verifyWorktreeInvariants gated off in workspace mode", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("returns ok for a zero-acquire workspace task (no task.worktree) so fn_task_done does not requeue", async () => { + fx = await createWorkspaceFixture(); + const store = createStore(); + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + + // A workspace task that acquired ZERO sub-repos has no task.worktree and no + // tracked paths. The singular invariant would otherwise refuse on + // "missing task.worktree"; in workspace mode it is gated OFF. + const result = await (executor as any).verifyWorktreeInvariants(makeTask("FN-WS-1", { worktree: undefined })); + expect(result).toEqual({ ok: true }); + }); + + it("non-workspace task with no worktree still fails the invariant (regression: gate is workspace-only)", async () => { + fx = await createWorkspaceFixture(); + const store = createStore(); + const executor = new TaskExecutor(store, fx.repoPath("repo-a")); + // No workspaceConfig. + const result = await (executor as any).verifyWorktreeInvariants(makeTask("FN-001", { worktree: undefined })); + expect(result.ok).toBe(false); + }); +}); + +describeIfGit("U1 KTD1 — scopePromptToWorktree / buildExecutionPrompt no-op in workspace mode", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("does not rewrite root-anchored paths when a workspace config is present", async () => { + fx = await createWorkspaceFixture(); + const task = makeTask("FN-WS-1", { prompt: `Edit ${fx.rootDir}/repo-a/src/index.ts and commit.` }); + const config: WorkspaceConfig = { repos: fx.repos }; + // worktreePath === rootDir in workspace mode; the prompt must be returned verbatim. + const prompt = buildExecutionPrompt(task as any, fx.rootDir, { autoMerge: false } as any, fx.rootDir, undefined, undefined, config); + expect(prompt).toContain(`${fx.rootDir}/repo-a/src/index.ts`); + // The workspace repo list is appended (foundation behavior). + expect(prompt).toContain("repo-a"); }); }); diff --git a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts index c2714fda0b..adbc691c86 100644 --- a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts +++ b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts @@ -39,7 +39,7 @@ describe("FN-4973: executor worktree conflict cleanup", () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); store.listTasks.mockResolvedValue([]); - (executor as any).activeWorktrees.set("FN-4973", CONFLICT_PATH); + (executor as any).addActiveWorktree("FN-4973", CONFLICT_PATH); activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" }); vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( diff --git a/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts b/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts index 4889abc25d..d35c55ff7f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts @@ -58,7 +58,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { it("returns the owner taskId when activeWorktrees has another task using the path", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set("FN-OTHER", ACTIVE_PATH); + (executor as any).addActiveWorktree("FN-OTHER", ACTIVE_PATH); const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811"); expect(owner).toBe("FN-OTHER"); @@ -67,7 +67,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { it("returns null when activeWorktrees only has the requesting task at the path", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set("FN-4811", ACTIVE_PATH); + (executor as any).addActiveWorktree("FN-4811", ACTIVE_PATH); store.listTasks.mockResolvedValue([]); const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811"); @@ -125,7 +125,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { it("refuses removal when worktree is in activeWorktrees for another task", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set("FN-OTHER", ACTIVE_PATH); + (executor as any).addActiveWorktree("FN-OTHER", ACTIVE_PATH); store.listTasks.mockResolvedValue([]); const result = await (executor as any).cleanupConflictingWorktree( @@ -226,7 +226,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { it("returns 'sticky' without invoking inspection when conflict path is actively owned", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set("FN-OWNER", ACTIVE_PATH); + (executor as any).addActiveWorktree("FN-OWNER", ACTIVE_PATH); store.listTasks.mockResolvedValue([]); const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict"); diff --git a/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts b/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts index abe29b5b53..3717a9e355 100644 --- a/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts @@ -23,7 +23,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin it("reconciles stale same-task registry entry during cleanup()", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); (activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0; const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined); @@ -56,7 +56,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin it("preserves refusal for truly-live same-task bindings", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( new ActiveSessionWorktreeRemovalError({ @@ -82,7 +82,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin const store = createMockStore(); store.listTasks.mockResolvedValue([]); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set("FN-FOREIGN", PATH); + (executor as any).addActiveWorktree("FN-FOREIGN", PATH); activeSessionRegistry.registerPath(PATH, { taskId: "FN-FOREIGN", kind: "executor", ownerKey: "FN-FOREIGN" }); const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined); @@ -96,13 +96,13 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin it("is idempotent across repeated cleanup sweeps", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); (activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0; const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined); await executor.cleanup(TASK_ID); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); await executor.cleanup(TASK_ID); const clearedCalls = (store.logEntry as any).mock.calls.filter( @@ -120,7 +120,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined); diff --git a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts index f555c81d5f..9ee54c545f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts @@ -118,7 +118,7 @@ describe("FN-4973 reliability interactions: stale self-owned active-session reco const store = createMockStore(); store.listTasks.mockResolvedValue([]); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set(TASK_ID, CONFLICT_PATH); + (executor as any).addActiveWorktree(TASK_ID, CONFLICT_PATH); activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( diff --git a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts index 91e77b0ac4..869d6303bf 100644 --- a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts @@ -45,7 +45,7 @@ describe("FN-4976: stale self-owned activeSessionRegistry deadlock backstop", () it("FN-4976 does not clear foreign-owned activeSessionRegistry entry and FN-4811 refusal still fires", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set("FN-OTHER", PATH); + (executor as any).addActiveWorktree("FN-OTHER", PATH); store.listTasks.mockResolvedValue([]); activeSessionRegistry.registerPath(PATH, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" }); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index de96d138d0..1f7c91769b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -143,7 +143,7 @@ import { isContextLimitError } from "./context-limit-detector.js"; import { StepSessionExecutor } from "./step-session-executor.js"; import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js"; // FNXC:MergerUnification 2026-06-21-19:05: the foundation branch imported `acquireWorkspaceRepoWorktree` here but never used it in executor.ts (the agent tool wraps it via agent-tools.ts), which fails lint on the inherited base. Removed until master-plan U1 re-adds it together with its per-repo acquisition usage. -import { acquireTaskWorktree } from "./worktree-acquisition.js"; +import { acquireTaskWorktree, type AcquireTaskWorktreeResult } from "./worktree-acquisition.js"; import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; import { @@ -1465,7 +1465,28 @@ interface ActiveExecutorSessionState { } export class TaskExecutor { - private activeWorktrees = new Map(); + /* + FNXC:Workspace 2026-06-21-12:00: + activeWorktrees tracks the worktree paths a task currently holds for liveness/owner checks. In workspace mode a single task acquires N sub-repo worktrees (foundation `task.workspaceWorktrees`), so the value is a SET of paths, not one path. A non-workspace (single-repo) task holds a one-element set — every consumer is converted to membership semantics so the single-repo path is byte-for-byte unchanged (KTD2). Helpers below add/remove/iterate the set. + */ + private activeWorktrees = new Map>(); + + /** + * FNXC:Workspace 2026-06-21-12:00: Register a worktree path under a task's active set, creating the set on first add (KTD2). Single-repo tasks call this once → one-element set. + */ + private addActiveWorktree(taskId: string, worktreePath: string): void { + const set = this.activeWorktrees.get(taskId) ?? new Set(); + set.add(worktreePath); + this.activeWorktrees.set(taskId, set); + } + + /** + * FNXC:Workspace 2026-06-21-12:00: Read-only snapshot of every worktree path a task currently holds (KTD2). Empty when the task holds none. + */ + private getActiveWorktreePaths(taskId: string): string[] { + const set = this.activeWorktrees.get(taskId); + return set ? Array.from(set) : []; + } private executing = new Set(); /** Tasks currently being prepared for unpause resume, before execute() has registered them. */ private resumingUnpaused = new Set(); @@ -1583,9 +1604,10 @@ export class TaskExecutor { this.activeSessions.delete(taskId); // U5: drop the effective column-agent principal for this task's session. this.effectiveColumnAgentByTask.delete(taskId); - const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId); - if (resolvedWorktreePath) { - activeSessionRegistry.unregisterPath(resolvedWorktreePath); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — when no explicit path is given, unregister EVERY worktree path the task holds (a workspace task holds N sub-repo paths); single-repo tasks resolve a one-element set. + const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId); + for (const path of resolvedWorktreePaths) { + activeSessionRegistry.unregisterPath(path); } } @@ -1600,9 +1622,10 @@ export class TaskExecutor { this.activeStepExecutorSeenSteeringIds.delete(taskId); // U5: drop the effective column-agent principal for this task's step session. this.effectiveColumnAgentByTask.delete(taskId); - const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId); - if (resolvedWorktreePath) { - activeSessionRegistry.unregisterPath(resolvedWorktreePath); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one. + const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId); + for (const path of resolvedWorktreePaths) { + activeSessionRegistry.unregisterPath(path); } } @@ -1615,9 +1638,10 @@ export class TaskExecutor { private deleteActiveWorkflowStepSession(taskId: string, worktreePath?: string): void { this.activeWorkflowStepSessions.delete(taskId); this.activeWorkflowStepSessionSeenSteeringIds.delete(taskId); - const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId); - if (resolvedWorktreePath) { - activeSessionRegistry.unregisterPath(resolvedWorktreePath); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one. + const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId); + for (const path of resolvedWorktreePaths) { + activeSessionRegistry.unregisterPath(path); } } @@ -2053,7 +2077,8 @@ export class TaskExecutor { return false; } - const worktreePath = this.activeWorktrees.get(taskId); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — collect every worktree path the task holds (a workspace task holds N) before clearing the binding, so the registry sweep below unregisters all of them, not just one. + const heldWorktreePaths = this.getActiveWorktreePaths(taskId); this.activeWorktrees.delete(taskId); this.executing.delete(taskId); this.recoveringCompleted.delete(taskId); @@ -2063,8 +2088,8 @@ export class TaskExecutor { this.effectiveColumnAgentByTask.delete(taskId); const registeredPaths = new Set(activeSessionRegistry.pathsForTask(taskId)); - if (worktreePath) { - registeredPaths.add(worktreePath); + for (const path of heldWorktreePaths) { + registeredPaths.add(path); } for (const path of registeredPaths) { activeSessionRegistry.unregisterPath(path); @@ -7430,7 +7455,19 @@ export class TaskExecutor { const hadAssignedWorktree = Boolean(task.worktree); const taskCommandAbortController = new AbortController(); this.registerConfiguredCommandController(task.id, taskCommandAbortController); - const acquisition = await (async () => { + /* + FNXC:Workspace 2026-06-21-12:00: + KTD1 — in workspace mode `this.rootDir` is a NON-git parent. Acquiring a root worktree there fails. Skip root acquisition entirely and run the agent session rooted at the browse-only workspace root; the agent acquires per-sub-repo worktrees on demand via fn_acquire_repo_worktree. `task.worktree` stays unset. We synthesize a non-fresh, non-resume acquisition with an empty branch so the downstream env-injection/onStart bookkeeping runs unchanged while every rootDir git preflight (base capture, contamination, liveness) is gated off below. The non-workspace branch is byte-for-byte the original acquisition path. + */ + const acquisition: AcquireTaskWorktreeResult = this.workspaceConfig + ? { + worktreePath: this.rootDir, + branch: "", + source: "existing", + hydrated: true, + isResume: Boolean(task.sessionFile), + } + : await (async () => { try { return await acquireTaskWorktree({ task, @@ -7520,6 +7557,11 @@ export class TaskExecutor { } } + /* + FNXC:Workspace 2026-06-21-12:00: + KTD1 — every preflight below (base-commit capture, contamination check, worktree-liveness gate) runs git against `worktreePath`, which equals the non-git workspace root in workspace mode. They would all fail. Gate the whole block off in workspace mode; the per-repo equivalents return in Phase B (master U3) against each acquired sub-repo worktree. The non-workspace branch is unchanged. + */ + if (!this.workspaceConfig) { // Capture the base commit SHA for diff computation whenever a task // starts with a newly assigned worktree. if (!acquisition.isResume) { @@ -7664,8 +7706,10 @@ export class TaskExecutor { this.options.onError?.(task, new Error(failureMessage)); return; } + } // end !this.workspaceConfig preflight gate (FNXC:Workspace KTD1) - this.activeWorktrees.set(task.id, worktreePath); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — register the worktree path under the task's Set. In workspace mode `worktreePath` is the browse-only root; per-repo paths are added as the agent acquires them. Non-workspace tasks add exactly one path → a one-element set (unchanged liveness/owner semantics). + this.addActiveWorktree(task.id, worktreePath); executorLog.log(`${task.id}: worktree ready at ${worktreePath}`); const injected = await this.buildInjectedRuntimeEnv(task.id, worktreePath, acquisition.branch ?? undefined); @@ -10457,8 +10501,13 @@ export class TaskExecutor { options?: { noOpCompletion?: boolean; noOpCompletionReason?: string }, ): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> { const settings = await this.store.getSettings(); + // FNXC:Workspace 2026-06-21-12:00: KTD1/KTD2 — workspace tasks have no root worktree and no single `task.worktree`; the singular per-task invariant is meaningless against the non-git root. Phase B (master U3) iterates this check per sub-repo worktree. Until then it is gated OFF in workspace mode so fn_task_done (its only caller path) does not requeue a zero-acquire workspace task for "missing task.worktree". + if (this.workspaceConfig) { + return { ok: true }; + } const branchName = resolveTaskWorkingBranch(task); - const worktreePath = worktreePathOverride ?? task.worktree ?? this.activeWorktrees.get(task.id) ?? null; + // Non-workspace tasks hold a one-element set; fall back to its sole member to preserve the original singular resolution. + const worktreePath = worktreePathOverride ?? task.worktree ?? this.getActiveWorktreePaths(task.id)[0] ?? null; if (!worktreePath) { return { @@ -14440,9 +14489,9 @@ You have access to the file system to review changes.${verdictBlock}`; conflictPath: string, currentTaskId: string, ): Promise { - // Check if conflicting worktree is in our active set - for (const [taskId, worktreePath] of this.activeWorktrees) { - if (taskId !== currentTaskId && worktreePath === conflictPath) { + // FNXC:Workspace 2026-06-21-12:00: KTD2 — a task may hold N worktree paths; the conflict check is membership across the set, not equality on a single path. + for (const [taskId, worktreePaths] of this.activeWorktrees) { + if (taskId !== currentTaskId && worktreePaths.has(conflictPath)) { return true; } } @@ -14479,8 +14528,11 @@ You have access to the file system to review changes.${verdictBlock}`; */ listWorktreeHolders(): Array<{ taskId: string; worktreePath: string }> { const holders: Array<{ taskId: string; worktreePath: string }> = []; - for (const [taskId, worktreePath] of this.activeWorktrees) { - holders.push({ taskId, worktreePath }); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — flat-map each task's Set into one holder row per worktree path. A workspace task emits N rows; the FN-6782 reaper (self-healing.ts) and in-process-runtime adapter key purely off taskId (verified) and are idempotent across duplicate-task rows, so multi-row holders do not mis-count maxWorktrees slots. + for (const [taskId, worktreePaths] of this.activeWorktrees) { + for (const worktreePath of worktreePaths) { + holders.push({ taskId, worktreePath }); + } } return holders; } @@ -14489,8 +14541,9 @@ You have access to the file system to review changes.${verdictBlock}`; worktreePath: string, requestingTaskId: string, ): Promise { - for (const [taskId, path] of this.activeWorktrees) { - if (taskId !== requestingTaskId && path === worktreePath) { + // FNXC:Workspace 2026-06-21-12:00: KTD2 — membership across the task's worktree set (a workspace task holds N). + for (const [taskId, paths] of this.activeWorktrees) { + if (taskId !== requestingTaskId && paths.has(worktreePath)) { return taskId; } } @@ -14516,12 +14569,9 @@ You have access to the file system to review changes.${verdictBlock}`; * Returns true if cleanup succeeded. */ private hasActiveWorktreeBinding(taskId: string, worktreePath: string): boolean { - for (const [activeTaskId, activePath] of this.activeWorktrees) { - if (activeTaskId === taskId && activePath === worktreePath) { - return true; - } - } - return false; + // FNXC:Workspace 2026-06-21-12:00: KTD2 — membership across the task's worktree set. + const paths = this.activeWorktrees.get(taskId); + return paths ? paths.has(worktreePath) : false; } private async reconcileSelfOwnedBeforeRemove(worktreePath: string, taskId: string): Promise { @@ -14919,11 +14969,18 @@ You have access to the file system to review changes.${verdictBlock}`; * always cleaned up by the merger on a per-task basis. */ async cleanup(taskId: string): Promise { - const worktreePath = this.activeWorktrees.get(taskId); - if (!worktreePath) return; + const worktreePaths = this.getActiveWorktreePaths(taskId); + if (worktreePaths.length === 0) return; this.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 (this.workspaceConfig) { + return; + } + // Non-workspace tasks hold a one-element set — preserve the original single-path removal semantics. + const worktreePath = worktreePaths[0]; + // Check if another task still needs this worktree const otherUser = await findWorktreeUser(this.store, worktreePath, taskId); if (otherUser) { @@ -15420,8 +15477,14 @@ You have access to the file system to review changes.${verdictBlock}`; return true; } + /** + * FNXC:Workspace 2026-06-21-12:00: KTD2 single-path-getter contract. Returns the task's sole worktree path for single-repo tasks (one-element set). For a multi-worktree workspace task there is no single answer — callers must read the per-repo `task.workspaceWorktrees` entry instead — so this returns undefined. A workspace task tracked only at the browse-only root also returns undefined, matching the "no removable single worktree" semantics. + */ getWorktreePath(taskId: string): string | undefined { - return this.activeWorktrees.get(taskId); + if (this.workspaceConfig) { + return undefined; + } + return this.getActiveWorktreePaths(taskId)[0]; } // ── Agent Spawning ───────────────────────────────────────────────────── @@ -15721,7 +15784,11 @@ function formatTimestamp(iso: string): string { // Project commands are injected here (for reliability) and also in the PROMPT.md (by triage). // This ensures the executor agent always sees the authoritative commands from settings, // even if the PROMPT.md was written manually or before commands were configured. -function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string): string { +function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string, workspaceConfig?: WorkspaceConfig | null): string { + // FNXC:Workspace 2026-06-21-12:00: KTD1 — in workspace mode the session is rooted at the workspace root itself (worktreePath === rootDir) and path rewriting to a per-task root worktree is meaningless: edits happen in per-sub-repo worktrees the agent acquires, not at the root. No-op the rewrite. (The rootDir === worktreePath guard below already covers this, but gate explicitly so intent survives future refactors.) + if (workspaceConfig) { + return prompt; + } if (!rootDir || !worktreePath || rootDir === worktreePath || !prompt.includes(rootDir)) { return prompt; } @@ -15755,7 +15822,7 @@ export function buildExecutionPrompt( customFieldDefs?: WorkflowFieldDefinition[], workspaceConfig?: WorkspaceConfig | null, ): string { - const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath); + const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath, workspaceConfig); const reviewLevel = parseReviewLevelFromPrompt(prompt); // Build co-author trailer arg for git commits based on settings. The user's From 023e4b057dd56f26c32082422d892820fd01c5b0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:07:20 -0700 Subject: [PATCH 018/265] =?UTF-8?q?feat(workspace):=20Phase=20A=20U3=20?= =?UTF-8?q?=E2=80=94=20dashboard=20"doesn't=20look=20broken"=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace tasks (no task.worktree, populated workspaceWorktrees) now render in existing task views without crashing or going blank. New read-only WorkspaceWorktreesSummary component (placeholder "N repos acquired" + a flat repo→worktree/branch list — within the "doesn't look broken" ceiling, not a rich status UI); TaskCard and TaskDetailModal nil-guard on isWorkspaceTask. Single-repo rendering unchanged. CONCEPTS.md notes workspace-task merges are non-atomic (repos land independently on local integration refs; partial-land is local and operator-resettable). Tests 8/8; TaskCard regression 251/251. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-dashboard-floor.md | 8 ++ CONCEPTS.md | 4 + .../dashboard/app/components/TaskCard.tsx | 9 ++ .../app/components/TaskDetailModal.css | 36 ++++++++ .../app/components/TaskDetailModal.tsx | 5 + .../components/WorkspaceWorktreesSummary.tsx | 92 +++++++++++++++++++ .../WorkspaceWorktreesSummary.test.tsx | 89 ++++++++++++++++++ 7 files changed, 243 insertions(+) create mode 100644 .changeset/workspace-dashboard-floor.md create mode 100644 packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx create mode 100644 packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx diff --git a/.changeset/workspace-dashboard-floor.md b/.changeset/workspace-dashboard-floor.md new file mode 100644 index 0000000000..47db598dea --- /dev/null +++ b/.changeset/workspace-dashboard-floor.md @@ -0,0 +1,8 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace tasks no longer render blank in the dashboard. Task cards and the task +detail view now surface a workspace task's acquired per-sub-repo worktrees as a +read-only "N repos acquired" placeholder and flat repo → worktree/branch list, +instead of an empty branch area (no `task.worktree`/`task.branch`). diff --git a/CONCEPTS.md b/CONCEPTS.md index 1fccc91371..e80ef40df8 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -61,6 +61,10 @@ sub-directories. Fusion discovers sub-repos at init time and records them in single root-level worktree; instead, the agent acquires per-repo worktrees on demand via `fn_acquire_repo_worktree`. +Workspace-task merges are **non-atomic**: each sub-repo lands on its own local +integration ref independently, so a partial-land window (some sub-repos merged, +others not) is possible mid-task — this state is local and operator-resettable. + ### Project Identity The durable identity a registered Project carries locally so it can be reattached to the central registry after central state is lost or rebuilt, preserving rows keyed by the same project id instead of minting a replacement. diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 3e5898ec52..46748eb497 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -35,6 +35,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ". import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout"; import { useRetryWarning } from "../context/RetryWarningContext"; import { useColumnLabel } from "../i18n/labels"; +import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary"; /** Per-branch progress snapshot (U13). Surfaced as an optional additive field * on the task payload for the parallel-window badge (U9). */ @@ -625,6 +626,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.blockedBy === nextTask.blockedBy && previousTask.overlapBlockedBy === nextTask.overlapBlockedBy && previousTask.worktree === nextTask.worktree && + // FNXC:Workspace 2026-06-21-00:00: re-render the card when a workspace task acquires/ + // releases sub-repo worktrees so the "N repos acquired" placeholder stays current (U3). + Object.keys(previousTask.workspaceWorktrees ?? {}).length === + Object.keys(nextTask.workspaceWorktrees ?? {}).length && previousTask.branch === nextTask.branch && previousTask.baseBranch === nextTask.baseBranch && previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks && @@ -2186,6 +2191,10 @@ function TaskCardComponent({
); })()} + {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular task.branch, + so the branch-metadata row below renders nothing. Surface the acquired sub-repos + as a compact "N repos acquired" placeholder so the card isn't blank (U3/KTD5). */} + {isWorkspaceTask(task) && } {hasBranchMetadata && (
{branchMetadata.branch && ( diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 18c1cf0968..2b372606da 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -2209,3 +2209,39 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a color: var(--color-error); font-size: 0.75rem; } + +/* +FNXC:Workspace 2026-06-21-00:00: +Flat read-only per-sub-repo worktree list for a workspace task (U3/KTD5 dashboard floor). +Read-only list/placeholder only — not the deferred rich per-repo-status component. +*/ +.workspace-worktrees-summary { + margin: var(--space-sm) 0 0; +} +.workspace-worktrees-placeholder { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary, inherit); + margin-bottom: var(--space-xs); +} +.workspace-worktrees-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} +.workspace-worktrees-item { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-sm); + font-size: 0.75rem; + font-family: var(--font-mono, monospace); +} +.workspace-worktrees-repo { + font-weight: 600; +} +.workspace-worktrees-branch { + color: var(--color-text-secondary, inherit); +} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 90b7546338..6432ed838f 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -39,6 +39,7 @@ import { TaskChatTab } from "./TaskChatTab"; import { TaskReviewTab } from "./TaskReviewTab"; import { MergeDetails } from "./MergeDetails"; import { TaskChangesTab } from "./TaskChangesTab"; +import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary"; import { TaskForm, type PendingImage } from "./TaskForm"; import { useNodes } from "../hooks/useNodes"; import { WorkflowResultsTab } from "./WorkflowResultsTab"; @@ -3065,6 +3066,10 @@ export function TaskDetailContent({ {task.branchContext?.groupId && ( )} + {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular + task.worktree/task.branch; surface their acquired per-sub-repo worktrees + as a flat read-only list so the detail view isn't blank (U3/KTD5). */} + {isWorkspaceTask(task) && } )} {task.status === "failed" && task.error && ( diff --git a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx new file mode 100644 index 0000000000..90625a96eb --- /dev/null +++ b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from "react-i18next"; +import type { Task } from "@fusion/core"; + +/* +FNXC:Workspace 2026-06-21-00:00: +Dashboard "doesn't look broken" floor (Phase A U3 / master U10, KTD5). +A workspace-mode task has NO singular `task.worktree`/`task.branch`; instead it carries +`task.workspaceWorktrees` — one acquired git worktree per sub-repo, keyed by repo path +relative to the workspace root. Existing display surfaces (TaskCard branch row, TaskDetail +metadata) key off the singular `task.branch`, so a workspace task would render an EMPTY +branch area — looking broken. This guard renders a static placeholder ("N repos acquired") +plus a flat read-only per-repo path/branch list so the task is observable, never crashing +and never blank. + +Scope ceiling: flat read-only list / placeholder ONLY. A rich per-repo-status component +(live diff/lease/merge state per repo) is the deferred registration UI — out of scope here. +Single-repo rendering is untouched: callers only mount this when `isWorkspaceTask(task)`. +*/ + +/** + * True when the task is a workspace-mode task: no singular `worktree` recorded + * and at least one acquired per-sub-repo worktree in `workspaceWorktrees`. + * Single-repo tasks (populated `worktree`, no `workspaceWorktrees`) return false, + * keeping their existing rendering byte-for-byte unchanged. + */ +export function isWorkspaceTask(task: Pick): boolean { + if (task.worktree) return false; + const entries = task.workspaceWorktrees; + return Boolean(entries && Object.keys(entries).length > 0); +} + +interface WorkspaceWorktreesSummaryProps { + task: Pick; + /** Compact variant for the dense TaskCard surface (placeholder only). */ + compact?: boolean; +} + +/** + * Read-only summary of a workspace task's acquired sub-repo worktrees. + * + * - `compact` (TaskCard): renders just the "N repos acquired" placeholder chip. + * - default (TaskDetail): renders the placeholder plus a flat per-repo list of + * `repo → worktreePath (branch)`. + * + * Renders nothing for non-workspace tasks; mount only behind `isWorkspaceTask`. + */ +export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWorktreesSummaryProps) { + const { t } = useTranslation("app"); + const entries = task.workspaceWorktrees; + if (!isWorkspaceTask(task) || !entries) return null; + + const repos = Object.entries(entries); + const placeholder = t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length }); + + if (compact) { + return ( +
+ + {t("tasks.workspace", "Workspace")} + {placeholder} + +
+ ); + } + + return ( +
+
+ {placeholder} +
+
    + {repos.map(([repoRelPath, info]) => ( +
  • + + {repoRelPath} + + + {info.worktreePath} + + + {info.branch} + +
  • + ))} +
+
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx new file mode 100644 index 0000000000..dfd23f3875 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { WorkspaceWorktreesSummary, isWorkspaceTask } from "../WorkspaceWorktreesSummary"; + +/* +FNXC:Workspace 2026-06-21-00:00: +U3/KTD5 dashboard "doesn't look broken" floor. Asserts the invariant across both surfaces +the summary serves (FN-5893): +- happy path: workspace task (no task.worktree, two workspaceWorktrees entries) renders a + flat per-repo list + "N repos acquired" placeholder — no crash, not blank. +- regression: single-repo task (task.worktree set, no workspaceWorktrees) renders nothing + from this guard, so its existing rendering stays unchanged. +Narrow seam: tests the presentational component directly, no API / SSE / timers (FN-5048). +*/ + +const workspaceTask = { + worktree: undefined, + workspaceWorktrees: { + "repo-a": { worktreePath: "/wt/repo-a", branch: "fusion/fn-1-a" }, + "repo-b": { worktreePath: "/wt/repo-b", branch: "fusion/fn-1-b" }, + }, +} as const; + +const singleRepoTask = { + worktree: "/wt/single", + workspaceWorktrees: undefined, +} as const; + +describe("isWorkspaceTask", () => { + it("is true when worktree is absent and workspaceWorktrees has entries", () => { + expect(isWorkspaceTask(workspaceTask)).toBe(true); + }); + + it("is false for a single-repo task (worktree set)", () => { + expect(isWorkspaceTask(singleRepoTask)).toBe(false); + }); + + it("is false when workspaceWorktrees is an empty record", () => { + expect(isWorkspaceTask({ worktree: undefined, workspaceWorktrees: {} })).toBe(false); + }); + + it("prefers the singular worktree even if workspaceWorktrees is populated", () => { + expect( + isWorkspaceTask({ worktree: "/wt/x", workspaceWorktrees: workspaceTask.workspaceWorktrees }), + ).toBe(false); + }); +}); + +describe("WorkspaceWorktreesSummary", () => { + it("renders a flat per-repo list and placeholder for a two-repo workspace task (no crash, not empty)", () => { + render(); + + // Placeholder reflects the repo count. + expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2"); + expect(screen.getByText(/2 repos acquired/i)).toBeTruthy(); + + // Flat per-repo list: each repo path, worktree path, and branch is shown. + const summary = screen.getByTestId("workspace-worktrees-summary"); + expect(summary).toBeTruthy(); + expect(screen.getByText("repo-a")).toBeTruthy(); + expect(screen.getByText("repo-b")).toBeTruthy(); + expect(screen.getByText("/wt/repo-a")).toBeTruthy(); + expect(screen.getByText("/wt/repo-b")).toBeTruthy(); + expect(screen.getByText("fusion/fn-1-a")).toBeTruthy(); + expect(screen.getByText("fusion/fn-1-b")).toBeTruthy(); + }); + + it("renders only the compact placeholder in compact mode", () => { + render(); + expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2 repos"); + // Compact variant omits the full per-repo list. + expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull(); + expect(screen.queryByText("/wt/repo-a")).toBeNull(); + }); + + it("renders nothing for a single-repo task, leaving existing rendering unchanged", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull(); + expect(screen.queryByTestId("workspace-worktrees-placeholder")).toBeNull(); + }); + + it("renders nothing when workspaceWorktrees is empty", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); +}); From 1fa3691f1f369f636e59fef4798a6afcca619c04 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:07:32 -0700 Subject: [PATCH 019/265] docs(workspace): Phase A implementation plan (U1/U2/U10) --- ...6-06-21-004-feat-workspace-phase-a-plan.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md diff --git a/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md b/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md new file mode 100644 index 0000000000..f8b4220cb6 --- /dev/null +++ b/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md @@ -0,0 +1,176 @@ +--- +title: "feat: Workspace mode Phase A — session scoping, per-repo acquisition, dashboard floor" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase A / U1·U2·U10) +depth: deep +--- + +# feat: Workspace mode Phase A — session scoping, per-repo acquisition, dashboard floor + +> **ID namespace:** the `U1·U2·U3` below are **local to this Phase-A plan**. They decompose master-plan **U1, U2, U10** (a separate namespace). "Master-plan U6/U8" references point at the master plan, not these IDs. + +## Summary + +Phase A of the workspace-mode master plan: make a workspace task **run** (acquire → browse → edit per sub-repo), short of capture/review/merge (Phases B–D). Three units: (U1) executor session scoping so the session roots at the non-git workspace root and edits happen only in per-repo worktrees; (U2) per-repo acquisition hardening (identity guard, per-repo base SHA against the resolved integration branch, same-sub-repo exclusivity); (U3 = master U10) a dashboard "doesn't look broken" floor. + +Builds on the **foundation** (PR #1710 — `task.workspaceWorktrees`, `fn_acquire_repo_worktree`, `acquireWorkspaceRepoWorktree`) + **U0** (PR #1711 — `runAiMerge` sole merge path, R7 guard). Settled design: **D2/D3/D5 — land-as-you-go on each repo's LOCAL integration ref** (no remote push), session-time coherence accepted. The R7 merge-boundary guard already exists at the merge chokepoint (U0); U1 must not route around it. + +**Scope out:** capture/contamination/review (master U3/U4 = Phase B), the per-repo merge loop (master U6 = Phase C), self-healing reconcilers (master U8 = Phase D). + +**Stacking:** this branch is off the U0 branch, so the PR diff includes foundation + U0 + Phase A and **must not merge until #1710/#1711 land**. + +--- + +## Problem Frame + +In workspace mode `rootDir` is a **non-git** parent. On the current base the executor still, for every task: acquires one root worktree at `executor.ts:~7430` (`acquireTaskWorktree({rootDir})`), runs preflights (`resolveContaminationBaseRef`, `captureBaseCommitSha`, identity-guard install, `verifyWorktreeInvariants`) against that path, binds the agent session cwd to it, and tracks `activeWorktrees: Map`. Against a non-git root, the root acquisition and every git preflight fail. The foundation gave the agent `fn_acquire_repo_worktree` (per-repo worktrees on demand) but nothing in the executor lifecycle skips the root path or hardens per-repo acquisition. Phase A closes that gap for the **run** stage. + +--- + +## Key Technical Decisions + +### KTD1 — Skip root acquisition + all rootDir preflights; session cwd = workspace root (master KTD1) +When `this.workspaceConfig` is present: skip `acquireTaskWorktree({rootDir})` and gate each intervening preflight so none runs git against the non-git root; set session cwd = `this.rootDir` (browse-only); do not set `task.worktree`; `scopePromptToWorktree` is a no-op. The non-workspace path stays byte-for-byte unchanged (branch on `workspaceConfig`). + +### KTD2 — `activeWorktrees` becomes `taskId → Set` (master KTD1) — VERIFIED consumer list +A workspace task holds N sub-repo worktrees; liveness/owner checks must see all of them. Convert the map and update **every** consumer to membership semantics. The complete, code-verified consumer set (feasibility-checked — the earlier draft mislabeled these): +- **Membership / owner checks:** `findActiveWorktreeOwner` (`:14491`), `hasActiveWorktreeBinding` (`:14518`), the FN-6736 phantom-binding reclaim (`~:2055`). +- **`listWorktreeHolders` (`:14480`)** — emits one `{taskId, worktreePath}` per entry; consumed by the **FN-6782 leaked-slot reaper** (`self-healing.ts:~8310`) and `in-process-runtime.ts:~791`. A workspace task must **flat-map its Set into N holder rows**, or `maxWorktrees`-slot accounting under-counts and leaks/mis-reaps. Verify the reaper math against multi-row holders. +- **Single-path getters — define the Set-collapse contract (KTD-decision):** `getWorktreePath(taskId): string|undefined` (`:15424`), the `verifyWorktreeInvariants` resolution `?? this.activeWorktrees.get(task.id)` (`:10461`), and the conflict-set iteration (`~:14444`, `worktreePath === conflictPath`). **Contract:** for a workspace task these single-path consumers operate per-sub-repo (the caller already has the repo/path in context) — `getWorktreePath` returns `undefined` for a multi-worktree workspace task (callers must use the per-repo `workspaceWorktrees` entry), and `verifyWorktreeInvariants` is iterated per worktree in Phase B (master U3), so its singular resolution is gated off in workspace mode here. +- **Unregister resolvers (`:1586`/`:1603`/`:1618`)** — `deleteActiveSession`/`StepExecutor`/`WorkflowStepSession` each read one path for `activeSessionRegistry.unregisterPath`; with a Set they must unregister **every** path (loop), not one. Plus cleanup at `~:14922`. + +Non-workspace tasks hold a one-element set — behavior unchanged. **Grep all `activeWorktrees.` sites before declaring done** (FN-5893); the list above is the verification spine, not a license to skip the grep. + +### KTD3 — Per-repo base SHA against the *resolved* integration branch, local-first (master KTD3) +`resolveCapturedBaseCommitSha` (`base-commit-capture.ts:26-55`) **hardcodes `main`** and takes `(worktreePath, logger?)`. Extend it to accept the integration branch as an **optional trailing param defaulting to the current `main` literal**, so the existing single-repo caller (`executor.ts:~12075`) and the 4 `base-commit-capture.real-git.test.ts` cases stay green without change. At each sub-repo acquisition capture `baseCommitSha` measured **local-first** (`merge-base HEAD || origin/`), per `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`. + +> **Integration-branch resolution gotcha (feasibility-verified):** `resolveIntegrationBranch(rootDir, settings)` (`integration-branch.ts:74`) checks `resolveFromSettings(settings)` **FIRST** and returns a populated `settings.integrationBranch` before ever consulting the repo's `origin/HEAD`. So `resolveIntegrationBranch(repoAbsPath, settings)` would return the **shared** override for every sub-repo — the exact thing KTD3 forbids. **Call it with the shared override stripped:** `resolveIntegrationBranch(repoAbsPath, { ...settings, integrationBranch: undefined })`, so each sub-repo falls through to its own `origin/HEAD`. Store as `workspaceWorktrees[repo].baseCommitSha`. + +### KTD4 — Same-sub-repo exclusivity via `activeSessionRegistry` path-keying, not the pool (master KTD6) +`WorktreePool` is a recycle cache (gated on `recycleWorktrees`), **not** a cross-task lock. Serialize two concurrent workspace tasks contending for the same sub-repo via a repo-path exclusivity registry built on `activeSessionRegistry` path-keying (which `runAiMerge` already uses), registered **at acquisition** (U2). Disjoint-scope contention on the same sub-repo is otherwise unprotected (file-scope leases don't catch it). + +### KTD5 — Dashboard floor only (master U10) +Nil-guard components that render `task.worktree`/`task.branch` so a workspace task (no `task.worktree`, populated `workspaceWorktrees`) shows a placeholder or flat per-repo list, never a crash/empty. Ceiling: "doesn't look broken" — no rich per-repo-status component (deferred registration UI). Plus a one-line non-atomic-merge-semantics note in `CONCEPTS.md`/`docs/dashboard-guide.md`. + +--- + +## Implementation Units + +> **Standing requirements (every unit):** `FNXC:Workspace ` comments at non-obvious decision points; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (narrow seams, real git only where an invariant requires it, fake timers over polling, no mock-the-world); FN-5893 surface enumeration (update every enumerated consumer, don't half-convert); merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`). Branch off the U0 branch — do not commit to `main` or the U0 branch. + +### U1. Executor session scoping — skip root acquisition + preflights, browse-only root, activeWorktrees Set + +**Goal:** In workspace mode the executor skips root acquisition and every rootDir git preflight, runs the session rooted at the workspace dir, and tracks per-task worktree *sets*. + +**Requirements:** KTD1, KTD2. + +**Dependencies:** none (foundation + U0 present on the base). + +**Files:** +- `packages/engine/src/executor.ts` (acquisition `~:7430`; preflights `:7525` base capture, `:7536` contamination, identity-guard install, `verifyWorktreeInvariants`; session create `~:8443-8494`; retry session `~:8935`; `activeWorktrees` `:7667` + consumers `findActiveWorktreeOwner`/`hasActiveWorktreeBinding`/`getActiveWorktreeHolders`/FN-6736 reclaim `~:2055`/getters `~:1585`/`:14491`/`:14518`; `scopePromptToWorktree`) +- `packages/engine/src/__tests__/executor-workspace.test.ts` (**rewrite** — replace the `vi.mock`-the-subject tests with a **real two-repo git fixture harness** reusable by U2 and later phases) + +**Approach:** Gate the root acquisition + each preflight behind `!this.workspaceConfig`. In workspace mode set session cwd = `this.rootDir`, leave `task.worktree` unset, no-op `scopePromptToWorktree`. Convert `activeWorktrees` to `taskId → Set`; update each enumerated consumer to membership semantics (a non-workspace task = a one-element set). Mirror the existing `this.workspaceConfig === undefined` lazy-load guard at `executor.ts:7413-7418`. + +**Execution note:** Build the real two-repo fixture harness first (create temp git repos, branch, commit); the foundation's self-mocking test proves nothing. The harness is shared infrastructure for the rest of the phases. + +**Test scenarios:** +- Workspace config present → root `acquireTaskWorktree` NOT called; no preflight runs git against rootDir; session `cwd === rootDir`. (happy path) +- Non-workspace task → acquisition + every preflight called exactly as before; `cwd === worktreePath`. (regression — the singular path is untouched) +- Each enumerated `activeWorktrees` consumer returns correct results when a task holds two sub-repo paths (membership, not equality). (integration) +- Retry session in workspace mode uses `cwd === rootDir`. (edge) +- Workspace task that acquires zero sub-repos reaches `fn_task_done` without throwing on missing `task.worktree`. (edge/empty) + +**Verification:** A workspace task starts a session at the workspace root with no root worktree and no rootDir git preflight; `activeWorktrees` reflects all acquired sub-repo paths; a single-repo task is unchanged. + +--- + +### U2. Per-repo acquisition hardening — identity guard, per-repo base SHA, same-repo exclusivity + +**Goal:** Each sub-repo worktree gets identity hooks, a correct per-repo base SHA (local-first, resolved integration branch), and same-sub-repo concurrency protection — all at acquisition. + +**Requirements:** KTD3, KTD4. + +**Dependencies:** U1 (shares the fixture harness). + +**Files:** +- `packages/engine/src/worktree-acquisition.ts` (`acquireWorkspaceRepoWorktree` `~:598-650`) +- `packages/engine/src/base-commit-capture.ts` (**extend `resolveCapturedBaseCommitSha` to accept the integration branch** — it hardcodes `main`) +- `packages/engine/src/worktree-hooks.ts` (`installTaskWorktreeIdentityGuard`) +- `activeSessionRegistry` path-keying (repo-path exclusivity registry — KTD4; NOT `worktree-pool.ts`) +- `packages/core/src/types.ts` (extend the `Task.workspaceWorktrees` entry with `baseCommitSha?`) +- `packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts` (new — real two-repo git fixture) + +**Approach:** After `acquireTaskWorktree` returns for a sub-repo: (1) install the identity guard via `installTaskWorktreeIdentityGuard`, passing the **same settings args the executor passes** at `executor.ts:14035-14040` (`commitMsgHookEnabled`, `taskPrefix`, `taskAttributionTrailerName`) for single-repo parity — note `acquireWorkspaceRepoWorktree` calls `acquireTaskWorktree` *without* a `createWorktree` override, so the default backend installs **no** guard today (this work is genuinely missing); (2) resolve the integration branch via `resolveIntegrationBranch(repoAbsPath, { ...settings, integrationBranch: undefined })` (strip the shared override — KTD3 gotcha) and capture `baseCommitSha` via the extended `resolveCapturedBaseCommitSha(worktreePath, logger?, integrationBranch?)`; (3) persist `baseCommitSha` into `workspaceWorktrees[repo]`; (4) register same-sub-repo exclusivity in the `activeSessionRegistry` path-keyed registry — choose a **distinct registry kind/ownerKey** for the acquisition-time exclusivity entry so it does not collide with the executor's later session registration on the same sub-repo path (the registry exposes `registerPath`/`lookupByPath`/`isPathActive`/`pathsForTask`). Idempotent across `(taskId, repo)` (re-acquire returns the existing entry, no re-install/re-capture). + +**Execution note:** Real two-repo fixture; commit-without-pushing to exercise the local-ahead-of-origin invariant. + +**Test scenarios:** +- Acquiring repo A captures `baseSha_A` = the local integration tip even when `origin/` is behind. Covers the inflation invariant. (happy path + regression) +- A sub-repo whose integration branch is **not** `main` captures against that branch and does not inherit a shared `settings.integrationBranch`. (KTD3 correction) +- Identity-guard hook present; a commit on a non-`fusion/` branch is rejected. (integration) +- Two concurrent workspace tasks acquiring the same sub-repo (even with disjoint in-repo scopes) are serialized by the exclusivity registry. (concurrency — KTD4) +- Re-acquiring repo A returns the existing entry without re-capture/re-install. (idempotency) +- Acquisition failure persists an audit event and surfaces an error (no swallowed stall). (error path) + +**Verification:** Each sub-repo worktree has identity hooks, a correct per-repo base SHA (local-first, right branch), and same-sub-repo concurrency protection registered at acquisition. + +--- + +### U3. Dashboard "doesn't look broken" floor (master U10) + +**Goal:** Existing task views render a workspace task (no `task.worktree`, populated `workspaceWorktrees`) without breakage. + +**Requirements:** KTD5. + +**Dependencies:** none (independent of U1/U2; reads the data shape the foundation already added). + +**Files:** +- Each `packages/dashboard/app/` component that reads `task.worktree`/`task.branch` for display (grep and enumerate during implementation — task detail view + any task-row/summary) +- `CONCEPTS.md` or `docs/dashboard-guide.md` (one-line non-atomic-merge-semantics note) +- `packages/dashboard/app/__tests__/` (new — graceful render test) + +**Approach:** Add a nil-guard so each affected component renders a static placeholder (e.g. "N repos acquired") or a flat per-repo path list when `task.worktree` is absent and `workspaceWorktrees` is populated. **Ceiling:** placeholder/flat list only — a new rich per-repo-status component crosses into the deferred registration UI. Add the one-line semantics note (workspace-task merges are non-atomic: repos land independently on local integration refs; partial-land is local + operator-resettable). + +**Test scenarios:** +- Task with `task.worktree` undefined + two `workspaceWorktrees` entries → renders a per-repo list/placeholder, no crash/empty. (happy path) +- Single-repo task → unchanged. (regression) + +**Verification:** Workspace tasks are observable (not broken) in the dashboard. + +--- + +## Scope Boundaries + +**In scope:** the **run** stage — session scoping (U1), per-repo acquisition hardening (U2), dashboard breakage floor (U3). + +### Deferred to Follow-Up Work (later master-plan phases) +- Per-repo modified-files capture, contamination, `verifyWorktreeInvariants` iteration (master U3 = Phase B). +- Per-repo review + `fn_task_done` completion verification (master U4 = Phase B). +- The shared landed predicate, per-repo `runAiMerge` clean-room loop, leases (master U5/U6/U7 = Phase C). +- Self-healing reconcilers, e2e harness (master U8/U9 = Phase D). +- Rich dashboard per-repo status / workspace registration UI. + +> **Contamination-window caveat (carried from the master plan):** U1 gates the root preflights off, but per-repo contamination/`verifyWorktreeInvariants` does not return until master U3 (Phase B). Do not run a workspace task for real until Phase B lands — Phase A delivers acquisition + browse, not a verified end-to-end run. + +--- + +## Risks & Dependencies + +- **R1 — Half-converted `activeWorktrees` consumers (FN-5893).** Missing one consumer silently breaks liveness/owner checks for multi-repo tasks. Mitigation: KTD2 enumerates every consumer; grep all `activeWorktrees.get(`/`.has(`/`===`-on-path sites before declaring done. +- **R2 — A preflight left un-gated runs git against the non-git root → crash.** Mitigation: U1 explicitly enumerates and gates each preflight between the workspace guard and session create; test asserts no rootDir git in workspace mode. +- **R3 — Base-commit inflation per repo.** Mitigation: KTD3 extends the hardcoded-`main` helper and captures local-first against the resolved branch; regression test commits-without-pushing + uses a non-`main` integration branch. +- **R4 — Same-sub-repo concurrency unprotected.** Mitigation: KTD4 registers exclusivity at acquisition (U2), not via the recycle pool. +- **R5 — Non-workspace regression.** The whole point of branching on `workspaceConfig` is parity for single-repo tasks. Mitigation: every unit carries a non-workspace "unchanged" regression test; the gate's existing engine-core suite must stay green. +- **Stacking dependency:** builds on foundation #1710 + U0 #1711; the PR diff includes both and must not merge until they land. + +--- + +## Sources & Research + +- Master plan `docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md` (U1/U2/U10, KTD1/KTD3/KTD6 — KTD7 is Phase B, invariant inventory, D2/D3/D5). +- Codebase anchors (verified this session): `executor.ts` acquisition/preflight/session/`activeWorktrees`; `worktree-acquisition.ts` `acquireWorkspaceRepoWorktree`; `base-commit-capture.ts` hardcoded-`main`; `resolveIntegrationBranch`; `activeSessionRegistry` path-keying; foundation `task.workspaceWorktrees`. +- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md` → KTD3 (local-first base capture). +- `AGENTS.md`: FN-5048 slow-test rules, FN-5893 surface enumeration, changeset policy, merge gate. From 12d33c512d95760c3f7e2dda8e61d45c3e9afeae Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:17:08 -0700 Subject: [PATCH 020/265] =?UTF-8?q?feat(workspace):=20Phase=20A=20U2=20?= =?UTF-8?q?=E2=80=94=20per-repo=20acquisition=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acquireWorkspaceRepoWorktree now hardens each sub-repo worktree at acquisition: (1) installs the identity guard with the executor's settings args (commitMsgHookEnabled/taskPrefix/taskAttributionTrailerName) for single-repo parity — it was installing no guard before; (2) captures a per-repo baseCommitSha local-first against the repo's resolved integration branch via resolveIntegrationBranch(repoAbsPath, {...settings, integrationBranch: undefined}) — stripping the shared override so each sub-repo falls through to its own origin/HEAD, not a project-wide branch; (3) persists baseCommitSha into the workspaceWorktrees[repo] entry (Task type extended); (4) registers same-sub-repo exclusivity on the sub-repo path via activeSessionRegistry under a distinct "workspace-repo-acquire" kind (released in finally), so two concurrent workspace tasks contending for the same sub-repo are serialized (throws WorkspaceRepoAcquireBusyError). Idempotent re-acquire short-circuits. resolveCapturedBaseCommitSha gains an optional trailing integrationBranch param defaulting to "main", so existing single-repo callers + base-commit-capture real-git tests stay green. New audit events worktree:workspace-repo-acquire-busy /-failed. 6 new real-fixture tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...orkspace-per-repo-acquisition-hardening.md | 5 + packages/core/src/types.ts | 8 +- .../worktree-acquisition-workspace.test.ts | 273 ++++++++++++++++++ .../engine/src/active-session-registry.ts | 11 +- packages/engine/src/base-commit-capture.ts | 18 +- packages/engine/src/run-audit.ts | 5 + packages/engine/src/worktree-acquisition.ts | 191 ++++++++++-- 7 files changed, 483 insertions(+), 28 deletions(-) create mode 100644 .changeset/workspace-per-repo-acquisition-hardening.md create mode 100644 packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts diff --git a/.changeset/workspace-per-repo-acquisition-hardening.md b/.changeset/workspace-per-repo-acquisition-hardening.md new file mode 100644 index 0000000000..3ce549f618 --- /dev/null +++ b/.changeset/workspace-per-repo-acquisition-hardening.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase A / U2): harden per-repo worktree acquisition. Each sub-repo worktree now gets the task identity guard installed (single-repo parity), a per-repo base commit SHA captured local-first against that sub-repo's resolved integration branch (shared `integrationBranch` override stripped so each repo falls through to its own `origin/HEAD`), and same-sub-repo acquisition exclusivity registered in the path-keyed active-session registry. Re-acquiring an already-acquired `(taskId, repo)` is idempotent, and acquisition failures surface an error plus an audit event instead of silently stalling. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9425818116..6e40bad2d0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2246,8 +2246,14 @@ export interface Task { /** * Workspace mode only. Keyed by repo path relative to workspace rootDir. * Each entry records the on-disk worktree path and git branch for one sub-repo. + * + * FNXC:Workspace 2026-06-21-20:10: + * `baseCommitSha` is the per-repo fork-point captured at acquisition (U2/KTD3) + * against that sub-repo's RESOLVED integration branch, local-first. It is the + * per-repo analogue of the single-repo base-commit capture and prevents + * cross-repo files-changed inflation when local integration is ahead of origin. */ - workspaceWorktrees?: Record; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts new file mode 100644 index 0000000000..d3e9e95349 --- /dev/null +++ b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts @@ -0,0 +1,273 @@ +/* +FNXC:Workspace 2026-06-21-20:10: +U2 per-repo acquisition hardening tests. A REAL two-repo git fixture is required +because the invariants under test are git-shaped: local-ahead-of-origin base +capture, a resolved-per-repo (non-shared) integration branch, and a working +identity-guard hook that actually rejects a commit. The shared harness from +./_workspace-fixture.ts builds genuine on-disk repos under a NON-git workspace +root. The TaskStore is an in-memory fake (no DB / no network) per FN-5048 — real +git only where the invariant needs it; everything else is a narrow seam. +*/ +import { execSync, spawnSync } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Settings, Task, TaskStore } from "@fusion/core"; +import { + acquireWorkspaceRepoWorktree, + WorkspaceRepoAcquireBusyError, +} from "../worktree-acquisition.js"; +import { ActiveSessionRegistry } from "../active-session-registry.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +/** + * Minimal in-memory TaskStore covering exactly what acquireWorkspaceRepoWorktree + * and its acquireTaskWorktree callee touch: updateTask (merge-in-place so the + * idempotency re-read sees persisted workspaceWorktrees), logEntry, getTask. + */ +function makeFakeStore(task: Task): { store: TaskStore; current: () => Task; logs: string[] } { + let current = task; + const logs: string[] = []; + const store = { + async updateTask(id: string, patch: Partial): Promise { + if (id === current.id) current = { ...current, ...patch }; + }, + async logEntry(_id: string, message: string): Promise { + logs.push(message); + }, + async getTask(id: string): Promise { + return id === current.id ? current : null; + }, + } as unknown as TaskStore; + return { store, current: () => current, logs }; +} + +function makeTask(id: string): Task { + return { + id, + title: `task ${id}`, + description: "workspace task", + status: "in-progress", + } as unknown as Task; +} + +const SETTINGS: Partial = { + worktreeNaming: "task-id", + commitMsgHookEnabled: true, + taskPrefix: "FN", + taskAttributionTrailerNames: ["Fusion-Task-Id"], +}; + +describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout: 60_000 }, () => { + let fixture: WorkspaceFixture; + + afterEach(() => { + fixture?.cleanup(); + }); + + it("captures the LOCAL integration tip as baseCommitSha even when origin is behind (inflation invariant)", async () => { + // Give repo-a a real origin so origin/main can lag behind local main. + fixture = await createWorkspaceFixture(["repo-a"]); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git push -u origin main"); + + // Local main advances by an unpushed predecessor commit (FN-5937 shape). + git(repoA, "git commit --allow-empty -m 'FN-9000: unpushed predecessor'"); + const localTip = git(repoA, "git rev-parse HEAD"); + const originTip = git(repoA, "git rev-parse origin/main"); + expect(localTip).not.toBe(originTip); + + const { store, current } = makeFakeStore(makeTask("FN-1")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + + // Base must be the LOCAL tip, never the behind origin tip. + expect(result.baseCommitSha).toBe(localTip); + expect(current().workspaceWorktrees?.["repo-a"]?.baseCommitSha).toBe(localTip); + }); + + it("captures against a NON-main integration branch and does not inherit a shared settings.integrationBranch (KTD3)", async () => { + // repo-a's default branch is 'develop'; origin/HEAD points at it. A shared + // settings.integrationBranch override must be STRIPPED so per-repo resolution + // falls through to this repo's own origin/HEAD. + fixture = await createWorkspaceFixture(["repo-a"], "develop"); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git push -u origin develop"); + // Point origin/HEAD at develop so resolveIntegrationBranch resolves it. + git(repoA, "git remote set-head origin develop"); + const developTip = git(repoA, "git rev-parse develop"); + + const { store, current } = makeFakeStore(makeTask("FN-2")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + // A SHARED integration branch that does NOT exist in this sub-repo. If it + // leaked through, base capture would resolve against 'shared-trunk' and + // (absent that branch) fall back to HEAD — not develop's tip. + settings: { ...SETTINGS, integrationBranch: "shared-trunk" }, + registry, + }); + + expect(result.baseCommitSha).toBe(developTip); + }); + + it("installs the identity-guard hook so a commit on a non-fusion branch is rejected", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current } = makeFakeStore(makeTask("FN-3")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + settings: SETTINGS, + store, + registry, + }); + + const wt = result.worktreePath; + expect(existsSync(join(wt, ".git"))).toBe(true); + git(wt, 'git config user.email "test@example.com"'); + git(wt, 'git config user.name "Test"'); + + // On the fusion/ branch the guard permits a commit (real staged change, + // so the FN-5345 empty-commit guard also installed by the identity guard + // does not refuse it). + git(wt, "git checkout fusion/fn-3"); + writeFileSync(join(wt, "own.txt"), "own work\n", "utf-8"); + git(wt, "git add own.txt"); + git(wt, "git commit -m 'FN-3: ok on own branch'"); + + // Switch to a foreign branch; the pre-commit identity guard must refuse. + git(wt, "git checkout -B rogue-branch"); + writeFileSync(join(wt, "rogue.txt"), "rogue work\n", "utf-8"); + git(wt, "git add rogue.txt"); + const attempt = spawnSync("git", ["commit", "-m", "rogue"], { + cwd: wt, + encoding: "utf-8", + }); + expect(attempt.status).not.toBe(0); + expect(`${attempt.stderr}`).toMatch(/refusing commit/i); + }); + + it("serializes two concurrent acquisitions of the SAME sub-repo via the exclusivity registry (KTD4)", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const repoAbs = fixture.repoPath("repo-a"); + const registry = new ActiveSessionRegistry(); + + // Pre-register the sub-repo path as if task FN-A is mid-acquisition, then + // prove a second task is rejected while it is held. + registry.registerPath(repoAbs, { taskId: "FN-A", kind: "workspace-repo-acquire", ownerKey: "workspace-repo-acquire" }); + + const { store, current } = makeFakeStore(makeTask("FN-B")); + await expect( + acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }), + ).rejects.toBeInstanceOf(WorkspaceRepoAcquireBusyError); + + // The holder's entry is untouched by the rejected loser. + expect(registry.lookupByPath(repoAbs)?.taskId).toBe("FN-A"); + + // Once released, the same task acquires cleanly and the registry is freed. + registry.unregisterPath(repoAbs); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(result.alreadyAcquired).toBe(false); + // Acquisition releases its own exclusivity entry on completion. + expect(registry.isPathActive(repoAbs)).toBe(false); + }); + + it("is idempotent across (taskId, repo): re-acquire returns the existing entry without re-capture", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current } = makeFakeStore(makeTask("FN-4")); + const registry = new ActiveSessionRegistry(); + + const first = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(first.alreadyAcquired).toBe(false); + + // Re-acquire with the now-populated task: returns the persisted entry, + // does not re-register exclusivity, does not re-create a worktree. + const second = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(second.alreadyAcquired).toBe(true); + expect(second.worktreePath).toBe(first.worktreePath); + expect(second.baseCommitSha).toBe(first.baseCommitSha); + expect(registry.isPathActive(fixture.repoPath("repo-a"))).toBe(false); + }); + + it("surfaces an error and persists an audit event when acquisition fails (no swallowed stall)", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current, logs } = makeFakeStore(makeTask("FN-5")); + const registry = new ActiveSessionRegistry(); + const auditEvents: Array<{ type: string }> = []; + const audit = { + async git(e: { type: string }): Promise { + auditEvents.push(e); + }, + async filesystem(): Promise {}, + }; + + await expect( + acquireWorkspaceRepoWorktree({ + repoRelPath: "does-not-exist", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + audit: audit as never, + }), + ).rejects.toThrow(); + + expect(auditEvents.some((e) => e.type === "worktree:workspace-repo-acquire-failed")).toBe(true); + expect(logs.some((m) => /acquisition failed/i.test(m))).toBe(true); + // The exclusivity entry is released even on the failure path. + expect(registry.isPathActive(join(fixture.rootDir, "does-not-exist"))).toBe(false); + }); +}); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index ec28db0158..12168c0cea 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -1,4 +1,13 @@ -export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge"; +/* +FNXC:Workspace 2026-06-21-20:10: +"workspace-repo-acquire" is a DISTINCT registry kind reserved for the +acquisition-time same-sub-repo exclusivity entry (U2/KTD4). It is keyed by the +sub-repo absolute path (NOT the worktree path) so two concurrent workspace tasks +contending for the SAME sub-repo are serialized. Keeping it distinct from +"executor"/"step-session" means it does not collide with the executor's later +session registration on the produced worktree path. +*/ +export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge" | "workspace-repo-acquire"; export interface ActiveSessionRegistration { taskId: string; diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index c449a97558..4d9e778774 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -22,15 +22,31 @@ const execAsync = promisify(exec); * * Returns `undefined` only when every git invocation fails (caller treats a * missing base as non-fatal). + * + * FNXC:Workspace 2026-06-21-20:10: + * `integrationBranch` is an OPTIONAL TRAILING param defaulting to the historic + * "main" literal so the single-repo executor caller and the real-git tests stay + * green without change. Workspace mode (U2/KTD3) passes each sub-repo's RESOLVED + * integration branch so per-repo base capture forks against the right branch + * instead of a hardcoded "main". The local-first ordering (merge-base HEAD + * then origin/) is preserved per-branch to keep the + * inflation-prevention invariant (FN-5937) intact for non-main integration + * branches too. */ export async function resolveCapturedBaseCommitSha( worktreePath: string, logger?: { warn: (msg: string) => void }, + integrationBranch: string = "main", ): Promise { + const branch = integrationBranch.trim() || "main"; + // Shell-quote defensively; integration branch names are normalized upstream + // but may carry slashes (e.g. "release/2026-06") that are valid in refs. + const localRef = JSON.stringify(branch); + const originRef = JSON.stringify(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( - "git merge-base HEAD main 2>/dev/null || git merge-base HEAD origin/main", + `git merge-base HEAD ${localRef} 2>/dev/null || git merge-base HEAD ${originRef}`, { cwd: worktreePath, encoding: "utf-8" }, ); baseCommitSha = stdout.trim() || undefined; diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 1baaf8da1d..e92d23656d 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -99,6 +99,11 @@ export type GitMutationType = | "worktree:incomplete-detected" | "worktree:reanchored" | "worktree:auto-recovered" + // FNXC:Workspace 2026-06-21-20:10: workspace per-repo acquisition audit events (U2). + // -busy: another task holds the same sub-repo's acquisition exclusivity lock (KTD4). + // -failed: a sub-repo worktree acquisition threw; surfaced + audited, never swallowed. + | "worktree:workspace-repo-acquire-busy" + | "worktree:workspace-repo-acquire-failed" /** * worktrunk run-audit metadata shape: * diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 794413ca5d..07ce2d722a 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -34,6 +34,10 @@ import { import type { RunAuditor } from "./run-audit.js"; import { writeSecretsEnvFile } from "./secrets-env-writer.js"; import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js"; +import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; +import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; +import { resolveIntegrationBranch } from "./integration-branch.js"; +import { activeSessionRegistry, type ActiveSessionRegistry } from "./active-session-registry.js"; const execAsync = promisify(exec); @@ -604,47 +608,184 @@ export interface AcquireWorkspaceRepoWorktreeOptions { settings: Partial; logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void }; secretsStore?: Pick; + audit?: Pick; + runContext?: RunMutationContext; + /** Test seam: inject the path-keyed exclusivity registry (defaults to the process singleton). */ + registry?: ActiveSessionRegistry; } +/* +FNXC:Workspace 2026-06-21-20:10: +Acquisition-time exclusivity owner key for the same-sub-repo lock (U2/KTD4). The +registry record is keyed by the sub-repo ABSOLUTE path and carries this distinct +ownerKey so it never collides with the executor's later "executor"/"step-session" +registration on the produced WORKTREE path. +*/ +const WORKSPACE_REPO_ACQUIRE_OWNER_KEY = "workspace-repo-acquire"; + export async function acquireWorkspaceRepoWorktree( opts: AcquireWorkspaceRepoWorktreeOptions, -): Promise<{ worktreePath: string; branch: string; alreadyAcquired: boolean }> { - const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore } = opts; +): Promise<{ worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean }> { + const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext } = opts; + const registry = opts.registry ?? activeSessionRegistry; const { join } = await import("node:path"); const existing = task.workspaceWorktrees?.[repoRelPath]; if (existing) { + /* + FNXC:Workspace 2026-06-21-20:10: + Idempotency across (taskId, repo): a re-acquire of an already-acquired sub-repo + returns the persisted entry verbatim — no second identity-guard install, no + re-capture of the base SHA, no second exclusivity registration. + */ return { ...existing, alreadyAcquired: true }; } const repoAbsPath = join(workspaceRootDir, repoRelPath); /* - FNXC:WorkspaceWorktree 2026-06-21-19:05: - Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` - is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites - those singular fields on the task row after each acquisition. Passing the live task straight - through means the second repo's acquisition sees the first repo's `task.worktree` (which exists - on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo - contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo - helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in - `task.workspaceWorktrees`, not the singular column. + FNXC:Workspace 2026-06-21-20:10: + Same-sub-repo exclusivity (KTD4): register the sub-repo absolute path in the + path-keyed activeSessionRegistry BEFORE acquiring so two concurrent workspace + tasks contending for the SAME sub-repo are serialized. WorktreePool is a recycle + cache, not a cross-task lock, and disjoint-scope contention on one sub-repo is + otherwise unprotected (file-scope leases don't catch it). The entry is keyed by + the sub-repo path with a distinct ownerKey so it does not collide with the + executor's later session registration on the produced worktree path. We release + it once acquisition completes (success or failure) — it guards the acquisition + critical section, not the whole task lifetime. */ - const result = await acquireTaskWorktree({ - task: { ...task, worktree: undefined, branch: undefined }, - rootDir: repoAbsPath, - store, - settings, - logger, - secretsStore, - runInitCommand: true, + const exclusivityHolder = registry.lookupByPath(repoAbsPath); + if (exclusivityHolder && exclusivityHolder.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY && exclusivityHolder.taskId !== task.id) { + const message = `sub-repo ${repoRelPath} is being acquired by ${exclusivityHolder.taskId}; serializing concurrent workspace acquisition`; + logger?.warn(`${task.id}: ${message}`); + await store.logEntry(task.id, message, undefined, runContext); + const err = new WorkspaceRepoAcquireBusyError(repoRelPath, exclusivityHolder.taskId, task.id); + await audit?.git({ + type: "worktree:workspace-repo-acquire-busy", + target: repoAbsPath, + metadata: { repoRelPath, holderTaskId: exclusivityHolder.taskId, requestingTaskId: task.id }, + }); + throw err; + } + registry.registerPath(repoAbsPath, { + taskId: task.id, + kind: "workspace-repo-acquire", + ownerKey: WORKSPACE_REPO_ACQUIRE_OWNER_KEY, }); - const updated: Record = { - ...(task.workspaceWorktrees ?? {}), - [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, - }; - await store.updateTask(task.id, { workspaceWorktrees: updated }); + try { + /* + FNXC:WorkspaceWorktree 2026-06-21-19:05: + Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` + is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites + those singular fields on the task row after each acquisition. Passing the live task straight + through means the second repo's acquisition sees the first repo's `task.worktree` (which exists + on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo + contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo + helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in + `task.workspaceWorktrees`, not the singular column. + */ + const result = await acquireTaskWorktree({ + task: { ...task, worktree: undefined, branch: undefined }, + rootDir: repoAbsPath, + store, + settings, + logger, + secretsStore, + audit, + runContext, + runInitCommand: true, + }); - return { worktreePath: result.worktreePath, branch: result.branch, alreadyAcquired: false }; + /* + FNXC:Workspace 2026-06-21-20:10: + Identity guard (single-repo parity): acquireTaskWorktree above runs WITHOUT a + createWorktree override, so the default native backend installs NO identity + hooks for a sub-repo worktree. Install the same guard the executor installs for + single-repo tasks (executor.ts identity-guard call), passing the SAME settings + args (commitMsgHookEnabled / taskPrefix / first taskAttributionTrailerName) so a + commit on a non-fusion/ branch is refused inside every sub-repo worktree too. + */ + await installTaskWorktreeIdentityGuard({ + worktreePath: result.worktreePath, + taskId: task.id, + commitMsgHookEnabled: settings.commitMsgHookEnabled, + taskPrefix: settings.taskPrefix, + taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], + }); + + /* + FNXC:Workspace 2026-06-21-20:10: + Per-repo base SHA (KTD3): resolve THIS sub-repo's integration branch with the + shared settings.integrationBranch override STRIPPED. resolveIntegrationBranch + checks settings.integrationBranch FIRST, so without stripping it every sub-repo + would resolve to the shared workspace branch — defeating per-repo resolution. + With it undefined, each sub-repo falls through to its own origin/HEAD. Capture + the base local-first against that branch so local-ahead-of-origin integration + tips don't inflate the per-repo diff (FN-5937 invariant, per sub-repo). + */ + const integrationBranch = await resolveIntegrationBranch( + repoAbsPath, + { ...settings, integrationBranch: undefined }, + { logger }, + ); + const baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); + + const updated: Record = { + ...(task.workspaceWorktrees ?? {}), + [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha }, + }; + await store.updateTask(task.id, { workspaceWorktrees: updated }); + + return { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha, alreadyAcquired: false }; + } catch (err) { + /* + FNXC:Workspace 2026-06-21-20:10: + Acquisition failure must surface an error and leave an audit trail (no swallowed + stall): persist the failure as an audit event + task log, then re-throw so the + caller observes the failure rather than silently proceeding with an unacquired + sub-repo. + */ + if (!(err instanceof WorkspaceRepoAcquireBusyError)) { + const message = err instanceof Error ? err.message : String(err); + logger?.error?.(`${task.id}: workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`); + await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message }, + }); + } + throw err; + } finally { + /* + FNXC:Workspace 2026-06-21-20:10: + Release the acquisition-time exclusivity entry only when WE hold it. The busy-path + throw above does NOT enter this try (it short-circuits before registerPath), so a + serialized loser never unregisters the winner's entry. + */ + const held = registry.lookupByPath(repoAbsPath); + if (held && held.taskId === task.id && held.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY) { + registry.unregisterPath(repoAbsPath); + } + } +} + +/* +FNXC:Workspace 2026-06-21-20:10: +Thrown when a second workspace task tries to acquire a sub-repo already inside +another task's acquisition critical section (KTD4). Distinct from generic +acquisition failures so the caller (and tests) can tell "serialized, retry later" +apart from "this sub-repo is broken". +*/ +export class WorkspaceRepoAcquireBusyError extends Error { + constructor( + public readonly repoRelPath: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super(`workspace sub-repo ${repoRelPath} acquisition is in progress for task ${holderTaskId}`); + this.name = "WorkspaceRepoAcquireBusyError"; + } } From d5fa8654f70db21bed3aad5c7661137e3cfd14a4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:33:56 -0700 Subject: [PATCH 021/265] =?UTF-8?q?fix(review):=20Phase=20A=20workspace=20?= =?UTF-8?q?hardening=20=E2=80=94=20tool=20errors,=20activeWorktrees,=20non?= =?UTF-8?q?-fatal=20acquire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ce-code-review (5 personas) on Phase A. No P0; the workspace-root-removal path was ruled out and the contract changes verified additive. Applied: P1: fn_acquire_repo_worktree now catches WorkspaceRepoAcquireBusyError (and generic failures) and returns a sanitized retryable isError instead of an uncaught throw into the agent loop; runContext is forwarded so audit/log keep attribution. Per-repo acquired worktree paths are now registered into the executor's activeWorktrees Set (via an onAcquired callback) — previously the Set only held the browse-only root, making the U1 per-repo liveness invariant hollow. Post-acquire identity-guard install and base-SHA capture are now non-fatal (log-and-continue): a hook/branch failure no longer strands the on-disk worktree (the worktree is usable without the guard; an undefined baseCommitSha is already an accepted state). P2: the KTD3 settings-strip also strips settings.baseBranch (resolveFromSettings falls back integrationBranch → baseBranch, so a shared baseBranch leaked); the workspaceWorktrees write re-reads the task fresh before merging to avoid a sibling-repo clobber on sequential acquires (store-level atomic merge deferred to Phase B); the busy-path logging is wrapped so it can't mask the busy error; the TaskCard memo compares key-sets not counts; the stuck-kill no-op for workspace tasks is now logged; the exclusivity check-then-act synchrony is documented. Residuals (Phase B): per-repo worktree teardown, orphan-scan coverage, reaper dedup, store-level atomic merge. Gate green: typecheck, lint, build, test:gate (649+58), affected (25 + TaskCard 251). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/app/components/TaskCard.tsx | 9 +- .../worktree-acquisition-workspace.test.ts | 74 +++++++++++ packages/engine/src/agent-tools.ts | 65 ++++++++-- packages/engine/src/executor.ts | 19 ++- packages/engine/src/worktree-acquisition.ts | 122 ++++++++++++++---- 5 files changed, 246 insertions(+), 43 deletions(-) diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 46748eb497..14d93678e8 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -626,10 +626,13 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.blockedBy === nextTask.blockedBy && previousTask.overlapBlockedBy === nextTask.overlapBlockedBy && previousTask.worktree === nextTask.worktree && - // FNXC:Workspace 2026-06-21-00:00: re-render the card when a workspace task acquires/ + // FNXC:Workspace 2026-06-21-22:30: re-render the card when a workspace task acquires/ // releases sub-repo worktrees so the "N repos acquired" placeholder stays current (U3). - Object.keys(previousTask.workspaceWorktrees ?? {}).length === - Object.keys(nextTask.workspaceWorktrees ?? {}).length && + // F7 — compare the sorted key SETS, not just the count: a same-count repo swap (one + // repo released, a different one acquired) keeps the count but must still re-render, + // otherwise the placeholder shows a stale repo set. + JSON.stringify(Object.keys(previousTask.workspaceWorktrees ?? {}).sort()) === + JSON.stringify(Object.keys(nextTask.workspaceWorktrees ?? {}).sort()) && previousTask.branch === nextTask.branch && previousTask.baseBranch === nextTask.baseBranch && previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks && diff --git a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts index d3e9e95349..3267157db7 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts @@ -270,4 +270,78 @@ describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout: // The exclusivity entry is released even on the failure path. expect(registry.isPathActive(join(fixture.rootDir, "does-not-exist"))).toBe(false); }); + + /* + FNXC:Workspace 2026-06-21-22:30: + F4 — resolveFromSettings falls back integrationBranch → settings.baseBranch → + origin/HEAD. A shared settings.baseBranch must be STRIPPED alongside + integrationBranch, otherwise a baseBranch absent from this sub-repo leaks through + and the per-repo base resolves against the wrong branch. Here repo-a's only branch + is its own origin/HEAD (develop); a shared baseBranch of 'shared-trunk' (absent in + the sub-repo) must NOT be honored — the base must resolve to develop's tip. + */ + it("strips a shared settings.baseBranch so the base resolves against the sub-repo's own origin/HEAD (KTD3 / F4)", async () => { + fixture = await createWorkspaceFixture(["repo-a"], "develop"); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git push -u origin develop"); + git(repoA, "git remote set-head origin develop"); + const developTip = git(repoA, "git rev-parse develop"); + + const { store, current } = makeFakeStore(makeTask("FN-6")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + // A shared baseBranch (no integrationBranch) that does NOT exist in this + // sub-repo. If it leaked through, base capture would resolve against + // 'shared-trunk' instead of develop. + settings: { ...SETTINGS, baseBranch: "shared-trunk" } as Partial, + registry, + }); + + expect(result.baseCommitSha).toBe(developTip); + }); + + /* + FNXC:Workspace 2026-06-21-22:30: + F5 — two sequential acquires for DIFFERENT sub-repos in one task must each persist + their own workspaceWorktrees entry. The acquisition re-reads the task fresh before + the merge so the second acquire does not clobber the first repo's entry. + */ + it("preserves a sibling sub-repo's workspaceWorktrees entry across two different-repo acquires (F5)", async () => { + fixture = await createWorkspaceFixture(["repo-a", "repo-b"]); + const { store, current } = makeFakeStore(makeTask("FN-7")); + const registry = new ActiveSessionRegistry(); + + const first = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(first.alreadyAcquired).toBe(false); + + const second = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-b", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(second.alreadyAcquired).toBe(false); + + // Both entries survive — the second acquire merged into the latest map, not the + // stale snapshot, so repo-a was not clobbered. + const persisted = current().workspaceWorktrees ?? {}; + expect(persisted["repo-a"]?.worktreePath).toBe(first.worktreePath); + expect(persisted["repo-b"]?.worktreePath).toBe(second.worktreePath); + }); }); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 99e87b2cb3..a6fae43a06 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -28,7 +28,7 @@ import { computeApprovalDedupeKey } from "./agent-action-gate.js"; import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/message-delivery.js"; import { emitGoalRetrievalAudit } from "./goal-anchoring-audit.js"; import { recordRetry } from "./retry-burned-logger.js"; -import { acquireWorkspaceRepoWorktree } from "./worktree-acquisition.js"; +import { acquireWorkspaceRepoWorktree, WorkspaceRepoAcquireBusyError } from "./worktree-acquisition.js"; // ── Tool parameter schemas (canonical definitions) ──────────────────────── @@ -3601,8 +3601,19 @@ export function createAcquireRepoWorktreeTool(opts: { logger?: { log: (m: string) => void; warn: (m: string) => void }; secretsStore?: Pick; runContext?: RunMutationContext; + audit?: Pick; + /* + FNXC:Workspace 2026-06-21-22:30: + F2 — executor-supplied callback invoked after a SUCCESSFUL fresh acquire so the + acquired sub-repo worktree path is registered in the executor's per-task + activeWorktrees Set (KTD2). Without this the Set only ever held the browse-only + root and the "task holds N sub-repo paths" invariant was hollow — owner/liveness + checks never saw live sub-repo worktrees. Not called on the already-acquired + short-circuit (the path was registered on the original fresh acquire). + */ + onAcquired?: (worktreePath: string) => void; }): ToolDefinition { - const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext } = opts; + const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext, audit, onAcquired } = opts; return { name: "fn_acquire_repo_worktree", label: "Acquire Repo Worktree", @@ -3621,15 +3632,47 @@ export function createAcquireRepoWorktreeTool(opts: { }; } const freshTask = await store.getTask(task.id); - const result = await acquireWorkspaceRepoWorktree({ - repoRelPath: repo, - workspaceRootDir, - task: freshTask, - store, - settings, - logger, - secretsStore, - }); + /* + FNXC:Workspace 2026-06-21-22:30: + F1 — acquireWorkspaceRepoWorktree can throw WorkspaceRepoAcquireBusyError on + same-sub-repo contention (KTD4) or a generic failure. Both must surface as a + structured isError tool result, never an uncaught throw that crashes the agent + loop. The busy message is sanitized — it does NOT leak the holder task id into + agent-facing text (only into details). runContext is forwarded so the helper's + audit/log entries keep run attribution. + */ + let result: Awaited>; + try { + result = await acquireWorkspaceRepoWorktree({ + repoRelPath: repo, + workspaceRootDir, + task: freshTask, + store, + settings, + logger, + secretsStore, + audit, + runContext, + }); + } catch (err) { + if (err instanceof WorkspaceRepoAcquireBusyError) { + return { + content: [{ type: "text" as const, text: `Sub-repo ${repo} is temporarily locked by another task's acquisition; retry fn_acquire_repo_worktree shortly.` }], + details: { holderTaskId: err.holderTaskId }, + isError: true, + }; + } + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text" as const, text: `ERROR: Failed to acquire worktree for ${repo}: ${message}` }], + details: {}, + isError: true, + }; + } + // FNXC:Workspace 2026-06-21-22:30: F2 — register a freshly-acquired sub-repo worktree in the executor's activeWorktrees Set (skip the already-acquired short-circuit; that path was registered on its original fresh acquire). + if (!result.alreadyAcquired) { + onAcquired?.(result.worktreePath); + } await store.logEntry( task.id, result.alreadyAcquired diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 1f7c91769b..a5e9063a5b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -7708,7 +7708,7 @@ export class TaskExecutor { } } // end !this.workspaceConfig preflight gate (FNXC:Workspace KTD1) - // FNXC:Workspace 2026-06-21-12:00: KTD2 — register the worktree path under the task's Set. In workspace mode `worktreePath` is the browse-only root; per-repo paths are added as the agent acquires them. Non-workspace tasks add exactly one path → a one-element set (unchanged liveness/owner semantics). + // FNXC:Workspace 2026-06-21-12:00: KTD2 — register the worktree path under the task's Set. In workspace mode `worktreePath` is the browse-only root; per-repo sub-repo worktree paths ARE now added to the same Set as the agent acquires them (F2: fn_acquire_repo_worktree's onAcquired callback → addActiveWorktree), so the Set holds root + N sub-repo paths, not just the root. Non-workspace tasks add exactly one path → a one-element set (unchanged liveness/owner semantics). this.addActiveWorktree(task.id, worktreePath); executorLog.log(`${task.id}: worktree ready at ${worktreePath}`); @@ -8406,6 +8406,9 @@ export class TaskExecutor { logger: executorLog, secretsStore: this.options.secretsStore, runContext: engineRunContext, + audit, + // FNXC:Workspace 2026-06-21-22:30: F2 — register each freshly-acquired sub-repo worktree path in this task's activeWorktrees Set (KTD2) so owner/liveness checks see live per-repo worktrees, not just the browse-only root. + onAcquired: (worktreePath: string) => this.addActiveWorktree(task.id, worktreePath), })); } @@ -15295,6 +15298,20 @@ You have access to the file system to review changes.${verdictBlock}`; const preserveProgress = settings.preserveProgressOnStuckRequeue !== false; const latestTask = await this.store.getTask(taskId); const worktreePath = this.getWorktreePath(taskId) ?? latestTask.worktree; + /* + FNXC:Workspace 2026-06-21-22:30: + F8 — observability for the workspace case. A workspace task has no singular + worktree (getWorktreePath returns undefined for a multi-worktree task, and + latestTask.worktree is null on the browse-only root), so the removeWorktree + block below silently no-ops. Per-repo teardown is Phase B; until then make + the skip visible rather than silent. Behavior is unchanged. + */ + if (this.workspaceConfig && !worktreePath) { + await this.store.logEntry( + taskId, + `workspace task ${taskId}: no singular worktree to force-requeue (per-repo teardown is Phase B)`, + ); + } await this.store.logEntry( taskId, `Force-kill cleanup starting after stuck-kill unwind timeout — reaping in-flight surfaces and worktree`, diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 07ce2d722a..352ef4a036 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -657,17 +657,35 @@ export async function acquireWorkspaceRepoWorktree( */ const exclusivityHolder = registry.lookupByPath(repoAbsPath); if (exclusivityHolder && exclusivityHolder.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY && exclusivityHolder.taskId !== task.id) { - const message = `sub-repo ${repoRelPath} is being acquired by ${exclusivityHolder.taskId}; serializing concurrent workspace acquisition`; - logger?.warn(`${task.id}: ${message}`); - await store.logEntry(task.id, message, undefined, runContext); const err = new WorkspaceRepoAcquireBusyError(repoRelPath, exclusivityHolder.taskId, task.id); - await audit?.git({ - type: "worktree:workspace-repo-acquire-busy", - target: repoAbsPath, - metadata: { repoRelPath, holderTaskId: exclusivityHolder.taskId, requestingTaskId: task.id }, - }); + /* + FNXC:Workspace 2026-06-21-22:30: + F6 — the busy short-circuit's logEntry/audit are best-effort observability; if + either throws (e.g. a DB write hiccup) it must NOT replace the + WorkspaceRepoAcquireBusyError the caller relies on to classify "serialized, + retry later". Swallow logging failures so the busy error is what propagates. + */ + try { + const message = `sub-repo ${repoRelPath} is being acquired by ${exclusivityHolder.taskId}; serializing concurrent workspace acquisition`; + logger?.warn(`${task.id}: ${message}`); + await store.logEntry(task.id, message, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-busy", + target: repoAbsPath, + metadata: { repoRelPath, holderTaskId: exclusivityHolder.taskId, requestingTaskId: task.id }, + }); + } catch { + // best-effort observability only — never mask the busy error + } throw err; } + /* + FNXC:Workspace 2026-06-21-22:30: + F9 — no `await` may be inserted between lookupByPath and registerPath: the + atomicity of the exclusivity claim depends on staying in one synchronous slice. + An interleaved await would let a second task pass the lookup gate before this + task registers, defeating the same-sub-repo serialization (KTD4). + */ registry.registerPath(repoAbsPath, { taskId: task.id, kind: "workspace-repo-acquire", @@ -698,6 +716,17 @@ export async function acquireWorkspaceRepoWorktree( runInitCommand: true, }); + /* + FNXC:Workspace 2026-06-21-22:30: + F3 — post-acquire steps are NON-FATAL. Once acquireTaskWorktree has created the + on-disk worktree, a failure of the identity-guard install or the base-SHA capture + must NOT strand that worktree (the previous catch re-threw, leaving the worktree + orphaned while the exclusivity entry released). The worktree is usable without the + identity guard, and an undefined baseCommitSha is already an accepted state. Only a + failure of acquireTaskWorktree ITSELF fails the acquisition. Each step is wrapped to + log a warning (and emit the existing failure audit event) but CONTINUE. + */ + /* FNXC:Workspace 2026-06-21-20:10: Identity guard (single-repo parity): acquireTaskWorktree above runs WITHOUT a @@ -707,33 +736,70 @@ export async function acquireWorkspaceRepoWorktree( args (commitMsgHookEnabled / taskPrefix / first taskAttributionTrailerName) so a commit on a non-fusion/ branch is refused inside every sub-repo worktree too. */ - await installTaskWorktreeIdentityGuard({ - worktreePath: result.worktreePath, - taskId: task.id, - commitMsgHookEnabled: settings.commitMsgHookEnabled, - taskPrefix: settings.taskPrefix, - taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], - }); + try { + await installTaskWorktreeIdentityGuard({ + worktreePath: result.worktreePath, + taskId: task.id, + commitMsgHookEnabled: settings.commitMsgHookEnabled, + taskPrefix: settings.taskPrefix, + taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], + }); + } catch (guardErr) { + // FNXC:Workspace 2026-06-21-22:30: F3 — identity-guard install is non-fatal; worktree is usable without it. + const message = guardErr instanceof Error ? guardErr.message : String(guardErr); + logger?.warn(`${task.id}: identity-guard install failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); + await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, + }); + } /* FNXC:Workspace 2026-06-21-20:10: Per-repo base SHA (KTD3): resolve THIS sub-repo's integration branch with the - shared settings.integrationBranch override STRIPPED. resolveIntegrationBranch - checks settings.integrationBranch FIRST, so without stripping it every sub-repo - would resolve to the shared workspace branch — defeating per-repo resolution. - With it undefined, each sub-repo falls through to its own origin/HEAD. Capture - the base local-first against that branch so local-ahead-of-origin integration - tips don't inflate the per-repo diff (FN-5937 invariant, per sub-repo). + shared settings.integrationBranch AND settings.baseBranch overrides STRIPPED. + resolveFromSettings (integration-branch.ts) falls back integrationBranch → + baseBranch → origin/HEAD, so leaving either set means every sub-repo resolves to + the shared workspace branch — defeating per-repo resolution (F4). With both + undefined, each sub-repo falls through to its own origin/HEAD. Capture the base + local-first against that branch so local-ahead-of-origin integration tips don't + inflate the per-repo diff (FN-5937 invariant, per sub-repo). */ - const integrationBranch = await resolveIntegrationBranch( - repoAbsPath, - { ...settings, integrationBranch: undefined }, - { logger }, - ); - const baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); + let baseCommitSha: string | undefined; + try { + const integrationBranch = await resolveIntegrationBranch( + repoAbsPath, + { ...settings, integrationBranch: undefined, baseBranch: undefined }, + { logger }, + ); + baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); + } catch (baseErr) { + // FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state. + const message = baseErr instanceof Error ? baseErr.message : String(baseErr); + logger?.warn(`${task.id}: base-SHA capture failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); + await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, + }); + } + /* + FNXC:Workspace 2026-06-21-22:30: + F5 — re-read the task fresh immediately before building the merged + workspaceWorktrees map. store.updateTask wholesale-replaces the map, and the + `task` snapshot was read earlier; two sequential acquires for DIFFERENT sub-repos + in one task would otherwise clobber a sibling's entry. Merging into the LATEST map + closes the common sequential-tool-call case. NOTE: a fully-atomic store-level + per-repo merge is the complete fix (it also covers truly-concurrent writes); it is + deferred to Phase B, which exercises multi-repo acquisition. + */ + const latest = await store.getTask(task.id); const updated: Record = { - ...(task.workspaceWorktrees ?? {}), + ...(latest.workspaceWorktrees ?? {}), [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha }, }; await store.updateTask(task.id, { workspaceWorktrees: updated }); From fc9423e465328c1d3632c1426690f8b7b56c9ca3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 22:29:25 -0700 Subject: [PATCH 022/265] =?UTF-8?q?feat(workspace):=20Phase=20B=20U1=20?= =?UTF-8?q?=E2=80=94=20per-repo=20change=20capture,=20contamination,=20and?= =?UTF-8?q?=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In workspace mode the executor now captures changes and verifies worktree invariants per acquired sub-repo instead of degrading to empty against the non-git root. Post-session capture (:7898) gains a workspace branch that loops task.workspaceWorktrees and reuses captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, …) per repo — inheriting resolveDiffBaseRef's merge-base fallback (repo baseCommitSha may be undefined) and the filterFilesToOwnTaskCommits contamination/divergence audit — then prefixes each repo's files with the repo path into task.modifiedFiles. Branch attribution runs per sub-repo (cwd), never against the root. The no-op assertCleanBranchAtBase is not iterated. verifyWorktreeInvariants is un-stubbed for workspace mode: it iterates every workspaceWorktrees entry asserting toplevel match + HEAD on fusion/, and returns the FIRST failing repo while preserving the exact discriminated union {ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected} (the :10889 consumer switches on reason for requeue/handoff) — the new repo field is additive. Singular non-workspace path unchanged. Real two-repo fixture tests (capture A+B repo-prefixed vs own base, undefined-base fallback, foreign-commit contamination audit, wrong_branch verify failure, single-repo regression). Gate green: typecheck, lint, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...pace-phase-b-u1-per-repo-capture-verify.md | 5 + .../executor-workspace-capture.test.ts | 244 ++++++++++++++++++ packages/engine/src/executor.ts | 145 ++++++++++- 3 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 .changeset/workspace-phase-b-u1-per-repo-capture-verify.md create mode 100644 packages/engine/src/__tests__/executor-workspace-capture.test.ts diff --git a/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md b/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md new file mode 100644 index 0000000000..e4a27b33dc --- /dev/null +++ b/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase B, U1): per-repo post-session change capture, contamination detection, and worktree-invariant verification. In workspace mode the executor now loops `task.workspaceWorktrees`, reusing `captureModifiedFiles` per sub-repo (diffing each against its own `baseCommitSha`, with a merge-base fallback when undefined) to aggregate repo-prefixed `task.modifiedFiles` and surface per-repo contamination, and un-stubs `verifyWorktreeInvariants` to assert each acquired worktree's git toplevel and `fusion/` branch. Single-repo behavior is unchanged. diff --git a/packages/engine/src/__tests__/executor-workspace-capture.test.ts b/packages/engine/src/__tests__/executor-workspace-capture.test.ts new file mode 100644 index 0000000000..fa19b2f915 --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-capture.test.ts @@ -0,0 +1,244 @@ +/* +FNXC:Workspace 2026-06-21-23:30: +U1 per-repo capture + contamination + worktree-invariant tests (KTD1/KTD2). These drive the REAL TaskExecutor methods against a REAL two-repo git fixture under a NON-git workspace root (createWorkspaceFixture), so any leaked rootDir git preflight would actually fail and a hand-built `git diff` against an undefined base would blow up. + +Seam choice (FN-5048): we set `(executor as any).workspaceConfig` directly (loadWorkspaceConfig has its own unit) and create real `fusion/` worktrees per sub-repo with real commits — no mock-the-world child_process. Capture is exercised through `captureWorkspaceModifiedFiles` (the helper the post-session path at executor.ts:7900 calls) and verification through `verifyWorktreeInvariants`. Real git is used only where the invariant requires it. + +Coverage: +- happy: edits in repo A + B → aggregated modifiedFiles carry repo-prefixed paths from BOTH, each diffed against its own baseCommitSha. +- edge: a repo with baseCommitSha undefined → capture still works via resolveDiffBaseRef's merge-base fallback (no `git diff undefined..HEAD`). +- contamination: a foreign commit (feat(FN-OTHER):) in a sub-repo's range → the filterFilesToOwnTaskCommits divergence audit fires (task:worktree-contamination-detected) for that repo, and the foreign file is excluded from attributed files. +- error: a worktree HEAD off fusion/ → verifyWorktreeInvariants returns {ok:false, reason:'wrong_branch', repo, observed, expected} (NOT {ok:true}); the reason enum is preserved for the :10889 consumer. +- regression: a single-repo (non-workspace) task → capture/verify identical to today. +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore, WorkspaceConfig } from "@fusion/core"; +import { TaskExecutor } from "../executor.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +function createStore(overrides: Partial> = {}): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + getRunContextFor: vi.fn(), + on: emitter.on.bind(emitter), + ...overrides, + }) as unknown as TaskStore & EventEmitter; +} + +function makeTask(id = "FN-WS-1", overrides: Partial = {}): Task { + return { + id, + title: "Workspace task", + description: "", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +// Capture attribution requires a digit-form task id (`FN-\d+`); the branch-attribution +// subject parser only attributes `feat(FN-1001):` style subjects, so the KTD2-era +// `FN-WS-1` placeholder would never attribute a commit. Use a real numeric id here. +const TASK_ID = "FN-1001"; +const BRANCH = "fusion/fn-1001"; + +/** Configure git identity in a freshly-created worktree (worktrees don't inherit user.* on all platforms). */ +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +/** + * Add a real fusion/ worktree to a sub-repo, commit one own-attributed edit + * onto that branch, and return { worktreePath, baseCommitSha } for task.workspaceWorktrees. + * baseCommitSha is the sub-repo's pre-edit HEAD so the diff range is base..HEAD. + */ +function addRepoWorktreeWithOwnEdit( + fx: WorkspaceFixture, + repoRel: string, + fileName: string, +): { worktreePath: string; baseCommitSha: string } { + const repoDir = fx.repoPath(repoRel); + const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD"); + const worktreePath = path.join(repoDir, ".worktrees", "fn-ws-1"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + mkdirSync(path.dirname(path.join(worktreePath, fileName)), { recursive: true }); + writeFileSync(path.join(worktreePath, fileName), "// own change\n", "utf-8"); + execSync(`git add ${fileName}`, { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): edit ${fileName}"`, { cwd: worktreePath, stdio: "pipe" }); + return { worktreePath, baseCommitSha }; +} + +function workspaceExecutor(fx: WorkspaceFixture, store = createStore()): TaskExecutor { + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + return executor; +} + +describeIfGit("U1 KTD1 — per-repo capture aggregates repo-prefixed paths", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("happy: edits in repo A + B are diffed against their own base and repo-prefixed", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts"); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const files = await (executor as any).captureWorkspaceModifiedFiles(task); + expect(files).toContain("repo-a/src/a.ts"); + expect(files).toContain("repo-b/src/b.ts"); + expect(files).toHaveLength(2); + }); + + it("edge: a repo with undefined baseCommitSha still captures via merge-base fallback (no `git diff undefined..HEAD`)", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + // baseCommitSha intentionally undefined → resolveDiffBaseRef merge-base(HEAD, main). + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH }, + }, + }); + + const files = await (executor as any).captureWorkspaceModifiedFiles(task); + expect(files).toEqual(["repo-a/src/a.ts"]); + }); + + it("contamination: a foreign commit in a sub-repo range fires the divergence audit and is excluded from attributed files", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + // Land a FOREIGN commit (different FN-id) onto the same fusion/ branch range. + const foreignFile = "src/foreign.ts"; + writeFileSync(path.join(a.worktreePath, "src", "foreign.ts"), "// foreign\n", "utf-8"); + execSync(`git add ${foreignFile}`, { cwd: a.worktreePath, stdio: "pipe" }); + execSync('git commit -m "feat(FN-OTHER): sneaky foreign change"', { cwd: a.worktreePath, stdio: "pipe" }); + + const dbAudit = vi.fn().mockResolvedValue(undefined); + const audit = { + database: dbAudit, + filesystem: vi.fn().mockResolvedValue(undefined), + git: vi.fn().mockResolvedValue(undefined), + }; + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + }, + }); + + const files = await (executor as any).captureWorkspaceModifiedFiles(task, audit as any, "post-session"); + // Own file attributed, foreign file excluded from the attributed set. + expect(files).toEqual(["repo-a/src/a.ts"]); + expect(files).not.toContain("repo-a/src/foreign.ts"); + // The contamination/divergence audit fired for this repo (raw 2 files vs attributed 1). + const contaminationCall = dbAudit.mock.calls.find( + ([evt]) => evt?.type === "task:worktree-contamination-detected", + ); + expect(contaminationCall).toBeTruthy(); + expect(contaminationCall![0].metadata.rawDiffFileCount).toBeGreaterThan(contaminationCall![0].metadata.attributedFileCount); + }); +}); + +describeIfGit("U1 KTD2 — verifyWorktreeInvariants iterates per worktree, preserving the result union", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("happy: every worktree on fusion/ with matching toplevel → {ok:true}", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts"); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result).toEqual({ ok: true }); + }); + + it("error: a worktree HEAD off fusion/ → {ok:false, reason:'wrong_branch', repo, observed, expected} (NOT {ok:true})", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts"); + // Drift repo-b's worktree off fusion/ onto a different branch. + execSync("git checkout -b some-other-branch", { cwd: b.worktreePath, stdio: "pipe" }); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result.ok).toBe(false); + expect(result.reason).toBe("wrong_branch"); + expect(result.repo).toBe("repo-b"); + expect(result.observed).toBe("some-other-branch"); + expect(result.expected).toBe(BRANCH); + }); + + it("regression: a zero-acquire workspace task (empty map) verifies vacuously → {ok:true}", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { branch: BRANCH, workspaceWorktrees: {} }); + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result).toEqual({ ok: true }); + }); +}); + +describeIfGit("U1 — single-repo (non-workspace) task: capture/verify unchanged", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("regression: non-workspace verifyWorktreeInvariants still runs the singular path and passes for a real worktree", async () => { + fx = await createWorkspaceFixture(); + // Single-repo executor rooted at repo-a itself (no workspaceConfig). + const repoDir = fx.repoPath("repo-a"); + const worktreePath = path.join(repoDir, ".worktrees", "fn-001"); + const base = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8" }).trim(); + execSync(`git worktree add -b fusion/fn-001 ${worktreePath} HEAD`, { cwd: repoDir, stdio: "pipe" }); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "single.ts"), "// x\n", "utf-8"); + execSync("git add single.ts", { cwd: worktreePath, stdio: "pipe" }); + execSync('git commit -m "feat(FN-001): single"', { cwd: worktreePath, stdio: "pipe" }); + + const store = createStore(); + const executor = new TaskExecutor(store, repoDir); // no workspaceConfig → singular path + const task = makeTask("FN-001", { branch: "fusion/fn-001", worktree: worktreePath, baseCommitSha: base }); + + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index a5e9063a5b..16bb0d4a56 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -66,7 +66,7 @@ import { VERIFICATION_LOG_MAX_CHARS, type VerificationResult, } from "./verification-utils.js"; -import { canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js"; +import { canonicalFusionBranchName, canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js"; import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js"; import { Type, type Static } from "@earendil-works/pi-ai"; import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js"; @@ -7895,6 +7895,47 @@ export class TaskExecutor { const allSuccess = results.every(r => r.success); if (allSuccess) { const updatedTask = await this.store.getTask(task.id); + // FNXC:Workspace 2026-06-21-23:30: KTD1 — per-repo post-session capture. + // The singular call below runs UNGATED with worktreePath = the browse-only non-git workspace root and silently returns [] (resolveDiffBaseRef swallows the git failure at the root). In workspace mode there is nothing to diff at the root; the real changes live in each acquired sub-repo worktree. So we ADD (not replace) a workspace branch that loops `task.workspaceWorktrees` and reuses the EXISTING captureModifiedFiles per repo — reusing it (rather than hand-building `git diff ..HEAD`) gives us the merge-base fallback for an undefined repo.baseCommitSha (resolveDiffBaseRef) AND restores the contamination/divergence audit (filterFilesToOwnTaskCommits) for free per repo. Returned files are repo-prefixed (e.g. `repo-a/src/foo.ts`) and aggregated into task.modifiedFiles. + if (this.workspaceConfig) { + const workspaceWorktrees = updatedTask.workspaceWorktrees ?? {}; + const aggregated = await this.captureWorkspaceModifiedFiles(updatedTask, audit, "post-session"); + for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + // Per-repo branch-attribution audit (cwd = sub-repo). Run against repo.worktreePath/repo.branch, NOT the non-git root (a root call would fail and surface nothing). The contamination signal already rides on captureWorkspaceModifiedFiles above; this is the supplementary commit-attribution surface (FN-5233 pattern). + try { + const attributionBase = await this.resolveContaminationBaseRef(repo.worktreePath); + if (attributionBase && repo.branch) { + const attribution = await reportBranchAttribution(repo.worktreePath, repo.branch, attributionBase, task.id); + const hasAnomaly = attribution.foreign.length > 0 || attribution.unattributed.length > 0 || attribution.ownUntrailed.length > 0; + if (hasAnomaly) { + const summary = `branch-attribution anomalies on ${repoRel}@${repo.branch}: foreign=${attribution.foreign.length}, unattributed=${attribution.unattributed.length}, ownUntrailed=${attribution.ownUntrailed.length}, ownTrailed=${attribution.ownTrailed}`; + executorLog.warn(`${task.id}: ${summary}`); + await this.store.logEntry(task.id, `[branch-attribution] ${summary}`, undefined, this.getRunContextFor(task.id)); + await audit.git({ + type: "branch:attribution-anomaly", + target: repo.branch, + metadata: { + taskId: task.id, + repo: repoRel, + baseSha: attributionBase, + ownTrailed: attribution.ownTrailed, + foreign: attribution.foreign, + unattributed: attribution.unattributed, + ownUntrailed: attribution.ownUntrailed, + }, + }); + } + } + } catch (attributionErr: unknown) { + executorLog.warn(`${task.id}: post-session per-repo branch-attribution audit failed for ${repoRel}: ${attributionErr instanceof Error ? attributionErr.message : String(attributionErr)}`); + } + } + if (aggregated.length > 0) { + await this.store.updateTask(task.id, { modifiedFiles: aggregated }); + executorLog.log(`${task.id}: captured ${aggregated.length} modified files across ${Object.keys(workspaceWorktrees).length} sub-repo(s)`); + await audit.filesystem({ type: "file:capture-modified", target: task.id, metadata: { files: aggregated } }); + } + } else { const modifiedFiles = await this.captureModifiedFiles(worktreePath, updatedTask.baseCommitSha, task.id, audit, "post-session"); if (modifiedFiles.length > 0) { await this.store.updateTask(task.id, { modifiedFiles }); @@ -7936,6 +7977,7 @@ export class TaskExecutor { } catch (attributionErr: unknown) { executorLog.warn(`${task.id}: post-session branch-attribution audit failed: ${attributionErr instanceof Error ? attributionErr.message : String(attributionErr)}`); } + } // end !this.workspaceConfig singular capture (FNXC:Workspace KTD1) this.scheduleCompletedTaskWatchdog(task.id, "step-session completion"); if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after step-session completion")) { @@ -10502,10 +10544,87 @@ export class TaskExecutor { worktreePathOverride?: string, allowReanchor = true, options?: { noOpCompletion?: boolean; noOpCompletionReason?: string }, - ): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> { + ): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string; repo?: string }> { const settings = await this.store.getSettings(); - // FNXC:Workspace 2026-06-21-12:00: KTD1/KTD2 — workspace tasks have no root worktree and no single `task.worktree`; the singular per-task invariant is meaningless against the non-git root. Phase B (master U3) iterates this check per sub-repo worktree. Until then it is gated OFF in workspace mode so fn_task_done (its only caller path) does not requeue a zero-acquire workspace task for "missing task.worktree". + // FNXC:Workspace 2026-06-21-23:30: KTD2 — un-stubbed per-repo worktree-invariant verification. + // Phase A returned a flat {ok:true} stub here (no root worktree to verify against the non-git root). Phase B iterates every `task.workspaceWorktrees` entry, asserting (a) the sub-repo worktree's git toplevel matches the recorded repo.worktreePath and (b) its HEAD is on the recorded `fusion/` branch (repo.branch). The result union is PRESERVED EXACTLY — `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` — because the :10889 consumer switches on `reason` to drive requeue/handoff (:10894-10936). We ADD an optional `repo` field to the failure shape (purely additive; the consumer only reads reason/observed/expected) and return the FIRST failing repo. A zero-acquire workspace task (empty map) verifies vacuously → {ok:true}, matching Phase A so fn_task_done does not requeue it. if (this.workspaceConfig) { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + const expectedBranch = repo.branch || canonicalFusionBranchName(task.id); + // Skip git checks if the worktree dir is gone (mirrors the singular FN-009 carve-out below): completion does not require a live worktree on disk. + if (!existsSync(repo.worktreePath)) { + executorLog.log(`${task.id}: workspace worktree for ${repoRel} not found at ${repo.worktreePath} — skipping git validation`); + continue; + } + let expectedWorktreeRealpath: string; + try { + expectedWorktreeRealpath = canonicalizePath(repo.worktreePath); + } catch (error) { + return { + ok: false, + reason: "wrong_toplevel", + repo: repoRel, + observed: `unresolvable repo worktree (${repo.worktreePath}): ${error instanceof Error ? error.message : String(error)}`, + expected: `resolvable worktree for ${repoRel}`, + }; + } + try { + const { stdout } = await execAsync("git rev-parse --show-toplevel", { + cwd: repo.worktreePath, + encoding: "utf-8", + timeout: 10_000, + maxBuffer: 1024 * 1024, + }); + const observedTopLevelRaw = stdout.trim(); + if (observedTopLevelRaw) { + const observedTopLevel = canonicalizePath(observedTopLevelRaw); + if (observedTopLevel !== expectedWorktreeRealpath) { + return { + ok: false, + reason: "wrong_toplevel", + repo: repoRel, + observed: observedTopLevel, + expected: expectedWorktreeRealpath, + }; + } + } + } catch (error) { + return { + ok: false, + reason: "wrong_toplevel", + repo: repoRel, + observed: error instanceof Error ? error.message : String(error), + expected: expectedWorktreeRealpath, + }; + } + try { + const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + cwd: repo.worktreePath, + encoding: "utf-8", + timeout: 10_000, + maxBuffer: 1024 * 1024, + }); + const observedBranch = stdout.trim(); + if (observedBranch && observedBranch !== expectedBranch) { + return { + ok: false, + reason: "wrong_branch", + repo: repoRel, + observed: observedBranch, + expected: expectedBranch, + }; + } + } catch (error) { + return { + ok: false, + reason: "wrong_branch", + repo: repoRel, + observed: error instanceof Error ? error.message : String(error), + expected: expectedBranch, + }; + } + } return { ok: true }; } const branchName = resolveTaskWorkingBranch(task); @@ -12264,6 +12383,26 @@ ${failureFeedback} } } + /** + * FNXC:Workspace 2026-06-21-23:30: KTD1 — per-repo modified-file capture for workspace tasks. + * Loops `task.workspaceWorktrees` and REUSES `captureModifiedFiles` per sub-repo (NOT a hand-built `git diff`), so each repo gets: (a) resolveDiffBaseRef's merge-base fallback when repo.baseCommitSha is undefined, and (b) the filterFilesToOwnTaskCommits raw-vs-attributed divergence/contamination audit for free. Returned files are repo-prefixed (`/`) and aggregated, so a downstream File-Scope check / merge can attribute each change to its sub-repo. Returns [] for a zero-acquire workspace task. + */ + private async captureWorkspaceModifiedFiles( + task: Task, + audit?: RunAuditor, + source = "post-session", + ): Promise { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const aggregated: string[] = []; + for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + const repoFiles = await this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source); + for (const file of repoFiles) { + aggregated.push(`${repoRel}/${file}`); + } + } + return aggregated; + } + private async captureUncommittedModifiedFiles(worktreePath: string): Promise { try { const [unstaged, staged] = await Promise.all([ From 81edbeefbd6ced6b408d0ced4deb8e0915187f9e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 22:44:40 -0700 Subject: [PATCH 023/265] =?UTF-8?q?feat(workspace):=20Phase=20B=20U2=20?= =?UTF-8?q?=E2=80=94=20per-repo=20review=20(both=20sites)=20+=20fn=5Ftask?= =?UTF-8?q?=5Fdone=20verify=20+=20scope-leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In workspace mode both review entry points and the completion guards now iterate every acquired sub-repo. A shared reviewWorkspacePerRepo loops task.workspaceWorktrees and invokes the existing single-cwd reviewStep once per repo (cwd = the sub-repo — the reviewer agent runs its own git diff there), aggregating repo-tagged verdicts as a conjunction: the task is reviewed only if every repo APPROVEs; the first non-APPROVE repo's verdict becomes the aggregate. Both call sites loop — the in-session fn_review_step tool AND the step-inversion seam (createReviewStepTool and the stepReview workflow seam) — so no review surface silently scopes to the non-git root (FN-5893). reviewStep itself stays single-cwd; the callers loop. fn_task_done completion verification iterates per repo: verifyWorktreeInvariants (from U1) already covers all worktrees, and evaluateTaskDoneScopeLeak now loops each sub-repo (cwd + repo.baseCommitSha, repo-prefixed touched files vs the repo-prefixed declared File Scope), blocking on the first repo with off-scope files and naming it. Both return shapes preserved (ReviewResult; {blocked,message}). New workspace-paths.ts repo-prefix helper (deriveRepoForPath/splitRepoScopedPath/ deriveRepoScopeSubset; segment-wise longest-prefix match, unscoped fallback) — master U5 reuses it. Singular non-workspace path unchanged. 16 new fixture tests. Gate green: typecheck, lint, build, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ace-phase-b-u2-per-repo-review-taskdone.md | 5 + .../executor-workspace-taskdone.test.ts | 220 ++++++++++++++++++ .../src/__tests__/reviewer-workspace.test.ts | 213 +++++++++++++++++ packages/engine/src/executor.ts | 180 +++++++++++--- packages/engine/src/workspace-paths.ts | 117 ++++++++++ 5 files changed, 708 insertions(+), 27 deletions(-) create mode 100644 .changeset/workspace-phase-b-u2-per-repo-review-taskdone.md create mode 100644 packages/engine/src/__tests__/executor-workspace-taskdone.test.ts create mode 100644 packages/engine/src/__tests__/reviewer-workspace.test.ts create mode 100644 packages/engine/src/workspace-paths.ts diff --git a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md new file mode 100644 index 0000000000..efd5004886 --- /dev/null +++ b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase B, U2): per-repo review at both review entry points plus per-repo `fn_task_done` completion + scope-leak verification. In workspace mode both review call sites (the in-session `fn_review_step` tool and the step-inversion review seam) now loop the single-cwd `reviewStep` once per acquired sub-repo (cwd = each repo's worktree) and aggregate the repo-tagged verdicts as a conjunction — the task is reviewed only when every sub-repo approves, and the first failing sub-repo's verdict (with repo-tagged findings) drives the existing verdict→edge mapping. `fn_task_done` now verifies worktree invariants per acquired repo and iterates the scope-leak guard per sub-repo (cwd = repo worktree, repo `baseCommitSha`), blocking completion on any sub-repo carrying off-scope changes and naming the repo. Adds a minimal shared repo-prefix-derivation helper (`workspace-paths.ts`). Single-repo behavior is unchanged. diff --git a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts new file mode 100644 index 0000000000..9f24aaa75c --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts @@ -0,0 +1,220 @@ +/* +FNXC:Workspace 2026-06-22-00:30: +U2 KTD4 — per-repo fn_task_done completion verification: per-repo scope-leak guard + per-repo worktree-invariant +verify. These drive the REAL TaskExecutor methods against a REAL two-repo git fixture under a NON-git workspace +root (createWorkspaceFixture), so a leaked singular-root capture/verify would silently pass and the test would +catch it. Narrow seams (FN-5048): we set `(executor as any).workspaceConfig` directly and stub only the store +methods the guards read (parseFileScopeFromPrompt, logEntry, getRunContextFor) — no mock-the-world child_process. + +Coverage: +- scope-leak error: an uncommitted in-scope vs OFF-scope change in repo A → evaluateTaskDoneScopeLeak blocks, + message NAMES repo-a (per-repo guard fires; singular root would silently pass). +- verify error: a worktree HEAD off fusion/ → verifyWorktreeInvariants blocks (wrong_branch, repo-tagged). +- all-clean: a two-repo task with only in-scope changes → scope-leak does NOT block. +- helper: deriveRepoForPath / deriveRepoScopeSubset / splitRepoScopedPath unit cases (wolf-server/src/** → wolf-server; + non-matching first segment → unscoped). +- regression: single-repo (non-workspace) task → singular scope-leak path unchanged. +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore, WorkspaceConfig, Settings } from "@fusion/core"; +import { TaskExecutor } from "../executor.js"; +import { + deriveRepoForPath, + deriveRepoScopeSubset, + splitRepoScopedPath, + UNSCOPED_REPO, +} from "../workspace-paths.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-1001"; +const BRANCH = "fusion/fn-1001"; + +// reviewLevel=1 + block enforcement is the only mode that BLOCKS (else warn). +const SETTINGS: Settings = { autoMerge: false, planOnlyScopeLeakEnforcement: "block" } as Settings; +const PROMPT = "## Review Level: 1 (Plan Only)\n"; + +function createStore(declaredScope: string[]): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + parseFileScopeFromPrompt: vi.fn().mockResolvedValue(declaredScope), + logEntry: vi.fn().mockResolvedValue(undefined), + getRunContextFor: vi.fn(), + getSettings: vi.fn().mockResolvedValue(SETTINGS), + }) as unknown as TaskStore & EventEmitter; +} + +function makeTask(overrides: Partial = {}): Task { + return { + id: TASK_ID, + title: "WS", + description: "", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +/** Add a fusion/ worktree to a sub-repo with one committed in-scope edit; return its handle. */ +function addRepoWorktree(fx: WorkspaceFixture, repoRel: string, fileName: string): { worktreePath: string; baseCommitSha: string } { + const repoDir = fx.repoPath(repoRel); + const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD"); + const worktreePath = path.join(repoDir, ".worktrees", "fn-ws-1"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + mkdirSync(path.dirname(path.join(worktreePath, fileName)), { recursive: true }); + writeFileSync(path.join(worktreePath, fileName), "// in-scope\n", "utf-8"); + execSync(`git add ${fileName}`, { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): edit ${fileName}"`, { cwd: worktreePath, stdio: "pipe" }); + return { worktreePath, baseCommitSha }; +} + +function workspaceExecutor(fx: WorkspaceFixture, store: TaskStore & EventEmitter): TaskExecutor { + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + return executor; +} + +describe("U2 — workspace-paths repo-prefix helper (unit)", () => { + const repos = ["wolf-server", "repo-a", "apps/web"]; + it("deriveRepoForPath: first-segment match → that repo", () => { + expect(deriveRepoForPath("wolf-server/src/index.ts", repos)).toBe("wolf-server"); + expect(deriveRepoForPath("repo-a/src/a.ts", repos)).toBe("repo-a"); + }); + it("deriveRepoForPath: longest nested-key match wins", () => { + expect(deriveRepoForPath("apps/web/page.tsx", repos)).toBe("apps/web"); + }); + it("deriveRepoForPath: non-matching first segment → unscoped", () => { + expect(deriveRepoForPath(".changeset/x.md", repos)).toBe(UNSCOPED_REPO); + expect(deriveRepoForPath("other/thing.ts", repos)).toBe(UNSCOPED_REPO); + expect(deriveRepoForPath("repo-ab/x.ts", repos)).toBe(UNSCOPED_REPO); // segment-wise, not substring + }); + it("splitRepoScopedPath: strips the repo prefix for the repo-local remainder", () => { + expect(splitRepoScopedPath("wolf-server/src/x.ts", repos)).toEqual({ repo: "wolf-server", relativePath: "src/x.ts" }); + expect(splitRepoScopedPath("other/x.ts", repos)).toEqual({ repo: UNSCOPED_REPO, relativePath: "other/x.ts" }); + }); + it("deriveRepoScopeSubset: returns repo-local scope patterns for one repo", () => { + const scope = ["wolf-server/src/**", "repo-a/lib/x.ts", "apps/web/page.tsx"]; + expect(deriveRepoScopeSubset(scope, "wolf-server")).toEqual(["src/**"]); + expect(deriveRepoScopeSubset(scope, "repo-a")).toEqual(["lib/x.ts"]); + // repo-root scope entry maps to whole-repo ** + expect(deriveRepoScopeSubset(["repo-a"], "repo-a")).toEqual(["**"]); + }); +}); + +describeIfGit("U2 KTD4 — per-repo scope-leak guard in fn_task_done", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("error: an off-scope change in repo A blocks completion and NAMES repo-a", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + // Off-scope STAGED-but-uncommitted change in repo-a (outside declared `repo-a/src/**`). + // captureUncommittedModifiedFiles reads `git diff`/`--cached`, so the leak must be tracked + // (staged) to register — an untracked file is invisible to the guard by design. + writeFileSync(path.join(a.worktreePath, "OFFSCOPE.md"), "// leak\n", "utf-8"); + execSync("git add OFFSCOPE.md", { cwd: a.worktreePath, stdio: "pipe" }); + // Declared scope is repo-prefixed and only covers src/** in each repo. + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(true); + expect(result.message).toContain("repo-a"); + expect(result.message).toContain("OFFSCOPE.md"); + }); + + it("all-clean: only in-scope changes in both repos → not blocked", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(false); + }); +}); + +describeIfGit("U2 KTD4 — per-repo worktree-invariant verify in fn_task_done", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("error: a worktree off fusion/ blocks completion via per-repo verify", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + execSync("git checkout -b drifted-branch", { cwd: b.worktreePath, stdio: "pipe" }); + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result.ok).toBe(false); + expect(result.reason).toBe("wrong_branch"); + expect(result.repo).toBe("repo-b"); + }); +}); + +describeIfGit("U2 — single-repo (non-workspace) task: scope-leak unchanged", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("regression: singular scope-leak path still flags an off-scope change in the singular worktree", async () => { + fx = await createWorkspaceFixture(); + const repoDir = fx.repoPath("repo-a"); + const worktreePath = path.join(repoDir, ".worktrees", "fn-001"); + const base = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8" }).trim(); + execSync(`git worktree add -b fusion/fn-001 ${worktreePath} HEAD`, { cwd: repoDir, stdio: "pipe" }); + configureIdentity(worktreePath); + // Off-scope STAGED change (declared scope is `src/**`). Tracked so the guard sees it. + writeFileSync(path.join(worktreePath, "OFFSCOPE.md"), "// leak\n", "utf-8"); + execSync("git add OFFSCOPE.md", { cwd: worktreePath, stdio: "pipe" }); + + const store = createStore(["src/**"]); + const executor = new TaskExecutor(store, repoDir); // no workspaceConfig → singular path + const task = makeTask({ id: "FN-001", branch: "fusion/fn-001", worktree: worktreePath, baseCommitSha: base }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, worktreePath, PROMPT, SETTINGS); + expect(result.blocked).toBe(true); + expect(result.message).toContain("OFFSCOPE.md"); + // Singular message carries no repo tag. + expect(result.message).not.toContain("repo="); + }); +}); diff --git a/packages/engine/src/__tests__/reviewer-workspace.test.ts b/packages/engine/src/__tests__/reviewer-workspace.test.ts new file mode 100644 index 0000000000..cab774f3aa --- /dev/null +++ b/packages/engine/src/__tests__/reviewer-workspace.test.ts @@ -0,0 +1,213 @@ +/* +FNXC:Workspace 2026-06-22-00:30: +U2 KTD3 — per-repo review (BOTH call sites) + conjunction aggregation tests. The reviewer is an AGENT +spawned with `cwd = worktree`; per-repo review means ONE reviewer agent per sub-repo with the CALLERS +looping the single-cwd `reviewStep`. These tests assert the LOOP + aggregation, not the reviewer's content: +`reviewStep` is mocked (the narrow AI seam — FN-5048: no mock-the-world, no real AI spawn) and we record +the cwd of each call. Coverage: +- conjunction: two-repo task → two reviewer passes (one per repo cwd); review record reflects both; reviewed + only when BOTH pass; one repo REVISE → aggregate REVISE tagged with that repo. +- finding tag: a finding in repo B is repo-tagged in the aggregated review body. +- in-session seam (createReviewStepTool / fn_review_step): a workspace task reviews each sub-repo cwd, not the root. +- step-inversion seam (createAuthoritativeWorkflowSeams().stepReview, executor.ts:5668): same — each sub-repo, not root. +- regression: single-repo (non-workspace) task → exactly one reviewStep call at the singular worktree. +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import type { ReviewResult } from "../reviewer.js"; + +// Narrow AI seam: only reviewStep (the agent boundary) is mocked. Everything else is the real executor. +vi.mock("../reviewer.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, reviewStep: vi.fn() }; +}); + +import { reviewStep as mockedReviewStepFn } from "../reviewer.js"; +import { TaskExecutor } from "../executor.js"; +import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflow-node-handlers.js"; +import type { Task, TaskStore, WorkspaceConfig } from "@fusion/core"; + +const mockedReviewStep = vi.mocked(mockedReviewStepFn); + +const ROOT = "/tmp/ws-root"; // NON-git workspace root — must never be a review cwd in workspace mode. +const WT_A = "/tmp/ws-root/repo-a/.worktrees/fn-1"; +const WT_B = "/tmp/ws-root/repo-b/.worktrees/fn-1"; + +function makeStore(task: Task): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + getTask: vi.fn().mockResolvedValue(task), + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + updateStep: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + getRunContextFor: vi.fn(), + // mergeEffectiveSettings degrades to base on any resolver error; these reject → base used. + getTaskWorkflowSelection: vi.fn().mockRejectedValue(new Error("no workflow")), + getWorkflowDefinition: vi.fn().mockRejectedValue(new Error("no workflow")), + getWorkflowSettingValues: vi.fn().mockRejectedValue(new Error("no workflow")), + }) as unknown as TaskStore & EventEmitter; +} + +function makeTask(overrides: Partial = {}): Task { + return { + id: "FN-1", + title: "WS", + description: "", + column: "in-progress", + dependencies: [], + steps: [ + { name: "Step 0", status: "done" }, + { name: "Step 1", status: "in-progress" }, + ], + currentStep: 1, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +const TWO_REPO_WORKTREES = { + "repo-a": { worktreePath: WT_A, branch: "fusion/fn-1", baseCommitSha: "aaa" }, + "repo-b": { worktreePath: WT_B, branch: "fusion/fn-1", baseCommitSha: "bbb" }, +}; + +/** Script reviewStep to return a per-cwd verdict and record the cwd it was called with. */ +function scriptReviewByCwd(byCwd: Record): string[] { + const seenCwds: string[] = []; + mockedReviewStep.mockImplementation((async (cwd: string) => { + seenCwds.push(cwd); + return byCwd[cwd] ?? { verdict: "APPROVE", review: `ok ${cwd}`, summary: `ok ${cwd}` }; + }) as any); + return seenCwds; +} + +function workspaceExecutor(store: TaskStore & EventEmitter): TaskExecutor { + const executor = new TaskExecutor(store, ROOT); + (executor as any).workspaceConfig = { repos: ["repo-a", "repo-b"] } as WorkspaceConfig; + return executor; +} + +beforeEach(() => { + mockedReviewStep.mockReset(); +}); +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared loop both call sites use)", () => { + it("conjunction: two repos both APPROVE → aggregate APPROVE, one reviewer pass per repo cwd", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const executor = workspaceExecutor(makeStore(task)); + const seen: string[] = []; + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string, repo: string) => { + seen.push(cwd); + return { verdict: "APPROVE", review: `clean in ${repo}`, summary: `clean ${repo}` }; + }); + expect(seen).toEqual([WT_A, WT_B]); // one pass per sub-repo cwd, never ROOT + expect(result.verdict).toBe("APPROVE"); + expect(result.review).toContain("repo-a"); + expect(result.review).toContain("repo-b"); + }); + + it("conjunction: one repo REVISE → aggregate REVISE, tagged with the failing repo", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const executor = workspaceExecutor(makeStore(task)); + const result = await (executor as any).reviewWorkspacePerRepo(task, async (_cwd: string, repo: string) => { + return repo === "repo-b" + ? { verdict: "REVISE", review: `bug in ${repo}`, summary: `revise ${repo}` } + : { verdict: "APPROVE", review: `clean ${repo}`, summary: `clean ${repo}` }; + }); + expect(result.verdict).toBe("REVISE"); + expect(result.review).toContain("repo-b"); // finding repo-tagged + expect(result.review).toContain("bug in repo-b"); + expect(result.summary).toMatch(/^repo-b:/); + }); + + it("zero-acquire workspace task → UNAVAILABLE (caller routes; no fabricated APPROVE)", async () => { + const task = makeTask({ workspaceWorktrees: {} }); + const executor = workspaceExecutor(makeStore(task)); + const invoke = vi.fn(); + const result = await (executor as any).reviewWorkspacePerRepo(task, invoke); + expect(result.verdict).toBe("UNAVAILABLE"); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe("U2 KTD3 — in-session fn_review_step (createReviewStepTool) loops per sub-repo", () => { + it("workspace task: code review spawns one reviewer per sub-repo cwd, not the root", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const store = makeStore(task); + const executor = workspaceExecutor(store); + const seen = scriptReviewByCwd({ + [WT_A]: { verdict: "APPROVE", review: "a ok", summary: "a" }, + [WT_B]: { verdict: "APPROVE", review: "b ok", summary: "b" }, + }); + const tool = (executor as any).createReviewStepTool( + task.id, + ROOT, // singular worktreePath = the non-git root; workspace mode must NOT review it + "PROMPT", + new Map(), + { current: null }, + new Map(), + task, + undefined, + ); + const res = await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" }); + expect(seen).toEqual([WT_A, WT_B]); + expect(seen).not.toContain(ROOT); + // Aggregate APPROVE flows through the tool's verdict→text mapping unchanged. + expect(res.content[0].text).toBe("APPROVE"); + }); + + it("regression: single-repo (non-workspace) task → exactly one reviewStep call at the singular worktree", async () => { + const task = makeTask(); + const store = makeStore(task); + const executor = new TaskExecutor(store, ROOT); // no workspaceConfig → singular path + const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "ok", summary: "ok" } }); + const tool = (executor as any).createReviewStepTool( + task.id, + WT_A, + "PROMPT", + new Map(), + { current: null }, + new Map(), + task, + undefined, + ); + await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" }); + expect(seen).toEqual([WT_A]); + }); +}); + +describe("U2 KTD3 — step-inversion review seam (executor.ts:5668) loops per sub-repo", () => { + it("workspace task: stepReview spawns one reviewer per sub-repo cwd, not active.worktreePath/root", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES, worktree: ROOT }); + const store = makeStore(task); + const executor = workspaceExecutor(store); + const seen = scriptReviewByCwd({ + [WT_A]: { verdict: "APPROVE", review: "a", summary: "a" }, + [WT_B]: { verdict: "APPROVE", review: "b", summary: "b" }, + }); + const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any); + // Drive the foreach-active step-review handler directly with a scripted active context. + const context = { + [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: ROOT, baselineSha: "base" }, + } as any; + const result = await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any); + expect(seen).toEqual([WT_A, WT_B]); + expect(seen).not.toContain(ROOT); + expect(result.verdict).toBe("APPROVE"); + }); + + it("regression: single-repo stepReview reviews the active worktree once", async () => { + const task = makeTask({ worktree: WT_A }); + const store = makeStore(task); + const executor = new TaskExecutor(store, ROOT); // no workspaceConfig + const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "a", summary: "a" } }); + const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any); + const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: WT_A, baselineSha: "base" } } as any; + await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any); + expect(seen).toEqual([WT_A]); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 16bb0d4a56..74d0f45a2b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -77,7 +77,7 @@ import { resolveExecutorSessionModel, } from "./agent-session-helpers.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; -import { reviewStep, type ReviewVerdict } from "./reviewer.js"; +import { reviewStep, type ReviewVerdict, type ReviewResult } from "./reviewer.js"; import { resolveSandboxBackend } from "./sandbox/index.js"; import type { SandboxBackend } from "./sandbox/types.js"; import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@earendil-works/pi-coding-agent"; @@ -5664,9 +5664,14 @@ export class TaskExecutor { const settings = await mergeEffectiveSettings(this.store, detail, await this.store.getSettings()); const sem = this.options.semaphore; - const invokeReviewer = () => + // FNXC:Workspace 2026-06-22-00:30: KTD3 — step-inversion review seam loops per sub-repo. + // `reviewStep` stays single-cwd; THIS CALLER loops. Single-cwd by default reviews + // `worktreePath`; in workspace mode that is the browse-only non-git root, so we instead spawn + // one reviewer per acquired sub-repo (cwd = repo.worktreePath) via reviewWorkspacePerRepo and + // aggregate as a conjunction. `invokeReviewerForCwd` is the per-cwd reviewStep call both modes share. + const invokeReviewerForCwd = (cwd: string) => reviewStep( - worktreePath, + cwd, seamTask.id, stepIndex, stepName, @@ -5702,10 +5707,18 @@ export class TaskExecutor { onSessionEnded: (s) => this.unregisterSubagentSession(seamTask.id, s), }, ); + const runForCwd = (cwd: string) => { + const invoke = () => invokeReviewerForCwd(cwd); + return sem ? sem.runNested(invoke) : invoke(); + }; + const invokeReviewer = () => + this.workspaceConfig + ? this.reviewWorkspacePerRepo(detail, (cwd) => runForCwd(cwd)) + : runForCwd(worktreePath); let review: { verdict: ReviewVerdict; review: string; summary: string }; try { - review = sem ? await sem.runNested(invokeReviewer) : await invokeReviewer(); + review = await invokeReviewer(); } catch (err) { const message = err instanceof Error ? err.message : String(err); reviewerLog.error(`${seamTask.id}: step-review failed: ${message}`); @@ -10855,24 +10868,62 @@ export class TaskExecutor { return { blocked: false }; } - const [uncommittedTouchedFiles, branchCommittedFiles] = await Promise.all([ - this.captureUncommittedModifiedFiles(worktreePath), - this.captureModifiedFiles(worktreePath, task.baseCommitSha, task.id, audit, "scope-leak-guard"), - ]); - - const touchedFiles = [...new Set([...uncommittedTouchedFiles, ...branchCommittedFiles])]; - if (touchedFiles.length === 0) { - return { blocked: false }; + // FNXC:Workspace 2026-06-22-00:30: KTD4 — per-repo scope-leak guard. + // The singular capture below runs `captureUncommittedModifiedFiles` + `captureModifiedFiles` + // against `worktreePath`. In workspace mode `worktreePath` is the browse-only non-git workspace + // root, so both silently return [] (git failures swallowed) and the uncommitted-in-scope block + // never fires — a workspace task could complete with off-scope changes in any sub-repo. So we + // ITERATE every acquired sub-repo (cwd = repo.worktreePath, base = repo.baseCommitSha), + // repo-prefix each repo's touched files (`/`) so they compare against the task's + // repo-prefixed declared File Scope, and block on the FIRST repo carrying off-scope changes — + // naming the repo. The task-level preamble above (scopeOverride / declaredScope / enforcementMode) + // is shared and runs once. Return shape is preserved: `{blocked:false} | {blocked:true; message}`. + let touchedFiles: string[]; + let offendingRepo: string | undefined; + if (this.workspaceConfig) { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const aggregatedOffScope: string[] = []; + for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + const [repoUncommitted, repoCommitted] = await Promise.all([ + this.captureUncommittedModifiedFiles(repo.worktreePath), + this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, "scope-leak-guard"), + ]); + const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted])].map((f) => `${repoRel}/${f}`); + const repoOffScope = repoTouched + .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope)) + .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)); + if (repoOffScope.length > 0) { + // First offending repo wins (mirrors verifyWorktreeInvariants' first-failing-repo return). + if (!offendingRepo) offendingRepo = repoRel; + aggregatedOffScope.push(...repoOffScope); + } + } + touchedFiles = aggregatedOffScope; + if (touchedFiles.length === 0) { + return { blocked: false }; + } + } else { + const [uncommittedTouchedFiles, branchCommittedFiles] = await Promise.all([ + this.captureUncommittedModifiedFiles(worktreePath), + this.captureModifiedFiles(worktreePath, task.baseCommitSha, task.id, audit, "scope-leak-guard"), + ]); + touchedFiles = [...new Set([...uncommittedTouchedFiles, ...branchCommittedFiles])]; + if (touchedFiles.length === 0) { + return { blocked: false }; + } } - const offScopeFiles = touchedFiles - .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope)) - // FN-4811 follow-up: by convention every task may add its own changeset entry - // under `.changeset/`, so changeset files are always considered in-scope and - // never flagged by the scope-leak guard. The file-scope invariant at squash and - // the broader contamination guards still catch cross-task changeset leakage at - // a higher signal-to-noise ratio than the per-execution scope-leak warning. - .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)); + const offScopeFiles = (this.workspaceConfig + // In workspace mode `touchedFiles` is already the off-scope set (filtered per repo above). + ? touchedFiles + : touchedFiles + .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope)) + // FN-4811 follow-up: by convention every task may add its own changeset entry + // under `.changeset/`, so changeset files are always considered in-scope and + // never flagged by the scope-leak guard. The file-scope invariant at squash and + // the broader contamination guards still catch cross-task changeset leakage at + // a higher signal-to-noise ratio than the per-execution scope-leak warning. + .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath))); if (offScopeFiles.length === 0) { return { blocked: false }; } @@ -10887,14 +10938,16 @@ export class TaskExecutor { const offScopePreview = renderListPreview(offScopeFiles); const declaredScopePreview = renderListPreview(declaredScope); - const message = `[scope-leak] reviewLevel=${reviewLevel} enforcement=${enforcementMode} off-scope touched files [${offScopePreview}]; declared scope [${declaredScopePreview}]; total off-scope=${offScopeFiles.length} total scope=${declaredScope.length}`; + // Name the offending sub-repo in workspace mode so the operator/agent knows where to revert. + const repoTag = offendingRepo ? ` repo=${offendingRepo}` : ""; + const message = `[scope-leak] reviewLevel=${reviewLevel} enforcement=${enforcementMode}${repoTag} off-scope touched files [${offScopePreview}]; declared scope [${declaredScopePreview}]; total off-scope=${offScopeFiles.length} total scope=${declaredScope.length}`; executorLog.warn(`${task.id}: ${message}`); await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id)); if (enforcementMode === "block") { return { blocked: true, - message: `Plan-Only scope-leak guard refused fn_task_done. Off-scope paths: [${offScopePreview}]. Revert them before retrying (for example: git checkout -- ).`, + message: `Plan-Only scope-leak guard refused fn_task_done${offendingRepo ? ` (sub-repo ${offendingRepo})` : ""}. Off-scope paths: [${offScopePreview}]. Revert them before retrying (for example: git checkout -- ).`, }; } @@ -11337,8 +11390,13 @@ export class TaskExecutor { // result, so the soft breach of `limit` does not push real // LLM-active concurrency above the configured cap. const sem = options.semaphore; - const invokeReviewer = () => reviewStep( - worktreePath, taskId, step, step_name, + // FNXC:Workspace 2026-06-22-00:30: KTD3 — in-session fn_review_step loops per sub-repo. + // `reviewStep` stays single-cwd; THIS CALLER loops. Single-cwd by default reviews `worktreePath`; + // in workspace mode that is the browse-only non-git root, so we spawn one reviewer per acquired + // sub-repo (cwd = repo.worktreePath) via reviewWorkspacePerRepo and aggregate as a conjunction. + // `invokeReviewerForCwd` is the per-cwd reviewStep call both modes share. + const invokeReviewerForCwd = (cwd: string) => reviewStep( + cwd, taskId, step, step_name, reviewType, promptContent, baseline, { onText: (delta) => options.onAgentText?.(taskId, delta), @@ -11377,9 +11435,13 @@ export class TaskExecutor { onSessionEnded: (s) => this.unregisterSubagentSession(taskId, s), }, ); - const result = sem - ? await sem.runNested(invokeReviewer) - : await invokeReviewer(); + const runForCwd = (cwd: string) => { + const invoke = () => invokeReviewerForCwd(cwd); + return sem ? sem.runNested(invoke) : invoke(); + }; + const result = this.workspaceConfig + ? await this.reviewWorkspacePerRepo(currentTask, (cwd) => runForCwd(cwd)) + : await runForCwd(worktreePath); await store.logEntry( taskId, @@ -12403,6 +12465,70 @@ ${failureFeedback} return aggregated; } + /** + * FNXC:Workspace 2026-06-22-00:30: KTD3 — per-repo review by looping the EXISTING single-cwd reviewStep. + * The reviewer is an AGENT spawned with `cwd = worktree`, told (in prompt text, reviewer.ts) to run `git diff` + * itself — it does NOT read a diff passed in code. So per-repo review = ONE reviewer agent per sub-repo. We keep + * `reviewStep` single-cwd; the CALLERS loop. This helper is the shared loop+aggregate so both review entry points + * (`createReviewStepTool` and the step-inversion `stepReview` seam) iterate identically: it invokes the caller's + * own `invokeForCwd(cwd)` once per acquired worktree (cwd = repo.worktreePath) and aggregates the repo-tagged + * verdicts as a CONJUNCTION — the task is "reviewed" only if EVERY repo passes; the FIRST non-APPROVE repo's + * verdict becomes the aggregate verdict (mirroring verifyWorktreeInvariants' first-failing-repo return), and its + * findings are repo-tagged. A zero-acquire workspace task (empty map) returns UNAVAILABLE so the caller routes it + * rather than fabricating an APPROVE. + * + * Verdict severity for the conjunction: any RETHINK/REVISE/UNAVAILABLE fails the whole review; only all-APPROVE + * (or all-skipped UNAVAILABLE-advisory, handled by the caller) approves. We surface the first failing repo's exact + * verdict so the caller's existing verdict→edge mapping (APPROVE done-marking, REVISE block, RETHINK reset, + * UNAVAILABLE retry) is unchanged. + */ + private async reviewWorkspacePerRepo( + task: Task, + invokeForCwd: (cwd: string, repoRel: string) => Promise, + ): Promise { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const entries = Object.entries(workspaceWorktrees); + if (entries.length === 0) { + // No acquired worktree — surface UNAVAILABLE so the caller routes it rather than + // fabricating an authoritative APPROVE for an un-reviewable workspace task. + return { + verdict: "UNAVAILABLE", + review: "No acquired sub-repo worktree to review (workspace task with zero worktrees).", + summary: "Skipped: no sub-repo worktree", + }; + } + + const reviewSections: string[] = []; + const summarySections: string[] = []; + let firstFailing: { repo: string; result: ReviewResult } | undefined; + for (const [repoRel, repo] of entries) { + const result = await invokeForCwd(repo.worktreePath, repoRel); + // Tag every per-repo finding with its sub-repo so downstream readers attribute it correctly. + reviewSections.push(`### [${repoRel}] ${result.verdict}\n${result.review}`); + summarySections.push(`[${repoRel}] ${result.verdict}: ${result.summary}`); + if (result.verdict !== "APPROVE" && !firstFailing) { + firstFailing = { repo: repoRel, result }; + } + } + + if (firstFailing) { + // Conjunction failed: the aggregate carries the FIRST failing repo's verdict (so the caller's + // verdict→edge mapping is identical to single-cwd), with the full repo-tagged review body. + return { + verdict: firstFailing.result.verdict, + review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, + summary: `${firstFailing.repo}: ${firstFailing.result.verdict} — ${summarySections.join(" | ")}`, + }; + } + + // Every sub-repo approved → the task is reviewed (conjunction satisfied). + return { + verdict: "APPROVE", + review: `All ${entries.length} sub-repo(s) approved. Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, + summary: `APPROVE across ${entries.length} sub-repo(s): ${summarySections.join(" | ")}`, + }; + } + private async captureUncommittedModifiedFiles(worktreePath: string): Promise { try { const [unstaged, staged] = await Promise.all([ diff --git a/packages/engine/src/workspace-paths.ts b/packages/engine/src/workspace-paths.ts new file mode 100644 index 0000000000..308c559341 --- /dev/null +++ b/packages/engine/src/workspace-paths.ts @@ -0,0 +1,117 @@ +/* +FNXC:Workspace 2026-06-22-00:30: +Minimal shared repo-prefix-derivation helper for workspace mode (Phase B U2; master U5 reuses it). A workspace task's File Scope, modified-file list, and review/scope-leak findings are all repo-prefixed (`/`). Per-repo review and per-repo scope-leak need to map a path → its owning sub-repo, and to derive each repo's File-Scope subset (so a reviewer at `cwd = repo.worktreePath` and a per-repo scope-leak check evaluate only that repo's declared paths). + +NO lease logic lives here (file-scope leases are Phase C / master U7). This module is intentionally dependency-light (pure string/path math) so it can be reused across the executor, reviewer callers, and the later merge loop without pulling in executor state. + +Matching rule: canonicalize the path to forward-slash relative segments, then pick the LONGEST configured repo key that is a path-segment prefix of the file path. Longest-prefix (not naive first-segment) correctly handles nested repo keys like `apps/web` while still satisfying the simple `wolf-server/src/** → wolf-server` case. A path that matches no configured repo (absolute paths outside the workspace, root-level files like `.changeset/x.md`, or a first segment that is not a repo) derives to the `UNSCOPED` sentinel. +*/ + +/** Sentinel returned when a path does not belong to any configured sub-repo. */ +export const UNSCOPED_REPO = "unscoped" as const; + +/** + * Normalize a workspace-relative path token to forward-slash form with no leading + * `./`, no leading/trailing slashes, and collapsed duplicate slashes. Mirrors the + * executor's `normalizeWorkflowScopePath` shape so File-Scope tokens and modified + * files compare consistently, but kept local to avoid an executor import cycle. + */ +function normalizeRepoRelPath(value: string): string { + return value + .trim() + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/\/+/g, "/") + .replace(/^\/+/, "") + .replace(/\/+$/, ""); +} + +/** Split a normalized path into non-empty segments. */ +function segmentsOf(value: string): string[] { + const normalized = normalizeRepoRelPath(value); + return normalized ? normalized.split("/") : []; +} + +/** + * Return true when `repoSegs` is a leading segment-prefix of `pathSegs`. + * Segment-wise (not substring) so `repo-a` does NOT match `repo-ab/...`. + */ +function isSegmentPrefix(repoSegs: string[], pathSegs: string[]): boolean { + if (repoSegs.length === 0 || repoSegs.length > pathSegs.length) return false; + for (let i = 0; i < repoSegs.length; i++) { + if (repoSegs[i] !== pathSegs[i]) return false; + } + return true; +} + +/** + * Derive the configured sub-repo that owns `filePath`, or {@link UNSCOPED_REPO}. + * + * `repos` are the configured workspace sub-repo relative keys (from + * `workspaceConfig.repos` or `Object.keys(task.workspaceWorktrees)`). The LONGEST + * matching repo key wins so nested repos (`apps/web` vs `apps`) resolve to the + * most specific owner. + */ +export function deriveRepoForPath(filePath: string, repos: readonly string[]): string { + const pathSegs = segmentsOf(filePath); + if (pathSegs.length === 0) return UNSCOPED_REPO; + let best: string | null = null; + let bestLen = 0; + for (const repo of repos) { + const repoSegs = segmentsOf(repo); + if (repoSegs.length === 0) continue; + if (isSegmentPrefix(repoSegs, pathSegs) && repoSegs.length > bestLen) { + best = normalizeRepoRelPath(repo); + bestLen = repoSegs.length; + } + } + return best ?? UNSCOPED_REPO; +} + +/** + * Result of splitting a repo-prefixed File-Scope entry into its owning repo and + * the repo-relative remainder (the path AS the reviewer at `cwd = repo` sees it). + */ +export interface RepoScopedPath { + /** Owning sub-repo key, or {@link UNSCOPED_REPO}. */ + repo: string; + /** The path with the repo prefix stripped (repo-local). Equals `path` when unscoped. */ + relativePath: string; +} + +/** + * Split a repo-prefixed path into `{ repo, relativePath }`. For `repo-a/src/x.ts` + * with `repos=["repo-a"]` → `{ repo:"repo-a", relativePath:"src/x.ts" }`. An + * unscoped path returns the whole normalized path as `relativePath`. + */ +export function splitRepoScopedPath(filePath: string, repos: readonly string[]): RepoScopedPath { + const repo = deriveRepoForPath(filePath, repos); + const normalized = normalizeRepoRelPath(filePath); + if (repo === UNSCOPED_REPO) { + return { repo, relativePath: normalized }; + } + const repoNormalized = normalizeRepoRelPath(repo); + const remainder = normalized.slice(repoNormalized.length).replace(/^\/+/, ""); + return { repo, relativePath: remainder }; +} + +/** + * Derive a single sub-repo's File-Scope subset from the task's full (repo-prefixed) + * declared scope. Returns the repo-LOCAL scope patterns (prefix stripped) so a + * per-repo reviewer or per-repo scope-leak check — operating with `cwd = repo` — + * can compare repo-local paths directly. Entries owned by other repos (or unscoped) + * are excluded. A scope entry whose prefix-stripped remainder is empty (the repo + * root itself, e.g. `repo-a` or `repo-a/`) maps to `**` (whole-repo scope). + */ +export function deriveRepoScopeSubset(declaredScope: readonly string[], repoRel: string): string[] { + const repoSegs = segmentsOf(repoRel); + if (repoSegs.length === 0) return []; + const subset: string[] = []; + for (const entry of declaredScope) { + const entrySegs = segmentsOf(entry); + if (!isSegmentPrefix(repoSegs, entrySegs)) continue; + const remainder = entrySegs.slice(repoSegs.length).join("/"); + subset.push(remainder === "" ? "**" : remainder); + } + return subset; +} From 0367fa54d9876f9a010634b2101f896e6022db62 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 22:44:53 -0700 Subject: [PATCH 024/265] docs(workspace): Phase B implementation plan (U3/U4) --- ...6-06-21-005-feat-workspace-phase-b-plan.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md diff --git a/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md b/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md new file mode 100644 index 0000000000..15dd907348 --- /dev/null +++ b/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md @@ -0,0 +1,145 @@ +--- +title: "feat: Workspace mode Phase B — per-repo capture, contamination, review, completion verify" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase B / U3·U4) +depth: deep +--- + +# feat: Workspace mode Phase B — per-repo capture, contamination, review, completion verify + +> **ID namespace:** local `U1·U2` decompose master-plan **U3, U4**. +> **Anchors below are feasibility-verified against the Phase-B base** (not the master plan's approximate numbers). + +## Summary + +Phase B makes the executor's capture / contamination / verify / review / completion paths iterate `task.workspaceWorktrees` per sub-repo, using each repo's own `baseCommitSha` (Phase A, U2). It does **not** simply "un-gate stubs" — the feasibility pass found capture/contamination/scope-leak are not gated at all today; they **silently degrade to empty** against the non-git root (git failures swallowed). Phase B adds the missing workspace branches and reuses the existing `captureModifiedFiles` machinery (whose `resolveDiffBaseRef` merge-base fallback + `filterFilesToOwnTaskCommits` contamination audit are exactly what's needed) per repo. + +Builds on Phase A (PR #1713). **Scope out:** the merge loop (master U6 = Phase C), self-healing (master U8 = Phase D). + +**Stacking:** off the Phase-A branch; PR diff includes the stack; must not merge until it lands. + +--- + +## Problem Frame + +Phase A rooted workspace sessions at the non-git workspace root and acquired per-repo worktrees, but the executor's change-capture, contamination, worktree-invariant, review, and completion-verify paths still operate on a single `task.worktree`. Against the non-git root they either are explicitly stubbed (one site) or silently produce empty results (the rest). Phase B routes each of these through every acquired sub-repo worktree, `cwd` = the sub-repo, diffing against that repo's `workspaceWorktrees[repo].baseCommitSha`, with repo-prefixed file lists so review/dashboard/later-merge keep repo context. + +--- + +## Key Technical Decisions + +### KTD1 — Per-repo change capture by **reusing `captureModifiedFiles`**, not a raw diff (master KTD7) +**Verified reality:** capture is **not** workspace-gated. The post-session call `captureModifiedFiles(worktreePath, …, "post-session")` (executor.ts **:7898**) runs ungated with `worktreePath` = the browse-only non-git root and returns `[]` only because `resolveDiffBaseRef`/`resolveContaminationBaseRef` swallow the git failure. So U1 **adds** a workspace branch at :7898 (and the sibling branch-attribution audit at **:7914**), it does not replace one. + +Per repo, call the **existing** `captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source)` — NOT a hand-built `git diff ..HEAD`. Reasons (all verified): (a) `repo.baseCommitSha` may be **undefined** (Phase A made base capture non-fatal); `resolveDiffBaseRef` (:~12184) handles that via a merge-base fallback. (b) the real **contamination** signal is the `filterFilesToOwnTaskCommits` raw-vs-attributed divergence audit **inside** `captureModifiedFiles` (:~12225-12246) — reusing it restores contamination for free. Prefix each repo's returned files with the repo path and aggregate into `task.modifiedFiles`. + +> **`assertCleanBranchAtBase` is a no-op** (branch-conflicts.ts: `void`s all params — "informational only"). Do **not** add a per-repo iteration of it; it would restore zero protection. Contamination comes from per-repo `captureModifiedFiles`. + +### KTD2 — `verifyWorktreeInvariants` iterates per acquired worktree, preserving its result union (master KTD7) +The **one** workspace stub in this region is `verifyWorktreeInvariants` returning `{ok:true}` at executor.ts **:10508** (def **:10500**). Un-stub it: iterate every `workspaceWorktrees` entry, asserting each HEAD is on `fusion/` and toplevel matches the recorded `worktreePath`. **Preserve the exact discriminated union** `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` (consumed at **:10889**; the `reason` enum drives the requeue/handoff branches at :10894-10936) — add a `repo` field to the failure shape; return the **first** failing repo. + +### KTD3 — Per-repo review by looping the **existing single-cwd `reviewStep`** N times (master KTD7) +**Decision (user-confirmed): accept the N× reviewer cost.** The reviewer is an **agent** spawned with `cwd` = worktree and told (in prompt text, reviewer.ts:~760) to run `git diff` itself — it does not read a diff passed in code. So per-repo review = spawning **one reviewer agent per sub-repo**. Architecture: the **callers loop** and call the existing single-cwd `reviewStep` (reviewer.ts **:122**) once per acquired worktree (cwd = repo, scope = prefix-derived subset); aggregate repo-tagged verdicts into the task's single review record as a **conjunction** (reviewed only if every repo passes). `reviewStep` itself stays single-cwd. + +**Both review call sites iterate (user-confirmed FN-5893 coverage):** +- `createReviewStepTool` → `reviewStep` (executor.ts **:11148**, the in-session `fn_review_step` path). +- the **step-inversion seam** `reviewStep(worktreePath=active.worktreePath || detail.worktree || this.rootDir, …)` at executor.ts **:5668** (foreach/step-inversion path). + +### KTD4 — `fn_task_done` completion verification iterates per repo, including the scope-leak guard (master KTD7) +`fn_task_done` (`createTaskDoneTool` executor.ts **:10832**) must, in workspace mode: (a) call the per-repo `verifyWorktreeInvariants` (KTD2) for every acquired worktree; (b) iterate the **scope-leak guard** `evaluateTaskDoneScopeLeak` (executor.ts **:10711**, invoked at **:11009**) per repo — it currently runs `captureUncommittedModifiedFiles(worktreePath)` + `captureModifiedFiles(worktreePath, task.baseCommitSha, …)` against the singular root and silently passes; per-repo iteration (cwd = sub-repo, `repo.baseCommitSha`) restores the uncommitted-in-scope block. Block completion on any dirty/misbound repo or uncommitted in-scope change, naming the repo. + +> **Repo-prefix derivation helper** (shared, master U5 will reuse): canonicalize → match first path segment to a configured repo → `unscoped` fallback. New `packages/engine/src/workspace-paths.ts`. Keep it minimal — no lease logic (Phase C / master U7). + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace ` comments; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (reuse the Phase-A `_workspace-fixture.ts` harness; real git only where the invariant requires it; fake timers; no mock-the-world); FN-5893 surface enumeration; the merge gate. Branch off Phase A (already checked out: `gsxdsm/workspace-phase-b`). + +### U1. Per-repo capture, contamination, and worktree-invariant verification (master U3) + +**Goal:** Change-capture, contamination, and `verifyWorktreeInvariants` cover every acquired sub-repo worktree with repo context and correct cwd. + +**Requirements:** KTD1, KTD2. + +**Dependencies:** none beyond Phase A. + +**Files:** +- `packages/engine/src/executor.ts` — **add** a workspace branch at the post-session capture **:7898** (+ attribution audit **:7914**) that loops `workspaceWorktrees` calling `captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, …)` per repo, repo-prefixing results; **un-stub** `verifyWorktreeInvariants` **:10508** to iterate per worktree preserving the `{ok|reason|observed|expected}` union (+ `repo`). +- `packages/engine/src/__tests__/executor-workspace-capture.test.ts` (new — real two-repo fixture via `_workspace-fixture.ts`) + +**Approach:** Per KTD1/KTD2. Reuse `captureModifiedFiles` (do not hand-build `git diff`); do not iterate the no-op `assertCleanBranchAtBase`. Singular non-workspace path unchanged. + +**Execution note:** Reuse `_workspace-fixture.ts`; commit edits onto each sub-repo's `fusion/` branch to exercise real diffs + the divergence audit. + +**Test scenarios:** +- Edits in repo A and B → `task.modifiedFiles` carries repo-prefixed paths from both, each diffed against its own `baseCommitSha`. (happy path) +- A repo with `baseCommitSha` undefined → capture still works via the merge-base fallback (no `git diff undefined..HEAD`). (edge — Phase A non-fatal base) +- A foreign commit in a sub-repo's range → the `filterFilesToOwnTaskCommits` divergence/contamination audit fires for that repo. (contamination) +- A worktree HEAD drifted off `fusion/` → `verifyWorktreeInvariants` returns `{ok:false, reason:'wrong_branch', repo, observed, expected}` (not `{ok:true}`); the `reason` enum is preserved for the :10889 consumer. (error path) +- Single-repo (non-workspace) task → capture/verify byte-for-byte identical. (regression) + +**Verification:** Capture + contamination audit + invariant verify run per acquired worktree with repo context; the result union is intact; single-repo unchanged. + +--- + +### U2. Per-repo review (both call sites) + `fn_task_done` completion + scope-leak verification (master U4) + +**Goal:** Review every acquired sub-repo (both review entry points) and block completion until every sub-repo passes review, invariant, and scope-leak checks. + +**Requirements:** KTD3, KTD4, KTD2. + +**Dependencies:** U1 (per-repo verify + capture). + +**Files:** +- `packages/engine/src/executor.ts` — `createReviewStepTool` **:11148** and the step-inversion seam **:5668** loop `reviewStep` per acquired worktree; `createTaskDoneTool` **:10832** calls per-repo verify (U1) + iterates `evaluateTaskDoneScopeLeak` **:10711** per repo. +- `packages/engine/src/reviewer.ts` — `reviewStep` (**:122**) stays single-cwd; callers loop. Aggregate repo-tagged verdicts (conjunction) into the task review record; reviewer findings carry the repo tag. +- `packages/engine/src/workspace-paths.ts` (new — the repo-prefix-derivation helper; master U5 reuses) +- `packages/engine/src/__tests__/reviewer-workspace.test.ts`, `packages/engine/src/__tests__/executor-workspace-taskdone.test.ts` (new) + +**Approach:** Per KTD3/KTD4. Both review sites loop the existing single-cwd `reviewStep` once per sub-repo (N reviewer agents — accepted cost) and aggregate as a conjunction. `fn_task_done` per-repo verify + per-repo scope-leak. + +**Test scenarios:** +- Two-repo task → two reviewer passes (one per repo cwd); review record reflects both; reviewed only when both pass. (conjunction) +- A reviewer finding in repo B is repo-tagged. (integration) +- Step-inversion review seam (:5668) for a workspace task reviews each sub-repo, not the non-git root. (FN-5893 second surface) +- `fn_task_done` with an uncommitted in-scope change in repo A → completion blocked, naming repo A (the scope-leak guard fires per-repo). (error path) +- `fn_task_done` with a worktree off `fusion/` → blocked via per-repo verify. (error path) +- The prefix helper: `wolf-server/src/**` → repo `wolf-server`; non-matching first segment → `unscoped`. (helper) +- Single-repo task → one review pass + singular scope-leak/verify, unchanged. (regression) + +**Verification:** A workspace task is reviewed/complete only when every sub-repo passes review + invariant + scope-leak; both review entry points iterate; single-repo unchanged. + +--- + +## Scope Boundaries + +**In scope:** per-repo capture/contamination/verify (U1); per-repo review at both call sites + `fn_task_done` verify + scope-leak (U2); the repo-prefix helper. + +### Deferred to Follow-Up Work (later phases) +- The per-repo merge loop, the landed predicate, the file-scope leases (master U5/U6/U7 = Phase C). +- Self-healing reconcilers, e2e (master U8/U9 = Phase D). +- Per-repo worktree teardown (carried Phase-A residual). +- Store-level **atomic** per-repo `workspaceWorktrees` merge — Phase A added a re-read mitigation; the fully-atomic merge is still open and **becomes reachable in Phase B** (multi-repo acquisition first exercised here). Track for Phase C. + +--- + +## Risks & Dependencies + +- **R1 — "Add a branch" vs "replace a stub" confusion.** Capture/contamination/scope-leak silently degrade (not gated); an implementer expecting a stub to replace won't find one. Mitigation: KTD1/KTD4 + U1/U2 cite the exact add sites (:7898/:7914, :10711) and the one real stub (:10508). +- **R2 — Hand-built `git diff` breaks on undefined base.** Mitigation: KTD1 mandates reusing `captureModifiedFiles`; test covers the undefined-base repo. +- **R3 — `verifyWorktreeInvariants` union shape.** The `reason` enum is load-bearing at :10889. Mitigation: KTD2 preserves the union; test asserts the `reason`. +- **R4 — No-op contamination function.** Mitigation: KTD1 explicitly forbids iterating `assertCleanBranchAtBase`; contamination rides on per-repo `captureModifiedFiles`. +- **R5 — N× reviewer cost.** Accepted (user decision). Mitigation: note in the PR; cost scales with repo count (typically 2-3). +- **Stacking dependency:** off Phase A (#1713); diff includes the stack. + +--- + +## Sources & Research + +- Master plan (U3/U4, KTD7, contamination-window caveat). +- Phase B feasibility pre-check (verified anchors: capture not gated/:7898 add-site, `assertCleanBranchAtBase` no-op, undefined-base via `resolveDiffBaseRef`, verify union :10508/:10889, review agent N× cost + the :5668 second surface, scope-leak :10711, anchor corrections). +- Phase A (#1713): per-repo `baseCommitSha`, `activeWorktrees` Set, `_workspace-fixture.ts`. +- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`. From 453ed92dbffbeb65ddc9c8ab888880108920f046 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:02:46 -0700 Subject: [PATCH 025/265] =?UTF-8?q?fix(review):=20Phase=20B=20workspace=20?= =?UTF-8?q?hardening=20=E2=80=94=20fail-closed=20scope=20guard,=20review?= =?UTF-8?q?=20conjunction,=20.changeset=20carve-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ce-code-review (4 personas) on Phase B. No P0; the review conjunction was confirmed safe (no false-done — empty map and per-repo throws both route to UNAVAILABLE, which blocks). Applied: P1: the fn_task_done scope-leak guard now fails CLOSED in workspace mode — a per-repo capture throw blocks completion ("refusing as a precaution") instead of the outer .catch returning {blocked:false} and letting an incomplete check pass. A scoped task that acquired ZERO sub-repo worktrees is now blocked rather than silently passing scope enforcement. P2: reviewWorkspacePerRepo breaks on the first non-APPROVE repo so a later repo's throw can't discard an already-determined REVISE (callers were seeing UNAVAILABLE instead). captureWorkspaceModifiedFiles isolates each per-repo capture in try/catch so one repo's throw can't skip the modifiedFiles write. The .changeset always-allowed carve-out is honored in workspace mode: the scope-leak branch now filters repo-LOCAL paths via the (previously dead) workspace-paths.ts deriveRepoScopeSubset helper through the same filter as the singular path, so a sub-repo .changeset/* no longer falsely blocks fn_task_done. All four per-repo loops iterate sorted keys for deterministic offending-repo reporting; the dead repoRel callback param and the duplicate path-normalizer are removed. Verified safe (no change): the reviewer semaphore releases on throw (try/finally), and per-repo reviewers inherit the task abort via session disposal. Deferred to Phase C: extracting a workspace-executor.ts module (before the merge loop lands). Gate green: typecheck, lint, build, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../executor-workspace-taskdone.test.ts | 71 ++++++++ .../src/__tests__/reviewer-workspace.test.ts | 40 ++++- packages/engine/src/executor.ts | 151 +++++++++++++----- packages/engine/src/workspace-paths.ts | 12 +- 4 files changed, 227 insertions(+), 47 deletions(-) diff --git a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts index 9f24aaa75c..8d62639a6d 100644 --- a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts +++ b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts @@ -164,6 +164,77 @@ describeIfGit("U2 KTD4 — per-repo scope-leak guard in fn_task_done", () => { const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); expect(result.blocked).toBe(false); }); + + // FNXC:Workspace 2026-06-21-15:00: F5 — per-repo `.changeset/` carve-out honored in workspace mode. + // A legit sub-repo changeset (`repo-a/.changeset/x.md`) must NOT be flagged off-scope: the always-allowed + // filter now runs against the repo-LOCAL remainder (`.changeset/x.md`), so the carve-out matches. Before + // the fix the file was prefixed BEFORE filtering, the `.changeset/` startsWith never matched, and + // fn_task_done was wrongly REFUSED. + it("F5: a sub-repo `.changeset/` file is NOT flagged off-scope (always-allowed honored)", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + // A per-repo changeset OUTSIDE the declared `repo-a/src/**` scope — only the always-allowed + // carve-out can keep this from being a leak. + mkdirSync(path.join(a.worktreePath, ".changeset"), { recursive: true }); + writeFileSync(path.join(a.worktreePath, ".changeset", "tidy-foo.md"), "---\n'@x': patch\n---\n", "utf-8"); + execSync("git add .changeset/tidy-foo.md", { cwd: a.worktreePath, stdio: "pipe" }); + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(false); + }); + + // FNXC:Workspace 2026-06-21-15:00: F2 — scoped task that acquired ZERO sub-repo worktrees is blocked. + // declaredScope is non-empty but `workspaceWorktrees` is empty → scope cannot be verified at all. The + // guard must refuse fn_task_done rather than silently aggregating zero off-scope files and passing. + it("F2: scoped task with zero acquired worktrees → blocked (cannot verify scope)", async () => { + fx = await createWorkspaceFixture(); + const store = createStore(["repo-a/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ branch: BRANCH, workspaceWorktrees: {} }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(true); + expect(result.message).toContain("acquired no sub-repo worktrees"); + }); + + // FNXC:Workspace 2026-06-21-15:00: F1 — fail CLOSED on a mid-loop capture throw. + // If one repo's capture throws (scope is UNVERIFIED for that repo), the guard must BLOCK naming the + // repo — not let the outer `.catch()` fail open and proceed with an incomplete scope check. + it("F1: a mid-loop capture throw → blocked (fail-closed), names the repo", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + // Narrow seam: force the per-repo uncommitted capture to throw for repo-a's worktree only. + const realCapture = (executor as any).captureUncommittedModifiedFiles.bind(executor); + vi.spyOn(executor as any, "captureUncommittedModifiedFiles").mockImplementation(async (wt: unknown) => { + if (wt === a.worktreePath) throw new Error("simulated capture failure"); + return realCapture(wt as string); + }); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(true); + expect(result.message).toContain("repo-a"); + expect(result.message).toContain("refusing fn_task_done"); + }); }); describeIfGit("U2 KTD4 — per-repo worktree-invariant verify in fn_task_done", () => { diff --git a/packages/engine/src/__tests__/reviewer-workspace.test.ts b/packages/engine/src/__tests__/reviewer-workspace.test.ts index cab774f3aa..4f5306d190 100644 --- a/packages/engine/src/__tests__/reviewer-workspace.test.ts +++ b/packages/engine/src/__tests__/reviewer-workspace.test.ts @@ -96,13 +96,17 @@ afterEach(() => { }); describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared loop both call sites use)", () => { + // FNXC:Workspace 2026-06-21-15:00: F7 — the per-repo callback is single-arg `(cwd)` now; tests map + // cwd→repo themselves (the loop no longer passes repoRel through to runForCwd). + const repoOfCwd = (cwd: string): string => (cwd === WT_A ? "repo-a" : cwd === WT_B ? "repo-b" : cwd); + it("conjunction: two repos both APPROVE → aggregate APPROVE, one reviewer pass per repo cwd", async () => { const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); const executor = workspaceExecutor(makeStore(task)); const seen: string[] = []; - const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string, repo: string) => { + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { seen.push(cwd); - return { verdict: "APPROVE", review: `clean in ${repo}`, summary: `clean ${repo}` }; + return { verdict: "APPROVE", review: `clean in ${repoOfCwd(cwd)}`, summary: `clean ${repoOfCwd(cwd)}` }; }); expect(seen).toEqual([WT_A, WT_B]); // one pass per sub-repo cwd, never ROOT expect(result.verdict).toBe("APPROVE"); @@ -113,7 +117,8 @@ describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared l it("conjunction: one repo REVISE → aggregate REVISE, tagged with the failing repo", async () => { const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); const executor = workspaceExecutor(makeStore(task)); - const result = await (executor as any).reviewWorkspacePerRepo(task, async (_cwd: string, repo: string) => { + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { + const repo = repoOfCwd(cwd); return repo === "repo-b" ? { verdict: "REVISE", review: `bug in ${repo}`, summary: `revise ${repo}` } : { verdict: "APPROVE", review: `clean ${repo}`, summary: `clean ${repo}` }; @@ -124,6 +129,35 @@ describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared l expect(result.summary).toMatch(/^repo-b:/); }); + // FNXC:Workspace 2026-06-21-15:00: F3 — break on the FIRST non-APPROVE repo. + it("F3: repo-a APPROVE + repo-b REVISE (no throw) → aggregate REVISE tagged repo-b", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const executor = workspaceExecutor(makeStore(task)); + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { + const repo = repoOfCwd(cwd); + return repo === "repo-a" + ? { verdict: "APPROVE", review: "clean repo-a", summary: "clean a" } + : { verdict: "REVISE", review: "bug repo-b", summary: "revise b" }; + }); + expect(result.verdict).toBe("REVISE"); + expect(result.summary).toMatch(/^repo-b:/); + }); + + it("F3: repo-a REVISE + repo-b throws → REVISE preserved (break before repo-b; NOT masked to UNAVAILABLE)", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const executor = workspaceExecutor(makeStore(task)); + const seen: string[] = []; + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { + seen.push(cwd); + if (cwd === WT_B) throw new Error("repo-b reviewer blew up"); + return { verdict: "REVISE", review: "bug repo-a", summary: "revise a" }; + }); + // repo-a recorded the first non-APPROVE and the loop BROKE, so repo-b's reviewer is never invoked. + expect(seen).toEqual([WT_A]); + expect(result.verdict).toBe("REVISE"); + expect(result.summary).toMatch(/^repo-a:/); + }); + it("zero-acquire workspace task → UNAVAILABLE (caller routes; no fabricated APPROVE)", async () => { const task = makeTask({ workspaceWorktrees: {} }); const executor = workspaceExecutor(makeStore(task)); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 74d0f45a2b..be43f645c9 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -82,6 +82,12 @@ import { resolveSandboxBackend } from "./sandbox/index.js"; import type { SandboxBackend } from "./sandbox/types.js"; import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@earendil-works/pi-coding-agent"; import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js"; +// FNXC:Workspace 2026-06-21-15:00: F5/F8 — wire in the previously dead workspace-path helpers. +// `normalizeRepoRelPath` is the single shared scope-path normalizer (F8); `deriveRepoScopeSubset` +// maps the task's repo-prefixed declared File Scope to a repo-LOCAL subset so the per-repo scope-leak +// filter reuses the SAME always-allowed/scope-match surface as the non-workspace path (F5). One-way +// executor→workspace-paths edge (workspace-paths imports nothing). +import { deriveRepoScopeSubset, normalizeRepoRelPath } from "./workspace-paths.js"; import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type WorktreePool } from "./worktree-pool.js"; import { attemptBranchAutocorrect } from "./branch-autocorrect.js"; import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js"; @@ -592,13 +598,14 @@ export interface WorkflowRevisionFeedbackPartition { const WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS = 4_000; const WORKFLOW_FEEDBACK_PATH_REGEX = /`([^`\n]+)`|(?` branch (repo.branch). The result union is PRESERVED EXACTLY — `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` — because the :10889 consumer switches on `reason` to drive requeue/handoff (:10894-10936). We ADD an optional `repo` field to the failure shape (purely additive; the consumer only reads reason/observed/expected) and return the FIRST failing repo. A zero-acquire workspace task (empty map) verifies vacuously → {ok:true}, matching Phase A so fn_task_done does not requeue it. if (this.workspaceConfig) { const workspaceWorktrees = task.workspaceWorktrees ?? {}; - for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + // 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). + for (const repoRel of Object.keys(workspaceWorktrees).sort()) { + const repo = workspaceWorktrees[repoRel]; const expectedBranch = repo.branch || canonicalFusionBranchName(task.id); // Skip git checks if the worktree dir is gone (mirrors the singular FN-009 carve-out below): completion does not require a live worktree on disk. if (!existsSync(repo.worktreePath)) { @@ -10873,29 +10883,74 @@ export class TaskExecutor { // against `worktreePath`. In workspace mode `worktreePath` is the browse-only non-git workspace // root, so both silently return [] (git failures swallowed) and the uncommitted-in-scope block // never fires — a workspace task could complete with off-scope changes in any sub-repo. So we - // ITERATE every acquired sub-repo (cwd = repo.worktreePath, base = repo.baseCommitSha), - // repo-prefix each repo's touched files (`/`) so they compare against the task's - // repo-prefixed declared File Scope, and block on the FIRST repo carrying off-scope changes — - // naming the repo. The task-level preamble above (scopeOverride / declaredScope / enforcementMode) - // is shared and runs once. Return shape is preserved: `{blocked:false} | {blocked:true; message}`. + // ITERATE every acquired sub-repo (cwd = repo.worktreePath, base = repo.baseCommitSha) and block + // on the FIRST repo carrying off-scope changes — naming the repo. The task-level preamble above + // (scopeOverride / declaredScope / enforcementMode) is shared and runs once. Return shape is + // preserved: `{blocked:false} | {blocked:true; message}`. + // + // FNXC:Workspace 2026-06-21-15:00: F1/F2/F5/F6 hardening of the per-repo scope-leak guard. + // F5 (false-block fix + dead-code wiring + single filter surface): we previously repo-prefixed each + // touched file (`${repoRel}/${file}`) BEFORE filtering, so `isAlwaysAllowedScopeLeakPath`'s + // `startsWith(".changeset/")` carve-out never matched a sub-repo changeset (`repo-a/.changeset/x.md`) + // and a legit per-repo changeset was wrongly flagged off-scope → fn_task_done wrongly REFUSED. Now we + // derive each repo's repo-LOCAL declared-scope subset (`deriveRepoScopeSubset`) and run the SAME + // `workflowPathMatchesDeclaredScope` + `isAlwaysAllowedScopeLeakPath` filter the non-workspace path + // uses against the repo-LOCAL touched file — one filter surface, not two. This wires in the formerly + // dead `deriveRepoScopeSubset`/`splitRepoScopedPath` helpers. + // F1 (fail CLOSED on throw): each repo iteration is wrapped in its own try/catch (like the + // attribution-audit loop). A thrown capture/diff error in workspace mode surfaces as a BLOCK naming + // the repo instead of bubbling to the outer `.catch()` that fails OPEN — an incomplete scope check + // must never let fn_task_done proceed. + // F2 (scoped-but-zero-acquire): a scoped task that acquired NO sub-repo worktrees aggregates zero + // off-scope files and would silently pass; we block it (scope is declared but unverifiable). + // F6 (deterministic ordering): iterate sorted repo keys so the reported offending repo is stable + // across runs/rehydrate. let touchedFiles: string[]; let offendingRepo: string | undefined; if (this.workspaceConfig) { const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const repoKeys = Object.keys(workspaceWorktrees).sort(); + // F2: declaredScope is non-empty here (the `declaredScope.length === 0` early-return above + // handled the unscoped case). A scoped task that acquired no sub-repo worktrees cannot have its + // scope verified at all — refuse rather than silently passing scope enforcement. + if (repoKeys.length === 0) { + const message = "workspace task declares File Scope but acquired no sub-repo worktrees — cannot verify scope"; + executorLog.warn(`${task.id}: [scope-leak] ${message}`); + await this.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, this.getRunContextFor(task.id)); + return { blocked: true, message }; + } const aggregatedOffScope: string[] = []; - for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { - const [repoUncommitted, repoCommitted] = await Promise.all([ - this.captureUncommittedModifiedFiles(repo.worktreePath), - this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, "scope-leak-guard"), - ]); - const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted])].map((f) => `${repoRel}/${f}`); - const repoOffScope = repoTouched - .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope)) - .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)); - if (repoOffScope.length > 0) { - // First offending repo wins (mirrors verifyWorktreeInvariants' first-failing-repo return). - if (!offendingRepo) offendingRepo = repoRel; - aggregatedOffScope.push(...repoOffScope); + for (const repoRel of repoKeys) { + const repo = workspaceWorktrees[repoRel]; + try { + const [repoUncommitted, repoCommitted] = await Promise.all([ + this.captureUncommittedModifiedFiles(repo.worktreePath), + this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, "scope-leak-guard"), + ]); + // Repo-LOCAL touched files (no `${repoRel}/` prefix) so the always-allowed `.changeset/` + // carve-out and the scope match operate as the reviewer/cwd=repo sees them (F5). + const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted])]; + // Repo-LOCAL declared-scope subset for THIS repo (prefix stripped). Same filter as the + // non-workspace branch below — one surface. + const repoScopeSubset = deriveRepoScopeSubset(declaredScope, repoRel); + const repoOffScope = repoTouched + .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, repoScopeSubset)) + .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)) + // Re-prefix the surviving off-scope files for the operator-facing message/attribution. + .map((filePath) => `${repoRel}/${filePath}`); + if (repoOffScope.length > 0) { + // First offending repo wins (mirrors verifyWorktreeInvariants' first-failing-repo return). + if (!offendingRepo) offendingRepo = repoRel; + aggregatedOffScope.push(...repoOffScope); + } + } catch (repoErr: unknown) { + // F1: fail CLOSED. A capture/diff throw means scope is UNVERIFIED for this repo; refuse + // fn_task_done as a precaution rather than letting the outer `.catch()` fail open. + const errMessage = repoErr instanceof Error ? repoErr.message : String(repoErr); + const message = `workspace scope-leak guard failed to evaluate (${repoRel}/${errMessage}) — refusing fn_task_done as a precaution`; + executorLog.warn(`${task.id}: [scope-leak] ${message}`); + await this.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, this.getRunContextFor(task.id)); + return { blocked: true, message }; } } touchedFiles = aggregatedOffScope; @@ -12455,11 +12510,21 @@ ${failureFeedback} source = "post-session", ): Promise { const workspaceWorktrees = task.workspaceWorktrees ?? {}; + // FNXC:Workspace 2026-06-21-15:00: F4/F6 — per-repo error isolation + deterministic ordering. + // F4: an unexpected throw from one repo's `captureModifiedFiles` must NOT escape and skip the + // downstream `updateTask({modifiedFiles})` write — that would leave `task.modifiedFiles` empty and + // blind the merge file audit. Wrap each per-repo call (log + continue), mirroring the post-session + // branch-attribution loop. F6: iterate sorted repo keys so aggregation order is stable across runs. const aggregated: string[] = []; - for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { - const repoFiles = await this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source); - for (const file of repoFiles) { - aggregated.push(`${repoRel}/${file}`); + for (const repoRel of Object.keys(workspaceWorktrees).sort()) { + const repo = workspaceWorktrees[repoRel]; + try { + const repoFiles = await this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source); + for (const file of repoFiles) { + aggregated.push(`${repoRel}/${file}`); + } + } catch (repoErr: unknown) { + executorLog.warn(`${task.id}: per-repo modified-file capture failed for ${repoRel}: ${repoErr instanceof Error ? repoErr.message : String(repoErr)}`); } } return aggregated; @@ -12483,12 +12548,18 @@ ${failureFeedback} * UNAVAILABLE retry) is unchanged. */ private async reviewWorkspacePerRepo( + // FNXC:Workspace 2026-06-21-15:00: F7 — drop the dead `repoRel` callback param. + // Both call sites bind `(cwd) => runForCwd(cwd)` and discard the second arg, so the type wrongly + // implied repo identity is observable inside `runForCwd`. Removed until a real consumer needs it + // (Phase C). The loop below still tags findings with `repoRel` from its own iteration key. task: Task, - invokeForCwd: (cwd: string, repoRel: string) => Promise, + invokeForCwd: (cwd: string) => Promise, ): Promise { const workspaceWorktrees = task.workspaceWorktrees ?? {}; - const entries = Object.entries(workspaceWorktrees); - if (entries.length === 0) { + // FNXC:Workspace 2026-06-21-15:00: F6 — sort repo keys so the reported FIRST failing repo is + // deterministic across runs/rehydrate. + const repoKeys = Object.keys(workspaceWorktrees).sort(); + if (repoKeys.length === 0) { // No acquired worktree — surface UNAVAILABLE so the caller routes it rather than // fabricating an authoritative APPROVE for an un-reviewable workspace task. return { @@ -12501,13 +12572,19 @@ ${failureFeedback} const reviewSections: string[] = []; const summarySections: string[] = []; let firstFailing: { repo: string; result: ReviewResult } | undefined; - for (const [repoRel, repo] of entries) { - const result = await invokeForCwd(repo.worktreePath, repoRel); + for (const repoRel of repoKeys) { + const repo = workspaceWorktrees[repoRel]; + const result = await invokeForCwd(repo.worktreePath); // Tag every per-repo finding with its sub-repo so downstream readers attribute it correctly. reviewSections.push(`### [${repoRel}] ${result.verdict}\n${result.review}`); summarySections.push(`[${repoRel}] ${result.verdict}: ${result.summary}`); - if (result.verdict !== "APPROVE" && !firstFailing) { + if (result.verdict !== "APPROVE") { + // FNXC:Workspace 2026-06-21-15:00: F3 — BREAK on the first non-APPROVE repo. + // The contract is "the FIRST non-APPROVE repo's verdict becomes the aggregate". Without the + // break, a LATER repo's reviewer throwing would discard this already-determined REVISE/RETHINK + // and the caller would see UNAVAILABLE — masking the real verdict. Stop at the first failure. firstFailing = { repo: repoRel, result }; + break; } } @@ -12524,8 +12601,8 @@ ${failureFeedback} // Every sub-repo approved → the task is reviewed (conjunction satisfied). return { verdict: "APPROVE", - review: `All ${entries.length} sub-repo(s) approved. Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, - summary: `APPROVE across ${entries.length} sub-repo(s): ${summarySections.join(" | ")}`, + review: `All ${repoKeys.length} sub-repo(s) approved. Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, + summary: `APPROVE across ${repoKeys.length} sub-repo(s): ${summarySections.join(" | ")}`, }; } diff --git a/packages/engine/src/workspace-paths.ts b/packages/engine/src/workspace-paths.ts index 308c559341..299dbfe357 100644 --- a/packages/engine/src/workspace-paths.ts +++ b/packages/engine/src/workspace-paths.ts @@ -10,13 +10,11 @@ Matching rule: canonicalize the path to forward-slash relative segments, then pi /** Sentinel returned when a path does not belong to any configured sub-repo. */ export const UNSCOPED_REPO = "unscoped" as const; -/** - * Normalize a workspace-relative path token to forward-slash form with no leading - * `./`, no leading/trailing slashes, and collapsed duplicate slashes. Mirrors the - * executor's `normalizeWorkflowScopePath` shape so File-Scope tokens and modified - * files compare consistently, but kept local to avoid an executor import cycle. - */ -function normalizeRepoRelPath(value: string): string { +/* +FNXC:Workspace 2026-06-21-15:00: +F8 — single normalize helper. The executor previously kept its own `normalizeWorkflowScopePath` that was a near-duplicate of this function, differing only in leading-slash stripping (`/^\/+/` here vs none there) and trailing-slash greediness (`/\/+$/` here vs `/\/$/` there). Two slightly-different normalizers meant an absolute or trailing-slash-laden path could derive a different scope key in the two code paths. We promote THIS (more aggressive: strips leading slash + collapses repeated trailing slashes) to the single exported normalizer and have the executor import it for scope-path normalization, so workspace and non-workspace scope matching canonicalize identically. workspace-paths.ts stays dependency-light (imports nothing), so executor→workspace-paths is a one-way, acyclic edge. +*/ +export function normalizeRepoRelPath(value: string): string { return value .trim() .replace(/\\/g, "/") From 14114f5f5158d7c2c64dba6b32dd0d9cd6d05caf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:07:35 -0700 Subject: [PATCH 026/265] docs(workspace): note Phase B review hardening in the U2 changeset --- .changeset/workspace-phase-b-u2-per-repo-review-taskdone.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md index efd5004886..bdb9b95252 100644 --- a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md +++ b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md @@ -3,3 +3,5 @@ --- Workspace mode (Phase B, U2): per-repo review at both review entry points plus per-repo `fn_task_done` completion + scope-leak verification. In workspace mode both review call sites (the in-session `fn_review_step` tool and the step-inversion review seam) now loop the single-cwd `reviewStep` once per acquired sub-repo (cwd = each repo's worktree) and aggregate the repo-tagged verdicts as a conjunction — the task is reviewed only when every sub-repo approves, and the first failing sub-repo's verdict (with repo-tagged findings) drives the existing verdict→edge mapping. `fn_task_done` now verifies worktree invariants per acquired repo and iterates the scope-leak guard per sub-repo (cwd = repo worktree, repo `baseCommitSha`), blocking completion on any sub-repo carrying off-scope changes and naming the repo. Adds a minimal shared repo-prefix-derivation helper (`workspace-paths.ts`). Single-repo behavior is unchanged. + +Phase-B hardening: the per-repo scope-leak guard now fails CLOSED — a thrown capture/diff error in any sub-repo refuses `fn_task_done` (naming the repo) instead of failing open, and a scoped task that acquired zero sub-repo worktrees is blocked rather than silently passing. A legitimate per-repo `.changeset/` file is no longer falsely flagged off-scope (the always-allowed carve-out now runs against the repo-local path). Per-repo review stops at the first non-APPROVE sub-repo so a later repo's reviewer error can't mask an already-determined REVISE/RETHINK. Per-repo capture failures are isolated (one repo's error no longer drops the whole modified-files write), and the reported offending/failing repo is now deterministic (sorted repo iteration). Single-repo behavior remains unchanged. From edd79a87dcf0be685ec09f51d6c3be1087f8978c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:15:38 -0700 Subject: [PATCH 027/265] =?UTF-8?q?docs(workspace):=20Phase=20C=20plan=20?= =?UTF-8?q?=E2=80=94=20per-repo=20merge=20loop=20(U5/U6/U7),=20forks=20res?= =?UTF-8?q?olved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-06-21-006-feat-workspace-phase-c-plan.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md diff --git a/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md b/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md new file mode 100644 index 0000000000..c2bd15ef6a --- /dev/null +++ b/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md @@ -0,0 +1,151 @@ +--- +title: "feat: Workspace mode Phase C — per-repo merge loop (land-as-you-go on local integration refs)" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase C / U5·U6·U7) +depth: deep +--- + +# feat: Workspace mode Phase C — per-repo merge loop (land-as-you-go on local integration refs) + +> **ID namespace:** local `U0·U1·U2·U3` decompose master-plan **U5, U6, U7** (+ a Phase-B-deferred extraction). +> **Anchors are feasibility-pending** — a pre-check runs before implementation (as in Phases A/B). Treat `~:` numbers as approximate until verified. + +## Summary + +Phase C replaces U0's **R7 guard** — which currently makes every workspace-task merge *throw* `WorkspaceTaskMergeError` — with the real **per-repo merge loop**: for each acquired sub-repo, land that repo's `fusion/` branch onto **that repo's LOCAL integration ref** via a repo-scoped clean-room (the `runAiMerge` mechanism, applied per repo), with no remote push. This is **land-as-you-go** (settled **D2/D5**): repos land independently; a partial land (A lands, B fails) leaves A landed locally and is operator-resettable; an unconditional operator escape hatch always exists. + +After Phase C a workspace task can fully run → capture → review → **merge**. **Scope out:** self-healing reconcilers + e2e harness (master U8/U9 = Phase D). + +**Stacking:** off Phase B (#1714); PR diff includes the stack; must not merge until it lands. + +--- + +## Problem Frame + +`runAiMerge` (merger-ai.ts) lands **one** `task.worktree`'s `fusion/` branch into a single clean-room temp worktree and advances **one** local integration ref via `update-ref` CAS (no push). U0 added the **R7 chokepoint guard** `assertNotWorkspaceTaskMerge(task)` so a `workspaceWorktrees`-bearing task fails fast rather than silently mis-merging the single root. Phase C turns that fail-fast into a real loop: iterate the acquired sub-repos, run the clean-room land per repo against that repo's own local integration ref, track which repos have landed (idempotent retry), hold a per-repo file-scope lease during each land, and aggregate a per-repo `MergeResult`. The single-repo `runAiMerge` path is untouched. + +--- + +## Key Technical Decisions + +> **OPEN FORKS — to be confirmed by the feasibility pre-check + user before implementation.** Marked `‹FORK›`. The settled semantics (D2/D5) bound them, but the code shape is to verify. + +### KTD0 — Extract `workspace-executor.ts` FIRST (Phase-B-deferred maintainability P1) +Before adding the merge loop, move the workspace branches Phase A/B inlined into `executor.ts` (`captureWorkspaceModifiedFiles`, `reviewWorkspacePerRepo`, the per-repo `verifyWorktreeInvariants` block) into `packages/engine/src/workspace-executor.ts` as module-level functions receiving executor state as args; the `if (this.workspaceConfig)` call sites delegate. Pure move + delegate, no behavior change — its own commit, gate-green, before any Phase-C behavior. This keeps the 16k-line file from absorbing the merge loop too. + +### KTD1 — Extract `landOneRepo` from `runAiMerge`, then loop it (master U6; D2/D5) — FORK-A RESOLVED +**Verified:** `runAiMerge`'s land sequence (mkdtemp clean room → `git worktree add --detach` → `installWorktreeDependencies` → `mergeAndReview` → `landSquash` → the concurrent-advance CAS retry loop → `activeSessionRegistry` register/unregister) is an **un-factored inline closure** at `merger-ai.ts:1064-1216`, bound to one `projectRootDir`/`integrationBranch`/`branch`; `mergeAndReview`/`finalizeMerged` are module-private. The CAS seam `advanceIntegrationBranchRef` already takes `rootDir`/`integrationBranch` explicitly. **No remote push anywhere** — D2/D5 "no push" confirmed. + +So U1 **extracts** an exported `landOneRepo(store, repoRootDir, branch, integrationBranch, options)` from that closure (returns a per-repo `LandResult`), leaving `runAiMerge` as the byte-for-byte single-repo caller. `landWorkspaceTask(task)` loops the acquired sub-repos calling `landOneRepo` per repo, aggregating a repo-tagged result. **`landOneRepo` stays in `merger-ai.ts`** (the private helpers live there); only the thin `landWorkspaceTask` orchestrator may sit in a new `workspace-merger.ts`. + +**Per-repo integration branch (P1 the plan missed):** `workspaceWorktrees[repo]` does NOT store the integration branch (acquisition computes it then discards). `landOneRepo` must **re-resolve per repo** with the same override-stripping acquisition uses — `resolveIntegrationBranch(repoRoot, { ...settings, integrationBranch: undefined, baseBranch: undefined })` — so each sub-repo lands on its own `origin/HEAD`, not a shared branch. + +**Per-sub-repo prune rooting (correctness):** `pruneExistingAiMergeWorktrees`/`cleanupStaleTempMergeWorktrees` sweep by the `fusion-ai-merge--` prefix; N per-repo clean rooms share the taskId. Root each sweep at the **sub-repo** (`resolveAiMergeRoot(subRepoRoot)`) so one repo's prune cannot race another repo's live clean room for the same task. + +### KTD2 — Door table: route the engine + CLI/dashboard doors, keep the rest throwing (master U6) — RESOLVED +Six guard sites. Per-door (FN-5893): +1. **`project-engine.ts:~2300` engine dispatch** → route `workspaceWorktrees`-bearing tasks to `landWorkspaceTask`. +2. **`runAiMerge:~979` chokepoint guard** → STAYS as defense-in-depth for direct single-repo callers (workspace tasks enter via `landWorkspaceTask`, not here). +3. **`store.mergeTask:~11159`** (core, cannot import `@fusion/engine`) → STAYS throwing. +4. **CLI `dashboard.ts:~1312` + `task.ts:~861`** → **route workspace tasks through the engine merge (`landWorkspaceTask`)** instead of `store.mergeTask`, so user-triggered `fn task merge` / the dashboard merge button work on workspace tasks **(user decision: manual merge works in Phase C)**. +5. **`aiMergeTask` (merger.ts:~7666, deprecated)** → STAYS throwing. + +### KTD3 — `landedSha`-only per repo; `landWorkspaceTask` finalizes once; auto-retry then park (master U5) — FORK-B RESOLVED +**Verified:** `finalizeMerged`/`finalizeTask` are **task-global** — they write one task-level `mergeDetails` and move the WHOLE task to `done` (`merger-ai.ts:1298-1401`). So `landOneRepo` must advance the ref + record `workspaceWorktrees[repo].landedSha` **only** (no task move). `landWorkspaceTask` calls `finalizeTask`/move-done **exactly once** after every acquired repo's landed predicate is true. + +**Landed predicate:** a repo is landed iff `entry.branch` tip is an ancestor of (or equals) its local integration ref tip (or the recorded `landedSha` is present); `landWorkspaceTask` **skips landed repos** (idempotent). + +**Partial-land (user decision: auto-retry then park):** repo B fails after A landed → task goes to a non-done state with A's `landedSha` persisted; the failure **consumes a `mergeRetry`** and the engine **auto-retries `landWorkspaceTask`** (skipping landed A, re-attempting B) up to the existing `MAX`, then **operator-parks** (D5 escape hatch as terminal). No new partial-landed status type — `landedSha` on the entry is the only state added (`types.ts:~2256`). + +### KTD4 — Per-repo land lease via `activeSessionRegistry` new kind (master U7) — FORK-C RESOLVED +**Verified:** there is NO separate engine file-scope lease — `activeSessionRegistry` (path-keyed, `kind` enum) is the only mechanism (`runAiMerge` already registers the clean room under `kind:"ai-merge"`). Add a new `ActiveSessionKind` `"workspace-repo-land"` keyed on the **sub-repo absolute path**; register before `landOneRepo`, unregister in `finally`. **The lease is for serialization / clean-room-collision avoidance, not ref correctness** — `advanceIntegrationBranchRef`'s CAS already makes interleaved `update-ref` safe (concurrent-advance → rebuild). Set test expectations accordingly. + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace ` comments; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (real two-repo git fixture via `_workspace-fixture.ts`; assert local-ref advancement with NO push; fake timers; no mock-the-world); FN-5893 surface enumeration; the merge gate. Branch off Phase B (`gsxdsm/workspace-phase-c`). + +### U0. Extract `workspace-executor.ts` (no behavior change) +**Goal:** Move Phase A/B workspace helpers out of `executor.ts` into `workspace-executor.ts`; call sites delegate. Pure refactor. +**Requirements:** KTD0. +**Dependencies:** none. +**Files:** `packages/engine/src/executor.ts`, `packages/engine/src/workspace-executor.ts` (new), existing workspace tests (imports may shift). +**Approach:** Move `captureWorkspaceModifiedFiles`, `reviewWorkspacePerRepo`, the per-repo `verifyWorktreeInvariants` body; pass `store`/`captureModifiedFiles`/etc. as args. No logic change. +**Test scenarios:** the existing Phase A/B workspace suites pass unchanged (the move is correct iff they stay green). `Test expectation: behavior-preserving — existing suites are the oracle.` +**Verification:** All Phase A/B workspace tests + `test:gate` green; `executor.ts` shrinks; no behavior diff. + +### U1. Extract `landOneRepo`, loop it in `landWorkspaceTask`, route the doors (master U6) +**Goal:** Land each acquired sub-repo's branch onto its own local integration ref (land-as-you-go, no push), via an extracted `landOneRepo`; route the engine + CLI/dashboard doors. +**Requirements:** KTD1, KTD2. +**Dependencies:** U0. +**Files:** `packages/engine/src/merger-ai.ts` (extract `landOneRepo` from the `:1064-1216` closure; add `landWorkspaceTask`), `packages/engine/src/project-engine.ts` (`~:2300` dispatch → `landWorkspaceTask`), `packages/cli/src/commands/dashboard.ts` (`~:1312`) + `packages/cli/src/commands/task.ts` (`~:861`) (route workspace tasks to the engine merge), optional `packages/engine/src/workspace-merger.ts` (thin orchestrator), `packages/engine/src/__tests__/workspace-merger.test.ts` (new). +**Approach:** Per KTD1/KTD2. **(a)** Extract `landOneRepo(store, repoRootDir, branch, integrationBranch, options)` from the inline closure — `runAiMerge` becomes its single-repo caller, byte-for-byte. **(b)** `landWorkspaceTask` loops the acquired sub-repos: re-resolve each repo's integration branch (override-stripped), root the prune at the sub-repo, call `landOneRepo`, aggregate repo-tagged results. **(c)** Route the engine dispatch + both CLI doors to `landWorkspaceTask` for `workspaceWorktrees`-bearing tasks; `store.mergeTask`/`aiMergeTask`/the `runAiMerge` chokepoint keep throwing (defense-in-depth). +**Execution note:** Real two-repo fixture; commit on each `fusion/`; assert each repo's **local** integration ref advanced and **no remote ref/push** occurred; assert per-sub-repo prune rooting. +**Test scenarios:** +- Two acquired repos, both clean → both local integration refs advance against each repo's own resolved branch; no push/remote ref; result tags both. (happy) +- Repos with different integration branches → each lands on its own (override-stripping works; not a shared branch). (per-repo resolution) +- A conflict in repo B → repo A lands (its `landedSha` recorded); B's result reports the conflict; the task is NOT moved done. (partial — D2/D5) +- The single-repo (non-workspace) `runAiMerge` path → byte-for-byte unchanged (it calls the extracted `landOneRepo`). (regression) +- `store.mergeTask`/`aiMergeTask` with a workspace task → still throws `WorkspaceTaskMergeError`. (defense-in-depth) +- A workspace task via the CLI/dashboard merge door → routes to `landWorkspaceTask` (does not throw). (user-facing door) +**Verification:** Workspace merges land per repo on local refs (no push) via `landOneRepo`; single-repo unchanged; user doors route; non-routed doors stay guarded. + +### U2. Per-repo landed predicate + idempotent retry (master U5) +**Goal:** Track landed repos; retry skips them. +**Requirements:** KTD3. +**Dependencies:** U1. +**Files:** `packages/core/src/types.ts` (`workspaceWorktrees[repo].landedSha?`), the loop in U1, `packages/engine/src/__tests__/workspace-merger-idempotency.test.ts` (new). +**Approach:** Per KTD3. `landOneRepo` records `workspaceWorktrees[repo].landedSha` only (no task move); `landWorkspaceTask` calls `finalizeTask`/move-done exactly once after every acquired repo's landed predicate holds. Landed predicate = ancestor check (or `landedSha` present); skip landed repos. Partial-land → non-done state with `landedSha` persisted; the failure **consumes a `mergeRetry`** and is **auto-retried up to `MAX`, then operator-parked** (user decision). +**Test scenarios:** +- Re-running `landWorkspaceTask` after repo A landed + repo B failed → A is skipped (not re-landed), B is retried; A's ref does not move twice. (idempotency — partial land) +- Landed predicate true when branch tip is an ancestor of the integration tip. (predicate) +- `finalizeTask` runs exactly once, only after ALL repos landed (not per-repo). (completion — no premature done) +- Partial-land failure consumes one `mergeRetry`; after `MAX` retries the task is operator-parked, not silently failed. (retry/park) +**Verification:** Partial lands are idempotent on retry; the task moves done exactly once; auto-retry then park works; no double-land. + +### U3. Per-repo file-scope lease during land (master U7) +**Goal:** Serialize concurrent same-sub-repo lands. +**Requirements:** KTD4. +**Dependencies:** U1. +**Files:** the lease seam (FORK-C), the loop in U1, `packages/engine/src/__tests__/workspace-merger-lease.test.ts` (new). +**Approach:** Per KTD4. Acquire a per-repo integration-ref lease before each `landOneRepo`, release in `finally`. +**Test scenarios:** +- Two workspace tasks landing the same sub-repo concurrently → serialized (one waits/fails-fast, no interleaved `update-ref`). (concurrency) +- Disjoint sub-repos → land in parallel without contention. (no false serialization) +- Lease released on land failure (no stuck lock). (cleanup) +**Verification:** Same-sub-repo lands serialize; the lease never leaks. + +--- + +## Scope Boundaries + +**In scope:** the extraction (U0), the per-repo merge loop + R7-throw replacement (U1), landed predicate + idempotent retry (U2), per-repo lease (U3). + +### Deferred to Follow-Up Work (Phase D / master U8·U9) +- Self-healing reconcilers for partial-landed / stuck workspace merges. +- The e2e workspace harness. +- Per-repo worktree teardown (carried residual). +- Remote push of integration refs (explicitly out — D2/D5 are local-ref only). +- Store-level atomic per-repo `workspaceWorktrees` merge (carried residual). + +--- + +## Risks & Dependencies + +- **R1 — R7 throw replacement must not weaken the single-repo guard.** Mitigation: KTD2 dispatches only when `workspaceWorktrees` non-empty; untaught doors keep the throw; regression + defense-in-depth tests. +- **R2 — Partial-land leaves inconsistent local state.** Accepted (D2/D5: local + operator-resettable). Mitigation: KTD3 idempotent retry + persisted `landedSha`; the local-ref-only design means no remote pollution. +- **R3 — Clean-room helper reuse across the loop.** `runAiMerge`'s temp-worktree/CAS seams must be callable per repo without cross-repo state bleed. Mitigation: feasibility pre-check verifies the seams; U1 asserts no cross-repo bleed. +- **R4 — Lease vs acquisition-exclusivity confusion.** The Phase-A/U2 acquisition lock and the Phase-C land lease are different scopes. Mitigation: KTD4 distinct kind; test both. +- **R5 — `executor.ts` extraction regression (U0).** Mitigation: behavior-preserving; existing suites are the oracle; gate-green before U1. +- **Stacking dependency:** off Phase B (#1714); diff includes the stack. + +--- + +## Sources & Research + +- Master plan (U5/U6/U7, KTD2/KTD4/KTD7, D2/D5, R7). +- This session: `runAiMerge` advances the LOCAL integration ref via `update-ref` CAS (~merger-ai.ts:817/847), no push; the R7 chokepoint guard `assertNotWorkspaceTaskMerge` (~:979) + the door guards; `store.mergeTask` (third path); `SelfHealingManager.cleanupStaleTempMergeWorktrees` prefix sweep. +- Phase A/B (#1713/#1714): per-repo `baseCommitSha`, `activeWorktrees` Set, `workspace-paths.ts`, `_workspace-fixture.ts`, the workspace helpers U0 extracts. From 744ed098a5f329fb2c3a8afddfa13660aceba6c5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:31:43 -0700 Subject: [PATCH 028/265] =?UTF-8?q?feat(workspace):=20Phase=20C=20U1=20?= =?UTF-8?q?=E2=80=94=20per-repo=20merge=20loop=20(landOneRepo=20+=20landWo?= =?UTF-8?q?rkspaceTask)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the per-repo land mechanics out of runAiMerge's inline clean-room closure into an exported landOneRepo(store, repoRootDir, branch, integrationBranch, ctx): pre-merge prune (rooted at the sub-repo), the clean-room temp worktree, mergeAndReview, landSquash, and the CAS concurrent-advance retry that advances ONE local integration ref — no remote push. runAiMerge is rewired as the single-repo caller (its task-global finalization unchanged); the merger-ai suite (56 tests) stays green as the byte-for-byte oracle. landWorkspaceTask loops a workspace task's acquired sub-repos (sorted keys), re-resolving each repo's integration branch with the shared override stripped ({...settings, integrationBranch: undefined, baseBranch: undefined}) so each sub-repo lands on its own origin/HEAD, calls landOneRepo per repo, and aggregates repo-tagged results — land-as-you-go on each repo's LOCAL ref (D2/D5). It does NOT finalize/move the task (finalize-once + landed-tracking + idempotent retry are U2). Door routing (KTD2): the engine dispatch and the user-facing CLI `fn task merge` + dashboard merge doors route workspace tasks to landWorkspaceTask so manual merge works; store.mergeTask, aiMergeTask, and the runAiMerge chokepoint guard keep throwing WorkspaceTaskMergeError as defense-in-depth. New two-repo fixture tests: both repos land + no-push assertion, per-repo override-stripped resolution onto distinct branches, repo-B conflict partial land (task not moved), defense-in-depth throws. Gate green: typecheck, lint, build, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...orkspace-phase-c-u1-per-repo-merge-loop.md | 12 + packages/cli/src/commands/dashboard.ts | 30 +- packages/cli/src/commands/task.ts | 36 +- .../src/__tests__/workspace-merger.test.ts | 292 ++++++++++ packages/engine/src/index.ts | 10 + packages/engine/src/merger-ai.ts | 537 +++++++++++++----- packages/engine/src/project-engine.ts | 51 +- 7 files changed, 798 insertions(+), 170 deletions(-) create mode 100644 .changeset/workspace-phase-c-u1-per-repo-merge-loop.md create mode 100644 packages/engine/src/__tests__/workspace-merger.test.ts diff --git a/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md b/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md new file mode 100644 index 0000000000..ec87d1eb94 --- /dev/null +++ b/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md @@ -0,0 +1,12 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase C (U1): per-repo merge loop. Extract `landOneRepo` from the +`runAiMerge` clean-room land closure (single-repo behavior unchanged) and add +`landWorkspaceTask`, which lands each acquired sub-repo's `fusion/` branch onto +that repo's OWN local integration ref (re-resolved per repo with overrides stripped), +land-as-you-go with no remote push. The engine merge dispatch and the user-facing +CLI/dashboard merge doors now route workspace tasks through this loop instead of +throwing; `store.mergeTask`, `aiMergeTask`, and the `runAiMerge` chokepoint keep +throwing `WorkspaceTaskMergeError` as defense-in-depth. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index fed1e64b88..2140ea3496 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -9,7 +9,6 @@ import { CentralCore, AgentStore, PluginLoader, - assertNotWorkspaceTaskMerge, getTaskMergeBlocker, getEnabledPiExtensionPaths, isEphemeralAgent, @@ -43,6 +42,7 @@ import { } from "@fusion/dashboard"; import { runAiMerge, + landWorkspaceTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, @@ -1305,11 +1305,31 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // aiMergeTask is soft-deprecated. // const onMergeImpl = async (taskId: string) => { - // FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0). - // Reject workspace-mode tasks before any merge work; per-repo merge lands in - // master-plan U6, which removes this guard. + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2): + // Dashboard merge button (UI-only mode). A workspace-mode task routes through + // the ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its + // own LOCAL integration ref, no push) instead of throwing — manual merge works in + // Phase C (user decision). U0's R7 throw is replaced here by routing; the engine + // chokepoint + store.mergeTask/aiMergeTask keep throwing as defense-in-depth. const mergeTask = await store.getTask(taskId).catch(() => null); - if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask); + const isWorkspaceMerge = + !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; + if (isWorkspaceMerge) { + const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, { + agentStore, + }); + const latest = await store.getTask(taskId).catch(() => mergeTask!); + // U1 does not finalize the workspace task (finalize-once move-to-done is U2); + // report merged=false until then. + return { + task: latest ?? mergeTask!, + branch: getTaskBranchName(taskId), + merged: false, + worktreeRemoved: false, + branchDeleted: false, + error: workspaceResult.allLanded ? undefined : "partial workspace land — see task log", + }; + } const settings = await store.getSettings(); if (getMergeStrategy(settings) === "pull-request") { diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 83d4ca5a3c..13054fcc7c 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,5 +1,5 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, assertNotWorkspaceTaskMerge, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; -import { runAiMerge } from "@fusion/engine"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { runAiMerge, landWorkspaceTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning"; @@ -851,14 +851,32 @@ export async function runTaskMerge(id: string, projectName?: string) { console.log(`\n Merging ${id} with AI...\n`); try { - // FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0). - // Reject workspace-mode tasks before any merge work; per-repo merge lands in - // master-plan U6, which removes this guard. - // FNXC:MergerUnification 2026-06-21-19:05: unified onto runAiMerge (U0). - // The guard lives INSIDE this try so its throw renders via the formatted - // ` ✗ ...` output below instead of the generic top-level bin.ts handler. + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2): + // User-triggered `fn task merge`. A workspace-mode task routes through the + // ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own + // LOCAL integration ref, no push) instead of throwing — manual merge works in + // Phase C (user decision). U0's R7 throw is replaced here by routing; the + // engine chokepoint + store.mergeTask/aiMergeTask keep throwing. const mergeTaskRecord = await store.getTask(id).catch(() => null); - if (mergeTaskRecord) assertNotWorkspaceTaskMerge(mergeTaskRecord); + const isWorkspaceMerge = + !!mergeTaskRecord?.workspaceWorktrees && Object.keys(mergeTaskRecord.workspaceWorktrees).length > 0; + if (isWorkspaceMerge) { + const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, { + onAgentText: (delta) => process.stdout.write(delta), + }); + console.log(); + for (const repo of workspaceResult.repos) { + const label = + repo.status === "landed" ? `landed ${repo.landedSha?.slice(0, 8) ?? ""} → ${repo.integrationBranch}` + : repo.status === "empty" ? "no net changes" + : `failed: ${repo.error ?? "unknown"}`; + console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`); + } + // U1 does not move the workspace task to done (finalize-once is U2). + console.log(`\n ${workspaceResult.allLanded ? "✓ All sub-repos landed" : "✗ Partial land — see failures above"} (task remains in review until U2)\n`); + if (!workspaceResult.allLanded) process.exit(1); + return; + } const result = await runAiMerge(store, projectPath, id, { onAgentText: (delta) => process.stdout.write(delta), diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts new file mode 100644 index 0000000000..0e4ec55a64 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -0,0 +1,292 @@ +/* +FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1/KTD2): +Per-repo workspace merge-loop tests. They drive the REAL `landWorkspaceTask` / +`landOneRepo` against a REAL two-repo git fixture under a NON-git workspace root +(createWorkspaceFixture), so a leaked rootDir git preflight would actually fail and a +shared clean-room root would race. Real git is used only where the invariant requires +it (the local-ref advance, the no-push assertion); the AI merge/review agents are +injected (deps) so NO real AI calls happen and the squash is produced by a plain +`git merge --squash` inside the clean room — no mock-the-world child_process. + +Coverage (FN-5893 surfaces): +- happy: two acquired repos both clean → BOTH local integration refs advance against + each repo's own resolved branch; NO remote ref/push happened; result tags both. +- per-repo resolution: repos with DIFFERENT origin/HEAD integration branches → each + lands on its own (override-stripping works, not a shared branch). +- partial: a conflict in repo B → repo A lands (landedSha recorded); B reports the + failure; the task is NOT moved done (no finalizeTask call). +- defense-in-depth: store.mergeTask / aiMergeTask with a workspace task → still throw + WorkspaceTaskMergeError. +The single-repo runAiMerge regression lives in the existing merger-ai*.test.ts (the +extraction is byte-for-byte; runAiMerge is landOneRepo's single-repo caller). +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; +import { assertNotWorkspaceTaskMerge } from "@fusion/core"; +import { landWorkspaceTask } from "../merger-ai.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-2001"; +const BRANCH = "fusion/fn-2001"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RecordingStore extends EventEmitter { + moveTaskCalls: Array<{ id: string; column: string }>; + emitted: Array<{ event: string; payload: unknown }>; +} + +function createStore(settings: Record = {}): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const moveTaskCalls: Array<{ id: string; column: string }> = []; + const emitted: Array<{ event: string; payload: unknown }> = []; + const realEmit = emitter.emit.bind(emitter); + const store = Object.assign(emitter, { + moveTaskCalls, + emitted, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false, ...settings }), + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getTask: vi.fn().mockResolvedValue(undefined), + moveTask: vi.fn((id: string, column: string) => { + moveTaskCalls.push({ id, column }); + return Promise.resolve({ id, column } as Task); + }), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + emit: (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return realEmit(event, payload); + }, + }) as unknown as TaskStore & RecordingStore; + return store; +} + +/** + * Add a real `fusion/` worktree to a sub-repo with one own commit that EDITS the + * README the integration tip already has, then remove the worktree (we only need the + * branch ref). Returns the branch name. By default the edit is non-conflicting. + */ +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + +/** Make a sub-repo's integration tip and the task branch BOTH edit README so the + * squash conflicts. */ +function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + // Task branch edits README on a new commit. + const worktreePath = path.join(repoDir, ".wt-conflict"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "README.md"), "# branch-side change\n", "utf-8"); + execSync("git add README.md", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); + // Integration tip (main) diverges with a conflicting README edit. + writeFileSync(path.join(repoDir, "README.md"), "# main-side change\n", "utf-8"); + fx.git(repoRel, "git add README.md"); + fx.git(repoRel, 'git commit -m "main diverge README"'); +} + +/** A merge agent that performs the real squash in the clean room (no AI). */ +function squashMergeAgent(branch: string) { + return async (cwd: string): Promise => { + configureIdentity(cwd); + try { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + } catch { + // squash reported conflicts — leave them for the test's expectation. + } + // If there are unresolved conflicts, throw so landOneRepo surfaces a failure. + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) { + throw new Error("merge conflict: unresolved paths in clean room"); + } + // Nothing staged (already up to date) → leave HEAD unchanged (empty merge). + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" }); + }; +} + +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task { + return { + id: TASK_ID, + title: "Workspace merge task", + description: "", + column: "in-review", + branch: BRANCH, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; +} + +describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("happy: both clean repos advance their OWN local integration ref with NO push", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const tipBBefore = fx.git("repo-b", "git rev-parse refs/heads/main"); + + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.allLanded).toBe(true); + expect(result.repos.map((r) => r.repo).sort()).toEqual(["repo-a", "repo-b"]); + for (const r of result.repos) expect(r.status).toBe("landed"); + + // Each repo's LOCAL integration ref advanced (main moved off its prior tip). + const tipAAfter = fx.git("repo-a", "git rev-parse refs/heads/main"); + const tipBAfter = fx.git("repo-b", "git rev-parse refs/heads/main"); + expect(tipAAfter).not.toBe(tipABefore); + expect(tipBAfter).not.toBe(tipBBefore); + + // No remote ref / no push: the fixture repos have no remotes at all. + for (const repo of ["repo-a", "repo-b"]) { + const remotes = fx.git(repo, "git remote").trim(); + expect(remotes).toBe(""); + const remoteRefs = execSync("git for-each-ref refs/remotes", { cwd: fx.repoPath(repo), encoding: "utf-8" }).trim(); + expect(remoteRefs).toBe(""); + } + + // U1 does NOT move the task to done. + expect(store.moveTaskCalls).toHaveLength(0); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + }); + + it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + // Give each repo a different default integration branch via a bare origin whose + // HEAD points at that branch. landWorkspaceTask strips integrationBranch/baseBranch + // overrides, so each repo resolves origin/HEAD independently. + for (const [repo, intBranch] of [["repo-a", "develop"], ["repo-b", "release"]] as const) { + const repoDir = fx.repoPath(repo); + fx.git(repo, `git branch ${intBranch}`); + const originDir = path.join(repoDir, "..", `${repo}-origin.git`); + execSync(`git init --bare ${originDir}`, { cwd: repoDir, stdio: "pipe" }); + fx.git(repo, `git remote add origin ${originDir}`); + fx.git(repo, "git push origin --all"); + execSync(`git symbolic-ref HEAD refs/heads/${intBranch}`, { cwd: originDir, stdio: "pipe" }); + fx.git(repo, "git remote set-head origin -a"); + // task branch off the integration branch with an edit + const wt = path.join(repoDir, ".wt"); + fx.git(repo, `git worktree add -b ${BRANCH} ${wt} ${intBranch}`); + configureIdentity(wt); + writeFileSync(path.join(wt, "feature.txt"), `${repo} feature\n`, "utf-8"); + execSync("git add feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add"`, { cwd: wt, stdio: "pipe" }); + fx.git(repo, `git worktree remove --force ${wt}`); + } + + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.allLanded).toBe(true); + const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r])); + expect(byRepo["repo-a"].integrationBranch).toBe("develop"); + expect(byRepo["repo-b"].integrationBranch).toBe("release"); + // Each landed onto its OWN integration branch's local ref. + expect(byRepo["repo-a"].status).toBe("landed"); + expect(byRepo["repo-b"].status).toBe("landed"); + expect(fx.git("repo-a", "git rev-parse refs/heads/develop")).toBe(byRepo["repo-a"].landedSha); + expect(fx.git("repo-b", "git rev-parse refs/heads/release")).toBe(byRepo["repo-b"].landedSha); + }); + + it("partial: repo B conflict → repo A lands, B reports failure, task NOT moved done", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + makeConflictingRepo(fx, "repo-b"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.allLanded).toBe(false); + const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r])); + expect(byRepo["repo-a"].status).toBe("landed"); + expect(byRepo["repo-b"].status).toBe("failed"); + expect(byRepo["repo-b"].error).toMatch(/conflict/i); + + // Repo A landed locally (its ref advanced). + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore); + + // The task was NOT finalized/moved done on a partial land. + expect(store.moveTaskCalls).toHaveLength(0); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + }); +}); + +describe("workspace merge defense-in-depth (non-routed doors keep throwing)", () => { + it("assertNotWorkspaceTaskMerge throws WorkspaceTaskMergeError for a workspace task (store.mergeTask/aiMergeTask door)", () => { + const task = { + id: TASK_ID, + workspaceWorktrees: { "repo-a": { worktreePath: "/x/repo-a", branch: BRANCH } }, + } as unknown as Task; + expect(() => assertNotWorkspaceTaskMerge(task)).toThrowError(/cannot merge until per-repo merge/i); + try { + assertNotWorkspaceTaskMerge(task); + } catch (err) { + expect((err as Error).name).toBe("WorkspaceTaskMergeError"); + } + }); + + it("assertNotWorkspaceTaskMerge is a no-op for a single-repo task", () => { + const task = { id: TASK_ID } as unknown as Task; + expect(() => assertNotWorkspaceTaskMerge(task)).not.toThrow(); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 40e65e9b0f..e9a55f5a18 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -190,6 +190,16 @@ export { // FNXC:MergerUnification 2026-06-21-19:05: runAiMerge is the sole merge path // (master-plan U0); exported for the CLI callers (fn task merge + UI-only merge). export { runAiMerge } from "./merger-ai.js"; +// FNXC:Workspace 2026-06-21-23:40 (Phase C U1): per-repo workspace merge loop + +// the extracted per-repo land primitive, exported for the CLI/dashboard merge doors. +export { + landWorkspaceTask, + landOneRepo, + type WorkspaceMergeResult, + type WorkspaceRepoLandResult, + type LandOneRepoResult, + type LandRepoContext, +} from "./merger-ai.js"; export { resolveMergePolicy, type ResolvedMergePolicy, diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 8e28aeed60..ab9302a1ea 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -945,6 +945,205 @@ export async function landSquash(input: { return { outcome: "advanced", localSync: "stash-ff-conflict" }; } +// --------------------------------------------------------------------------- +// Per-repo land (extracted from runAiMerge's inline clean-room closure) +// --------------------------------------------------------------------------- + +/* +FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1): +`landOneRepo` is the per-repo land mechanic extracted byte-for-byte from +`runAiMerge`'s former inline clean-room closure: pre-merge prune (rooted at THIS +repo) → mkdtemp clean room → `git worktree add --detach` → installWorktreeDependencies +→ mergeAndReview → landSquash → the concurrent-advance CAS retry loop → the +activeSessionRegistry register/unregister + cleanup-finally. It advances ONE local +integration ref (no remote push) and returns what landed. It deliberately does NOT +move the task or write task-level mergeDetails — that task-global finalization +(`finalizeMerged`/`finalizeTask`/`evaluateNoCommitsNoOpFinalize`) stays with the +caller, so the same primitive is callable per sub-repo from `landWorkspaceTask` +without finalizing the whole task per repo (KTD3). + +`runAiMerge` is the SINGLE-REPO caller: it builds the same context it always built +and calls `landOneRepo` once against the project root, then runs its existing +finalization on the result. Single-repo behavior is unchanged. +*/ + +/** Per-task context shared by every per-repo land (agents/audit/log are bound to + * the task, not the repo). The repo-varying inputs (rootDir/branch/integrationBranch) + * are explicit `landOneRepo` args. */ +export interface LandRepoContext { + taskId: string; + settings: Settings; + audit: RunAuditor; + log: (message: string) => Promise; + setStatus: (status: string | null) => Promise; + maxPasses: number; + mergeAgent: (cwd: string, prompt: string) => Promise; + reviewAgent: (cwd: string, prompt: string) => Promise; + stashResolveAgent: (cwd: string, prompt: string) => Promise; + includeTaskId: boolean; + trailers: string[]; + taskTitle?: string; + signal?: AbortSignal; + allowDirtyLocalCheckoutSync?: boolean; +} + +/** What a single repo's land produced. No task move / mergeDetails — the caller + * decides task-global finalization. */ +export type LandOneRepoResult = + | { + /** The branch had no net changes vs the integration tip — nothing landed. */ + outcome: "empty"; + tipSha: string; + integrationBranch: string; + } + | { + /** The squash landed; the local integration ref now points at `squashSha`. */ + outcome: "landed"; + squashSha: string; + localSync: LocalSyncOutcome; + tipSha: string; + integrationBranch: string; + }; + +/** + * Land `branch` onto `integrationBranch`'s LOCAL ref in `repoRootDir` via a + * repo-scoped clean room, retrying on concurrent advance. No remote push. See + * the FNXC note above for the extraction contract. + */ +export async function landOneRepo( + store: TaskStore, + repoRootDir: string, + branch: string, + integrationBranch: string, + ctx: LandRepoContext, +): Promise { + const { + taskId, settings, audit, log, setStatus, maxPasses, + mergeAgent, reviewAgent, stashResolveAgent, + includeTaskId, trailers, taskTitle, signal, + } = ctx; + + // Pre-merge prune is rooted at THIS sub-repo (KTD1): N per-repo clean rooms for + // one task share the `fusion-ai-merge--` prefix, so a prune rooted at a + // shared root could reap a sibling repo's live clean room. Rooting it at + // repoRootDir keeps each repo's prune to its own temp roots. + try { + const pruned = await pruneExistingAiMergeWorktrees(taskId, repoRootDir, audit, log, settings); + if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`); + } catch (err: unknown) { + await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`); + } + let advanceRetries = 0; + while (true) { + throwIfAborted(signal, taskId); + const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir); + + // 1. Clean-room worktree at the integration tip. + let mergeRoot: string | undefined; + let worktreeAdded = false; + const registeredMergePaths = new Set(); + const registerMergeRoot = (pathToRegister: string): void => { + if (registeredMergePaths.has(pathToRegister)) return; + activeSessionRegistry.registerPath(pathToRegister, { taskId, kind: "ai-merge", ownerKey: `ai-merge:${taskId}` }); + registeredMergePaths.add(pathToRegister); + }; + try { + mergeRoot = await mkdtemp(join(resolveAiMergeRoot(repoRootDir, settings), `fusion-ai-merge-${taskId.toLowerCase()}-`)); + /* + * FNXC:AIMerge 2026-06-14-16:36: + * The AI-merge clean-room directory must be created and registered inside the cleanup guard. Any terminal path or interrupt after `mkdtemp`, including active-session registration failure before `git worktree add`, must still unregister known paths and remove the `fusion-ai-merge-*` directory. + */ + // Register the repo-local clean-room path as soon as it exists, before + // `git worktree add`, so self-healing/pre-merge sweeps cannot reap a + // just-created clean room in the small window before canonical registration + // is available. + registerMergeRoot(mergeRoot); + await git(["worktree", "add", "--detach", mergeRoot, tipSha], repoRootDir); + worktreeAdded = true; + let canonicalMergeRoot = mergeRoot; + try { + canonicalMergeRoot = realpathSync(mergeRoot); + } catch { + canonicalMergeRoot = mergeRoot; + } + for (const pathToRegister of new Set([canonicalMergeRoot, mergeRoot])) { + registerMergeRoot(pathToRegister); + } + await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } }); + await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`); + + /* + * FNXC:AIMerge 2026-06-13-20:32: + * The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run. + */ + const depsSyncStartedAt = Date.now(); + const depsSyncResult = await installWorktreeDependencies({ + cwd: canonicalMergeRoot, + settings, + taskId, + signal, + context: "for AI merge clean room", + logger: aiMergeLog, + log, + }); + await audit.git({ + type: "merge:ai-deps-sync", + target: integrationBranch, + metadata: { + taskId, + tipSha, + mergeRoot: canonicalMergeRoot, + installCommand: depsSyncResult.installCommand, + configured: depsSyncResult.configured, + skipped: depsSyncResult.skipped, + skipReason: depsSyncResult.skipReason, + durationMs: depsSyncResult.durationMs, + }, + }); + await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`); + + // 2 + 3. Merge + review loop (corrective passes). + const squashSha = await mergeAndReview({ + mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, + maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal, + }); + + if (!squashSha) { + // Branch had no net changes vs the tip — nothing to land. The caller + // decides how to finalize the (possibly multi-repo) task. + await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } }); + return { outcome: "empty", tipSha, integrationBranch }; + } + + // 4 + 5. Land the squash on the target branch and sync the user's + // checkout (AI reconciles a conflicting restore). + await setStatus("landing"); + const landed = await landSquash({ + projectRootDir: repoRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit, + resolveConflicts: stashResolveAgent, + allowDirtyLocalCheckoutSync: ctx.allowDirtyLocalCheckoutSync === true, + }); + if (landed.outcome === "concurrent") { + if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) { + advanceRetries++; + await log(`AI merge: ${integrationBranch} moved during merge — rebuilding on new tip (retry ${advanceRetries})`); + continue; // rebuild the clean room on the new tip + } + throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId} after ${advanceRetries} retries (concurrent advances)`); + } + await log(`AI merge: advanced ${integrationBranch} → ${short(squashSha)} (local checkout: ${landed.localSync})`); + return { outcome: "landed", squashSha, localSync: landed.localSync, tipSha, integrationBranch }; + } finally { + for (const registeredPath of registeredMergePaths) { + activeSessionRegistry.unregisterPath(registeredPath); + } + if (mergeRoot) { + await cleanupAiMergeWorktree({ taskId, mergeRoot, projectRootDir: repoRootDir, worktreeAdded, audit, log }); + } + } + } +} + // --------------------------------------------------------------------------- // Orchestrator // --------------------------------------------------------------------------- @@ -1055,165 +1254,215 @@ export async function runAiMerge( const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; await setStatus("merging"); - try { - const pruned = await pruneExistingAiMergeWorktrees(taskId, projectRootDir, audit, log, settings); - if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`); - } catch (err: unknown) { - await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`); - } - let advanceRetries = 0; - while (true) { - throwIfAborted(options.signal, taskId); - const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], projectRootDir); + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1): + // runAiMerge is now the SINGLE-REPO caller of the extracted `landOneRepo`. It + // builds the same per-task context it always built and lands the project root + // once; the task-global finalization below (empty no-op / no-commits demote / + // finalizeMerged) is unchanged byte-for-byte — only the inline clean-room land + // loop moved into `landOneRepo` so `landWorkspaceTask` can reuse it per sub-repo. + const landResult = await landOneRepo(store, projectRootDir, branch, integrationBranch, { + taskId, settings, audit, log, setStatus, maxPasses, + mergeAgent, reviewAgent, stashResolveAgent, + includeTaskId, trailers, taskTitle, signal: options.signal, + allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, + }); - // 1. Clean-room worktree at the integration tip. - let mergeRoot: string | undefined; - let worktreeAdded = false; - const registeredMergePaths = new Set(); - const registerMergeRoot = (pathToRegister: string): void => { - if (registeredMergePaths.has(pathToRegister)) return; - activeSessionRegistry.registerPath(pathToRegister, { taskId, kind: "ai-merge", ownerKey: `ai-merge:${taskId}` }); - registeredMergePaths.add(pathToRegister); - }; - try { - mergeRoot = await mkdtemp(join(resolveAiMergeRoot(projectRootDir, settings), `fusion-ai-merge-${taskId.toLowerCase()}-`)); + if (landResult.outcome === "empty") { + const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task); + if (noCommitsFinalize.blocked) { + const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes"; /* - * FNXC:AIMerge 2026-06-14-16:36: - * The AI-merge clean-room directory must be created and registered inside the cleanup guard. Any terminal path or interrupt after `mkdtemp`, including active-session registration failure before `git worktree add`, must still unregister known paths and remove the `fusion-ai-merge-*` directory. + * FNXC:Lifecycle 2026-06-14-20:02: + * FN-6461/FN-6455 requires the AI empty-merge lane to demote no-commits tasks whose skipped/incomplete steps outweigh done steps instead of finalizing the operational work as done. */ - // Register the repo-local clean-room path as soon as it exists, before - // `git worktree add`, so self-healing/pre-merge sweeps cannot reap a - // just-created clean room in the small window before canonical registration - // is available. - registerMergeRoot(mergeRoot); - await git(["worktree", "add", "--detach", mergeRoot, tipSha], projectRootDir); - worktreeAdded = true; - let canonicalMergeRoot = mergeRoot; - try { - canonicalMergeRoot = realpathSync(mergeRoot); - } catch { - canonicalMergeRoot = mergeRoot; - } - for (const pathToRegister of new Set([canonicalMergeRoot, mergeRoot])) { - registerMergeRoot(pathToRegister); - } - await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } }); - await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`); - - /* - * FNXC:AIMerge 2026-06-13-20:32: - * The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run. - */ - const depsSyncStartedAt = Date.now(); - const depsSyncResult = await installWorktreeDependencies({ - cwd: canonicalMergeRoot, - settings, + await store.updateTask(taskId, { error: reason }); + await store.logEntry( taskId, - signal: options.signal, - context: "for AI merge clean room", - logger: aiMergeLog, - log, - }); - await audit.git({ - type: "merge:ai-deps-sync", - target: integrationBranch, + `Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`, + JSON.stringify({ + doneCount: noCommitsFinalize.doneCount, + incompleteCount: noCommitsFinalize.incompleteCount, + branch, + integrationBranch, + lane: "ai-empty-merge", + }, null, 2), + ); + await audit.database({ + type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters[0]["type"], + target: taskId, metadata: { - taskId, - tipSha, - mergeRoot: canonicalMergeRoot, - installCommand: depsSyncResult.installCommand, - configured: depsSyncResult.configured, - skipped: depsSyncResult.skipped, - skipReason: depsSyncResult.skipReason, - durationMs: depsSyncResult.durationMs, + reason, + doneCount: noCommitsFinalize.doneCount, + incompleteCount: noCommitsFinalize.incompleteCount, + branch, + integrationBranch, + lane: "ai-empty-merge", }, }); - await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`); + await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters[2]); + return { + task, + branch, + merged: false, + noOp: false, + ok: true, + reason, + error: reason, + worktreeRemoved: false, + branchDeleted: false, + }; + } + await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`); + return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.tipSha, audit, log, { empty: true }); + } - // 2 + 3. Merge + review loop (corrective passes). - const squashSha = await mergeAndReview({ - mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, - maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal: options.signal, - }); + return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false }); +} - if (!squashSha) { - // Branch had no net changes vs the tip — nothing to land. - await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } }); - const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task); - if (noCommitsFinalize.blocked) { - const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes"; - /* - * FNXC:Lifecycle 2026-06-14-20:02: - * FN-6461/FN-6455 requires the AI empty-merge lane to demote no-commits tasks whose skipped/incomplete steps outweigh done steps instead of finalizing the operational work as done. - */ - await store.updateTask(taskId, { error: reason }); - await store.logEntry( - taskId, - `Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`, - JSON.stringify({ - doneCount: noCommitsFinalize.doneCount, - incompleteCount: noCommitsFinalize.incompleteCount, - branch, - integrationBranch, - lane: "ai-empty-merge", - }, null, 2), - ); - await audit.database({ - type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters[0]["type"], - target: taskId, - metadata: { - reason, - doneCount: noCommitsFinalize.doneCount, - incompleteCount: noCommitsFinalize.incompleteCount, - branch, - integrationBranch, - lane: "ai-empty-merge", - }, - }); - await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters[2]); - return { - task, - branch, - merged: false, - noOp: false, - ok: true, - reason, - error: reason, - worktreeRemoved: false, - branchDeleted: false, - }; - } - await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`); - return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, tipSha, audit, log, { empty: true }); - } +// --------------------------------------------------------------------------- +// Workspace-mode per-repo merge loop (Phase C U1) +// --------------------------------------------------------------------------- - // 4 + 5. Land the squash on the target branch and sync the user's - // checkout (AI reconciles a conflicting restore). - await setStatus("landing"); - const landed = await landSquash({ - projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit, - resolveConflicts: stashResolveAgent, +/** Per-repo land outcome inside a workspace task, tagged with its sub-repo. */ +export interface WorkspaceRepoLandResult { + /** The sub-repo's relative path (the `workspaceWorktrees` key). */ + repo: string; + /** Absolute path to the sub-repo's main checkout (where the ref advanced). */ + repoRootDir: string; + /** The per-repo integration branch this repo landed onto (origin/HEAD-derived). */ + integrationBranch: string; + /** The `fusion/` branch that was landed. */ + branch: string; + /** What happened: landed, empty (no net changes), or failed. */ + status: "landed" | "empty" | "failed"; + /** The squash sha when `status === "landed"`. */ + landedSha?: string; + /** How the sub-repo checkout was reconciled when landed. */ + localSync?: LocalSyncOutcome; + /** Failure message when `status === "failed"`. */ + error?: string; +} + +/** Aggregated result of a workspace task's per-repo merge loop. */ +export interface WorkspaceMergeResult { + taskId: string; + repos: WorkspaceRepoLandResult[]; + /** True iff every acquired sub-repo landed (or was empty) with no failure. */ + allLanded: boolean; +} + +/* +FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1/KTD2): +`landWorkspaceTask` replaces U0's R7 fail-fast throw with the real per-repo merge +loop. For each acquired sub-repo (iterated by SORTED relative-path key for +determinism) it lands that repo's `fusion/` branch onto THAT repo's own LOCAL +integration ref via the extracted `landOneRepo` — no remote push, land-as-you-go +(settled D2/D5). + +Per-repo integration branch (KTD1): `workspaceWorktrees[repo]` does NOT store the +integration branch (acquisition computes then discards it), so we re-resolve it per +repo with the SAME override-stripping acquisition used — integrationBranch/baseBranch +undefined — so each sub-repo falls through to its own origin/HEAD rather than a shared +workspace branch. + +U1 scope: on a repo failure we stop the loop and return a PARTIAL result (repo A may +have landed; B reports the failure). The landed-state predicate + idempotent retry and +the finalize-task-ONCE move-to-done are U2 — `landWorkspaceTask` here deliberately does +NOT call finalizeMerged/finalizeTask or move the task. Routing the engine + CLI doors +to this loop is KTD2. +*/ +export async function landWorkspaceTask( + store: TaskStore, + task: Task, + workspaceRootDir: string, + options: MergerOptions = {}, + deps: AgentDeps = {}, +): Promise { + const taskId = task.id; + const settings = await store.getSettings(); + const audit = createRunAuditor(store, { + runId: generateSyntheticRunId("ai-merge", taskId), + agentId: "merger", + taskId, + phase: "merge", + }); + const log = async (message: string): Promise => { + await store.logEntry(taskId, message, "AiMerge").catch(() => undefined); + await store.appendAgentLog(taskId, message, "text", undefined, "merger").catch(() => undefined); + }; + const setStatus = (status: string | null): Promise => + store.updateTask(taskId, { status }).catch(() => undefined); + + const maxPasses = Math.max(0, Math.trunc(settings.merger?.maxReviewPasses ?? 3)); + const mergeAgent = deps.mergeAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildMergeSystemPrompt(settings.agentPrompts)); + const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit); + const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt()); + const includeTaskId = settings.includeTaskIdInCommit !== false; + const trailers = taskTrailers(taskId, task.lineageId); + const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; + + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + // SORTED keys for deterministic land order (KTD1). + const repoKeys = Object.keys(workspaceWorktrees).sort(); + const repos: WorkspaceRepoLandResult[] = []; + let allLanded = true; + + await setStatus("merging"); + for (const repoRel of repoKeys) { + throwIfAborted(options.signal, taskId); + const entry = workspaceWorktrees[repoRel]; + const repoRootDir = join(workspaceRootDir, repoRel); + + // Re-resolve THIS sub-repo's integration branch with the shared overrides + // stripped (KTD1) so each sub-repo lands on its OWN origin/HEAD, not a shared + // workspace branch. + let integrationBranch: string; + try { + integrationBranch = await resolveIntegrationBranch( + repoRootDir, + { ...settings, integrationBranch: undefined, baseBranch: undefined }, + ); + } catch (err: unknown) { + const message = getErrorMessage(err); + await log(`AI merge (workspace): failed to resolve integration branch for sub-repo ${repoRel}: ${message}`); + repos.push({ repo: repoRel, repoRootDir, integrationBranch: "", branch: entry.branch, status: "failed", error: message }); + allLanded = false; + break; + } + + try { + const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, { + taskId, settings, audit, log, setStatus, maxPasses, + mergeAgent, reviewAgent, stashResolveAgent, + includeTaskId, trailers, taskTitle, signal: options.signal, allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, }); - if (landed.outcome === "concurrent") { - if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) { - advanceRetries++; - await log(`AI merge: ${integrationBranch} moved during merge — rebuilding on new tip (retry ${advanceRetries})`); - continue; // rebuild the clean room on the new tip - } - throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId} after ${advanceRetries} retries (concurrent advances)`); - } - await log(`AI merge: advanced ${integrationBranch} → ${short(squashSha)} (local checkout: ${landed.localSync})`); - return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, squashSha, audit, log, { empty: false }); - } finally { - for (const registeredPath of registeredMergePaths) { - activeSessionRegistry.unregisterPath(registeredPath); - } - if (mergeRoot) { - await cleanupAiMergeWorktree({ taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log }); + if (landResult.outcome === "landed") { + repos.push({ + repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, + status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync, + }); + } else { + repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "empty" }); } + } catch (err: unknown) { + const message = getErrorMessage(err); + await log(`AI merge (workspace): sub-repo ${repoRel} land failed: ${message}`); + await audit.git({ type: "merge:ai-no-branch", target: entry.branch, metadata: { taskId, kind: "workspace-repo-land-failed", repo: repoRel, error: message } }).catch(() => undefined); + repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "failed", error: message }); + allLanded = false; + // U1: stop on first failure and return a partial result. U2 adds the landed + // predicate + idempotent retry so a re-run skips the already-landed repos. + break; } } + + await setStatus(null); + // TODO(Phase C U2): when `allLanded` and every acquired repo landed, finalize the + // task ONCE (finalizeTask / move-done) — NEVER per repo. Until U2's landed + // predicate + idempotent retry land, this loop leaves the task in place; the + // engine dispatch (KTD2) does not move it on a partial result. + return { taskId, repos, allLanded }; } async function mergeAndReview(input: { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 575464cc00..35f87f4308 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -13,7 +13,7 @@ import type { ResearchSynthesisRequest, ResearchSynthesisResult, } from "@fusion/core"; -import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; +import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -31,7 +31,7 @@ import { createFusionAuthStorage, getFusionOAuthAlertStatePath } from "./auth-st import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { sweepStaleAutostashes, VerificationError } from "./merger.js"; -import { runAiMerge } from "./merger-ai.js"; +import { runAiMerge, landWorkspaceTask } from "./merger-ai.js"; import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn, type SyncGroupPrFn } from "./group-merge-coordinator.js"; import { PRIORITY_MERGE } from "./concurrency.js"; import { runtimeLog } from "./logger.js"; @@ -2287,17 +2287,44 @@ export class ProjectEngine { this.activeMergeSession = session; }, }; - // FNXC:Workspace 2026-06-21-19:40: - // R7 merge-boundary guard (master-plan U0). Reject workspace-mode - // tasks BEFORE any git work — they need the per-repo merge loop that - // lands in master-plan U6 (which removes this guard). Load the task - // here so the dispatch shares the one predicate in @fusion/core. - // This door is a FAST-FAIL only: a getTask failure is swallowed to null - // and the guard is skipped, but the unconditional chokepoint guard inside - // runAiMerge (which re-reads the task) is the authoritative enforcement, - // so a transient read failure here cannot let a workspace task reach git work. + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2): + // Engine merge dispatch door. A workspace-mode task (non-empty + // `workspaceWorktrees`) routes to the per-repo merge loop + // `landWorkspaceTask` (Phase C U1) instead of the singular runAiMerge — + // each sub-repo lands on its own LOCAL integration ref, no push. The + // U0 R7 throw is REPLACED by this routing (the runAiMerge chokepoint + // + store.mergeTask/aiMergeTask keep throwing as defense-in-depth). + // FAST-FAIL note preserved: a getTask failure is swallowed to null and + // routing falls through to runAiMerge, whose chokepoint guard re-reads + // the task and is the authoritative workspace enforcement. const mergeTask = await store.getTask(taskId).catch(() => null); - if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask); + const isWorkspaceMerge = + !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; + if (isWorkspaceMerge) { + // U1: land each acquired sub-repo on its own local integration ref. + // Task move-to-done (finalize once after all land) + idempotent retry + // are U2 — for now the loop returns a partial/aggregate result and the + // task is left in place. + const settings = await store.getSettings().catch(() => ({}) as Settings); + const workspaceResult = await landWorkspaceTask( + store, + mergeTask!, + cwd, + { ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true }, + ); + const latest = await store.getTask(taskId).catch(() => mergeTask!); + return { + task: latest ?? mergeTask!, + branch: mergeTask!.branch ?? "", + // U1 does not finalize the task; report merged=false until U2 wires + // the finalize-once move-to-done after every repo lands. + merged: false, + noOp: !workspaceResult.repos.some((r) => r.status === "landed"), + ok: workspaceResult.allLanded, + worktreeRemoved: false, + branchDeleted: false, + } as MergeResult; + } // FNXC:MergerUnification 2026-06-21-19:05: // Master-plan U0 collapsed the merge dispatch: `runAiMerge` (the From 79e53e8d973c164491e25873fb8494825a8f1ed1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:37:12 -0700 Subject: [PATCH 029/265] fix(workspace): remove unused acquireWorkspaceRepoWorktree import The foundation imports acquireWorkspaceRepoWorktree in executor.ts but deliberately stops before wiring it into the executor lifecycle, so the import is unused and fails @typescript-eslint/no-unused-vars (the sole Lint failure on this PR). Remove the dead import; it is reintroduced with real usage in the session-scoping follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/engine/src/executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index d849580e08..fb57716b9d 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -142,7 +142,7 @@ import type { PluginRunner } from "./plugin-runner.js"; import { isContextLimitError } from "./context-limit-detector.js"; import { StepSessionExecutor } from "./step-session-executor.js"; import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js"; -import { acquireTaskWorktree, acquireWorkspaceRepoWorktree } from "./worktree-acquisition.js"; +import { acquireTaskWorktree } from "./worktree-acquisition.js"; import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; import { From 7544346320161808ee83c870485b86ed9d485ef3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:46:44 -0700 Subject: [PATCH 030/265] =?UTF-8?q?feat(workspace):=20Phase=20C=20U2=20?= =?UTF-8?q?=E2=80=94=20per-repo=20landed=20predicate,=20finalize-once,=20a?= =?UTF-8?q?uto-retry-then-park?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit landWorkspaceTask now tracks per-repo landing and finalizes the task exactly once. After a repo lands, its advanced integration tip is persisted as workspaceWorktrees[repo].landedSha (fresh-read merge, siblings untouched). Before landing, isRepoLanded skips a repo iff its landedSha is present AND an ancestor of (or equal to) its local integration ref — so a retry after a partial land never re-advances an already-landed ref. finalizeWorkspaceTask runs only when every acquired repo is landed: it builds an aggregate MergeResult (representative commitSha + a workspaceLandedShas map in MergeDetails) and calls the existing task-global finalizeTask once, satisfying the task:merged consumer. No premature done on the first repo. Partial lands surface as WorkspacePartialLandError; the engine consumes a mergeRetry and re-enqueues landWorkspaceTask (skipping landed repos) with the existing conflict-retry backoff up to MAX, then operator-parks (status:failed) — mirroring shouldRetryAutoMergeConflict (new exported shouldRetryWorkspacePartialLand seam). The defense-in-depth WorkspaceTaskMergeError still hard-fails without burning retries; manual merges fall through to rejectMergeResolvers. types: workspaceWorktrees entry gains landedSha?; MergeDetails gains workspaceLandedShas?. 6 new idempotency/predicate/finalize-once/retry-park tests; oracle (52) + U1 (5) stay green. Gate: build, typecheck, lint, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ase-c-u2-landed-predicate-finalize-once.md | 15 + packages/core/src/types.ts | 22 +- .../workspace-merger-idempotency.test.ts | 353 ++++++++++++++++++ .../src/__tests__/workspace-merger.test.ts | 13 +- packages/engine/src/merger-ai.ts | 168 ++++++++- packages/engine/src/project-engine.ts | 105 +++++- 6 files changed, 650 insertions(+), 26 deletions(-) create mode 100644 .changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md create mode 100644 packages/engine/src/__tests__/workspace-merger-idempotency.test.ts diff --git a/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md new file mode 100644 index 0000000000..1f7f837c80 --- /dev/null +++ b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md @@ -0,0 +1,15 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase C (U2): per-repo landed predicate, finalize-once, and idempotent +auto-retry-then-park. `landWorkspaceTask` now records each sub-repo's `landedSha` after +its branch advances that repo's local integration ref, and on a re-run SKIPS any repo +whose recorded `landedSha` is an ancestor of (or equals) its current integration tip — so +an interrupted multi-repo land retries only the un-landed repos and never re-advances an +already-landed ref. When every acquired repo's landed predicate holds, the task moves to +`done` EXACTLY ONCE via the task-global finalize path with an aggregate `mergeDetails` +(representative `commitSha` + a `workspaceLandedShas` map). A partial land (some repos +unlanded) does not move the task done; the engine merge dispatch surfaces it as a +retryable failure that consumes a `mergeRetry` and auto-retries the merge (skipping landed +repos) up to the configured max, then operator-parks the task as failed. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6e40bad2d0..98c69d02ad 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1845,6 +1845,17 @@ export interface MergeDetails { * `task.mergeRetries`, which counts in-cycle aiMergeTask retries. */ transientRecoveryCount?: number; + /** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Workspace-mode aggregate landed map: sub-repo relative path → the squash sha + * that landed on that repo's local integration ref. Set ONLY by + * `landWorkspaceTask`'s finalize-once after EVERY acquired repo's landed + * predicate holds; the task-level `commitSha` points at one representative + * landed sha (the first sorted landed repo) so the existing `task:merged` + * consumer (which reads `mergeDetails.commitSha`) is satisfied. Empty/absent + * for single-repo tasks. + */ + workspaceLandedShas?: Record; } /** Represents an agent's checkout lease on a task. */ @@ -2252,8 +2263,17 @@ export interface Task { * against that sub-repo's RESOLVED integration branch, local-first. It is the * per-repo analogue of the single-repo base-commit capture and prevents * cross-repo files-changed inflation when local integration is ahead of origin. + * + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * `landedSha` is the per-repo "this repo's branch has landed on its local + * integration ref" marker, set by `landWorkspaceTask` after a sub-repo's squash + * advances that repo's ref. It is the ONLY partial-land state added (no new + * status type): a re-run's landed predicate skips a repo whose `landedSha` is + * present AND whose recorded value is an ancestor of (or equals) the repo's + * integration tip, so an interrupted multi-repo land retries only the un-landed + * repos and never re-advances an already-landed ref (idempotent retry). */ - workspaceWorktrees?: Record; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts new file mode 100644 index 0000000000..af9ed2e1af --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -0,0 +1,353 @@ +/* +FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): +Per-repo landed-predicate + finalize-once + idempotent-retry tests. They drive the REAL +`landWorkspaceTask` against a REAL two-repo git fixture (createWorkspaceFixture) under a +NON-git workspace root, asserting LOCAL integration-ref shas directly (FN-5048: real git +only where the invariant requires it; the AI merge/review agents are injected so NO real +AI calls happen and the squash is a plain `git merge --squash`). The retry/park decision +is tested via the engine's narrow exported seam `shouldRetryWorkspacePartialLand` with +fake timers — NOT by spinning real engine retries. + +Coverage (FN-5893 surfaces): +- idempotency: re-run after repo A landed + repo B failed → A is SKIPPED (its integration + ref does NOT advance a second time — assert the ref sha is unchanged), B is retried. +- predicate: landed predicate true when branch tip is an ancestor of integration tip; + false otherwise (ref rebuilt / no landedSha). +- no premature done: finalizeTask/move-done runs EXACTLY ONCE, only after BOTH repos land + — assert the task is NOT moved done after the first repo (partial run). +- completion: all repos landed → task reaches done with aggregate mergeDetails + (workspaceLandedShas map + representative commitSha). +- retry/park: a partial-land failure consumes one mergeRetry; after MAX it parks + (shouldRetryWorkspacePartialLand boundary, fake timers). +*/ +import { afterEach, beforeEach, afterAll, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; +import { landWorkspaceTask } from "../merger-ai.js"; +import { shouldRetryWorkspacePartialLand } from "../project-engine.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-2002"; +const BRANCH = "fusion/fn-2002"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RecordingStore extends EventEmitter { + task: Task; + moveTaskCalls: Array<{ id: string; column: string }>; + emitted: Array<{ event: string; payload: unknown }>; +} + +/** + * A store that PERSISTS workspaceWorktrees + mergeDetails updates on a single in-memory + * task and returns it from getTask, so the landed-predicate retry reads back the + * `landedSha` that landWorkspaceTask wrote (real fresh-read-then-merge behavior). + */ +function createStore(task: Task, settings: Record = {}): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const moveTaskCalls: Array<{ id: string; column: string }> = []; + const emitted: Array<{ event: string; payload: unknown }> = []; + const realEmit = emitter.emit.bind(emitter); + const store = Object.assign(emitter, { + task, + moveTaskCalls, + emitted, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false, ...settings }), + updateTask: vi.fn(async (_id: string, patch: Partial) => { + Object.assign(store.task, patch); + return undefined; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getTask: vi.fn(async () => store.task), + moveTask: vi.fn((id: string, column: string) => { + moveTaskCalls.push({ id, column }); + store.task.column = column as Task["column"]; + return Promise.resolve(store.task); + }), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + emit: (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return realEmit(event, payload); + }, + }) as unknown as TaskStore & RecordingStore; + return store; +} + +/** Add a real `fusion/` branch to a sub-repo with one own non-conflicting commit. */ +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + +/** Make a sub-repo's integration tip + task branch BOTH edit README → squash conflicts. */ +function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, ".wt-conflict"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "README.md"), "# branch-side change\n", "utf-8"); + execSync("git add README.md", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); + writeFileSync(path.join(repoDir, "README.md"), "# main-side change\n", "utf-8"); + fx.git(repoRel, "git add README.md"); + fx.git(repoRel, 'git commit -m "main diverge README"'); +} + +/** Resolve repo-b's conflict by replacing the conflicting README content (no markers). */ +function resolveConflictInRepo(fx: WorkspaceFixture, repoRel: string): void { + // Re-point the task branch so the squash no longer conflicts: drop the branch's + // README edit and add a clean feature file instead. + const repoDir = fx.repoPath(repoRel); + fx.git(repoRel, `git branch -D ${BRANCH}`); + const worktreePath = path.join(repoDir, ".wt-resolved"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), "resolved feature\n", "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): resolved"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + +/** A merge agent that performs the real squash in the clean room (no AI). */ +function squashMergeAgent(branch: string) { + return async (cwd: string): Promise => { + configureIdentity(cwd); + try { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + } catch { + // squash reported conflicts — fall through to the unmerged check. + } + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) { + throw new Error("merge conflict: unresolved paths in clean room"); + } + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" }); + }; +} + +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task { + return { + id: TASK_ID, + title: "Workspace merge task", + description: "", + column: "in-review", + branch: BRANCH, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; +} + +describeIfGit("landWorkspaceTask — landed predicate + finalize-once + idempotent retry (Phase C U2)", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("idempotency: re-run after A landed + B failed skips A (ref unchanged) and retries B", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + makeConflictingRepo(fx, "repo-b"); + + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore(task); + + // First run: A lands, B conflicts → partial. + const first = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(first.allLanded).toBe(false); + expect(first.finalized).toBe(false); + const tipAAfterFirst = fx.git("repo-a", "git rev-parse refs/heads/main"); + // A's landedSha was persisted onto the task entry. + expect(store.task.workspaceWorktrees!["repo-a"].landedSha).toBe(tipAAfterFirst); + // Not moved done on a partial land. + expect(store.moveTaskCalls).toHaveLength(0); + + // Operator resolves repo B's conflict, then the merge is re-run (auto-retry). + resolveConflictInRepo(fx, "repo-b"); + + const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + // A was SKIPPED (already landed): its integration ref did NOT advance a second time. + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAAfterFirst); + const repoA = second.repos.find((r) => r.repo === "repo-a")!; + expect(repoA.alreadyLanded).toBe(true); + expect(repoA.status).toBe("landed"); + // B was retried and landed this time. + const repoB = second.repos.find((r) => r.repo === "repo-b")!; + expect(repoB.status).toBe("landed"); + expect(repoB.alreadyLanded).toBeFalsy(); + expect(second.allLanded).toBe(true); + // Finalize-once ran on the completing run. + expect(second.finalized).toBe(true); + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + }); + + it("predicate: landedSha that is an ancestor of the integration tip reads as landed; a non-ancestor does not", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + const store = createStore(task); + + // Land repo-a once. + const first = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(first.allLanded).toBe(true); + const landedSha = store.task.workspaceWorktrees!["repo-a"].landedSha!; + const tip = fx.git("repo-a", "git rev-parse refs/heads/main"); + // landedSha == tip → ancestor-or-equal → landed. Advance main with an UNRELATED + // commit; the landedSha is still an ancestor, so it must STILL read as landed. + writeFileSync(path.join(fx.repoPath("repo-a"), "unrelated.txt"), "x\n", "utf-8"); + fx.git("repo-a", "git add unrelated.txt"); + fx.git("repo-a", 'git commit -m "unrelated advance"'); + expect(fx.git("repo-a", "git merge-base --is-ancestor " + landedSha + " refs/heads/main && echo yes").trim()).toBe("yes"); + + // Re-run: predicate true (ancestor) → repo skipped, no re-land. + const tipBeforeRerun = fx.git("repo-a", "git rev-parse refs/heads/main"); + const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(second.repos[0].alreadyLanded).toBe(true); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipBeforeRerun); + + // Non-ancestor: reset main to before the landedSha → landedSha no longer reachable → + // predicate false → the repo re-lands. + void tip; + fx.git("repo-a", "git reset --hard HEAD~2"); // before the squash + unrelated commit + const tipReset = fx.git("repo-a", "git rev-parse refs/heads/main"); + expect(fx.git("repo-a", `git merge-base --is-ancestor ${landedSha} refs/heads/main || echo no`).trim()).toBe("no"); + const third = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(third.repos[0].alreadyLanded).toBeFalsy(); + expect(third.repos[0].status).toBe("landed"); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipReset); + }); + + it("no premature done: a partial run (one repo failed) does NOT move the task done", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + makeConflictingRepo(fx, "repo-b"); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore(task); + + const result = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + // repo-a landed first, but the task must NOT be done because repo-b failed. + expect(result.repos.find((r) => r.repo === "repo-a")!.status).toBe("landed"); + expect(result.finalized).toBe(false); + expect(store.moveTaskCalls).toHaveLength(0); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + }); + + it("completion: all repos landed → task moves done ONCE with aggregate mergeDetails", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore(task); + + const result = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.allLanded).toBe(true); + expect(result.finalized).toBe(true); + // Moved done exactly once and emitted task:merged exactly once. + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + const mergedEvents = store.emitted.filter((e) => e.event === "task:merged"); + expect(mergedEvents).toHaveLength(1); + + // Aggregate mergeDetails: a representative commitSha + the per-repo landed map. + const md = store.task.mergeDetails!; + expect(md.mergeConfirmed).toBe(true); + const landedShaA = fx.git("repo-a", "git rev-parse refs/heads/main"); + const landedShaB = fx.git("repo-b", "git rev-parse refs/heads/main"); + expect(md.workspaceLandedShas).toEqual({ "repo-a": landedShaA, "repo-b": landedShaB }); + // commitSha is one of the landed repo shas (representative for the task:merged consumer). + expect([landedShaA, landedShaB]).toContain(md.commitSha); + }); +}); + +describe("workspace partial-land retry/park decision (engine seam, fake timers)", () => { + beforeEach(() => vi.useFakeTimers()); + afterAll(() => vi.useRealTimers()); + + it("consumes a mergeRetry up to MAX, then parks (shouldRetryWorkspacePartialLand)", () => { + // Default MAX = 3. currentRetries + 1 < MAX gates retry. + expect(shouldRetryWorkspacePartialLand(0, {})).toMatchObject({ + shouldRetry: true, + maxAutoMergeRetries: 3, + nextRetryCount: 1, + }); + expect(shouldRetryWorkspacePartialLand(1, {})).toMatchObject({ + shouldRetry: true, + maxAutoMergeRetries: 3, + nextRetryCount: 2, + }); + // Last attempt: currentRetries + 1 === MAX → park (no further retry). + expect(shouldRetryWorkspacePartialLand(2, {})).toMatchObject({ + shouldRetry: false, + maxAutoMergeRetries: 3, + nextRetryCount: 3, + }); + // Custom cap honored. + expect(shouldRetryWorkspacePartialLand(3, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(true); + expect(shouldRetryWorkspacePartialLand(4, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(false); + }); + + it("fake-timer backoff schedule does not spin real retries", () => { + // The dispatch schedules internalEnqueueMerge via setTimeout(5000 * 2^retries). + // Assert a scheduled callback exists and only fires when advanced — no real wait. + const fired: number[] = []; + setTimeout(() => fired.push(1), 5000); + expect(fired).toHaveLength(0); + vi.advanceTimersByTime(5000); + expect(fired).toHaveLength(1); + }); +}); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index 0e4ec55a64..fe15703435 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -10,11 +10,14 @@ injected (deps) so NO real AI calls happen and the squash is produced by a plain Coverage (FN-5893 surfaces): - happy: two acquired repos both clean → BOTH local integration refs advance against - each repo's own resolved branch; NO remote ref/push happened; result tags both. + each repo's own resolved branch; NO remote ref/push happened; result tags both. Since + Phase C U2, a fully-landed workspace task also finalizes ONCE (moves done, emits + task:merged) — asserted here; the landed-predicate/finalize-once/retry mechanics have + dedicated coverage in workspace-merger-idempotency.test.ts. - per-repo resolution: repos with DIFFERENT origin/HEAD integration branches → each lands on its own (override-stripping works, not a shared branch). - partial: a conflict in repo B → repo A lands (landedSha recorded); B reports the - failure; the task is NOT moved done (no finalizeTask call). + failure; the task is NOT moved done (no finalizeTask call) — the partial-land retry is U2. - defense-in-depth: store.mergeTask / aiMergeTask with a workspace task → still throw WorkspaceTaskMergeError. The single-repo runAiMerge regression lives in the existing merger-ai*.test.ts (the @@ -187,9 +190,9 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { expect(remoteRefs).toBe(""); } - // U1 does NOT move the task to done. - expect(store.moveTaskCalls).toHaveLength(0); - expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + // U2 finalize-once: every repo landed → the task moves to done exactly once. + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1); }); it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => { diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index ab9302a1ea..b810cd59f8 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -1341,6 +1341,13 @@ export interface WorkspaceRepoLandResult { localSync?: LocalSyncOutcome; /** Failure message when `status === "failed"`. */ error?: string; + /** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * True when this repo was SKIPPED by the landed predicate on a retry (its recorded + * `landedSha` is already an ancestor of the integration tip) — its ref was NOT + * re-advanced this run. + */ + alreadyLanded?: boolean; } /** Aggregated result of a workspace task's per-repo merge loop. */ @@ -1349,6 +1356,12 @@ export interface WorkspaceMergeResult { repos: WorkspaceRepoLandResult[]; /** True iff every acquired sub-repo landed (or was empty) with no failure. */ allLanded: boolean; + /** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * True iff the finalize-once move-to-done ran this call (only when `allLanded`). + * False on a partial land (the task stays put for the engine dispatch's auto-retry). + */ + finalized: boolean; } /* @@ -1366,10 +1379,30 @@ undefined — so each sub-repo falls through to its own origin/HEAD rather than workspace branch. U1 scope: on a repo failure we stop the loop and return a PARTIAL result (repo A may -have landed; B reports the failure). The landed-state predicate + idempotent retry and -the finalize-task-ONCE move-to-done are U2 — `landWorkspaceTask` here deliberately does -NOT call finalizeMerged/finalizeTask or move the task. Routing the engine + CLI doors -to this loop is KTD2. +have landed; B reports the failure). Routing the engine + CLI doors to this loop is KTD2. + +FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): +U2 adds per-repo landed tracking + finalize-once + idempotent retry on top of U1's loop: + + - Landed predicate + skip: before landing a repo, we skip it iff its `landedSha` is + recorded AND that sha is an ancestor of (or equals) the repo's CURRENT integration + tip. A skipped repo's ref is NEVER re-advanced, so re-running `landWorkspaceTask` + after a partial land (A landed, B failed) re-attempts ONLY B — A is idempotent. + - landedSha persistence: after a repo lands, we record `workspaceWorktrees[repo].landedSha` + = the advanced integration tip via a FRESH-read-then-merge `store.updateTask` (re-read + the latest task and merge only this repo's entry, so concurrent sibling-entry writes + are not clobbered — the Phase A/B per-repo persistence pattern). + - finalize-once: the task moves to `done` EXACTLY ONCE, only after EVERY acquired repo's + landed predicate holds (all landed/empty, none failed). We reuse the task-global + `finalizeTask` move-done path with an AGGREGATE mergeDetails (representative + `commitSha` = first sorted landed repo + a `workspaceLandedShas` map) so the existing + `task:merged` consumer is satisfied. On a partial land we do NOT move done — we return + `allLanded:false` with the landed repos' `landedSha` already persisted. + +The partial-land retry/park policy (consume a mergeRetry, auto-retry skipping landed +repos up to MAX, then operator-park) is wired at the engine dispatch (project-engine.ts), +NOT here: this function reports the partial via `allLanded:false` and the dispatch drives +the retry seam. */ export async function landWorkspaceTask( store: TaskStore, @@ -1430,6 +1463,19 @@ export async function landWorkspaceTask( break; } + // U2 landed predicate + skip (KTD3): a repo whose recorded `landedSha` is an + // ancestor of (or equals) its CURRENT integration tip is already landed — SKIP + // it so a retry never re-advances the ref. This makes a re-run after a partial + // land idempotent for the already-landed repos. + if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha)) { + await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`); + repos.push({ + repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, + status: "landed", landedSha: entry.landedSha, alreadyLanded: true, + }); + continue; + } + try { const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, @@ -1438,6 +1484,10 @@ export async function landWorkspaceTask( allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, }); if (landResult.outcome === "landed") { + // Persist this repo's landedSha BEFORE moving on (fresh-read-then-merge so + // sibling entries written by a concurrent path are not clobbered). The retry + // predicate above reads this back to skip the repo on a re-run. + await persistRepoLandedSha(store, taskId, repoRel, landResult.squashSha); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync, @@ -1451,18 +1501,114 @@ export async function landWorkspaceTask( await audit.git({ type: "merge:ai-no-branch", target: entry.branch, metadata: { taskId, kind: "workspace-repo-land-failed", repo: repoRel, error: message } }).catch(() => undefined); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "failed", error: message }); allLanded = false; - // U1: stop on first failure and return a partial result. U2 adds the landed - // predicate + idempotent retry so a re-run skips the already-landed repos. + // Stop on first failure and return a partial result. The already-landed repos' + // `landedSha` is persisted, so the engine dispatch's auto-retry re-runs this + // loop and the landed predicate above skips them (only the failed repo retries). break; } } await setStatus(null); - // TODO(Phase C U2): when `allLanded` and every acquired repo landed, finalize the - // task ONCE (finalizeTask / move-done) — NEVER per repo. Until U2's landed - // predicate + idempotent retry land, this loop leaves the task in place; the - // engine dispatch (KTD2) does not move it on a partial result. - return { taskId, repos, allLanded }; + + // U2 finalize-once (KTD3): move the task to `done` EXACTLY ONCE, only after EVERY + // acquired repo's landed predicate holds (all landed/empty, none failed). Reuse the + // task-global `finalizeTask` move-done path with an aggregate mergeDetails so the + // existing `task:merged` consumer is satisfied. On a partial land we do NOT move + // done (the landed repos' `landedSha` is already persisted for the retry). + if (allLanded) { + const finalized = await finalizeWorkspaceTask(store, taskId, task, repos); + return { taskId, repos, allLanded, finalized }; + } + return { taskId, repos, allLanded, finalized: false }; +} + +/** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Landed predicate: a sub-repo is landed iff a `landedSha` is recorded AND that sha is + * an ancestor of (or equals) the repo's CURRENT integration tip. The ancestor check + * (not just sha presence) survives a later un-related advance of the integration ref: + * the landed commit is still reachable, so the repo stays "landed". A `landedSha` that + * is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and + * the repo re-lands. + */ +async function isRepoLanded( + repoRootDir: string, + integrationBranch: string, + landedSha: string | undefined, +): Promise { + if (!landedSha) return false; + if (!(await gitOk(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir))) { + return false; + } + // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. + return await gitOk(["merge-base", "--is-ancestor", landedSha, `refs/heads/${integrationBranch}`], repoRootDir); +} + +/** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Persist one sub-repo's `landedSha` with a FRESH-read-then-merge so a concurrent + * sibling-entry write is not clobbered (Phase A/B per-repo `workspaceWorktrees` + * pattern). Re-read the latest task, merge only this repo's entry, write the whole map. + */ +async function persistRepoLandedSha( + store: TaskStore, + taskId: string, + repoRel: string, + landedSha: string, +): Promise { + const latest = await store.getTask(taskId).catch(() => undefined); + const current = latest?.workspaceWorktrees ?? {}; + const entry = current[repoRel]; + if (!entry) return; // entry vanished — nothing to merge into + const next = { ...current, [repoRel]: { ...entry, landedSha } }; + await store.updateTask(taskId, { workspaceWorktrees: next }).catch(() => undefined); +} + +/** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Finalize-once: build an aggregate `MergeResult` from the per-repo lands and run the + * task-global `finalizeTask` move-done path ONCE. The representative `commitSha` is the + * first sorted landed repo's sha (so `mergeDetails.commitSha` is populated for the + * `task:merged` consumer); the full per-repo map is carried in `mergeDetails.workspaceLandedShas`. + * Returns true iff the task was moved to done. + */ +async function finalizeWorkspaceTask( + store: TaskStore, + taskId: string, + task: Task, + repos: WorkspaceRepoLandResult[], +): Promise { + const landed = repos.filter((r) => r.status === "landed" && r.landedSha); + const workspaceLandedShas: Record = {}; + for (const r of landed) workspaceLandedShas[r.repo] = r.landedSha!; + const representative = landed.length > 0 ? landed[0].landedSha : undefined; + const anyLanded = landed.length > 0; + + // Pre-populate task.mergeDetails so finalizeTask's spread carries the workspace map. + const mergeDetails: MergeDetails = { + ...task.mergeDetails, + ...(representative ? { commitSha: representative } : {}), + ...(anyLanded ? { workspaceLandedShas } : {}), + mergeConfirmed: anyLanded, + }; + await store.updateTask(taskId, { mergeDetails }).catch(() => undefined); + task.mergeDetails = mergeDetails; + + const result: MergeResult = { + task, + branch: task.branch ?? "", + merged: anyLanded, + noOp: !anyLanded, + ok: true, + reason: anyLanded ? undefined : "no-net-changes", + commitSha: representative, + mergeConfirmed: anyLanded, + worktreeRemoved: false, + branchDeleted: false, + }; + await store.logEntry(taskId, `AI merge (workspace): all ${repos.length} sub-repo(s) landed — task → done`, "AiMerge").catch(() => undefined); + await finalizeTask(store, taskId, result); + return true; } async function mergeAndReview(input: { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 35f87f4308..0276d4ee00 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -137,6 +137,28 @@ export function shouldRetryAutoMergeConflict( }; } +/* +FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): +Pure retry/park decision for a workspace PARTIAL land (some sub-repos landed, one failed). +Mirrors `shouldRetryAutoMergeConflict` so the engine dispatch's partial-land catch branch +has a narrow, unit-testable seam: a partial land is RETRYABLE (the landed repos' `landedSha` +is persisted, so a re-run skips them and only the failed repo retries), so it CONSUMES a +mergeRetry and re-enqueues up to `resolveMaxAutoMergeRetries(settings)`, then OPERATOR-PARKS +(`shouldRetry:false`). `currentRetries + 1 < MAX` keeps the LAST attempt's failure parking +in the same tick rather than scheduling an Nth timer that a restart could strand. +*/ +export function shouldRetryWorkspacePartialLand( + currentRetries: number, + settings: { maxAutoMergeRetries?: unknown } | null | undefined, +): { shouldRetry: boolean; maxAutoMergeRetries: number; nextRetryCount: number } { + const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); + return { + shouldRetry: currentRetries + 1 < maxAutoMergeRetries, + maxAutoMergeRetries, + nextRetryCount: currentRetries + 1, + }; +} + /** * FN-5627: Defense-in-depth gate for the auto-merge "merge already confirmed" * fast-path. Verifies the task's recorded `mergeDetails.commitSha` is actually @@ -2301,10 +2323,14 @@ export class ProjectEngine { const isWorkspaceMerge = !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; if (isWorkspaceMerge) { - // U1: land each acquired sub-repo on its own local integration ref. - // Task move-to-done (finalize once after all land) + idempotent retry - // are U2 — for now the loop returns a partial/aggregate result and the - // task is left in place. + // FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + // Land each acquired sub-repo on its own local integration ref; + // `landWorkspaceTask` records each landed `landedSha`, skips + // already-landed repos on a retry (idempotent), and on full success + // finalizes the task to `done` EXACTLY ONCE. On a PARTIAL land it does + // NOT finalize — it returns `allLanded:false`, which we surface as a + // WorkspacePartialLandError so the catch-block auto-retry consumes a + // mergeRetry and re-runs (skipping landed repos) up to MAX, then parks. const settings = await store.getSettings().catch(() => ({}) as Settings); const workspaceResult = await landWorkspaceTask( store, @@ -2312,15 +2338,28 @@ export class ProjectEngine { cwd, { ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true }, ); + if (!workspaceResult.allLanded) { + const failed = workspaceResult.repos.filter((r) => r.status === "failed"); + const landedCount = workspaceResult.repos.filter((r) => r.status === "landed").length; + const detail = failed.map((r) => `${r.repo}: ${r.error ?? "land failed"}`).join("; "); + const partialErr = new Error( + `Workspace partial land for ${taskId}: ${landedCount} repo(s) landed, ${failed.length} failed — ${detail}`, + ); + partialErr.name = "WorkspacePartialLandError"; + throw partialErr; + } + // Finalized to done by landWorkspaceTask; report the merge as merged so + // the success path (retry reset + branch-group promotion) runs normally. const latest = await store.getTask(taskId).catch(() => mergeTask!); + const anyLanded = workspaceResult.repos.some((r) => r.status === "landed"); return { task: latest ?? mergeTask!, branch: mergeTask!.branch ?? "", - // U1 does not finalize the task; report merged=false until U2 wires - // the finalize-once move-to-done after every repo lands. - merged: false, - noOp: !workspaceResult.repos.some((r) => r.status === "landed"), - ok: workspaceResult.allLanded, + merged: anyLanded, + noOp: !anyLanded, + ok: true, + commitSha: workspaceResult.repos.find((r) => r.status === "landed")?.landedSha, + mergeConfirmed: anyLanded, worktreeRemoved: false, branchDeleted: false, } as MergeResult; @@ -2421,6 +2460,54 @@ export class ProjectEngine { continue; } + // FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + // Workspace PARTIAL-LAND auto-retry-then-park (user decision). Unlike the R7 + // WorkspaceTaskMergeError above (a permanent config error that must NOT burn + // retries), a partial land — repo A landed, repo B failed — is RETRYABLE: the + // landed repos' `landedSha` is persisted, so a re-run of `landWorkspaceTask` + // skips them and re-attempts only the failed repo (idempotent). So this CONSUMES + // a `mergeRetry` and re-enqueues the merge with exponential backoff up to the + // existing MAX (resolveMaxAutoMergeRetries), then OPERATOR-PARKS (status:"failed") + // — mirroring the conflict-retry seam below. Detect by err.name (robust across + // the package boundary). Manual merges fall through to rejectMergeResolvers at + // the hasManualResolver early-return below (no auto-retry for manual). + const isWorkspacePartialLand = + err instanceof Error && err.name === "WorkspacePartialLandError"; + if (isWorkspacePartialLand && !hasManualResolver) { + const wsSettings = await store.getSettings().catch(() => ({ maxAutoMergeRetries: undefined })); + const wsTask = await store.getTask(taskId).catch(() => null); + const wsRetries = wsTask?.mergeRetries ?? 0; + const decision = shouldRetryWorkspacePartialLand(wsRetries, wsSettings as { maxAutoMergeRetries?: unknown }); + await store + .logEntry(taskId, `Workspace partial land: ${errorMsg}`, "WorkspacePartialLand") + .catch(() => undefined); + if (decision.shouldRetry) { + await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }).catch(() => undefined); + const delayMs = 5000 * Math.pow(2, wsRetries); + runtimeLog.log( + `Workspace partial-land retry ${decision.nextRetryCount}/${decision.maxAutoMergeRetries} for ${taskId} in ${delayMs / 1000}s (re-runs skipping landed repos)`, + ); + setTimeout(() => { + if (!this.shuttingDown) this.internalEnqueueMerge(taskId); + }, delayMs); + } else { + await store + .updateTask(taskId, { status: "failed", mergeRetries: decision.maxAutoMergeRetries, error: errorMsg }) + .catch(() => undefined); + await store + .logEntry( + taskId, + `Workspace partial land exhausted ${decision.maxAutoMergeRetries} retries — parking as failed for operator intervention (landed repos remain landed locally): ${errorMsg}`, + "WorkspacePartialLand", + ) + .catch(() => undefined); + runtimeLog.error( + `Auto-merge: ${taskId} workspace partial land exhausted ${decision.maxAutoMergeRetries} retries — parked as failed`, + ); + } + continue; + } + runtimeLog.error(`${hasManualResolver ? "Manual" : "Auto"}-merge failed for ${taskId}: ${errorMsg}`); // Surface every merge failure on the task log so the dashboard shows From 64e87f9a1264e57788af1a5994fd94c78e3ed936 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:56:41 -0700 Subject: [PATCH 031/265] =?UTF-8?q?feat(workspace):=20Phase=20C=20U3=20?= =?UTF-8?q?=E2=80=94=20per-repo=20land=20lease=20(serialize=20same-sub-rep?= =?UTF-8?q?o=20lands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit landWorkspaceTask now holds a per-repo land lease around each landOneRepo call: a new activeSessionRegistry kind "workspace-repo-land" keyed on the sub-repo absolute path, registered synchronously before the per-repo try and released in a finally (on success and failure, only yanking our own taskId+ownerKey entry — never a foreign/different-kind entry). Two workspace tasks landing the same sub-repo serialize; the loser throws the retryable WorkspaceRepoLandBusyError, which reuses the U2 partial-land retry/park machinery (consume a mergeRetry, backoff re-enqueue up to MAX skipping landed repos, then operator-park). Disjoint sub-repos never falsely serialize. The lease is for serialization / clean-room-collision avoidance, not ref correctness — advanceIntegrationBranchRef's CAS already makes interleaved update-ref safe. Distinct from the execution-phase "workspace-repo-acquire" lease (different kind, different lifecycle phase, each ignores the other's entry). 3 new tests (serialize, independence, release-on-failure); oracle (56) + U1/U2 (idempotency) stay green. Gate: build, typecheck, lint, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-per-repo-land-lease.md | 5 + .../__tests__/workspace-merger-lease.test.ts | 272 ++++++++++++++++++ .../engine/src/active-session-registry.ts | 15 +- packages/engine/src/merger-ai.ts | 79 +++++ packages/engine/src/project-engine.ts | 12 +- 5 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 .changeset/workspace-per-repo-land-lease.md create mode 100644 packages/engine/src/__tests__/workspace-merger-lease.test.ts diff --git a/.changeset/workspace-per-repo-land-lease.md b/.changeset/workspace-per-repo-land-lease.md new file mode 100644 index 0000000000..8d9fe58642 --- /dev/null +++ b/.changeset/workspace-per-repo-land-lease.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase C U3): serialize concurrent same-sub-repo lands with a per-repo file-scope lease. When two workspace tasks try to land onto the SAME sub-repo's local integration ref at the same time, the merge phase now registers the sub-repo's absolute path in the path-keyed active-session registry under a distinct `workspace-repo-land` kind before each land and releases it in a `finally` (on land success or failure — no stuck lock). A second task contending for the same sub-repo fast-fails with a retryable `WorkspaceRepoLandBusyError`, which the existing partial-land auto-retry-then-park dispatch handles (consume a `mergeRetry`, re-enqueue with backoff, then operator-park). Disjoint sub-repos lease different paths and never serialize against each other. The lease prevents clean-room ai-merge worktree collisions; ref correctness is already guaranteed by `advanceIntegrationBranchRef`'s CAS (concurrent-advance → rebuild). diff --git a/packages/engine/src/__tests__/workspace-merger-lease.test.ts b/packages/engine/src/__tests__/workspace-merger-lease.test.ts new file mode 100644 index 0000000000..074752aca4 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger-lease.test.ts @@ -0,0 +1,272 @@ +/* +FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): +Per-repo LAND lease tests. They drive the REAL `landWorkspaceTask` against a REAL +two-repo git fixture (createWorkspaceFixture) and assert the lease seam directly on +the REAL module-level `activeSessionRegistry` singleton (FN-5048: narrow seam — we +assert registry state + a merge-agent spy, NO real concurrent processes, NO +mock-the-world; the AI merge/review agents are injected so no real AI calls happen +and the squash is a plain `git merge --squash`). + +The lease is keyed by the sub-repo ABSOLUTE path under kind "workspace-repo-land". +It is for SERIALIZATION / clean-room-collision avoidance only — `advanceIntegration +BranchRef`'s CAS already makes the interleaved `update-ref` correct — so we assert +serialization behavior (one wins, the other fast-fails) and that the lease never leaks. + +Coverage (FN-5893 surfaces): +- concurrency: two tasks landing the SAME sub-repo → one acquires the land lease, + the other FAST-FAILS with WorkspaceRepoLandBusyError; no interleaved update-ref on + that repo's ref (the loser advances nothing). Lease kind/path asserted while held. +- independence: disjoint sub-repos (task1→repo-a, task2→repo-b) → both proceed, no + false serialization (neither sees the other's lease path). +- cleanup: a repo land that THROWS → the lease for that path is released (not stuck), + so a subsequent land of the same repo can acquire it. +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; +import { landWorkspaceTask, WorkspaceRepoLandBusyError } from "../merger-ai.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const BRANCH = "fusion/fn-3003"; +const LAND_KIND = "workspace-repo-land"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RecordingStore extends EventEmitter { + task: Task; + moveTaskCalls: Array<{ id: string; column: string }>; +} + +/** A store that persists workspaceWorktrees/mergeDetails on one in-memory task. */ +function createStore(task: Task): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const moveTaskCalls: Array<{ id: string; column: string }> = []; + const store = Object.assign(emitter, { + task, + moveTaskCalls, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + updateTask: vi.fn(async (_id: string, patch: Partial) => { + Object.assign(store.task, patch); + return undefined; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getTask: vi.fn(async () => store.task), + moveTask: vi.fn((id: string, column: string) => { + moveTaskCalls.push({ id, column }); + store.task.column = column as Task["column"]; + return Promise.resolve(store.task); + }), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + }) as unknown as TaskStore & RecordingStore; + return store; +} + +/** Add a real `fusion/` branch to a sub-repo with one own non-conflicting commit. */ +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, taskId: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, `.wt-${taskId}`); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${taskId}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + +/** A merge agent that performs the real squash in the clean room (no AI). */ +function squashMergeAgent(branch: string, onEnter?: (cwd: string) => void | Promise) { + return async (cwd: string): Promise => { + if (onEnter) await onEnter(cwd); + configureIdentity(cwd); + try { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + } catch { + // squash reported conflicts — fall through to the unmerged check. + } + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) throw new Error("merge conflict: unresolved paths in clean room"); + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" }); + }; +} + +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +function makeTask(id: string, workspaceWorktrees: Task["workspaceWorktrees"]): Task { + return { + id, + title: "Workspace merge task", + description: "", + column: "in-review", + branch: BRANCH, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; +} + +describeIfGit("landWorkspaceTask — per-repo land lease (Phase C U3, KTD4)", () => { + let fx: WorkspaceFixture; + afterEach(() => { + fx?.cleanup(); + activeSessionRegistry.clear(); + vi.restoreAllMocks(); + }); + beforeEach(() => activeSessionRegistry.clear()); + + it("concurrency: two tasks landing the SAME sub-repo serialize — one acquires the land lease, the other fast-fails (no interleaved update-ref)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); + const repoAbs = fx.repoPath("repo-a"); + + const task1 = makeTask("FN-3001", { "repo-a": { worktreePath: repoAbs, branch: BRANCH } }); + const task2 = makeTask("FN-3001", { "repo-a": { worktreePath: repoAbs, branch: BRANCH } }); + // Distinct task IDs so the lease owner check (taskId !== holder) triggers. + task2.id = "FN-3002"; + const store1 = createStore(task1); + const store2 = createStore(task2); + + let loserError: unknown; + const tipBefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + // task1's merge agent blocks until task2 has tried (and failed) to acquire the + // land lease for the SAME sub-repo path. While task1 holds the lease we assert it + // is registered under the right kind + path; task2 fast-fails with the busy error. + const winner = landWorkspaceTask(store1, store1.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH, async () => { + // task1 now holds the land lease for repo-a. + const held = activeSessionRegistry.lookupByPath(repoAbs); + expect(held?.kind).toBe(LAND_KIND); + expect(held?.taskId).toBe("FN-3001"); + + // task2 attempts the same sub-repo concurrently → must fast-fail. + try { + await landWorkspaceTask(store2, store2.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + } catch (err) { + loserError = err; + } + // The loser advanced NOTHING: the ref is still at the pre-land tip. + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipBefore); + }), + reviewAgent: approveReviewAgent, + }); + + const result = await winner; + + // Winner landed. + expect(result.allLanded).toBe(true); + expect(result.repos[0].status).toBe("landed"); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipBefore); + + // Loser fast-failed with the retryable busy error (serialized, not broken). + expect(loserError).toBeInstanceOf(WorkspaceRepoLandBusyError); + expect((loserError as WorkspaceRepoLandBusyError).retryable).toBe(true); + expect((loserError as WorkspaceRepoLandBusyError).holderTaskId).toBe("FN-3001"); + + // Lease released after the winner finished — no leak. + expect(activeSessionRegistry.lookupByPath(repoAbs)).toBeNull(); + }); + + it("independence: disjoint sub-repos land without contention (no false serialization)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "FN-3002", "b feature\n"); + const repoAAbs = fx.repoPath("repo-a"); + const repoBAbs = fx.repoPath("repo-b"); + + const task1 = makeTask("FN-3001", { "repo-a": { worktreePath: repoAAbs, branch: BRANCH } }); + const task2 = makeTask("FN-3002", { "repo-b": { worktreePath: repoBAbs, branch: BRANCH } }); + const store1 = createStore(task1); + const store2 = createStore(task2); + + let task2Error: unknown; + let task2Landed = false; + + // task1 lands repo-a; mid-land it kicks off task2 landing the DISJOINT repo-b. + // task2 leases a DIFFERENT path, so it must NOT serialize against task1. + const t1 = landWorkspaceTask(store1, store1.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH, async () => { + // While task1 holds repo-a's lease, repo-b's lease is unheld. + expect(activeSessionRegistry.lookupByPath(repoAAbs)?.kind).toBe(LAND_KIND); + expect(activeSessionRegistry.lookupByPath(repoBAbs)).toBeNull(); + try { + const r2 = await landWorkspaceTask(store2, store2.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + task2Landed = r2.allLanded; + } catch (err) { + task2Error = err; + } + }), + reviewAgent: approveReviewAgent, + }); + + const r1 = await t1; + + // Both proceeded — no false serialization. + expect(task2Error).toBeUndefined(); + expect(task2Landed).toBe(true); + expect(r1.allLanded).toBe(true); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe( + fx.git("repo-a", "git rev-parse fusion/fn-3003^"), + ); + // Both leases released. + expect(activeSessionRegistry.lookupByPath(repoAAbs)).toBeNull(); + expect(activeSessionRegistry.lookupByPath(repoBAbs)).toBeNull(); + }); + + it("cleanup: a land failure releases the lease (not stuck) so a subsequent land can acquire", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); + const repoAbs = fx.repoPath("repo-a"); + + const task = makeTask("FN-3001", { "repo-a": { worktreePath: repoAbs, branch: BRANCH } }); + const store = createStore(task); + + // A merge agent that throws → landOneRepo fails → the per-repo land lease finally + // must release the lease even on failure. + const throwingAgent = async (): Promise => { + // Lease is held at this point. + expect(activeSessionRegistry.lookupByPath(repoAbs)?.kind).toBe(LAND_KIND); + throw new Error("synthetic clean-room failure"); + }; + + const failed = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: throwingAgent, + reviewAgent: approveReviewAgent, + }); + expect(failed.allLanded).toBe(false); + expect(failed.repos[0].status).toBe("failed"); + // Lease was released despite the failure — NOT stuck. + expect(activeSessionRegistry.lookupByPath(repoAbs)).toBeNull(); + + // A subsequent land of the SAME repo can acquire (real squash this time). + const retry = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(retry.allLanded).toBe(true); + expect(retry.repos[0].status).toBe("landed"); + expect(activeSessionRegistry.lookupByPath(repoAbs)).toBeNull(); + }); +}); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index 12168c0cea..75c3b226eb 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -6,8 +6,21 @@ sub-repo absolute path (NOT the worktree path) so two concurrent workspace tasks contending for the SAME sub-repo are serialized. Keeping it distinct from "executor"/"step-session" means it does not collide with the executor's later session registration on the produced worktree path. + +FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): +"workspace-repo-land" is a DISTINCT registry kind for the LAND-time (merge phase) +same-sub-repo lease. Like the acquire kind it is keyed by the sub-repo ABSOLUTE +path, but it guards a different lifecycle scope: two workspace tasks landing the +SAME sub-repo onto its local integration ref are serialized so their clean-room +ai-merge worktrees do not collide. This lease is for SERIALIZATION / clean-room- +collision avoidance only — it is NOT what makes the interleaved `update-ref` +correct. `advanceIntegrationBranchRef`'s CAS already makes a concurrent advance +safe by construction (concurrent-advance → rebuild). The acquire lease (execution +phase) and the land lease (merge phase) never overlap in time on the same path, so +keeping them distinct kinds (each released in its own `finally`) means a stale +entry of one kind can never be mistaken for a live hold of the other. */ -export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge" | "workspace-repo-acquire"; +export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge" | "workspace-repo-acquire" | "workspace-repo-land"; export interface ActiveSessionRegistration { taskId: string; diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index b810cd59f8..e2dd4c6291 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -1403,7 +1403,48 @@ The partial-land retry/park policy (consume a mergeRetry, auto-retry skipping la repos up to MAX, then operator-park) is wired at the engine dispatch (project-engine.ts), NOT here: this function reports the partial via `allLanded:false` and the dispatch drives the retry seam. + +FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): +Per-repo LAND lease. Before each `landOneRepo` we register the sub-repo ABSOLUTE +path in the path-keyed activeSessionRegistry under kind "workspace-repo-land" and +release it in a per-repo `finally` (so the lease is freed on land success OR land +failure — no stuck lock). If another task already holds the land lease for that +sub-repo path we FAST-FAIL the whole `landWorkspaceTask` with a retryable +`WorkspaceRepoLandBusyError`, which the U2 partial-land retry/park machinery +(project-engine dispatch) already handles — reusing that path instead of +reimplementing a waiting lock. The lease serializes same-sub-repo lands so two +tasks' clean-room ai-merge worktrees do not collide; it is NOT what makes the +interleaved `update-ref` correct — `advanceIntegrationBranchRef`'s CAS already +guarantees ref correctness (concurrent-advance → rebuild). Disjoint sub-repos lease +DIFFERENT paths, so they never serialize against each other (no false contention). +This lease is a DIFFERENT scope/kind from the execution-phase +"workspace-repo-acquire" lease and from `landOneRepo`'s own inner "ai-merge" +clean-room registration on the temp worktree path — none of the three collide. */ + +/** FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): ownerKey for the land-time lease. */ +const WORKSPACE_REPO_LAND_OWNER_KEY = "workspace-repo-land"; + +/* +FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): +Thrown when a second workspace task tries to land a sub-repo already inside another +task's land critical section. Distinct from a generic land failure so the engine +dispatch (and tests) can tell "serialized, retry later" apart from "this land is +broken". Carries `retryable = true` so the existing partial-land auto-retry/park +path treats it as a transient contention, not a terminal failure. +*/ +export class WorkspaceRepoLandBusyError extends Error { + public readonly retryable = true; + constructor( + public readonly repoRel: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super(`workspace sub-repo ${repoRel} land is in progress for task ${holderTaskId}`); + this.name = "WorkspaceRepoLandBusyError"; + } +} + export async function landWorkspaceTask( store: TaskStore, task: Task, @@ -1476,6 +1517,32 @@ export async function landWorkspaceTask( continue; } + /* + FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): + Same-sub-repo LAND lease. Register the sub-repo absolute path BEFORE landing so + two tasks landing the SAME sub-repo are serialized (their clean-room ai-merge + worktrees would otherwise collide). The lookupByPath → registerPath pair stays in + ONE synchronous slice (no `await` between them) so the claim is atomic — an + interleaved await would let a second task pass the gate before we register. If + another task holds the land lease we FAST-FAIL with a retryable busy error; the + U2 dispatch auto-retry/park path handles it (no waiting lock reimplemented here). + We only treat a HELD entry of OUR OWN land ownerKey as contention, so a stale + entry of a different kind on this path (e.g. a leftover acquire entry) is ignored. + */ + const landLeaseHolder = activeSessionRegistry.lookupByPath(repoRootDir); + if ( + landLeaseHolder && + landLeaseHolder.ownerKey === WORKSPACE_REPO_LAND_OWNER_KEY && + landLeaseHolder.taskId !== taskId + ) { + throw new WorkspaceRepoLandBusyError(repoRel, landLeaseHolder.taskId, taskId); + } + activeSessionRegistry.registerPath(repoRootDir, { + taskId, + kind: "workspace-repo-land", + ownerKey: WORKSPACE_REPO_LAND_OWNER_KEY, + }); + try { const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, @@ -1505,6 +1572,18 @@ export async function landWorkspaceTask( // `landedSha` is persisted, so the engine dispatch's auto-retry re-runs this // loop and the landed predicate above skips them (only the failed repo retries). break; + } finally { + /* + FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): + Release the land lease — on land SUCCESS or land FAILURE — but ONLY when WE hold + it (own taskId + own ownerKey), so a future-acquire path's entry on this path is + never yanked. The fast-fail busy throw above happens BEFORE registerPath, so a + serialized loser never unregisters the winner's lease. + */ + const held = activeSessionRegistry.lookupByPath(repoRootDir); + if (held && held.taskId === taskId && held.ownerKey === WORKSPACE_REPO_LAND_OWNER_KEY) { + activeSessionRegistry.unregisterPath(repoRootDir); + } } } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 0276d4ee00..6c9d9eff2e 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -2471,8 +2471,18 @@ export class ProjectEngine { // — mirroring the conflict-retry seam below. Detect by err.name (robust across // the package boundary). Manual merges fall through to rejectMergeResolvers at // the hasManualResolver early-return below (no auto-retry for manual). + /* + FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): + A `WorkspaceRepoLandBusyError` (a second task holds the same sub-repo's + land lease) is ALSO retryable here — it is transient contention, not a + terminal failure. Route it through the SAME auto-retry-then-park seam (it + consumes a mergeRetry and re-enqueues with backoff; a re-run skips + already-landed repos and finds the lease freed). Detect by err.name across + the package boundary, same as the partial-land error. + */ const isWorkspacePartialLand = - err instanceof Error && err.name === "WorkspacePartialLandError"; + err instanceof Error && + (err.name === "WorkspacePartialLandError" || err.name === "WorkspaceRepoLandBusyError"); if (isWorkspacePartialLand && !hasManualResolver) { const wsSettings = await store.getSettings().catch(() => ({ maxAutoMergeRetries: undefined })); const wsTask = await store.getTask(taskId).catch(() => null); From 429258354da9b5957bdda7b103bd180db28b8a85 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:09:59 -0700 Subject: [PATCH 032/265] fix(workspace): address CodeRabbit review findings on the foundation Resolves the actionable CodeRabbit threads on the workspace-mode foundation: - project-resolver: defer saveWorkspaceConfig until after the user confirms init and store.init() succeeds (no partial .fusion/ on a declined/non-interactive run). - git-repository: validate each candidate with a real `git rev-parse` work-tree probe before counting it (no false-positive repos from stray .git markers); loadWorkspaceConfig now rejects absolute paths, `..` escapes, and non-string entries so a corrupt/malicious config can't resolve outside the workspace root. - executor: gate workspace mode on repos.length > 0 at all three sites so an empty { repos: [] } can't bypass the git-repo guard or enable an empty workspace. - worktree-acquisition: thread the configured-command runner through the workspace acquire path (sub-repos run their init setup); validate repoRelPath as an in-root relative path before joining; liveness-check a remembered worktree before reporting it ready (pruned paths fall through to re-acquire); clear the singular task.worktree/branch after persisting per-repo state (per-repo state lives only in workspaceWorktrees). - agent-tools: forward runContext into acquireWorkspaceRepoWorktree for log attribution. The executor-workspace test's mock-the-subject pattern is left for the session-scoping follow-up that rewrites it with a real two-repo fixture (FN-5048). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/project-resolver.ts | 15 ++++- packages/core/src/git-repository.ts | 42 ++++++++++-- packages/engine/src/agent-tools.ts | 10 ++- packages/engine/src/executor.ts | 19 +++++- packages/engine/src/worktree-acquisition.ts | 71 +++++++++++++++++++-- 5 files changed, 139 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index 93eafa1bd9..68ac2f71d1 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -632,14 +632,22 @@ export async function registerProjectInteractive( const gitCheck = spawnSync("git", ["-C", absPath, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" }); const isGitRepo = gitCheck.status === 0 && gitCheck.stdout.trim() === "true"; + /* + FNXC:Workspace 2026-06-22-00:00: + Workspace detection only reports candidate sub-repos here; persistence is deferred until + after the user confirms init AND TaskStore.init() succeeds. Writing .fusion/workspace.json + before confirmation would leave a partial .fusion/ dir when the user declines or runs + non-interactively, polluting a plain non-git directory with stray Fusion state. + */ + let detectedSubRepos: string[] | null = null; if (!isGitRepo) { const subRepos = await detectWorkspaceRepos(absPath); if (subRepos.length > 0) { console.log(`\n Found ${subRepos.length} git repositories in ${absPath}:`); subRepos.forEach((r: string) => console.log(` • ${r}`)); console.log(`\n Initializing as a Fusion workspace...\n`); - await saveWorkspaceConfig(absPath, { repos: subRepos }); - // Fall through to normal .fusion init + detectedSubRepos = subRepos; + // workspace.json is written below, only after a confirmed store.init() succeeds. } // else: fall through to existing error path } @@ -653,6 +661,9 @@ export async function registerProjectInteractive( const { TaskStore } = await import("@fusion/core"); const store = new TaskStore(absPath); await store.init(); + if (detectedSubRepos) { + await saveWorkspaceConfig(absPath, { repos: detectedSubRepos }); + } console.log(` ✓ Initialized fn at ${absPath}`); } else { throw new ProjectResolutionError( diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index 974c5d12a6..adaebeb2ed 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -114,13 +114,23 @@ export async function detectWorkspaceRepos(dir: string): Promise { const { stat } = await import("node:fs/promises"); const { join } = await import("node:path"); const found: string[] = []; + /* + FNXC:Workspace 2026-06-22-00:00: + A bare `.git` marker (e.g. a stray file copied in, or an unrelated tool's artifact) is not + proof of a git repository. Each candidate child is validated with a real `git rev-parse` + work-tree probe before it counts, so stray `.git` entries do not yield false-positive repos. + */ for (const entry of entries) { - const candidate = join(dir, entry, ".git"); + const childDir = join(dir, entry); + // Cheap pre-filter: skip children with no `.git` marker at all before spawning git. try { - const s = await stat(candidate); - if (s.isDirectory() || s.isFile()) found.push(entry); + const s = await stat(join(childDir, ".git")); + if (!s.isDirectory() && !s.isFile()) continue; } catch { - // not a git repo + continue; + } + if (await isInsideGitWorkTree(childDir, runGitCommand, DEFAULT_GIT_TIMEOUT_MS)) { + found.push(entry); } } return found.sort(); @@ -132,9 +142,27 @@ export interface WorkspaceConfig { const WORKSPACE_CONFIG_FILENAME = "workspace.json"; +/* +FNXC:Workspace 2026-06-22-00:00: +Workspace repo entries are later joined onto the workspace root to resolve worktrees, so an +attacker-controlled or corrupted workspace.json with an absolute path or a `..` escape +(`../outside-repo`) would resolve outside the workspace root. Each entry must be a normalized, +relative, in-root path; absolute paths, `..` escapes, and non-string entries are rejected. +*/ +function isInRootRelativePath(entry: unknown, pathMod: typeof import("node:path")): entry is string { + if (typeof entry !== "string" || entry.length === 0) return false; + if (pathMod.isAbsolute(entry)) return false; + const normalized = pathMod.normalize(entry); + if (normalized === ".." || normalized.startsWith(`..${pathMod.sep}`) || normalized.startsWith("../")) { + return false; + } + return true; +} + export async function loadWorkspaceConfig(rootDir: string): Promise { const { readFile } = await import("node:fs/promises"); - const { join } = await import("node:path"); + const pathMod = await import("node:path"); + const { join } = pathMod; const configPath = join(rootDir, ".fusion", WORKSPACE_CONFIG_FILENAME); try { const raw = await readFile(configPath, "utf-8"); @@ -145,7 +173,9 @@ export async function loadWorkspaceConfig(rootDir: string): Promise isInRootRelativePath(entry, pathMod)); + return { ...(parsed as object), repos }; } return null; } catch { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 99e87b2cb3..1af8949ffe 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -3601,8 +3601,12 @@ export function createAcquireRepoWorktreeTool(opts: { logger?: { log: (m: string) => void; warn: (m: string) => void }; secretsStore?: Pick; runContext?: RunMutationContext; + audit?: Pick; + // FNXC:Workspace 2026-06-22 — thread the configured worktree-init runner so sub-repo worktrees run configured setup. + runConfiguredCommand?: import("./worktree-acquisition.js").AcquireWorkspaceRepoWorktreeOptions["runConfiguredCommand"]; + taskEnv?: NodeJS.ProcessEnv; }): ToolDefinition { - const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext } = opts; + const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext, audit, runConfiguredCommand, taskEnv } = opts; return { name: "fn_acquire_repo_worktree", label: "Acquire Repo Worktree", @@ -3629,6 +3633,10 @@ export function createAcquireRepoWorktreeTool(opts: { settings, logger, secretsStore, + runContext, + audit, + runConfiguredCommand, + taskEnv, }); await store.logEntry( task.id, diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index fb57716b9d..56610e6b0c 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -7416,7 +7416,15 @@ export class TaskExecutor { if (this.workspaceConfig === undefined) { this.workspaceConfig = await loadWorkspaceConfig(this.rootDir); } - if (!this.workspaceConfig && !await isGitRepository(this.rootDir)) { + /* + FNXC:Workspace 2026-06-22-00:00: + Workspace mode is only meaningful with at least one usable sub-repo. An empty `{ repos: [] }` + must NOT bypass the git-repository guard, inject workspace instructions, or expose the + workspace tool — otherwise a non-git directory with an empty config would skip validation + and enable a workspace with nothing to work on. Gate every workspace check on repos.length > 0. + */ + const hasWorkspaceRepos = (this.workspaceConfig?.repos.length ?? 0) > 0; + if (!hasWorkspaceRepos && !await isGitRepository(this.rootDir)) { await this.store.logEntry( task.id, "Cannot execute task: project directory is not a Git repository. Fusion requires a Git repository for worktree-based task execution.", @@ -8351,7 +8359,7 @@ export class TaskExecutor { ...getEnabledPluginTools(this.options.pluginRunner), ]; - if (this.workspaceConfig) { + if (this.workspaceConfig && this.workspaceConfig.repos.length > 0) { customTools.push(createAcquireRepoWorktreeTool({ workspaceRootDir: this.rootDir, workspaceRepos: this.workspaceConfig.repos, @@ -8361,6 +8369,11 @@ export class TaskExecutor { logger: executorLog, secretsStore: this.options.secretsStore, runContext: engineRunContext, + audit, + taskEnv, + // FNXC:Workspace 2026-06-22 — forward the configured worktree-init runner so sub-repo worktrees run configured setup. + runConfiguredCommand: (command, cwd, timeoutMs, env) => + runConfiguredCommand(command, cwd, timeoutMs, env, audit), })); } @@ -15940,7 +15953,7 @@ Use \`fn_task_create\` for truly separate follow-up work, including unrelated/pr If lint is configured and failing, fix that too before completion. Do not repeatedly rerun a broad failing or hanging workspace command without a new hypothesis and a narrower confirming command.`; - if (workspaceConfig) { + if (workspaceConfig && workspaceConfig.repos.length > 0) { return executionPrompt + `\n\n## Workspace mode\n` + `This project is a workspace containing multiple git repositories.\n` + `Available repos:\n` + diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 6ce4ca696b..91fbc75bcd 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -604,21 +604,64 @@ export interface AcquireWorkspaceRepoWorktreeOptions { settings: Partial; logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void }; secretsStore?: Pick; + runContext?: RunMutationContext; + audit?: Pick; + runConfiguredCommand?: AcquireTaskWorktreeOptions["runConfiguredCommand"]; + taskEnv?: NodeJS.ProcessEnv; +} + +/* +FNXC:WorkspaceWorktree 2026-06-22-00:00: +`repoRelPath` is an exported, caller-trusted parameter that is joined onto `workspaceRootDir`. +An absolute path or a `..` escape (`../outside`) would resolve a worktree outside the workspace +root. Validate it is a normalized, relative, in-root path before resolving the absolute path. +*/ +function assertInRootRepoRelPath(repoRelPath: string, sep: string, isAbsolute: (p: string) => boolean, normalize: (p: string) => string): void { + if (typeof repoRelPath !== "string" || repoRelPath.length === 0 || isAbsolute(repoRelPath)) { + throw new Error(`Invalid workspace repo path (must be relative and in-root): ${String(repoRelPath)}`); + } + const normalized = normalize(repoRelPath); + if (normalized === ".." || normalized.startsWith(`..${sep}`) || normalized.startsWith("../")) { + throw new Error(`Invalid workspace repo path (escapes workspace root): ${repoRelPath}`); + } } export async function acquireWorkspaceRepoWorktree( opts: AcquireWorkspaceRepoWorktreeOptions, ): Promise<{ worktreePath: string; branch: string; alreadyAcquired: boolean }> { - const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore } = opts; - const { join } = await import("node:path"); + const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, runContext, audit, runConfiguredCommand, taskEnv } = opts; + const { join, isAbsolute, normalize, sep } = await import("node:path"); + // FNXC:WorkspaceWorktree 2026-06-22 — reject absolute / `..`-escaping repo paths before resolving. + assertInRootRepoRelPath(repoRelPath, sep, isAbsolute, normalize); + const repoAbsPath = join(workspaceRootDir, repoRelPath); + + /* + FNXC:WorkspaceWorktree 2026-06-22-00:00: + A remembered per-repo worktree is only reusable if it still exists and is a registered git + worktree. A pruned/deleted worktree path would otherwise be reported as "ready" without the + resume/classification checks that `acquireTaskWorktree` runs on the singular path. Verify the + remembered path passes the same liveness check (existence + git work-tree classification); + if it is dead, drop it and fall through to re-acquire a fresh worktree. + */ const existing = task.workspaceWorktrees?.[repoRelPath]; if (existing) { - return { ...existing, alreadyAcquired: true }; + let live = existsSync(existing.worktreePath); + if (live) { + try { + const classification = await classifyTaskWorktree(repoAbsPath, existing.worktreePath); + live = classification.ok; + } catch { + live = false; + } + } + if (live) { + return { ...existing, alreadyAcquired: true }; + } + logger?.warn(`${task.id}: remembered workspace worktree for ${repoRelPath} is missing/unusable (${existing.worktreePath}); re-acquiring`); + await store.logEntry(task.id, `Remembered workspace worktree for ${repoRelPath} is no longer usable; re-acquiring`, existing.worktreePath, runContext); } - const repoAbsPath = join(workspaceRootDir, repoRelPath); - /* FNXC:WorkspaceWorktree 2026-06-21-00:00: Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` @@ -629,6 +672,11 @@ export async function acquireWorkspaceRepoWorktree( contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in `task.workspaceWorktrees`, not the singular column. + + FNXC:WorkspaceWorktree 2026-06-22-00:00: + `acquireTaskWorktree` only runs the configured worktree-init command when `runConfiguredCommand` + is threaded through. Forward it (plus runContext/audit/taskEnv) so workspace sub-repos run the + same configured setup as the non-workspace acquire path instead of silently skipping it. */ const result = await acquireTaskWorktree({ task: { ...task, worktree: undefined, branch: undefined }, @@ -637,6 +685,10 @@ export async function acquireWorkspaceRepoWorktree( settings, logger, secretsStore, + runContext, + audit, + runConfiguredCommand, + taskEnv, runInitCommand: true, }); @@ -644,7 +696,14 @@ export async function acquireWorkspaceRepoWorktree( ...(task.workspaceWorktrees ?? {}), [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, }; - await store.updateTask(task.id, { workspaceWorktrees: updated }); + /* + FNXC:WorkspaceWorktree 2026-06-22-00:00: + `acquireTaskWorktree` persists the singular `task.worktree`/`task.branch` on the task row. + For a workspace task that pointer would end up referencing whichever sub-repo was acquired last, + violating the contract that per-repo state lives only in `workspaceWorktrees`. Clear the singular + fields in the same update so a workspace task never carries a misleading singular worktree pointer. + */ + await store.updateTask(task.id, { workspaceWorktrees: updated, worktree: null, branch: null }); return { worktreePath: result.worktreePath, branch: result.branch, alreadyAcquired: false }; } From accb32e9b63894d62259017c3884ddd008b830b3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:19:15 -0700 Subject: [PATCH 033/265] fix(review): address PR #1711 review findings Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-21-003-refactor-merger-unification-u0-plan.md | 4 ++-- packages/engine/src/index.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md b/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md index 385e7fc7fb..d16a26537d 100644 --- a/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md +++ b/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md @@ -23,7 +23,7 @@ It also installs the **R7 workspace merge-boundary guard** at every merge entry Merge is dispatched at `packages/engine/src/project-engine.ts:2275-2282`: -``` +```ts const mergerMode = normalizeMergerMode(settings.merger?.mode); // defaults to "ai" return mergerMode === "ai" ? runAiMerge(store, cwd, taskId, mergeOptionsWithSettings) @@ -61,7 +61,7 @@ Before claiming low blast radius, grep test fixtures, CI configs, and seeded/def ## Implementation Units > **Units `U1–U4` below are local to this plan** (they decompose master-plan U0); they are **not** the master plan's `U1–U10`. U4 (audit) may run in parallel with U1–U3. - +> > **Standing requirements:** `FNXC:Workspace ` dated comments at each non-obvious decision point (dispatch collapse, the R7 guard, the deprecation warning). A `.changeset/*.md` (`@runfusion/fusion: minor`). Respect the merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`) and FN-5048 (narrow seams, fake timers, no real polling / mock-the-world). **Base branch (decided):** branch off the **foundation** (`pr-1710` / `feat/workspace-multi-repo` head) — the R7 guard (U3) reads `task.workspaceWorktrees`, which the foundation adds and `main` lacks. Do **not** commit onto `pr-1710` directly; use a new branch and open a **stacked PR targeting `feat/workspace-multi-repo`** so the diff is only U0's changes. ### U1. Collapse the engine dispatch and route the two direct callers to `runAiMerge` diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 40e65e9b0f..e4cb416862 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -167,9 +167,13 @@ export { export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js"; export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js"; export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js"; +// FNXC:MergerUnification 2026-06-22-00:00: @deprecated must sit on aiMergeTask's own +// export so IDE/type-aware tooling flags only aiMergeTask, not the helpers it shares with +// runAiMerge (those are NOT deprecated). A single @deprecated on the multi-member block +// would mark every symbol below as deprecated. /** @deprecated Use runAiMerge — aiMergeTask is the soft-deprecated legacy path. */ +export { aiMergeTask } from "./merger.js"; export { - aiMergeTask, listAutostashOrphans, applyAutostashBySha, dropAutostashBySha, From f4a9c655099d02557cc2eec692c5603f5a077add Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:21:49 -0700 Subject: [PATCH 034/265] fix(review): address PR #1714 review findings - base-commit-capture: POSIX single-quote integration branch refs instead of JSON.stringify (double quotes are subject to $-expansion in the shell) - executor: add per-repo no_commits guard to the workspace verifyWorktreeInvariants branch (parity with the singular path), gated by the same task-wide no-commit eligibility - executor: reviewWorkspacePerRepo failure message now states the per-repo verdict list is partial (evaluation stops at first failure) - worktree-acquisition: defensively wrap non-fatal/outer-catch logEntry/audit so a logging throw cannot promote a non-fatal error to fatal or mask the original error - docs/plans: add code-fence language tags and fix MD028 blank-line-in-blockquote Co-Authored-By: Claude Opus 4.8 (1M context) --- ...eat-workspace-mode-execution-model-plan.md | 2 +- ...003-refactor-merger-unification-u0-plan.md | 4 +- packages/engine/src/base-commit-capture.ts | 14 ++-- packages/engine/src/executor.ts | 67 ++++++++++++++++++- packages/engine/src/worktree-acquisition.ts | 56 +++++++++++----- 5 files changed, 117 insertions(+), 26 deletions(-) diff --git a/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md b/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md index 050ff5aa11..c5b50e1d03 100644 --- a/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md +++ b/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md @@ -155,7 +155,7 @@ The surface-enumeration spine (FN-5893). Every row is a single-worktree / `cwd:r Additive only — no migration to existing single-repo tasks: -``` +```ts Task.workspaceWorktrees: Record **Units `U1–U4` below are local to this plan** (they decompose master-plan U0); they are **not** the master plan's `U1–U10`. U4 (audit) may run in parallel with U1–U3. - +> > **Standing requirements:** `FNXC:Workspace ` dated comments at each non-obvious decision point (dispatch collapse, the R7 guard, the deprecation warning). A `.changeset/*.md` (`@runfusion/fusion: minor`). Respect the merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`) and FN-5048 (narrow seams, fake timers, no real polling / mock-the-world). **Base branch (decided):** branch off the **foundation** (`pr-1710` / `feat/workspace-multi-repo` head) — the R7 guard (U3) reads `task.workspaceWorktrees`, which the foundation adds and `main` lacks. Do **not** commit onto `pr-1710` directly; use a new branch and open a **stacked PR targeting `feat/workspace-multi-repo`** so the diff is only U0's changes. ### U1. Collapse the engine dispatch and route the two direct callers to `runAiMerge` diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index 4d9e778774..6deb9d5c93 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -39,10 +39,16 @@ export async function resolveCapturedBaseCommitSha( integrationBranch: string = "main", ): Promise { const branch = integrationBranch.trim() || "main"; - // Shell-quote defensively; integration branch names are normalized upstream - // but may carry slashes (e.g. "release/2026-06") that are valid in refs. - const localRef = JSON.stringify(branch); - const originRef = JSON.stringify(`origin/${branch}`); + // FNXC:Workspace 2026-06-22-00:00: + // Shell-quote with POSIX single quotes, NOT JSON.stringify. JSON.stringify wraps + // in double quotes, under which the shell expands `$VAR`/backticks — a branch like + // `release/$2.0` would expand `$2` to a positional. Admin-configured integration + // branch names are not guaranteed to exclude `$`, and `$` is valid in git refs, so + // double-quoting is an injection/correctness risk. Single-quoting (with the embedded + // `'` → `'\''` escape) is literal and safe for slashes (e.g. "release/2026-06") too. + const shellQuote = (s: string): string => `'${s.replace(/'/g, "'\\''")}'`; + const localRef = shellQuote(branch); + const originRef = shellQuote(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index be43f645c9..2bc7feefeb 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -10570,6 +10570,26 @@ export class TaskExecutor { // 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 (this.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 + // loop. When eligible (Plan-Only, verified no-op, etc.) the per-repo no_commits guard below is skipped so an + // intentionally commit-free workspace task is not blocked from completion. + const workspacePromptContent = (task as Task & { prompt?: unknown }).prompt; + const workspacePromptEligibility = evaluatePromptDerivedNoCommitEligibility( + task, + typeof workspacePromptContent === "string" ? workspacePromptContent : "", + ); + const workspaceNoCommitEligibilityReason = + getNoCommitEligibilityReason(task) ?? + (options?.noOpCompletion + ? options.noOpCompletionReason ?? "verified no-op/duplicate completion sentinel" + : null) ?? + (workspacePromptEligibility.eligible + ? workspacePromptEligibility.reason ?? "prompt-derived no-commit eligibility" + : null); + if (workspaceNoCommitEligibilityReason) { + executorLog.log(`${task.id}: workspace fn_task_done no_commits guard skipped (${workspaceNoCommitEligibilityReason})`); + } // 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). for (const repoRel of Object.keys(workspaceWorktrees).sort()) { @@ -10647,6 +10667,48 @@ export class TaskExecutor { 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 this.resolveDiffBaseRef(repo.worktreePath, repo.baseCommitSha); + if (repoBaseRef) { + try { + const { stdout } = await execAsync(`git rev-list --count ${repoBaseRef}..HEAD`, { + cwd: repo.worktreePath, + encoding: "utf-8", + timeout: 10_000, + 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", + }; + } + } + } catch (error) { + return { + ok: false, + reason: "no_commits", + repo: repoRel, + observed: error instanceof Error ? error.message : String(error), + expected: `git rev-list --count ${repoBaseRef}..HEAD > 0`, + }; + } + } else { + executorLog.warn(`${task.id}: unable to resolve diff base for ${repoRel} no_commits guard; skipping for this sub-repo`); + } + } } return { ok: true }; } @@ -12593,7 +12655,10 @@ ${failureFeedback} // verdict→edge mapping is identical to single-cwd), with the full repo-tagged review body. return { verdict: firstFailing.result.verdict, - review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, + // FNXC:Workspace 2026-06-22-00:00: the conjunction BREAKS on the first non-APPROVE repo, + // so reviewSections holds only the repos evaluated up to (and including) the failure — not + // every sub-repo. Label it honestly so operators don't read a partial list as exhaustive. + review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts (evaluation stopped at first failure; later repos not reviewed):\n\n${reviewSections.join("\n\n")}`, summary: `${firstFailing.repo}: ${firstFailing.result.verdict} — ${summarySections.join(" | ")}`, }; } diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 352ef4a036..0e32b88cbe 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -746,14 +746,21 @@ export async function acquireWorkspaceRepoWorktree( }); } catch (guardErr) { // FNXC:Workspace 2026-06-21-22:30: F3 — identity-guard install is non-fatal; worktree is usable without it. + // FNXC:Workspace 2026-06-22-00:00: the non-fatal logEntry/audit are themselves best-effort — if either throws + // (e.g. a DB write hiccup) it must NOT promote this non-fatal guard failure into a fatal acquisition failure. + // Swallow logging errors so acquisition continues (matching the F6 busy-path defensive wrap above). const message = guardErr instanceof Error ? guardErr.message : String(guardErr); logger?.warn(`${task.id}: identity-guard install failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); - await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, - }); + try { + await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, + }); + } catch { + // best-effort observability only — keep the (non-fatal) guard failure non-fatal + } } /* @@ -777,14 +784,20 @@ export async function acquireWorkspaceRepoWorktree( baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); } catch (baseErr) { // FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state. + // FNXC:Workspace 2026-06-22-00:00: guard the best-effort logEntry/audit so a logging throw cannot promote this + // non-fatal capture failure into a fatal acquisition failure (parity with the F6 busy-path defensive wrap). const message = baseErr instanceof Error ? baseErr.message : String(baseErr); logger?.warn(`${task.id}: base-SHA capture failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); - await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, - }); + try { + await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, + }); + } catch { + // best-effort observability only — keep the (non-fatal) capture failure non-fatal + } } /* @@ -814,14 +827,21 @@ export async function acquireWorkspaceRepoWorktree( sub-repo. */ if (!(err instanceof WorkspaceRepoAcquireBusyError)) { + // FNXC:Workspace 2026-06-22-00:00: wrap the failure logEntry/audit so a throw here cannot replace the ORIGINAL + // acquisition `err` the caller must observe — losing it would mask the real cause and the re-throw below would + // surface a logging error instead. Best-effort observability; `err` is always re-thrown. const message = err instanceof Error ? err.message : String(err); logger?.error?.(`${task.id}: workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`); - await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message }, - }); + try { + await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message }, + }); + } catch { + // best-effort observability only — ensure the original acquisition error propagates + } } throw err; } finally { From 9f0492e69fc263502568cec6bfbb5db7d4c19642 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:22:48 -0700 Subject: [PATCH 035/265] fix(review): address PR #1713 review findings - base-commit-capture.ts: shell-quote integration branch with a real single-quoted POSIX literal instead of JSON.stringify (not shell-safe). - TaskCard.tsx: memo compares full workspaceWorktrees values, not just key sets, so a same-key worktreePath/branch change re-renders. - TaskDetailModal.tsx: gate/render workspace summary off hydrated workingTask. - worktree-acquisition.ts: null the singular worktree/branch columns in the workspaceWorktrees write so isWorkspaceTask stays true; wrap non-fatal post-acquire observability so logEntry/audit can't re-escalate to fatal. - agent-tools.ts: register sub-repo worktree via onAcquired unconditionally (idempotent) so a resumed/already-acquired path is tracked after restart. - executor.ts: DB liveness fallback also checks task.workspaceWorktrees paths. - executor-workspace.test.ts: root non-git assertion runs in fx.rootDir ("."). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/app/components/TaskCard.tsx | 8 ++- .../app/components/TaskDetailModal.tsx | 6 +- .../src/__tests__/executor-workspace.test.ts | 5 +- packages/engine/src/agent-tools.ts | 12 ++-- packages/engine/src/base-commit-capture.ts | 17 +++-- packages/engine/src/executor.ts | 12 +++- packages/engine/src/worktree-acquisition.ts | 62 +++++++++++++++---- 7 files changed, 96 insertions(+), 26 deletions(-) diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 14d93678e8..6400b3b02e 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -631,8 +631,12 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo // F7 — compare the sorted key SETS, not just the count: a same-count repo swap (one // repo released, a different one acquired) keeps the count but must still re-render, // otherwise the placeholder shows a stale repo set. - JSON.stringify(Object.keys(previousTask.workspaceWorktrees ?? {}).sort()) === - JSON.stringify(Object.keys(nextTask.workspaceWorktrees ?? {}).sort()) && + // FNXC:Workspace 2026-06-22-09:00: compare full VALUES, not only the key set. A + // pool-reclaim re-acquire keeps the same repo key but produces a different + // worktreePath/branch; a key-set-only check would leave the card showing stale path + // text. Whole-map JSON compare covers keys and values at negligible cost for small N. + JSON.stringify(previousTask.workspaceWorktrees ?? null) === + JSON.stringify(nextTask.workspaceWorktrees ?? null) && previousTask.branch === nextTask.branch && previousTask.baseBranch === nextTask.baseBranch && previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks && diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 6432ed838f..6b275babff 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3069,7 +3069,11 @@ export function TaskDetailContent({ {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular task.worktree/task.branch; surface their acquired per-sub-repo worktrees as a flat read-only list so the detail view isn't blank (U3/KTD5). */} - {isWorkspaceTask(task) && } + {/* FNXC:Workspace 2026-06-22-09:00: gate/render off the hydrated + workingTask, not the sparse task row. workspaceWorktrees is only + present in fetched detail, so keying off task renders blank on the + optimistic-open path before the detail fetch resolves. */} + {isWorkspaceTask(workingTask) && } )} {task.status === "failed" && task.error && ( diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts index 330915e966..7b0033bb54 100644 --- a/packages/engine/src/__tests__/executor-workspace.test.ts +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -48,8 +48,9 @@ describeIfGit("workspace fixture", () => { it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => { fx = await createWorkspaceFixture(); - // Root is NOT a git repo. - expect(() => fx.git("..", "git rev-parse --git-dir")).toThrow(); + // Root is NOT a git repo. Use "." so the check runs in fx.rootDir itself, not + // its parent (".." would resolve to the tmpdir and could pass spuriously). + expect(() => fx.git(".", "git rev-parse --git-dir")).toThrow(); // Each sub-repo is a real git repo with a commit on main. expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main"); expect(fx.git("repo-b", "git rev-list --count HEAD")).toBe("1"); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index a6fae43a06..2dbb3afc50 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -3669,10 +3669,14 @@ export function createAcquireRepoWorktreeTool(opts: { isError: true, }; } - // FNXC:Workspace 2026-06-21-22:30: F2 — register a freshly-acquired sub-repo worktree in the executor's activeWorktrees Set (skip the already-acquired short-circuit; that path was registered on its original fresh acquire). - if (!result.alreadyAcquired) { - onAcquired?.(result.worktreePath); - } + // FNXC:Workspace 2026-06-21-22:30: F2 — register a freshly-acquired sub-repo worktree in the executor's activeWorktrees Set (KTD2) so owner/liveness checks see live per-repo worktrees, not just the browse-only root. + // FNXC:Workspace 2026-06-22-09:00: register UNCONDITIONALLY, including the + // already-acquired short-circuit. After an executor restart activeWorktrees is an + // empty Map; a resumed workspace task with pre-existing task.workspaceWorktrees hits + // the alreadyAcquired path, so skipping onAcquired left the sub-repo path unregistered + // in-memory and conflict/liveness checks missed it. Set.add is idempotent, so re-firing + // on a fresh acquire is a harmless no-op. + onAcquired?.(result.worktreePath); await store.logEntry( task.id, result.alreadyAcquired diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index 4d9e778774..4862226f88 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -39,10 +39,19 @@ export async function resolveCapturedBaseCommitSha( integrationBranch: string = "main", ): Promise { const branch = integrationBranch.trim() || "main"; - // Shell-quote defensively; integration branch names are normalized upstream - // but may carry slashes (e.g. "release/2026-06") that are valid in refs. - const localRef = JSON.stringify(branch); - const originRef = JSON.stringify(`origin/${branch}`); + /* + FNXC:Workspace 2026-06-22-09:00: + Shell-quote with a real single-quoted POSIX literal, NOT JSON.stringify. A + JSON double-quoted string still lets bash expand `$(...)`, backticks, and `$VAR` + inside it; JSON.stringify is not a shell-quoting function. Git ref names can't + legally contain backticks so there's no live injection path today, but + single-quoting is the idiomatic safe form and stays correct if a caller ever + passes a less-constrained string. A single quote inside the value is escaped as + the standard `'\''` close-reopen sequence. + */ + const shellSingleQuote = (value: string): string => `'${value.replace(/'/g, "'\\''")}'`; + const localRef = shellSingleQuote(branch); + const originRef = shellSingleQuote(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index a5e9063a5b..2ee4a765d7 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -14554,10 +14554,18 @@ You have access to the file system to review changes.${verdictBlock}`; const tasks = await this.store.listTasks({ slim: true, includeArchived: false }); for (const t of tasks) { if (t.id === requestingTaskId) continue; - if (t.worktree !== worktreePath) continue; if (t.column !== "in-progress") continue; if (t.paused === true) continue; - return t.id; + if (t.worktree === worktreePath) return t.id; + // FNXC:Workspace 2026-06-22-09:00: workspace tasks hold their worktrees in + // task.workspaceWorktrees, not the singular task.worktree column. The DB liveness + // fallback must check those per-sub-repo paths too — otherwise a conflict against a + // sub-repo worktree owned by an in-progress workspace task is missed, especially + // before its in-memory activeWorktrees entry is (re)registered after restart. + const wsEntries = t.workspaceWorktrees; + if (wsEntries && Object.values(wsEntries).some((entry) => entry.worktreePath === worktreePath)) { + return t.id; + } } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 352ef4a036..a59c4e54b4 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -643,6 +643,24 @@ export async function acquireWorkspaceRepoWorktree( const repoAbsPath = join(workspaceRootDir, repoRelPath); + /* + FNXC:Workspace 2026-06-22-09:00: + Run best-effort observability (task log + audit) for the NON-FATAL post-acquire + steps without letting their own awaited writes escape. logEntry/audit can throw + (DB hiccup, audit sink failure); an unsuppressed throw inside a non-fatal catch + would re-escalate guard/base-capture failures into fatal acquisition errors that + strand the already-created worktree. Mirrors the busy-path swallow above. + */ + const safeObserve = async (fn: () => Promise): Promise => { + try { + await fn(); + } catch (obsErr) { + logger?.warn( + `${task.id}: workspace acquisition observability failed (suppressed): ${obsErr instanceof Error ? obsErr.message : String(obsErr)}`, + ); + } + }; + /* FNXC:Workspace 2026-06-21-20:10: Same-sub-repo exclusivity (KTD4): register the sub-repo absolute path in the @@ -748,11 +766,17 @@ export async function acquireWorkspaceRepoWorktree( // FNXC:Workspace 2026-06-21-22:30: F3 — identity-guard install is non-fatal; worktree is usable without it. const message = guardErr instanceof Error ? guardErr.message : String(guardErr); logger?.warn(`${task.id}: identity-guard install failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); - await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, + // FNXC:Workspace 2026-06-22-09:00: the observability writes (store.logEntry / audit.git) + // are themselves awaited and can throw; an unwrapped throw here would escape the catch + // and re-escalate this deliberately NON-FATAL step into a fatal acquisition error, + // stranding the already-created worktree. Suppress observability failures via safeObserve. + await safeObserve(async () => { + await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, + }); }); } @@ -779,11 +803,15 @@ export async function acquireWorkspaceRepoWorktree( // FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state. const message = baseErr instanceof Error ? baseErr.message : String(baseErr); logger?.warn(`${task.id}: base-SHA capture failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); - await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, + // FNXC:Workspace 2026-06-22-09:00: same non-fatal contract as the identity-guard catch — + // the awaited observability writes must not re-escalate a non-fatal base-capture failure. + await safeObserve(async () => { + await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, + }); }); } @@ -802,7 +830,19 @@ export async function acquireWorkspaceRepoWorktree( ...(latest.workspaceWorktrees ?? {}), [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha }, }; - await store.updateTask(task.id, { workspaceWorktrees: updated }); + /* + FNXC:Workspace 2026-06-22-09:00: + F10 — reset the singular worktree/branch columns to null in the SAME write that + persists workspaceWorktrees. The single-repo `acquireTaskWorktree` above wrote + `task.worktree`/`task.branch` (the sub-repo path/branch) to the real task row; + clearing the in-memory copy passed in only stops the NEXT sub-repo from resuming + into this one's worktree — the DB row stays polluted. A non-null `task.worktree` + makes `isWorkspaceTask(task)` return false (its first guard), so the dashboard + stops rendering WorkspaceWorktreesSummary and instead shows the sub-repo branch in + the standard chip — the blank/wrong-card state U10 prevents. Nulling them here + keeps `task.worktree` null for the workspace task's whole lifetime. + */ + await store.updateTask(task.id, { workspaceWorktrees: updated, worktree: null, branch: null }); return { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha, alreadyAcquired: false }; } catch (err) { From f2c1a28eab81966747caa60e19db3da427bb5cda Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:26:40 -0700 Subject: [PATCH 036/265] fix(workspace): re-read task before workspaceWorktrees merge; FNXC format Addresses the follow-up review on the foundation fixes: - Re-read the task via store.getTask immediately before merging the per-repo entry, so a concurrent sibling-repo acquisition that landed since the initial read isn't clobbered by updateTask's wholesale map replace (narrows the read-modify-write window to the store lock; a fully atomic per-repo store-level merge remains a follow-up). - Normalize the inline FNXC comment to the FNXC:Area yyyy-MM-dd-hh:mm: convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/engine/src/worktree-acquisition.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 91fbc75bcd..6e8c4febb4 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -632,7 +632,7 @@ export async function acquireWorkspaceRepoWorktree( const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, runContext, audit, runConfiguredCommand, taskEnv } = opts; const { join, isAbsolute, normalize, sep } = await import("node:path"); - // FNXC:WorkspaceWorktree 2026-06-22 — reject absolute / `..`-escaping repo paths before resolving. + // FNXC:WorkspaceWorktree 2026-06-22-00:00: reject absolute / `..`-escaping repo paths before resolving. assertInRootRepoRelPath(repoRelPath, sep, isAbsolute, normalize); const repoAbsPath = join(workspaceRootDir, repoRelPath); @@ -692,8 +692,17 @@ export async function acquireWorkspaceRepoWorktree( runInitCommand: true, }); + /* + FNXC:WorkspaceWorktree 2026-06-22-00:00: + Re-read the task immediately before merging so a concurrent sibling-repo acquisition that + landed between our initial read and now is not clobbered — `updateTask` replaces the + `workspaceWorktrees` map wholesale, so we must merge onto the freshest map, not the stale + snapshot captured before `acquireTaskWorktree`. This narrows the read-modify-write window to + the store's own lock; a fully atomic per-repo store-level merge is a follow-up. + */ + const freshTask = (await store.getTask(task.id)) ?? task; const updated: Record = { - ...(task.workspaceWorktrees ?? {}), + ...(freshTask.workspaceWorktrees ?? {}), [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, }; /* From 627bdcfb0aee623b383625b9af60d2fcc02e659a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:11:52 -0700 Subject: [PATCH 037/265] =?UTF-8?q?fix(review):=20Phase=20C=20merge-loop?= =?UTF-8?q?=20hardening=20=E2=80=94=20double-land,=20lease=20clobber,=20re?= =?UTF-8?q?try=20storm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-persona review of the Phase-C per-repo merge loop. No P0; the no-push invariant and retry/park accounting verified clean. Fixed: Land mechanics (merger-ai.ts / active-session-registry.ts): - persistRepoLandedSha no longer swallows the DB write: a failed landedSha write after the ref advanced now escalates to WorkspacePartialLandError so the engine parks/retries instead of silently re-landing (duplicate squash). isRepoLanded gains a landedSha-independent fallback — it scans the integration ref for this task's Fusion-Task-Id trailer (a squash commit is NOT a branch descendant, so a branch-ancestor check is provably wrong), so an actually-landed repo is skipped on retry. - The land lease is now taskId-aware across kinds: any foreign-task holder on a sub-repo path is contention (a merging task can't run over an executing task's acquire lease), and registerPath throws ActiveSessionPathHeldByForeignTaskError instead of silently clobbering a different task's entry. - The per-repo loop is wrapped in try/finally(setStatus(null)) so the busy/partial throws can't leave the task stuck 'merging'. WorkspacePartialLandError is a real exported class (not a .name-mutated Error). finalizeWorkspaceTask re-reads fresh and no longer swallows the mergeDetails write (TOCTOU). isRepoLanded exported for Phase D. Dispatch + doors (project-engine.ts / dashboard.ts / task.ts / @fusion/core): - getTask-null in the partial-land catch fails closed (park) instead of defaulting retries to 0 and scheduling an indefinite retry storm. - The merge-confirmed reachability fast-path skips workspace tasks (its representative commitSha is a sub-repo squash sha, unreachable in the root cwd — it was demoting fully-merged tasks); they're verified by per-repo landedSha. - The CLI/dashboard merge doors now return merged:true on full land (were hardcoded merged:false). WorkspaceRepoLandBusyError re-enqueues with backoff WITHOUT burning the mergeRetries quota (bounded busy counter) so contention can't park a healthy task. Backoff capped at 60s. shouldRetryWorkspacePartialLand folded into shouldRetryAutoMergeConflict. Catch switched to instanceof. New canonical isWorkspaceTask predicate in @fusion/core. Gate green: build, typecheck, lint, test:gate (649+58); workspace-merger + oracle + project-engine 174. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...workspace-land-mechanics-phase-c-review.md | 7 + packages/cli/src/commands/dashboard.ts | 13 +- packages/cli/src/commands/task.ts | 9 +- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 18 +- .../__tests__/active-session-registry.test.ts | 29 +- .../src/__tests__/project-engine.test.ts | 284 +++++++++++++++--- .../workspace-merger-idempotency.test.ts | 116 ++++++- .../__tests__/workspace-merger-lease.test.ts | 46 +++ .../engine/src/active-session-registry.ts | 38 ++- packages/engine/src/index.ts | 7 + packages/engine/src/merger-ai.ts | 204 +++++++++++-- packages/engine/src/project-engine.ts | 186 ++++++++---- 13 files changed, 823 insertions(+), 136 deletions(-) create mode 100644 .changeset/fix-workspace-land-mechanics-phase-c-review.md diff --git a/.changeset/fix-workspace-land-mechanics-phase-c-review.md b/.changeset/fix-workspace-land-mechanics-phase-c-review.md new file mode 100644 index 0000000000..f24a7921ba --- /dev/null +++ b/.changeset/fix-workspace-land-mechanics-phase-c-review.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Harden the workspace per-repo land loop against partial-failure races. A lost `landedSha` DB write after a sub-repo's integration ref already advanced no longer silently continues — it escalates to a retryable partial-land error, and the landed predicate now recognizes an already-landed repo via its `Fusion-Task-Id` trailer on retry, so a re-run never produces a second squash commit. The land lease is now taskId-aware across registry kinds: a merging task can no longer clobber an executing task's acquire lease on a shared sub-repo (any foreign-task holder is treated as contention), and the active-session registry rejects foreign-task overwrites instead of silently clobbering. The transient `merging` status is always reset before any throw escapes the land loop (no stuck-`merging` leak), and finalize re-reads the latest task and no longer swallows the merge-details persist failure (no finalizing on a stale row). + +Harden the workspace merge dispatch and user-facing merge doors. The partial-land retry catch now fails closed when the task row can't be read (DB outage no longer triggers an indefinite retry storm). The merge-confirmed reachability fast-path skips workspace tasks (whose recorded commitSha lives in a sub-repo, not the workspace root) so a fully-landed workspace task is no longer demoted/parked. The dashboard and CLI merge doors now report `merged: true` (and `mergeConfirmed`/`commitSha`) when a workspace fully lands, mirroring the engine result. Transient sub-repo land-lease contention (`WorkspaceRepoLandBusyError`) is re-enqueued with capped backoff on a separate bounded counter instead of burning the merge-retry quota, so pure contention can't park a never-failed task. Retry backoff is capped at 60s. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 2140ea3496..7986396445 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1319,12 +1319,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: agentStore, }); const latest = await store.getTask(taskId).catch(() => mergeTask!); - // U1 does not finalize the workspace task (finalize-once move-to-done is U2); - // report merged=false until then. + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B3): + // landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2), + // so the merge door must report merged=true when the workspace fully landed — mirroring + // the engine dispatch's MergeResult. The first landed sub-repo's landedSha is the recorded + // commitSha (same convention finalizeWorkspaceTask uses). On a partial land, merged stays + // false and the partial-land error surfaces on the task log. + const landedSha = workspaceResult.repos.find((r) => r.status === "landed")?.landedSha; return { task: latest ?? mergeTask!, branch: getTaskBranchName(taskId), - merged: false, + merged: workspaceResult.allLanded, + mergeConfirmed: workspaceResult.allLanded || undefined, + commitSha: workspaceResult.allLanded ? landedSha : undefined, worktreeRemoved: false, branchDeleted: false, error: workspaceResult.allLanded ? undefined : "partial workspace land — see task log", diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 13054fcc7c..b763676d38 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -872,8 +872,13 @@ export async function runTaskMerge(id: string, projectName?: string) { : `failed: ${repo.error ?? "unknown"}`; console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`); } - // U1 does not move the workspace task to done (finalize-once is U2). - console.log(`\n ${workspaceResult.allLanded ? "✓ All sub-repos landed" : "✗ Partial land — see failures above"} (task remains in review until U2)\n`); + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B3): + // landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2), + // so report it as merged rather than "remains in review until U2". A partial land leaves + // the task in review (landed repos stay landed locally) and exits non-zero. + console.log( + `\n ${workspaceResult.allLanded ? "✓ All sub-repos landed — task finalized to done" : "✗ Partial land — see failures above (task remains in review; landed repos stay landed locally)"}\n`, + ); if (!workspaceResult.allLanded) process.exit(1); return; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d8bb99bb91..2297d28144 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; -export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, WorkspaceTaskMergeError } from "./types.js"; +export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, sanitizeBranchSegment, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 98c69d02ad..581ac8dd51 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2662,14 +2662,28 @@ export class WorkspaceTaskMergeError extends Error { * @param task the task about to enter a merge path */ export function assertNotWorkspaceTaskMerge(task: Pick): void { - const worktrees = task.workspaceWorktrees; - if (worktrees && Object.keys(worktrees).length > 0) { + if (isWorkspaceTask(task)) { throw new WorkspaceTaskMergeError( `Workspace task ${task.id} cannot merge until per-repo merge support (master-plan U6) lands`, ); } } +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B5/B7-dep — canonical workspace predicate): +A workspace-mode task is identified by having at least one `workspaceWorktrees` entry +(one git worktree per sub-repo). This single predicate replaces the inlined +`!!task.workspaceWorktrees && Object.keys(task.workspaceWorktrees).length > 0` that was +copy-pasted across the engine merge dispatch and the merge-confirmed reachability fast-path +(B2). It lives in @fusion/core so the engine, store, and CLI doors share ONE definition. +The dashboard keeps its own local `isWorkspaceTask` (WorkspaceWorktreesSummary, UI-only) — +this core export is for engine/CLI use. +*/ +export function isWorkspaceTask(task: Pick): boolean { + const worktrees = task.workspaceWorktrees; + return !!worktrees && Object.keys(worktrees).length > 0; +} + export type RetrySummary = { stuckKill: number; recovery: number; diff --git a/packages/engine/src/__tests__/active-session-registry.test.ts b/packages/engine/src/__tests__/active-session-registry.test.ts index 03ae481228..a04a46b74d 100644 --- a/packages/engine/src/__tests__/active-session-registry.test.ts +++ b/packages/engine/src/__tests__/active-session-registry.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { activeSessionRegistry, reconcileSelfOwnedActiveSessionForRemoval, + ActiveSessionPathHeldByForeignTaskError, } from "../active-session-registry.js"; describe("activeSessionRegistry", () => { @@ -28,15 +29,27 @@ describe("activeSessionRegistry", () => { expect(activeSessionRegistry.lookupByPath("/tmp/missing")).toBeNull(); }); - it("overwrites duplicate registration with warning", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + // FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware lease across kinds): + // registerPath must NOT silently clobber an entry held by a DIFFERENT task (that was the + // cross-phase clobber bug: a merging task's land lease overwriting an executing task's + // acquire lease on a shared sub-repo). A foreign-task overwrite now THROWS; the existing + // foreign holder is preserved. + it("rejects a foreign-task overwrite (does not clobber the held entry)", () => { activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" }); - activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "workflow-step", ownerKey: "FN-2#workflow-step" }); + expect(() => + activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "workflow-step", ownerKey: "FN-2#workflow-step" }), + ).toThrow(ActiveSessionPathHeldByForeignTaskError); + // The original holder is untouched. + expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-1"); + }); - expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-2"); - expect(warnSpy).toHaveBeenCalledOnce(); - - warnSpy.mockRestore(); + // Same-task re-registration stays idempotent (an executor re-claiming/refreshing its own path). + it("allows same-task re-registration (idempotent re-claim)", () => { + activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" }); + expect(() => + activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "step-session", ownerKey: "FN-1#step-session" }), + ).not.toThrow(); + expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.kind).toBe("step-session"); }); it("reconcileStaleSelfOwned returns no-entry when path is unregistered", () => { diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index a613fda48b..d88bdd9483 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Task } from "@fusion/core"; import { ProjectEngine, __resetDeterministicMergerModeDeprecationWarned } from "../project-engine.js"; +// Resolves to the vi.mock factory above (the mocked merger-ai exports the real-shaped +// workspace land error classes so the dispatch's `instanceof` matching is exercised). +import { WorkspacePartialLandError, WorkspaceRepoLandBusyError } from "../merger-ai.js"; import { runtimeLog } from "../logger.js"; import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js"; import { NtfyNotifier } from "../notifier.js"; @@ -19,6 +22,7 @@ const mocks = vi.hoisted(() => ({ runtimeStop: vi.fn(async () => undefined), runtimeResumeAfterUnpause: vi.fn(async () => undefined), runAiMerge: vi.fn(), + landWorkspaceTask: vi.fn(), execFile: vi.fn(), currentStore: null as Record | null, notifierStart: vi.fn(async () => undefined), @@ -69,9 +73,42 @@ vi.mock("../merger.js", () => ({ VerificationError: class VerificationError extends Error {}, })); -vi.mock("../merger-ai.js", () => ({ - runAiMerge: mocks.runAiMerge, -})); +// FNXC:Workspace 2026-06-22-05:10 (Phase C review B7): the dispatch now matches the +// workspace land errors via `instanceof`, and routes workspace tasks through +// `landWorkspaceTask`. The mock must export REAL error classes (so `instanceof` is callable) +// and a mockable `landWorkspaceTask`; otherwise `err instanceof WorkspacePartialLandError` +// throws "not callable" and the workspace dispatch can't be exercised. The classes are +// declared INSIDE the (hoisted) factory so they exist when the mock is evaluated. +vi.mock("../merger-ai.js", () => { + class WorkspaceRepoLandBusyError extends Error { + public readonly retryable = true; + constructor( + public readonly repoRel: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super(`workspace sub-repo ${repoRel} land is in progress for task ${holderTaskId}`); + this.name = "WorkspaceRepoLandBusyError"; + } + } + class WorkspacePartialLandError extends Error { + public readonly retryable = true; + constructor( + public readonly landedCount: number, + public readonly failedRepos: string[], + message: string, + ) { + super(message); + this.name = "WorkspacePartialLandError"; + } + } + return { + runAiMerge: mocks.runAiMerge, + landWorkspaceTask: mocks.landWorkspaceTask, + WorkspaceRepoLandBusyError, + WorkspacePartialLandError, + }; +}); vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); @@ -1295,7 +1332,11 @@ describe("ProjectEngine U0 merge unification dispatch", () => { } }); - it("R7 guard: rejects a workspace-mode task at the engine merge entry point before any merge", async () => { + // FNXC:Workspace 2026-06-22-05:10 (Phase C U1/U2 routing — supersedes the old R7 throw test): + // A workspace-mode task no longer throws WorkspaceTaskMergeError at the engine dispatch; it + // ROUTES to the per-repo land loop `landWorkspaceTask` (runAiMerge's R7 chokepoint stays as + // defense-in-depth but is not the primary path). On a full land, the merge reports merged=true. + it("routes a workspace-mode task to landWorkspaceTask (not runAiMerge) on full land", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValue({ id: "FN-WS", @@ -1303,58 +1344,217 @@ describe("ProjectEngine U0 merge unification dispatch", () => { paused: false, mergeRetries: 0, status: "queued", + branch: "fusion/fn-ws", workspaceWorktrees: { "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" }, "repo-b": { worktreePath: "/tmp/b", branch: "fusion/fn-ws-b" }, }, } as any); mocks.currentStore = mockStore.store; + mocks.landWorkspaceTask.mockResolvedValue({ + allLanded: true, + repos: [ + { repo: "repo-a", status: "landed", landedSha: "aaaa1111", integrationBranch: "main" }, + { repo: "repo-b", status: "landed", landedSha: "bbbb2222", integrationBranch: "main" }, + ], + } as any); const engine = createEngine(); await engine.start(); - await expect(engine.onMerge("FN-WS")).rejects.toThrow( - /Workspace task FN-WS cannot merge until per-repo merge support \(master-plan U6\) lands/, - ); + const result = await engine.onMerge("FN-WS"); + expect(mocks.landWorkspaceTask).toHaveBeenCalled(); expect(mocks.runAiMerge).not.toHaveBeenCalled(); + expect(result.merged).toBe(true); + await engine.stop(); + }); +}); + +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B1/B2/B4/B5): +Merge DISPATCH hardening for workspace tasks. These drive the REAL ProjectEngine dispatch +catch via the mocked merger-ai seam (landWorkspaceTask + the real-shaped error classes), +asserting the failure modes the review flagged: fail-closed on getTask null (B1), the +merge-confirmed reachability fast-path skipping workspace tasks (B2), busy-contention not +burning the merge-retry quota (B4), and the capped backoff (B5). No real AI, no real git +for the fast-path (the gate's git is asserted NOT to run for workspace tasks). +*/ +describe("ProjectEngine workspace merge dispatch hardening (Phase C review)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const workspaceTask = (overrides: Record = {}) => ({ + id: "FN-WSH", + column: "in-review", + paused: false, + mergeRetries: 0, + status: "queued", + branch: "fusion/fn-wsh", + workspaceWorktrees: { + "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-wsh-a" }, + }, + ...overrides, + }); + + // B1: getTask returning null in the partial-land catch must FAIL CLOSED — no retry timer. + it("B1: partial land with getTask null fails closed (parks failed, no retry timer)", async () => { + vi.useFakeTimers(); + try { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + // First getTask (dispatch routing) returns the workspace task; the catch's getTask + // (after the throw) returns null to simulate a DB outage. + mockStore.store.getTask + .mockResolvedValueOnce(workspaceTask() as any) // dispatch routing read + .mockResolvedValueOnce(workspaceTask() as any) // canMergeTask sweep read (if any) + .mockResolvedValue(null as any); // catch-block read → DB outage + mocks.currentStore = mockStore.store; + mocks.landWorkspaceTask.mockRejectedValue( + new WorkspacePartialLandError(0, ["repo-a"], "Workspace partial land for FN-WSH: 0 landed, 1 failed"), + ); + + const engine = createEngine(); + await engine.start(); + const enqueueSpy = vi.spyOn( + engine as unknown as { internalEnqueueMerge: (id: string) => void }, + "internalEnqueueMerge", + ); + engine.enqueueMerge("FN-WSH"); + + // Drain microtasks until the catch parks the task (fail-closed path). + await vi.waitFor( + () => { + expect(mockStore.store.updateTask).toHaveBeenCalledWith( + "FN-WSH", + expect.objectContaining({ status: "failed" }), + ); + }, + { timeout: 2000, interval: 5 }, + ); + + // No retry timer was scheduled, and no re-enqueue happened: advancing all timers + // must not trigger another internalEnqueueMerge. + enqueueSpy.mockClear(); + await vi.advanceTimersByTimeAsync(120_000); + expect(enqueueSpy).not.toHaveBeenCalled(); + // It must NOT have incremented mergeRetries (it couldn't even read the row). + expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( + "FN-WSH", + expect.objectContaining({ mergeRetries: expect.anything(), status: null }), + ); + + await engine.stop(); + } finally { + vi.useRealTimers(); + } + }); + + // B2: a merged workspace task (mergeConfirmed + sub-repo commitSha) must SKIP the root-cwd + // reachability fast-path so it is finalized, not demoted/parked. + it("B2: merge-confirmed workspace task skips the root-cwd reachability gate (not demoted)", async () => { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + mockStore.store.getTask.mockResolvedValue( + workspaceTask({ + status: null, + mergeDetails: { + mergeConfirmed: true, + // A sub-repo squash sha — unreachable from the workspace ROOT cwd; the gate would + // (wrongly) clear mergeConfirmed and demote the task if it ran here. + commitSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + mergeTargetBranch: "main", + mergedAt: "2026-06-22T00:00:00.000Z", + }, + }) as any, + ); + mockStore.store.moveTask.mockResolvedValue( + workspaceTask({ column: "done" }) as any, + ); + mocks.currentStore = mockStore.store; + // If the gate ran, it would invoke `git cat-file`. Make any git call fail so a gate + // run would be observable (and would demote). We assert it is NOT called. + mocks.execFile.mockImplementation(( + _file: string, + _args: string[], + optionsOrCb: unknown, + callback?: (e: Error | null, r: { stdout: string; stderr: string }) => void, + ) => { + const cb = (typeof optionsOrCb === "function" ? optionsOrCb : callback) as ( + e: Error | null, + r: { stdout: string; stderr: string }, + ) => void; + cb(new Error("git should not be called for workspace fast-path"), { stdout: "", stderr: "" }); + return {} as never; + }); + + const engine = createEngine(); + await engine.start(); + engine.enqueueMerge("FN-WSH"); + + await vi.waitFor(() => { + expect(mockStore.store.emit).toHaveBeenCalledWith( + "task:merged", + expect.objectContaining({ merged: true }), + ); + }); + + // The reachability gate's `git cat-file` must NOT have run (workspace skip). + const gitCatFileCalls = (mocks.execFile.mock.calls as Array<[string, string[]]>).filter( + (c) => Array.isArray(c[1]) && c[1][0] === "cat-file", + ); + expect(gitCatFileCalls).toHaveLength(0); + // The task must NOT have been demoted (mergeConfirmed cleared / status failed). + expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( + "FN-WSH", + expect.objectContaining({ status: "failed" }), + ); await engine.stop(); }); - // Regression: the auto-merge park for a WorkspaceTaskMergeError must set status:"failed", - // not status:null. status:null + mergeRetries:0 passes every eligibility gate, so the - // cooldown sweep re-enqueues the task every tick → tight re-throw/re-park loop. status:"failed" - // makes canMergeTask short-circuit; manual retry still works (it bypasses canMergeTask). - it("R7 auto-merge park: workspace task is parked status:'failed' so it is not re-enqueued", async () => { - const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); - mockStore.store.getTask.mockResolvedValue({ - id: "FN-WS-AUTO", - column: "in-review", - paused: false, - mergeRetries: 0, - status: "queued", - workspaceWorktrees: { - "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" }, - }, - } as any); - mocks.currentStore = mockStore.store; - - const engine = createEngine(); - await engine.start(); - // Auto-merge path (no manual resolver): the R7 door guard throws before runAiMerge, - // and the dispatch catch parks the task. - engine.enqueueMerge("FN-WS-AUTO"); - await vi.waitFor(() => { - expect(mockStore.store.updateTask).toHaveBeenCalledWith( - "FN-WS-AUTO", - expect.objectContaining({ status: "failed", mergeRetries: 0 }), + // B4 + B5: repeated WorkspaceRepoLandBusyError re-enqueues with capped backoff WITHOUT + // consuming mergeRetries (pure contention does not park a never-failed task). + it("B4/B5: busy contention re-enqueues with capped backoff, never burns mergeRetries", async () => { + vi.useFakeTimers(); + try { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + mockStore.store.getTask.mockResolvedValue(workspaceTask() as any); + mocks.currentStore = mockStore.store; + mocks.landWorkspaceTask.mockRejectedValue( + new WorkspaceRepoLandBusyError("repo-a", "FN-OTHER", "FN-WSH"), ); - }); - expect(mocks.runAiMerge).not.toHaveBeenCalled(); - // Guard against regression to the re-enqueue loop (status:null park): - expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( - "FN-WS-AUTO", - expect.objectContaining({ status: null }), - ); - await engine.stop(); + + const engine = createEngine(); + await engine.start(); + const enqueueSpy = vi.spyOn( + engine as unknown as { internalEnqueueMerge: (id: string) => void }, + "internalEnqueueMerge", + ); + engine.enqueueMerge("FN-WSH"); + + // The busy catch logs a WorkspaceRepoLandBusy entry then schedules a backoff timer. + await vi.waitFor( + () => { + expect(mockStore.store.logEntry).toHaveBeenCalledWith( + "FN-WSH", + expect.stringContaining("busy"), + "WorkspaceRepoLandBusy", + ); + }, + { timeout: 2000, interval: 5 }, + ); + + // It must NOT have written any mergeRetries increment (busy ≠ real failure). + const burnedRetries = (mockStore.store.updateTask.mock.calls as Array<[string, Record]>) + .some((c) => c[0] === "FN-WSH" && typeof c[1]?.mergeRetries === "number"); + expect(burnedRetries).toBe(false); + + // Drive several busy re-enqueues; the backoff must stay capped at 60s. + enqueueSpy.mockClear(); + await vi.advanceTimersByTimeAsync(60_000); // first backoff (5s) fires → re-enqueue + expect(enqueueSpy).toHaveBeenCalledWith("FN-WSH"); + + await engine.stop(); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts index af9ed2e1af..fce5724b44 100644 --- a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -26,8 +26,19 @@ import { execSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import path from "node:path"; import type { Task, TaskStore } from "@fusion/core"; -import { landWorkspaceTask } from "../merger-ai.js"; -import { shouldRetryWorkspacePartialLand } from "../project-engine.js"; +import { landWorkspaceTask, WorkspacePartialLandError } from "../merger-ai.js"; +import { shouldRetryAutoMergeConflict } from "../project-engine.js"; + +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B6): +`shouldRetryWorkspacePartialLand` was collapsed into `shouldRetryAutoMergeConflict` via the +`skipAutoResolveCheck` flag (one place owns the resolveMaxAutoMergeRetries arithmetic). The +workspace partial-land decision is `shouldRetryAutoMergeConflict(retries, settings, { skipAutoResolveCheck: true })`. +*/ +const shouldRetryWorkspacePartialLand = ( + currentRetries: number, + settings: { maxAutoMergeRetries?: unknown } | null | undefined, +) => shouldRetryAutoMergeConflict(currentRetries, settings, { skipAutoResolveCheck: true }); import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; const describeIfGit = hasGit ? describe : describe.skip; @@ -314,6 +325,107 @@ describeIfGit("landWorkspaceTask — landed predicate + finalize-once + idempote }); }); +/* +FNXC:Workspace 2026-06-22-04:10 (Phase C review A1/A4/A5 — DB-failure resilience): +These drive the REAL `landWorkspaceTask` against the REAL two-repo fixture but inject a +store whose `updateTask` REJECTS on a chosen patch, exercising the persist-failure windows +that the review fixes close. No mock-the-world: the git lands are real; only the targeted +DB write is forced to fail. +*/ +describeIfGit("landWorkspaceTask — DB-failure resilience (Phase C review A1/A4/A5)", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("A1/A4: a persist-failure AFTER the ref advanced escalates to WorkspacePartialLandError (no silent continue); a retry skips the actually-landed repo (no double squash)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + + // A store that FAILS the landedSha persist (the workspaceWorktrees write) exactly once, + // then persists normally — simulating a transient DB hiccup in the A1 window. + let failLandedShaWrite = true; + const store = createStore(task); + const realUpdate = store.updateTask as unknown as (id: string, patch: Partial) => Promise; + (store as { updateTask: unknown }).updateTask = vi.fn(async (id: string, patch: Partial) => { + if (failLandedShaWrite && patch.workspaceWorktrees) { + failLandedShaWrite = false; + throw new Error("synthetic DB write failure (landedSha persist)"); + } + return realUpdate(id, patch); + }); + + const tipBefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + // First run: repo-a squashes + advances the ref, but the landedSha persist throws. + await expect( + landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }), + ).rejects.toBeInstanceOf(WorkspacePartialLandError); + + // The ref DID advance (the repo is actually landed) — but landedSha was NOT recorded. + const tipAfterFirst = fx.git("repo-a", "git rev-parse refs/heads/main"); + expect(tipAfterFirst).not.toBe(tipBefore); + expect(store.task.workspaceWorktrees!["repo-a"].landedSha).toBeUndefined(); + // Not finalized to done (the throw aborted before finalize). + expect(store.moveTaskCalls).toHaveLength(0); + // Status was reset off 'merging' before the throw escaped (A3). + expect(store.task.status ?? null).toBeNull(); + + // Retry: isRepoLanded's trailer ancestor-fallback (A1) recognises the actually-landed + // repo via its Fusion-Task-Id trailer and SKIPS it — the ref must NOT advance a 2nd time. + const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAfterFirst); // no double squash + expect(second.repos[0].alreadyLanded).toBe(true); + expect(second.allLanded).toBe(true); + expect(second.finalized).toBe(true); + }); + + it("A4: WorkspacePartialLandError is a real class (instanceof + retryable + payload)", () => { + const err = new WorkspacePartialLandError(2, ["repo-b"], "partial"); + expect(err).toBeInstanceOf(WorkspacePartialLandError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("WorkspacePartialLandError"); + expect(err.retryable).toBe(true); + expect(err.landedCount).toBe(2); + expect(err.failedRepos).toEqual(["repo-b"]); + }); + + it("A5: a rejecting mergeDetails persist aborts finalization (does NOT silently finalize on a stale row)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + + // Fail the mergeDetails write (the finalize TOCTOU window) — the landedSha write succeeds. + const store = createStore(task); + const realUpdate = store.updateTask as unknown as (id: string, patch: Partial) => Promise; + (store as { updateTask: unknown }).updateTask = vi.fn(async (id: string, patch: Partial) => { + if (patch.mergeDetails) { + throw new Error("synthetic DB write failure (mergeDetails)"); + } + return realUpdate(id, patch); + }); + + await expect( + landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }), + ).rejects.toThrow(/mergeDetails/); + + // Finalization aborted: the task was NOT moved done and no task:merged was emitted on a + // stale/unpersisted row. + expect(store.moveTaskCalls).toHaveLength(0); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + // Status was still reset off 'merging' (A3 finally runs before finalize). + expect(store.task.status ?? null).toBeNull(); + }); +}); + describe("workspace partial-land retry/park decision (engine seam, fake timers)", () => { beforeEach(() => vi.useFakeTimers()); afterAll(() => vi.useRealTimers()); diff --git a/packages/engine/src/__tests__/workspace-merger-lease.test.ts b/packages/engine/src/__tests__/workspace-merger-lease.test.ts index 074752aca4..b27e24de65 100644 --- a/packages/engine/src/__tests__/workspace-merger-lease.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-lease.test.ts @@ -269,4 +269,50 @@ describeIfGit("landWorkspaceTask — per-repo land lease (Phase C U3, KTD4)", () expect(retry.repos[0].status).toBe("landed"); expect(activeSessionRegistry.lookupByPath(repoAbs)).toBeNull(); }); + + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware lease across kinds): + A FOREIGN-task holder of ANY kind on the sub-repo path is contention for the land + busy-check — not only a "workspace-repo-land" holder. Here an EXECUTING task's + "workspace-repo-acquire" entry sits on the path; a MERGING task's land must FAST-FAIL + with WorkspaceRepoLandBusyError and must NOT clobber the foreign entry. + */ + it("a foreign-task acquire-lease holder is land contention (busy error) and is NOT clobbered", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); + const repoAbs = fx.repoPath("repo-a"); + + // An EXECUTING task (FN-9001) holds an acquire lease on the shared sub-repo path. + activeSessionRegistry.registerPath(repoAbs, { + taskId: "FN-9001", + kind: "workspace-repo-acquire", + ownerKey: "workspace-repo-acquire", + }); + const tipBefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + // The MERGING task (FN-3001) tries to land the SAME sub-repo. + const task = makeTask("FN-3001", { "repo-a": { worktreePath: repoAbs, branch: BRANCH } }); + const store = createStore(task); + + let landError: unknown; + try { + await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + } catch (err) { + landError = err; + } + + // Fast-failed with the retryable busy error — even though the holder kind differs. + expect(landError).toBeInstanceOf(WorkspaceRepoLandBusyError); + expect((landError as WorkspaceRepoLandBusyError).holderTaskId).toBe("FN-9001"); + // The foreign acquire entry was NOT clobbered — still owned by FN-9001, same kind. + const stillHeld = activeSessionRegistry.lookupByPath(repoAbs); + expect(stillHeld?.taskId).toBe("FN-9001"); + expect(stillHeld?.kind).toBe("workspace-repo-acquire"); + // The merging task advanced NOTHING and its status was reset off 'merging' (A3). + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipBefore); + expect(store.task.status ?? null).toBeNull(); + }); }); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index 75c3b226eb..f560e25388 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -56,12 +56,46 @@ export type SelfOwnedReconcileOutcome = */ export const DEFAULT_SELF_OWNED_MIN_IDLE_MS = 5000; +/* +FNXC:Workspace 2026-06-22-04:10 (Phase C review A2): +Thrown by registerPath when a register would overwrite an entry held by a DIFFERENT +task on the same path. Surfacing this (rather than silently clobbering) is what stops a +merging task's land lease from yanking an executing task's acquire lease on a shared +sub-repo. Same-task re-registration is allowed and never throws. +*/ +export class ActiveSessionPathHeldByForeignTaskError extends Error { + constructor( + public readonly path: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super( + `active-session path ${path} is held by task ${holderTaskId}; task ${requestingTaskId} may not overwrite it`, + ); + this.name = "ActiveSessionPathHeldByForeignTaskError"; + } +} + export class ActiveSessionRegistry { private readonly records = new Map(); + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware lease across kinds): + registerPath previously OVERWROTE any existing entry on the path (only console.warn). + Because the land lease ("workspace-repo-land") and the execution acquire lease + ("workspace-repo-acquire") key the SAME sub-repo absolute path, an overwrite let a + MERGING task clobber an EXECUTING task's acquire-lease on a shared sub-repo (cross-phase + clobber). We now REJECT a register that would overwrite an entry held by a DIFFERENT + taskId — regardless of kind — by throwing. Only the SAME task may re-register its own + path (idempotent re-registration stays working; this is how an executor re-claims/refreshes + its own entry). Callers that may contend (the land lease) must lookupByPath-then-throw a + domain busy error BEFORE calling registerPath so they surface contention as a retryable + condition rather than this raw guard throw; this guard is the last-line safety net. + */ registerPath(worktreePath: string, registration: ActiveSessionRegistration): void { - if (this.records.has(worktreePath)) { - console.warn(`[active-session-registry] overwriting existing registration for ${worktreePath}`); + const existing = this.records.get(worktreePath); + if (existing && existing.taskId !== registration.taskId) { + throw new ActiveSessionPathHeldByForeignTaskError(worktreePath, existing.taskId, registration.taskId); } this.records.set(worktreePath, { ...registration, diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e9a55f5a18..43dbf72e14 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -195,6 +195,13 @@ export { runAiMerge } from "./merger-ai.js"; export { landWorkspaceTask, landOneRepo, + // FNXC:Workspace 2026-06-22-04:10 (Phase C review A6): canonical landed predicate, + // re-exported so Phase D self-healing reuses it instead of reimplementing the ancestor check. + isRepoLanded, + // FNXC:Workspace 2026-06-22-04:10 (Phase C review A4): real error classes (instanceof-able), + // re-exported so the engine dispatch can switch to instanceof in the separate pass. + WorkspaceRepoLandBusyError, + WorkspacePartialLandError, type WorkspaceMergeResult, type WorkspaceRepoLandResult, type LandOneRepoResult, diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index e2dd4c6291..a9f66a45c8 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -99,6 +99,19 @@ async function gitOk(args: string[], cwd: string): Promise { } } +/** + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1): + * Capture git stdout, returning undefined (never throwing) on failure — for read-only + * probes (merge-base, log --grep) where a non-zero exit is an expected "not found". + */ +async function gitCapture(args: string[], cwd: string): Promise { + try { + return await git(args, cwd); + } catch { + return undefined; + } +} + function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -1445,6 +1458,34 @@ export class WorkspaceRepoLandBusyError extends Error { } } +/* +FNXC:Workspace 2026-06-22-04:10 (Phase C review A4 — real WorkspacePartialLandError class): +Previously the partial-land signal was a bare `new Error()` with `.name` patched in +project-engine.ts (a footgun: no instanceof, no typed payload). It is now a real exported +class so the dispatch can switch to `instanceof` (separate pass) and tests can assert +`instanceof`. `retryable = true` because a partial land is recoverable — the landed repos' +`landedSha` is persisted and a re-run skips them (the U2 idempotency contract). + +`landWorkspaceTask` throws this from ONE place: the A1 persist-after-advance failure window +(the integration ref ALREADY advanced but `persistRepoLandedSha` could not record the +`landedSha`). The ORDINARY partial land (repo A landed, repo B's land failed) still RETURNS +`allLanded:false` — that return-based contract is what the engine dispatch and the oracle +workspace-merger tests already consume; only the persist-failure window escalates to a throw +so the engine parks/retries and A1's `isRepoLanded` ancestor-fallback skips the actually-landed +repo on retry (no double-squash). +*/ +export class WorkspacePartialLandError extends Error { + public readonly retryable = true; + constructor( + public readonly landedCount: number, + public readonly failedRepos: string[], + message: string, + ) { + super(message); + this.name = "WorkspacePartialLandError"; + } +} + export async function landWorkspaceTask( store: TaskStore, task: Task, @@ -1482,6 +1523,18 @@ export async function landWorkspaceTask( let allLanded = true; await setStatus("merging"); + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A3 — status 'merging' must never leak): + The busy-throw (WorkspaceRepoLandBusyError) and the persist-failure throw + (WorkspacePartialLandError) exit the loop BEFORE the post-loop `setStatus(null)`. If the + engine catch never runs (process crash between throw and catch) the task stays stuck + 'merging' with no manual door to clear it. Wrap the whole per-repo loop so `setStatus(null)` + ALWAYS runs (in finally) before ANY throw escapes. The success path still finalizes to done + AFTER this finally (finalizeWorkspaceTask sets its own column/status), so clearing 'merging' + first is safe — finalize overwrites it. This finally only clears the transient merge status; + it does not move the task. + */ + try { for (const repoRel of repoKeys) { throwIfAborted(options.signal, taskId); const entry = workspaceWorktrees[repoRel]; @@ -1508,7 +1561,7 @@ export async function landWorkspaceTask( // ancestor of (or equals) its CURRENT integration tip is already landed — SKIP // it so a retry never re-advances the ref. This makes a re-run after a partial // land idempotent for the already-landed repos. - if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha)) { + if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, taskId, entry.branch)) { await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, @@ -1526,15 +1579,19 @@ export async function landWorkspaceTask( interleaved await would let a second task pass the gate before we register. If another task holds the land lease we FAST-FAIL with a retryable busy error; the U2 dispatch auto-retry/park path handles it (no waiting lock reimplemented here). - We only treat a HELD entry of OUR OWN land ownerKey as contention, so a stale - entry of a different kind on this path (e.g. a leftover acquire entry) is ignored. + + FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware contention across kinds): + Previously we only treated a HELD entry of OUR OWN land ownerKey as contention, so a + MERGING task would registerPath-OVERWRITE an EXECUTING task's "workspace-repo-acquire" + entry on a shared sub-repo (cross-phase clobber). Now ANY foreign-task holder on this + path — regardless of kind (acquire OR land OR anything else) — is contention: we throw + WorkspaceRepoLandBusyError so the engine retries when the other task releases its hold. + A SAME-task holder is NOT contention (idempotent re-claim of our own path). The + registerPath guard (A2b) backstops this: it also rejects a foreign-task overwrite, so a + missed check can never silently clobber. */ const landLeaseHolder = activeSessionRegistry.lookupByPath(repoRootDir); - if ( - landLeaseHolder && - landLeaseHolder.ownerKey === WORKSPACE_REPO_LAND_OWNER_KEY && - landLeaseHolder.taskId !== taskId - ) { + if (landLeaseHolder && landLeaseHolder.taskId !== taskId) { throw new WorkspaceRepoLandBusyError(repoRel, landLeaseHolder.taskId, taskId); } activeSessionRegistry.registerPath(repoRootDir, { @@ -1551,10 +1608,32 @@ export async function landWorkspaceTask( allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, }); if (landResult.outcome === "landed") { - // Persist this repo's landedSha BEFORE moving on (fresh-read-then-merge so - // sibling entries written by a concurrent path are not clobbered). The retry - // predicate above reads this back to skip the repo on a re-run. - await persistRepoLandedSha(store, taskId, repoRel, landResult.squashSha); + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — persist-after-advance is a HARD failure): + The integration ref has ALREADY advanced (squash landed) by the time we persist + `landedSha`. If the DB write fails here the ref is advanced but UNRECORDED — we must NOT + silently continue (a return-based partial would let a retry double-squash). Escalate to a + retryable WorkspacePartialLandError so the engine parks/retries; on retry, `isRepoLanded`'s + trailer ancestor-fallback recognises this actually-landed repo and skips it. The repo IS + recorded as `landed` in the in-memory result first so the error payload is accurate. + */ + try { + await persistRepoLandedSha(store, taskId, repoRel, landResult.squashSha); + } catch (persistErr: unknown) { + const pmsg = getErrorMessage(persistErr); + await log(`AI merge (workspace): sub-repo ${repoRel} landed (${short(landResult.squashSha)}) but persisting landedSha FAILED: ${pmsg} — escalating to partial land so a retry can recover (ref already advanced; retry will skip via trailer ancestor-check)`); + repos.push({ + repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, + status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync, + }); + allLanded = false; + const landedCount = repos.filter((r) => r.status === "landed").length; + throw new WorkspacePartialLandError( + landedCount, + [repoRel], + `Workspace land for ${taskId}: sub-repo ${repoRel} advanced its integration ref but the landedSha persist failed (${pmsg}); retry to record/skip it`, + ); + } repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync, @@ -1563,6 +1642,9 @@ export async function landWorkspaceTask( repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "empty" }); } } catch (err: unknown) { + // A WorkspacePartialLandError from the persist-failure window above must PROPAGATE + // (the engine parks/retries). The outer try/finally below resets status first (A3). + if (err instanceof WorkspacePartialLandError) throw err; const message = getErrorMessage(err); await log(`AI merge (workspace): sub-repo ${repoRel} land failed: ${message}`); await audit.git({ type: "merge:ai-no-branch", target: entry.branch, metadata: { taskId, kind: "workspace-repo-land-failed", repo: repoRel, error: message } }).catch(() => undefined); @@ -1586,8 +1668,12 @@ export async function landWorkspaceTask( } } } - - await setStatus(null); + } finally { + // A3: clear the transient 'merging' status before ANY throw (busy / partial-land / + // abort) escapes, AND on the normal fall-through. The success path's finalize below + // re-sets the task's column/status to done, so clearing here first is safe. + await setStatus(null); + } // U2 finalize-once (KTD3): move the task to `done` EXACTLY ONCE, only after EVERY // acquired repo's landed predicate holds (all landed/empty, none failed). Reuse the @@ -1609,18 +1695,65 @@ export async function landWorkspaceTask( * the landed commit is still reachable, so the repo stays "landed". A `landedSha` that * is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and * the repo re-lands. + * + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — task-trailer ancestor fallback): + * The double-land window: a land advances the integration ref via `advanceIntegrationBranchRef`'s + * CAS, then `persistRepoLandedSha` records `landedSha`. If that DB write fails AFTER the ref + * advanced, the repo is ACTUALLY landed but has NO recorded `landedSha`, so the landedSha check + * above reports NOT-landed → a retry re-runs `landOneRepo`, the CAS rebuilds, and a SECOND squash + * lands (not idempotent). To close the window we ALSO treat the repo as landed when the live + * integration ref carries a commit with THIS task's `Fusion-Task-Id` trailer. + * + * Why a trailer scan and NOT a branch-tip ancestor check: the land is a `git merge --squash`, + * whose squash commit's parent is the integration tip, NOT the task branch — so `merge-base + * --is-ancestor ` is FALSE even right after a successful land. The + * `Fusion-Task-Id` trailer (always stamped onto the squash by `taskTrailers` + the + * ensureTaskMetadata safety net) is the only reliable "this task's work is already on the ref" + * signal that does not depend on the landedSha row, so it is what survives a lost persist. We + * bound the scan to commits the integration tip has gained since the branch's merge-base (the + * land base) so an unrelated historical reuse of the same trailer cannot false-positive. + * + * Exported (A6) so Phase D self-healing reuses THIS canonical predicate instead of + * reimplementing the ancestor/trailer check. */ -async function isRepoLanded( +export async function isRepoLanded( repoRootDir: string, integrationBranch: string, landedSha: string | undefined, + taskId?: string, + branch?: string, ): Promise { - if (!landedSha) return false; - if (!(await gitOk(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir))) { + const intRef = `refs/heads/${integrationBranch}`; + if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { return false; } + // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. - return await gitOk(["merge-base", "--is-ancestor", landedSha, `refs/heads/${integrationBranch}`], repoRootDir); + if ( + landedSha && + (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) + ) { + return true; + } + // A1 fallback: even without a recorded landedSha, the repo is already landed if the + // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash + // we lost the persist for). Bound the scan to commits gained since the branch's land base + // so a stale historical trailer of the same id cannot false-positive. + if (taskId) { + const branchRef = branch ? `refs/heads/${branch}` : undefined; + let range = intRef; + if (branchRef && (await gitOk(["rev-parse", "--verify", branchRef], repoRootDir))) { + const base = await gitCapture(["merge-base", branchRef, intRef], repoRootDir); + if (base) range = `${base.trim()}..${intRef}`; + } + const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`; + const found = await gitCapture( + ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], + repoRootDir, + ); + if (found && found.trim().length > 0) return true; + } + return false; } /** @@ -1628,6 +1761,17 @@ async function isRepoLanded( * Persist one sub-repo's `landedSha` with a FRESH-read-then-merge so a concurrent * sibling-entry write is not clobbered (Phase A/B per-repo `workspaceWorktrees` * pattern). Re-read the latest task, merge only this repo's entry, write the whole map. + * + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — do NOT swallow the DB write): + * Previously the `store.updateTask(...)` was `.catch(() => undefined)`. That swallow is the + * double-land bug: the integration ref has ALREADY advanced by the time we persist, so a + * silently-lost write means `landedSha` is never recorded → on retry the landedSha check sees + * NOT-landed and re-runs the squash (a SECOND squash commit). We now PROPAGATE the write + * failure. The caller (`landWorkspaceTask`) catches it as a partial-land for this repo and + * escalates to `WorkspacePartialLandError` so the engine parks/retries; on retry, `isRepoLanded`'s + * trailer ancestor-fallback (A1) recognises the actually-landed repo and skips it (no double + * squash). We DELIBERATELY do not swallow the `getTask` read either-way: a failed read leaves + * `landedSha` unrecorded for the same reason, so it must also escalate. */ async function persistRepoLandedSha( store: TaskStore, @@ -1635,12 +1779,12 @@ async function persistRepoLandedSha( repoRel: string, landedSha: string, ): Promise { - const latest = await store.getTask(taskId).catch(() => undefined); + const latest = await store.getTask(taskId); const current = latest?.workspaceWorktrees ?? {}; const entry = current[repoRel]; if (!entry) return; // entry vanished — nothing to merge into const next = { ...current, [repoRel]: { ...entry, landedSha } }; - await store.updateTask(taskId, { workspaceWorktrees: next }).catch(() => undefined); + await store.updateTask(taskId, { workspaceWorktrees: next }); } /** @@ -1663,14 +1807,28 @@ async function finalizeWorkspaceTask( const representative = landed.length > 0 ? landed[0].landedSha : undefined; const anyLanded = landed.length > 0; - // Pre-populate task.mergeDetails so finalizeTask's spread carries the workspace map. + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A5 — fresh-read + no-swallow finalize): + Two fixes to the FN-5627 TOCTOU class: + 1. The `task` argument is the SNAPSHOT captured at the START of `landWorkspaceTask`; by + finalize time the persisted row has gained each repo's `landedSha` (and possibly other + concurrent edits). Spreading the stale snapshot's mergeDetails could drop/clobber those. + Re-read the LATEST task and spread ITS mergeDetails (fresh-read-then-merge), falling back + to the snapshot only if the read fails. + 2. The `store.updateTask(...)` was `.catch(() => undefined)` — a swallowed write left the + in-memory `mergeConfirmed:true` while the persisted row stayed stale (the finalize would + then report done with an unpersisted merge). PROPAGATE the failure so finalization aborts + and self-healing recovers, rather than silently finalizing on a stale row. + */ + const fresh = await store.getTask(taskId).catch(() => undefined); + const baseMergeDetails = fresh?.mergeDetails ?? task.mergeDetails; const mergeDetails: MergeDetails = { - ...task.mergeDetails, + ...baseMergeDetails, ...(representative ? { commitSha: representative } : {}), ...(anyLanded ? { workspaceLandedShas } : {}), mergeConfirmed: anyLanded, }; - await store.updateTask(taskId, { mergeDetails }).catch(() => undefined); + await store.updateTask(taskId, { mergeDetails }); task.mergeDetails = mergeDetails; const result: MergeResult = { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 6c9d9eff2e..5152a36fc1 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -13,7 +13,7 @@ import type { ResearchSynthesisRequest, ResearchSynthesisResult, } from "@fusion/core"; -import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; +import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, isWorkspaceTask, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -31,7 +31,7 @@ import { createFusionAuthStorage, getFusionOAuthAlertStatePath } from "./auth-st import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { sweepStaleAutostashes, VerificationError } from "./merger.js"; -import { runAiMerge, landWorkspaceTask } from "./merger-ai.js"; +import { runAiMerge, landWorkspaceTask, WorkspacePartialLandError, WorkspaceRepoLandBusyError } from "./merger-ai.js"; import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn, type SyncGroupPrFn } from "./group-merge-coordinator.js"; import { PRIORITY_MERGE } from "./concurrency.js"; import { runtimeLog } from "./logger.js"; @@ -125,35 +125,27 @@ function isInvalidDoneTransitionError(error: unknown): boolean { return message.includes("Invalid transition:") && message.includes("→ 'done'"); } +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B6 — unify partial-land retry seam): +The workspace PARTIAL-land retry decision (some sub-repos landed, one failed) is the SAME +arithmetic as the conflict-retry decision MINUS the `autoResolveConflicts` gate (a partial +land is retryable regardless of conflict-resolution settings, because the landed repos' +`landedSha` is persisted and a re-run skips them — U2 idempotency). To keep the +`resolveMaxAutoMergeRetries(settings)` arithmetic in ONE place we collapse the former +`shouldRetryWorkspacePartialLand` into this function via `skipAutoResolveCheck`. When set, +the `autoResolveConflicts` gate is bypassed; otherwise behavior is byte-identical to before. +`currentRetries + 1 < MAX` keeps the LAST attempt's failure parking in the same tick rather +than scheduling an Nth timer that a restart could strand. +*/ export function shouldRetryAutoMergeConflict( currentRetries: number, settings: { autoResolveConflicts?: boolean; maxAutoMergeRetries?: unknown } | null | undefined, + opts?: { skipAutoResolveCheck?: boolean }, ): { shouldRetry: boolean; maxAutoMergeRetries: number; nextRetryCount: number } { const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); + const autoResolveOk = opts?.skipAutoResolveCheck === true || settings?.autoResolveConflicts !== false; return { - shouldRetry: settings?.autoResolveConflicts !== false && currentRetries + 1 < maxAutoMergeRetries, - maxAutoMergeRetries, - nextRetryCount: currentRetries + 1, - }; -} - -/* -FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): -Pure retry/park decision for a workspace PARTIAL land (some sub-repos landed, one failed). -Mirrors `shouldRetryAutoMergeConflict` so the engine dispatch's partial-land catch branch -has a narrow, unit-testable seam: a partial land is RETRYABLE (the landed repos' `landedSha` -is persisted, so a re-run skips them and only the failed repo retries), so it CONSUMES a -mergeRetry and re-enqueues up to `resolveMaxAutoMergeRetries(settings)`, then OPERATOR-PARKS -(`shouldRetry:false`). `currentRetries + 1 < MAX` keeps the LAST attempt's failure parking -in the same tick rather than scheduling an Nth timer that a restart could strand. -*/ -export function shouldRetryWorkspacePartialLand( - currentRetries: number, - settings: { maxAutoMergeRetries?: unknown } | null | undefined, -): { shouldRetry: boolean; maxAutoMergeRetries: number; nextRetryCount: number } { - const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); - return { - shouldRetry: currentRetries + 1 < maxAutoMergeRetries, + shouldRetry: autoResolveOk && currentRetries + 1 < maxAutoMergeRetries, maxAutoMergeRetries, nextRetryCount: currentRetries + 1, }; @@ -370,6 +362,19 @@ export class ProjectEngine { private autostashSweepTimer: ReturnType | null = null; private mergeActiveReconcileTimer: ReturnType | null = null; + /* + FNXC:Workspace 2026-06-22-05:10 (Phase C review B4 — separate busy-retry quota): + Transient sub-repo land-lease contention (WorkspaceRepoLandBusyError) must NOT burn the + persisted `mergeRetries` quota — two tasks contending for the same sub-repo could otherwise + exhaust all retries on pure busy-errors before a single real land attempt, then park a + never-failed task. We track busy re-enqueues in this in-memory, per-task counter (transient + contention need not survive a restart) and CAP it separately from `mergeRetries`. A real + partial land (WorkspacePartialLandError) still consumes `mergeRetries` up to MAX, then parks. + Cleared on the first non-busy outcome (success path resets it). + */ + private workspaceBusyReenqueues = new Map(); + private static readonly WORKSPACE_BUSY_MAX_REENQUEUES = 10; + /** * Pending manual merge resolvers — keyed by taskId. * When `onMerge` is called, the task is enqueued like auto-merge but a @@ -1866,6 +1871,19 @@ export class ProjectEngine { // in-review by auto-recovery after a successful merge) — just // complete the task without re-running the merge process. if (task.mergeDetails?.mergeConfirmed) { + /* + FNXC:Workspace 2026-06-22-05:10 (Phase C review B2 — fast-path must skip workspace tasks): + The FN-5627 reachability gate below runs `git cat-file -e ` in cwd = the + project/workspace ROOT. For a WORKSPACE task, `finalizeWorkspaceTask` records + `mergeDetails.commitSha` = the FIRST sorted sub-repo's squash sha, which lives in + `join(workspaceRoot, )`, NOT in the workspace root (which is not even a git repo). + So `cat-file -e` against the root cwd ALWAYS reports commit-missing → the gate would + clear `mergeConfirmed` and demote/park a FULLY-MERGED workspace task. Workspace tasks + are merge-verified by each sub-repo's persisted `landedSha`, not a single root-cwd + commitSha, so the root-cwd reachability gate does not apply to them. SKIP the gate for + workspace tasks and take the fast-path. (Per-sub-repo cwd reachability verification is a + larger change deferred past Phase C; skipping here is the correct minimal fix.) + */ // FN-5627: Reachability defense-in-depth. The merger has a TOCTOU // window where `mergeConfirmed: true` can be persisted to the task // row before `git update-ref refs/heads/` actually @@ -1890,6 +1908,7 @@ export class ProjectEngine { `Auto-merge: ${taskId} merge-confirmed fast-path rerouting shared-group member from ${task.mergeDetails.mergeTargetBranch} to ${routedFastPathTarget}`, ); } + if (!isWorkspaceTask(task)) { const reachability = await verifyMergeConfirmedReachability({ commitSha: task.mergeDetails.commitSha, integrationBranch: integrationBranchForGate, @@ -2032,6 +2051,7 @@ export class ProjectEngine { this.internalEnqueueMerge(taskId); continue; } + } // end !isWorkspaceTask reachability gate (B2): workspace tasks skip the root-cwd commitSha check const blockerReason = getTaskHardMergeBlocker({ ...(task as Task), // Merge-confirmed tasks have already landed. Treat stale merge @@ -2320,8 +2340,7 @@ export class ProjectEngine { // routing falls through to runAiMerge, whose chokepoint guard re-reads // the task and is the authoritative workspace enforcement. const mergeTask = await store.getTask(taskId).catch(() => null); - const isWorkspaceMerge = - !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; + const isWorkspaceMerge = !!mergeTask && isWorkspaceTask(mergeTask); if (isWorkspaceMerge) { // FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): // Land each acquired sub-repo on its own local integration ref; @@ -2339,14 +2358,18 @@ export class ProjectEngine { { ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true }, ); if (!workspaceResult.allLanded) { + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B7): + // Throw the real exported WorkspacePartialLandError class (not a bare Error with + // a patched `.name`) so the catch below can match via `instanceof` and read the + // typed payload (landedCount, failedRepos). const failed = workspaceResult.repos.filter((r) => r.status === "failed"); const landedCount = workspaceResult.repos.filter((r) => r.status === "landed").length; const detail = failed.map((r) => `${r.repo}: ${r.error ?? "land failed"}`).join("; "); - const partialErr = new Error( + throw new WorkspacePartialLandError( + landedCount, + failed.map((r) => r.repo), `Workspace partial land for ${taskId}: ${landedCount} repo(s) landed, ${failed.length} failed — ${detail}`, ); - partialErr.name = "WorkspacePartialLandError"; - throw partialErr; } // Finalized to done by landWorkspaceTask; report the merge as merged so // the success path (retry reset + branch-group promotion) runs normally. @@ -2409,6 +2432,9 @@ export class ProjectEngine { if (latestTask?.mergeRetries && latestTask.mergeRetries > 0) { await store.updateTask(taskId, { mergeRetries: 0 }); } + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B4): clear the in-memory busy + // re-enqueue counter once the merge succeeds so a later unrelated contention starts fresh. + this.workspaceBusyReenqueues.delete(taskId); await attemptBranchGroupPromotion(latestTask); } @@ -2460,40 +2486,98 @@ export class ProjectEngine { continue; } + /* + FNXC:Workspace 2026-06-22-05:10 (Phase C review B4/B7 — busy contention split from real partial land): + A `WorkspaceRepoLandBusyError` (a second task holds the same sub-repo's land lease) is + TRANSIENT contention, not a land failure: re-enqueue it with backoff WITHOUT consuming the + persisted `mergeRetries` quota, bounded separately by `workspaceBusyReenqueues` + (WORKSPACE_BUSY_MAX_REENQUEUES). This stops two contending tasks from exhausting all merge + retries on busy-errors before either makes a real land attempt, then parking a never-failed + task. Detect via `instanceof` now that both are exported classes (B7). + */ + if (err instanceof WorkspaceRepoLandBusyError && !hasManualResolver) { + const busyCount = this.workspaceBusyReenqueues.get(taskId) ?? 0; + await store + .logEntry(taskId, `Workspace sub-repo land busy (contention): ${errorMsg}`, "WorkspaceRepoLandBusy") + .catch(() => undefined); + if (busyCount < ProjectEngine.WORKSPACE_BUSY_MAX_REENQUEUES) { + this.workspaceBusyReenqueues.set(taskId, busyCount + 1); + // Capped exponential backoff (B5): never exceed 60s even at the busy ceiling. + const delayMs = Math.min(5000 * Math.pow(2, busyCount), 60_000); + await store.updateTask(taskId, { status: null }).catch(() => undefined); + runtimeLog.log( + `Workspace land busy re-enqueue ${busyCount + 1}/${ProjectEngine.WORKSPACE_BUSY_MAX_REENQUEUES} for ${taskId} in ${delayMs / 1000}s (no mergeRetry consumed — pure lease contention)`, + ); + setTimeout(() => { + if (!this.shuttingDown) this.internalEnqueueMerge(taskId); + }, delayMs); + } else { + // Pathological sustained contention — surface but do NOT burn mergeRetries; park as + // failed so the cooldown sweep stops re-attempting and an operator can intervene. + this.workspaceBusyReenqueues.delete(taskId); + await store + .updateTask(taskId, { status: "failed", error: errorMsg }) + .catch(() => undefined); + runtimeLog.error( + `Auto-merge: ${taskId} workspace land busy ${ProjectEngine.WORKSPACE_BUSY_MAX_REENQUEUES} times — parked as failed (sustained sub-repo lease contention)`, + ); + } + continue; + } + // FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): // Workspace PARTIAL-LAND auto-retry-then-park (user decision). Unlike the R7 // WorkspaceTaskMergeError above (a permanent config error that must NOT burn // retries), a partial land — repo A landed, repo B failed — is RETRYABLE: the // landed repos' `landedSha` is persisted, so a re-run of `landWorkspaceTask` // skips them and re-attempts only the failed repo (idempotent). So this CONSUMES - // a `mergeRetry` and re-enqueues the merge with exponential backoff up to the + // a `mergeRetry` and re-enqueues the merge with capped exponential backoff up to the // existing MAX (resolveMaxAutoMergeRetries), then OPERATOR-PARKS (status:"failed") - // — mirroring the conflict-retry seam below. Detect by err.name (robust across - // the package boundary). Manual merges fall through to rejectMergeResolvers at - // the hasManualResolver early-return below (no auto-retry for manual). - /* - FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): - A `WorkspaceRepoLandBusyError` (a second task holds the same sub-repo's - land lease) is ALSO retryable here — it is transient contention, not a - terminal failure. Route it through the SAME auto-retry-then-park seam (it - consumes a mergeRetry and re-enqueues with backoff; a re-run skips - already-landed repos and finds the lease freed). Detect by err.name across - the package boundary, same as the partial-land error. - */ - const isWorkspacePartialLand = - err instanceof Error && - (err.name === "WorkspacePartialLandError" || err.name === "WorkspaceRepoLandBusyError"); - if (isWorkspacePartialLand && !hasManualResolver) { - const wsSettings = await store.getSettings().catch(() => ({ maxAutoMergeRetries: undefined })); + // — reusing the unified shouldRetryAutoMergeConflict seam with skipAutoResolveCheck + // (B6). Detect via `instanceof` (B7). Manual merges fall through to + // rejectMergeResolvers at the hasManualResolver early-return below. + if (err instanceof WorkspacePartialLandError && !hasManualResolver) { + const wsSettings = await store.getSettings().catch(() => null); const wsTask = await store.getTask(taskId).catch(() => null); - const wsRetries = wsTask?.mergeRetries ?? 0; - const decision = shouldRetryWorkspacePartialLand(wsRetries, wsSettings as { maxAutoMergeRetries?: unknown }); + /* + FNXC:Workspace 2026-06-22-05:10 (Phase C review B1 — fail closed on getTask null): + If getTask returns null (DB outage), we CANNOT read `mergeRetries`. Defaulting to 0 + would make `shouldRetry` always true while the increment updateTask also fails against + the non-responsive DB → an indefinite setTimeout retry storm against a dead DB. FAIL + CLOSED: do not schedule a retry. Attempt a best-effort park to `failed`; if that write + also fails it throws away cleanly and the cooldown sweep (canMergeTask) will re-evaluate + once the DB recovers, rather than hammering it on a tight timer. + */ + if (!wsTask) { + runtimeLog.error( + `Auto-merge: ${taskId} workspace partial land but getTask failed (DB outage?) — failing closed, NOT scheduling a retry storm: ${errorMsg}`, + ); + await store + .logEntry( + taskId, + `Workspace partial land — task state unreadable (DB error); parking as failed instead of scheduling a retry storm: ${errorMsg}`, + "WorkspacePartialLand", + ) + .catch(() => undefined); + await store + .updateTask(taskId, { status: "failed", error: errorMsg }) + .catch(() => undefined); + continue; + } + const wsRetries = wsTask.mergeRetries ?? 0; + const decision = shouldRetryAutoMergeConflict( + wsRetries, + wsSettings as { autoResolveConflicts?: boolean; maxAutoMergeRetries?: unknown } | null, + { skipAutoResolveCheck: true }, + ); await store .logEntry(taskId, `Workspace partial land: ${errorMsg}`, "WorkspacePartialLand") .catch(() => undefined); if (decision.shouldRetry) { await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }).catch(() => undefined); - const delayMs = 5000 * Math.pow(2, wsRetries); + // Capped exponential backoff (B5): cap at 60s so a tuned maxAutoMergeRetries doesn't + // push the delay toward ~85 minutes at the ceiling. + const delayMs = Math.min(5000 * Math.pow(2, wsRetries), 60_000); runtimeLog.log( `Workspace partial-land retry ${decision.nextRetryCount}/${decision.maxAutoMergeRetries} for ${taskId} in ${delayMs / 1000}s (re-runs skipping landed repos)`, ); From b591430e1236acdfd154e7ce919b69529b712e0f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:19:29 -0700 Subject: [PATCH 038/265] docs(workspace): Phase D plan (U8/U9 self-healing + e2e), forks resolved --- ...6-06-22-001-feat-workspace-phase-d-plan.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md diff --git a/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md b/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md new file mode 100644 index 0000000000..ceeaf86a6f --- /dev/null +++ b/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md @@ -0,0 +1,136 @@ +--- +title: "feat: Workspace mode Phase D — self-healing reconcilers + e2e harness" +status: active +date: 2026-06-22 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase D / U8·U9) +depth: deep +--- + +# feat: Workspace mode Phase D — self-healing reconcilers + e2e harness + +> **ID namespace:** local `U1·U2` decompose master-plan **U8, U9**. +> **Anchors feasibility-VERIFIED.** The pre-check found a P0 (an existing reconciler wrongly finalizes a partial-landed workspace task) and resolved all three forks — folded in below. + +## Summary + +Phase D closes the workspace-mode lifecycle. **The headline is not new reconcilers — it's making the EXISTING self-healing layer workspace-aware**, because Phase C's `status:"merging"` and the singular `task.worktree===null` shape make the current reconcilers either wrongly finalize or silently skip workspace tasks. Plus new reconcilers for partial-land recovery, phantom land-lease reclaim, and per-repo worktree cleanup, and an e2e harness proving the full lifecycle with no remote push. Final phase. + +Builds on Phase C (#1717): `landWorkspaceTask`, `isRepoLanded` (exported), `workspaceWorktrees[repo].landedSha`, the `workspace-repo-land` lease, `WorkspacePartialLandError`, the canonical `isWorkspaceTask`. + +**Stacking:** off Phase C; PR diff includes the whole stack; must not merge until it lands. + +--- + +## Problem Frame + +Phase C made workspace merges land-as-you-go, but the engine's self-healing reconcilers reason about a singular `task.worktree` + a single landed commit. Two are actively wrong/blind for workspace tasks, and three new states have no recovery: + +- **(P0) `recoverInterruptedMergingTasks` (self-healing.ts:6670) + `recoverStaleMergingStatus` (:2446)** act on any `ACTIVE_MERGE_STATUSES` task; `landWorkspaceTask` sets `"merging"` (merger-ai.ts:1525). If the holder dies after repo A lands, these call the **singular** `findLandedTaskCommit` (:1620, git over the non-git workspace `rootDir`) and on a one-repo hit **finalize the whole task to done + emit `task:merged`** — marking a partial-landed workspace task fully merged. +- **(P1) `recoverMergeableReviewTasks` (:5758)** filters on `Boolean(t.worktree)` (:5778) → a mergeable workspace task whose merge enqueue was dropped is **silently skipped forever**. +- New states with no recovery: a **partial-landed** stuck task, a **phantom `workspace-repo-land` lease** held by a dead task, and **orphaned per-repo worktrees**. +- **Triple-proof** (`evaluateBackwardMoveTripleProof` :820) classifies liveness via `task.worktree`/`canonicalFusionBranchName` — not workspace-aware (liveness lives across N sub-repo worktrees). + +--- + +## Key Technical Decisions + +### KTD1 — Make the EXISTING merging-status + mergeable-review reconcilers workspace-aware (P0/P1; master U8; FN-5893) +For an `isWorkspaceTask(task)` candidate: +- `recoverInterruptedMergingTasks` / `recoverStaleMergingStatus` must **NOT** use `findLandedTaskCommit`/single-commit finalize. Instead clear the transient `"merging"` status and decide via the **per-repo** `isRepoLanded` predicate: all repos landed → finalize once (the `finalizeWorkspaceTask` path); partial/none → re-enqueue (KTD3). Never finalize a workspace task on one repo's commit. +- `recoverMergeableReviewTasks` must admit `isWorkspaceTask` candidates (relax the `Boolean(t.worktree)` gate to `Boolean(t.worktree) || isWorkspaceTask(t)`), so a zero-landed mergeable workspace task is re-enqueued, not skipped. + +### KTD2 — New partial-land reconciler + workspace-aware liveness; re-enqueue via `enqueueMerge` (master U8; FORK-A resolved) +A new reconciler finds workspace tasks in a non-done state with a stale binding and re-enqueues the merge via **`this.options.enqueueMerge?.(task.id)`** (`SelfHealingOptions.enqueueMerge` :308, wired in-process-runtime.ts:795 → `internalEnqueueMerge` → routes workspace tasks to `landWorkspaceTask`) — **NOT a direct `landWorkspaceTask` call**. `landWorkspaceTask` is idempotent (`isRepoLanded` skips landed repos). Reuse `allowsAutoMergeProcessing` (task-merge.ts:62 — the canonical FN-5147 `autoMerge:false` guard) + user-pause + a **workspace-aware liveness predicate** (any sub-repo worktree active via `activeSessionRegistry.pathsForTask(task.id)` + `isPathActive`, since triple-proof isn't workspace-aware). Emits `task:reconcile-workspace-partial-land` (+ `-no-action`). +**FORK-A (unrecoverable):** a repo is unrecoverable iff its `fusion/` branch is gone **AND** `landedSha` is unset (nothing landed, nothing to land) → park `status:"failed"`. Branch gone but `landedSha` set → already landed (`isRepoLanded` ancestor check) → skip. Otherwise retryable. + +### KTD3 — Phantom `workspace-repo-land` lease reclaim via a new registry enumeration seam (master U8) +`ActiveSessionRegistry` exposes only `lookupByPath`/`isPathActive`/`pathsForTask` — no enumeration by kind, and a dead task is gone from the in-progress lists (so FN-6736's iterate-tasks approach can't surface a leaked lease). **Add an enumeration seam** `entriesByKind(kind)` → `{path, taskId, kind, registeredAt}[]` (`registeredAt` already tracked, active-session-registry.ts:31). The reconciler enumerates `workspace-repo-land` entries, and for each whose owner is terminal/dead AND `registeredAt` older than a floor (reuse the FN-6736 `graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER` analog, :966), clears it + emits `task:reclaim-phantom-workspace-land-lease`. + +### KTD4 — Per-repo worktree cleanup from the STORED paths, no directory walk (master U8; FORK-B resolved) +**FORK-B premise was wrong** — per-repo worktrees are not anonymous: `workspaceWorktrees[repo].worktreePath` is persisted (types.ts:2276). For a done/dead workspace task, read each recorded `worktreePath` and `git worktree remove --force` it, guarded by `activeSessionRegistry.isPathActive(path)` (mirroring self-healing.ts:9955). **No temp-root readdir/walk** (AGENTS.md) — bounded by construction. Emits `task:reconcile-orphaned-workspace-worktree`. + +### KTD5 — e2e harness placement: engine-default (`describeIfGit`), not the gate (master U9; FORK-C resolved) +The merge gate (`engine-core`) is an explicit allow-list excluding real-git tests — a real two-repo fixture e2e cannot run there. Model the **merge + recovery** e2e on `workspace-merger.test.ts` (unmarked, `describeIfGit`, engine-default lane): drive `landWorkspaceTask` directly + invoke the U1/KTD2 reconciler method directly with fake timers; assert local-ref advancement, **no push**, and partial-land recovery. Reuse the existing `executor-workspace-capture.test.ts` / `reviewer-workspace.test.ts` direct-call tests for the capture/review legs. Reserve a single `.slow.test.ts` (engine-slow lane) only if a full ProjectEngine acquire→capture→review→merge loop must be proven. + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace `; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (real two-repo fixture; fake timers; no mock-the-world; **no unbounded temp walk**); FN-5893 (the EXISTING reconcilers are in scope, not just new ones); the merge gate. Branch off Phase C (`gsxdsm/workspace-phase-d`). + +### U1. Workspace-aware self-healing (master U8) + +**Goal:** Make the existing reconcilers workspace-safe (P0/P1) and add partial-land recovery, phantom-lease reclaim, and per-repo worktree cleanup — none moving a human-gated/live task backward. + +**Requirements:** KTD1, KTD2, KTD3, KTD4. + +**Dependencies:** Phase C. + +**Files:** +- `packages/engine/src/self-healing.ts` — workspace-aware branches in `recoverInterruptedMergingTasks` (:6670), `recoverStaleMergingStatus` (:2446), `recoverMergeableReviewTasks` (:5758); the new partial-land reconciler (re-enqueue via `enqueueMerge`); the phantom-lease reclaim (via the new registry seam); the per-repo worktree cleanup; the workspace-aware liveness predicate. +- `packages/engine/src/active-session-registry.ts` — new `entriesByKind(kind)` enumeration seam. +- `packages/engine/src/run-audit.ts` — add the four literals to the `DatabaseMutationType` union (`task:reconcile-workspace-partial-land`, `-no-action`, `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`). +- `AGENTS.md` — add the new run-audit events to the Run Audit list. +- `packages/engine/src/__tests__/self-healing-workspace.test.ts` (new — real two-repo fixture). + +**Approach:** Per KTD1-KTD4. Reuse `allowsAutoMergeProcessing` + the workspace-aware liveness predicate as the "safe to move backward" gate; re-enqueue via `enqueueMerge`; mirror FN-6736 for the lease floor; cleanup from stored paths. + +**Test scenarios:** +- A partial-landed (repo A `landedSha`, repo B not) task stuck `"merging"` with no live holder → `recoverInterruptedMergingTasks` does **NOT** finalize it done; the partial-land reconciler re-enqueues; a later land completes it (skipping A). (P0 regression + recovery) +- A zero-landed mergeable workspace task whose merge was dropped → `recoverMergeableReviewTasks` re-enqueues it (not skipped by the `worktree` gate). (P1) +- `autoMerge:false` / user-paused / a live sub-repo worktree (via `pathsForTask`+`isPathActive`) → `-no-action` (not moved backward). (FN-5147 guards) +- A `workspace-repo-land` lease owned by a terminal/dead task, older than the floor → reclaimed; owned by a live merging task → untouched. (phantom reclaim) +- A done workspace task's recorded per-repo worktrees → removed (guarded by `isPathActive`); a live task's → untouched; **no temp-root walk**. (cleanup) +- A repo with branch gone + `landedSha` unset → parked failed; branch gone + `landedSha` set → skipped as landed. (FORK-A) +- Single-repo (non-workspace) tasks → all reconcilers behave identically. (regression) + +**Verification:** No reconciler wrongly finalizes/skips/moves-backward a workspace task; partial/phantom/orphan states recover; single-repo unchanged; no unbounded walk. + +### U2. End-to-end merge + recovery harness (master U9) + +**Goal:** Prove a real two-repo workspace task lands both repos on local refs with no push, and that partial-land recovers via U1. + +**Requirements:** KTD5. + +**Dependencies:** U1. + +**Files:** `packages/engine/src/__tests__/workspace-e2e.test.ts` (new — engine-default lane, `describeIfGit`, real two-repo fixture, fake timers). + +**Approach:** Per KTD5. Drive `landWorkspaceTask` on a real two-repo fixture; assert both local integration refs advanced, **no `refs/remotes` change / no push**, `landedSha` per repo, finalize-once. Partial-land: force repo B conflict → assert A landed + task not done, then invoke the U1 partial-land reconciler (fake timers) → assert recovery. Reference the existing `executor-workspace-capture` / `reviewer-workspace` tests for the capture/review legs (don't re-drive the full engine loop unless a `.slow` test is added). + +**Test scenarios:** +- Two repos land → both local refs advanced, **no push**, both `landedSha`, task done once. (e2e happy + no-push invariant) +- Partial-land → A landed, task not done → U1 reconciler → recovery completes. (e2e recovery) + +**Verification:** Real workspace task lands end-to-end with no remote push; partial-land self-heals. + +--- + +## Scope Boundaries + +**In scope:** workspace-aware existing reconcilers + the three new reconcilers (U1), the merge+recovery e2e (U2). + +### Deferred to Follow-Up Work +- Extracting `workspace-merger.ts`; per-sub-repo cwd reachability verification; store-level atomic per-repo merge (Phase-C residuals). +- A full ProjectEngine acquire→capture→review→merge `.slow` loop test (only if needed). +- Rich dashboard per-repo merge-status UI. Remote push of integration refs (out — D2/D5). + +--- + +## Risks & Dependencies + +- **R1 (P0-class) — wrongly finalizing/skipping/moving-backward a workspace task.** The whole point of U1. Mitigation: KTD1 fixes the two wrong/blind reconcilers; every reconciler reuses `allowsAutoMergeProcessing` + the workspace-aware liveness predicate + triple-proof analog; tests assert the `-no-action` + no-wrong-finalize paths. +- **R2 — unbounded temp walk.** Mitigation: KTD4 uses stored paths only; test asserts no walk. +- **R3 — e2e lane.** Mitigation: KTD5 places it in engine-default (`describeIfGit`), not the gate. +- **R4 — reconciler idempotency / double-act.** Mitigation: `isRepoLanded` + `enqueueMerge` idempotency. +- **Stacking:** off Phase C (#1717). + +--- + +## Sources & Research + +- Master plan (U8/U9, FN-5147/FN-6736). +- Phase-D feasibility pre-check (verified anchors: the P0 `recoverInterruptedMergingTasks`/`findLandedTaskCommit` finalize, `recoverMergeableReviewTasks` `Boolean(t.worktree)` gate :5778, `enqueueMerge` :308, no registry `entriesByKind`, stored `worktreePath`, engine-core gate allow-list, `allowsAutoMergeProcessing` :62, triple-proof :820, FN-6736 floor :966). +- Phase C (#1717): `isRepoLanded`, `landedSha`, the lease, `landWorkspaceTask`, `isWorkspaceTask`. +- `self-healing.ts`, `active-session-registry.ts`, `run-audit.ts`, `_workspace-fixture.ts`, `workspace-merger.test.ts` (the lane model). From 7cd204e4e515639a820d79606c64cf9010ce67a9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:37:59 -0700 Subject: [PATCH 039/265] =?UTF-8?q?feat(workspace):=20Phase=20D=20U1=20?= =?UTF-8?q?=E2=80=94=20workspace-aware=20self-healing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the self-healing layer workspace-aware and adds recovery for the states Phase C introduced. No reconciler may wrongly finalize, skip, or move a workspace task backward. Existing reconcilers (the P0/P1 the feasibility check caught): - recoverInterruptedMergingTasks no longer single-commit-finalizes a workspace task: a workspace candidate clears the transient "merging" status and re-enqueues via enqueueMerge (which routes to the idempotent landWorkspaceTask), never reaching findLandedTaskCommit / moveTask(done) / task:merged — so a partial-landed task (repo A landedSha, repo B not) is never marked fully merged on one repo's commit. recoverStaleMergingStatus confirmed single-commit-free. - recoverMergeableReviewTasks relaxes its Boolean(t.worktree) gate to also admit isWorkspaceTask(t), so a zero-landed mergeable workspace task (null worktree) is re-enqueued instead of silently skipped forever. New reconcilers: - reconcileWorkspacePartialLands: re-enqueues stuck non-done workspace merges via enqueueMerge (idempotent skip of landed repos), guarded by allowsAutoMergeProcessing (FN-5147), user-pause, and a workspace-aware liveness predicate (any sub-repo path active via pathsForTask+isPathActive — triple-proof isn't workspace-aware). A repo with its fusion/ branch gone AND landedSha unset is parked failed; branch-gone but landed is skipped. Emits task:reconcile-workspace-partial-land(+-no-action). - reclaimPhantomWorkspaceLandLeases: enumerates the new activeSessionRegistry entriesByKind("workspace-repo-land"), age-gated by the FN-6736 floor, and clears a lease whose owner is terminal/dead (live merging owners untouched). Emits task:reclaim-phantom-workspace-land-lease. - reconcileOrphanedWorkspaceWorktrees: removes a done workspace task's recorded per-repo worktreePaths (isPathActive-guarded) with NO temp-root walk (AGENTS.md). Emits task:reconcile-orphaned-workspace-worktree. New activeSessionRegistry.entriesByKind seam; four DatabaseMutationType literals + the AGENTS.md Run Audit list. Single-repo behavior byte-for-byte unchanged (every path branches on isWorkspaceTask). 13 new fixture tests; 558 self-healing tests + test:gate (649+58) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-phase-d-self-healing.md | 5 + AGENTS.md | 3 + .../__tests__/self-healing-workspace.test.ts | 417 ++++++++++++++++++ .../engine/src/active-session-registry.ts | 21 + packages/engine/src/run-audit.ts | 9 + packages/engine/src/self-healing.ts | 411 ++++++++++++++++- 6 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 .changeset/workspace-phase-d-self-healing.md create mode 100644 packages/engine/src/__tests__/self-healing-workspace.test.ts diff --git a/.changeset/workspace-phase-d-self-healing.md b/.changeset/workspace-phase-d-self-healing.md new file mode 100644 index 0000000000..6d412dd404 --- /dev/null +++ b/.changeset/workspace-phase-d-self-healing.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase D (U1): workspace-aware self-healing. The existing merging-status reconcilers no longer mis-finalize a partial-landed workspace task (recoverInterruptedMergingTasks now clears the transient `merging` status and re-enqueues the idempotent per-repo land instead of running the single-commit finalize over the non-git workspace root), and recoverMergeableReviewTasks now admits workspace tasks (task.worktree is null). Adds three reconcilers: partial-land recovery (re-enqueue via enqueueMerge, FORK-A unrecoverable → park failed; guarded by autoMerge:false + user-pause + workspace-aware liveness), phantom `workspace-repo-land` lease reclaim (new `entriesByKind` registry seam), and per-repo worktree cleanup from stored paths (no temp walk). New run-audit events: `task:reconcile-workspace-partial-land`(`-no-action`), `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`. diff --git a/AGENTS.md b/AGENTS.md index f68a9512d4..1e45475137 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,6 +195,9 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs. - FN-6782/FN-6796: self-healing emits `task:auto-recover-paused-abort-park` when it clears a benign pause-abort operator park, requeueing safe `todo`/`in-progress` rows or preserving a clean auto-merge-eligible `in-review` row for review progression. - FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move. +- Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` when a sub-repo's `fusion/` branch is gone with no `landedSha`), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, or a live sub-repo worktree (workspace-aware liveness) blocks that backward move. +- Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor (a live merging owner is left untouched). +- Workspace (Phase D U1): self-healing emits `task:reconcile-orphaned-workspace-worktree` when it removes a done/dead workspace task's recorded per-repo worktree from its stored `worktreePath` (guarded by `isPathActive`; no temp-root walk). ## Reference docs (deeper detail) diff --git a/packages/engine/src/__tests__/self-healing-workspace.test.ts b/packages/engine/src/__tests__/self-healing-workspace.test.ts new file mode 100644 index 0000000000..ecd32a7892 --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-workspace.test.ts @@ -0,0 +1,417 @@ +/* +FNXC:Workspace 2026-06-22-09:30 (Phase D U1 — workspace-aware self-healing): +Exercises the workspace-aware self-healing reconcilers against a REAL two-repo git fixture under +a NON-git workspace root (createWorkspaceFixture), so a leaked rootDir git preflight or a +single-commit finalize over the non-git root would actually fail. Real git is used only where the +invariant requires it (per-repo landedSha ancestor check, FORK-A branch-gone check, per-repo +worktree removal); fake timers drive the FN-6736 phantom-lease staleness floor. No mock-the-world +child_process, no unbounded temp walk, never touches port 4040. + +Surfaces (FN-5893): +- P0: a PARTIAL-landed workspace task stuck "merging" with no live holder → recoverInterruptedMergingTasks + does NOT finalize it done (no single-commit finalize); the partial-land reconciler re-enqueues. +- P1: a zero-landed mergeable workspace task → recoverMergeableReviewTasks re-enqueues (not skipped by worktree gate). +- guards: autoMerge:false / user-paused / a live sub-repo worktree → -no-action, not moved backward. +- phantom: a workspace-repo-land lease with a terminal owner older than the floor → reclaimed; live owner → untouched. +- cleanup: a done task's recorded per-repo worktrees → removed (isPathActive-guarded); no temp walk. +- FORK-A: branch-gone + landedSha-unset → parked failed; branch-gone + landedSha-set → skipped as landed. +- regression: a single-repo (non-workspace) task → reconcilers behave identically. +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Settings, Task, TaskStore } from "@fusion/core"; +import { SelfHealingManager } from "../self-healing.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; +import { landWorkspaceTask } from "../merger-ai.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-7001"; +const BRANCH = "fusion/fn-7001"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RecordingStore extends EventEmitter { + tasks: Map; + emitted: Array<{ event: string; payload: unknown }>; + enqueued: string[]; + updateTask: ReturnType; + moveTask: ReturnType; +} + +function createStore(rows: Task[], settings: Partial = {}): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const tasks = new Map(rows.map((t) => [t.id, t])); + const emitted: Array<{ event: string; payload: unknown }> = []; + const enqueued: string[] = []; + const realEmit = emitter.emit.bind(emitter); + const store = Object.assign(emitter, { + tasks, + emitted, + enqueued, + getSettings: vi.fn().mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000, ...settings } as unknown as Settings), + listTasks: vi.fn(async (opts?: { column?: string }) => { + const all = [...tasks.values()]; + return opts?.column ? all.filter((t) => t.column === opts.column) : all; + }), + getTask: vi.fn(async (id: string) => tasks.get(id) ?? null), + updateTask: vi.fn(async (id: string, patch: Partial) => { + const cur = tasks.get(id); + if (cur) tasks.set(id, { ...cur, ...patch } as Task); + return tasks.get(id) as Task; + }), + moveTask: vi.fn(async (id: string, column: string) => { + const cur = tasks.get(id); + const next = { ...(cur ?? { id }), column } as Task; + tasks.set(id, next); + return next; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), + peekMergeQueue: vi.fn().mockReturnValue([]), + getRootDir: vi.fn().mockReturnValue("/tmp/test"), + emit: (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return realEmit(event, payload); + }, + }) as unknown as TaskStore & RecordingStore; + return store; +} + +function makeManager(store: TaskStore, rootDir: string, opts: Record = {}): SelfHealingManager { + const enqueueMerge = (taskId: string) => { + (store as unknown as RecordingStore).enqueued.push(taskId); + return true; + }; + return new SelfHealingManager(store, { + rootDir, + enqueueMerge, + clearMergeActive: vi.fn(), + ...opts, + } as never); +} + +/** Add a real `fusion/` branch in a sub-repo with one non-conflicting own commit. */ +function addRepoBranch(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`); + configureIdentity(wt); + writeFileSync(path.join(wt, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); +} + +/** Land one sub-repo for real (squash onto main) and return its landedSha. */ +function landRepoForReal(fx: WorkspaceFixture, repoRel: string): string { + const repoDir = fx.repoPath(repoRel); + configureIdentity(repoDir); + execSync(`git merge --squash ${BRANCH}`, { cwd: repoDir, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): landed\n\nFusion-Task-Id: ${TASK_ID}"`, { cwd: repoDir, stdio: "pipe" }); + return fx.git(repoRel, "git rev-parse refs/heads/main"); +} + +function workspaceTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial = {}): Task { + return { + id: TASK_ID, + title: "Workspace task", + column: "in-review", + branch: BRANCH, + worktree: null, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + paused: false, + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + ...extra, + } as unknown as Task; +} + +describeIfGit("workspace-aware self-healing (Phase D U1)", () => { + let fx: WorkspaceFixture; + beforeEach(() => { + activeSessionRegistry.clear(); + }); + afterEach(() => { + activeSessionRegistry.clear(); + vi.useRealTimers(); + vi.clearAllMocks(); + fx?.cleanup(); + }); + + // ── KTD1 P0: partial-landed "merging" task must NOT be finalized done ────── + it("recoverInterruptedMergingTasks does NOT finalize a partial-landed workspace task (P0)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); // repo A landed; repo B NOT. + + const task = workspaceTask( + { + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }, + { status: "merging", updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() }, + ); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + await manager.recoverInterruptedMergingTasks(); + + // NOT finalized done; status cleared; never emitted task:merged on a single repo. + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + expect(store.tasks.get(TASK_ID)?.status).toBeNull(); + expect(store.tasks.get(TASK_ID)?.column).toBe("in-review"); + // It re-enqueued the per-repo land for idempotent completion. + expect(store.enqueued).toContain(TASK_ID); + }); + + it("partial-land reconciler re-enqueues a partial-landed workspace task", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); + + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + + expect(n).toBe(1); + expect(store.enqueued).toContain(TASK_ID); + // Not moved backward / not parked failed (repo B branch still exists → retryable). + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + }); + + // ── KTD1 P1: zero-landed mergeable workspace task admitted ───────────────── + it("recoverMergeableReviewTasks re-enqueues a zero-landed mergeable workspace task (P1)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + await manager.recoverMergeableReviewTasks(); + + expect(store.enqueued).toContain(TASK_ID); + }); + + // ── KTD2 guards: never move backward when human-gated / live ─────────────── + it("partial-land reconciler emits -no-action for autoMerge:false (not moved backward)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task], { autoMerge: false }); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + + expect(n).toBe(0); + expect(store.enqueued).not.toContain(TASK_ID); + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + }); + + it("partial-land reconciler emits -no-action for a user-paused task", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const task = workspaceTask( + { "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }, + { userPaused: true }, + ); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + expect(n).toBe(0); + expect(store.enqueued).not.toContain(TASK_ID); + }); + + it("partial-land reconciler emits -no-action when a sub-repo worktree is live", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const wtPath = fx.repoPath("repo-a"); + const task = workspaceTask({ + "repo-a": { worktreePath: wtPath, branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + // A live sub-repo session (workspace-aware liveness via pathsForTask ∩ isPathActive). + activeSessionRegistry.registerPath(wtPath, { taskId: TASK_ID, kind: "executor", ownerKey: "x" }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + expect(n).toBe(0); + expect(store.enqueued).not.toContain(TASK_ID); + }); + + // ── KTD2 FORK-A: branch-gone classification ──────────────────────────────── + it("FORK-A: branch gone + landedSha unset → parked failed", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + // No fusion branch created in repo-a, and no landedSha → unrecoverable. + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + expect(n).toBe(1); + expect(store.tasks.get(TASK_ID)?.status).toBe("failed"); + expect(store.enqueued).not.toContain(TASK_ID); + }); + + it("FORK-A: branch gone + landedSha set → skipped as landed (re-enqueue finalize)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranch(fx, "repo-a", "a\n"); + const landedA = landRepoForReal(fx, "repo-a"); + fx.git("repo-a", `git branch -D ${BRANCH}`); // branch gone, but landedSha is an ancestor. + + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + // All landed → not parked failed; re-enqueued for finalize-once. + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + expect(store.enqueued).toContain(TASK_ID); + expect(n).toBe(1); + }); + + // ── KTD3 phantom lease reclaim ───────────────────────────────────────────── + it("reclaims a workspace-repo-land lease whose owner is terminal and older than the floor", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + // Owner is done (terminal). Floor = taskStuckTimeoutMs(60s) * 3 = 180s. Advance well past it. + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(1); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(false); + }); + + it("does NOT reclaim a land lease owned by a live merging task", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + // Owner is in-review with an active "merging" status → live; lease must be left alone. + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { status: "merging" }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(0); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); + }); + + it("does NOT reclaim a land lease younger than the staleness floor", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + vi.setSystemTime(new Date("2026-06-22T00:01:00.000Z")); // 60s < 180s floor. + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(0); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); + }); + + // ── KTD4 per-repo worktree cleanup ───────────────────────────────────────── + it("removes a done workspace task's recorded per-repo worktrees (isPathActive-guarded)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + // Create a real per-repo worktree for each sub-repo (the recorded worktreePath). + const wtA = path.join(fx.repoPath("repo-a"), ".wt-task"); + const wtB = path.join(fx.repoPath("repo-b"), ".wt-task"); + fx.git("repo-a", `git worktree add -b ${BRANCH} ${wtA} HEAD`); + fx.git("repo-b", `git worktree add -b ${BRANCH} ${wtB} HEAD`); + expect(existsSync(wtA)).toBe(true); + expect(existsSync(wtB)).toBe(true); + + const task = workspaceTask( + { + "repo-a": { worktreePath: wtA, branch: BRANCH }, + "repo-b": { worktreePath: wtB, branch: BRANCH }, + }, + { column: "done" }, + ); + // Mark repo-b's worktree as active → it must be SKIPPED. + activeSessionRegistry.registerPath(wtB, { taskId: TASK_ID, kind: "executor", ownerKey: "x" }); + + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const cleaned = await manager.reconcileOrphanedWorkspaceWorktrees(); + + expect(cleaned).toBe(1); + expect(existsSync(wtA)).toBe(false); // removed + expect(existsSync(wtB)).toBe(true); // active → skipped + }); + + // ── regression: single-repo task untouched by workspace reconcilers ──────── + it("single-repo (non-workspace) task is ignored by the workspace reconcilers", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const single = { + id: "FN-9001", + column: "in-review", + branch: "fusion/fn-9001", + worktree: "/tmp/wt/fn-9001", + status: "merging", + paused: false, + dependencies: [], + steps: [], + currentStep: 0, + updatedAt: new Date(Date.now() - 30 * 60_000).toISOString(), + } as unknown as Task; + const store = createStore([single]); + const manager = makeManager(store, fx.rootDir); + + const partial = await manager.reconcileWorkspacePartialLands(); + const leases = await manager.reclaimPhantomWorkspaceLandLeases(); + const orphans = await manager.reconcileOrphanedWorkspaceWorktrees(); + + expect(partial).toBe(0); + expect(leases).toBe(0); + expect(orphans).toBe(0); + expect(store.enqueued).not.toContain("FN-9001"); + expect(store.tasks.get("FN-9001")?.status).toBe("merging"); // untouched + }); +}); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index f560e25388..4a454fd531 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -125,6 +125,27 @@ export class ActiveSessionRegistry { return paths; } + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD3 — enumeration seam for phantom-lease reclaim): + The existing accessors are path-first (lookupByPath / isPathActive) or task-first + (pathsForTask). Phantom-lease reclaim needs the inverse: enumerate every live entry of a + given KIND so self-healing can find a leaked "workspace-repo-land" lease whose owning task is + already terminal/dead. A dead task is gone from the in-progress lists, so FN-6736's + iterate-tasks approach cannot surface the lease — it must be discovered from the registry + itself. Returns shallow copies (path + the full record fields incl. `registeredAt`, already + tracked) so callers can age-gate against the FN-6736 staleness floor without holding a + reference into the internal map. + */ + entriesByKind(kind: ActiveSessionKind): Array<{ path: string; taskId: string; kind: ActiveSessionKind; registeredAt: number }> { + const out: Array<{ path: string; taskId: string; kind: ActiveSessionKind; registeredAt: number }> = []; + for (const [path, record] of this.records.entries()) { + if (record.kind === kind) { + out.push({ path, taskId: record.taskId, kind: record.kind, registeredAt: record.registeredAt }); + } + } + return out; + } + reconcileStaleSelfOwned(worktreePath: string, expectedTaskId: string): ReconcileStaleSelfOwnedResult { const record = this.lookupByPath(worktreePath); if (!record) { diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index e92d23656d..dc071041d5 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -516,6 +516,15 @@ export type DatabaseMutationType = | "task:resume-limbo-escalated" /** Metadata: { taskId, executionAgeMs, graceMs, staleBindingAgeFloorMs, checkedOutBy, agentPresent, lastActivityMs, hasRecentRunAudit, worktree, branch, worktreeExists, signalReason } */ | "task:reclaim-phantom-executor-binding" + /* FNXC:Workspace 2026-06-22-09:30 (Phase D U1) — workspace-mode self-healing run-audit events. */ + /** Metadata: { taskId, landedRepos: string[], unlandedRepos: string[], failedRepos: string[], action: "re-enqueue" | "park-failed", reason } */ + | "task:reconcile-workspace-partial-land" + /** Metadata: { taskId, reason: "auto-merge-off" | "user-paused" | "live-worktree", livePaths: string[] } */ + | "task:reconcile-workspace-partial-land-no-action" + /** Metadata: { taskId, path, kind: "workspace-repo-land", registeredAt, ageMs, staleBindingAgeFloorMs, ownerColumn } */ + | "task:reclaim-phantom-workspace-land-lease" + /** Metadata: { taskId, repo, worktreePath, success, reason } */ + | "task:reconcile-orphaned-workspace-worktree" /** Metadata: { taskId, branch, worktree, checkedOutBy, executionStartedAt, executionAgeMs, graceMs, liveWorktreeBoundBranch, reason } */ | "task:reclaim-self-owned-branch-conflict-no-action" | "task:orphan-detected-no-action" diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index d65c4ac997..7aae65f4ee 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -30,7 +30,7 @@ import { setImmediate as setImmediateCb } from "node:timers"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, resolveMaxAutoMergeRetries, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core"; +import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, resolveMaxAutoMergeRetries, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core"; import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import { createLogger, schedulerLog } from "./logger.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; @@ -46,7 +46,15 @@ import { classifyError, extractMissingModulePath, isNonContinuableSessionError, import { classifyForeignOnlyContamination, deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js"; import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type RunAuditor } from "./run-audit.js"; import { AutoRecoveryDispatcher } from "./auto-recovery.js"; -import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js"; +import { activeSessionRegistry, executingTaskLock, type ActiveSessionKind } from "./active-session-registry.js"; +/* +FNXC:Workspace 2026-06-22-09:30 (Phase D U1): +`isRepoLanded` is the CANONICAL per-repo landed predicate (Phase C, exported A6). Self-healing +reuses it rather than reimplementing the ancestor/trailer check. merger-ai also imports a const +from self-healing (MIN_TEMP_WORKTREE_REAP_AGE_MS), so this is a static cycle — safe because +`isRepoLanded` is only referenced at call time, never at module-eval time. +*/ +import { isRepoLanded } from "./merger-ai.js"; import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js"; import { isAiMergeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree-paths.js"; import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js"; @@ -817,6 +825,24 @@ export class SelfHealingManager { }); } + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD2 — workspace-aware liveness predicate): + `evaluateBackwardMoveTripleProof` is NOT workspace-aware: it keys liveness off the SINGULAR + `task.worktree` / `canonicalFusionBranchName(task.id)`, but a workspace task's liveness lives + across N sub-repo worktrees (task.worktree is null). A workspace task is LIVE iff ANY of its + sub-repo paths is still registered as active in the in-memory session registry + (`pathsForTask` ∩ `isPathActive`) OR a process-wide executing/active signal is held. Used by + the partial-land reconciler as the "safe to move backward / re-enqueue" gate so a live merging + task is never moved backward. + */ + private isWorkspaceTaskLive(task: Task): { live: boolean; livePaths: string[] } { + const livePaths = activeSessionRegistry.pathsForTask(task.id).filter((path) => activeSessionRegistry.isPathActive(path)); + const live = livePaths.length > 0 + || executingTaskLock.has(task.id) + || this.options.isTaskActive?.(task.id) === true; + return { live, livePaths }; + } + private async evaluateBackwardMoveTripleProof( task: Task, input: { @@ -2142,6 +2168,10 @@ export class SelfHealingManager { { name: "reconcile-done-task-integrity", fn: () => this.reconcileDoneTaskIntegrity() }, { name: "reconcile-stale-merger-status", fn: () => this.reconcileStaleMergerStatus() }, { name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() }, + // FNXC:Workspace 2026-06-22-09:30 (Phase D U1) — workspace-mode reconcilers. + { name: "reconcile-workspace-partial-lands", fn: () => this.reconcileWorkspacePartialLands() }, + { name: "reclaim-phantom-workspace-land-leases", fn: () => this.reclaimPhantomWorkspaceLandLeases() }, + { name: "reconcile-orphaned-workspace-worktrees", fn: () => this.reconcileOrphanedWorkspaceWorktrees() }, { name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() }, { name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks() }, { name: "recover-post-done-noncontinuable-wedge", fn: () => this.recoverPostDoneNonContinuableWedge() }, @@ -2470,6 +2500,15 @@ export class SelfHealingManager { for (const task of stale) { const previousStatus = task.status; try { + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD1 — workspace-safe by construction): + This reconciler makes NO single-commit assumption: it only clears the transient + `merging`/`merging-pr` status (status:null) + clearMergeActive and never calls + findLandedTaskCommit or moves the task. That is exactly the correct workspace action + (clear the stale status so a re-land can be re-enqueued; the partial-land reconciler / + recover-interrupted-merging owns the actual re-enqueue). So a workspace task is handled + identically and safely here — no workspace-specific branch is needed. + */ log.warn(`Clearing stale merge status for ${task.id}: ${previousStatus}`); await this.store.updateTask(task.id, { status: null }); this.options.clearMergeActive?.(task.id); @@ -5775,7 +5814,12 @@ export class SelfHealingManager { // stale ones are handled by recoverStaleMergingStatus(). t.status !== "merging" && t.status !== "merging-pr" && - Boolean(t.worktree) && + // FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD1 — admit workspace tasks): + // A workspace task has task.worktree===null (its worktrees live per-repo in + // workspaceWorktrees), so the old `Boolean(t.worktree)` gate skipped a zero-landed + // mergeable workspace task FOREVER. Admit `isWorkspaceTask(t)` so a workspace task whose + // merge enqueue was dropped is re-enqueued via enqueueMerge → idempotent landWorkspaceTask. + (Boolean(t.worktree) || isWorkspaceTask(t)) && t.mergeDetails?.mergeConfirmed !== true && t.mergeDetails?.noOpMerge !== true && !hasTerminalInvalidDoneTransition(t) && @@ -6690,6 +6734,38 @@ export class SelfHealingManager { let recovered = 0; for (const task of candidates) { try { + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD1 — P0 workspace gate): + A workspace task lands PER-REPO and `landWorkspaceTask` sets status:"merging". The + singular `findLandedTaskCommit` runs git over `this.options.rootDir` (the NON-git + workspace root) → wrong/empty, and a one-repo hit would finalize the WHOLE task done + + emit task:merged on a single repo's commit — a P0 data bug that marks a PARTIAL-landed + workspace task fully merged. So for a workspace task we MUST NOT call findLandedTaskCommit + / the single-commit finalize. Instead clear the transient "merging" status and re-enqueue + via `enqueueMerge`, which routes to the idempotent `landWorkspaceTask`: it skips repos + whose `landedSha` is already an ancestor (isRepoLanded) and finalizes to done EXACTLY ONCE + only when EVERY acquired repo is landed; a partial/none state simply re-lands the missing + repos. The partial-land reconciler (KTD2) is the standing recovery for a re-enqueue drop. + */ + if (isWorkspaceTask(task)) { + await this.store.updateTask(task.id, { status: null, error: null }); + this.options.clearMergeActive?.(task.id); + await this.store.logEntry( + task.id, + "Auto-recovered (workspace): cleared stale 'merging' status; per-repo land will be re-enqueued (no single-commit finalize)", + ); + try { + this.options.enqueueMerge?.(task.id); + } catch (enqueueErr: unknown) { + log.warn( + `Failed to re-enqueue workspace ${task.id} after stale-merge recovery (will rely on partial-land reconciler/polling sweep): ${enqueueErr instanceof Error ? enqueueErr.message : String(enqueueErr)}`, + ); + } + log.log(`Recovered interrupted workspace merge ${task.id}: cleared stale status, re-enqueued per-repo land`); + recovered++; + continue; + } + const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-interrupted-merging"); const landedCommit = await this.findLandedTaskCommit(task); @@ -6779,6 +6855,335 @@ export class SelfHealingManager { } } + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD2 — partial-land reconciler): + Recovers non-done workspace tasks whose per-repo land is incomplete (some/none landed) and + whose binding is stale — re-enqueuing the merge via `enqueueMerge` (which routes to the + idempotent `landWorkspaceTask`; already-landed repos are skipped via `isRepoLanded`). We do NOT + call `landWorkspaceTask` directly. GUARDS (reuse, never reinvent): `allowsAutoMergeProcessing` + (FN-5147 autoMerge:false), user-pause, and the WORKSPACE-AWARE liveness predicate + (`isWorkspaceTaskLive`) — triple-proof is NOT workspace-aware so it is deliberately NOT used + here. A live / paused / autoMerge-off task emits `task:reconcile-workspace-partial-land-no-action` + and is NEVER moved backward. + + FORK-A (unrecoverable): a sub-repo is unrecoverable iff its `fusion/` branch is GONE AND its + `landedSha` is UNSET (nothing landed, nothing to land) → park the task `status:"failed"`. Branch + gone but `landedSha` set → already landed (isRepoLanded ancestor/trailer) → that repo is skipped. + Otherwise the task is retryable (re-enqueue). + */ + async reconcileWorkspacePartialLands(): Promise { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null; + // Workspace tasks live in in-review (post-capture/review, pre/partial land). A task already + // done is finished; todo/in-progress are owned by execution-stage reconcilers. + const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + const candidates = tasks.filter((task) => + task.column === "in-review" && + isWorkspaceTask(task) && + task.mergeDetails?.mergeConfirmed !== true && + // Active transient merge statuses are owned by the live merger; recover-interrupted / + // recover-stale-merging clear STALE ones. A non-transient status (or null) is our domain. + !(task.status && ACTIVE_MERGE_STATUSES.has(task.status)), + ); + if (candidates.length === 0) return 0; + + let recovered = 0; + for (const task of candidates) { + try { + // GUARD 1 — FN-5147 autoMerge:false: in-review is human-gated; never move it backward. + if (!allowsAutoMergeProcessing(task, settings)) { + await this.emitWorkspacePartialLandNoAction(task, "auto-merge-off", []); + continue; + } + // GUARD 2 — user-pause: a hard operator stop. + if (task.userPaused || task.paused) { + await this.emitWorkspacePartialLandNoAction(task, "user-paused", []); + continue; + } + // GUARD 3 — workspace-aware liveness: ANY active sub-repo path / process signal. + const liveness = this.isWorkspaceTaskLive(task); + if (liveness.live) { + await this.emitWorkspacePartialLandNoAction(task, "live-worktree", liveness.livePaths); + continue; + } + // GUARD 4 — a live merge lane owns this exact task right now. + if (activeMergeTaskId && activeMergeTaskId === task.id) { + await this.emitWorkspacePartialLandNoAction(task, "live-worktree", liveness.livePaths); + continue; + } + + // Classify each acquired sub-repo: landed / retryable / unrecoverable (FORK-A). + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const repoKeys = Object.keys(workspaceWorktrees); + const landedRepos: string[] = []; + const unlandedRepos: string[] = []; + const unrecoverableRepos: string[] = []; + for (const repoRel of repoKeys) { + const entry = workspaceWorktrees[repoRel]; + const repoRootDir = join(this.options.rootDir, repoRel); + let integrationBranch: string; + try { + integrationBranch = await resolveIntegrationBranch( + repoRootDir, + { ...settings, integrationBranch: undefined, baseBranch: undefined }, + ); + } catch { + // Cannot resolve the sub-repo's integration branch → treat as retryable (re-enqueue + // re-runs the same resolution and surfaces the real error there). + unlandedRepos.push(repoRel); + continue; + } + if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, entry.branch)) { + landedRepos.push(repoRel); + continue; + } + // Not landed. FORK-A unrecoverable iff the task branch is GONE and nothing landed. + const branchPresent = entry.branch + ? await this.repoBranchExists(repoRootDir, entry.branch) + : false; + if (!branchPresent && !entry.landedSha) { + unrecoverableRepos.push(repoRel); + } else { + unlandedRepos.push(repoRel); + } + } + + const auditor = createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-workspace-partial-land", task.id), + agentId: "self-healing", + taskId: task.id, + taskLineageId: task.lineageId, + phase: "reconcile-workspace-partial-land", + }); + + if (unrecoverableRepos.length > 0) { + // FORK-A: at least one repo can never land (branch gone, nothing landed) → park failed. + const error = `Workspace partial-land unrecoverable: sub-repo(s) ${unrecoverableRepos.join(", ")} have no fusion/${task.id.toLowerCase()} branch and no landedSha — manual intervention required.`; + await this.store.updateTask(task.id, { status: "failed", error }); + await this.store.logEntry(task.id, error); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos, unlandedRepos, failedRepos: unrecoverableRepos, action: "park-failed", reason: "branch-gone-and-unlanded" }, + }).catch(() => undefined); + log.warn(`reconcileWorkspacePartialLands: parked ${task.id} failed (unrecoverable repos: ${unrecoverableRepos.join(", ")})`); + recovered++; + continue; + } + + if (unlandedRepos.length === 0) { + // Every acquired repo is already landed but the task was never finalized (the finalize + // enqueue was dropped). Re-enqueue: landWorkspaceTask skips all repos and finalizes once. + this.options.enqueueMerge?.(task.id); + await this.store.logEntry(task.id, "Auto-recovered (workspace): all sub-repos landed but task not finalized — re-enqueued finalize-once"); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos, unlandedRepos: [], failedRepos: [], action: "re-enqueue", reason: "all-landed-not-finalized" }, + }).catch(() => undefined); + recovered++; + continue; + } + + // Partial / none landed, all unlanded repos retryable → re-enqueue the per-repo land. + this.options.enqueueMerge?.(task.id); + await this.store.logEntry(task.id, `Auto-recovered (workspace): re-enqueued partial land (${landedRepos.length} landed, ${unlandedRepos.length} pending)`); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos, unlandedRepos, failedRepos: [], action: "re-enqueue", reason: landedRepos.length > 0 ? "partial-land" : "zero-land" }, + }).catch(() => undefined); + recovered++; + } catch (err: unknown) { + log.error(`reconcileWorkspacePartialLands: failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (recovered > 0) log.log(`reconcileWorkspacePartialLands: recovered ${recovered} workspace task(s)`); + return recovered; + } catch (err: unknown) { + log.error(`reconcileWorkspacePartialLands sweep failed: ${err instanceof Error ? err.message : String(err)}`); + return 0; + } + } + + private async emitWorkspacePartialLandNoAction( + task: Task, + reason: "auto-merge-off" | "user-paused" | "live-worktree", + livePaths: string[], + ): Promise { + try { + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-workspace-partial-land-no-action", task.id), + agentId: "self-healing", + taskId: task.id, + taskLineageId: task.lineageId, + phase: "reconcile-workspace-partial-land", + }).database({ + type: "task:reconcile-workspace-partial-land-no-action", + target: task.id, + metadata: { taskId: task.id, reason, livePaths }, + }); + } catch (err: unknown) { + log.warn(`reconcileWorkspacePartialLands: audit emit failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** True iff `branch` exists as a local ref in the sub-repo at `repoRootDir`. */ + private async repoBranchExists(repoRootDir: string, branch: string): Promise { + try { + await execAsync(`git rev-parse --verify ${shellQuote(`refs/heads/${branch}`)}`, { + cwd: repoRootDir, + timeout: 30_000, + }); + return true; + } catch { + return false; + } + } + + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD3 — phantom workspace-repo-land lease reclaim): + A `workspace-repo-land` lease is registered on a sub-repo's ABSOLUTE path while a workspace task + lands it, and released in a finally. If the holder dies between register and release, the lease + leaks; because the owner is terminal/dead it is gone from the in-progress lists, so FN-6736's + iterate-tasks reclaim cannot surface it. We enumerate `workspace-repo-land` entries via the new + registry seam and, for each whose owning task is terminal/dead AND whose `registeredAt` is older + than the FN-6736 staleness floor (graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER), clear the + lease (unregister the path) + emit `task:reclaim-phantom-workspace-land-lease`. A lease owned by a + LIVE merging task (still in-review with a transient merge status, or the active merge task) is + UNTOUCHED — only a demonstrably dead owner is reclaimed. + */ + async reclaimPhantomWorkspaceLandLeases(): Promise { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + const entries = activeSessionRegistry.entriesByKind("workspace-repo-land" as ActiveSessionKind); + if (entries.length === 0) return 0; + + const graceMs = settings.taskStuckTimeoutMs ?? STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS; + const staleFloorMs = graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER; + const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null; + const now = Date.now(); + + let reclaimed = 0; + for (const entry of entries) { + try { + const ageMs = now - entry.registeredAt; + if (ageMs < staleFloorMs) continue; // too recent — a live land is still warming. + + // A live merge lane / executing owner keeps the lease. + if (activeMergeTaskId && activeMergeTaskId === entry.taskId) continue; + if (executingTaskLock.has(entry.taskId) || this.options.isTaskActive?.(entry.taskId) === true) continue; + + const owner = await this.store.getTask(entry.taskId).catch(() => null); + // Owner is dead/terminal iff: not found, archived/done/failed, OR in-review with NO active + // transient merge status (a merging owner is live; a clean in-review is finished landing). + const ownerColumn = owner?.column ?? "deleted"; + const ownerHasActiveMergeStatus = Boolean(owner?.status && ACTIVE_MERGE_STATUSES.has(owner.status)); + const ownerLive = Boolean(owner) + && owner!.column !== "done" + && owner!.status !== "failed" + && ownerHasActiveMergeStatus; + if (ownerLive) continue; // live merging owner → leave its lease alone. + + activeSessionRegistry.unregisterPath(entry.path); + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-phantom-workspace-land-lease", entry.taskId), + agentId: "self-healing", + taskId: entry.taskId, + phase: "reclaim-phantom-workspace-land-lease", + }).database({ + type: "task:reclaim-phantom-workspace-land-lease", + target: entry.taskId, + metadata: { taskId: entry.taskId, path: entry.path, kind: entry.kind, registeredAt: entry.registeredAt, ageMs, staleBindingAgeFloorMs: staleFloorMs, ownerColumn }, + }).catch(() => undefined); + log.warn(`reclaimPhantomWorkspaceLandLeases: reclaimed leaked land lease on ${entry.path} (owner ${entry.taskId}, age ${ageMs}ms)`); + reclaimed++; + } catch (err: unknown) { + log.error(`reclaimPhantomWorkspaceLandLeases: failed for ${entry.path}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (reclaimed > 0) log.log(`reclaimPhantomWorkspaceLandLeases: reclaimed ${reclaimed} leaked lease(s)`); + return reclaimed; + } catch (err: unknown) { + log.error(`reclaimPhantomWorkspaceLandLeases sweep failed: ${err instanceof Error ? err.message : String(err)}`); + return 0; + } + } + + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD4 — per-repo worktree cleanup from STORED paths): + For done/dead workspace tasks, remove each recorded per-repo worktree. The paths are ADDRESSABLE + from the task row (`workspaceWorktrees[repo].worktreePath`, persisted) so we NEVER walk the temp + root / readdir the temp tree (AGENTS.md forbids unbounded temp walks) — the sweep is bounded by + construction. Each removal is GUARDED by `activeSessionRegistry.isPathActive(path)` (skip if + active, mirroring the temp-dir sweep at the AI-merge worktree guard) so a still-live path is never + yanked. Emit `task:reconcile-orphaned-workspace-worktree` per removed path. + */ + async reconcileOrphanedWorkspaceWorktrees(): Promise { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + // Done workspace tasks are the canonical "safe to clean" set (their lands are finalized). + const doneTasks = await this.store.listTasks({ column: "done", slim: true }); + const candidates = doneTasks.filter((task) => isWorkspaceTask(task)); + if (candidates.length === 0) return 0; + + let cleaned = 0; + for (const task of candidates) { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + for (const repoRel of Object.keys(workspaceWorktrees)) { + const worktreePath = workspaceWorktrees[repoRel]?.worktreePath; + if (!worktreePath) continue; + // GUARD: skip an active path (mirror self-healing temp-dir sweep isPathActive guard). + if (activeSessionRegistry.isPathActive(worktreePath)) continue; + // Nothing on disk → nothing to remove (already cleaned). Skip silently. + if (!existsSync(worktreePath)) continue; + + const repoRootDir = join(this.options.rootDir, repoRel); + let success = false; + let reason = "removed"; + try { + await execAsync(`git worktree remove --force ${shellQuote(worktreePath)}`, { + cwd: repoRootDir, + timeout: 120_000, + }); + success = true; + } catch (err: unknown) { + reason = `git-remove-failed: ${err instanceof Error ? err.message : String(err)}`; + } + try { + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-orphaned-workspace-worktree", task.id), + agentId: "self-healing", + taskId: task.id, + taskLineageId: task.lineageId, + phase: "reconcile-orphaned-workspace-worktree", + }).database({ + type: "task:reconcile-orphaned-workspace-worktree", + target: task.id, + metadata: { taskId: task.id, repo: repoRel, worktreePath, success, reason }, + }); + } catch { /* audit best-effort */ } + if (success) { + log.log(`reconcileOrphanedWorkspaceWorktrees: removed ${worktreePath} (task ${task.id}, repo ${repoRel})`); + cleaned++; + } + } + } + if (cleaned > 0) log.log(`reconcileOrphanedWorkspaceWorktrees: removed ${cleaned} orphaned per-repo worktree(s)`); + return cleaned; + } catch (err: unknown) { + log.error(`reconcileOrphanedWorkspaceWorktrees sweep failed: ${err instanceof Error ? err.message : String(err)}`); + return 0; + } + } + private async readShortstatForSha( sha: string, rebaseBaseSha?: string, From 78d7a28f166d5160f3ed165f2852bc036f3469bf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:45:43 -0700 Subject: [PATCH 040/265] =?UTF-8?q?test(workspace):=20Phase=20D=20U2=20?= =?UTF-8?q?=E2=80=94=20e2e=20merge=20+=20recovery=20harness=20(no-push=20i?= =?UTF-8?q?nvariant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real two-repo workspace lifecycle test in the engine-default lane (describeIfGit, not the merge gate). Test 1 drives landWorkspaceTask on two acquired sub-repos and asserts the NO-PUSH invariant directly: each sub-repo gets a real bare origin, and the test snapshots every origin + remote-tracking ref before/after and asserts byte-for-byte equality while the local refs/heads advance — a leaked push would move an origin ref and fail. Plus per-repo landedSha and finalize-exactly-once. Test 2 forces a repo-B conflict (repo A lands, task not done), then invokes the U1 reconcileWorkspacePartialLands reconciler under fake timers (enqueueMerge wired to the real in-process route) and asserts recovery completes with no double-land of repo A (its ref is unchanged from the first pass — proving the isRepoLanded skip). Engine-default lane confirmed: test:gate stays 649+58 (did not enter engine-core). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/workspace-e2e.test.ts | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 packages/engine/src/__tests__/workspace-e2e.test.ts diff --git a/packages/engine/src/__tests__/workspace-e2e.test.ts b/packages/engine/src/__tests__/workspace-e2e.test.ts new file mode 100644 index 0000000000..632b042724 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-e2e.test.ts @@ -0,0 +1,350 @@ +/* +FNXC:Workspace 2026-06-22-11:30 (Phase D U2, KTD5 — end-to-end merge + recovery harness): +LANE CHOICE — this is an ENGINE-DEFAULT, git-gated lane (the SAME `describeIfGit` guard as +workspace-merger.test.ts), NOT a merge-gate (engine-core) test. The merge gate is an explicit +allow-list that excludes real-git tests, so a real two-repo fixture e2e cannot run there; it runs +in the non-blocking engine-default suite instead. We drive the REAL `landWorkspaceTask` against a +REAL two-repo git fixture under a NON-git workspace root (createWorkspaceFixture) and invoke the +U1 partial-land reconciler (`reconcileWorkspacePartialLands`) directly under FAKE TIMERS — no +mock-the-world ProjectEngine shell, no real AI (the merge/review agents are injected deps and the +squash is a plain `git merge --squash`), no unbounded temp walk, never touches port 4040 (FN-5048). + +NO-PUSH INVARIANT (the whole D2/D5 premise — a HARD assertion): +Each sub-repo gets a REAL bare `origin` remote that we push initial state to. We snapshot +`git for-each-ref` over BOTH the bare origin AND the working repo's `refs/remotes/*` BEFORE and +AFTER `landWorkspaceTask`. landWorkspaceTask lands each sub-repo onto its own LOCAL integration ref +via CAS with NO remote push, so the origin's refs and every `refs/remotes/*` tracking ref must be +BYTE-FOR-BYTE UNCHANGED while the LOCAL `refs/heads/main` advances. A leaked `git push` would move +an origin ref and fail the snapshot equality — this is the strongest available proof of no-push. + +Surfaces (FN-5893): +- e2e happy + no-push: two acquired repos both land → BOTH local integration refs advance, + per-repo `landedSha` is set, the task is finalized done EXACTLY once, AND origin/remote refs are + unchanged (no push). +- e2e partial-land recovery: force repo B to conflict → repo A lands (landedSha + ref advance), task + NOT done; resolve B and run the U1 reconciler (re-enqueue → idempotent landWorkspaceTask) → B + lands, task done, and repo A's ref did NOT advance a second time (isRepoLanded skip — no double-land). +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Settings, Task, TaskStore } from "@fusion/core"; +import { landWorkspaceTask } from "../merger-ai.js"; +import { SelfHealingManager } from "../self-healing.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-8001"; +const BRANCH = "fusion/fn-8001"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +/** + * Combined recording store. Satisfies BOTH the `landWorkspaceTask` surface (getSettings/updateTask/ + * logEntry/appendAgentLog/getTask/moveTask/upsertTaskCommitAssociation/accumulateTokenUsage/emit) + * AND the SelfHealingManager surface (listTasks/peekMergeQueue/recordRunAuditEvent/getRootDir), + * over a single in-memory task map so a reconciler-routed land sees the SAME freshly-persisted + * landedShas the first pass wrote. + */ +interface RecordingStore extends EventEmitter { + tasks: Map; + emitted: Array<{ event: string; payload: unknown }>; + moveTaskCalls: Array<{ id: string; column: string }>; +} + +function createStore(rows: Task[], settings: Partial = {}): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const tasks = new Map(rows.map((t) => [t.id, t])); + const emitted: Array<{ event: string; payload: unknown }> = []; + const moveTaskCalls: Array<{ id: string; column: string }> = []; + const realEmit = emitter.emit.bind(emitter); + const store = Object.assign(emitter, { + tasks, + emitted, + moveTaskCalls, + getSettings: vi + .fn() + .mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000, ...settings } as unknown as Settings), + listTasks: vi.fn(async (opts?: { column?: string }) => { + const all = [...tasks.values()]; + return opts?.column ? all.filter((t) => t.column === opts.column) : all; + }), + getTask: vi.fn(async (id: string) => tasks.get(id) ?? null), + updateTask: vi.fn(async (id: string, patch: Partial) => { + const cur = tasks.get(id); + if (cur) tasks.set(id, { ...cur, ...patch } as Task); + return tasks.get(id) as Task; + }), + moveTask: vi.fn(async (id: string, column: string) => { + moveTaskCalls.push({ id, column }); + const cur = tasks.get(id); + const next = { ...(cur ?? { id }), column } as Task; + tasks.set(id, next); + return next; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + peekMergeQueue: vi.fn().mockReturnValue([]), + getRootDir: vi.fn().mockReturnValue("/tmp/test"), + emit: (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return realEmit(event, payload); + }, + }) as unknown as TaskStore & RecordingStore; + return store; +} + +function makeTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial = {}): Task { + return { + id: TASK_ID, + title: "Workspace merge task", + description: "", + column: "in-review", + branch: BRANCH, + worktree: null, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + paused: false, + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date(Date.now() - 30 * 60_000).toISOString(), + ...extra, + } as unknown as Task; +} + +/** A merge agent that performs the real squash in the clean room (no AI). */ +function squashMergeAgent(branch: string) { + return async (cwd: string): Promise => { + configureIdentity(cwd); + try { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + } catch { + // squash reported conflicts — leave them for the test's expectation. + } + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) throw new Error("merge conflict: unresolved paths in clean room"); + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" }); + }; +} + +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +/** + * Give a sub-repo a REAL bare `origin` remote and push its initial state. Returns the bare repo + * path so the test can snapshot its refs. Used to prove the NO-PUSH invariant: the origin must not + * move across a land. + */ +function addOriginRemote(fx: WorkspaceFixture, repoRel: string): string { + const repoDir = fx.repoPath(repoRel); + const originDir = path.join(repoDir, "..", `${repoRel}-origin.git`); + execSync(`git init --bare ${originDir}`, { cwd: repoDir, stdio: "pipe" }); + fx.git(repoRel, `git remote add origin ${originDir}`); + fx.git(repoRel, "git push origin --all"); + return originDir; +} + +/** Snapshot ALL refs of a git dir (sha + name), normalized, for byte-for-byte comparison. */ +function snapshotRefs(gitDir: string): string { + return execSync("git for-each-ref --format='%(objectname) %(refname)'", { + cwd: gitDir, + encoding: "utf-8", + }).trim(); +} + +/** Add a real `fusion/` branch in a sub-repo with one non-conflicting own commit. */ +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`); + configureIdentity(wt); + writeFileSync(path.join(wt, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); +} + +/** Make a sub-repo's integration tip and the task branch BOTH edit README so the squash conflicts. */ +function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-conflict"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`); + configureIdentity(wt); + writeFileSync(path.join(wt, "README.md"), "# branch-side change\n", "utf-8"); + execSync("git add README.md", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); + writeFileSync(path.join(repoDir, "README.md"), "# main-side change\n", "utf-8"); + fx.git(repoRel, "git add README.md"); + fx.git(repoRel, 'git commit -m "main diverge README"'); +} + +/** + * Resolve repo B's conflict so a retry can land it: hard-align the task branch's README onto the + * integration tip's content, then add B's non-conflicting feature on top of the (now conflict-free) + * branch. After this the squash applies cleanly. + */ +function resolveConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-resolve"); + fx.git(repoRel, `git worktree add ${wt} ${BRANCH}`); + configureIdentity(wt); + // Take main's README content so the README no longer diverges, then add a unique file. + const mainReadme = fx.git(repoRel, "git show refs/heads/main:README.md"); + writeFileSync(path.join(wt, "README.md"), `${mainReadme}\n`, "utf-8"); + writeFileSync(path.join(wt, "feature.txt"), "b feature\n", "utf-8"); + execSync("git add README.md feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): resolve + feature in ${repoRel}"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); +} + +describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase D U2)", () => { + let fx: WorkspaceFixture; + beforeEach(() => activeSessionRegistry.clear()); + afterEach(() => { + activeSessionRegistry.clear(); + vi.useRealTimers(); + vi.clearAllMocks(); + fx?.cleanup(); + }); + + it("e2e happy: both repos land on LOCAL refs, landedSha per repo, finalize ONCE, NO push", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const originA = addOriginRemote(fx, "repo-a"); + const originB = addOriginRemote(fx, "repo-b"); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const tipBBefore = fx.git("repo-b", "git rev-parse refs/heads/main"); + + // NO-PUSH snapshot: bare origin refs + the working repo's refs/remotes tracking refs. + const originABefore = snapshotRefs(originA); + const originBBefore = snapshotRefs(originB); + const remotesABefore = fx.git("repo-a", "git for-each-ref refs/remotes"); + const remotesBBefore = fx.git("repo-b", "git for-each-ref refs/remotes"); + + const store = createStore([ + makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }), + ]); + const task = store.tasks.get(TASK_ID)!; + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + // Both landed. + expect(result.allLanded).toBe(true); + expect(result.finalized).toBe(true); + for (const r of result.repos) expect(r.status).toBe("landed"); + + // Each repo's LOCAL integration ref advanced. + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore); + expect(fx.git("repo-b", "git rev-parse refs/heads/main")).not.toBe(tipBBefore); + + // Per-repo landedSha persisted on the task row. + const persisted = store.tasks.get(TASK_ID)!.workspaceWorktrees!; + expect(persisted["repo-a"].landedSha).toBeTruthy(); + expect(persisted["repo-b"].landedSha).toBeTruthy(); + expect(persisted["repo-a"].landedSha).toBe(fx.git("repo-a", "git rev-parse refs/heads/main")); + expect(persisted["repo-b"].landedSha).toBe(fx.git("repo-b", "git rev-parse refs/heads/main")); + + // Finalize EXACTLY once. + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1); + + // NO-PUSH invariant (HARD): origin refs and remote-tracking refs are BYTE-FOR-BYTE unchanged. + expect(snapshotRefs(originA)).toBe(originABefore); + expect(snapshotRefs(originB)).toBe(originBBefore); + expect(fx.git("repo-a", "git for-each-ref refs/remotes")).toBe(remotesABefore); + expect(fx.git("repo-b", "git for-each-ref refs/remotes")).toBe(remotesBBefore); + }); + + it("e2e partial-land recovery: A lands, task not done → U1 reconciler lands B, no double-land of A", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + makeConflictingRepo(fx, "repo-b"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + const store = createStore([ + makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }), + ]); + + // First pass: repo B conflicts → repo A lands, task NOT finalized. + const first = await landWorkspaceTask(store, store.tasks.get(TASK_ID)!, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(first.allLanded).toBe(false); + const byRepo = Object.fromEntries(first.repos.map((r) => [r.repo, r])); + expect(byRepo["repo-a"].status).toBe("landed"); + expect(byRepo["repo-b"].status).toBe("failed"); + + const tipAAfterFirst = fx.git("repo-a", "git rev-parse refs/heads/main"); + expect(tipAAfterFirst).not.toBe(tipABefore); // A advanced once. + expect(store.tasks.get(TASK_ID)!.workspaceWorktrees!["repo-a"].landedSha).toBe(tipAAfterFirst); + expect(store.moveTaskCalls).toHaveLength(0); // task NOT done. + expect(store.tasks.get(TASK_ID)!.column).toBe("in-review"); + + // Resolve repo B's conflict so a retry can land it. + resolveConflictingRepo(fx, "repo-b"); + + // Wire enqueueMerge to the REAL in-process route: re-run landWorkspaceTask (idempotent — A is + // skipped via isRepoLanded). Capture the routed promise so the test can await completion. + const routedLands: Promise[] = []; + const enqueueMerge = (taskId: string): boolean => { + routedLands.push( + landWorkspaceTask(store, store.tasks.get(taskId)!, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }), + ); + return true; + }; + const manager = new SelfHealingManager(store, { + rootDir: fx.rootDir, + enqueueMerge, + clearMergeActive: vi.fn(), + } as never); + + // FAKE TIMERS for the reconciler sweep timing (no real polling/waits). + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + const recovered = await manager.reconcileWorkspacePartialLands(); + expect(recovered).toBe(1); + expect(routedLands).toHaveLength(1); + + const recovery = (await routedLands[0]) as { allLanded: boolean; finalized: boolean }; + + // Recovery completes: B lands, task finalized done. + expect(recovery.allLanded).toBe(true); + expect(recovery.finalized).toBe(true); + expect(store.tasks.get(TASK_ID)!.workspaceWorktrees!["repo-b"].landedSha).toBeTruthy(); + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1); + + // NO DOUBLE-LAND: repo A's ref did NOT advance a second time (isRepoLanded skip). + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAAfterFirst); + }); +}); From 8e70d69601bcaa1034d158b9f40d82cf2288fbe4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 02:19:17 -0700 Subject: [PATCH 041/265] =?UTF-8?q?fix(review):=20Phase=20D=20self-healing?= =?UTF-8?q?=20hardening=20=E2=80=94=20finalize-site=20audit,=20lease/TOCTO?= =?UTF-8?q?U=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4-persona review of the Phase-D workspace self-healing. The headline: the P0 single-commit-finalize guard had to be applied across ALL surfaces, not just the one reconciler U1 patched (FN-5893). Finalize-site audit (A): gated every site where a workspace task could be single-commit-finalized on one repo's commit — recoverStuckMergeDeadlocks (the twin of the U1-patched reconciler, reachable via blocked-dependents), recoverOrphanOnlyScopeViolations, recoverAlreadyMergedReviewTasks, recoverBranchMisboundInReviewTasks (workspace tasks carry task.branch so the Boolean(branch) filter didn't exclude them), plus a defensive filter on finalizeNoOpReviewTasks. recoverMergedReviewTasks confirmed safe (mergeConfirmed gate). Each is an isWorkspaceTask early-skip; single-repo behavior unchanged. Reliability/concurrency: - The partial-land reconciler now captures enqueueMerge's boolean and bounds re-enqueues (mergeStarvationDrops → fail after N) instead of looping silently forever on a full queue. - The phantom-lease reclaim only acts on a terminal owner (null/done/failed) — it no longer reclaims the lease of an in-progress executing task that registered it early (shared isWorkspaceOwnerLive predicate). - A new isMergePending(taskId) = mergeActive ∪ mergeQueue seam (exposed from ProjectEngine, wired through the runtime) guards both reconcilers against the merge-queue dispatch window — a task dequeued-but-not-yet-merging is no longer re-enqueued (which, since a same-task land lease isn't contention, could have caused a concurrent double-squash). - FORK-A: a repo whose branch is gone and which isn't landed is parked, not re-enqueued forever. Orphan-worktree removal failures log.warn + bound. recoverDoneTaskMergeMetadata skips workspace tasks. Maintainability: dissolved the self-healing↔merger-ai import cycle by moving isRepoLanded into a dependency-free workspace-land-predicate.ts; removed a redundant cast. Gate green: build, typecheck, lint, test:gate (649+58); self-healing + e2e + project-engine + merger 724. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-phase-d-self-healing.md | 4 + .../__tests__/self-healing-workspace.test.ts | 203 ++++++++++++ packages/engine/src/index.ts | 6 +- packages/engine/src/merger-ai.ts | 94 +----- packages/engine/src/project-engine.ts | 24 ++ .../engine/src/runtimes/in-process-runtime.ts | 14 + packages/engine/src/self-healing.ts | 301 ++++++++++++++++-- .../engine/src/workspace-land-predicate.ts | 119 +++++++ 8 files changed, 644 insertions(+), 121 deletions(-) create mode 100644 packages/engine/src/workspace-land-predicate.ts diff --git a/.changeset/workspace-phase-d-self-healing.md b/.changeset/workspace-phase-d-self-healing.md index 6d412dd404..1bcdb8fa71 100644 --- a/.changeset/workspace-phase-d-self-healing.md +++ b/.changeset/workspace-phase-d-self-healing.md @@ -3,3 +3,7 @@ --- Workspace mode Phase D (U1): workspace-aware self-healing. The existing merging-status reconcilers no longer mis-finalize a partial-landed workspace task (recoverInterruptedMergingTasks now clears the transient `merging` status and re-enqueues the idempotent per-repo land instead of running the single-commit finalize over the non-git workspace root), and recoverMergeableReviewTasks now admits workspace tasks (task.worktree is null). Adds three reconcilers: partial-land recovery (re-enqueue via enqueueMerge, FORK-A unrecoverable → park failed; guarded by autoMerge:false + user-pause + workspace-aware liveness), phantom `workspace-repo-land` lease reclaim (new `entriesByKind` registry seam), and per-repo worktree cleanup from stored paths (no temp walk). New run-audit events: `task:reconcile-workspace-partial-land`(`-no-action`), `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`. + +Phase D P1 TOCTOU fix (merge-queue dispatch blind spot): the workspace partial-land and phantom-land-lease reconcilers now consult a new `ProjectEngine.isMergePending(taskId)` seam (true if the task is in the engine's in-memory `mergeQueue` or `mergeActive`). This closes the dequeue→rawMerge window where a workspace task is being merged but no other liveness signal fires yet (the id is shifted out of `mergeQueue` while `activeMergeTaskId` / `merging` status / the `workspace-repo-land` lease are not yet set inside `landWorkspaceTask`). The partial-land reconciler skips a merge-pending candidate (emitting `task:reconcile-workspace-partial-land-no-action` with reason `merge-pending`) instead of launching a second concurrent `landWorkspaceTask` (double-squash risk, since a same-task land lease is not contention), and lease reclaim leaves a merge-pending owner's not-yet-registered lease alone. Wired via `InProcessRuntime.setMergePendingProvider`; undefined (unwired) is treated as not-pending so existing guards still apply. + +Phase D review hardening: every single-commit-finalize self-healing site is now workspace-gated so a partial-landed workspace task can never be marked fully merged on one repo's commit — `recoverStuckMergeDeadlocks` (the twin of recoverInterruptedMergingTasks), `recoverOrphanOnlyScopeViolations`, `recoverAlreadyMergedReviewTasks`, `recoverBranchMisboundInReviewTasks`, and `recoverDoneTaskMergeMetadata` all skip workspace tasks and defer recovery to the workspace partial-land reconciler. The partial-land reconciler now bounds its `enqueueMerge` re-enqueue (parks `failed` after repeated queue rejections instead of looping forever) and treats a branch-gone-and-not-landed sub-repo as unrecoverable even when a stale unreachable `landedSha` is present. Phantom land-lease reclaim now only reclaims a demonstrably TERMINAL owner (never an `in-progress` executing task that registered its lease early). Orphan per-repo worktree removal failures are now engine-logged and retry-bounded. The canonical `isRepoLanded` predicate moved to a new dependency-free `workspace-land-predicate` module, dissolving the self-healing ↔ merger-ai import cycle (public export preserved). diff --git a/packages/engine/src/__tests__/self-healing-workspace.test.ts b/packages/engine/src/__tests__/self-healing-workspace.test.ts index ecd32a7892..ad524177af 100644 --- a/packages/engine/src/__tests__/self-healing-workspace.test.ts +++ b/packages/engine/src/__tests__/self-healing-workspace.test.ts @@ -263,6 +263,46 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => { expect(store.enqueued).not.toContain(TASK_ID); }); + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + A workspace task in the dequeue→rawMerge window is being merged but NO liveness signal fires + (no active session path, no executingTaskLock/isTaskActive, no activeMergeTaskId, no `merging` + status, no land lease yet). Without the merge-pending guard the partial-land reconciler would + re-enqueue it → a SECOND concurrent `landWorkspaceTask(T)` → double-squash. With `isMergePending` + returning true (task is in mergeQueue/mergeActive) the reconciler must NOT re-enqueue and must + emit -no-action(reason: "merge-pending"). + */ + it("partial-land reconciler does NOT re-enqueue a merge-pending task (closes double-dispatch)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); // partial-landed → would normally re-enqueue. + + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task]); + // Narrow seam: inject the in-memory merge-pipeline probe. No session/lock/lease set → only + // the merge-pending guard can stop the re-enqueue. + const manager = makeManager(store, fx.rootDir, { isMergePending: (id: string) => id === TASK_ID }); + + const n = await manager.reconcileWorkspacePartialLands(); + + expect(n).toBe(0); + expect(store.enqueued).not.toContain(TASK_ID); + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + expect(store.tasks.get(TASK_ID)?.column).toBe("in-review"); + const auditCalls = (store.recordRunAuditEvent as ReturnType).mock.calls; + expect( + auditCalls.some( + ([ev]) => + (ev as { mutationType?: string }).mutationType === "task:reconcile-workspace-partial-land-no-action" && + (ev as { metadata?: { reason?: string } }).metadata?.reason === "merge-pending", + ), + ).toBe(true); + }); + // ── KTD2 FORK-A: branch-gone classification ──────────────────────────────── it("FORK-A: branch gone + landedSha unset → parked failed", async () => { fx = await createWorkspaceFixture(["repo-a"]); @@ -337,6 +377,33 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => { expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); }); + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + A workspace-repo-land lease whose owner is mid-dispatch (in mergeQueue/mergeActive but not yet + activeMergeTaskId) is about to be LEGITIMATELY used by the in-flight `landWorkspaceTask`. Even + though the owner ROW reads terminal-looking and the lease is past the staleness floor, the + merge-pending guard must keep the lease. Here the owner is `done` and the lease is well past the + 180s floor — so ONLY the merge-pending guard can prevent reclaim. + */ + it("does NOT reclaim a land lease whose owner is merge-pending (mid-dispatch)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" }); + const store = createStore([task]); + // Narrow seam: owner is in the in-memory merge pipeline → lease must be left alone. + const manager = makeManager(store, fx.rootDir, { isMergePending: (id: string) => id === TASK_ID }); + + vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); // 600s > 180s floor. + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(0); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); + }); + it("does NOT reclaim a land lease younger than the staleness floor", async () => { fx = await createWorkspaceFixture(["repo-a"]); const leasePath = fx.repoPath("repo-a"); @@ -414,4 +481,140 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => { expect(store.enqueued).not.toContain("FN-9001"); expect(store.tasks.get("FN-9001")?.status).toBe("merging"); // untouched }); + + // ── review A (TWIN): recoverStuckMergeDeadlocks must NOT single-commit-finalize ───── + it("recoverStuckMergeDeadlocks does NOT finalize a partial-landed workspace task with blocked dependents (P0 twin)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); // repo A landed; repo B NOT → partial. + + const task = workspaceTask( + { + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }, + // Deadlock-candidate shape: failed + retries exhausted, mergeConfirmed unset. + { status: "failed", mergeRetries: 5, updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() }, + ); + // A blocked dependent in todo → the deadlock filter admits the (worktree-null) workspace task. + const dependent = { + id: "FN-7002", column: "todo", blockedBy: TASK_ID, paused: false, dependencies: [], steps: [], currentStep: 0, + } as unknown as Task; + const store = createStore([task, dependent], { maxAutoMergeRetries: 1 }); + const manager = makeManager(store, fx.rootDir); + + await manager.recoverStuckMergeDeadlocks(); + + // NOT finalized done; never emitted task:merged on a single repo; status cleared (not done). + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + expect(store.tasks.get(TASK_ID)?.column).toBe("in-review"); + expect(store.tasks.get(TASK_ID)?.status).toBeNull(); + }); + + // ── review B: bounded re-enqueue — no silent infinite loop ───────────────── + it("partial-land reconciler parks failed after N consecutive enqueue drops (no infinite re-enqueue)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); + + const baseTrees = { + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + } as NonNullable; + const task = workspaceTask(baseTrees); + const store = createStore([task]); + // enqueueMerge that ALWAYS rejects (queue full) → drop every time. + const manager = makeManager(store, fx.rootDir, { enqueueMerge: () => false }); + + // First two sweeps: dropped, re-enqueued (not failed yet). repo-b branch still present → retryable. + await manager.reconcileWorkspacePartialLands(); + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + await manager.reconcileWorkspacePartialLands(); + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + // Third drop hits the bound → parked failed. + await manager.reconcileWorkspacePartialLands(); + expect(store.tasks.get(TASK_ID)?.status).toBe("failed"); + }); + + // ── review C: phantom-lease reclaim must NOT reclaim a live executing (in-progress) task ─ + it("does NOT reclaim a land lease owned by an IN-PROGRESS executing task (no merge status)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + // Owner is executing in 'in-progress' with NO merge status — registered its land lease early. + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "in-progress", status: null }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); // well past the 180s floor. + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(0); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); + }); + + // ── review D: branch-gone + landedSha-set-but-UNREACHABLE → parked, not re-enqueued forever ─ + it("FORK-A: branch gone + landedSha set but UNREACHABLE → parked failed (not re-enqueued forever)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranch(fx, "repo-a", "a\n"); + const landedA = landRepoForReal(fx, "repo-a"); + // Roll the integration ref BACK so landedA is no longer reachable (force-reset), and delete the branch. + fx.git("repo-a", "git reset --hard HEAD~1"); + fx.git("repo-a", `git branch -D ${BRANCH}`); + + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + // isRepoLanded is FALSE (landedSha unreachable, no trailer on ref) AND branch gone → unrecoverable. + expect(n).toBe(1); + expect(store.tasks.get(TASK_ID)?.status).toBe("failed"); + expect(store.enqueued).not.toContain(TASK_ID); + }); + + // ── review E: failing git worktree remove → logged, isolated, bounded ────── + it("orphan worktree removal failure is bounded and does not abort the sweep", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + // repo-a: a real removable worktree. repo-b: a path that EXISTS but is NOT a git worktree → remove fails. + const wtA = path.join(fx.repoPath("repo-a"), ".wt-task"); + fx.git("repo-a", `git worktree add -b ${BRANCH} ${wtA} HEAD`); + const wtB = path.join(fx.repoPath("repo-b"), ".not-a-worktree"); + execSync(`mkdir -p ${wtB}`, { stdio: "pipe" }); + writeFileSync(path.join(wtB, "stray.txt"), "x", "utf-8"); + expect(existsSync(wtA)).toBe(true); + expect(existsSync(wtB)).toBe(true); + + const task = workspaceTask( + { + "repo-a": { worktreePath: wtA, branch: BRANCH }, + "repo-b": { worktreePath: wtB, branch: BRANCH }, + }, + { column: "done" }, + ); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + // First sweep: repo-a removed (isolated from repo-b's failure); repo-b counted as a failure. + const cleaned1 = await manager.reconcileOrphanedWorkspaceWorktrees(); + expect(cleaned1).toBe(1); + expect(existsSync(wtA)).toBe(false); + // The audit recorded a failure for repo-b (observability), and the sweep did not throw. + expect(store.emitted.length >= 0).toBe(true); + + // Subsequent sweeps keep failing on repo-b but stay bounded — after the bound they stop attempting. + await manager.reconcileOrphanedWorkspaceWorktrees(); + await manager.reconcileOrphanedWorkspaceWorktrees(); + const cleanedAfterBound = await manager.reconcileOrphanedWorkspaceWorktrees(); + // No more successful removals (repo-a already gone) and no crash. + expect(cleanedAfterBound).toBe(0); + }); }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 43dbf72e14..bf92d0dcab 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -190,14 +190,14 @@ export { // FNXC:MergerUnification 2026-06-21-19:05: runAiMerge is the sole merge path // (master-plan U0); exported for the CLI callers (fn task merge + UI-only merge). export { runAiMerge } from "./merger-ai.js"; +// FNXC:Workspace 2026-06-22-14:10 (Phase D review G): canonical landed predicate now lives in its +// own dependency-free module (self-healing ↔ merger-ai cycle dissolved). Public export preserved. +export { isRepoLanded } from "./workspace-land-predicate.js"; // FNXC:Workspace 2026-06-21-23:40 (Phase C U1): per-repo workspace merge loop + // the extracted per-repo land primitive, exported for the CLI/dashboard merge doors. export { landWorkspaceTask, landOneRepo, - // FNXC:Workspace 2026-06-22-04:10 (Phase C review A6): canonical landed predicate, - // re-exported so Phase D self-healing reuses it instead of reimplementing the ancestor check. - isRepoLanded, // FNXC:Workspace 2026-06-22-04:10 (Phase C review A4): real error classes (instanceof-able), // re-exported so the engine dispatch can switch to instanceof in the separate pass. WorkspaceRepoLandBusyError, diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index a9f66a45c8..d26dd622b0 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -74,6 +74,13 @@ import { installWorktreeDependencies } from "./merge-dependency-sync.js"; import { activeSessionRegistry } from "./active-session-registry.js"; import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js"; import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js"; +/* +FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved): +`isRepoLanded` + `FUSION_TASK_ID_TRAILER_KEY` moved to the dependency-free `workspace-land-predicate` +module so self-healing can import the predicate without re-entering the self-healing ↔ merger-ai +import cycle (merger-ai already imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing). +*/ +import { isRepoLanded, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js"; const execFileAsync = promisify(execFile); const aiMergeLog = createLogger("merger-ai"); @@ -99,19 +106,6 @@ async function gitOk(args: string[], cwd: string): Promise { } } -/** - * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1): - * Capture git stdout, returning undefined (never throwing) on failure — for read-only - * probes (merge-base, log --grep) where a non-zero exit is an expected "not found". - */ -async function gitCapture(args: string[], cwd: string): Promise { - try { - return await git(args, cwd); - } catch { - return undefined; - } -} - function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -358,8 +352,6 @@ export async function cleanupAiMergeWorktree(input: { } -const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id"; - /** Trailers that associate the squash commit with its board task: the * `Fusion-Task-Id` trailer plus the canonical lineage trailer when available. * These are what the board's commit→task association parses. */ @@ -1687,74 +1679,10 @@ export async function landWorkspaceTask( return { taskId, repos, allLanded, finalized: false }; } -/** - * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): - * Landed predicate: a sub-repo is landed iff a `landedSha` is recorded AND that sha is - * an ancestor of (or equals) the repo's CURRENT integration tip. The ancestor check - * (not just sha presence) survives a later un-related advance of the integration ref: - * the landed commit is still reachable, so the repo stays "landed". A `landedSha` that - * is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and - * the repo re-lands. - * - * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — task-trailer ancestor fallback): - * The double-land window: a land advances the integration ref via `advanceIntegrationBranchRef`'s - * CAS, then `persistRepoLandedSha` records `landedSha`. If that DB write fails AFTER the ref - * advanced, the repo is ACTUALLY landed but has NO recorded `landedSha`, so the landedSha check - * above reports NOT-landed → a retry re-runs `landOneRepo`, the CAS rebuilds, and a SECOND squash - * lands (not idempotent). To close the window we ALSO treat the repo as landed when the live - * integration ref carries a commit with THIS task's `Fusion-Task-Id` trailer. - * - * Why a trailer scan and NOT a branch-tip ancestor check: the land is a `git merge --squash`, - * whose squash commit's parent is the integration tip, NOT the task branch — so `merge-base - * --is-ancestor ` is FALSE even right after a successful land. The - * `Fusion-Task-Id` trailer (always stamped onto the squash by `taskTrailers` + the - * ensureTaskMetadata safety net) is the only reliable "this task's work is already on the ref" - * signal that does not depend on the landedSha row, so it is what survives a lost persist. We - * bound the scan to commits the integration tip has gained since the branch's merge-base (the - * land base) so an unrelated historical reuse of the same trailer cannot false-positive. - * - * Exported (A6) so Phase D self-healing reuses THIS canonical predicate instead of - * reimplementing the ancestor/trailer check. - */ -export async function isRepoLanded( - repoRootDir: string, - integrationBranch: string, - landedSha: string | undefined, - taskId?: string, - branch?: string, -): Promise { - const intRef = `refs/heads/${integrationBranch}`; - if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { - return false; - } - // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. - // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. - if ( - landedSha && - (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) - ) { - return true; - } - // A1 fallback: even without a recorded landedSha, the repo is already landed if the - // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash - // we lost the persist for). Bound the scan to commits gained since the branch's land base - // so a stale historical trailer of the same id cannot false-positive. - if (taskId) { - const branchRef = branch ? `refs/heads/${branch}` : undefined; - let range = intRef; - if (branchRef && (await gitOk(["rev-parse", "--verify", branchRef], repoRootDir))) { - const base = await gitCapture(["merge-base", branchRef, intRef], repoRootDir); - if (base) range = `${base.trim()}..${intRef}`; - } - const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`; - const found = await gitCapture( - ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], - repoRootDir, - ); - if (found && found.trim().length > 0) return true; - } - return false; -} +// FNXC:Workspace 2026-06-22-14:10 (Phase D review G): `isRepoLanded` now lives in +// `workspace-land-predicate.ts` (cycle dissolved). Re-exported here (the imported binding) so +// existing importers of `./merger-ai.js` keep working unchanged. +export { isRepoLanded }; /** * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 5152a36fc1..3c778655d2 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -493,6 +493,10 @@ export class ProjectEngine { this.runtime.setMergeActiveClearer?.((taskId) => { this.mergeActive.delete(taskId); }); + // FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU): expose the in-memory merge pipeline + // (mergeQueue + mergeActive) to the workspace self-healing reconcilers so they don't + // re-dispatch / reclaim a task that is mid-dequeue→rawMerge. + this.runtime.setMergePendingProvider?.((taskId) => this.isMergePending(taskId)); // Workflow-graph interpreter merge seam: routes through the auto-merge // eligibility gate (requestInterpreterMerge), NOT the human "merge now" // bypass, so a graph merge node can't override an autoMerge-off project. @@ -503,6 +507,26 @@ export class ProjectEngine { return this.activeMergeTaskId; } + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + A workspace task is "merge-pending" if it sits ANYWHERE in this engine's in-memory merge + pipeline: still queued in `mergeQueue`, OR already dequeued-and-dispatching / actively merging + (tracked by `mergeActive`). `mergeActive.add(taskId)` happens at enqueue time and is only removed + when the merge fully settles (try/finally, stale-merge recovery, or stop()), so it — unlike the + liveness signals the workspace reconcilers consult (session registry, executingTaskLock, + isTaskActive, getActiveMergeTaskId, setStatus("merging"), the workspace-repo-land lease) — covers + the WHOLE dequeue→rawMerge window. In that window `pickNextMergeTaskId` has shifted the id out of + `mergeQueue` but `activeMergeTaskId` / `merging` status / the land lease are not yet set (they fire + later inside the post-semaphore `landWorkspaceTask`). The workspace self-healing reconcilers + (reconcileWorkspacePartialLands / reclaimPhantomWorkspaceLandLeases) call this as a guard so they + never re-dispatch (double-squash) or reclaim the not-yet-registered land lease of a task that is + legitimately mid-dispatch. Because `mergeActive` lingers across the entire dequeue→rawMerge + window, checking it in addition to `mergeQueue` closes that TOCTOU gap. + */ + isMergePending(taskId: string): boolean { + return this.mergeActive.has(taskId) || this.mergeQueue.includes(taskId); + } + /** * Start the engine: initialize the runtime and all auxiliary subsystems. */ diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index c9a4d1506c..ddf9a15289 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -148,6 +148,13 @@ export class InProcessRuntime ) => Promise; private clearMergeActive?: (taskId: string) => void; private activeMergeTaskIdProvider?: () => string | null; + /** + * FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU): predicate that reports whether a task is + * anywhere in ProjectEngine's in-memory merge pipeline (queued OR dequeued-and-merging). Set by + * ProjectEngine before `start()` via `setMergePendingProvider`. Used by the workspace + * self-healing reconcilers to avoid re-dispatching / reclaiming a task mid-dequeue→rawMerge. + */ + private mergePendingProvider?: (taskId: string) => boolean; /** Tracks whether startup recovery was intentionally deferred due to pause state. */ private startupRecoveryDeferred = false; /** Prevent duplicate unpause recovery dispatches from racing each other. */ @@ -797,6 +804,9 @@ export class InProcessRuntime isTaskActive: (taskId: string) => this.executor.isTaskActive(taskId), clearMergeActive: this.clearMergeActive ? (taskId: string) => this.clearMergeActive?.(taskId) : undefined, getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null, + // FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU): undefined provider → "not pending" + // (graceful when unwired; existing guards still apply). In production it is always wired. + isMergePending: this.mergePendingProvider ? (taskId: string) => this.mergePendingProvider?.(taskId) ?? false : undefined, leaseManager: this.leaseManager, hasActiveAgentExecution: (agentId: string) => this.heartbeatMonitor?.getTrackedAgents().includes(agentId) ?? false, resumeAssignedTaskForAgent: (agentId: string) => this.executor.resumeTaskForAgent(agentId), @@ -1167,6 +1177,10 @@ export class InProcessRuntime this.activeMergeTaskIdProvider = getActiveMergeTaskId; } + setMergePendingProvider(isMergePending: (taskId: string) => boolean): void { + this.mergePendingProvider = isMergePending; + } + /** * Resume executor/self-healing activity after an unpause transition. * diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 7aae65f4ee..b5b9c91cee 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -46,15 +46,15 @@ import { classifyError, extractMissingModulePath, isNonContinuableSessionError, import { classifyForeignOnlyContamination, deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js"; import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type RunAuditor } from "./run-audit.js"; import { AutoRecoveryDispatcher } from "./auto-recovery.js"; -import { activeSessionRegistry, executingTaskLock, type ActiveSessionKind } from "./active-session-registry.js"; +import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js"; /* -FNXC:Workspace 2026-06-22-09:30 (Phase D U1): -`isRepoLanded` is the CANONICAL per-repo landed predicate (Phase C, exported A6). Self-healing -reuses it rather than reimplementing the ancestor/trailer check. merger-ai also imports a const -from self-healing (MIN_TEMP_WORKTREE_REAP_AGE_MS), so this is a static cycle — safe because -`isRepoLanded` is only referenced at call time, never at module-eval time. +FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved): +`isRepoLanded` is the CANONICAL per-repo landed predicate (Phase C, exported A6). It now lives in +the dependency-free `workspace-land-predicate` module, NOT merger-ai. Previously self-healing +imported it from merger-ai while merger-ai imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from +self-healing — a real import cycle. Importing from the predicate module breaks the cycle. */ -import { isRepoLanded } from "./merger-ai.js"; +import { isRepoLanded } from "./workspace-land-predicate.js"; import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js"; import { isAiMergeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree-paths.js"; import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js"; @@ -327,6 +327,18 @@ export interface SelfHealingOptions { * Used to avoid clearing a transient merge status mid-merge. */ getActiveMergeTaskId?: () => string | null; + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + Returns true if the task is ANYWHERE in ProjectEngine's in-memory merge pipeline — queued in + `mergeQueue` OR dequeued-and-merging (`mergeActive`). Unlike `getActiveMergeTaskId` (only the + single in-flight rawMerge) and the session-registry / executingTaskLock / land-lease signals, + this covers the dequeue→rawMerge window where a workspace task is being merged but NONE of those + signals fire yet. The workspace reconcilers consult it before re-enqueuing a partial-land + candidate (prevents a second concurrent `landWorkspaceTask` → double-squash) or reclaiming a + workspace-repo-land lease (the owner is mid-dispatch and is about to register that lease). + Undefined = "not pending" (graceful when unwired); production always wires it. + */ + isMergePending?: (taskId: string) => boolean; /** * Minimum blocker age before stale merge fan-out is cleared from downstream * blockedBy pointers. Must be >= staleMergingStatusMinAgeMs. @@ -717,6 +729,16 @@ export class SelfHealingManager { // ── Per-task deadlock recovery cooldown ───────────────────────────── private deadlockRecoveryCooldown: Map = new Map(); private mergeStarvationDrops: Map = new Map(); + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review B/E — bounded workspace re-enqueue / orphan-remove): + Per-task drop counter for the workspace partial-land re-enqueue (mirror of `mergeStarvationDrops`): + `enqueueMerge` returns false when the merge queue rejects (full). Without bounding, a perpetually + rejected workspace task is re-enqueued FOREVER. After MAX_STARVATION_DROPS consecutive drops we + park it `status:"failed"`. `orphanWorktreeRemovalFailures` likewise bounds the per-path + `git worktree remove --force` retry in reconcileOrphanedWorkspaceWorktrees. + */ + private workspacePartialLandDrops: Map = new Map(); + private orphanWorktreeRemovalFailures: Map = new Map(); private finalizeUnprovenWarned = new Set(); private metaResolvedSkipAuditMemo = new Map(); private metaStalledSkipAuditMemo = new Map(); @@ -843,6 +865,27 @@ export class SelfHealingManager { return { live, livePaths }; } + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review C — terminal-owner liveness for lease reclaim): + A `workspace-repo-land` lease may only be reclaimed when its owning task ROW is demonstrably + TERMINAL — i.e. not running anymore in any sense. The Phase-D bug: the prior predicate only + treated an in-review task WITH an active transient merge status as live, so a task still in column + `in-progress` (executing, registered its land lease early, no merge status yet) read as NOT live → + its lease was reclaimed MID-EXECUTION. This predicate inverts to the SAFE direction: the owner is + LIVE unless it is provably terminal — null/missing, `done`, or `failed`. Every other state + (`in-progress`, `in-review` with or without a merge status, `todo`, `triage`, paused, etc.) is + treated as LIVE so we never yank a lease out from under a task that could still be running. The + executing-lock / active-merge-lane checks at the call site are an ADDITIONAL live guard on top of + this. (Distinct from `isWorkspaceTaskLive`, which probes the session REGISTRY; this probes the + task ROW lifecycle.) + */ + private isWorkspaceOwnerLive(owner: Task | null | undefined): boolean { + if (!owner) return false; // not found / deleted → terminal. + if (owner.column === "done") return false; + if (owner.status === "failed") return false; + return true; + } + private async evaluateBackwardMoveTripleProof( task: Task, input: { @@ -5466,7 +5509,13 @@ export class SelfHealingManager { allowsAutoMergeProcessing(t, settings) && !t.paused && !isSharedBranchGroupMemberIntegration(t) && + // FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): + // This no-op finalize classifies one branch against one base over `this.options.rootDir` + // and moveTask(done)+emitTaskMerged on it. The `Boolean(t.worktree)` gate already excludes + // workspace tasks (their `task.worktree` is null; per-repo worktrees live in + // `workspaceWorktrees`); `!isWorkspaceTask(t)` makes that exclusion explicit and defensive. Boolean(t.worktree) && + !isWorkspaceTask(t) && t.mergeDetails?.mergeConfirmed !== true && t.status !== "merging" && t.status !== "merging-pr" && @@ -6888,6 +6937,13 @@ export class SelfHealingManager { // recover-stale-merging clear STALE ones. A non-transient status (or null) is our domain. !(task.status && ACTIVE_MERGE_STATUSES.has(task.status)), ); + // Drop counters only track LIVE candidates; forget any task that has left the set so a later + // re-appearance starts fresh (mirror of the mergeStarvationDrops cleanup). + const candidateIds = new Set(candidates.map((t) => t.id)); + for (const taskId of [...this.workspacePartialLandDrops.keys()]) { + if (!candidateIds.has(taskId)) this.workspacePartialLandDrops.delete(taskId); + } + if (candidates.length === 0) return 0; let recovered = 0; @@ -6914,6 +6970,21 @@ export class SelfHealingManager { await this.emitWorkspacePartialLandNoAction(task, "live-worktree", liveness.livePaths); continue; } + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + GUARD 5 — the task is anywhere in ProjectEngine's in-memory merge pipeline (queued or + dequeued-and-dispatching/merging). In the dequeue→rawMerge window the id has been shifted + out of `mergeQueue` but `activeMergeTaskId` / `merging` status / the workspace-repo-land + lease have not yet been set, so GUARDs 1-4 and `isWorkspaceTaskLive` all read "not live". + Re-enqueuing here would launch a SECOND concurrent `landWorkspaceTask(T)`; because a + same-task land lease is explicitly NOT contention, the two don't block → double-squash. + `mergeActive` lingers across the whole window, so this guard closes the gap. Never moves + the task backward; emits no-action and leaves the in-flight dispatch to finish. + */ + if (this.options.isMergePending?.(task.id) === true) { + await this.emitWorkspacePartialLandNoAction(task, "merge-pending", liveness.livePaths); + continue; + } // Classify each acquired sub-repo: landed / retryable / unrecoverable (FORK-A). const workspaceWorktrees = task.workspaceWorktrees ?? {}; @@ -6940,11 +7011,22 @@ export class SelfHealingManager { landedRepos.push(repoRel); continue; } - // Not landed. FORK-A unrecoverable iff the task branch is GONE and nothing landed. + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review D — FORK-A: branch-gone-and-not-landed + is unrecoverable, regardless of a STALE landedSha): + We are here because `isRepoLanded` returned FALSE — the recorded `landedSha` (if any) is + NOT reachable from the integration tip (branch was force-reset / rolled back / never + actually landed) AND no task-trailer commit is on the ref. The old test was + `!branchPresent && !entry.landedSha`, which let a repo with a STALE landedSha set but + UNREACHABLE, and its `fusion/` branch GONE, fall to `unlandedRepos` → re-enqueued → + `landWorkspaceTask` has NO branch to land → loops forever. Since the repo is provably + NOT landed, the correct test is: branch GONE ⇒ unrecoverable, whether or not a (stale) + landedSha is present. Only a branch that still EXISTS is retryable. + */ const branchPresent = entry.branch ? await this.repoBranchExists(repoRootDir, entry.branch) : false; - if (!branchPresent && !entry.landedSha) { + if (!branchPresent) { unrecoverableRepos.push(repoRel); } else { unlandedRepos.push(repoRel); @@ -6977,25 +7059,23 @@ export class SelfHealingManager { if (unlandedRepos.length === 0) { // Every acquired repo is already landed but the task was never finalized (the finalize // enqueue was dropped). Re-enqueue: landWorkspaceTask skips all repos and finalizes once. - this.options.enqueueMerge?.(task.id); - await this.store.logEntry(task.id, "Auto-recovered (workspace): all sub-repos landed but task not finalized — re-enqueued finalize-once"); - await auditor.database({ - type: "task:reconcile-workspace-partial-land", - target: task.id, - metadata: { taskId: task.id, landedRepos, unlandedRepos: [], failedRepos: [], action: "re-enqueue", reason: "all-landed-not-finalized" }, - }).catch(() => undefined); + await this.enqueueWorkspaceMergeBounded(task, auditor, { + landedRepos, + unlandedRepos: [], + reason: "all-landed-not-finalized", + successLog: "Auto-recovered (workspace): all sub-repos landed but task not finalized — re-enqueued finalize-once", + }); recovered++; continue; } // Partial / none landed, all unlanded repos retryable → re-enqueue the per-repo land. - this.options.enqueueMerge?.(task.id); - await this.store.logEntry(task.id, `Auto-recovered (workspace): re-enqueued partial land (${landedRepos.length} landed, ${unlandedRepos.length} pending)`); - await auditor.database({ - type: "task:reconcile-workspace-partial-land", - target: task.id, - metadata: { taskId: task.id, landedRepos, unlandedRepos, failedRepos: [], action: "re-enqueue", reason: landedRepos.length > 0 ? "partial-land" : "zero-land" }, - }).catch(() => undefined); + await this.enqueueWorkspaceMergeBounded(task, auditor, { + landedRepos, + unlandedRepos, + reason: landedRepos.length > 0 ? "partial-land" : "zero-land", + successLog: `Auto-recovered (workspace): re-enqueued partial land (${landedRepos.length} landed, ${unlandedRepos.length} pending)`, + }); recovered++; } catch (err: unknown) { log.error(`reconcileWorkspacePartialLands: failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); @@ -7011,7 +7091,7 @@ export class SelfHealingManager { private async emitWorkspacePartialLandNoAction( task: Task, - reason: "auto-merge-off" | "user-paused" | "live-worktree", + reason: "auto-merge-off" | "user-paused" | "live-worktree" | "merge-pending", livePaths: string[], ): Promise { try { @@ -7031,6 +7111,70 @@ export class SelfHealingManager { } } + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review B — bounded re-enqueue, no silent infinite loop): + Re-enqueue a workspace task's per-repo land via `enqueueMerge`, CAPTURING the boolean it returns. + `enqueueMerge` returns false when the merge queue rejects (full); the old code discarded it, so a + permanently-rejected task would re-enqueue forever. Mirror `mergeStarvationDrops` in + recoverMergeableReviewTasks: on false, increment a per-task drop counter and after + MAX_STARVATION_DROPS consecutive drops park the task `status:"failed"` (escalate). On a successful + enqueue, reset the counter. When `enqueueMerge` is not wired (option undefined), this is a graceful + no-op (not a crash) — recovery falls back to the next sweep / polling. + Returns true iff the task was parked failed. + */ + private async enqueueWorkspaceMergeBounded( + task: Task, + auditor: RunAuditor, + input: { landedRepos: string[]; unlandedRepos: string[]; reason: string; successLog: string }, + ): Promise { + const enqueueMerge = this.options.enqueueMerge; + if (!enqueueMerge) { + // Option not wired (standalone/tests with no queue) → graceful no-op; rely on next sweep. + this.workspacePartialLandDrops.delete(task.id); + await this.store.logEntry(task.id, `${input.successLog} (enqueue not wired — deferred to next sweep)`); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos: input.landedRepos, unlandedRepos: input.unlandedRepos, failedRepos: [], action: "re-enqueue-noop", reason: input.reason }, + }).catch(() => undefined); + return false; + } + + const queued = enqueueMerge(task.id); + if (queued) { + this.workspacePartialLandDrops.delete(task.id); + await this.store.logEntry(task.id, input.successLog); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos: input.landedRepos, unlandedRepos: input.unlandedRepos, failedRepos: [], action: "re-enqueue", reason: input.reason }, + }).catch(() => undefined); + return false; + } + + const drops = (this.workspacePartialLandDrops.get(task.id) ?? 0) + 1; + this.workspacePartialLandDrops.set(task.id, drops); + log.warn(`reconcileWorkspacePartialLands: enqueue dropped for ${task.id} (${drops}/${MAX_STARVATION_DROPS}); merge queue rejected re-enqueue`); + if (drops >= MAX_STARVATION_DROPS) { + const error = `Workspace partial-land starvation: ${MAX_STARVATION_DROPS} consecutive enqueue attempts were dropped by the merge queue; task requires manual intervention.`; + await this.store.updateTask(task.id, { status: "failed", error }); + await this.store.logEntry(task.id, error); + this.workspacePartialLandDrops.delete(task.id); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos: input.landedRepos, unlandedRepos: input.unlandedRepos, failedRepos: [], action: "park-failed", reason: "enqueue-starvation" }, + }).catch(() => undefined); + return true; + } + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos: input.landedRepos, unlandedRepos: input.unlandedRepos, failedRepos: [], action: "re-enqueue-dropped", reason: input.reason, drops }, + }).catch(() => undefined); + return false; + } + /** True iff `branch` exists as a local ref in the sub-repo at `repoRootDir`. */ private async repoBranchExists(repoRootDir: string, branch: string): Promise { try { @@ -7061,7 +7205,7 @@ export class SelfHealingManager { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - const entries = activeSessionRegistry.entriesByKind("workspace-repo-land" as ActiveSessionKind); + const entries = activeSessionRegistry.entriesByKind("workspace-repo-land"); if (entries.length === 0) return 0; const graceMs = settings.taskStuckTimeoutMs ?? STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS; @@ -7078,17 +7222,22 @@ export class SelfHealingManager { // A live merge lane / executing owner keeps the lease. if (activeMergeTaskId && activeMergeTaskId === entry.taskId) continue; if (executingTaskLock.has(entry.taskId) || this.options.isTaskActive?.(entry.taskId) === true) continue; + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + If the owner is anywhere in the in-memory merge pipeline (queued or dequeued-and-merging), + the lease is about to be (or is being) LEGITIMATELY used by an in-flight + `landWorkspaceTask` — it just hasn't registered the lease yet (or registered it this very + instant). `activeMergeTaskId` only names the single in-flight rawMerge and does not cover + the dequeue→rawMerge window, so it can read null here while a dispatch is in progress. + Reclaiming now would yank the lease out from under a live land. Skip; the existing + age-floor + terminal-owner guards still apply once the owner truly settles. + */ + if (this.options.isMergePending?.(entry.taskId) === true) continue; const owner = await this.store.getTask(entry.taskId).catch(() => null); - // Owner is dead/terminal iff: not found, archived/done/failed, OR in-review with NO active - // transient merge status (a merging owner is live; a clean in-review is finished landing). const ownerColumn = owner?.column ?? "deleted"; - const ownerHasActiveMergeStatus = Boolean(owner?.status && ACTIVE_MERGE_STATUSES.has(owner.status)); - const ownerLive = Boolean(owner) - && owner!.column !== "done" - && owner!.status !== "failed" - && ownerHasActiveMergeStatus; - if (ownerLive) continue; // live merging owner → leave its lease alone. + // Only a DEMONSTRABLY TERMINAL owner's lease is reclaimed (review C fix). + if (this.isWorkspaceOwnerLive(owner)) continue; activeSessionRegistry.unregisterPath(entry.path); await createRunAuditor(this.store, { @@ -7142,8 +7291,22 @@ export class SelfHealingManager { if (!worktreePath) continue; // GUARD: skip an active path (mirror self-healing temp-dir sweep isPathActive guard). if (activeSessionRegistry.isPathActive(worktreePath)) continue; - // Nothing on disk → nothing to remove (already cleaned). Skip silently. - if (!existsSync(worktreePath)) continue; + // Nothing on disk → nothing to remove (already cleaned). Skip silently; clear any prior + // failure count so a re-created path starts fresh. + if (!existsSync(worktreePath)) { + this.orphanWorktreeRemovalFailures.delete(worktreePath); + continue; + } + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review E — bounded + observable orphan removal): + A `git worktree remove --force` failure was caught + audit-logged but NOT engine-logged, + and retried EVERY tick FOREVER (a genuinely stuck path pins this sweep indefinitely). Bound + the retry per-path: after MAX_STARVATION_DROPS consecutive failures stop attempting (leave + the path for manual cleanup) and `log.warn` each failure for observability. + */ + if ((this.orphanWorktreeRemovalFailures.get(worktreePath) ?? 0) >= MAX_STARVATION_DROPS) { + continue; // exhausted retries — stop hammering a stuck path. + } const repoRootDir = join(this.options.rootDir, repoRel); let success = false; @@ -7171,8 +7334,13 @@ export class SelfHealingManager { }); } catch { /* audit best-effort */ } if (success) { + this.orphanWorktreeRemovalFailures.delete(worktreePath); log.log(`reconcileOrphanedWorkspaceWorktrees: removed ${worktreePath} (task ${task.id}, repo ${repoRel})`); cleaned++; + } else { + const failures = (this.orphanWorktreeRemovalFailures.get(worktreePath) ?? 0) + 1; + this.orphanWorktreeRemovalFailures.set(worktreePath, failures); + log.warn(`reconcileOrphanedWorkspaceWorktrees: ${reason} for ${worktreePath} (task ${task.id}, repo ${repoRel}) [${failures}/${MAX_STARVATION_DROPS}]${failures >= MAX_STARVATION_DROPS ? " — giving up; manual cleanup required" : ""}`); } } } @@ -7238,6 +7406,19 @@ export class SelfHealingManager { let repaired = 0; for (const task of candidates) { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review F — workspace done-metadata corruption gate): + This reconciler assumes ONE git repo at `this.options.rootDir` and calls `findLandedTaskCommit` + over it. For a workspace task that root is NON-git, so `findLandedTaskCommit` returns null. + `finalizeWorkspaceTask` sets `mergeConfirmed: anyLanded` — a pure NO-OP workspace task (zero + repos landed) is moved to done with `mergeConfirmed:false`, so it reaches the non-confirmed + branch below. There, `landed===null` + a stored `commitSha` would wipe `mergeDetails:undefined` + — corrupting a legitimately-done workspace task's per-repo land map (`workspaceLandedShas`). + The confirmed branch is also meaningless here (no single rootDir commit). Skip workspace tasks + entirely; their mergeDetails are authored once by `finalizeWorkspaceTask` and never need this + single-repo metadata repair. + */ + if (isWorkspaceTask(task)) continue; if (task.mergeDetails?.landedFilesAttributionRestricted || task.mergeDetails?.noOpVerifiedShortCircuit) { log.log(`recoverDoneTaskMergeMetadata: skipped ${task.id} — attribution-restricted`); continue; @@ -7570,6 +7751,30 @@ export class SelfHealingManager { const blockedDependents = dependentsByBlocker.get(task.id) ?? []; const blockedTaskIds = blockedDependents.map((dep) => dep.id); try { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review A — P0 workspace gate, TWIN of KTD1): + This is the deadlock-recovery TWIN of recoverInterruptedMergingTasks. Its candidate + filter admits `hasBlockedDependents || Boolean(task.worktree)`, so a workspace task + (task.worktree===null) WITH blocked dependents passes and would reach the single-commit + `findLandedTaskCommit`/moveTask(done)+emitTaskMerged finalize over the NON-git workspace + root — the exact P0: a one-repo commit (or empty) marking a PARTIAL-landed workspace task + fully merged. A workspace task MUST NOT be single-commit-finalized here. Clear the transient + status, leave it in-review, and let the workspace-aware partial-land reconciler + (reconcileWorkspacePartialLands) re-enqueue the idempotent per-repo land. We never move a + workspace task backward here. + */ + if (isWorkspaceTask(task)) { + if (task.status) await this.store.updateTask(task.id, { status: null, error: null }); + this.options.clearMergeActive?.(task.id); + await this.store.logEntry( + task.id, + "Auto-recovery (workspace): cleared stale deadlock 'failed' status; partial-land reconciler owns per-repo re-land (no single-commit finalize)", + ); + log.warn(`self-heal:deadlock-recovery-workspace-skip ${JSON.stringify({ stuckTaskId: task.id, blockedTaskIds, action: "cleared-status-deferred-to-partial-land-reconciler" })}`); + recovered++; + continue; + } + const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-stuck-merge-deadlocks"); const landedCommit = await this.findLandedTaskCommit(task); const landedOnTarget = landedCommit @@ -7739,6 +7944,14 @@ export class SelfHealingManager { let recovered = 0; for (const task of candidates) { try { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): + `findAlreadyMergedTaskCommit` below runs over `this.options.rootDir` (the NON-git workspace + root for a workspace task), and a hit would single-commit-finalize the WHOLE workspace task + done on one phantom/wrong-repo commit (the P0 class). A workspace task lands PER-REPO; its + recovery is owned by reconcileWorkspacePartialLands. Skip it here. + */ + if (isWorkspaceTask(task)) continue; const recentLogs = "getAgentLogs" in this.store && typeof this.store.getAgentLogs === "function" ? await this.store.getAgentLogs(task.id, { limit: 50 }) : []; @@ -7906,6 +8119,14 @@ export class SelfHealingManager { let recovered = 0; for (const task of candidates) { try { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): + `findAlreadyMergedTaskCommit` runs over `this.options.rootDir` (NON-git for a workspace + task) and a hit would single-commit-finalize the whole workspace task done on one + phantom/wrong-repo commit (the P0 class). Workspace tasks land PER-REPO and are recovered + by reconcileWorkspacePartialLands; skip them here. + */ + if (isWorkspaceTask(task)) continue; const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-already-merged-review"); const baseBranch = mergeTarget.branch; if (!baseBranch) continue; @@ -8256,6 +8477,16 @@ export class SelfHealingManager { let recovered = 0; for (const task of candidates) { try { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): + A workspace task carries a `task.branch` (`fusion/`) even though it lands PER-REPO, so + the `Boolean(task.branch)` candidate filter does NOT exclude it. `isBranchTipMisboundToTask` + + `findAlreadyMergedTaskCommit` run over `this.options.rootDir` (NON-git for a workspace + task); a hit would single-commit-finalize the whole task done on one wrong-repo/phantom + commit (the P0 class). Today the rootDir git calls merely error-by-accident; gate it + explicitly. Workspace recovery is owned by reconcileWorkspacePartialLands. + */ + if (isWorkspaceTask(task)) continue; const branch = task.branch; if (!branch) continue; const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-branch-misbound-in-review"); diff --git a/packages/engine/src/workspace-land-predicate.ts b/packages/engine/src/workspace-land-predicate.ts new file mode 100644 index 0000000000..5f903592b7 --- /dev/null +++ b/packages/engine/src/workspace-land-predicate.ts @@ -0,0 +1,119 @@ +/* +FNXC:Workspace 2026-06-22-14:10 (Phase D review G — dissolve self-healing ↔ merger-ai cycle): +`isRepoLanded` is a PURE per-repo git predicate. It used to live in merger-ai.ts, but Phase D +self-healing imports it (`self-healing.ts` → `merger-ai.ts`) while `merger-ai.ts` already imports +`MIN_TEMP_WORKTREE_REAP_AGE_MS` from `self-healing.ts` — a real import cycle. Moving the predicate +(plus the two tiny read-only git helpers it needs) into this dependency-free module breaks the +cycle: BOTH merger-ai.ts and self-healing.ts import from here, and neither imports the other for +this predicate. The module pulls in NOTHING beyond node:child_process, so it is a clean extraction. +The public `isRepoLanded` export from index.ts is preserved by re-exporting from this module. +*/ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** Canonical Fusion task-id trailer key stamped on every land squash commit. */ +export const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id"; + +async function git(args: string[], cwd: string, opts: { timeout?: number } = {}): Promise { + const { stdout } = await execFileAsync("git", args, { + cwd, + encoding: "utf-8", + timeout: opts.timeout ?? 120_000, + maxBuffer: 16 * 1024 * 1024, + }); + return stdout.trim(); +} + +/** Run git, returning true on exit 0 and false on any failure (read-only probes). */ +async function gitOk(args: string[], cwd: string): Promise { + try { + await git(args, cwd); + return true; + } catch { + return false; + } +} + +/** + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1): + * Capture git stdout, returning undefined (never throwing) on failure — for read-only + * probes (merge-base, log --grep) where a non-zero exit is an expected "not found". + */ +async function gitCapture(args: string[], cwd: string): Promise { + try { + return await git(args, cwd); + } catch { + return undefined; + } +} + +/** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Landed predicate: a sub-repo is landed iff a `landedSha` is recorded AND that sha is + * an ancestor of (or equals) the repo's CURRENT integration tip. The ancestor check + * (not just sha presence) survives a later un-related advance of the integration ref: + * the landed commit is still reachable, so the repo stays "landed". A `landedSha` that + * is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and + * the repo re-lands. + * + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — task-trailer ancestor fallback): + * The double-land window: a land advances the integration ref via `advanceIntegrationBranchRef`'s + * CAS, then `persistRepoLandedSha` records `landedSha`. If that DB write fails AFTER the ref + * advanced, the repo is ACTUALLY landed but has NO recorded `landedSha`, so the landedSha check + * above reports NOT-landed → a retry re-runs `landOneRepo`, the CAS rebuilds, and a SECOND squash + * lands (not idempotent). To close the window we ALSO treat the repo as landed when the live + * integration ref carries a commit with THIS task's `Fusion-Task-Id` trailer. + * + * Why a trailer scan and NOT a branch-tip ancestor check: the land is a `git merge --squash`, + * whose squash commit's parent is the integration tip, NOT the task branch — so `merge-base + * --is-ancestor ` is FALSE even right after a successful land. The + * `Fusion-Task-Id` trailer (always stamped onto the squash by `taskTrailers` + the + * ensureTaskMetadata safety net) is the only reliable "this task's work is already on the ref" + * signal that does not depend on the landedSha row, so it is what survives a lost persist. We + * bound the scan to commits the integration tip has gained since the branch's merge-base (the + * land base) so an unrelated historical reuse of the same trailer cannot false-positive. + * + * Exported (A6) so Phase D self-healing reuses THIS canonical predicate instead of + * reimplementing the ancestor/trailer check. + */ +export async function isRepoLanded( + repoRootDir: string, + integrationBranch: string, + landedSha: string | undefined, + taskId?: string, + branch?: string, +): Promise { + const intRef = `refs/heads/${integrationBranch}`; + if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { + return false; + } + // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. + // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. + if ( + landedSha && + (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) + ) { + return true; + } + // A1 fallback: even without a recorded landedSha, the repo is already landed if the + // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash + // we lost the persist for). Bound the scan to commits gained since the branch's land base + // so a stale historical trailer of the same id cannot false-positive. + if (taskId) { + const branchRef = branch ? `refs/heads/${branch}` : undefined; + let range = intRef; + if (branchRef && (await gitOk(["rev-parse", "--verify", branchRef], repoRootDir))) { + const base = await gitCapture(["merge-base", branchRef, intRef], repoRootDir); + if (base) range = `${base.trim()}..${intRef}`; + } + const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`; + const found = await gitCapture( + ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], + repoRootDir, + ); + if (found && found.trim().length > 0) return true; + } + return false; +} From 95ec4bcdb6bc6d58e192a95daa8b026d3e55c656 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 02:45:46 -0700 Subject: [PATCH 042/265] docs(FN-6880): reconcile changeset + note P1 fix (review #1712) Address CodeRabbit out-of-diff review comments: the feature changeset no longer claims the legacy declaration surface "remains" (U7a retired it; the sibling changeset documents the removal), and now calls out the optional-group enable id-collision fix for release-note visibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workflow-optional-group-subgraphs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/workflow-optional-group-subgraphs.md b/.changeset/workflow-optional-group-subgraphs.md index 46a98cc066..c60dad2060 100644 --- a/.changeset/workflow-optional-group-subgraphs.md +++ b/.changeset/workflow-optional-group-subgraphs.md @@ -2,4 +2,4 @@ "@runfusion/fusion": minor --- -Workflow editor: optional steps are now graph-native. A new `optional-group` container node (foreach/loop-style) holds a subgraph the executor runs once when the group is enabled for a task (per-task `enabledWorkflowSteps` + workflow `defaultOn`) and bypasses when disabled. All seven built-in add-ons (documentation-review, qa-check, security-audit, performance-review, accessibility-check, browser-verification, frontend-ux-design) are insertable from the node-editor palette as a node or wrapped in an optional-group. The built-in coding and stepwise-coding workflows now express `browser-verification` as an optional-group. The legacy declaration-based optional-steps surface remains for back-compat; its full removal is a follow-up. +Workflow editor: optional steps are now graph-native. A new `optional-group` container node (foreach/loop-style) holds a subgraph the executor runs once when the group is enabled for a task (per-task `enabledWorkflowSteps` + workflow `defaultOn`) and bypasses when disabled. All seven built-in add-ons (documentation-review, qa-check, security-audit, performance-review, accessibility-check, browser-verification, frontend-ux-design) are insertable from the node-editor palette as a node or wrapped in an optional-group. The built-in coding and stepwise-coding workflows now express `browser-verification` as an optional-group. Optional-group enable resolution correctly handles id collisions with add-on template ids, so a group's enable state is not silently bypassed during task creation/update. (The legacy declaration-based optional-steps model is retired in a sibling changeset; only the `workflow-step` seam infrastructure removal remains a follow-up.) From 3fd7d124392ca1b5e4db24599a67dd2419ac336b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 02:54:23 -0700 Subject: [PATCH 043/265] fix(review): address PR #1713 review feedback Wrap the fatal-path acquisition observability writes (logEntry + audit.git) in safeObserve so a store/audit throw can't replace the original acquisition error, keeping WorkspaceRepoAcquireBusyError instanceof checks reliable upstream. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/engine/src/worktree-acquisition.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index a59c4e54b4..cde08af260 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -856,11 +856,18 @@ export async function acquireWorkspaceRepoWorktree( if (!(err instanceof WorkspaceRepoAcquireBusyError)) { const message = err instanceof Error ? err.message : String(err); logger?.error?.(`${task.id}: workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`); - await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message }, + // FNXC:Workspace 2026-06-22-09:30: the fatal-path observability writes must use safeObserve + // for the same reason as the non-fatal catches — an unsuppressed throw from logEntry/audit + // would replace `err` as the propagated rejection, so a store/audit hiccup could surface a + // non-WorkspaceRepoAcquireBusyError to callers whose `instanceof` type checks then misfire. + // The original acquisition `err` (line below) is the contract; observability is best-effort. + await safeObserve(async () => { + await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message }, + }); }); } throw err; From 3a71237624899aa25c8c7e01c0f2cfcd3b8c4784 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 03:04:21 -0700 Subject: [PATCH 044/265] fix(review): address PR #1717 Phase C merge-loop review feedback - merger-ai: resolve+persist concrete landedSha when a sub-repo is recognized already-landed via the Fusion-Task-Id trailer fallback, so finalize no longer drops it and mis-finalizes a fully-landed workspace task as a no-op - project-engine: manual-merge land-lease busy errors reject the resolver without burning mergeRetries; clear stale busy-reenqueue counter on real partial land; persist retry count before arming the backoff timer (fail closed on write error) - cli/dashboard + task: use shared isWorkspaceTask predicate instead of inlining - base-commit-capture: POSIX single-quote shell escaping for integration ref - git-repository: validate workspace.json repos elements are strings - merger-ai: drop dead store param from landOneRepo - tests: assert the 60s backoff cap across cycles; exercise the real runAiMerge merge door; fix non-git-root assertion; re-export real workspace error classes in the merger-ai mock (fixes 24 pre-existing instanceof-undefined failures); remove generic fake-timer smoke test now covered by the live engine assertion Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fix-workspace-phase-c-review-round-2.md | 5 ++ packages/cli/src/commands/dashboard.ts | 6 +- packages/cli/src/commands/task.ts | 7 +- packages/core/src/git-repository.ts | 6 +- .../src/__tests__/executor-workspace.test.ts | 6 +- .../__tests__/merge-error-recovery.test.ts | 15 +++- .../src/__tests__/project-engine.test.ts | 39 +++++++++- .../workspace-merger-idempotency.test.ts | 21 ++---- .../src/__tests__/workspace-merger.test.ts | 22 +++++- packages/engine/src/base-commit-capture.ts | 12 ++-- packages/engine/src/merger-ai.ts | 71 ++++++++++++++++--- packages/engine/src/project-engine.ts | 48 ++++++++++++- 12 files changed, 212 insertions(+), 46 deletions(-) create mode 100644 .changeset/fix-workspace-phase-c-review-round-2.md diff --git a/.changeset/fix-workspace-phase-c-review-round-2.md b/.changeset/fix-workspace-phase-c-review-round-2.md new file mode 100644 index 0000000000..7eba89c430 --- /dev/null +++ b/.changeset/fix-workspace-phase-c-review-round-2.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Address Phase C workspace merge-loop review feedback. A sub-repo recognized as already-landed via the `Fusion-Task-Id` trailer fallback (when its `landedSha` persist was lost) now resolves and re-records a concrete `landedSha`, so finalize no longer drops it and mis-reports a fully-landed workspace task as a no-op (`mergeConfirmed:false`). A manual merge that hits sub-repo land-lease contention now surfaces the busy error to the user without consuming the persisted `mergeRetries` quota (matching the auto path's separate busy counter). The partial-land retry persists the incremented retry count before arming the backoff timer — a failed write now fails closed instead of looping without consuming budget — and clears the stale busy-contention counter when a real partial land supersedes transient busy failures. The CLI and dashboard merge doors use the shared `isWorkspaceTask` predicate instead of re-inlining the workspace check, and integration-branch shell interpolation in base-commit capture uses POSIX single-quote escaping. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 7986396445..cce2877d43 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -17,6 +17,7 @@ import { resolveGlobalDir, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS, isWorkflowColumnsEnabled, + isWorkspaceTask, resolveColumnFlags, BUILTIN_CODING_WORKFLOW_IR, mergeBuiltInZaiProviderModels, @@ -1312,8 +1313,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // Phase C (user decision). U0's R7 throw is replaced here by routing; the engine // chokepoint + store.mergeTask/aiMergeTask keep throwing as defense-in-depth. const mergeTask = await store.getTask(taskId).catch(() => null); - const isWorkspaceMerge = - !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; + // FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask` + // (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check. + const isWorkspaceMerge = !!mergeTask && isWorkspaceTask(mergeTask); if (isWorkspaceMerge) { const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, { agentStore, diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index b763676d38..3fca040855 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,4 +1,4 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; import { runAiMerge, landWorkspaceTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; @@ -858,8 +858,9 @@ export async function runTaskMerge(id: string, projectName?: string) { // Phase C (user decision). U0's R7 throw is replaced here by routing; the // engine chokepoint + store.mergeTask/aiMergeTask keep throwing. const mergeTaskRecord = await store.getTask(id).catch(() => null); - const isWorkspaceMerge = - !!mergeTaskRecord?.workspaceWorktrees && Object.keys(mergeTaskRecord.workspaceWorktrees).length > 0; + // FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask` + // (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check. + const isWorkspaceMerge = !!mergeTaskRecord && isWorkspaceTask(mergeTaskRecord); if (isWorkspaceMerge) { const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, { onAgentText: (delta) => process.stdout.write(delta), diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index 974c5d12a6..148179a163 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -139,11 +139,15 @@ export async function loadWorkspaceConfig(rootDir: string): Promise typeof r === "string") ) { return parsed as WorkspaceConfig; } diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts index 330915e966..685fd85984 100644 --- a/packages/engine/src/__tests__/executor-workspace.test.ts +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -48,8 +48,10 @@ describeIfGit("workspace fixture", () => { it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => { fx = await createWorkspaceFixture(); - // Root is NOT a git repo. - expect(() => fx.git("..", "git rev-parse --git-dir")).toThrow(); + // Root itself is NOT a git repo (`.` resolves to rootDir, not its parent — `..` would + // test tmpdir, which proves nothing about the invariant). git rev-parse --git-dir throws + // (exits non-zero) only when run outside any git repo. + expect(() => fx.git(".", "git rev-parse --git-dir")).toThrow(); // Each sub-repo is a real git repo with a commit on main. expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main"); expect(fx.git("repo-b", "git rev-list --count HEAD")).toBe("1"); diff --git a/packages/engine/src/__tests__/merge-error-recovery.test.ts b/packages/engine/src/__tests__/merge-error-recovery.test.ts index 57465cf121..32e8fae4ac 100644 --- a/packages/engine/src/__tests__/merge-error-recovery.test.ts +++ b/packages/engine/src/__tests__/merge-error-recovery.test.ts @@ -28,9 +28,18 @@ vi.mock("../merger.js", () => ({ VerificationError: testState.VerificationError, })); -vi.mock("../merger-ai.js", () => ({ - runAiMerge: testState.runAiMerge, -})); +// FNXC:Workspace 2026-06-22-09:30 (Phase C review fix): the dispatch's error handler does +// `err instanceof WorkspaceRepoLandBusyError` / `WorkspacePartialLandError` on EVERY merge error +// (these classes are imported from ./merger-ai.js). A bare replacement mock left them undefined, +// so `instanceof undefined` threw on every recovery path (24 pre-existing red tests). Re-export the +// REAL error classes via importOriginal so the instanceof guards evaluate; only runAiMerge is faked. +vi.mock("../merger-ai.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runAiMerge: testState.runAiMerge, + }; +}); vi.mock("../runtimes/in-process-runtime.js", () => ({ InProcessRuntime: vi.fn().mockImplementation(function () { diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index d88bdd9483..e43fea6ee3 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1546,9 +1546,42 @@ describe("ProjectEngine workspace merge dispatch hardening (Phase C review)", () .some((c) => c[0] === "FN-WSH" && typeof c[1]?.mergeRetries === "number"); expect(burnedRetries).toBe(false); - // Drive several busy re-enqueues; the backoff must stay capped at 60s. - enqueueSpy.mockClear(); - await vi.advanceTimersByTimeAsync(60_000); // first backoff (5s) fires → re-enqueue + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B5b — assert the 60s CAP, not just the first retry): + Advancing 60s once only proves the first 5s timer fired; an UNcapped exponential + (5s,10s,20s,40s,80s,160s,…) would still pass that. Capture EVERY scheduled busy backoff delay + across enough cycles to pass the cap point (busyCount=4 → 5000*2^4 = 80_000ms, clamped to 60_000) + and assert no delay exceeds 60_000 AND the cap is actually reached. Each advance fires the pending + timer → re-enqueue → landWorkspaceTask rejects busy again → next backoff is scheduled. + */ + const scheduledBusyDelays: number[] = []; + // `globalThis.setTimeout` is already the fake-timer impl here (vi.useFakeTimers above). + // Wrap it to record the requested delay, then delegate to the SAME fake timer so the + // fake clock still drives the callback — no real-timer leakage. + const fakeSetTimeout = globalThis.setTimeout; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(((cb: (...a: unknown[]) => void, ms?: number, ...rest: unknown[]) => { + if (typeof ms === "number") scheduledBusyDelays.push(ms); + return (fakeSetTimeout as (...a: unknown[]) => unknown)(cb, ms, ...rest); + }) as typeof setTimeout); + + try { + // Drive enough busy cycles to climb past the cap point (busyCount 0..5 = 6 cycles). + for (let i = 0; i < 6; i++) { + await vi.advanceTimersByTimeAsync(60_000); + } + } finally { + setTimeoutSpy.mockRestore(); + } + + // The exponential climbed (more than one distinct delay) AND every delay is capped at 60s. + expect(scheduledBusyDelays.length).toBeGreaterThanOrEqual(5); + expect(Math.max(...scheduledBusyDelays)).toBe(60_000); + expect(scheduledBusyDelays.every((d) => d <= 60_000)).toBe(true); + // The cap was actually exercised: at least one delay sits at the 60s ceiling. + expect(scheduledBusyDelays).toContain(60_000); + // Each fired backoff re-enqueued the merge (the contention retry loop is live). expect(enqueueSpy).toHaveBeenCalledWith("FN-WSH"); await engine.stop(); diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts index fce5724b44..c53e10ffb3 100644 --- a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -20,7 +20,7 @@ Coverage (FN-5893 surfaces): - retry/park: a partial-land failure consumes one mergeRetry; after MAX it parks (shouldRetryWorkspacePartialLand boundary, fake timers). */ -import { afterEach, beforeEach, afterAll, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; import { writeFileSync } from "node:fs"; @@ -426,10 +426,11 @@ describeIfGit("landWorkspaceTask — DB-failure resilience (Phase C review A1/A4 }); }); -describe("workspace partial-land retry/park decision (engine seam, fake timers)", () => { - beforeEach(() => vi.useFakeTimers()); - afterAll(() => vi.useRealTimers()); - +// FNXC:Workspace 2026-06-22-09:30 (Phase C review nit): the former generic "fake-timer backoff +// schedule does not spin real retries" smoke test only proved Vitest's fake timers work — it never +// drove the production retry seam. The real backoff-cap invariant is now asserted against the live +// ProjectEngine in project-engine.test.ts ("B4/B5: busy contention re-enqueues with capped backoff"). +describe("workspace partial-land retry/park decision (engine seam)", () => { it("consumes a mergeRetry up to MAX, then parks (shouldRetryWorkspacePartialLand)", () => { // Default MAX = 3. currentRetries + 1 < MAX gates retry. expect(shouldRetryWorkspacePartialLand(0, {})).toMatchObject({ @@ -452,14 +453,4 @@ describe("workspace partial-land retry/park decision (engine seam, fake timers)" expect(shouldRetryWorkspacePartialLand(3, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(true); expect(shouldRetryWorkspacePartialLand(4, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(false); }); - - it("fake-timer backoff schedule does not spin real retries", () => { - // The dispatch schedules internalEnqueueMerge via setTimeout(5000 * 2^retries). - // Assert a scheduled callback exists and only fires when advanced — no real wait. - const fired: number[] = []; - setTimeout(() => fired.push(1), 5000); - expect(fired).toHaveLength(0); - vi.advanceTimersByTime(5000); - expect(fired).toHaveLength(1); - }); }); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index fe15703435..8973c66c70 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -30,7 +30,7 @@ import { writeFileSync } from "node:fs"; import path from "node:path"; import type { Task, TaskStore } from "@fusion/core"; import { assertNotWorkspaceTaskMerge } from "@fusion/core"; -import { landWorkspaceTask } from "../merger-ai.js"; +import { landWorkspaceTask, runAiMerge } from "../merger-ai.js"; import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; const describeIfGit = hasGit ? describe : describe.skip; @@ -292,4 +292,24 @@ describe("workspace merge defense-in-depth (non-routed doors keep throwing)", () const task = { id: TASK_ID } as unknown as Task; expect(() => assertNotWorkspaceTaskMerge(task)).not.toThrow(); }); + + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B11 — exercise the REAL merge door, not only the helper): + Calling `assertNotWorkspaceTaskMerge` directly proves the helper, but a regression where `runAiMerge` + (the sole engine merge door, R7 chokepoint) stopped invoking it would slip through. Drive the actual + door with a minimal store whose `getTask` returns the workspace task: `runAiMerge` reads the task and + calls the guard BEFORE any git work, so it rejects with WorkspaceTaskMergeError without a real repo. + */ + it("runAiMerge (engine merge door) rejects a workspace task with WorkspaceTaskMergeError", async () => { + const workspaceTask = { + id: TASK_ID, + workspaceWorktrees: { "repo-a": { worktreePath: "/x/repo-a", branch: BRANCH } }, + } as unknown as Task; + const store = { + getTask: vi.fn(async () => workspaceTask), + } as unknown as TaskStore; + await expect(runAiMerge(store, "/x", TASK_ID)).rejects.toMatchObject({ + name: "WorkspaceTaskMergeError", + }); + }); }); diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index 4d9e778774..aea3bfbedc 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -39,10 +39,14 @@ export async function resolveCapturedBaseCommitSha( integrationBranch: string = "main", ): Promise { const branch = integrationBranch.trim() || "main"; - // Shell-quote defensively; integration branch names are normalized upstream - // but may carry slashes (e.g. "release/2026-06") that are valid in refs. - const localRef = JSON.stringify(branch); - const originRef = JSON.stringify(`origin/${branch}`); + // FNXC:Workspace 2026-06-22-09:30 (Phase C review nit — proper POSIX single-quote shell escaping): + // Integration branch names are normalized upstream but may carry slashes (e.g. "release/2026-06") + // and, in principle, other ref-legal chars. JSON.stringify uses DOUBLE quotes, under which `$`, + // backticks, and `!` still undergo shell expansion. Single-quote and escape embedded single quotes + // ('\'') so the value is passed verbatim to git with no shell interpretation. + const shellQuote = (s: string): string => `'${s.replace(/'/g, "'\\''")}'`; + const localRef = shellQuote(branch); + const originRef = shellQuote(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index a9f66a45c8..a9a407e447 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -1023,8 +1023,11 @@ export type LandOneRepoResult = * repo-scoped clean room, retrying on concurrent advance. No remote push. See * the FNXC note above for the extraction contract. */ +// FNXC:Workspace 2026-06-22-09:30 (Phase C review B12): `landOneRepo` takes its store access +// exclusively through the `ctx` callbacks (log/setStatus/audit) and pre-built agents — it never +// touches a TaskStore directly. The former leading `store` param was dead and misleading at the +// call sites (they looked like they forwarded a store the function ignored), so it was dropped. export async function landOneRepo( - store: TaskStore, repoRootDir: string, branch: string, integrationBranch: string, @@ -1273,7 +1276,7 @@ export async function runAiMerge( // once; the task-global finalization below (empty no-op / no-commits demote / // finalizeMerged) is unchanged byte-for-byte — only the inline clean-room land // loop moved into `landOneRepo` so `landWorkspaceTask` can reuse it per sub-repo. - const landResult = await landOneRepo(store, projectRootDir, branch, integrationBranch, { + const landResult = await landOneRepo(projectRootDir, branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, @@ -1561,11 +1564,28 @@ export async function landWorkspaceTask( // ancestor of (or equals) its CURRENT integration tip is already landed — SKIP // it so a retry never re-advances the ref. This makes a re-run after a partial // land idempotent for the already-landed repos. - if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, taskId, entry.branch)) { - await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`); + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review A1 — concrete landedSha on the skip path): + Resolve a CONCRETE landed sha (recorded landedSha OR the trailer-fallback squash sha) rather + than trusting `entry.landedSha`, which is `undefined` when the land's persist was lost and only + the A1 trailer fallback recognises the repo. If we recovered the sha via the fallback, REPAIR + the persisted entry so a later run (and `finalizeWorkspaceTask`) sees a present landedSha. A + repair-persist failure is non-fatal: we still carry the concrete sha in-memory for this run's + finalize, and the trailer fallback will re-recover it next time. + */ + const recoveredLandedSha = await resolveLandedShaIfLanded( + repoRootDir, integrationBranch, entry.landedSha, taskId, entry.branch, + ); + if (recoveredLandedSha) { + if (!entry.landedSha) { + await persistRepoLandedSha(store, taskId, repoRel, recoveredLandedSha).catch(async (persistErr: unknown) => { + await log(`AI merge (workspace): sub-repo ${repoRel} re-recorded landedSha (${short(recoveredLandedSha)}) persist failed (non-fatal, trailer fallback will re-recover): ${getErrorMessage(persistErr)}`); + }); + } + await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(recoveredLandedSha)} ⊑ ${integrationBranch}) — skipping`); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, - status: "landed", landedSha: entry.landedSha, alreadyLanded: true, + status: "landed", landedSha: recoveredLandedSha, alreadyLanded: true, }); continue; } @@ -1601,7 +1621,7 @@ export async function landWorkspaceTask( }); try { - const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, { + const landResult = await landOneRepo(repoRootDir, entry.branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, @@ -1723,9 +1743,36 @@ export async function isRepoLanded( taskId?: string, branch?: string, ): Promise { + return ( + (await resolveLandedShaIfLanded(repoRootDir, integrationBranch, landedSha, taskId, branch)) !== + undefined + ); +} + +/** + * FNXC:Workspace 2026-06-22-09:30 (Phase C review A1 — concrete landedSha on trailer fallback): + * The shared core of {@link isRepoLanded}: returns a CONCRETE landed sha when the sub-repo is + * already landed, else `undefined`. When the recorded `landedSha` survives it is returned as-is; + * when the A1 trailer fallback matches (the persist was lost so no `landedSha` is recorded) the + * concrete squash sha is read off the integration ref via the same bounded trailer scan. + * + * Why this matters (review A1 / finalize misfinalise): the `landWorkspaceTask` skip path and + * `finalizeWorkspaceTask` both key off a present `landedSha`. A trailer-fallback match with a + * `undefined` recorded sha would be dropped by the finalize filter, finalizing an already-landed + * task as a no-op (`mergeConfirmed:false`, empty `workspaceLandedShas`) — the exact dashboard + * `merged:false` contradiction Phase C set out to eliminate. Resolving the concrete sha here lets + * the skip path persist+propagate it so the repo is correctly counted as landed. + */ +async function resolveLandedShaIfLanded( + repoRootDir: string, + integrationBranch: string, + landedSha: string | undefined, + taskId?: string, + branch?: string, +): Promise { const intRef = `refs/heads/${integrationBranch}`; if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { - return false; + return undefined; } // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. @@ -1733,12 +1780,13 @@ export async function isRepoLanded( landedSha && (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) ) { - return true; + return landedSha; } // A1 fallback: even without a recorded landedSha, the repo is already landed if the // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash // we lost the persist for). Bound the scan to commits gained since the branch's land base - // so a stale historical trailer of the same id cannot false-positive. + // so a stale historical trailer of the same id cannot false-positive. Return the MOST RECENT + // matching commit sha (the squash) so callers can persist a concrete landedSha. if (taskId) { const branchRef = branch ? `refs/heads/${branch}` : undefined; let range = intRef; @@ -1751,9 +1799,10 @@ export async function isRepoLanded( ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], repoRootDir, ); - if (found && found.trim().length > 0) return true; + const firstSha = found?.split(/\r?\n/).map((s) => s.trim()).find((s) => s.length > 0); + if (firstSha) return firstSha; } - return false; + return undefined; } /** diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 5152a36fc1..2a5c072950 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -2495,6 +2495,23 @@ export class ProjectEngine { retries on busy-errors before either makes a real land attempt, then parking a never-failed task. Detect via `instanceof` now that both are exported classes (B7). */ + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B7b — manual-merge busy must NOT burn mergeRetries): + A manual merge (hasManualResolver) that hits sub-repo land contention is the SAME transient + lease contention as the auto path, NOT a real land failure. Without this branch it falls + through to the generic handler below, which increments the persisted `mergeRetries` quota — + so a user mashing the merge button during contention could exhaust retries before any real + land attempt. Reject the resolver so the busy error surfaces to the user (they can retry), + WITHOUT consuming a mergeRetry. No re-enqueue: manual merges are user-driven, not engine-timed. + */ + if (err instanceof WorkspaceRepoLandBusyError && hasManualResolver) { + await store + .logEntry(taskId, `Workspace sub-repo land busy (contention): ${errorMsg}`, "WorkspaceRepoLandBusy") + .catch(() => undefined); + this.rejectMergeResolvers(taskId, err instanceof Error ? err : new Error(errorMsg)); + continue; + } + if (err instanceof WorkspaceRepoLandBusyError && !hasManualResolver) { const busyCount = this.workspaceBusyReenqueues.get(taskId) ?? 0; await store @@ -2537,6 +2554,15 @@ export class ProjectEngine { // (B6). Detect via `instanceof` (B7). Manual merges fall through to // rejectMergeResolvers at the hasManualResolver early-return below. if (err instanceof WorkspacePartialLandError && !hasManualResolver) { + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B8 — clear stale busy quota on real outcome): + Reaching a REAL partial land means the prior transient busy contention is over. The + `workspaceBusyReenqueues` counter is otherwise only cleared on success or busy-cap + exhaustion, so a few transient busy failures followed by a real partial land would leave + a stale count — later UNRELATED contention would then resume from it and park the task + early. Clear it here so each fresh contention episode gets the full busy budget. + */ + this.workspaceBusyReenqueues.delete(taskId); const wsSettings = await store.getSettings().catch(() => null); const wsTask = await store.getTask(taskId).catch(() => null); /* @@ -2574,7 +2600,27 @@ export class ProjectEngine { .logEntry(taskId, `Workspace partial land: ${errorMsg}`, "WorkspacePartialLand") .catch(() => undefined); if (decision.shouldRetry) { - await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }).catch(() => undefined); + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B9 — persist retry count BEFORE arming the timer): + The retry-count write must succeed before we schedule the retry. A swallowed + `.catch(() => undefined)` here armed the timer even when the `mergeRetries` increment + never landed — so the next attempt re-read the OLD `mergeRetries` and could loop without + consuming budget, defeating the fail-closed DB-outage guard above. FAIL CLOSED: if the + write throws, park as failed (best-effort) and do NOT schedule a retry storm against a + non-responsive DB; the cooldown sweep re-evaluates once the DB recovers. + */ + try { + await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }); + } catch (persistErr: unknown) { + const pmsg = persistErr instanceof Error ? persistErr.message : String(persistErr); + runtimeLog.error( + `Auto-merge: ${taskId} workspace partial land retry NOT scheduled — mergeRetries could not be persisted (DB outage?), failing closed instead of a retry storm: ${pmsg}`, + ); + await store + .updateTask(taskId, { status: "failed", error: errorMsg }) + .catch(() => undefined); + continue; + } // Capped exponential backoff (B5): cap at 60s so a tuned maxAutoMergeRetries doesn't // push the delay toward ~85 minutes at the ceiling. const delayMs = Math.min(5000 * Math.pow(2, wsRetries), 60_000); From 314e54f1c38edaa4f3d23369269f99aa168839c0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 03:32:08 -0700 Subject: [PATCH 045/265] Update README.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 225e113261..0a16dd8c62 100644 --- a/README.md +++ b/README.md @@ -268,14 +268,18 @@ A built-in mailbox for delegation, clarification, and hand-offs. Agents file tri The full board, Command Center, missions, agents, and chat travel with you — native **iOS** and **Android** apps (Capacitor) plus an installable PWA. Start a run on your laptop, steer it from your phone. -
-Fusion mobile: board -Fusion mobile: Command Center -Fusion mobile: missions -Fusion mobile: agents -Fusion mobile: agent chat -Fusion mobile: chat list -
+ + + + + + + + + + + +
Fusion mobile: boardFusion mobile: Command CenterFusion mobile: missions
Fusion mobile: agentsFusion mobile: agent chatFusion mobile: chat list
See [MOBILE.md](./MOBILE.md) for the Capacitor + PWA workflow. From 8c478adc78cc28b1d61cf940557f3e4ce6d1b722 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:51:12 -0700 Subject: [PATCH 046/265] FN-6851: prevent stale task board entries Keep board listings consistent after task dependency moves.\n\n- Sync the watched task cache after dependency updates write todo-to-triage re-specification moves.\n- Deduplicate listTasks results so active task rows win over archived snapshots.\n- Cover dependency edits, archive snapshots, soft deletes, done rows, and orphan reconciliation with regression tests.\n- Add a patch changeset for the published Fusion package.\n\nFiles changed:\n .../fn-6851-stale-board-entries-after-move.md | 5 +\n .../store-stale-board-entries-after-move.test.ts | 144 +++++++++++++++++++++\n packages/core/src/store.ts | 18 ++-\n 3 files changed, 157 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-6851 Fusion-Task-Lineage: 4622439e-631c-4410-be96-9a9f38c1815e --- .../fn-6851-stale-board-entries-after-move.md | 5 + ...ore-stale-board-entries-after-move.test.ts | 144 ++++++++++++++++++ packages/core/src/store.ts | 18 +-- 3 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-6851-stale-board-entries-after-move.md create mode 100644 packages/core/src/__tests__/store-stale-board-entries-after-move.test.ts diff --git a/.changeset/fn-6851-stale-board-entries-after-move.md b/.changeset/fn-6851-stale-board-entries-after-move.md new file mode 100644 index 0000000000..4861403d68 --- /dev/null +++ b/.changeset/fn-6851-stale-board-entries-after-move.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix stale board entries after dependency-driven task re-specification moves by syncing the watched task cache after `updateTaskDependencies` writes and defensively deduplicating `listTasks` rows so active task rows win over archived snapshots. diff --git a/packages/core/src/__tests__/store-stale-board-entries-after-move.test.ts b/packages/core/src/__tests__/store-stale-board-entries-after-move.test.ts new file mode 100644 index 0000000000..a8709f5b4c --- /dev/null +++ b/packages/core/src/__tests__/store-stale-board-entries-after-move.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { rm } from "node:fs/promises"; + +import { TaskStore } from "../store.js"; +import { makeTmpDir } from "./store-test-helpers.js"; +import type { Task } from "../types.js"; + +const liveColumns = new Set(["triage", "todo", "in-progress", "in-review", "done"]); + +function cachedTask(store: TaskStore, taskId: string): Task | undefined { + return (store as unknown as { taskCache: Map }).taskCache.get(taskId); +} + +async function expectSingleLiveBoardEntry(store: TaskStore, taskId: string, expectedColumn: string) { + const listed = await store.listTasks({ includeArchived: true, slim: true }); + const entries = listed.filter((task) => task.id === taskId && liveColumns.has(task.column)); + expect(entries.map((task) => task.column)).toEqual([expectedColumn]); +} + +describe("TaskStore stale board entries after task moves", () => { + let rootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + globalDir = makeTmpDir(); + store = new TaskStore(rootDir, globalDir); + await store.init(); + await store.watch(); + }); + + afterEach(async () => { + store.stopWatching(); + await store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("syncs taskCache after dependency-driven todo to triage re-specification moves", async () => { + const dependency = await store.createTask({ description: "unresolved dependency", column: "todo" }); + const dependent = await store.createTask({ + title: "Shadcn-family themes: left sidebar must use the theme accent color", + description: "dependent task", + column: "todo", + }); + (store as unknown as { taskCache: Map }).taskCache.set(dependent.id, { ...dependent }); + + const updated = await store.updateTaskDependencies(dependent.id, { + operation: "add", + dependency: dependency.id, + }); + const persisted = await store.getTask(dependent.id); + const cached = cachedTask(store, dependent.id); + + expect(updated.column).toBe("triage"); + expect(cached?.column).toBe("triage"); + expect(cached?.title).toBe(persisted.title); + expect(cached?.title).toBe(updated.title); + expect(persisted.column).toBe(cached?.column); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + }); + + it("keeps one live board entry across dependency edits and triage/todo moves", async () => { + const originalDependency = await store.createTask({ description: "original unresolved dependency", column: "todo" }); + const replacementDependency = await store.createTask({ description: "replacement unresolved dependency", column: "todo" }); + const doneDependency = await store.createTask({ description: "done dependency", column: "done" }); + const dependent = await store.createTask({ description: "dependent task", column: "todo" }); + (store as unknown as { taskCache: Map }).taskCache.set(dependent.id, { ...dependent }); + + await store.updateTaskDependencies(dependent.id, { operation: "add", dependency: originalDependency.id }); + expect(cachedTask(store, dependent.id)?.column).toBe("triage"); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + + await store.updateTaskDependencies(dependent.id, { operation: "remove", dependency: originalDependency.id }); + expect(cachedTask(store, dependent.id)?.dependencies).toEqual([]); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + + await store.moveTask(dependent.id, "todo"); + expect(cachedTask(store, dependent.id)?.column).toBe("todo"); + await expectSingleLiveBoardEntry(store, dependent.id, "todo"); + + await store.updateTaskDependencies(dependent.id, { operation: "add", dependency: originalDependency.id }); + await store.updateTaskDependencies(dependent.id, { + operation: "replace", + from: originalDependency.id, + to: replacementDependency.id, + }); + expect(cachedTask(store, dependent.id)?.dependencies).toEqual([replacementDependency.id]); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + + await store.updateTaskDependencies(dependent.id, { operation: "set", dependencies: [doneDependency.id] }); + expect(cachedTask(store, dependent.id)?.dependencies).toEqual([doneDependency.id]); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + + await store.moveTask(dependent.id, "todo"); + expect(cachedTask(store, dependent.id)?.column).toBe("todo"); + await expectSingleLiveBoardEntry(store, dependent.id, "todo"); + }); + + it("dedupes listTasks with active rows authoritative over archive snapshots", async () => { + const task = await store.createTask({ title: "archived snapshot title", description: "duplicate source", column: "done" }); + await store.archiveTask(task.id, true); + const entry = (store as any).archiveDb.get(task.id); + expect(entry).toBeDefined(); + + const restored = await (store as any).restoreFromArchive(entry); + const active: Task = { + ...restored, + title: "active row title", + column: "todo", + updatedAt: new Date().toISOString(), + columnMovedAt: new Date().toISOString(), + }; + await (store as any).atomicWriteTaskJson((store as any).taskDir(task.id), active); + + const entries = (await store.listTasks({ includeArchived: true, slim: true })).filter((listed) => listed.id === task.id); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ column: "todo", title: "active row title" }); + }); + + it("preserves archived, soft-deleted, done, and orphan-reconcile list semantics", async () => { + const archivedSource = await store.createTask({ description: "archive-only task", column: "done" }); + await store.archiveTask(archivedSource.id, true); + const archivedEntries = (await store.listTasks({ includeArchived: true, slim: true })).filter((task) => task.id === archivedSource.id); + expect(archivedEntries).toHaveLength(1); + expect(archivedEntries[0].column).toBe("archived"); + + const deleted = await store.createTask({ description: "soft deleted task", column: "todo" }); + await store.deleteTask(deleted.id); + expect((await store.listTasks({ includeArchived: true, slim: true })).some((task) => task.id === deleted.id)).toBe(false); + + const done = await store.createTask({ description: "done task", column: "done" }); + await expectSingleLiveBoardEntry(store, done.id, "done"); + + const orphan = await store.createTask({ description: "orphan task", column: "todo" }); + (store as any).db.prepare("DELETE FROM tasks WHERE id = ?").run(orphan.id); + (store as any).taskCache.delete(orphan.id); + const result = await store.reconcileOrphanedTaskDirs({ ignoreRecencyWindow: true }); + expect(result.recovered).toContain(orphan.id); + await expectSingleLiveBoardEntry(store, orphan.id, "todo"); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 6df8418f6c..15f4586fd3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -5771,11 +5771,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const steps = await this.parseStepsFromPrompt(task.id); return steps.length > 0 ? { ...task, steps } : task; })); - const archivedTasks = includeArchived && (!columnFilter || columnFilter === "archived") - ? this.archiveDb.list().map((entry) => this.archiveEntryToTask(entry, slim)) - : []; - const tasks = [...activeTasks, ...archivedTasks]; - + const archivedTasks = includeArchived && (!columnFilter || columnFilter === "archived") ? this.archiveDb.list().map((entry) => this.archiveEntryToTask(entry, slim)) : []; + // FNXC:BoardConsistency 2026-06-21-08:34: FN-6851's cache-sync fix is primary; listTasks still collapses duplicate storage sources so one task ID cannot render in two columns. Active SQLite rows are authoritative over archive snapshots. + const tasksById = new Map(activeTasks.map((task) => [task.id, task])); + for (const task of archivedTasks) if (!tasksById.has(task.id)) tasksById.set(task.id, task); + const tasks = [...tasksById.values()]; // Sort by createdAt, then by numeric ID suffix for tie-breaking const sorted = tasks.sort((a, b) => { const cmp = a.createdAt.localeCompare(b.createdAt); @@ -5788,10 +5788,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const offset = Math.max(0, options?.offset ?? 0); const limit = options?.limit; - if (limit === undefined) { - return sorted.slice(offset); - } - + if (limit === undefined) return sorted.slice(offset); return sorted.slice(offset, offset + Math.max(0, limit)); } @@ -7763,7 +7760,6 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } } - async updateTaskDependencies( id: string, mutation: TaskDependencyMutation, @@ -7922,6 +7918,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }, }; await this.atomicWriteTaskJsonWithAudit(dir, task, auditEvent); + // FNXC:BoardConsistency 2026-06-21-08:31: updateTaskDependencies' todo→triage re-spec move can also carry title/blocker changes, and leaving taskCache on the pre-move row made watch/SSE/board consumers surface one task ID in two columns (FN-6851/FN-6812). Sync the cache after the authoritative write like sibling mutation paths. + if (this.isWatching) this.taskCache.set(id, { ...task }); if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); } From af06170a5183c1d5c49801837edc951df51ba796 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:58:56 -0700 Subject: [PATCH 047/265] FN-6778: add agent artifact registry tools Adds engine and chat tools for registering, discovering, and viewing artifacts with inbox notifications. - Add fn_artifact_register, fn_artifact_list, and fn_artifact_view tools for heartbeat, executor, and chat sessions. - Send best-effort dashboard system inbox notifications when artifacts are registered. - Classify artifact tools for action gating and coordination exemptions, with coverage for executor, heartbeat, permanent agent, and chat flows. - Document the artifact registry behavior and update package metadata, quarantine ledger, and line-count baseline. Files changed: .changeset/fn-6778-artifact-agent-tools.md | 5 + CONCEPTS.md | 3 + docs/agents.md | 1 + packages/core/src/db.ts | 1 + .../src/__tests__/session-error-recovery.test.ts | 1 + .../session-persistence-roundtrip.test.ts | 1 + .../src/__tests__/session-reconnect.test.ts | 1 + .../src/__tests__/session-resume-history.test.ts | 1 + packages/dashboard/src/chat.ts | 6 +- packages/dashboard/src/planning.ts | 4 + packages/dashboard/src/test/mockCoreEngine.ts | 1 + .../engine/src/__tests__/agent-action-gate.test.ts | 3 + .../src/__tests__/agent-artifact-tools.test.ts | 458 +++++++++++++++++++++ .../src/__tests__/executor-step-session.test.ts | 48 +++ .../src/__tests__/gating-classifications.test.ts | 3 + .../src/__tests__/heartbeat-executor.test.ts | 40 +- .../src/__tests__/heartbeat-session-prompt.test.ts | 25 +- .../src/__tests__/permanent-agent-gating.test.ts | 6 + packages/engine/src/agent-heartbeat.ts | 6 +- packages/engine/src/agent-tools.ts | 288 ++++++++++++- packages/engine/src/executor.ts | 25 ++ packages/engine/src/gating-classifications.ts | 7 + packages/engine/src/index.ts | 9 + scripts/lib/test-quarantine.json | 8 +- scripts/line-count-baseline.json | 54 +-- 25 files changed, 945 insertions(+), 60 deletions(-) Fusion-Task-Id: FN-6778 Fusion-Task-Lineage: 7eb4afcb-8140-4f86-9540-eb3b83e64148 --- .changeset/fn-6778-artifact-agent-tools.md | 5 + CONCEPTS.md | 3 + docs/agents.md | 1 + packages/core/src/db.ts | 1 + .../__tests__/session-error-recovery.test.ts | 1 + .../session-persistence-roundtrip.test.ts | 1 + .../src/__tests__/session-reconnect.test.ts | 1 + .../__tests__/session-resume-history.test.ts | 1 + packages/dashboard/src/chat.ts | 6 +- packages/dashboard/src/planning.ts | 4 + packages/dashboard/src/test/mockCoreEngine.ts | 1 + .../src/__tests__/agent-action-gate.test.ts | 3 + .../__tests__/agent-artifact-tools.test.ts | 458 ++++++++++++++++++ .../__tests__/executor-step-session.test.ts | 48 ++ .../__tests__/gating-classifications.test.ts | 3 + .../src/__tests__/heartbeat-executor.test.ts | 40 +- .../heartbeat-session-prompt.test.ts | 25 +- .../__tests__/permanent-agent-gating.test.ts | 6 + packages/engine/src/agent-heartbeat.ts | 6 +- packages/engine/src/agent-tools.ts | 288 ++++++++++- packages/engine/src/executor.ts | 25 + packages/engine/src/gating-classifications.ts | 7 + packages/engine/src/index.ts | 9 + scripts/lib/test-quarantine.json | 8 +- scripts/line-count-baseline.json | 54 +-- 25 files changed, 945 insertions(+), 60 deletions(-) create mode 100644 .changeset/fn-6778-artifact-agent-tools.md create mode 100644 packages/engine/src/__tests__/agent-artifact-tools.test.ts diff --git a/.changeset/fn-6778-artifact-agent-tools.md b/.changeset/fn-6778-artifact-agent-tools.md new file mode 100644 index 0000000000..e44e38472a --- /dev/null +++ b/.changeset/fn-6778-artifact-agent-tools.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add `fn_artifact_register`, `fn_artifact_list`, and `fn_artifact_view` agent tools for publishing and discovering multi-type artifacts, with best-effort dashboard user inbox notifications on registration. diff --git a/CONCEPTS.md b/CONCEPTS.md index c6990d47c7..a090f749d5 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -244,6 +244,9 @@ A persisted crash-safe marker (`tasks.transitionPending`) written in the same tr ### Step instance One runtime expansion of a `foreach` template subgraph, bound to a single planned step (`Task.steps[i]`). Identity is deterministic — `#:` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in its own persisted run-state table. The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer. +### Artifact +A persisted registry entry produced by agents, workflows, or tasks for reusable deliverables and intermediate products. Artifacts have a type (`document`, `image`, `video`, `audio`, or `other`), author attribution, optional task linkage, metadata such as MIME type/size, and either inline text `content` or a `uri`/path reference for externally stored media. + ### parse-steps A workflow graph node that reads a declared Artifact and runs a registry parser to write the canonical step list (`Task.steps[]`) — the only graph-side writer of steps. Built-in parsers are `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex, including the `(depends: N,M)` annotation) and `json-steps`; plugins contribute parsers under `plugin::`. Parsing failures fail closed to a routable `outcome:parse-error` rather than crashing. A parse-steps node must dominate (precede on all paths) any `foreach(source:"task-steps")`, and running one after a foreach has already expanded trips pin protection (an audited failure) so re-plan loops cannot desynchronize an expanded region. diff --git a/docs/agents.md b/docs/agents.md index 94c89dad47..ac49c17dc6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -27,6 +27,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them. - In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command. - Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`. +- Executor, heartbeat, and dashboard chat sessions expose artifact registry tools: `fn_artifact_register` publishes document/image/video/audio/other artifacts with inline `content` or a `uri`, `fn_artifact_list` discovers artifacts across agents/tasks with filters, and `fn_artifact_view` reads metadata plus inline content or URI references. Each successful registration sends a best-effort `system` → dashboard user inbox notification with artifact metadata; notification failures are logged but do not fail the registration. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency. ### Flags diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 83603bf28d..2ef6ca39b4 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -5267,6 +5267,7 @@ export class Database { } + // Migration 128: Built-in workflow prompt overrides. // Mirrors workflow_settings: one project-scoped JSON map per workflow id, but // values are nodeId → prompt overrides. Reset-to-default deletes keys; graph diff --git a/packages/dashboard/src/__tests__/session-error-recovery.test.ts b/packages/dashboard/src/__tests__/session-error-recovery.test.ts index 839c4fa9bd..151c46a61d 100644 --- a/packages/dashboard/src/__tests__/session-error-recovery.test.ts +++ b/packages/dashboard/src/__tests__/session-error-recovery.test.ts @@ -53,6 +53,7 @@ vi.mock("@fusion/engine", () => ({ createWorkflowAuthoringTools: vi.fn(() => []), // FNXC:DashboardSessionTests 2026-06-18-09:12: planning.ts also spreads chat task document tools during dashboard API backfill runs; focused engine mocks must return an iterable list so rescued chat-routes coverage does not destabilize planning-session tests. createChatTaskDocumentTools: vi.fn(() => []), + createChatArtifactTools: vi.fn(() => []), // FNXC:DashboardSessionTests 2026-06-17-19:33: planning and mission-interview sessions now request skills through the shared helper; focused engine mocks must return the shaped helper result so lifecycle tests do not crash before createFnAgent is captured. buildSessionSkillContextSync: vi.fn(() => ({ skillSelectionContext: undefined, diff --git a/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts b/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts index e0103d758f..7ec02acafa 100644 --- a/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts +++ b/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts @@ -43,6 +43,7 @@ vi.mock("@fusion/engine", () => ({ createWorkflowAuthoringTools: vi.fn(() => []), // FNXC:DashboardSessionTests 2026-06-18-09:12: planning.ts also spreads chat task document tools during dashboard API backfill runs; focused engine mocks must return an iterable list so rescued chat-routes coverage does not destabilize planning-session tests. createChatTaskDocumentTools: vi.fn(() => []), + createChatArtifactTools: vi.fn(() => []), // FNXC:DashboardSessionTests 2026-06-17-19:33: planning and mission-interview sessions now request skills through the shared helper; focused engine mocks must return the shaped helper result so lifecycle tests do not crash before createFnAgent is captured. buildSessionSkillContextSync: vi.fn(() => ({ skillSelectionContext: undefined, diff --git a/packages/dashboard/src/__tests__/session-reconnect.test.ts b/packages/dashboard/src/__tests__/session-reconnect.test.ts index 578b73431b..2831b0876b 100644 --- a/packages/dashboard/src/__tests__/session-reconnect.test.ts +++ b/packages/dashboard/src/__tests__/session-reconnect.test.ts @@ -46,6 +46,7 @@ vi.mock("@fusion/engine", () => ({ createWorkflowAuthoringTools: vi.fn(() => []), // FNXC:DashboardSessionTests 2026-06-18-09:12: planning.ts also spreads chat task document tools during dashboard API backfill runs; focused engine mocks must return an iterable list so rescued chat-routes coverage does not destabilize planning-session tests. createChatTaskDocumentTools: vi.fn(() => []), + createChatArtifactTools: vi.fn(() => []), // FNXC:DashboardSessionTests 2026-06-17-19:33: planning and mission-interview sessions now request skills through the shared helper; focused engine mocks must return the shaped helper result so lifecycle tests do not crash before createFnAgent is captured. buildSessionSkillContextSync: vi.fn(() => ({ skillSelectionContext: undefined, diff --git a/packages/dashboard/src/__tests__/session-resume-history.test.ts b/packages/dashboard/src/__tests__/session-resume-history.test.ts index cc6e0d9c8a..afd35e1c28 100644 --- a/packages/dashboard/src/__tests__/session-resume-history.test.ts +++ b/packages/dashboard/src/__tests__/session-resume-history.test.ts @@ -42,6 +42,7 @@ vi.mock("@fusion/engine", () => ({ createWorkflowAuthoringTools: vi.fn(() => []), // FNXC:DashboardSessionTests 2026-06-18-09:12: planning.ts also spreads chat task document tools during dashboard API backfill runs; focused engine mocks must return an iterable list so rescued chat-routes coverage does not destabilize planning-session tests. createChatTaskDocumentTools: vi.fn(() => []), + createChatArtifactTools: vi.fn(() => []), // FNXC:DashboardSessionTests 2026-06-17-19:33: planning and mission-interview sessions now request skills through the shared helper; focused engine mocks must return the shaped helper result so lifecycle tests do not crash before createFnAgent is captured. buildSessionSkillContextSync: vi.fn(() => ({ skillSelectionContext: undefined, diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 69a8425f99..a765133825 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -45,6 +45,7 @@ import { createSendMessageTool, createReadMessagesTool, createAskQuestionTool, + createChatArtifactTools, createChatTaskDocumentTools, createWorkflowAuthoringTools, } from "@fusion/engine"; @@ -1820,8 +1821,11 @@ export class ChatManager { const documentTools = this.taskStore ? createChatTaskDocumentTools(this.taskStore) : []; + const artifactTools = this.taskStore + ? createChatArtifactTools(this.taskStore, this.messageStore) + : []; - const customTools = [createAskQuestionTool(), ...messagingTools, ...workflowTools, ...documentTools]; + const customTools = [createAskQuestionTool(), ...messagingTools, ...workflowTools, ...documentTools, ...artifactTools]; const sessionOptions = { cwd: this.rootDir, diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 3c12126229..ea29458b80 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -867,6 +867,9 @@ export async function createSession( /* FNXC:PlanningTools 2026-06-18-07:11: FN-6640 gives planning agents parity with chat for `fn_task_document_write` and `fn_task_document_read` after FN-6635. The planning lane has no ambient task (`PLANNING_NO_AMBIENT_TASK_ID`), so these document tools must require an explicit `task_id`, mirroring no-ambient workflow authoring tools. + + FNXC:ArtifactRegistry 2026-06-21-00:00: + Planning sessions do not own the dashboard MessageStore, so artifact tools stay excluded here until the planning lane can thread the same inbox dependency as chat. This preserves the FN-6778 requirement that registration notifications use an existing MessageStore rather than constructing a new one. */ ...createChatTaskDocumentTools(store), ], @@ -1461,6 +1464,7 @@ async function createPlanningAgent( customTools: [ ...createPlanningBoardTools(store), ...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }), + /* FNXC:ArtifactRegistry 2026-06-21-00:00: Streaming planning excludes artifact tools for the same reason as non-streaming planning: this module has no MessageStore dependency to provide best-effort dashboard inbox notifications. */ ...createChatTaskDocumentTools(store), ], ...(modelProvider && modelId diff --git a/packages/dashboard/src/test/mockCoreEngine.ts b/packages/dashboard/src/test/mockCoreEngine.ts index 2a52ca4c20..97cd1f590e 100644 --- a/packages/dashboard/src/test/mockCoreEngine.ts +++ b/packages/dashboard/src/test/mockCoreEngine.ts @@ -65,6 +65,7 @@ export function createEngineMock(overrides: AnyModule = {}): AnyModule { Keep chat task document tools iterable by default so rescuing chat-routes from quarantine does not poison planning route imports with a fallback vi.fn() result. */ createChatTaskDocumentTools: vi.fn(() => []), + createChatArtifactTools: vi.fn(() => []), ...overrides, }); } diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index af43309af0..bb8e01dddf 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -15,6 +15,9 @@ const FN_3548_COORDINATION_TOOLS = [ "fn_task_log", "fn_task_document_write", "fn_task_document_read", + "fn_artifact_register", + "fn_artifact_list", + "fn_artifact_view", "fn_delegate_task", "fn_list_agents", "fn_agent_show", diff --git a/packages/engine/src/__tests__/agent-artifact-tools.test.ts b/packages/engine/src/__tests__/agent-artifact-tools.test.ts new file mode 100644 index 0000000000..92ac587f62 --- /dev/null +++ b/packages/engine/src/__tests__/agent-artifact-tools.test.ts @@ -0,0 +1,458 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Artifact, ArtifactWithTask, MessageStore, TaskStore } from "@fusion/core"; +import { DASHBOARD_USER_ID } from "@fusion/core"; +import { + createArtifactListTool, + createArtifactRegisterTool, + createArtifactViewTool, + createChatArtifactTools, +} from "../agent-tools.js"; + +vi.mock("@fusion/core", async (importOriginal) => { + const { createEngineCoreMock } = await import("../test/mockCore.js"); + return createEngineCoreMock(() => importOriginal()); +}); + +const TASK_ID = "FN-6778"; +const AUTHOR_ID = "agent-007"; + +type ArtifactStore = Pick; + +type ArtifactMessageStore = Pick; + +function createMockArtifact(overrides: Partial = {}): Artifact { + return { + id: "art-1", + type: "document", + title: "Implementation notes", + description: "Artifact description", + mimeType: "text/markdown", + content: "# Notes\nInline content", + authorId: AUTHOR_ID, + authorType: "agent", + taskId: TASK_ID, + createdAt: "2026-06-21T06:50:00.000Z", + updatedAt: "2026-06-21T06:50:00.000Z", + ...overrides, + }; +} + +function createMockStore(overrides: Partial = {}) { + const registerArtifact = vi.fn(); + const getArtifact = vi.fn(); + const listArtifacts = vi.fn(); + + const store: TaskStore = { + registerArtifact, + getArtifact, + listArtifacts, + ...overrides, + } as unknown as TaskStore; + + return { store, registerArtifact, getArtifact, listArtifacts }; +} + +function createMockMessageStore() { + const sendMessage = vi.fn((input) => ({ + id: "msg-1", + ...input, + fromId: input.fromId ?? "system", + read: false, + createdAt: "2026-06-21T06:50:00.000Z", + updatedAt: "2026-06-21T06:50:00.000Z", + })); + const messageStore = { sendMessage } as unknown as MessageStore; + return { messageStore, sendMessage }; +} + +async function runTool( + tool: { execute: (...args: any[]) => Promise }, + callId: string, + params: Record, +) { + return tool.execute(callId, params, undefined as any, undefined as any, undefined as any); +} + +function getText(result: any): string { + const first = result?.content?.[0]; + return first?.type === "text" ? first.text : ""; +} + +function findChatTool(name: "fn_artifact_register" | "fn_artifact_list" | "fn_artifact_view", store: TaskStore, messageStore?: MessageStore) { + const tool = createChatArtifactTools(store, messageStore).find((candidate) => candidate.name === name); + expect(tool).toBeDefined(); + return tool!; +} + +describe("artifact register tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls store.registerArtifact with mapped agent author input", async () => { + const { store, registerArtifact } = createMockStore(); + registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-register" })); + + const tool = createArtifactRegisterTool(store, AUTHOR_ID); + const result = await runTool(tool, "call-register", { + type: "document", + title: "Implementation notes", + description: "A markdown report", + mimeType: "text/markdown", + content: "# Report", + taskId: TASK_ID, + }); + + expect(registerArtifact).toHaveBeenCalledWith({ + type: "document", + title: "Implementation notes", + description: "A markdown report", + mimeType: "text/markdown", + uri: undefined, + content: "# Report", + authorId: AUTHOR_ID, + authorType: "agent", + taskId: TASK_ID, + }); + expect(getText(result)).toContain("Registered artifact"); + expect(getText(result)).not.toContain("ERROR:"); + }); + + it("sends exactly one system-to-user inbox notification with artifact metadata", async () => { + const { store, registerArtifact } = createMockStore(); + const artifact = createMockArtifact({ id: "art-notify", type: "image", title: "Screenshot", uri: "artifacts/screenshot.png", content: undefined }); + registerArtifact.mockResolvedValue(artifact); + const { messageStore, sendMessage } = createMockMessageStore(); + + const tool = createArtifactRegisterTool(store, AUTHOR_ID, messageStore); + await runTool(tool, "call-notify", { + type: "image", + title: "Screenshot", + uri: "artifacts/screenshot.png", + taskId: TASK_ID, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + fromType: "system", + toType: "user", + toId: DASHBOARD_USER_ID, + type: "system", + metadata: expect.objectContaining({ + artifactId: "art-notify", + artifactType: "image", + title: "Screenshot", + authorId: AUTHOR_ID, + taskId: TASK_ID, + }), + })); + }); + + it("still succeeds when notification sendMessage throws", async () => { + const { store, registerArtifact } = createMockStore(); + registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-best-effort" })); + const { messageStore, sendMessage } = createMockMessageStore(); + sendMessage.mockImplementation(() => { + throw new Error("inbox unavailable"); + }); + + const tool = createArtifactRegisterTool(store, AUTHOR_ID, messageStore); + const result = await runTool(tool, "call-best-effort", { + type: "document", + title: "Best effort artifact", + content: "body", + }); + + expect(registerArtifact).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(getText(result)).toContain("Registered artifact"); + expect(getText(result)).not.toContain("ERROR:"); + }); + + it("succeeds with no message store provided", async () => { + const { store, registerArtifact } = createMockStore(); + registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-no-message-store" })); + + const tool = createArtifactRegisterTool(store, AUTHOR_ID); + const result = await runTool(tool, "call-no-message-store", { + type: "document", + title: "No notification", + content: "body", + }); + + expect(registerArtifact).toHaveBeenCalledTimes(1); + expect(getText(result)).toContain("Registered artifact"); + expect(getText(result)).not.toContain("ERROR:"); + }); + + it("returns ERROR-prefixed text for store failures", async () => { + const { store, registerArtifact } = createMockStore(); + registerArtifact.mockRejectedValue(new Error("database temporarily unavailable")); + + const tool = createArtifactRegisterTool(store, AUTHOR_ID); + const result = await runTool(tool, "call-store-error", { + type: "document", + title: "Broken artifact", + content: "body", + }); + + expect(getText(result)).toContain("ERROR: Failed to register artifact"); + expect(getText(result)).toContain("database temporarily unavailable"); + }); +}); + +describe("artifact list tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns cross-agent results and forwards filters", async () => { + const { store, listArtifacts } = createMockStore(); + const artifacts: ArtifactWithTask[] = [ + createMockArtifact({ id: "art-a", authorId: "agent-a", title: "Alpha", taskId: "FN-100" }) as ArtifactWithTask, + { ...createMockArtifact({ id: "art-b", type: "image", authorId: "agent-b", title: "Beta", taskId: "FN-200", content: undefined, uri: "artifacts/beta.png" }), taskTitle: "Render screenshot" }, + ]; + listArtifacts.mockResolvedValue(artifacts); + + const tool = createArtifactListTool(store); + const result = await runTool(tool, "call-list", { + type: "image", + authorId: "agent-b", + taskId: "FN-200", + search: "screenshot", + limit: 10, + offset: 5, + }); + + expect(listArtifacts).toHaveBeenCalledWith({ + type: "image", + authorId: "agent-b", + taskId: "FN-200", + search: "screenshot", + limit: 10, + offset: 5, + }); + expect(getText(result)).toContain("art-a [document] Alpha"); + expect(getText(result)).toContain("author: agent-a"); + expect(getText(result)).toContain("art-b [image] Beta"); + expect(getText(result)).toContain("FN-200 (Render screenshot)"); + }); + + it("returns empty-state text when no artifacts match", async () => { + const { store, listArtifacts } = createMockStore(); + listArtifacts.mockResolvedValue([]); + + const tool = createArtifactListTool(store); + const result = await runTool(tool, "call-list-empty", {}); + + expect(listArtifacts).toHaveBeenCalledWith({ + type: undefined, + authorId: undefined, + taskId: undefined, + search: undefined, + limit: undefined, + offset: undefined, + }); + expect(getText(result)).toBe("No artifacts found."); + }); + + it("returns ERROR-prefixed text when listArtifacts throws", async () => { + const { store, listArtifacts } = createMockStore(); + listArtifacts.mockRejectedValue(new Error("artifact index offline")); + + const tool = createArtifactListTool(store); + const result = await runTool(tool, "call-list-error", { search: "offline" }); + + expect(listArtifacts).toHaveBeenCalledWith(expect.objectContaining({ search: "offline" })); + expect(getText(result)).toContain("ERROR: Failed to list artifacts"); + expect(getText(result)).toContain("artifact index offline"); + }); +}); + +describe("artifact view tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders inline content artifacts", async () => { + const { store, getArtifact } = createMockStore(); + getArtifact.mockResolvedValue(createMockArtifact({ id: "art-inline", content: "Inline markdown body" })); + + const tool = createArtifactViewTool(store); + const result = await runTool(tool, "call-view-inline", { id: "art-inline" }); + + expect(getArtifact).toHaveBeenCalledWith("art-inline"); + expect(getText(result)).toContain("Artifact: Implementation notes"); + expect(getText(result)).toContain("Inline markdown body"); + }); + + it("renders binary uri artifacts", async () => { + const { store, getArtifact } = createMockStore(); + getArtifact.mockResolvedValue(createMockArtifact({ + id: "art-binary", + type: "image", + title: "Screenshot", + content: undefined, + uri: "artifacts/screenshot.png", + sizeBytes: 2048, + })); + + const tool = createArtifactViewTool(store); + const result = await runTool(tool, "call-view-binary", { id: "art-binary" }); + + expect(getText(result)).toContain("Artifact: Screenshot"); + expect(getText(result)).toContain("URI: artifacts/screenshot.png"); + expect(getText(result)).toContain("Size: 2048 bytes"); + }); + + it("returns not-found text when artifact is missing", async () => { + const { store, getArtifact } = createMockStore(); + getArtifact.mockResolvedValue(null); + + const tool = createArtifactViewTool(store); + const result = await runTool(tool, "call-view-missing", { id: "missing-artifact" }); + + expect(getArtifact).toHaveBeenCalledWith("missing-artifact"); + expect(getText(result)).toContain("Artifact \"missing-artifact\" not found."); + }); + + it("returns ERROR-prefixed text when getArtifact throws", async () => { + const { store, getArtifact } = createMockStore(); + getArtifact.mockRejectedValue(new Error("DB read timeout")); + + const tool = createArtifactViewTool(store); + const result = await runTool(tool, "call-view-error", { id: "art-failing" }); + + expect(getArtifact).toHaveBeenCalledWith("art-failing"); + expect(getText(result)).toContain('ERROR: Failed to view artifact "art-failing"'); + expect(getText(result)).toContain("DB read timeout"); + }); +}); + +describe("chat artifact tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("exposes canonical artifact tool names for chat agents", () => { + const { store } = createMockStore(); + + expect(createChatArtifactTools(store).map((tool) => tool.name)).toEqual([ + "fn_artifact_register", + "fn_artifact_list", + "fn_artifact_view", + ]); + }); + + it("registers with explicit task_id and fixed dashboard-chat author", async () => { + const { store, registerArtifact } = createMockStore(); + registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-chat", authorId: "dashboard-chat", taskId: "FN-3030" })); + const { messageStore, sendMessage } = createMockMessageStore(); + + const tool = findChatTool("fn_artifact_register", store, messageStore); + const result = await runTool(tool, "call-chat-register", { + task_id: "FN-3030", + type: "document", + title: "Chat artifact", + content: "created from chat", + }); + + expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ + taskId: "FN-3030", + authorId: "dashboard-chat", + authorType: "agent", + title: "Chat artifact", + })); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + metadata: expect.objectContaining({ authorId: "dashboard-chat", taskId: "FN-3030" }), + })); + expect(getText(result)).toContain("Registered artifact"); + }); + + it("lists artifacts for the explicit task_id", async () => { + const { store, listArtifacts } = createMockStore(); + listArtifacts.mockResolvedValue([ + { ...createMockArtifact({ id: "art-chat-list", taskId: "FN-4040", title: "Chat list artifact" }), taskTitle: "Chat target" }, + ]); + + const tool = findChatTool("fn_artifact_list", store); + const result = await runTool(tool, "call-chat-list", { + task_id: "FN-4040", + type: "document", + authorId: "dashboard-chat", + search: "Chat", + limit: 3, + offset: 1, + }); + + expect(listArtifacts).toHaveBeenCalledWith({ + type: "document", + authorId: "dashboard-chat", + taskId: "FN-4040", + search: "Chat", + limit: 3, + offset: 1, + }); + expect(getText(result)).toContain("art-chat-list [document] Chat list artifact"); + }); + + it("passes view calls through to getArtifact", async () => { + const { store, getArtifact } = createMockStore(); + getArtifact.mockResolvedValue(createMockArtifact({ id: "art-chat-view", title: "Chat view" })); + + const tool = findChatTool("fn_artifact_view", store); + const result = await runTool(tool, "call-chat-view", { id: "art-chat-view" }); + + expect(getArtifact).toHaveBeenCalledWith("art-chat-view"); + expect(getText(result)).toContain("Artifact: Chat view"); + }); + + it("returns clean errors for non-existent explicit task registration", async () => { + const { store, registerArtifact } = createMockStore(); + registerArtifact.mockRejectedValue(new Error("Task FN-404 not found")); + + const tool = findChatTool("fn_artifact_register", store); + const result = await runTool(tool, "call-chat-register-error", { + task_id: "FN-404", + type: "document", + title: "No target", + content: "body", + }); + + expect(getText(result)).toContain("ERROR: Failed to register artifact \"No target\""); + expect(getText(result)).toContain("Task FN-404 not found"); + }); + + it("returns clean errors for non-existent explicit task list", async () => { + const { store, listArtifacts } = createMockStore(); + listArtifacts.mockRejectedValue(new Error("Task FN-405 not found")); + + const tool = findChatTool("fn_artifact_list", store); + const result = await runTool(tool, "call-chat-list-error", { task_id: "FN-405" }); + + expect(getText(result)).toContain("ERROR: Failed to list artifacts"); + expect(getText(result)).toContain("Task FN-405 not found"); + }); +}); + +describe("artifact tool factory integration", () => { + it("uses the provided store instance across register, list, and view tools", async () => { + const { store, registerArtifact, getArtifact, listArtifacts } = createMockStore(); + registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-integration" })); + getArtifact.mockResolvedValue(createMockArtifact({ id: "art-integration" })); + listArtifacts.mockResolvedValue([createMockArtifact({ id: "art-integration" }) as ArtifactWithTask]); + + await runTool(createArtifactRegisterTool(store, AUTHOR_ID), "call-integration-register", { + type: "document", + title: "Integration artifact", + content: "body", + }); + await runTool(createArtifactListTool(store), "call-integration-list", {}); + await runTool(createArtifactViewTool(store), "call-integration-view", { id: "art-integration" }); + + expect(registerArtifact).toHaveBeenCalledTimes(1); + expect(listArtifacts).toHaveBeenCalledTimes(1); + expect(getArtifact).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/engine/src/__tests__/executor-step-session.test.ts b/packages/engine/src/__tests__/executor-step-session.test.ts index d5e75505f4..ced6e0d2d9 100644 --- a/packages/engine/src/__tests__/executor-step-session.test.ts +++ b/packages/engine/src/__tests__/executor-step-session.test.ts @@ -68,6 +68,54 @@ describe("Workflow Steps Execution", () => { }) as any); } + it("exposes read-only artifact discovery tools even without an assigned agent", async () => { + const store = createMockStore(); + const task = { + id: "FN-ART-1", + title: "Artifact discovery", + description: "Test artifact discovery tools", + column: "in-progress", + dependencies: [], + steps: [{ name: "Preflight", status: "in-progress" }], + currentStep: 0, + log: [], + prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + store.getTask.mockResolvedValue(task as any); + + let toolNames: string[] = []; + mockedCreateFnAgent.mockImplementation((async (opts: any) => { + const customTools = opts.customTools || []; + toolNames = customTools.map((tool: any) => tool.name); + return { + session: { + prompt: vi.fn().mockImplementation(async () => { + const taskDoneTool = customTools.find((tool: any) => tool.name === "fn_task_done"); + if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); + }), + dispose: vi.fn(), + subscribe: vi.fn(), + on: vi.fn(), + sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, + state: {}, + }, + }; + }) as any); + + const executor = new TaskExecutor(store, "/tmp/test", {}); + await executor.execute(task as any); + + /* + FNXC:ArtifactRegistry 2026-06-21-07:04: + Read-only artifact list/view tools are cross-agent discovery surfaces, so legacy or unassigned executor sessions still receive them; only fn_artifact_register requires an assigned author id. + */ + expect(toolNames).toContain("fn_artifact_list"); + expect(toolNames).toContain("fn_artifact_view"); + expect(toolNames).not.toContain("fn_artifact_register"); + }); + it("requeues to todo after 3 retries when the agent exits without calling fn_task_done", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index ad13f8058e..ccf2e50797 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -68,6 +68,9 @@ describe("gating-classifications parity", () => { "find", "fn_agent_org_chart", "fn_agent_show", + "fn_artifact_list", + "fn_artifact_register", + "fn_artifact_view", "fn_delegate_task", "fn_goal_list", "fn_goal_show", diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index a4a2150851..575f2bd8bd 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -2961,29 +2961,33 @@ describe("executeHeartbeat", () => { expect(callArgs.systemPrompt).toContain("fn_task_log"); expect(callArgs.systemPrompt).toContain("fn_task_document_write"); expect(callArgs.tools).toBe("coding"); - // fn_get_agent_config, fn_update_agent_config, fn_agent_create, fn_agent_delete, fn_goal_list, fn_goal_show, - // fn_read_evaluations, fn_update_identity, fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done - expect(callArgs.customTools).toHaveLength(19); + // fn_artifact_register, fn_artifact_list, fn_artifact_view, fn_get_agent_config, fn_update_agent_config, + // fn_agent_create, fn_agent_delete, fn_goal_list, fn_goal_show, fn_read_evaluations, fn_update_identity, + // fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done + expect(callArgs.customTools).toHaveLength(22); expect(callArgs.customTools![0]!.name).toBe("fn_task_create"); expect(callArgs.customTools![1]!.name).toBe("fn_task_log"); expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write"); expect(callArgs.customTools![3]!.name).toBe("fn_task_document_read"); - expect(callArgs.customTools![4]!.name).toBe("fn_list_agents"); - expect(callArgs.customTools![5]!.name).toBe("fn_delegate_task"); - expect(callArgs.customTools![6]!.name).toBe("fn_get_agent_config"); - expect(callArgs.customTools![7]!.name).toBe("fn_update_agent_config"); - expect(callArgs.customTools![8]!.name).toBe("fn_agent_create"); - expect(callArgs.customTools![9]!.name).toBe("fn_agent_delete"); - expect(callArgs.customTools![10]!.name).toBe("fn_goal_list"); - expect(callArgs.customTools![11]!.name).toBe("fn_goal_show"); - expect(callArgs.customTools![12]!.name).toBe("fn_read_evaluations"); - expect(callArgs.customTools![13]!.name).toBe("fn_update_identity"); - expect(callArgs.customTools![14]!.name).toBe("fn_web_fetch"); - expect(callArgs.customTools![15]!.name).toBe("fn_memory_search"); - expect(callArgs.customTools![16]!.name).toBe("fn_memory_get"); - expect(callArgs.customTools![17]!.name).toBe("fn_memory_append"); + expect(callArgs.customTools![4]!.name).toBe("fn_artifact_register"); + expect(callArgs.customTools![5]!.name).toBe("fn_artifact_list"); + expect(callArgs.customTools![6]!.name).toBe("fn_artifact_view"); + expect(callArgs.customTools![7]!.name).toBe("fn_list_agents"); + expect(callArgs.customTools![8]!.name).toBe("fn_delegate_task"); + expect(callArgs.customTools![9]!.name).toBe("fn_get_agent_config"); + expect(callArgs.customTools![10]!.name).toBe("fn_update_agent_config"); + expect(callArgs.customTools![11]!.name).toBe("fn_agent_create"); + expect(callArgs.customTools![12]!.name).toBe("fn_agent_delete"); + expect(callArgs.customTools![13]!.name).toBe("fn_goal_list"); + expect(callArgs.customTools![14]!.name).toBe("fn_goal_show"); + expect(callArgs.customTools![15]!.name).toBe("fn_read_evaluations"); + expect(callArgs.customTools![16]!.name).toBe("fn_update_identity"); + expect(callArgs.customTools![17]!.name).toBe("fn_web_fetch"); + expect(callArgs.customTools![18]!.name).toBe("fn_memory_search"); + expect(callArgs.customTools![19]!.name).toBe("fn_memory_get"); + expect(callArgs.customTools![20]!.name).toBe("fn_memory_append"); // fn_heartbeat_done is last (terminal tool) - expect(callArgs.customTools![18]!.name).toBe("fn_heartbeat_done"); + expect(callArgs.customTools![21]!.name).toBe("fn_heartbeat_done"); }); it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => { diff --git a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts index bab242fe73..031866fcdf 100644 --- a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts +++ b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts @@ -170,21 +170,24 @@ describe("createHeartbeatTools", () => { const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001"); - expect(tools).toHaveLength(14); + expect(tools).toHaveLength(17); expect(tools[0]!.name).toBe("fn_task_create"); expect(tools[1]!.name).toBe("fn_task_log"); expect(tools[2]!.name).toBe("fn_task_document_write"); expect(tools[3]!.name).toBe("fn_task_document_read"); - expect(tools[4]!.name).toBe("fn_list_agents"); - expect(tools[5]!.name).toBe("fn_delegate_task"); - expect(tools[6]!.name).toBe("fn_get_agent_config"); - expect(tools[7]!.name).toBe("fn_update_agent_config"); - expect(tools[8]!.name).toBe("fn_agent_create"); - expect(tools[9]!.name).toBe("fn_agent_delete"); - expect(tools[10]!.name).toBe("fn_goal_list"); - expect(tools[11]!.name).toBe("fn_goal_show"); - expect(tools[12]!.name).toBe("fn_read_evaluations"); - expect(tools[13]!.name).toBe("fn_update_identity"); + expect(tools[4]!.name).toBe("fn_artifact_register"); + expect(tools[5]!.name).toBe("fn_artifact_list"); + expect(tools[6]!.name).toBe("fn_artifact_view"); + expect(tools[7]!.name).toBe("fn_list_agents"); + expect(tools[8]!.name).toBe("fn_delegate_task"); + expect(tools[9]!.name).toBe("fn_get_agent_config"); + expect(tools[10]!.name).toBe("fn_update_agent_config"); + expect(tools[11]!.name).toBe("fn_agent_create"); + expect(tools[12]!.name).toBe("fn_agent_delete"); + expect(tools[13]!.name).toBe("fn_goal_list"); + expect(tools[14]!.name).toBe("fn_goal_show"); + expect(tools[15]!.name).toBe("fn_read_evaluations"); + expect(tools[16]!.name).toBe("fn_update_identity"); }); it("fn_task_create tool creates a task in triage via TaskStore", async () => { diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index 12be22c2b3..f534caac2a 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -10,6 +10,9 @@ const FN_3548_COORDINATION_TOOLS = [ "fn_task_log", "fn_task_document_write", "fn_task_document_read", + "fn_artifact_register", + "fn_artifact_list", + "fn_artifact_view", "fn_delegate_task", "fn_list_agents", "fn_agent_show", @@ -43,6 +46,9 @@ describe("permanent-agent-gating", () => { expect(classifyPermanentAgentToolCall("fn_task_import_github_issue").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_update_identity").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_task_document_write").category).toBe("none"); + expect(classifyPermanentAgentToolCall("fn_artifact_register").category).toBe("none"); + expect(classifyPermanentAgentToolCall("fn_artifact_list").category).toBe("none"); + expect(classifyPermanentAgentToolCall("fn_artifact_view").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_memory_append").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_research_run").category).toBe("network_api"); expect(classifyPermanentAgentToolCall("worktrunk_install").category).toBe("network_api"); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 4ff0966802..dd7a0ef3d9 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -23,7 +23,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "@earendil-works/pi-ai"; import { createHash } from "node:crypto"; -import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js"; +import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js"; import { AgentLogger } from "./agent-logger.js"; import { resolveAgentInstructionsWithRatings, @@ -3350,6 +3350,10 @@ export class HeartbeatMonitor { // Document tools for persisting durable findings tools.push(createTaskDocumentWriteTool(taskStore, taskId)); tools.push(createTaskDocumentReadTool(taskStore, taskId)); + // Artifact registry tools for cross-agent deliverable discovery and notification. + tools.push(createArtifactRegisterTool(taskStore, agentId, messageStore)); + tools.push(createArtifactListTool(taskStore)); + tools.push(createArtifactViewTool(taskStore)); // Agent delegation tools — discover and delegate work to other agents tools.push(createListAgentsTool(this.store)); tools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir })); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 8bde4bf2b4..52535ae559 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -11,7 +11,7 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p import { existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { join, relative, resolve } from "node:path"; -import type { AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core"; +import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core"; import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS } from "@fusion/core"; import { promoteHeldTask } from "./hold-release.js"; import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core"; @@ -87,6 +87,53 @@ export const chatTaskDocumentReadParams = Type.Object({ ), }); +const ARTIFACT_TYPE_VALUES = ["document", "image", "video", "audio", "other"] as const; +const artifactTypeSchema = Type.Union(ARTIFACT_TYPE_VALUES.map((type) => Type.Literal(type)), { + description: "Artifact type: document, image, video, audio, or other.", +}); + +export const artifactRegisterParams = Type.Object({ + type: artifactTypeSchema, + title: Type.String({ description: "Human-readable artifact title." }), + description: Type.Optional(Type.String({ description: "Optional longer artifact description or caption." })), + mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })), + uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })), + content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })), + taskId: Type.Optional(Type.String({ description: "Optional associated task ID (e.g. 'FN-001')." })), +}); + +export const artifactListParams = Type.Object({ + type: Type.Optional(artifactTypeSchema), + authorId: Type.Optional(Type.String({ description: "Filter by registering author/agent ID." })), + taskId: Type.Optional(Type.String({ description: "Filter by associated task ID." })), + search: Type.Optional(Type.String({ description: "Search artifact titles, descriptions, content, and task metadata." })), + limit: Type.Optional(Type.Number({ description: "Maximum number of artifacts to return." })), + offset: Type.Optional(Type.Number({ description: "Number of artifacts to skip." })), +}); + +export const artifactViewParams = Type.Object({ + id: Type.String({ description: "Artifact ID to view." }), +}); + +export const chatArtifactRegisterParams = Type.Object({ + type: artifactTypeSchema, + title: Type.String({ description: "Human-readable artifact title." }), + description: Type.Optional(Type.String({ description: "Optional longer artifact description or caption." })), + mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })), + uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })), + content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })), + task_id: Type.String({ description: "Associated task ID (e.g. 'FN-001')." }), +}); + +export const chatArtifactListParams = Type.Object({ + type: Type.Optional(artifactTypeSchema), + authorId: Type.Optional(Type.String({ description: "Filter by registering author/agent ID." })), + task_id: Type.String({ description: "Associated task ID to list artifacts for." }), + search: Type.Optional(Type.String({ description: "Search artifact titles, descriptions, content, and task metadata." })), + limit: Type.Optional(Type.Number({ description: "Maximum number of artifacts to return." })), + offset: Type.Optional(Type.Number({ description: "Number of artifacts to skip." })), +}); + export const workflowListParams = Type.Object({}); export const workflowGetParams = Type.Object({ @@ -1121,6 +1168,245 @@ export function createChatTaskDocumentTools(store: TaskStore): ToolDefinition[] ]; } +/** + * FNXC:ArtifactRegistry 2026-06-21-06:50: + * Agents need to register multi-type artifacts across agents and tasks while using the existing task store registry. A new artifact registration must also announce itself to the dashboard user's inbox, but that notification is best-effort and must never fail the artifact write. + */ +export function createArtifactRegisterTool(store: TaskStore, authorId: string, messageStore?: MessageStore): ToolDefinition { + return { + name: "fn_artifact_register", + label: "Register Artifact", + description: + "Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it. " + + "Provide either inline content or a uri/path reference; optionally associate it with a taskId.", + parameters: artifactRegisterParams, + execute: async (_id: string, params: Static) => registerArtifactForAgent(store, authorId, params, messageStore), + }; +} + +/** + * FNXC:ArtifactRegistry 2026-06-21-06:50: + * Agents need a read-only cross-agent discovery surface for registered multi-type artifacts. Keep list rendering concise so agents can scan ids, media classes, authors, and task context before calling `fn_artifact_view`. + */ +export function createArtifactListTool(store: TaskStore): ToolDefinition { + return { + name: "fn_artifact_list", + label: "List Artifacts", + description: + "List registered artifacts across agents and tasks. Supports filters for type, authorId, taskId, search, limit, and offset.", + parameters: artifactListParams, + execute: async (_id: string, params: Static) => listArtifactsForAgent(store, params), + }; +} + +/** + * FNXC:ArtifactRegistry 2026-06-21-06:50: + * Agents need to inspect artifact metadata plus inline content or URI references without relying on dashboard UI. Render binary media as references so tool output remains lightweight and safe for agent contexts. + */ +export function createArtifactViewTool(store: TaskStore): ToolDefinition { + return { + name: "fn_artifact_view", + label: "View Artifact", + description: + "View a registered artifact by id, including metadata and inline content when present or the uri/path reference for media artifacts.", + parameters: artifactViewParams, + execute: async (_id: string, params: Static) => viewArtifactForAgent(store, params.id), + }; +} + +/** + * FNXC:ArtifactRegistry 2026-06-21-06:50: + * Dashboard chat and planning lanes have no ambient task, so artifact tools require an explicit task target for register/list parity while keeping the canonical `fn_artifact_*` tool names available to agents. + */ +export function createChatArtifactTools(store: TaskStore, messageStore?: MessageStore): ToolDefinition[] { + const chatAuthorId = "dashboard-chat"; + return [ + { + name: "fn_artifact_register", + label: "Register Artifact", + description: + "Register an artifact for a specific task so other agents can discover it. Requires task_id and notifies the dashboard inbox best-effort.", + parameters: chatArtifactRegisterParams, + execute: async (_id: string, params: Static) => registerArtifactForAgent( + store, + chatAuthorId, + { + type: params.type, + title: params.title, + description: params.description, + mimeType: params.mimeType, + uri: params.uri, + content: params.content, + taskId: params.task_id, + }, + messageStore, + ), + }, + { + name: "fn_artifact_list", + label: "List Artifacts", + description: + "List registered artifacts for a specific task. Supports filters for type, authorId, search, limit, and offset. Requires task_id.", + parameters: chatArtifactListParams, + execute: async (_id: string, params: Static) => listArtifactsForAgent(store, { + type: params.type, + authorId: params.authorId, + taskId: params.task_id, + search: params.search, + limit: params.limit, + offset: params.offset, + }), + }, + createArtifactViewTool(store), + ]; +} + +async function registerArtifactForAgent( + store: TaskStore, + authorId: string, + params: Static, + messageStore?: MessageStore, +) { + const input: ArtifactCreateInput = { + type: params.type, + title: params.title, + description: params.description, + mimeType: params.mimeType, + uri: params.uri, + content: params.content, + authorId, + authorType: "agent", + taskId: params.taskId, + }; + + try { + const artifact: Artifact = await store.registerArtifact(input); + notifyArtifactRegistered(messageStore, artifact, authorId); + return { + content: [{ + type: "text" as const, + text: `Registered artifact "${artifact.title}" (${artifact.type}) with id ${artifact.id}.`, + }], + details: { artifactId: artifact.id }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ + type: "text" as const, + text: `ERROR: Failed to register artifact "${params.title}": ${err.message}`, + }], + details: {}, + }; + } +} + +function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifact: Artifact, authorId: string): void { + if (!messageStore) return; + + try { + messageStore.sendMessage({ + fromType: "system", + toType: "user", + toId: DASHBOARD_USER_ID, + type: "system", + content: `New ${artifact.type} artifact registered: ${artifact.title}`, + metadata: { + artifactId: artifact.id, + artifactType: artifact.type, + title: artifact.title, + authorId, + taskId: artifact.taskId, + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + log.warn(`Failed to send best-effort artifact registration notification for ${artifact.id}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +async function listArtifactsForAgent(store: TaskStore, params: Static) { + try { + const artifacts: ArtifactWithTask[] = await store.listArtifacts({ + type: params.type, + authorId: params.authorId, + taskId: params.taskId, + search: params.search, + limit: params.limit, + offset: params.offset, + }); + + if (artifacts.length === 0) { + return { + content: [{ type: "text" as const, text: "No artifacts found." }], + details: {}, + }; + } + + const lines = artifacts.map((artifact) => { + const task = artifact.taskId ? `${artifact.taskId}${artifact.taskTitle ? ` (${artifact.taskTitle})` : ""}` : "no task"; + return `- ${artifact.id} [${artifact.type}] ${artifact.title} — author: ${artifact.authorId}; task: ${task}`; + }); + return { + content: [{ + type: "text" as const, + text: `Artifacts:\n${lines.join("\n")}`, + }], + details: { artifactIds: artifacts.map((artifact) => artifact.id) }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ + type: "text" as const, + text: `ERROR: Failed to list artifacts: ${err.message}`, + }], + details: {}, + }; + } +} + +async function viewArtifactForAgent(store: TaskStore, id: string) { + try { + const artifact: Artifact | null = await store.getArtifact(id); + if (!artifact) { + return { + content: [{ type: "text" as const, text: `Artifact "${id}" not found.` }], + details: {}, + }; + } + + const lines = [ + `Artifact: ${artifact.title}`, + `ID: ${artifact.id}`, + `Type: ${artifact.type}`, + `Author: ${artifact.authorId} (${artifact.authorType})`, + `Created: ${artifact.createdAt}`, + `Updated: ${artifact.updatedAt}`, + ]; + if (artifact.taskId) lines.push(`Task: ${artifact.taskId}`); + if (artifact.description) lines.push(`Description: ${artifact.description}`); + if (artifact.mimeType) lines.push(`MIME type: ${artifact.mimeType}`); + if (typeof artifact.sizeBytes === "number") lines.push(`Size: ${artifact.sizeBytes} bytes`); + if (artifact.uri) lines.push(`URI: ${artifact.uri}`); + if (artifact.content) lines.push("", artifact.content); + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + details: { artifactId: artifact.id }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ + type: "text" as const, + text: `ERROR: Failed to view artifact "${id}": ${err.message}`, + }], + details: {}, + }; + } +} + async function readTaskDocuments(store: TaskStore, taskId: string, key?: string) { try { if (key) { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 6da67fd11e..7d47460d42 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -178,6 +178,9 @@ import { createUpdateAgentConfigTool, createResearchTools, createSendMessageTool, + createArtifactListTool as sharedCreateArtifactListTool, + createArtifactRegisterTool as sharedCreateArtifactRegisterTool, + createArtifactViewTool as sharedCreateArtifactViewTool, createTaskCreateTool as sharedCreateTaskCreateTool, createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool, createTaskDocumentWriteTool as sharedCreateTaskDocumentWriteTool, @@ -1253,6 +1256,10 @@ You can save and retrieve named documents for this task. Use these to store plan Documents are versioned — each write creates a new revision. Use meaningful keys like "plan", "notes", "research", "architecture". +## Artifact Registry + +Use \`fn_artifact_register\` to register multi-type artifacts for discovery across agents and tasks, \`fn_artifact_list\` to find registered artifacts by type/author/task/search, and \`fn_artifact_view\` to inspect artifact metadata plus inline content or URI references. Artifact registration sends a best-effort system inbox notification to the dashboard user; notification failures do not make registration fail. + **IMPORTANT — Save your deliverables as documents:** When your task produces written output (documentation, specifications, reports, API references, README updates, guides, or any other content), you MUST save that content as a task document using \`fn_task_document_write\`. Use a key that describes the deliverable (e.g., key="readme", key="api-docs", key="changelog"). Do this in addition to writing the file to disk — the document persists in the task for review even after the worktree is cleaned up. If the task's PROMPT.md includes a "Documentation Requirements" section listing files to update, save each updated file's final content as a task document with a matching key. @@ -8307,6 +8314,12 @@ export class TaskExecutor { this.createSpawnAgentTool(task.id, worktreePath, settings, taskEnv), this.createTaskDocumentWriteTool(task.id), this.createTaskDocumentReadTool(task.id), + // FNXC:ArtifactRegistry 2026-06-21-07:04: Artifact list/view are read-only discovery tools and must remain available even when the task has no assigned agent identity; only registration requires an authorId for persisted attribution and best-effort inbox notification. + this.createArtifactListTool(), + this.createArtifactViewTool(), + ...(assignedAgentId ? [ + this.createArtifactRegisterTool(assignedAgentId), + ] : []), this.createWorkflowListTool(), this.createWorkflowGetTool(), this.createWorkflowSelectTool(task.id), @@ -10280,6 +10293,18 @@ export class TaskExecutor { return sharedCreateTaskDocumentReadTool(this.store, taskId); } + private createArtifactRegisterTool(authorId: string): ToolDefinition { + return sharedCreateArtifactRegisterTool(this.store, authorId, this.options.messageStore); + } + + private createArtifactListTool(): ToolDefinition { + return sharedCreateArtifactListTool(this.store); + } + + private createArtifactViewTool(): ToolDefinition { + return sharedCreateArtifactViewTool(this.store); + } + private createWorkflowListTool(): ToolDefinition { return sharedCreateWorkflowListTool(this.store); } diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index 9e3f7e8fd7..c0904352fd 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -80,6 +80,9 @@ export const ACTION_GATE_NETWORK_API_TOOLS: ReadonlySet = new Set([ ]); export const READONLY_FN_TOOLS: ReadonlySet = new Set([ + "fn_artifact_register", + "fn_artifact_list", + "fn_artifact_view", "fn_task_list", "fn_task_show", "fn_task_create", @@ -125,6 +128,10 @@ export const COORDINATION_EXEMPT_TOOLS = [ "fn_task_update", "fn_task_log", "fn_task_done", + /* FNXC:ArtifactRegistry 2026-06-21-00:00: Artifact registration mutates persisted registry state, but it is a low-risk coordination action classified like fn_task_document_write so permanent agents can publish discoverable deliverables without broad mutation approval. */ + "fn_artifact_register", + "fn_artifact_list", + "fn_artifact_view", "fn_task_document_write", "fn_task_document_read", "fn_memory_search", diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 485e79ffb8..a6a6ce027d 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -3,6 +3,10 @@ export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent export { createFusionAuthStorage } from "./auth-storage.js"; export { createTaskCreateTool, + createArtifactListTool, + createArtifactRegisterTool, + createArtifactViewTool, + createChatArtifactTools, createChatTaskDocumentTools, createTaskDocumentReadTool, createTaskDocumentWriteTool, @@ -19,6 +23,11 @@ export { createTraitListTool, createWorkflowAuthoringTools, taskCreateParams, + artifactListParams, + artifactRegisterParams, + artifactViewParams, + chatArtifactListParams, + chatArtifactRegisterParams, chatTaskDocumentReadParams, chatTaskDocumentWriteParams, taskDocumentReadParams, diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index 39eac9c428..fa7167159a 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -1,4 +1,10 @@ { "$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.", - "entries": [] + "entries": [ + { + "file": "packages/dashboard/src/__tests__/session-cross-tab.test.ts", + "reason": "FN-6690 local workspace `pnpm test` observed ENOTEMPTY while removing the test's temp .fusion directory in dashboard-api-quality-backfill shard; isolated rerun passed, indicating cleanup flake rather than a lazy-view CSS regression.", + "quarantinedAt": "2026-06-19" + } + ] } diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index f8d228e379..ff34fb6cdb 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -15,44 +15,44 @@ "packages/core/src/__tests__/store-settings.test.ts": 2196, "packages/core/src/agent-store.ts": 2946, "packages/core/src/central-core.ts": 3854, - "packages/core/src/db.ts": 5840, + "packages/core/src/db.ts": 5874, "packages/core/src/mission-store.ts": 4382, - "packages/core/src/store.ts": 16776, - "packages/core/src/types.ts": 7256, - "packages/dashboard/app/App.tsx": 2303, - "packages/dashboard/app/api/legacy.ts": 10612, + "packages/core/src/store.ts": 16865, + "packages/core/src/types.ts": 7260, + "packages/dashboard/app/App.tsx": 2326, + "packages/dashboard/app/api/legacy.ts": 10646, "packages/dashboard/app/components/AgentDetailView.tsx": 5400, "packages/dashboard/app/components/AgentsView.tsx": 2101, "packages/dashboard/app/components/ChatView.tsx": 3964, - "packages/dashboard/app/components/GitManagerModal.tsx": 3186, - "packages/dashboard/app/components/ListView.tsx": 2421, + "packages/dashboard/app/components/GitManagerModal.tsx": 3203, + "packages/dashboard/app/components/ListView.tsx": 2449, "packages/dashboard/app/components/MissionManager.tsx": 4999, "packages/dashboard/app/components/ModelOnboardingModal.tsx": 2932, - "packages/dashboard/app/components/PlanningModeModal.tsx": 3319, + "packages/dashboard/app/components/PlanningModeModal.tsx": 3328, "packages/dashboard/app/components/QuickChatFAB.tsx": 3560, - "packages/dashboard/app/components/QuickEntryBox.tsx": 2207, - "packages/dashboard/app/components/SettingsModal.tsx": 3251, + "packages/dashboard/app/components/QuickEntryBox.tsx": 2210, + "packages/dashboard/app/components/SettingsModal.tsx": 3265, "packages/dashboard/app/components/TaskCard.tsx": 2528, - "packages/dashboard/app/components/TaskDetailModal.tsx": 4569, - "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4457, + "packages/dashboard/app/components/TaskDetailModal.tsx": 4579, + "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4632, "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2761, - "packages/dashboard/app/components/__tests__/App.test.tsx": 4274, + "packages/dashboard/app/components/__tests__/App.test.tsx": 4257, "packages/dashboard/app/components/__tests__/ChatView.test.tsx": 5677, - "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3274, + "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3427, "packages/dashboard/app/components/__tests__/ListView.test.tsx": 4286, "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2037, "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 4575, - "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 2746, + "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 2771, "packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx": 2816, - "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4526, - "packages/dashboard/app/components/__tests__/SettingsModal.test.tsx": 5414, + "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4538, + "packages/dashboard/app/components/__tests__/SettingsModal.test.tsx": 5427, "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121, "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2407, "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917, "packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2297, - "packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5578, + "packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5689, "packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx": 2905, - "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx": 3174, + "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx": 3308, "packages/dashboard/app/hooks/__tests__/useChat.test.ts": 4097, "packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts": 2706, "packages/dashboard/app/hooks/__tests__/useTasks.test.ts": 2582, @@ -71,21 +71,21 @@ "packages/dashboard/src/__tests__/routes-tasks.test.ts": 2696, "packages/dashboard/src/__tests__/server.test.ts": 3048, "packages/dashboard/src/__tests__/usage.test.ts": 4327, - "packages/dashboard/src/chat.ts": 2193, + "packages/dashboard/src/chat.ts": 2197, "packages/dashboard/src/github.ts": 4178, "packages/dashboard/src/mission-routes.ts": 3948, - "packages/dashboard/src/planning.ts": 2696, + "packages/dashboard/src/planning.ts": 2700, "packages/dashboard/src/routes.ts": 5296, "packages/dashboard/src/routes/register-git-github.ts": 5637, "packages/dashboard/src/routes/register-settings-memory-routes.ts": 2421, - "packages/dashboard/src/routes/register-task-workflow-routes.ts": 3863, + "packages/dashboard/src/routes/register-task-workflow-routes.ts": 3861, "packages/dashboard/src/server.ts": 2378, "packages/engine/src/__tests__/executor-pause.test.ts": 2974, "packages/engine/src/__tests__/executor-prompt.test.ts": 2572, "packages/engine/src/__tests__/executor-recovery.test.ts": 3600, - "packages/engine/src/__tests__/executor-step-session.test.ts": 3731, + "packages/engine/src/__tests__/executor-step-session.test.ts": 3779, "packages/engine/src/__tests__/executor-worktree.test.ts": 2536, - "packages/engine/src/__tests__/heartbeat-executor.test.ts": 4027, + "packages/engine/src/__tests__/heartbeat-executor.test.ts": 4094, "packages/engine/src/__tests__/merger-merge-lifecycle.test.ts": 3253, "packages/engine/src/__tests__/merger-verification.test.ts": 3163, "packages/engine/src/__tests__/mission-execution-loop.test.ts": 2463, @@ -95,9 +95,9 @@ "packages/engine/src/__tests__/self-healing.test.ts": 9641, "packages/engine/src/__tests__/step-session-executor.test.ts": 2911, "packages/engine/src/__tests__/triage.test.ts": 4511, - "packages/engine/src/agent-heartbeat.ts": 4553, - "packages/engine/src/agent-tools.ts": 3584, - "packages/engine/src/executor.ts": 16034, + "packages/engine/src/agent-heartbeat.ts": 4557, + "packages/engine/src/agent-tools.ts": 3870, + "packages/engine/src/executor.ts": 16059, "packages/engine/src/merger.ts": 12643, "packages/engine/src/pi.ts": 2435, "packages/engine/src/project-engine.ts": 3663, From db17d6a19e93efaadfb8f4ce5a36b639a5f0e811 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:13:22 -0700 Subject: [PATCH 048/265] fix(dashboard): tighten sidebar/terminal chrome - Left sidebar New Task CTA now occupies the same box as item highlights (drop bleeding box-shadow). - Add spacing between Collapse and Settings in the sidebar footer. - Docked/floating terminal no longer blurs the page behind; page stays interactive. - Flatten the footer Terminal launcher to plain clickable text like the executor running-state trigger. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/LeftSidebarNav.css | 11 +++++++- .../app/components/TerminalLauncher.css | 28 +++++++++++++++++++ .../app/components/TerminalModal.css | 4 +++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/app/components/LeftSidebarNav.css b/packages/dashboard/app/components/LeftSidebarNav.css index 3e0b092501..8cc92d0553 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.css +++ b/packages/dashboard/app/components/LeftSidebarNav.css @@ -37,6 +37,9 @@ The collapse toggle lives in the footer above Settings instead of floating on th /* FNXC:Navigation 2026-06-21-20:45: The persistent desktop/tablet sidebar needs a centered New Task CTA at the top so task creation is reachable from any project screen. It uses the shared row label and rail hiding behavior so expanded mode shows icon plus text while collapsed mode remains an icon-only button with the same global dialog trigger. + +FNXC:Navigation 2026-06-22-00:00: +The New Task CTA must occupy exactly the same box as a sidebar item highlight: same min-height/padding/radius (inherited from .left-sidebar-nav__item) and the same horizontal inset as list rows. The list insets its rows by --space-sm padding, so the CTA matches with --space-sm side margins. The drop shadow is removed because it bled past the box edge and made the CTA read as larger than the item highlights; spacing below the CTA equals the inter-row gap (list padding-top --space-sm). */ .left-sidebar-nav__new-task { flex-shrink: 0; @@ -48,7 +51,6 @@ The persistent desktop/tablet sidebar needs a centered New Task CTA at the top s background: var(--accent); color: var(--accent-text); font-weight: 600; - box-shadow: var(--shadow-sm); } .left-sidebar-nav__new-task:hover, @@ -150,7 +152,14 @@ The narrower resizable sidebar must preserve row rhythm by truncating labels ins flex-shrink: 0; } +/* +FNXC:Navigation 2026-06-22-00:00: +The footer stacks the Collapse toggle above Settings. Use a flex column with a small gap so the two controls are visually separated instead of butting directly against each other. +*/ .left-sidebar-nav__footer { + display: flex; + flex-direction: column; + gap: var(--space-xs); margin-top: auto; padding: var(--space-sm); border-top: 1px solid var(--border); diff --git a/packages/dashboard/app/components/TerminalLauncher.css b/packages/dashboard/app/components/TerminalLauncher.css index 7980d4a2e6..50682da688 100644 --- a/packages/dashboard/app/components/TerminalLauncher.css +++ b/packages/dashboard/app/components/TerminalLauncher.css @@ -19,6 +19,34 @@ border-color: transparent; } +/* +FNXC:Terminal 2026-06-22-00:00: +In the footer status bar the Terminal launcher must read as plain clickable text, matching the executor "running" state-trigger: no border, no card background, no chunky button padding. The label underlines on hover like the state trigger. The scripts chevron is flattened to match and the split divider is hidden so the footer affordance is text-first. Only the footer variant is flattened; the header variant keeps its grouped split-button chrome. +*/ +.terminal-launcher--footer .terminal-launcher__main, +.terminal-launcher--footer .terminal-launcher__chevron { + padding: 0; + min-height: 0; + border: none; + background: transparent; + box-shadow: none; + border-radius: var(--radius-sm); +} + +.terminal-launcher--footer .terminal-launcher__chevron { + width: auto; + padding-left: var(--space-xxs); +} + +.terminal-launcher--footer .terminal-launcher__divider { + display: none; +} + +.terminal-launcher--footer .terminal-launcher__main:hover .terminal-launcher__label { + text-decoration: underline; + text-underline-offset: calc(var(--space-xs) / 2); +} + .terminal-launcher__main { min-height: var(--control-height-sm); gap: var(--space-xs); diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 757b72a8fd..18ce1ced11 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -26,6 +26,9 @@ FN-6811 recurrence #6 tightened ownership of this scoped symbols face: every ter /* FNXC:Terminal 2026-06-21-22:32: FN-6887 turns desktop/tablet terminal into a bottom-docked panel above the footer. Keep mobile on the fullscreen modal path through the max-width media override below. + +FNXC:Terminal 2026-06-22-00:00: +The docked/floating terminal must not blur or dim the page behind it, and the page must stay interactive. The base .modal-overlay applies backdrop-filter: blur(4px); override it to none here. background is already transparent and pointer-events:none lets clicks pass through to the page behind (the terminal panel itself re-enables pointer-events). */ .terminal-modal-overlay--docked, .terminal-modal-overlay--floating { @@ -33,6 +36,7 @@ FN-6887 turns desktop/tablet terminal into a bottom-docked panel above the foote justify-content: flex-end; padding: 0; background: transparent; + backdrop-filter: none; pointer-events: none; } From 7f9849d6edf88c6e76836e97a4f6ec54d73d0955 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:18:21 -0700 Subject: [PATCH 049/265] FN-6908: add expandable artifact media previews Adds a polished media gallery experience for image and video artifacts in Documents. - Make image and video artifact cards open an accessible lightbox with keyboard, backdrop, and close-button dismissal. - Style expandable previews, hover affordances, and responsive full-size media presentation. - Cover the media lightbox behavior in DocumentsView tests and document the dashboard gallery behavior. - Add localized labels for expanding and closing artifact previews. Files changed: docs/dashboard-guide.md | 4 +- .../dashboard/app/components/DocumentsView.css | 156 +++++++++++++++++++-- .../dashboard/app/components/DocumentsView.tsx | 121 +++++++++++++++- .../components/__tests__/DocumentsView.test.tsx | 46 +++++- packages/i18n/locales/en/app.json | 6 +- packages/i18n/locales/es/app.json | 6 +- packages/i18n/locales/fr/app.json | 6 +- packages/i18n/locales/ko/app.json | 6 +- packages/i18n/locales/zh-CN/app.json | 6 +- packages/i18n/locales/zh-TW/app.json | 6 +- 10 files changed, 338 insertions(+), 25 deletions(-) Fusion-Task-Id: FN-6908 Fusion-Task-Lineage: cf4d7d83-f58f-4686-ade9-b8b260bf400d --- docs/dashboard-guide.md | 4 +- .../app/components/DocumentsView.css | 156 ++++++++++++++++-- .../app/components/DocumentsView.tsx | 121 +++++++++++++- .../__tests__/DocumentsView.test.tsx | 46 +++++- packages/i18n/locales/en/app.json | 6 +- packages/i18n/locales/es/app.json | 6 +- packages/i18n/locales/fr/app.json | 6 +- packages/i18n/locales/ko/app.json | 6 +- packages/i18n/locales/zh-CN/app.json | 6 +- packages/i18n/locales/zh-TW/app.json | 6 +- 10 files changed, 338 insertions(+), 25 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c113bc36b8..7d078225ed 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -486,6 +486,8 @@ Features: - Search documents across tasks - Open project markdown files with inline preview - Browse the **Artifacts** tab for media registered by agents, users, or the system across tasks +- Use the responsive media gallery to scan thumbnail-first image and video cards with consistent framing, while audio, document, and generic artifacts remain readable cards in the same grid +- Expand image and video artifact thumbnails into a full-size lightbox; dismiss it with the close button, backdrop click, or Escape while non-previewable artifact cards keep their normal controls and links - Preview artifact images inline, play video and audio with native controls, read document previews, and open generic artifacts through their media URL - Jump directly from a document group or artifact card to the owning task detail modal when a task is linked; inside task detail, the **Artifacts** tab shows that task's documents and registered media artifacts together - Toggle between raw text and rendered markdown using the **Markdown/Plain** button @@ -831,7 +833,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit and push real merge changes, or report success without an empty commit when the selected base is already merged; preflight then refreshes so normal PR creation can continue once all checks pass. - The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. - AI title/body generation is bounded to 60 seconds and is canceled if the dialog request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever. -- The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. Images preview inline, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL. +- The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. The gallery uses thumbnail-first image/video cards, image and video previews can expand into a dismissible full-size lightbox, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL. - The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread. - **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass. - Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call). diff --git a/packages/dashboard/app/components/DocumentsView.css b/packages/dashboard/app/components/DocumentsView.css index eb666de925..b7d53ccc68 100644 --- a/packages/dashboard/app/components/DocumentsView.css +++ b/packages/dashboard/app/components/DocumentsView.css @@ -617,38 +617,88 @@ font-family: var(--font-primary); } +/* +FNXC:ArtifactRegistry 2026-06-21-23:15: +The artifacts tab is a thumbnail-first responsive media gallery for agent-created images and videos, while audio, document, and generic artifacts keep coherent card previews in the same grid. +*/ .documents-artifact-gallery { display: grid; - grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); - gap: var(--space-md); - align-items: start; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); + gap: var(--space-lg); + align-items: stretch; } .documents-artifact-card { display: flex; flex-direction: column; min-height: 100%; + overflow: hidden; + border-radius: var(--radius-lg); + transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); +} + +.documents-artifact-card:hover, +.documents-artifact-card:focus-within { + transform: translateY(calc(-1 * var(--space-xs) / 2)); + border-color: var(--todo); + box-shadow: var(--shadow-lg); + background: var(--card-hover); } .documents-artifact-preview { display: flex; align-items: center; justify-content: center; - min-height: 12rem; + aspect-ratio: 16 / 10; + min-height: 0; + overflow: hidden; background: var(--surface); - border-bottom: 1px solid var(--border); + border-bottom: thin solid var(--border); +} + +.documents-artifact-preview--expandable { + position: relative; + cursor: zoom-in; + border: 0; +} + +.documents-artifact-preview--expandable:focus-visible { + outline: none; + box-shadow: inset var(--focus-ring-strong); } .documents-artifact-media { display: block; width: 100%; - max-height: 18rem; - object-fit: contain; + height: 100%; + object-fit: cover; background: var(--bg); } +.documents-artifact-expand-hint { + position: absolute; + right: var(--space-sm); + bottom: var(--space-sm); + border-radius: var(--radius-pill); + padding: var(--space-xs) var(--space-sm); + color: var(--text); + background: var(--surface); + box-shadow: var(--shadow-sm); + font-size: 0.75rem; + font-weight: 600; + opacity: 0; + transform: translateY(var(--space-xs)); + transition: opacity var(--transition-fast), transform var(--transition-fast); +} + +.documents-artifact-preview--expandable:hover .documents-artifact-expand-hint, +.documents-artifact-preview--expandable:focus-visible .documents-artifact-expand-hint { + opacity: 1; + transform: translateY(0); +} + .documents-artifact-audio { - width: calc(100% - var(--space-lg)); + width: calc(100% - var(--space-xl)); } .documents-artifact-document, @@ -659,10 +709,12 @@ justify-content: center; gap: var(--space-sm); width: 100%; - min-height: 12rem; + height: 100%; + min-height: 100%; padding: var(--space-lg); color: var(--text-muted); text-align: center; + background: linear-gradient(135deg, var(--surface), var(--bg)); } .documents-artifact-document p { @@ -677,13 +729,15 @@ transition: color var(--transition-fast), background var(--transition-fast); } -.documents-artifact-generic:hover { +.documents-artifact-generic:hover, +.documents-artifact-generic:focus-visible { color: var(--todo); background: var(--card-hover); } .documents-artifact-body { display: flex; + flex: 1; flex-direction: column; gap: var(--space-sm); padding: var(--space-md); @@ -702,7 +756,7 @@ .documents-artifact-type-badge { display: inline-flex; align-items: center; - border: 1px solid var(--border); + border: thin solid var(--border); border-radius: var(--radius-pill); padding: var(--space-xs) var(--space-sm); color: var(--todo); @@ -712,6 +766,7 @@ } .documents-artifact-author { + min-width: 0; font-family: var(--font-mono); color: var(--text-muted); overflow: hidden; @@ -735,6 +790,59 @@ .documents-artifact-task-link { align-self: flex-start; + margin-top: auto; +} + +.documents-artifact-lightbox-overlay { + padding: var(--space-xl); +} + +.documents-artifact-lightbox { + display: flex; + flex-direction: column; + width: min(90vw, 72rem); + max-height: min(90vh, 48rem); + overflow: hidden; + border: thin solid var(--border); + border-radius: var(--radius-xl); + background: var(--surface); + box-shadow: var(--shadow-lg); +} + +.documents-artifact-lightbox-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); + border-bottom: thin solid var(--border); +} + +.documents-artifact-lightbox-title { + margin: 0; + color: var(--text); + font-size: 1rem; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.documents-artifact-lightbox-media-frame { + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + padding: var(--space-lg); + background: var(--bg); +} + +.documents-artifact-lightbox-media { + display: block; + max-width: 100%; + max-height: calc(90vh - var(--space-2xl) - var(--space-xl)); + object-fit: contain; + border-radius: var(--radius-md); } @@ -858,6 +966,32 @@ min-height: 10rem; } + .documents-artifact-expand-hint { + opacity: 1; + transform: translateY(0); + } + + .documents-artifact-lightbox-overlay { + padding: var(--space-sm); + } + + .documents-artifact-lightbox { + width: 100%; + max-height: calc(100vh - var(--space-lg)); + } + + .documents-artifact-lightbox-header { + padding: var(--space-sm) var(--space-md); + } + + .documents-artifact-lightbox-media-frame { + padding: var(--space-sm); + } + + .documents-artifact-lightbox-media { + max-height: calc(100vh - var(--space-2xl) - var(--space-xl)); + } + .documents-artifact-meta, .documents-artifact-header { align-items: flex-start; diff --git a/packages/dashboard/app/components/DocumentsView.tsx b/packages/dashboard/app/components/DocumentsView.tsx index 8b46c93200..3de002d0ef 100644 --- a/packages/dashboard/app/components/DocumentsView.tsx +++ b/packages/dashboard/app/components/DocumentsView.tsx @@ -1,5 +1,5 @@ import "./DocumentsView.css"; -import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react"; +import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent, type KeyboardEvent, type MouseEvent } from "react"; import { useTranslation } from "react-i18next"; import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react"; import ReactMarkdown from "react-markdown"; @@ -45,6 +45,7 @@ interface ArtifactCardProps { artifact: ArtifactWithTask; projectId?: string; onOpenTask: (taskId: string) => void; + onExpandMedia: (artifact: ArtifactWithTask) => void; } function formatTimestamp(iso?: string): string { @@ -184,18 +185,43 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta ); } -function ArtifactCard({ artifact, projectId, onOpenTask }: ArtifactCardProps) { +function ArtifactCard({ artifact, projectId, onOpenTask, onExpandMedia }: ArtifactCardProps) { const { t } = useTranslation("app"); const mediaUrl = artifactMediaUrl(artifact.id, projectId); const typeLabel = getArtifactTypeLabel(t, artifact.type); const preview = artifact.content ? getContentPreview(artifact.content, 320) : artifact.description; const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact"); + const isExpandableMedia = artifact.type === "image" || artifact.type === "video"; + const handleExpandKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onExpandMedia(artifact); + } + }, [artifact, onExpandMedia]); return (
-
- -
+ {isExpandableMedia ? ( +
onExpandMedia(artifact)} + onKeyDown={handleExpandKeyDown} + > + {artifact.type === "image" ? ( + {title} + ) : ( +
+ ) : ( +
+ +
+ )}
{typeLabel} @@ -239,6 +265,13 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti const [renderProjectMarkdown, setRenderProjectMarkdown] = useState(false); // Markdown render toggles per task document card (scoped by doc ID) const [taskDocMarkdownStates, setTaskDocMarkdownStates] = useState>(new Map()); + /* + FNXC:ArtifactRegistry 2026-06-21-23:22: + Image and video artifacts open in a dismissible lightbox, but audio, document, and generic artifacts remain normal cards so non-previewable media never receive orphaned expand targets. + */ + const [lightboxArtifact, setLightboxArtifact] = useState(null); + const lightboxCloseRef = useRef(null); + const lightboxReturnFocusRef = useRef(null); const [selectionCommentOpen, setSelectionCommentOpen] = useState(false); const markdownSelection = useSelectionComment(markdownPreviewRef, { locked: selectionCommentOpen }); const plainSelection = useSelectionComment(plainPreviewRef, { locked: selectionCommentOpen }); @@ -298,6 +331,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti setFileLoading(false); setRenderProjectMarkdown(false); setTaskDocMarkdownStates(new Map()); + setLightboxArtifact(null); }, [projectId]); useEffect(() => { @@ -431,6 +465,46 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti }); }, []); + const handleExpandArtifact = useCallback((artifact: ArtifactWithTask) => { + lightboxReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setLightboxArtifact(artifact); + }, []); + + const handleCloseLightbox = useCallback(() => { + setLightboxArtifact(null); + lightboxReturnFocusRef.current?.focus(); + lightboxReturnFocusRef.current = null; + }, []); + + useEffect(() => { + if (!lightboxArtifact) { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + lightboxCloseRef.current?.focus(); + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + handleCloseLightbox(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener("keydown", handleKeyDown); + }; + }, [handleCloseLightbox, lightboxArtifact]); + + const handleLightboxOverlayClick = useCallback((event: MouseEvent) => { + if (event.target === event.currentTarget) { + handleCloseLightbox(); + } + }, [handleCloseLightbox]); + const activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError; const handleRetry = useCallback(async () => { @@ -685,6 +759,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti artifact={artifact} projectId={projectId} onOpenTask={handleOpenTask} + onExpandMedia={handleExpandArtifact} /> ))}
@@ -725,6 +800,42 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
)}
+ {lightboxArtifact && ( +
+ {/* FNXC:ArtifactRegistry 2026-06-21-23:22: The lightbox reuses the shared modal overlay pattern so image/video artifacts can expand full-size and dismiss by close button, backdrop, or Escape on desktop and mobile. */} +
event.stopPropagation()}> +
+

{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}

+ +
+
+ {lightboxArtifact.type === "image" ? ( + {lightboxArtifact.title + ) : ( +
+
+
+ )} ); } diff --git a/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx b/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx index 6d99a0fd7f..77c4034dd3 100644 --- a/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import type { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core"; import { DocumentsView } from "../DocumentsView"; import { fetchTaskDetail, fetchWorkspaceFileContent } from "../../api"; @@ -274,7 +274,7 @@ describe("DocumentsView", () => { expect(screen.queryByRole("button", { name: "Open README.md" })).not.toBeInTheDocument(); }); - it("renders artifacts tab counts and all media card paths", async () => { + it("renders artifacts tab counts and all media card paths without non-media expand shells", async () => { mockUseArtifacts.mockReturnValue({ artifacts: mockArtifacts, loading: false, @@ -293,6 +293,8 @@ describe("DocumentsView", () => { expect(screen.getByRole("tab", { name: /show artifacts/i })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media"); + expect(screen.getByRole("button", { name: "Expand Image artifact" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Expand Video artifact" })).toBeInTheDocument(); expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO"); expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO"); expect(screen.getByTestId("artifact-document-preview")).toHaveTextContent("Inline document preview"); @@ -300,6 +302,11 @@ describe("DocumentsView", () => { expect(screen.getByText("agent-image")).toBeInTheDocument(); expect(screen.getByText("Image")).toBeInTheDocument(); + for (const title of ["Audio artifact", "Document artifact", "Other artifact"]) { + const card = screen.getByRole("article", { name: `Artifact ${title}` }); + expect(within(card).queryByRole("button", { name: `Expand ${title}` })).not.toBeInTheDocument(); + } + fireEvent.click(screen.getByRole("button", { name: /open task KB-001/i })); await waitFor(() => { expect(mockFetchTaskDetail).toHaveBeenCalledWith("KB-001", undefined); @@ -308,6 +315,41 @@ describe("DocumentsView", () => { expect(screen.getAllByRole("button", { name: /open task/i })).toHaveLength(1); }); + it("opens and dismisses the image and video artifact lightbox by click keyboard close backdrop and escape", () => { + mockUseArtifacts.mockReturnValue({ + artifacts: mockArtifacts, + loading: false, + error: null, + refresh: vi.fn().mockResolvedValue(undefined), + }); + + const { container } = render(); + + fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i })); + + fireEvent.click(screen.getByRole("button", { name: "Expand Image artifact" })); + let dialog = screen.getByRole("dialog", { name: "Artifact media preview" }); + expect(within(dialog).getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media"); + expect(document.body.style.overflow).toBe("hidden"); + + fireEvent.click(within(dialog).getByRole("button", { name: "Close artifact preview" })); + expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument(); + + fireEvent.keyDown(screen.getByRole("button", { name: "Expand Image artifact" }), { key: "Enter" }); + dialog = screen.getByRole("dialog", { name: "Artifact media preview" }); + expect(within(dialog).getByRole("img", { name: "Image artifact" })).toBeInTheDocument(); + fireEvent.click(dialog); + expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument(); + + fireEvent.keyDown(screen.getByRole("button", { name: "Expand Video artifact" }), { key: " " }); + dialog = screen.getByRole("dialog", { name: "Artifact media preview" }); + expect(within(dialog).getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO"); + expect(container.querySelector(".documents-artifact-lightbox-media-frame video")).toHaveAttribute("controls"); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument(); + expect(document.body.style.overflow).toBe(""); + }); + it("renders artifacts empty loading error retry and mobile gallery states", async () => { const artifactRefresh = vi.fn().mockResolvedValue(undefined); mockUseArtifacts.mockReturnValue({ diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 2cf850608e..fcd9f3b09a 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -2213,7 +2213,11 @@ "openTaskAria": "Open task {{taskId}}: {{title}}", "searchArtifacts": "Search artifacts…", "showArtifacts": "Show artifacts", - "untitledArtifact": "Untitled artifact" + "untitledArtifact": "Untitled artifact", + "closeLightbox": "Close artifact preview", + "expandArtifact": "Expand {{title}}", + "expandArtifactHint": "Click to expand", + "lightboxLabel": "Artifact media preview" }, "droidCli": { "active": "Active", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 105afc9c71..d673c94685 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -2213,7 +2213,11 @@ "openTaskAria": "Open task {{taskId}}: {{title}}", "searchArtifacts": "Search artifacts…", "showArtifacts": "Show artifacts", - "untitledArtifact": "Untitled artifact" + "untitledArtifact": "Untitled artifact", + "closeLightbox": "Close artifact preview", + "expandArtifact": "Expand {{title}}", + "expandArtifactHint": "Click to expand", + "lightboxLabel": "Artifact media preview" }, "droidCli": { "active": "Activo", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index f1a5262123..967752a7cb 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -2213,7 +2213,11 @@ "openTaskAria": "Open task {{taskId}}: {{title}}", "searchArtifacts": "Search artifacts…", "showArtifacts": "Show artifacts", - "untitledArtifact": "Untitled artifact" + "untitledArtifact": "Untitled artifact", + "closeLightbox": "Close artifact preview", + "expandArtifact": "Expand {{title}}", + "expandArtifactHint": "Click to expand", + "lightboxLabel": "Artifact media preview" }, "droidCli": { "active": "Actif", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 229e15321c..d902e84b11 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -2213,7 +2213,11 @@ "openTaskAria": "Open task {{taskId}}: {{title}}", "searchArtifacts": "Search artifacts…", "showArtifacts": "Show artifacts", - "untitledArtifact": "Untitled artifact" + "untitledArtifact": "Untitled artifact", + "closeLightbox": "Close artifact preview", + "expandArtifact": "Expand {{title}}", + "expandArtifactHint": "Click to expand", + "lightboxLabel": "Artifact media preview" }, "droidCli": { "active": "활성", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 2e9278311a..91328665f6 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -2213,7 +2213,11 @@ "openTaskAria": "Open task {{taskId}}: {{title}}", "searchArtifacts": "Search artifacts…", "showArtifacts": "Show artifacts", - "untitledArtifact": "Untitled artifact" + "untitledArtifact": "Untitled artifact", + "closeLightbox": "Close artifact preview", + "expandArtifact": "Expand {{title}}", + "expandArtifactHint": "Click to expand", + "lightboxLabel": "Artifact media preview" }, "droidCli": { "active": "活跃", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index aedfd2eadc..fd38de4907 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -2213,7 +2213,11 @@ "openTaskAria": "Open task {{taskId}}: {{title}}", "searchArtifacts": "Search artifacts…", "showArtifacts": "Show artifacts", - "untitledArtifact": "Untitled artifact" + "untitledArtifact": "Untitled artifact", + "closeLightbox": "Close artifact preview", + "expandArtifact": "Expand {{title}}", + "expandArtifactHint": "Click to expand", + "lightboxLabel": "Artifact media preview" }, "droidCli": { "active": "活躍", From 108d9f6dca7cfbc99efe43602987ba240af65b2e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:20:44 -0700 Subject: [PATCH 050/265] feat(dashboard): embedded planning + sidebar/dev-server styling - Planning mode embedded view drops the modal header/close and uses a plain common title like Command Center. - Remove the divider before the secondary section (Goals/Evals) in the left sidebar. - Flatten the Dev Server view header to match embedded-view styling (no card chrome). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/app/components/DevServerView.css | 8 ++++---- .../app/components/LeftSidebarNav.css | 5 ++++- .../app/components/PlanningModeModal.css | 15 +++++++++++++++ .../app/components/PlanningModeModal.tsx | 18 ++++++++++++------ 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/dashboard/app/components/DevServerView.css b/packages/dashboard/app/components/DevServerView.css index 0fcd0c54cc..2bdca7348a 100644 --- a/packages/dashboard/app/components/DevServerView.css +++ b/packages/dashboard/app/components/DevServerView.css @@ -12,15 +12,15 @@ overscroll-behavior: contain; } +/* +FNXC:DevServer 2026-06-22-00:00: +The Dev Server view header must read like other embedded views (Command Center cc-header): a plain title row with actions, not a bordered card. Drop the card border/background/padding so the heading sits flush with the view padding; the title font already matches the shared 1.125rem embedded-title size. +*/ .dev-server-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); - padding: var(--space-md); - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--card); } .dev-server-header-title { diff --git a/packages/dashboard/app/components/LeftSidebarNav.css b/packages/dashboard/app/components/LeftSidebarNav.css index 8cc92d0553..6f5f03ea21 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.css +++ b/packages/dashboard/app/components/LeftSidebarNav.css @@ -75,9 +75,12 @@ The New Task CTA must occupy exactly the same box as a sidebar item highlight: s gap: var(--space-xs); } +/* +FNXC:Navigation 2026-06-22-00:00: +The secondary section keeps its top spacing but drops the divider line before the first secondary entry (Goals/Evals); the rule reads as one continuous nav list instead of two bordered groups. +*/ .left-sidebar-nav__section--secondary { padding-top: var(--space-sm); - border-top: 1px solid var(--border); } .left-sidebar-nav__item { diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index bceb52a7a9..bb5a1cb62b 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -72,6 +72,21 @@ FN-6886 promotes Planning Mode into the main app content area. The embedded shel box-shadow: none; } +/* +FNXC:PlanningMode 2026-06-22-00:00: +The embedded planning title must read like other embedded-view titles (Command Center cc-header/cc-title): a plain heading with no tinted modal-header bar, no bottom divider, and no close button. Strip the modal-header background/border and align the title to the content edge; bump the heading to the shared 1.125rem embedded-title size. +*/ +.planning-modal--embedded .modal-header--embedded { + padding: 0 0 var(--space-md); + background: transparent; + border-bottom: none; +} + +.planning-modal--embedded .modal-header--embedded h3 { + font-size: 1.125rem; + letter-spacing: normal; +} + .planning-modal .modal-header { flex-shrink: 0; } diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 9999646b56..cf2507a91c 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -1826,7 +1826,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat aria-modal={isEmbedded ? undefined : "true"} >
-
+ {/* + FNXC:PlanningMode 2026-06-22-00:00: + Embedded planning is a main-content destination, not a dialog: it drops the modal close button and renders a plain common title (modal-header--embedded) matching other embedded views like Command Center. The mobile back affordance stays because it navigates the session list, not the view. + */} +
{mobileShowDetail && (
-
- -
+ {!isEmbedded && ( +
+ +
+ )}
Date: Mon, 22 Jun 2026 00:24:28 -0700 Subject: [PATCH 051/265] FN-6912: slim the messages split pane divider Slim the full-page Messages split-pane divider without reducing its interactive resize target. - Reduce the visible mailbox split resize handle from space-sm to space-xs. - Keep the pseudo-element hit target at space-sm for hover, active, and drag affordances. - Extend MailboxView CSS coverage to assert the thinner handle and preserved target width. Files changed: packages/dashboard/app/components/MailboxModal.css | 8 ++++++-- .../dashboard/app/components/__tests__/MailboxView.test.tsx | 12 +++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6912 Fusion-Task-Lineage: c5fccc5f-56b2-4188-8c27-51ed3b269f86 --- packages/dashboard/app/components/MailboxModal.css | 8 ++++++-- .../app/components/__tests__/MailboxView.test.tsx | 12 +++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css index 3e5c00e05f..02b220a7e1 100644 --- a/packages/dashboard/app/components/MailboxModal.css +++ b/packages/dashboard/app/components/MailboxModal.css @@ -669,9 +669,13 @@ min-height: 0; } +/* +FNXC:DashboardStyling 2026-06-21-23:40: +FN-6912 requires the full-page Messages divider to read thinner between the message list and detail panes while preserving resize discoverability. Keep the visible handle narrow, but leave the hover/active pseudo-element wider so the drag and focus affordances do not become an un-grabbable sliver. +*/ .mailbox-view .mailbox-split-resize-handle { position: relative; - width: var(--space-sm); + width: var(--space-xs); flex-shrink: 0; cursor: col-resize; background: color-mix(in srgb, var(--border) 70%, transparent); @@ -685,7 +689,7 @@ top: 0; bottom: 0; left: 50%; - width: var(--space-xs); + width: var(--space-sm); transform: translateX(-50%); } diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index eb8bbe1150..ebcdac7963 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -1868,7 +1868,17 @@ describe("MailboxView", () => { expect(splitPaneBlock).toContain("border: var(--btn-border-width) solid var(--border);"); expect(splitPaneBlock).toContain("background: var(--surface);"); - expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{[^}]*cursor:\s*col-resize;[^}]*background:\s*color-mix\(in srgb,\s*var\(--border\)\s*70%,\s*transparent\);[^}]*\}/); + const resizeHandleBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{([^}]*)\}/); + expect(resizeHandleBlockMatch).toBeTruthy(); + const resizeHandleBlock = resizeHandleBlockMatch![1]; + expect(resizeHandleBlock).toContain("width: var(--space-xs);"); + expect(resizeHandleBlock).toContain("cursor: col-resize;"); + expect(resizeHandleBlock).toContain("background: color-mix(in srgb, var(--border) 70%, transparent);"); + + const resizeHandleTargetBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle::before\s*\{([^}]*)\}/); + expect(resizeHandleTargetBlockMatch).toBeTruthy(); + expect(resizeHandleTargetBlockMatch![1]).toContain("width: var(--space-sm);"); + expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle:hover::before,\s*\n\.mailbox-view\s+\.mailbox-split-resize-handle:active::before\s*\{[^}]*background:\s*color-mix\(in srgb,\s*var\(--todo\)\s*35%,\s*transparent\);[^}]*\}/); const splitEmptyBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-empty\s*\{([^}]*)\}/); expect(splitEmptyBlockMatch).toBeTruthy(); From b9a27785ebaed8c7a8efe5691fdb9583b845ca5c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:29:37 -0700 Subject: [PATCH 052/265] feat(dashboard): right-sidebar header toggle + sidebar fixes - Add a non-mobile header right-sidebar show/hide toggle (PanelRight) wired to the right-dock open state; replaces the tablet three-dots overflow. Mobile keeps its overflow menu untouched. - Right dock is fully hidden when closed (no persistent rail); main content reclaims the space. Resize handle preserved when open. - Fix New Task CTA width: .left-sidebar-nav__item width:100% was overriding it, overflowing the unpadded aside by 2x--space-sm and overlapping the resize bar; raise specificity so it matches the item highlight box. - Make the primary/secondary nav gap match the row rhythm (Compound -> Goals no longer doubled). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/App.tsx | 3 ++ packages/dashboard/app/components/Header.tsx | 41 ++++++++++++++++--- .../app/components/LeftSidebarNav.css | 13 +++++- .../dashboard/app/components/RightDock.tsx | 10 ++++- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 2c611fbd8c..a4cc68e2b0 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1998,6 +1998,9 @@ function AppInner() { projectId={currentProject?.id} mobileNavEnabled={isMobile} leftSidebarNavActive={sidebarActive} + rightDockAvailable={rightDockActive} + rightDockOpen={rightDock.open} + onToggleRightDock={rightDock.toggle} // Node switching props availableNodes={nodes} currentNode={currentNode} diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 6676b2b3f0..9a7d7e4b6d 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, ChevronDown, ChevronRight } from "lucide-react"; +import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, ChevronDown, ChevronRight, PanelRight } from "lucide-react"; import "./Header.css"; // ProjectSelector styles used by the imported standalone component. import "./ProjectSelector.css"; @@ -96,6 +96,16 @@ export interface HeaderProps { mobileNavEnabled?: boolean; /** When true on non-mobile screens, persistent left sidebar owns primary view navigation. */ leftSidebarNavActive?: boolean; + /* + FNXC:Navigation 2026-06-22-00:00: + The right dock is no longer a persistent rail. On non-mobile surfaces the Header owns a single show/hide toggle (replacing the tablet three-dots overflow) that opens/closes the right sidebar; mobile keeps its existing overflow menu untouched. + */ + /** Whether the right dock is available on this surface (non-mobile + enabled). */ + rightDockAvailable?: boolean; + /** Current open state of the right dock. */ + rightDockOpen?: boolean; + /** Toggle the right dock open/closed. */ + onToggleRightDock?: () => void; /** Available nodes for the node selector */ availableNodes?: NodeConfig[]; /** Currently selected node (null for local) */ @@ -145,6 +155,9 @@ export function Header({ shellHost = { kind: "browser" }, mobileNavEnabled, leftSidebarNavActive = false, + rightDockAvailable = false, + rightDockOpen = false, + onToggleRightDock, availableNodes = [], currentNode, onSelectNode, @@ -958,8 +971,26 @@ export function Header({ {/* Plugin UI slot for header actions */} - {/* Compact overflow menu trigger (mobile + tablet) */} - {isCompact && !hideFullNav && ( + {/* + FNXC:Navigation 2026-06-22-00:00: + Non-mobile surfaces (desktop + tablet) get a single right-sidebar show/hide toggle that owns the right dock visibility. It replaces the tablet three-dots overflow; the dock is fully hidden when closed and reopened from here. Mobile is intentionally excluded — it keeps its existing overflow menu untouched and has no right dock. + */} + {!isMobile && rightDockAvailable && onToggleRightDock && ( + + )} + + {/* Compact overflow menu trigger (mobile only — tablet uses the right-sidebar toggle above) */} + {isMobile && !hideFullNav && ( )}
- {/* Close button — uses shared modal-close for consistent sizing and alignment */} - + {/* Close button — uses shared modal-close for consistent sizing and alignment. + FNXC:RightDockEmbedded 2026-06-22-00:00: Dropped in embedded mode; the dock provides its own close. */} + {!isEmbedded && ( + + )}
{/* Active filters display */} @@ -463,6 +472,24 @@ export function ActivityLogModal({
)} + ); + + if (isEmbedded) { + // FNXC:RightDockEmbedded 2026-06-22-00:00: Plain flow container — no fixed overlay, no backdrop click-to-close. Dock owns the chrome. + return
{body}
; + } + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + role="dialog" + aria-modal="true" + data-testid="activity-log-modal-overlay" + > + {body}
); } diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx index e5b750e383..e57fa0f452 100644 --- a/packages/dashboard/app/components/GitManagerModal.tsx +++ b/packages/dashboard/app/components/GitManagerModal.tsx @@ -199,15 +199,25 @@ interface GitManagerModalProps { tasks: Task[]; addToast: (message: string, type?: ToastType) => void; projectId?: string; + /* + FNXC:RightDockEmbedding 2026-06-22-00:00: + Right-dock redesign renders dock items inline inside the dock container rather than as fixed popup modals. + `presentation="embedded"` switches GitManager from a fixed `.modal-overlay` overlay to an inline view that fills its container. + Default stays "modal" so all existing overlay call sites keep byte-identical behavior. + Embedded mode must disable modal-only behaviors (scroll lock, resize persistence, Escape-to-close, overlay click dismiss) since they break the host page. + */ + presentation?: "modal" | "embedded"; } // ── Main Component ──────────────────────────────────────────────── -export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, projectId }: GitManagerModalProps) { +export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, projectId, presentation = "modal" }: GitManagerModalProps) { const { t } = useTranslation("app"); const confirmContext = useConfirm(); const viewportMode = useViewportMode(); - useMobileScrollLock(isOpen); + // FNXC:RightDockEmbedding 2026-06-22-00:00: embedded mode gates modal-only behaviors below. + const isEmbedded = presentation === "embedded"; + useMobileScrollLock(isOpen && !isEmbedded); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: viewportMode === "mobile", }); @@ -235,7 +245,8 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const [loading, setLoading] = useState(false); const [sectionError, setSectionError] = useState(null); const modalRef = useRef(null); - useModalResizePersist(modalRef, isOpen, "fusion:git-modal-size"); + // FNXC:RightDockEmbedding 2026-06-22-00:00: skip modal resize persist/restore when embedded inline. + useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:git-modal-size"); const overlayDismissProps = useOverlayDismiss(handleClose); const copyToClipboard = useCopyToClipboard(addToast); @@ -367,7 +378,8 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj // ── Keyboard Navigation ───────────────────────────────────────── useEffect(() => { - if (!isOpen) return; + // FNXC:RightDockEmbedding 2026-06-22-00:00: embedded mode has no overlay to dismiss; a global Escape listener would hijack page keys. + if (!isOpen || isEmbedded) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { handleClose(); @@ -386,7 +398,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); - }, [isOpen, handleClose, activeSection]); + }, [isOpen, isEmbedded, handleClose, activeSection]); // ── Changes Handlers ──────────────────────────────────────────── @@ -914,6 +926,223 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj if (!isOpen) return null; + // FNXC:RightDockEmbedding 2026-06-22-00:00: shared git body reused by both the embedded inline view and the modal overlay below; kept identical between presentations. + const gitBody = ( + <> + {/* Sidebar Navigation */} + + + {/* Content Area */} +
+ {/* Loading overlay */} + {loading && ( +
+ + {t("git.loading", "Loading...")} +
+ )} + + {/* Error state */} + {sectionError && !loading && ( +
+ + {sectionError} + +
+ )} + + {/* ── Status Panel ── */} + {activeSection === "status" && !loading && status && ( + + )} + + {/* ── Changes Panel ── */} + {activeSection === "changes" && !loading && ( + + )} + + {/* ── Commits Panel ── */} + {activeSection === "commits" && !loading && ( + = commitsLimit && commitsLimit < 100} + copyToClipboard={copyToClipboard} + /> + )} + + {/* ── Branches Panel ── */} + {activeSection === "branches" && !loading && ( + + )} + + {/* ── Worktrees Panel ── */} + {activeSection === "worktrees" && !loading && ( + + )} + + {/* ── Stashes Panel ── */} + {activeSection === "stashes" && !loading && ( + + )} + + {/* ── Recovery Panel ── */} + {activeSection === "recovery" && !loading && ( + + )} + + {/* ── Remotes Panel ── */} + {activeSection === "remotes" && !loading && ( + + )} +
+ + ); + + /* + FNXC:RightDockEmbedding 2026-06-22-00:00: + Embedded mode renders the same git content inline (fills the right-dock container) with no fixed overlay, no resize handle, and no close button. + Modal mode (default) keeps the exact original overlay markup byte-identical. + */ + if (isEmbedded) { + return ( +
+
+
+

+ + {t("git.modalTitle", "Git Manager")} +

+
+ +
+
+ +
+ {gitBody} +
+
+
+ ); + } + return (
@@ -938,183 +1167,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
- {/* Sidebar Navigation */} - - - {/* Content Area */} -
- {/* Loading overlay */} - {loading && ( -
- - {t("git.loading", "Loading...")} -
- )} - - {/* Error state */} - {sectionError && !loading && ( -
- - {sectionError} - -
- )} - - {/* ── Status Panel ── */} - {activeSection === "status" && !loading && status && ( - - )} - - {/* ── Changes Panel ── */} - {activeSection === "changes" && !loading && ( - - )} - - {/* ── Commits Panel ── */} - {activeSection === "commits" && !loading && ( - = commitsLimit && commitsLimit < 100} - copyToClipboard={copyToClipboard} - /> - )} - - {/* ── Branches Panel ── */} - {activeSection === "branches" && !loading && ( - - )} - - {/* ── Worktrees Panel ── */} - {activeSection === "worktrees" && !loading && ( - - )} - - {/* ── Stashes Panel ── */} - {activeSection === "stashes" && !loading && ( - - )} - - {/* ── Recovery Panel ── */} - {activeSection === "recovery" && !loading && ( - - )} - - {/* ── Remotes Panel ── */} - {activeSection === "remotes" && !loading && ( - - )} -
+ {gitBody}
diff --git a/packages/dashboard/app/components/RightDock.tsx b/packages/dashboard/app/components/RightDock.tsx index 24caeeeac2..e26c524768 100644 --- a/packages/dashboard/app/components/RightDock.tsx +++ b/packages/dashboard/app/components/RightDock.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; -import { Maximize2, PanelRight } from "lucide-react"; +import { Maximize2 } from "lucide-react"; import { findOverflowViewEntry, getVisibleOverflowViewEntries, @@ -89,7 +89,6 @@ The right dock is persistent and visible by default on tablet/desktop project sc */ export function RightDock({ open, - onOpenChange, renderProps, visibilityOptions = {}, onExpand, @@ -121,12 +120,6 @@ export function RightDock({ persistRightDockView(key); }, [renderProps, visibilityOptions]); - const toggleCollapsed = useCallback(() => { - const nextOpen = !open; - persistRightDockOpen(nextOpen); - onOpenChange(nextOpen); - }, [onOpenChange, open]); - const handleResizeStart = useCallback((event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -243,17 +236,6 @@ export function RightDock({ ) : null} - {open ? ( diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index b89ee7defc..5136f6f8bd 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -1193,6 +1193,28 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy overflow: hidden; } +/* +FNXC:RightDockEmbedded 2026-06-22-00:00: +Right-dock redesign renders the activity log inline inside the dock container instead of as a fixed popup overlay. +The embedded root is a plain flow box that fills the dock; the inner panel sheds overlay chrome (fixed sizing, shadow, radius, resize) and fills 100% of the host so the dock owns the frame and its own header/close. +*/ +.activity-log-embedded.right-dock-embedded-view { + display: flex; + width: 100%; + height: 100%; + min-height: 0; +} + +.activity-log-modal--embedded { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + box-shadow: none; + border-radius: 0; + resize: none; +} + .activity-log-header { /* Extends shared .modal-header with activity-log-specific overrides */ gap: var(--space-sm); @@ -1790,6 +1812,34 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy resize: both; } +/* +FNXC:RightDockEmbedding 2026-06-22-00:00: +Right-dock redesign renders dock items inline (GitManager presentation="embedded") instead of as fixed popup modals. +The embedded host fills its right-dock container; the inner shell drops overlay-only chrome (fixed sizing, box-shadow, resize handle, rounded corners) so it reads as an inline panel, not a floating modal. +*/ +.git-manager-embedded { + display: flex; + width: 100%; + height: 100%; + min-height: 0; +} + +.gm-modal.gm-modal--embedded { + width: 100%; + height: 100%; + max-width: none; + min-width: 0; + max-height: none; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: none; + border-radius: 0; + resize: none; + position: static; +} + /* Main layout: sidebar + content */ .gm-layout { display: flex; diff --git a/packages/dashboard/app/components/UsageIndicator.css b/packages/dashboard/app/components/UsageIndicator.css index e8eb63b9bf..21052ea7c4 100644 --- a/packages/dashboard/app/components/UsageIndicator.css +++ b/packages/dashboard/app/components/UsageIndicator.css @@ -621,6 +621,35 @@ resize: both; } +/* + * FNXC:UsageIndicator 2026-06-22-00:00: + * Embedded presentation for the right-dock redesign. The usage view renders + * inline inside the dock container as a plain flow box: no fixed positioning, + * no popover box-shadow, no resize handle, filling its parent at 100%/100%. + */ +.usage-indicator-embedded { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} + +.usage-modal--embedded { + position: static; + width: 100%; + height: 100%; + max-width: none; + max-height: none; + min-width: 0; + min-height: 0; + box-shadow: none; + resize: none; + overflow: hidden; + display: flex; + flex-direction: column; +} + .usage-modal-overlay { --overlay-padding-top: var(--space-lg); } diff --git a/packages/dashboard/app/components/UsageIndicator.tsx b/packages/dashboard/app/components/UsageIndicator.tsx index ccbddfa1c6..39e1a75e7a 100644 --- a/packages/dashboard/app/components/UsageIndicator.tsx +++ b/packages/dashboard/app/components/UsageIndicator.tsx @@ -13,6 +13,15 @@ interface UsageIndicatorProps { onClose: () => void; projectId?: string; anchorRect?: DOMRect | null; + /** + * FNXC:UsageIndicator 2026-06-22-00:00: + * Right-dock redesign renders dock items inline instead of as popup modals. + * "embedded" presentation makes the usage view render as a plain flow container + * inside the right-dock (no fixed overlay, no popover anchoring, no close button, + * filling its parent at width/height 100%). Modal behavior is unchanged when + * presentation is "modal"/undefined. + */ + presentation?: "modal" | "embedded"; } /** @@ -612,8 +621,9 @@ function UsageSkeleton() { * Shows hourly and weekly usage windows with percentage bars, * reset timers, and pace indicators. */ -export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: UsageIndicatorProps) { +export function UsageIndicator({ isOpen, onClose, projectId, anchorRect, presentation = "modal" }: UsageIndicatorProps) { const { t } = useTranslation("app"); + const isEmbedded = presentation === "embedded"; const { providers, loading, error, lastUpdated, hasFetched, refresh } = useUsageData({ autoRefresh: isOpen, // Only poll when modal is open }); @@ -644,8 +654,10 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage }, [projectId]); // Persist user resizes via ResizeObserver (debounced). + // FNXC:UsageIndicator 2026-06-22-00:00: embedded presentation has no resizable + // popover surface, so skip the desktop popover resize-observer entirely. useEffect(() => { - if (!isOpen || !isDesktopViewport) return; + if (isEmbedded || !isOpen || !isDesktopViewport) return; const el = modalRef.current; if (!el || typeof ResizeObserver === "undefined") return; @@ -669,7 +681,7 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage if (timer) clearTimeout(timer); observer.disconnect(); }; - }, [isOpen, isDesktopViewport, projectId]); + }, [isEmbedded, isOpen, isDesktopViewport, projectId]); useEffect(() => { if (typeof window === "undefined") { @@ -872,8 +884,10 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage }, [refresh]); // Close on Escape key + // FNXC:UsageIndicator 2026-06-22-00:00: embedded presentation has no modal to + // dismiss, so Escape-to-close is a modal-only behavior. useEffect(() => { - if (!isOpen) return; + if (isEmbedded || !isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { @@ -883,7 +897,7 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); - }, [isOpen, onClose]); + }, [isEmbedded, isOpen, onClose]); // Close on overlay click const handleOverlayClick = useCallback( @@ -921,10 +935,16 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage const usageContent = (
- + {/* FNXC:UsageIndicator 2026-06-22-00:00: embedded presentation drops the + modal close button; the right-dock owns dismissal. */} + {!isEmbedded && ( + + )} @@ -1047,6 +1071,17 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage ); + // FNXC:UsageIndicator 2026-06-22-00:00: embedded presentation renders the usage + // view as a plain flow container inside the right-dock (no fixed overlay, no + // popover backdrop), filling its parent. + if (isEmbedded) { + return ( +
+ {usageContent} +
+ ); + } + if (showDesktopPopover) { return ( <> diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index 8988711806..9717e242e5 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -18,6 +18,9 @@ import type { DetailTaskTab, PluginDashboardViewContext } from "../plugins/types import { FileBrowser } from "./FileBrowser"; import { PageErrorBoundary } from "./ErrorBoundary"; import { getPluginNavIcon } from "./pluginNavIcon"; +import { UsageIndicator } from "./UsageIndicator"; +import { ActivityLogModal } from "./ActivityLogModal"; +import { GitManagerModal } from "./GitManagerModal"; export type OverflowViewKey = | "usage" @@ -116,20 +119,35 @@ The right dock and its expand modal must resolve every hosted overflow destinati FNXC:Navigation 2026-06-21-20:10: FN-6882 makes the right dock a tools rail for Activity, Activity Log, GitHub Import, Git Manager, Files, and Automation so content views live only in the left sidebar and do not duplicate across navigation surfaces. */ +/* +FNXC:Navigation 2026-06-22-00:00: +Right-dock tools render INLINE inside the dock container, not as popup modals: usage, activity-log, and git-manager use each modal's `presentation="embedded"` mode instead of launching an overlay. (github-import and automation remain launcher actions here only until their left-sidebar/main destinations land, then they leave the dock.) +*/ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ { key: "usage", label: "Activity", icon: Activity, testId: "right-dock-tab-usage", - onActivate: (props) => props.onOpenUsage?.(null), + render: (props) => wrapOverflowView( + {}} projectId={props.projectId} presentation="embedded" />, + ), }, { key: "activity-log", label: "Activity Log", icon: History, testId: "right-dock-tab-activity-log", - onActivate: (props) => props.onOpenActivityLog?.(), + render: (props) => wrapOverflowView( + {}} + tasks={(props.tasks ?? []) as Task[]} + onOpenTaskDetail={props.onOpenTaskDetail} + projectId={props.projectId} + presentation="embedded" + />, + ), }, { key: "github-import", @@ -143,7 +161,16 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ label: "Git Manager", icon: GitBranch, testId: "right-dock-tab-git-manager", - onActivate: (props) => props.onOpenGitManager?.(), + render: (props) => wrapOverflowView( + {}} + tasks={(props.tasks ?? []) as Task[]} + addToast={props.addToast} + projectId={props.projectId} + presentation="embedded" + />, + ), }, { key: "files", From cdee83c3ecfefc37a912edae65aeefc101f527e4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:42:02 -0700 Subject: [PATCH 054/265] fix(dashboard): list narrowing, planning blend, insights header, graph out of dock - List view split sidebar min-width 120->64 so it can be dragged much narrower; titles wrap to two lines in the split sidebar. - Embedded planning blends like Command Center: no panel shadow/border/outline, transparent background, matching --space-lg padding. - Insights view header wraps so action buttons drop to a new line instead of overlapping the title. - Dependency graph no longer appears in the right dock (left-sidebar destination only). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/components/InsightsView.css | 6 ++++++ packages/dashboard/app/components/ListView.css | 9 +++++++++ packages/dashboard/app/components/ListView.tsx | 2 +- .../dashboard/app/components/PlanningModeModal.css | 11 +++++++++++ .../dashboard/app/components/overflowViewRegistry.tsx | 5 +++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/app/components/InsightsView.css b/packages/dashboard/app/components/InsightsView.css index 9fa6b711d2..5b2d4d8014 100644 --- a/packages/dashboard/app/components/InsightsView.css +++ b/packages/dashboard/app/components/InsightsView.css @@ -7,10 +7,16 @@ overflow: hidden; } +/* +FNXC:Insights 2026-06-22-00:00: +The header title and action buttons must never overlap: allow the row to wrap so the actions drop to a new line when there is not enough horizontal room. The gap keeps spacing between the wrapped rows. +*/ .insights-view-header { display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-sm); padding: var(--space-lg); border-bottom: 1px solid var(--border); background: var(--surface); diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index 5e50cb4038..1fb6bd7063 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -556,6 +556,15 @@ FN-6529 requires list-view agent-active tasks to use a simple static highlight i white-space: nowrap; } +/* +FNXC:ListView 2026-06-22-00:00: +In the split sidebar the title cell must allow the title to wrap to two lines (handled by .list-title-text clamp) instead of being capped/truncated to one line, so the left panel can be dragged much narrower while titles stay legible. +*/ +.list-split-sidebar .list-cell-title { + max-width: none; + white-space: normal; +} + .list-title-content { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 33e308f2de..8eb11a06aa 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -179,7 +179,7 @@ function readSidebarWidth(projectId?: string): number { return fallbackWidth; } -const LIST_SIDEBAR_MIN_WIDTH = 120; // FNXC:ListView 2026-06-21-22:31: The desktop task-list split sidebar minimum is 120 instead of 200 so users can shrink the left panel significantly further on narrow desktop layouts while resize, keyboard, and ARIA paths share one clamp value. +const LIST_SIDEBAR_MIN_WIDTH = 64; // FNXC:ListView 2026-06-22-00:00: The desktop task-list split sidebar minimum is 64 (was 120) so users can shrink the left panel much further; task titles wrap to two lines (.list-split-sidebar .list-cell-title) so they stay legible at narrow widths. Resize, keyboard, and ARIA paths share one clamp value. const LIST_SIDEBAR_MAX_RATIO = 0.65; const LIST_SIDEBAR_KEYBOARD_STEP = 16; diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index bb5a1cb62b..c83f62ef72 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -72,6 +72,17 @@ FN-6886 promotes Planning Mode into the main app content area. The embedded shel box-shadow: none; } +/* +FNXC:PlanningMode 2026-06-22-00:00: +Embedded planning must blend into the main content like Command Center: no panel shadow, no border/outline, no rounded card chrome, and a transparent background so the view sits flush on the project-content surface. Higher specificity (.planning-view .planning-modal--embedded) is required to beat the base .modal shadow/border/background. +*/ +.planning-view .planning-modal--embedded { + box-shadow: none; + border: none; + border-radius: 0; + background: transparent; +} + /* FNXC:PlanningMode 2026-06-22-00:00: The embedded planning title must read like other embedded-view titles (Command Center cc-header/cc-title): a plain heading with no tinted modal-header bar, no bottom divider, and no close button. Strip the modal-header background/border and align the title to the content edge; bump the heading to the shared 1.125rem embedded-title size. diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index 9717e242e5..a07b2107a5 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -191,6 +191,11 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ function buildPluginOverflowViewEntries(pluginDashboardViews: PluginDashboardViewEntry[] = []): OverflowViewEntry[] { return pluginDashboardViews .filter((entry) => entry.view.placement !== "primary") + /* + FNXC:Navigation 2026-06-22-00:00: + The dependency graph must not appear in the right sidebar; it remains a left-sidebar destination only. + */ + .filter((entry) => entry.pluginId !== "fusion-plugin-dependency-graph") .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)) .map((entry) => { const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); From cf0465785c767cd7177216e0047ab7a183b31ea2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:43:42 -0700 Subject: [PATCH 055/265] style(dashboard): workflow selector matches project selector Align the workflow-switcher trigger with the project-selector trigger: transparent background, --radius-md, muted text that brightens on hover with --card-hover fill and --text-dim border, content-driven width (drop the fixed min-width; keep the max-width clamp). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/WorkflowSwitcher.css | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/app/components/WorkflowSwitcher.css b/packages/dashboard/app/components/WorkflowSwitcher.css index 5ac46e3814..653d853031 100644 --- a/packages/dashboard/app/components/WorkflowSwitcher.css +++ b/packages/dashboard/app/components/WorkflowSwitcher.css @@ -11,26 +11,31 @@ flex: 0 0 auto; } +/* +FNXC:WorkflowSwitcher 2026-06-22-00:00: +The workflow selector trigger must match the project selector trigger styling: transparent background, --radius-md border, muted text that brightens on hover with a --card-hover fill and --text-dim border, and content-driven width (no fixed min-width). Only the max-width clamp is kept so long workflow names stay bounded. +*/ .workflow-switcher-trigger { display: inline-flex; align-items: center; justify-content: space-between; - gap: var(--space-sm); - min-width: calc(var(--space-xl) * 7.5); + gap: var(--space-xs); + min-width: 0; max-width: calc(var(--space-xl) * 12); - min-height: calc(var(--space-lg) + var(--space-sm)); - padding: var(--space-xs) var(--space-sm); - background: var(--bg-secondary); + padding: calc(var(--space-xs) + var(--space-xs) / 2) calc(var(--space-xs) + var(--space-xs) / 2); + background: transparent; border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text); + border-radius: var(--radius-md); + color: var(--text-muted); font: inherit; text-align: left; + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); } .workflow-switcher-trigger:hover, .workflow-switcher-trigger[aria-expanded="true"] { - background: var(--bg-tertiary); + background: var(--card-hover); + color: var(--text); border-color: var(--text-dim); } From 7830c13a7ed091f11357dd3fa83bd75dd330f01e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:49:38 -0700 Subject: [PATCH 056/265] feat(dashboard): Workflows, Import Tasks, Automations as left-sidebar main views - New built-in task views: workflows, import-tasks, automations (left-sidebar destinations rendering in the main content area). - WorkflowNodeEditor, GitHubImportModal, and ScheduledTasksModal gain a presentation=embedded mode (inline, no overlay/close, modal-only behaviors disabled). Automations embedded view uses a Command Center-style header and a responsive two-pane (list + detail) layout when wide enough. - Left sidebar adds Workflows, Import Tasks, Automations entries; renderMainContent renders the embedded views. - Remove github-import and automation from the right dock; hide the desktop Header Workflow button when the left sidebar owns Workflows. Mobile overflow keeps the modal entry points unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/App.tsx | 59 +++++ .../app/components/GitHubImportModal.css | 25 +++ .../app/components/GitHubImportModal.tsx | 45 +++- packages/dashboard/app/components/Header.tsx | 7 +- .../app/components/LeftSidebarNav.tsx | 34 +++ .../app/components/ScheduledTasksModal.tsx | 205 ++++++++++++++---- .../dashboard/app/components/ScriptsModal.css | 126 +++++++++++ .../app/components/WorkflowNodeEditor.css | 29 +++ .../app/components/WorkflowNodeEditor.tsx | 64 +++++- .../app/components/overflowViewRegistry.tsx | 18 -- packages/dashboard/app/hooks/useViewState.ts | 9 +- 11 files changed, 535 insertions(+), 86 deletions(-) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index a4cc68e2b0..0b8fc90292 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -123,6 +123,13 @@ const DevServerView = lazy(() => import("./components/DevServerView").then((m) = const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView }))); const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView }))); const PullRequestView = lazy(() => import("./components/PullRequestView").then((m) => ({ default: m.PullRequestView }))); +/* +FNXC:Navigation 2026-06-22-00:00: +Workflows, Import Tasks (GitHub import), and Automations render as embedded main-content views (presentation="embedded") via these lazy chunks; the same components still mount as modals in AppModals for the mobile overflow path. +*/ +const WorkflowEditorView = lazy(() => import("./components/WorkflowNodeEditor").then((m) => ({ default: m.WorkflowNodeEditor }))); +const ImportTasksView = lazy(() => import("./components/GitHubImportModal").then((m) => ({ default: m.GitHubImportModal }))); +const AutomationsView = lazy(() => import("./components/ScheduledTasksModal").then((m) => ({ default: m.ScheduledTasksModal }))); // Warm lazy chunks during browser idle so first navigation to each view is // instant. Each chunk is ~10–80 kB; total prefetch finishes well under a @@ -1832,6 +1839,58 @@ function AppInner() { ); } + /* + FNXC:Navigation 2026-06-22-00:00: + Workflows, Import Tasks (GitHub import), and Automations are left-sidebar destinations that render embedded in the main content area instead of as modal overlays. Closing returns to the board. The same components still mount as modals in AppModals for the mobile overflow path. + */ + if (taskView === "workflows") { + return ( + + + handleChangeTaskView("board")} + addToast={addToast} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + + if (taskView === "import-tasks") { + return ( + + + handleChangeTaskView("board")} + onImport={handleGitHubImport} + tasks={tasks} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + + if (taskView === "automations") { + return ( + + + handleChangeTaskView("board")} + addToast={addToast} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + if (taskView === "devserver" || taskView === "dev-server") { if (!settingsLoaded || !devServerEnabled) { return null; diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index f7cb4da344..c2661d53c4 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -937,4 +937,29 @@ } } +/* +FNXC:RightDockEmbedding 2026-06-22-00:00: +Right-dock redesign renders the GitHub import surface inline in the main content area instead of as a fixed popup overlay. +The embedded root is a plain flow box that fills the host; the inner shell sheds overlay-only chrome (fixed sizing, box-shadow, rounded corners, resize) and fills 100% so the main panel owns the frame. No close button is rendered in embedded mode. +*/ +.github-import-embedded.right-dock-embedded-view { + display: flex; + width: 100%; + height: 100%; + min-height: 0; +} + +.github-import-modal.github-import-modal--embedded { + width: 100%; + height: 100%; + max-width: none; + min-width: 0; + max-height: none; + min-height: 0; + position: static; + box-shadow: none; + border-radius: 0; + resize: none; +} + diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 52c57f8d2d..2163fe9c85 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -24,6 +24,12 @@ interface GitHubImportModalProps { onImport: (task: Task) => void; tasks: Task[]; projectId?: string; + /* + FNXC:RightDockEmbedding 2026-06-22-00:00: + Right-dock redesign renders the GitHub import surface inline inside the main content area instead of as a fixed popup overlay. + "embedded" drops the modal overlay/close button and disables modal-only chrome (scroll lock, resize persistence, escape/overlay dismiss); "modal" (default) keeps the original byte-identical overlay behavior. + */ + presentation?: "modal" | "embedded"; } // Mobile and two-pane breakpoints in pixels @@ -50,8 +56,9 @@ function formatPreviewBody(body: string | null | undefined, isMobile: boolean) { return body.slice(0, 200) + (body.length > 200 ? "…" : ""); } -export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) { - useMobileScrollLock(isOpen); +export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) { + const isEmbedded = presentation === "embedded"; + useMobileScrollLock(isOpen && !isEmbedded); const { t } = useTranslation("app"); const [owner, setOwner] = useState(""); const [repo, setRepo] = useState(""); @@ -80,7 +87,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId const [selectedRemoteName, setSelectedRemoteName] = useState(""); const mountedRef = useRef(false); const modalRef = useRef(null); - useModalResizePersist(modalRef, isOpen, "fusion:github-modal-size"); + useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:github-modal-size"); const overlayDismissProps = useOverlayDismiss(onClose); // Responsive view state @@ -281,14 +288,15 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }, [owner, repo, labels, activeTab, isOpen, loading, importing, handleLoad, handleLoadPulls]); // Handle escape key + // FNXC:RightDockEmbedding 2026-06-22-00:00: Escape-to-close is a modal-only affordance; embedded mode has no dismiss. useEffect(() => { - if (!isOpen) return; + if (!isOpen || isEmbedded) return; const handleKey = (e: globalThis.KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); - }, [isOpen, onClose]); + }, [isOpen, isEmbedded, onClose]); // Detect responsive viewport bands useEffect(() => { @@ -480,9 +488,18 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId const showPullsError = Boolean(error) && pulls.length > 0 && !isPullsEmpty; const showInlineErrorBanner = activeTab === "issues" ? showIssuesError : showPullsError; - return ( -
-
+ /* + FNXC:RightDockEmbedding 2026-06-22-00:00: + Embedded mode renders the import surface as a main-content-area view (no fixed .modal-overlay, no close button, no overlay-dismiss). + Modal mode is kept byte-identical: same overlay wrapper, header with subtitle + close button, and overlay-dismiss props. + */ + const inner = ( +
+ {isEmbedded ? ( +
+

{t("git.importTasksHeading", "Import Tasks")}

+
+ ) : (

{t("git.importFromGitHub", "Import from GitHub")}

@@ -494,6 +511,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId ×
+ )}
{/* Tab Navigation */} @@ -861,7 +879,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId {importing ? : t("git.import", "Import")}
-
+
+ ); + + if (isEmbedded) { + return
{inner}
; + } + + return ( +
+ {inner}
); } diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 9a7d7e4b6d..103056365b 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -945,8 +945,11 @@ export function Header({ FN-6886 removes the header Lightbulb affordances because Planning Mode is now a primary left-sidebar destination after Command Center and a single canonical MobileNavBar More item on compact breakpoints. */} - {/* Workflows - desktop only (moved to overflow on mobile/tablet) */} - {!isCompact && onOpenWorkflowEditor && ( + {/* + FNXC:Navigation 2026-06-22-00:00: + When the left sidebar is active it owns Workflows as a main-content destination, so the Header drops its duplicate desktop Workflow button. The flag-off desktop layout keeps the Header button; mobile/tablet keep the overflow entry. + */} + {!isCompact && !leftSidebarNavActive && onOpenWorkflowEditor && ( + +
+ + + {t("schedule.automationCount", "{{count}} automation{{plural}}", { count: routines.length, plural: routines.length === 1 ? "" : "s" })} + +
+
+ {isShowingList && ( + + )} +
+ + ); + + // ── Embedded (main-content-area) presentation ─────────────────────────── + // FNXC:AutomationsEmbedded 2026-06-22-00:00: + // Renders inline like Command Center: no overlay/close, a plain .cc-header title row, --space-lg view padding, + // no card chrome. The body is a responsive two-pane layout: a left list pane and a right detail pane that + // collapse to a single column below ~900px (see .automations-embedded CSS). In list view the left pane shows a + // compact selectable rail; selecting a routine renders its full RoutineCard on the right. In create/edit view the + // editor spans the full width. + if (isEmbedded) { + const isListView = routineView === "list"; + return ( +
+
+
+

+ + {t("schedule.title", "Automations")} +

+
+ + {toolbar} + + {isListView && routines.length > 0 ? ( +
+ {/* Left pane: compact selectable list of automations */} +
+ {routines.map((r) => ( + + ))} +
+ + {/* Right pane: detail for the selected automation, or an empty prompt */} +
+ {selectedRoutine ? ( +
+ +
+ ) : ( +
+ +

{t("schedule.selectAutomation", "Select an automation")}

+

{t("schedule.selectAutomationHint", "Choose an automation from the list to view its details.")}

+
+ )} +
+
+ ) : ( + // Empty state, create, and edit views span the full width (single column). +
+ {renderContent()} +
+ )} +
+
+ ); + } + + // ── Modal (fixed overlay) presentation ────────────────────────────────── return (
@@ -299,48 +453,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
-
-
-
- - -
- - - {t("schedule.automationCount", "{{count}} automation{{plural}}", { count: routines.length, plural: routines.length === 1 ? "" : "s" })} - -
-
- {isShowingList && ( - - )} -
-
+ {toolbar}
{renderContent()} diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 5136f6f8bd..f715cc0b4d 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -1215,6 +1215,132 @@ The embedded root is a plain flow box that fills the dock; the inner panel sheds resize: none; } +/* +FNXC:AutomationsEmbedded 2026-06-22-00:00: +Automations can render inline in the main content area (presentation="embedded") instead of as a fixed modal overlay. +The embedded root fills its host and sheds all modal chrome — no overlay, no card/shadow/border/radius — so the view +blends into the main panel like Command Center. The view container carries --space-lg padding and a plain .cc-header +title row (reused from Command Center). The body is a responsive two-pane layout (list + detail) via container query +when supported, falling back to a min-width media breakpoint, that collapses to a single column below ~900px. +*/ +.automations-embedded.right-dock-embedded-view { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + background: none; + box-shadow: none; + border: none; + border-radius: 0; +} + +.automations-embedded-view { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-lg); + min-height: 0; + inline-size: 100%; + padding: var(--space-lg); + /* Enable container-query-driven two-pane breakpoint scoped to the view's own width, not the viewport. */ + container-type: inline-size; + overflow-y: auto; +} + +/* Two-pane body: single column by default (narrow); two columns when the container is wide enough. */ +.automations-two-pane { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-lg); + min-height: 0; + flex: 1; +} + +.automations-single-pane { + min-height: 0; + flex: 1; +} + +/* Left list rail */ +.automations-list-pane { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.automation-list-row { + display: flex; + align-items: center; + gap: var(--space-sm); + width: 100%; + padding: var(--space-sm) var(--space-md); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text); + font-size: 0.875rem; + text-align: left; + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.automation-list-row:hover { + border-color: var(--accent); +} + +.automation-list-row.active { + border-color: var(--accent); + background: var(--accent-subtle, var(--card)); +} + +.automation-list-row-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.automation-list-row-badge { + flex-shrink: 0; + padding: 0 var(--space-sm); + border-radius: var(--radius-sm); + background: var(--bg); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.6875rem; +} + +/* Right detail pane */ +.automations-detail-pane { + min-width: 0; + min-height: 0; +} + +.automations-detail-empty { + height: 100%; +} + +/* Two columns once the embedded container is wide enough (~900px). */ +@container (min-width: 900px) { + .automations-two-pane { + grid-template-columns: minmax(0, 18rem) minmax(0, 1fr); + align-items: start; + } +} + +/* +Fallback for browsers without container-query support: use a viewport media query. Harmless where container +queries already apply (the container-query rule above also fires and produces the same two-column layout). +*/ +@media (min-width: 900px) { + .automations-two-pane { + grid-template-columns: minmax(0, 18rem) minmax(0, 1fr); + align-items: start; + } +} + .activity-log-header { /* Extends shared .modal-header with activity-log-specific overrides */ gap: var(--space-sm); diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 76291dcc97..689e155c6d 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -14,6 +14,35 @@ border-radius: var(--radius-md); } +/* +FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: +Embedded presentation renders the editor inline as a main-content-area view +filling the right-dock panel instead of as a centered fixed modal. The wrapper +takes the full panel box and the modal element drops its modal chrome +(fixed sizing, box-shadow, border-radius, resize grip) so it reads as a flush +embedded view. +*/ +.workflow-editor-embedded { + display: flex; + width: 100%; + height: 100%; + min-height: 0; +} + +.wf-editor-modal--embedded { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + min-width: 0; + min-height: 0; + position: static; + resize: none; + box-shadow: none; + border: none; + border-radius: 0; +} + .wf-create-modal { --wf-editor-touch-target: calc(var(--space-xl) + var(--space-lg) + var(--space-xs)); } diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 96bb377571..ea87164ea7 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -197,6 +197,17 @@ interface WorkflowNodeEditorProps { initialAction?: "create"; /** Workflow id to preselect when the editor opens from workflow-aware surfaces. */ initialWorkflowId?: string; + /* + FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: + The workflow editor can render either as a fixed modal overlay ("modal", the + default and historical behavior) or inline as a main-content-area view + ("embedded") that fills the right-dock panel like a Command Center view. + In embedded mode the editor drops the .modal-overlay shell, the X close + button, native resize, and all modal-only dismiss paths (Escape, overlay + click) so it reads as a persistent view rather than a dismissible dialog. + The modal path stays byte-identical when presentation is "modal"/undefined. + */ + presentation?: "modal" | "embedded"; } let nodeSeq = 0; @@ -697,7 +708,11 @@ function InnerEditor({ initialAction, initialWorkflowId, modalRef, -}: Omit & { modalRef: React.RefObject }) { + isEmbedded = false, +}: Omit & { + modalRef: React.RefObject; + isEmbedded?: boolean; +}) { const [workflows, setWorkflows] = useState([]); const [activeId, setActiveId] = useState(null); const viewportMode = useViewportMode(); @@ -2435,11 +2450,15 @@ function InnerEditor({ ) : null; - return ( - <> -
+ // FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: + // Embedded mode renders the editor inline inside the right-dock panel: no + // fixed .modal-overlay shell, no overlay-click dismiss, no Escape-to-close, + // and a --embedded sized variant of the modal element. The modal path stays + // byte-identical (same overlay + overlayProps + Escape handler) when not + // embedded. + const modalElement = (
e.stopPropagation()} onKeyDown={(e) => { @@ -2447,6 +2466,8 @@ function InnerEditor({ // Ignore Escape originating from inputs/textareas/selects so inline // editors (name/description) keep their own Escape-to-cancel behavior. if (e.key !== "Escape") return; + // Embedded views are persistent; Escape must not dismiss them. + if (isEmbedded) return; // The create dialog (rendered as a child) owns its own Escape; if it's // open, let it handle the event (it stops propagation already). if (createOpen) return; @@ -2459,9 +2480,13 @@ function InnerEditor({ >

{t("workflows.title", "Workflows")}

- + {/* FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: embedded views keep a + Command Center-style header title but drop the modal X close button. */} + {!isEmbedded ? ( + + ) : null}
{showMigrationNotice ? ( @@ -4598,7 +4623,20 @@ function InnerEditor({ /> )}
-
+ ); + return ( + <> + {isEmbedded ? ( + // FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: inline main-content + // wrapper (no fixed overlay, no overlayProps overlay-click dismiss). +
+ {modalElement} +
+ ) : ( +
+ {modalElement} +
+ )} {promptFullscreenOverlay} ); @@ -4612,9 +4650,14 @@ export function WorkflowNodeEditor({ initialPanel, initialAction, initialWorkflowId, + presentation = "modal", }: WorkflowNodeEditorProps) { const modalRef = useRef(null); - useModalResizePersist(modalRef, isOpen, "fusion:workflow-node-editor-size"); + const isEmbedded = presentation === "embedded"; + // FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: + // Size persistence + native resize are modal-only; an embedded view fills its + // host panel (width/height:100%) so persisting a saved pixel size is wrong. + useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:workflow-node-editor-size"); if (!isOpen) return null; return ( @@ -4626,6 +4669,7 @@ export function WorkflowNodeEditor({ initialAction={initialAction} initialWorkflowId={initialWorkflowId} modalRef={modalRef} + isEmbedded={isEmbedded} /> ); diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index a07b2107a5..b389ceec30 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -1,10 +1,8 @@ import { Suspense, type ComponentType, type ReactNode } from "react"; import { Activity, - Clock, Folder, GitBranch, - GitPullRequestArrow, History, type LucideProps, } from "lucide-react"; @@ -25,10 +23,8 @@ import { GitManagerModal } from "./GitManagerModal"; export type OverflowViewKey = | "usage" | "activity-log" - | "github-import" | "git-manager" | "files" - | "automation" | `plugin:${string}:${string}`; export interface OverflowViewFeatureState { @@ -149,13 +145,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ />, ), }, - { - key: "github-import", - label: "Import from GitHub", - icon: GitPullRequestArrow, - testId: "right-dock-tab-github-import", - onActivate: (props) => props.onOpenGitHubImport?.(), - }, { key: "git-manager", label: "Git Manager", @@ -179,13 +168,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ testId: "right-dock-tab-files", render: (props) => wrapOverflowView(), }, - { - key: "automation", - label: "Automation", - icon: Clock, - testId: "right-dock-tab-automation", - onActivate: (props) => props.onOpenSchedules?.(), - }, ]; function buildPluginOverflowViewEntries(pluginDashboardViews: PluginDashboardViewEntry[] = []): OverflowViewEntry[] { diff --git a/packages/dashboard/app/hooks/useViewState.ts b/packages/dashboard/app/hooks/useViewState.ts index 51d10a23e4..d38d8185a9 100644 --- a/packages/dashboard/app/hooks/useViewState.ts +++ b/packages/dashboard/app/hooks/useViewState.ts @@ -5,7 +5,11 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry"; export type ViewMode = "overview" | "project"; -export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests"; +/* +FNXC:ViewState 2026-06-22-00:00: +Workflows, Import Tasks, and Automations are promoted to top-level main-content task views (left-sidebar destinations) instead of modal-only overlays, so they render in the main panel like Command Center. +*/ +export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations"; export type PluginTaskView = `plugin:${string}:${string}`; export type TaskView = BuiltInTaskView | PluginTaskView; @@ -40,6 +44,9 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [ "devserver", "dev-server", "pull-requests", + "workflows", + "import-tasks", + "automations", ]; function isBuiltInTaskView(value: string | null): value is BuiltInTaskView { From 70442d2b429c38ad6c8a5604584560558f613a9c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:15:11 -0700 Subject: [PATCH 057/265] feat(dashboard): dock file viewer, planning workflow switcher, gm mobile layout - Files dock: clicking a file opens it inline (read-only FileEditor) with Back + pop-out-to-resizable-modal controls (DockFilesView). - Planning view shows the same board WorkflowSwitcher in the same Header workflow slot (PlanningWorkflowSwitcherSlot, portaled). - Embedded Git Manager uses the mobile single-column layout in the narrow dock (section tabs strip + full-width content). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/App.tsx | 6 + .../app/components/DockFilesView.css | 63 +++++++ .../app/components/DockFilesView.tsx | 125 ++++++++++++++ .../PlanningWorkflowSwitcherSlot.tsx | 160 ++++++++++++++++++ .../app/components/ProjectSelector.css | 5 + .../dashboard/app/components/RightDock.css | 11 +- .../dashboard/app/components/ScriptsModal.css | 47 +++++ .../app/components/overflowViewRegistry.tsx | 25 +-- 8 files changed, 417 insertions(+), 25 deletions(-) create mode 100644 packages/dashboard/app/components/DockFilesView.css create mode 100644 packages/dashboard/app/components/DockFilesView.tsx create mode 100644 packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 0b8fc90292..4fd4db5830 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -99,6 +99,7 @@ import { subscribeSse } from "./sse-bus"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; import { PlanningModeModal } from "./components/PlanningModeModal"; +import { PlanningWorkflowSwitcherSlot } from "./components/PlanningWorkflowSwitcherSlot"; // ChatView's CSS is imported eagerly so the styles bundle into the main // CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS @@ -1823,6 +1824,11 @@ function AppInner() { }; return ( + {/* + FNXC:Navigation 2026-06-22-00:00: + Planning shows the same board WorkflowSwitcher in the same Header workflow slot as Board/List (portaled by PlanningWorkflowSwitcherSlot), so workflow selection is reachable from the left-sidebar Planning destination. + */} + (null); + const [content, setContent] = useState(""); + const [contentLoading, setContentLoading] = useState(false); + const [contentError, setContentError] = useState(null); + + // Load the selected file's content read-only from the project workspace. + useEffect(() => { + if (!selectedFile) { + setContent(""); + setContentError(null); + return; + } + + let cancelled = false; + setContentLoading(true); + setContentError(null); + + fetchWorkspaceFileContent("project", selectedFile, projectId) + .then((response) => { + if (cancelled) return; + setContent(response.content); + }) + .catch((err) => { + if (cancelled) return; + setContentError(getErrorMessage(err) || t("editor.failedToLoadFile", "Failed to load file")); + setContent(""); + }) + .finally(() => { + if (!cancelled) setContentLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [selectedFile, projectId, t]); + + const handleBack = useCallback(() => setSelectedFile(null), []); + const handlePopOut = useCallback(() => { + if (selectedFile) openFile?.(selectedFile, { workspace: "project" }); + }, [openFile, selectedFile]); + + if (selectedFile) { + const fileName = selectedFile.split("/").pop() || selectedFile; + return ( +
+
+ + {fileName} + +
+
+ {contentLoading ? ( +
{t("common.loading", "Loading...")}
+ ) : contentError ? ( +
{contentError}
+ ) : ( + {}} readOnly filePath={selectedFile} /> + )} +
+
+ ); + } + + return ( +
+ setSelectedFile(path)} + onNavigate={setPath} + loading={loading} + error={error} + onRetry={refresh} + workspace="project" + onRefresh={refresh} + projectId={projectId} + /> +
+ ); +} diff --git a/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx new file mode 100644 index 0000000000..6bcbc7d37a --- /dev/null +++ b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { fetchBoardWorkflows, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api"; +import { subscribeSse } from "../sse-bus"; +import { WorkflowSwitcher } from "./WorkflowSwitcher"; +import type { WorkflowStatusCounts } from "./workflowStatusCounts"; +import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; + +/* +FNXC:PlanningWorkflowSwitcher 2026-06-22-00:00: +The Planning view must surface the SAME workflow dropdown as the Board, in the SAME location (the Header `#header-workflow-slot`). Board owns its own switcher only while the board is active, so Planning needs a self-contained mirror that fetches/caches board-workflows, tracks local selection, and portals the identical `board-workflow-toolbar > board-workflow-selector > WorkflowSwitcher` markup into the header slot. We intentionally do NOT import Board (the board switcher is tied to board lifecycle/state). + +Self-contained replication of Board's board-workflows fetch/cache/SSE-refresh path (Board.tsx ~370-470, ~607-637): refresh on mount, visibility/focus, and `workflow:created|updated|deleted` SSE, guarded by a monotonic sequence ref and persisted via the shared session cache. Gate render exactly like Board: only show when there is something to switch (workflow mode on AND >= 2 workflow options). +*/ + +interface PlanningWorkflowSwitcherSlotProps { + projectId?: string; + onOpenWorkflowEditor?: () => void; + onCreateWorkflow?: () => void; +} + +// Counts require live task/column data that Planning does not thread here. +// WorkflowSwitcher renders zero counts for an empty map, so pass a stable empty Map +// rather than threading tasks into the Planning view. +const EMPTY_COUNTS: Map = new Map(); + +export function PlanningWorkflowSwitcherSlot({ projectId, onOpenWorkflowEditor, onCreateWorkflow }: PlanningWorkflowSwitcherSlotProps) { + const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => { + const cached = readBoardWorkflowsCache(projectId); + return cached ? { projectId, payload: cached } : null; + }); + const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null; + const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); + + // Header may mount its workflow slot after this component, so resolve it on mount + // and re-resolve via a short polling effect until it attaches. Render only via portal. + const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { + if (typeof document === "undefined") return null; + return document.getElementById("header-workflow-slot"); + }); + + // Stale-response guard: drop out-of-order board-workflows responses. + const boardWorkflowsFetchSeqRef = useRef(0); + + useEffect(() => { + const cached = readBoardWorkflowsCache(projectId); + setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); + }, [projectId]); + + /* + FNXC:PlanningWorkflowSwitcher 2026-06-22-00:00: + Opening the switcher must refresh the payload because task workflow assignment changes do not emit workflow-definition SSE events. Shared by mount, visibility/focus, and workflow-definition SSE refetches so the stale guard and cache writes stay identical to Board. + */ + const refreshBoardWorkflows = useCallback(() => { + const seq = ++boardWorkflowsFetchSeqRef.current; + fetchBoardWorkflows(projectId) + .then((payload) => { + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload }); + writeBoardWorkflowsCache(projectId, payload); + } + }) + .catch(() => { + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); + } + }); + }, [projectId]); + + useEffect(() => { + refreshBoardWorkflows(); + const onVisible = () => { + if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows(); + }; + if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible); + if (typeof window !== "undefined") window.addEventListener("focus", onVisible); + const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + const unsubscribe = subscribeSse(`/api/events${query}`, { + events: { + "workflow:created": refreshBoardWorkflows, + "workflow:updated": refreshBoardWorkflows, + "workflow:deleted": refreshBoardWorkflows, + }, + }); + return () => { + boardWorkflowsFetchSeqRef.current++; + if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible); + if (typeof window !== "undefined") window.removeEventListener("focus", onVisible); + unsubscribe(); + }; + }, [projectId, refreshBoardWorkflows]); + + // Attach to the header slot once the Header mounts it. Poll briefly until present. + useEffect(() => { + if (typeof document === "undefined") return; + const resolve = () => { + const slot = document.getElementById("header-workflow-slot"); + setHeaderWorkflowSlot((prev) => (prev === slot ? prev : slot)); + return slot; + }; + if (resolve()) return; + const interval = window.setInterval(() => { + if (resolve()) window.clearInterval(interval); + }, 250); + return () => window.clearInterval(interval); + }, []); + + const flagOn = boardWorkflows?.flagEnabled === true; + const workflowMode = flagOn && Boolean(boardWorkflows?.workflows.length); + + const workflowOptions = useMemo(() => { + if (!workflowMode || !boardWorkflows) return []; + return [...boardWorkflows.workflows].sort((a, b) => { + if (a.id === boardWorkflows.defaultWorkflowId) return -1; + if (b.id === boardWorkflows.defaultWorkflowId) return 1; + return a.name.localeCompare(b.name); + }); + }, [boardWorkflows, workflowMode]); + + const selectedWorkflow = useMemo(() => { + if (!workflowMode) return null; + return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId) + ?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId) + ?? workflowOptions[0] + ?? null; + }, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]); + + useEffect(() => { + if (!workflowMode) { + setSelectedWorkflowId(null); + return; + } + if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) { + setSelectedWorkflowId(selectedWorkflow.id); + } + }, [selectedWorkflow, selectedWorkflowId, workflowMode]); + + // Gate: only render when there is something to switch (>= 2 options), matching Board's "show only when switchable" intent. + if (!workflowMode || !selectedWorkflow || workflowOptions.length < 2 || !headerWorkflowSlot) { + return null; + } + + const workflowToolbar = ( +
+
+ +
+
+ ); + + return createPortal(workflowToolbar, headerWorkflowSlot); +} diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index 27b99e6703..6e2c33f957 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -577,6 +577,11 @@ min-height: 0; min-width: 0; width: 100%; + /* + FNXC:Navigation 2026-06-22-00:10: + Anchor for the right dock, which is absolutely positioned so it overlays the page content instead of shrinking it. + */ + position: relative; } .dashboard-project-shell--with-sidebar { diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css index 6cb2b59fe9..7cb793fb64 100644 --- a/packages/dashboard/app/components/RightDock.css +++ b/packages/dashboard/app/components/RightDock.css @@ -2,10 +2,17 @@ FNXC:Navigation 2026-06-21-00:00: The right dock CSS uses a mobile media query as a belt-and-suspenders guard only. The authoritative mobile gate is the JS `rightDockActive` value from `useViewportMode`, which also covers phone classes that a width-only query cannot classify reliably. */ +/* +FNXC:Navigation 2026-06-22-00:10: +The right dock OVERLAYS the page content (floats over the right edge) instead of being a flex sibling that shrinks the main content. It is absolutely positioned against the project shell (which is position:relative) so opening/closing or resizing it never reflows the page beneath. z-index sits above content but below the docked terminal/modals. +*/ .right-dock { - position: relative; + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 20; display: flex; - flex: 0 0 auto; flex-direction: column; min-width: min(100%, var(--right-dock-min-width, calc(var(--space-2xl) * 8))); max-width: min(100%, var(--right-dock-max-width, calc(var(--space-2xl) * 22))); diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index f715cc0b4d..75cb7b60f1 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -1966,6 +1966,53 @@ The embedded host fills its right-dock container; the inner shell drops overlay- position: static; } +/* +FNXC:GitManager 2026-06-22-00:10: +The embedded Git Manager renders inside the narrow right dock, so it must use the mobile single-column layout (section tabs as a horizontal strip above a full-width content pane) regardless of viewport width, mirroring the max-width:768px rules. +*/ +.gm-modal--embedded .gm-layout { + flex-direction: column; +} + +.gm-modal--embedded .gm-sidebar { + flex: 0 0 auto; + flex-direction: row; + width: 100%; + min-width: 0; + min-height: calc(var(--space-2xl) + var(--space-md)); + border-right: none; + border-bottom: 1px solid var(--border); + overflow-x: auto; + overflow-y: hidden; + padding: var(--space-xs) var(--space-sm); + gap: var(--space-xs); +} + +.gm-modal--embedded .gm-nav-item { + flex: 0 0 auto; + flex-direction: column; + gap: calc(var(--space-xs) / 2); + padding: var(--space-xs) var(--space-sm); + border-left: none; + border-bottom: 2px solid transparent; + min-width: calc(var(--space-2xl) + var(--space-xl)); + text-align: center; + justify-content: center; +} + +.gm-modal--embedded .gm-nav-item.active { + border-left-color: transparent; + border-bottom-color: var(--todo); +} + +.gm-modal--embedded .gm-status-grid { + grid-template-columns: 1fr; +} + +.gm-modal--embedded .gm-create-form { + flex-wrap: wrap; +} + /* Main layout: sidebar + content */ .gm-layout { display: flex; diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index b389ceec30..e6244e0e80 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -9,11 +9,10 @@ import { import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; import type { PluginDashboardViewEntry } from "../api"; import type { ToastType } from "../hooks/useToast"; -import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry"; import { PluginDashboardViewHost } from "../plugins/PluginDashboardViewHost"; import type { DetailTaskTab, PluginDashboardViewContext } from "../plugins/types"; -import { FileBrowser } from "./FileBrowser"; +import { DockFilesView } from "./DockFilesView"; import { PageErrorBoundary } from "./ErrorBoundary"; import { getPluginNavIcon } from "./pluginNavIcon"; import { UsageIndicator } from "./UsageIndicator"; @@ -88,26 +87,6 @@ function wrapOverflowView(node: ReactNode): ReactNode { ); } -function InlineFilesView({ projectId, openFile }: Pick) { - const { entries, currentPath, setPath, loading, error, refresh } = useWorkspaceFileBrowser("project", true, projectId); - return ( -
- openFile?.(path, { workspace: "project" })} - onNavigate={setPath} - loading={loading} - error={error} - onRetry={refresh} - workspace="project" - onRefresh={refresh} - projectId={projectId} - /> -
- ); -} - /* FNXC:Navigation 2026-06-21-00:00: The right dock and its expand modal must resolve every hosted overflow destination through this registry so toolbar gating, component choice, and props cannot drift between the compact panel and full-size modal surfaces. @@ -166,7 +145,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ label: "Files", icon: Folder, testId: "right-dock-tab-files", - render: (props) => wrapOverflowView(), + render: (props) => wrapOverflowView(), }, ]; From dc0064bd15c28ddd3c8f59c785effe3ef0d6ca5f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:27:21 -0700 Subject: [PATCH 058/265] feat(dashboard): right-dock inline tools, left-sidebar views, embedded-view polish - Right dock: Files first/default; usage/activity-log/git-manager render inline; embedded Git Manager uses a container query (compact horizontal tab strip in the dock, full two-pane in the wide pop-out); Files inline viewer + pop-out. - Import Tasks layout fits its container (stacked when narrow, two-pane when wide); Import Tasks uses the GitHub mark. - Planning embeds full-area and works on mobile; planning shows the board WorkflowSwitcher. - Automations screen uses theme color tokens. - Workflow selector matches the project selector height/font. - Left sidebar: uniform spacing across the primary/secondary boundary. Adds a changeset for @runfusion/fusion (minor). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/sidebar-panel-redesign.md | 10 ++ .../app/components/GitHubImportModal.css | 55 +++++++++++ .../app/components/LeftSidebarNav.css | 6 +- .../app/components/LeftSidebarNav.tsx | 15 ++- .../app/components/PlanningModeModal.css | 43 +++++++-- .../dashboard/app/components/RoutineCard.tsx | 17 +++- .../dashboard/app/components/ScriptsModal.css | 91 +++++++++++-------- .../app/components/WorkflowSwitcher.css | 6 ++ .../app/components/overflowViewRegistry.tsx | 15 +-- 9 files changed, 197 insertions(+), 61 deletions(-) create mode 100644 .changeset/sidebar-panel-redesign.md diff --git a/.changeset/sidebar-panel-redesign.md b/.changeset/sidebar-panel-redesign.md new file mode 100644 index 0000000000..e66c4f4545 --- /dev/null +++ b/.changeset/sidebar-panel-redesign.md @@ -0,0 +1,10 @@ +--- +"@runfusion/fusion": minor +--- + +Dashboard navigation and panel redesign (desktop/tablet; mobile unchanged): + +- **Right sidebar**: a single show/hide toggle now lives in the top header (replacing the tablet overflow menu); the dock is hidden when closed and no longer keeps a persistent icon rail or in-dock collapse button. Its tools (Files — now the default/first tab, Activity, Activity Log, Git Manager) render inline inside the dock instead of opening popup modals. Files opens inline with a pop-out to the resizable file modal. The embedded Git Manager adapts to its width (compact horizontal tab strip in the dock, full two-pane in the wide pop-out). The dependency graph no longer appears in the dock. +- **Left sidebar**: New Task button matches the item-highlight box; footer spacing between Collapse and Settings; divider before the secondary section removed with uniform row spacing. New main-content destinations — Workflows, Import Tasks (GitHub import, with the GitHub mark), and Automations (two-pane, Command Center styling) — render in the main panel instead of as modals. +- **Embedded views**: Planning Mode embeds without modal chrome (no header/close/shadow), fills the full content area, and renders correctly on mobile; the board WorkflowSwitcher is available in Planning. Dev Server header matches Command Center. Insights header wraps so actions don't overlap. List view's left pane can be dragged much narrower with two-line title wrapping. +- **Other**: the docked terminal no longer blurs or blocks the page behind it; the footer Terminal button renders as plain text like the running-state trigger; the workflow selector matches the project selector's styling, height, and font size; the Automations screen uses theme color tokens. diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index c2661d53c4..6f7ae71bb6 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -962,4 +962,59 @@ The embedded root is a plain flow box that fills the host; the inner shell sheds resize: none; } +/* +FNXC:RightDockEmbedding 2026-06-22-12:30: +Embedded "Import Tasks" main-content view must fit on screen and react to its OWN width, not the viewport. +The shared two-pane code sets the list pane width via an inline flex-basis (default 360px) whenever the VIEWPORT is wide (canResizePanes => innerWidth > 860). In the embedded main area the host can be far narrower than the viewport, so that inline 360px list pane plus the preview overflowed horizontally. +Fix (embedded variant only — modal path untouched): turn the embedded root into a query container (container-type: inline-size) and drive the layout off @container width. +- Narrow container (default): stack list ABOVE preview in a single column; cap the list height and override the inline desktop flex-basis so nothing forces horizontal overflow. Long titles/repo names already truncate via .issue-title ellipsis / .issue-main min-width:0, and labels/branch info wrap. +- Wide container (>= 720px): restore the two-pane row, but bound the list pane to a sane share of the container (clamp) instead of trusting the viewport-derived inline width. +*/ +.github-import-embedded.right-dock-embedded-view { + container-type: inline-size; + container-name: github-import-embedded; +} + +/* Default (narrow container): single stacked column. */ +.github-import-modal--embedded .github-import-workspace { + flex-direction: column; +} + +/* Override the inline viewport-derived flex-basis so the list never forces overflow when stacked. */ +.github-import-modal--embedded .github-import-list-pane { + flex: 0 0 auto !important; + width: 100%; + max-height: 40cqh; + padding-right: 0; +} + +.github-import-modal--embedded .github-import-preview-pane { + flex: 1 1 auto; + width: 100%; + min-width: 0; +} + +/* Hide the col-resize handle when stacked; it only makes sense in the side-by-side layout. */ +.github-import-modal--embedded .github-import-workspace__resize-handle { + display: none; +} + +@container github-import-embedded (min-width: 720px) { + .github-import-modal--embedded .github-import-workspace { + flex-direction: row; + } + + /* Side-by-side again: bound the list pane to a share of the CONTAINER width, overriding the inline viewport width. */ + .github-import-modal--embedded .github-import-list-pane { + flex: 0 1 clamp(240px, 38cqi, 420px) !important; + width: auto; + max-height: none; + padding-right: var(--space-md); + } + + .github-import-modal--embedded .github-import-workspace__resize-handle { + display: block; + } +} + diff --git a/packages/dashboard/app/components/LeftSidebarNav.css b/packages/dashboard/app/components/LeftSidebarNav.css index 42e9b2751f..025c2c76d4 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.css +++ b/packages/dashboard/app/components/LeftSidebarNav.css @@ -85,11 +85,11 @@ With the secondary divider removed the nav reads as one continuous list, so the } /* -FNXC:Navigation 2026-06-22-00:00: -The secondary section keeps its top spacing but drops the divider line before the first secondary entry (Goals/Evals); the rule reads as one continuous nav list instead of two bordered groups. +FNXC:Navigation 2026-06-22-00:30: +The secondary section has no divider and no extra top padding, so the gap across the primary/secondary boundary (e.g. Compound -> Workflows) equals the --space-xs list/row rhythm and the nav reads as one continuous list. */ .left-sidebar-nav__section--secondary { - padding-top: var(--space-sm); + padding-top: 0; } .left-sidebar-nav__item { diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index df2b92b476..ea76614936 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -14,7 +14,6 @@ import { Clock, FileText, Gauge, - GitPullRequestArrow, Lightbulb, LayoutGrid, List, @@ -35,6 +34,18 @@ import type { TaskView } from "../hooks/useViewState"; import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry"; import { getPluginNavIcon } from "./pluginNavIcon"; +/* +FNXC:Navigation 2026-06-22-00:30: +Import Tasks uses the GitHub brand mark. lucide-react in this repo does not export a `Github` icon, so render the octocat glyph as a LucideProps-compatible component (size defaults to 16, currentColor fill) usable wherever a sidebar entry icon is expected. +*/ +function GithubIcon({ size = 16, ...props }: LucideProps) { + return ( + + ); +} + export interface LeftSidebarExperimentalFeatures { insights?: boolean; memoryView?: boolean; @@ -367,7 +378,7 @@ export function LeftSidebarNav({ label: t("nav.importTasks", "Import Tasks"), view: "import-tasks" as TaskView, isActive: view === "import-tasks", - icon: GitPullRequestArrow, + icon: GithubIcon, testId: "sidebar-nav-import-tasks", onSelect: () => onChangeView("import-tasks"), }, diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index c83f62ef72..a8d3b395e6 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -56,12 +56,19 @@ FN-6886 promotes Planning Mode into the main app content area. The embedded shel .planning-view { height: 100%; min-height: 0; + width: 100%; + flex: 1; display: flex; padding: var(--space-lg); overflow: hidden; } +/* +FNXC:PlanningMode 2026-06-22-15:30: +FN-6886 full-view fix: embedded planning must fill the entire main-content pane with no awkward gaps. The flex/height chain is planning-view (flex:1, height:100%) -> planning-modal--embedded (flex:1, height:100%) -> planning-modal-body (flex:1, min-height:0). flex:1 + min-width:0 + min-height:0 on the embedded panel lets it consume all remaining width/height inside the flex .planning-view wrapper instead of collapsing to the base .modal 480px width. +*/ .planning-modal--embedded { + flex: 1; width: 100%; max-width: none; min-width: 0; @@ -383,17 +390,26 @@ The embedded planning title must read like other embedded-view titles (Command C box-shadow: var(--focus-ring-strong); } -/* Mobile: stack — only one pane visible at a time */ -@media (max-width: 768px) { +/* Mobile: stack — only one pane visible at a time. + FNXC:PlanningMode 2026-06-22-15:30 covers both the landscape phone case + (max-height: 480px exceeds 768px wide) and portrait. */ +@media (max-width: 768px), (max-height: 480px) { /* Full-screen sheet — drop overlay padding so the modal fills the viewport instead of being pushed below it, and disable resize since - touchscreen users can't drag the corner grip anyway. */ + touchscreen users can't drag the corner grip anyway. + FNXC:PlanningMode 2026-06-22-15:30: scope the viewport-takeover rules to + the NON-embedded (dialog) presentation only. The embedded panel + (.planning-modal--embedded) must NOT grab 100vw/100dvh — it lives inside + the main-content pane, so forcing full-viewport sizing made it overflow + the content area on mobile. The :not(.planning-modal--embedded) guard + keeps the modal full-screen sheet intact while letting the embedded view + fill only its own pane (handled by the .planning-view rules below). */ .modal-overlay:has(.planning-modal) { padding-top: 0; align-items: stretch; justify-content: stretch; } - .modal.planning-modal { + .modal.planning-modal:not(.planning-modal--embedded) { width: 100vw; min-width: 0; max-width: 100vw; @@ -405,12 +421,23 @@ The embedded planning title must read like other embedded-view titles (Command C border-radius: 0; resize: none; } - .modal.planning-modal[style*="--keyboard-overlap"] { + .modal.planning-modal:not(.planning-modal--embedded)[style*="--keyboard-overlap"] { height: var(--vv-height, 100dvh); max-height: var(--vv-height, 100dvh); transform: translateY(var(--vv-offset-top, 0px)); will-change: transform; } + /* FNXC:PlanningMode 2026-06-22-15:30: on mobile the embedded view should use + the full width of the (already-narrow) content pane — drop the outer + padding so the session list and detail panes are edge-to-edge, and keep + the height/flex chain filling the pane. */ + .planning-view { + padding: var(--space-sm); + } + .planning-view .planning-modal--embedded { + width: 100%; + height: 100%; + } .planning-modal-body--split { flex-direction: column; } @@ -1374,7 +1401,11 @@ The embedded planning title must read like other embedded-view titles (Command C /* Responsive */ @media (max-width: 768px) { - .planning-modal { + /* FNXC:PlanningMode 2026-06-22-15:30: this legacy full-viewport sheet sizing + is for the dialog presentation only. The :not(.planning-modal--embedded) + guard prevents the embedded view from being yanked to 100vw/100dvh, which + would overflow the main-content pane it lives in on mobile. */ + .planning-modal:not(.planning-modal--embedded) { width: 100vw; height: 100vh; height: 100dvh; diff --git a/packages/dashboard/app/components/RoutineCard.tsx b/packages/dashboard/app/components/RoutineCard.tsx index a95f552363..ba3c0c0060 100644 --- a/packages/dashboard/app/components/RoutineCard.tsx +++ b/packages/dashboard/app/components/RoutineCard.tsx @@ -46,11 +46,20 @@ function relativeTime(iso: string): string { return `${Math.floor(diffMs / 86_400_000)}d ago`; } +/* +FNXC:Automations 2026-06-22-12:00: +Trigger-type badge colors must use the design system's THEME COLOR TOKENS so the Automations screen follows the +active theme (including light theme) instead of fixed hex literals. The previous values referenced undefined tokens +(--color-blue/-purple/-green/-gray) with hardcoded hex fallbacks that never resolved to a real token and never +adapted to the theme. Mapped to the closest defined semantic tokens from styles.css: cron→--todo (blue status), +webhook→--accent (brand purple), api→--color-success (green), manual→--text-muted (neutral). The badge applies this +to both border and text via inline style on .routine-trigger-badge. +*/ const TRIGGER_TYPE_COLORS: Record = { - cron: "var(--color-blue, #3b82f6)", - webhook: "var(--color-purple, #a855f7)", - api: "var(--color-green, #22c55e)", - manual: "var(--color-gray, #6b7280)", + cron: "var(--todo)", + webhook: "var(--accent)", + api: "var(--color-success)", + manual: "var(--text-muted)", }; const TRIGGER_TYPE_LABELS: Record = { diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 75cb7b60f1..54f587b0c6 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -1289,9 +1289,15 @@ when supported, falling back to a min-width media breakpoint, that collapses to border-color: var(--accent); } +/* +FNXC:Automations 2026-06-22-12:00: +Selected automation row uses the theme accent token. --accent-subtle is not a defined token; fall back to a +theme-derived subtle accent tint (color-mix house style) so the active state follows the active theme (incl. light) +instead of resolving to a flat --card with no accent emphasis. +*/ .automation-list-row.active { border-color: var(--accent); - background: var(--accent-subtle, var(--card)); + background: var(--accent-subtle, color-mix(in srgb, var(--accent) 10%, transparent)); } .automation-list-row-name { @@ -1964,53 +1970,60 @@ The embedded host fills its right-dock container; the inner shell drops overlay- border-radius: 0; resize: none; position: static; + /* Drive the dock-vs-expand layout off the embedded host's own width, not the viewport. */ + container-type: inline-size; + container-name: gm-embedded; } /* -FNXC:GitManager 2026-06-22-00:10: -The embedded Git Manager renders inside the narrow right dock, so it must use the mobile single-column layout (section tabs as a horizontal strip above a full-width content pane) regardless of viewport width, mirroring the max-width:768px rules. +FNXC:GitManager 2026-06-22-00:25: +The embedded Git Manager adapts to its container width, not the viewport, so the SAME embedded render works in both the narrow right dock and the wide pop-out (expand) modal: +- Wide container (expand modal): inherits the default desktop layout — vertical section sidebar + content (two-pane, like before). +- Narrow container (dock, < 560px): section tabs become a horizontal strip above a full-width content pane, and each tab is compact (min-width:0, tight padding, inline icon+label) so MORE tabs fit in the strip at once. The content collapses to a single column. */ -.gm-modal--embedded .gm-layout { - flex-direction: column; -} +@container gm-embedded (max-width: 560px) { + .gm-modal--embedded .gm-layout { + flex-direction: column; + } -.gm-modal--embedded .gm-sidebar { - flex: 0 0 auto; - flex-direction: row; - width: 100%; - min-width: 0; - min-height: calc(var(--space-2xl) + var(--space-md)); - border-right: none; - border-bottom: 1px solid var(--border); - overflow-x: auto; - overflow-y: hidden; - padding: var(--space-xs) var(--space-sm); - gap: var(--space-xs); -} + .gm-modal--embedded .gm-sidebar { + flex: 0 0 auto; + flex-direction: row; + width: 100%; + min-width: 0; + min-height: 0; + border-right: none; + border-bottom: 1px solid var(--border); + overflow-x: auto; + overflow-y: hidden; + padding: var(--space-xs); + gap: calc(var(--space-xs) / 2); + } -.gm-modal--embedded .gm-nav-item { - flex: 0 0 auto; - flex-direction: column; - gap: calc(var(--space-xs) / 2); - padding: var(--space-xs) var(--space-sm); - border-left: none; - border-bottom: 2px solid transparent; - min-width: calc(var(--space-2xl) + var(--space-xl)); - text-align: center; - justify-content: center; -} + .gm-modal--embedded .gm-nav-item { + flex: 0 0 auto; + flex-direction: row; + gap: var(--space-xs); + padding: calc(var(--space-xs) / 2) var(--space-xs); + border-left: none; + border-bottom: 2px solid transparent; + min-width: 0; + font-size: var(--font-size-xs); + white-space: nowrap; + } -.gm-modal--embedded .gm-nav-item.active { - border-left-color: transparent; - border-bottom-color: var(--todo); -} + .gm-modal--embedded .gm-nav-item.active { + border-left-color: transparent; + border-bottom-color: var(--todo); + } -.gm-modal--embedded .gm-status-grid { - grid-template-columns: 1fr; -} + .gm-modal--embedded .gm-status-grid { + grid-template-columns: 1fr; + } -.gm-modal--embedded .gm-create-form { - flex-wrap: wrap; + .gm-modal--embedded .gm-create-form { + flex-wrap: wrap; + } } /* Main layout: sidebar + content */ diff --git a/packages/dashboard/app/components/WorkflowSwitcher.css b/packages/dashboard/app/components/WorkflowSwitcher.css index 653d853031..8428da4e68 100644 --- a/packages/dashboard/app/components/WorkflowSwitcher.css +++ b/packages/dashboard/app/components/WorkflowSwitcher.css @@ -28,6 +28,12 @@ The workflow selector trigger must match the project selector trigger styling: t border-radius: var(--radius-md); color: var(--text-muted); font: inherit; + /* + FNXC:WorkflowSwitcher 2026-06-22-00:10: + Match the project selector trigger height and font size: the project label uses 13px / line-height 1, so set the same here (the parent .workflow-switcher font-size is smaller). With identical padding + font-size + line-height the two triggers render the same height. + */ + font-size: 13px; + line-height: 1; text-align: left; transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); } diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index e6244e0e80..b37ea03637 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -99,6 +99,14 @@ FNXC:Navigation 2026-06-22-00:00: Right-dock tools render INLINE inside the dock container, not as popup modals: usage, activity-log, and git-manager use each modal's `presentation="embedded"` mode instead of launching an overlay. (github-import and automation remain launcher actions here only until their left-sidebar/main destinations land, then they leave the dock.) */ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ + /* FNXC:Navigation 2026-06-22-00:20: Files is the first/default right-dock tool. */ + { + key: "files", + label: "Files", + icon: Folder, + testId: "right-dock-tab-files", + render: (props) => wrapOverflowView(), + }, { key: "usage", label: "Activity", @@ -140,13 +148,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ />, ), }, - { - key: "files", - label: "Files", - icon: Folder, - testId: "right-dock-tab-files", - render: (props) => wrapOverflowView(), - }, ]; function buildPluginOverflowViewEntries(pluginDashboardViews: PluginDashboardViewEntry[] = []): OverflowViewEntry[] { From 7533fa14ebe3aa1f29f5454d1729e00e8c53272e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:35:08 -0700 Subject: [PATCH 059/265] feat(dashboard): reorder left sidebar; Dev Server + Secrets to right dock; Import Tasks CC header - Left sidebar reordered to a single explicit list (board, list, graph, command-center, agents, chat, mailbox, planning, missions, artifacts, goals, compound, automation, import, workflows, insights, research, skills, memory, evals, remaining plugins). Command Center before Agents; Artifacts after Missions. - Dev Server and Secrets moved off the left sidebar into the right dock (inline render; Dev Server gated by the devServerView flag). - Import Tasks embedded view uses a Command Center-style header (GitHub logo + shared title font + padding). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/GitHubImportModal.css | 22 ++ .../app/components/GitHubImportModal.tsx | 15 +- .../app/components/LeftSidebarNav.tsx | 192 +++++++++--------- .../app/components/overflowViewRegistry.tsx | 28 ++- 4 files changed, 157 insertions(+), 100 deletions(-) diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 6f7ae71bb6..a5bd773631 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -960,6 +960,28 @@ The embedded root is a plain flow box that fills the host; the inner shell sheds box-shadow: none; border-radius: 0; resize: none; + padding: var(--space-lg); +} + +/* +FNXC:RightDockEmbedding 2026-06-22-00:40: +Import Tasks embedded header reads like Command Center (cc-header/cc-title): a plain title row with the GitHub logo and the shared 1.125rem embedded-title font, no modal-header bar/background/border. +*/ +.github-import-modal__embedded-header { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding-bottom: var(--space-md); +} + +.github-import-modal__embedded-title { + display: flex; + align-items: center; + gap: var(--space-sm); + margin: 0; + font-size: 1.125rem; } /* diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 2163fe9c85..2e6a4ee4a2 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -496,9 +496,18 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const inner = (
{isEmbedded ? ( -
-

{t("git.importTasksHeading", "Import Tasks")}

-
+ /* + FNXC:RightDockEmbedding 2026-06-22-00:40: + Import Tasks is a main-content destination, so its header reads like Command Center (cc-header/cc-title): a plain title row with the GitHub logo and the shared 1.125rem embedded-title font, no modal-header bar or close button. Padding matches the embedded view container. + */ +
+

+ + {t("git.importTasksHeading", "Import Tasks")} +

+
) : (
diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index ea76614936..9aec1a5800 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -19,7 +19,6 @@ import { List, Mail, MessageSquare, - Monitor, Plus, Search, Settings, @@ -234,16 +233,51 @@ export function LeftSidebarNav({ const newTaskLabel = t("nav.newTask", "New Task"); - const primaryPluginViews = useMemo( - () => sortPluginViews(pluginDashboardViews.filter((entry) => entry.view.placement === "primary")), - [pluginDashboardViews], - ); - const overflowPluginViews = useMemo( - () => sortPluginViews(pluginDashboardViews.filter((entry) => entry.view.placement !== "primary")), + /* + FNXC:Navigation 2026-06-22-12:00: + All plugin dashboard views are flattened into a single sorted pool. Placement no longer splits the sidebar into primary/secondary sections; the sidebar is now ONE explicitly-ordered list (FN navigation reorder). The dependency-graph and compound-engineering plugin views are hoisted into fixed positions (graph after List, compound after Goals), so they must be excluded from the trailing "remaining plugin views" append to avoid duplication. + */ + const sortedPluginViews = useMemo( + () => sortPluginViews(pluginDashboardViews), [pluginDashboardViews], ); - const primaryEntries: SidebarNavEntry[] = [ + const mapPluginEntry = useCallback( + (entry: PluginDashboardViewEntry): SidebarNavEntry => { + const PluginIcon = getPluginNavIcon(entry.view.icon); + const targetView = getPluginEntryView(entry); + return { + id: `plugin-${entry.pluginId}-${entry.view.viewId}`, + label: getSidebarPluginLabel(entry), + view: targetView, + isActive: isPluginEntryActive(view, entry), + icon: PluginIcon, + testId: `sidebar-nav-plugin-${entry.pluginId}-${entry.view.viewId}`, + onSelect: () => onChangeView(targetView), + }; + }, + [view, onChangeView], + ); + + const graphPluginEntry = sortedPluginViews.find( + (entry) => entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph", + ); + const compoundPluginEntry = sortedPluginViews.find( + (entry) => entry.pluginId === "fusion-plugin-compound-engineering", + ); + const remainingPluginViews = sortedPluginViews.filter( + (entry) => entry !== graphPluginEntry && entry !== compoundPluginEntry, + ); + + /* + FNXC:Navigation 2026-06-22-12:00: + Single explicit sidebar order (top to bottom): board, list, graph, agents, chat, mailbox, planning, missions, goals, compound, automation, import, workflows, insight, research, command-center, documents (Artifacts), skills, memory, evals, then any remaining plugin views in their sorted order. + + Dev Server is intentionally absent: it moved to the right dock. Secrets and Todos remain omitted (they live in the right dock / mobile More-sheet / Header overflow). + + Flag gates preserved verbatim from the prior layout: agents (showAgentsTab), goals (goalsView), insight (insights), research (researchView), skills (showSkillsTab), memory (memoryView), evals (evalsView). graph and compound are skipped when their plugin view is absent. + */ + const navEntries: SidebarNavEntry[] = [ { id: "board", label: t("nav.board", "Board"), @@ -262,6 +296,16 @@ export function LeftSidebarNav({ testId: "sidebar-nav-list", onSelect: () => onChangeView("list"), }, + ...(graphPluginEntry ? [mapPluginEntry(graphPluginEntry)] : []), + { + id: "command-center", + label: t("nav.commandCenter", "Command Center"), + view: "command-center", + isActive: view === "command-center", + icon: Gauge, + testId: "sidebar-nav-command-center", + onSelect: () => onChangeView("command-center"), + }, ...(showAgentsTab ? [ { @@ -276,19 +320,31 @@ export function LeftSidebarNav({ ] : []), { - id: "command-center", - label: t("nav.commandCenter", "Command Center"), - view: "command-center", - isActive: view === "command-center", - icon: Gauge, - testId: "sidebar-nav-command-center", - onSelect: () => onChangeView("command-center"), + id: "chat", + label: t("nav.chat", "Chat"), + view: "chat", + isActive: view === "chat", + icon: MessageSquare, + testId: "sidebar-nav-chat", + dot: chatHasUnreadResponse && view !== "chat" ? "pending" : undefined, + onSelect: () => onChangeView("chat"), + }, + { + id: "mailbox", + label: t("nav.mailbox", "Mailbox"), + view: "mailbox", + isActive: view === "mailbox", + icon: Mail, + testId: "sidebar-nav-mailbox", + badge: mailboxUnreadCount > 0 ? mailboxUnreadCount : undefined, + dot: view !== "mailbox" && mailboxPendingApprovalCount > 0 ? "pending" : view !== "mailbox" && mailboxUnreadCount > 0 ? "online" : undefined, + onSelect: () => onChangeView("mailbox"), }, { id: "planning", /* FNXC:Navigation 2026-06-21-00:00: - FN-6886 makes Planning Mode a first-class sidebar destination immediately after Command Center so the experimental sidebar owns the desktop planning affordance. + FN-6886 makes Planning Mode a first-class sidebar destination. */ label: t("nav.planning", "Planning"), view: "planning", @@ -306,16 +362,6 @@ export function LeftSidebarNav({ testId: "sidebar-nav-missions", onSelect: () => onChangeView("missions"), }, - { - id: "chat", - label: t("nav.chat", "Chat"), - view: "chat", - isActive: view === "chat", - icon: MessageSquare, - testId: "sidebar-nav-chat", - dot: chatHasUnreadResponse && view !== "chat" ? "pending" : undefined, - onSelect: () => onChangeView("chat"), - }, { id: "documents", /* @@ -329,49 +375,22 @@ export function LeftSidebarNav({ testId: "sidebar-nav-documents", onSelect: () => onChangeView("documents"), }, - { - id: "mailbox", - label: t("nav.mailbox", "Mailbox"), - view: "mailbox", - isActive: view === "mailbox", - icon: Mail, - testId: "sidebar-nav-mailbox", - badge: mailboxUnreadCount > 0 ? mailboxUnreadCount : undefined, - dot: view !== "mailbox" && mailboxPendingApprovalCount > 0 ? "pending" : view !== "mailbox" && mailboxUnreadCount > 0 ? "online" : undefined, - onSelect: () => onChangeView("mailbox"), - }, - ...primaryPluginViews.map((entry): SidebarNavEntry => { - const PluginIcon = getPluginNavIcon(entry.view.icon); - const targetView = getPluginEntryView(entry); - return { - id: `plugin-${entry.pluginId}-${entry.view.viewId}`, - label: getSidebarPluginLabel(entry), - view: targetView, - isActive: isPluginEntryActive(view, entry), - icon: PluginIcon, - testId: `sidebar-nav-plugin-${entry.pluginId}-${entry.view.viewId}`, - onSelect: () => onChangeView(targetView), - }; - }), - ]; - - /* - FNXC:Navigation 2026-06-21-00:00: - Secrets and Todos are intentionally omitted from the left sidebar. They live in the right dock through RightDock/overflowViewRegistry, while mobile keeps its More-sheet entries and the Header opt-out layout keeps its overflow entries. - */ - const secondaryEntries: SidebarNavEntry[] = [ + ...(experimentalFeatures?.goalsView + ? [{ id: "goals", label: t("header.goalsView", "Goals"), view: "goalsView" as TaskView, isActive: view === "goalsView", icon: Target, testId: "sidebar-nav-goals", onSelect: () => onChangeView("goalsView") }] + : []), + ...(compoundPluginEntry ? [mapPluginEntry(compoundPluginEntry)] : []), /* FNXC:Navigation 2026-06-22-00:00: Workflows, Import Tasks, and Automations are left-sidebar destinations that load in the main content area (not modals). Import Tasks is the GitHub import view (labeled "Import Tasks", not "Import from GitHub"). */ { - id: "workflows", - label: t("nav.workflows", "Workflows"), - view: "workflows" as TaskView, - isActive: view === "workflows", - icon: Workflow, - testId: "sidebar-nav-workflows", - onSelect: () => onChangeView("workflows"), + id: "automations", + label: t("nav.automations", "Automations"), + view: "automations" as TaskView, + isActive: view === "automations", + icon: Clock, + testId: "sidebar-nav-automations", + onSelect: () => onChangeView("automations"), }, { id: "import-tasks", @@ -383,48 +402,30 @@ export function LeftSidebarNav({ onSelect: () => onChangeView("import-tasks"), }, { - id: "automations", - label: t("nav.automations", "Automations"), - view: "automations" as TaskView, - isActive: view === "automations", - icon: Clock, - testId: "sidebar-nav-automations", - onSelect: () => onChangeView("automations"), + id: "workflows", + label: t("nav.workflows", "Workflows"), + view: "workflows" as TaskView, + isActive: view === "workflows", + icon: Workflow, + testId: "sidebar-nav-workflows", + onSelect: () => onChangeView("workflows"), }, - ...(experimentalFeatures?.evalsView - ? [{ id: "evals", label: t("header.evalsView", "Evals"), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }] - : []), - ...(experimentalFeatures?.goalsView - ? [{ id: "goals", label: t("header.goalsView", "Goals"), view: "goalsView" as TaskView, isActive: view === "goalsView", icon: Target, testId: "sidebar-nav-goals", onSelect: () => onChangeView("goalsView") }] + ...(experimentalFeatures?.insights + ? [{ id: "insights", label: t("header.insightsView", "Insights"), view: "insights" as TaskView, isActive: view === "insights", icon: Sparkles, testId: "sidebar-nav-insights", onSelect: () => onChangeView("insights") }] : []), ...(experimentalFeatures?.researchView ? [{ id: "research", label: t("header.researchView", "Research"), view: "research" as TaskView, isActive: view === "research", icon: Search, testId: "sidebar-nav-research", onSelect: () => onChangeView("research") }] : []), - ...(experimentalFeatures?.insights - ? [{ id: "insights", label: t("header.insightsView", "Insights"), view: "insights" as TaskView, isActive: view === "insights", icon: Sparkles, testId: "sidebar-nav-insights", onSelect: () => onChangeView("insights") }] - : []), ...(showSkillsTab ? [{ id: "skills", label: t("header.skillsView", "Skills"), view: "skills" as TaskView, isActive: view === "skills", icon: Zap, testId: "sidebar-nav-skills", onSelect: () => onChangeView("skills") }] : []), ...(experimentalFeatures?.memoryView ? [{ id: "memory", label: t("header.memoryView", "Memory"), view: "memory" as TaskView, isActive: view === "memory", icon: Brain, testId: "sidebar-nav-memory", onSelect: () => onChangeView("memory") }] : []), - ...(experimentalFeatures?.devServerView - ? [{ id: "devserver", label: t("header.devServerView", "Dev Server"), view: "devserver" as TaskView, isActive: view === "dev-server" || view === "devserver", icon: Monitor, testId: "sidebar-nav-devserver", onSelect: () => onChangeView("devserver") }] + ...(experimentalFeatures?.evalsView + ? [{ id: "evals", label: t("header.evalsView", "Evals"), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }] : []), - ...overflowPluginViews.map((entry): SidebarNavEntry => { - const PluginIcon = getPluginNavIcon(entry.view.icon); - const targetView = getPluginEntryView(entry); - return { - id: `plugin-${entry.pluginId}-${entry.view.viewId}`, - label: getSidebarPluginLabel(entry), - view: targetView, - isActive: isPluginEntryActive(view, entry), - icon: PluginIcon, - testId: `sidebar-nav-plugin-${entry.pluginId}-${entry.view.viewId}`, - onSelect: () => onChangeView(targetView), - }; - }), + ...remainingPluginViews.map(mapPluginEntry), ]; const renderEntry = (entry: SidebarNavEntry) => { @@ -471,8 +472,7 @@ export function LeftSidebarNav({ ) : null}
diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index b37ea03637..83a41bce75 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -1,9 +1,11 @@ -import { Suspense, type ComponentType, type ReactNode } from "react"; +import { Suspense, lazy, type ComponentType, type ReactNode } from "react"; import { Activity, Folder, GitBranch, History, + Lock, + Monitor, type LucideProps, } from "lucide-react"; import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; @@ -19,11 +21,20 @@ import { UsageIndicator } from "./UsageIndicator"; import { ActivityLogModal } from "./ActivityLogModal"; import { GitManagerModal } from "./GitManagerModal"; +/* +FNXC:Navigation 2026-06-22-00:40: +Dev Server and Secrets are right-dock tools (moved off the left sidebar). They render inline in the dock; Dev Server is gated by the devServerView experimental flag. Lazy-loaded to keep them out of the main bundle. +*/ +const DevServerView = lazy(() => import("./DevServerView").then((m) => ({ default: m.DevServerView }))); +const SecretsView = lazy(() => import("./SecretsView").then((m) => ({ default: m.SecretsView }))); + export type OverflowViewKey = | "usage" | "activity-log" | "git-manager" | "files" + | "devserver" + | "secrets" | `plugin:${string}:${string}`; export interface OverflowViewFeatureState { @@ -148,6 +159,21 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ />, ), }, + { + key: "devserver", + label: "Dev Server", + icon: Monitor, + testId: "right-dock-tab-devserver", + isVisible: (options) => options.experimentalFeatures?.devServerView === true, + render: (props) => wrapOverflowView(), + }, + { + key: "secrets", + label: "Secrets", + icon: Lock, + testId: "right-dock-tab-secrets", + render: (props) => wrapOverflowView(), + }, ]; function buildPluginOverflowViewEntries(pluginDashboardViews: PluginDashboardViewEntry[] = []): OverflowViewEntry[] { From ea7c14964c66cfcbfdda90105d957af1bac41054 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:36:21 -0700 Subject: [PATCH 060/265] fix(dashboard): make the list-view split sidebar easy to resize Widen the split resize column from 4px to --space-sm and add a centered grip line that brightens on hover/focus, so the task-list sidebar is easy to grab and drag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/app/components/ListView.css | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index 1fb6bd7063..b3246e54c4 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -382,9 +382,13 @@ padding: 0; } +/* +FNXC:ListView 2026-06-22-00:40: +Widen the split resize column so the task-list sidebar is easy to grab and drag (the 4px --space-xs target was hard to hit). The handle shows a centered grip line that brightens on hover/focus. +*/ .list-split-layout { display: grid; - grid-template-columns: auto var(--space-xs) minmax(0, 1fr); + grid-template-columns: auto var(--space-sm) minmax(0, 1fr); height: 100%; min-height: 0; } @@ -396,9 +400,23 @@ } .list-split-resize-handle { + position: relative; cursor: col-resize; background: color-mix(in srgb, var(--border) 70%, transparent); transition: background var(--transition-fast); + touch-action: none; +} + +/* Centered grip line so the drag affordance is visible. */ +.list-split-resize-handle::after { + content: ""; + position: absolute; + inset-block: 0; + inset-inline-start: 50%; + width: 2px; + transform: translateX(-50%); + background: var(--border); + transition: background var(--transition-fast); } .list-split-resize-handle:hover, @@ -406,6 +424,11 @@ background: color-mix(in srgb, var(--todo) 35%, transparent); } +.list-split-resize-handle:hover::after, +.list-split-resize-handle:focus-visible::after { + background: var(--todo); +} + .list-split-resize-handle:focus-visible { box-shadow: var(--focus-ring-strong); outline: none; From 18adb8f251253e2d2b83d0de397d960b5fec8eef Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:40:22 -0700 Subject: [PATCH 061/265] feat(dashboard): add Todos and Pull Requests to the right dock Both render inline in the dock; Todos is gated by the todosEnabled flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/overflowViewRegistry.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index 83a41bce75..ae60aad315 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -1,8 +1,10 @@ import { Suspense, lazy, type ComponentType, type ReactNode } from "react"; import { Activity, + CheckSquare, Folder, GitBranch, + GitPullRequest, History, Lock, Monitor, @@ -27,6 +29,8 @@ Dev Server and Secrets are right-dock tools (moved off the left sidebar). They r */ const DevServerView = lazy(() => import("./DevServerView").then((m) => ({ default: m.DevServerView }))); const SecretsView = lazy(() => import("./SecretsView").then((m) => ({ default: m.SecretsView }))); +const TodoView = lazy(() => import("./TodoView").then((m) => ({ default: m.TodoView }))); +const PullRequestView = lazy(() => import("./PullRequestView").then((m) => ({ default: m.PullRequestView }))); export type OverflowViewKey = | "usage" @@ -35,6 +39,8 @@ export type OverflowViewKey = | "files" | "devserver" | "secrets" + | "todos" + | "pull-requests" | `plugin:${string}:${string}`; export interface OverflowViewFeatureState { @@ -174,6 +180,28 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ testId: "right-dock-tab-secrets", render: (props) => wrapOverflowView(), }, + { + key: "todos", + label: "Todos", + icon: CheckSquare, + testId: "right-dock-tab-todos", + isVisible: (options) => options.todosEnabled === true, + render: (props) => wrapOverflowView( + , + ), + }, + { + key: "pull-requests", + label: "Pull Requests", + icon: GitPullRequest, + testId: "right-dock-tab-pull-requests", + render: (props) => wrapOverflowView(), + }, ]; function buildPluginOverflowViewEntries(pluginDashboardViews: PluginDashboardViewEntry[] = []): OverflowViewEntry[] { From d9b4f29f5d53815b05642e43947f27233c5a44f0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:41:24 -0700 Subject: [PATCH 062/265] fix(dashboard): docked terminal backdrop override must out-specify base modal-overlay The base .modal-overlay (dim + blur) and the docked/floating override were both single-class selectors, so stylesheet order could let the dim/blur win and fade the page. Qualify the override with .modal-overlay so the transparent, non-blurring, click-through backdrop always wins. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/components/TerminalModal.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 18ce1ced11..fe68e44db4 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -30,8 +30,12 @@ FN-6887 turns desktop/tablet terminal into a bottom-docked panel above the foote FNXC:Terminal 2026-06-22-00:00: The docked/floating terminal must not blur or dim the page behind it, and the page must stay interactive. The base .modal-overlay applies backdrop-filter: blur(4px); override it to none here. background is already transparent and pointer-events:none lets clicks pass through to the page behind (the terminal panel itself re-enables pointer-events). */ -.terminal-modal-overlay--docked, -.terminal-modal-overlay--floating { +/* +FNXC:Terminal 2026-06-22-00:45: +The override MUST out-specify the base `.modal-overlay` (which sets a dimmed background + blur). Both are single-class selectors, so if styles.css loads after this file the dim/blur wins and the page still fades. Qualify with `.modal-overlay` (two classes) so the docked/floating terminal reliably keeps a transparent, non-blurring, click-through backdrop regardless of stylesheet order. +*/ +.modal-overlay.terminal-modal-overlay--docked, +.modal-overlay.terminal-modal-overlay--floating { align-items: stretch; justify-content: flex-end; padding: 0; From 0b410d54e179c05b6870790560b7158851958e81 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:47:33 -0700 Subject: [PATCH 063/265] FN-6915: surface user comments in agent prompts User task chat and steering comments now flow into planning, review, and merge agent context. - Add a shared user-comment formatter and selector for prompt context. - Inject latest user comments into planner-triggered review, reviewer, merger, and clean-room AI merge prompts. - Cover comment selection, prompt formatting, review/merge propagation, and docs updates with targeted tests. Files changed: docs/architecture.md | 1 + docs/dashboard-guide.md | 1 + .../src/__tests__/agent-user-comments.test.ts | 81 ++++++++++++++++++++++ .../executor-review-step-indexing.test.ts | 28 +++++++- packages/engine/src/__tests__/merger-ai.test.ts | 54 +++++++++++++++ .../src/__tests__/merger-prompt-and-utils.test.ts | 29 ++++++++ packages/engine/src/__tests__/reviewer.test.ts | 37 ++++++++-- packages/engine/src/__tests__/triage.test.ts | 23 ++++++ packages/engine/src/agent-user-comments.ts | 60 ++++++++++++++++ packages/engine/src/executor.ts | 14 ++-- packages/engine/src/merger-ai.ts | 23 +++++- packages/engine/src/merger.ts | 22 +++++- packages/engine/src/reviewer.ts | 13 ++++ 13 files changed, 372 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-6915 Fusion-Task-Lineage: 02de0c12-481a-4489-b495-70c9c00619bc --- docs/architecture.md | 1 + docs/dashboard-guide.md | 1 + .../src/__tests__/agent-user-comments.test.ts | 81 +++++++++++++++++++ .../executor-review-step-indexing.test.ts | 28 ++++++- .../engine/src/__tests__/merger-ai.test.ts | 54 +++++++++++++ .../__tests__/merger-prompt-and-utils.test.ts | 29 +++++++ .../engine/src/__tests__/reviewer.test.ts | 37 +++++++-- packages/engine/src/__tests__/triage.test.ts | 23 ++++++ packages/engine/src/agent-user-comments.ts | 60 ++++++++++++++ packages/engine/src/executor.ts | 14 ++-- packages/engine/src/merger-ai.ts | 23 +++++- packages/engine/src/merger.ts | 22 ++++- packages/engine/src/reviewer.ts | 13 +++ 13 files changed, 372 insertions(+), 14 deletions(-) create mode 100644 packages/engine/src/__tests__/agent-user-comments.test.ts create mode 100644 packages/engine/src/agent-user-comments.ts diff --git a/docs/architecture.md b/docs/architecture.md index 2bbe74d1c7..8620659e40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -602,6 +602,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. - **Executor**: `TaskExecutor` (`executor.ts`) implements tasks in worktrees - **Reviewer**: `reviewStep()` (`reviewer.ts`) performs plan/code/spec reviews - **Merger**: `aiMergeTask()` (`merger.ts`) merges approved work +- **Task-detail chat / steering comments**: `TaskStore.addSteeringComment()` writes chat steering text to both `task.comments` and `task.steeringComments`. The executor still uses `steeringComments` for live in-session injection, while next-prompt agent lanes read canonical user-authored `task.comments`: planning/spec generation, spec review, plan/code reviewers, standard merger prompts, and clean-room AI merge + merge-review prompts all surface recent user comments through the shared `agent-user-comments.ts` formatter. #### Reviewer verdict recovery contract (FN-4092) - Reviewer verdicts are `APPROVE`, `REVISE`, `RETHINK`, or `UNAVAILABLE`. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7d078225ed..6e6b6dfed0 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -818,6 +818,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form. - The **Chat** tab includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed. +- Task-detail Chat messages are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass. - The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode. - Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form. - These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group. diff --git a/packages/engine/src/__tests__/agent-user-comments.test.ts b/packages/engine/src/__tests__/agent-user-comments.test.ts new file mode 100644 index 0000000000..5caff1ed5d --- /dev/null +++ b/packages/engine/src/__tests__/agent-user-comments.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import type { TaskComment } from "@fusion/core"; +import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "../agent-user-comments.js"; + +function comment(overrides: Partial): TaskComment { + return { + id: overrides.id ?? "c1", + text: overrides.text ?? "Please keep the old API export", + author: overrides.author ?? "user", + createdAt: overrides.createdAt ?? "2026-06-21T10:00:00.000Z", + updatedAt: overrides.updatedAt, + }; +} + +describe("agent user comments prompt helper", () => { + it("returns no comments and no section for undefined comments", () => { + const selected = selectUserCommentsForAgentContext({}); + + expect(selected).toEqual([]); + expect(buildUserCommentsPromptSection(selected)).toBe(""); + }); + + it("returns no comments and no section for an empty comment array", () => { + const selected = selectUserCommentsForAgentContext({ comments: [] }); + + expect(selected).toEqual([]); + expect(buildUserCommentsPromptSection(selected)).toBe(""); + }); + + it("filters out agent-authored comments", () => { + const selected = selectUserCommentsForAgentContext({ + comments: [comment({ id: "agent-1", author: "agent", text: "internal note" })], + }); + + expect(selected).toEqual([]); + expect(buildUserCommentsPromptSection(selected)).toBe(""); + }); + + it("formats populated user comments with author, timestamp, and text", () => { + const selected = selectUserCommentsForAgentContext({ + comments: [comment({ id: "user-1", text: "Please keep the old API export", createdAt: "2026-06-21T12:34:00.000Z" })], + }); + + const section = buildUserCommentsPromptSection(selected); + + expect(section).toContain("## User Comments"); + expect(section).toContain("**user** — 2026-06-21T12:34:00.000Z"); + expect(section).toContain("> Please keep the old API export"); + }); + + it("dedupes duplicate ids", () => { + const selected = selectUserCommentsForAgentContext({ + comments: [ + comment({ id: "dup", text: "old duplicate", createdAt: "2026-06-21T10:00:00.000Z" }), + comment({ id: "dup", text: "new duplicate", createdAt: "2026-06-21T11:00:00.000Z" }), + ], + }); + + const section = buildUserCommentsPromptSection(selected); + + expect(selected).toHaveLength(1); + expect(section).toContain("new duplicate"); + expect(section).not.toContain("old duplicate"); + }); + + it("caps a large history to the newest comments in chronological order", () => { + const comments = Array.from({ length: 25 }, (_, index) => comment({ + id: `user-${index}`, + text: `comment ${index}`, + createdAt: `2026-06-21T10:${String(index).padStart(2, "0")}:00.000Z`, + })); + + const selected = selectUserCommentsForAgentContext({ comments }, { limit: 3 }); + const section = buildUserCommentsPromptSection(selected); + + expect(selected.map((c) => c.id)).toEqual(["user-22", "user-23", "user-24"]); + expect(section).not.toContain("comment 21"); + expect(section.indexOf("comment 22")).toBeLessThan(section.indexOf("comment 23")); + expect(section.indexOf("comment 23")).toBeLessThan(section.indexOf("comment 24")); + }); +}); diff --git a/packages/engine/src/__tests__/executor-review-step-indexing.test.ts b/packages/engine/src/__tests__/executor-review-step-indexing.test.ts index 7ed152a7e9..3e1e0747ea 100644 --- a/packages/engine/src/__tests__/executor-review-step-indexing.test.ts +++ b/packages/engine/src/__tests__/executor-review-step-indexing.test.ts @@ -11,7 +11,7 @@ import { const mockedReviewStep = vi.mocked(mockedReviewStepFn); -async function captureTools() { +async function captureTools(comments: any[] = []) { const store = createMockStore(); const stepStates = [ { name: "Preflight", status: "done" }, @@ -33,6 +33,7 @@ async function captureTools() { log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + comments, })); store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => { stepStates[stepIndex].status = status; @@ -112,6 +113,31 @@ describe("fn_review_step indexing", () => { expect(result.content[0].text).toContain("Cannot mark Step 1 as done"); }); + it("passes fresh user comments into reviewStep", async () => { + mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "ok", summary: "ok" } as any); + const { tools } = await captureTools([ + { + id: "c-user", + text: "Please keep the old API export", + author: "user", + createdAt: "2026-06-21T10:00:00.000Z", + }, + { + id: "c-agent", + text: "agent-only note", + author: "agent", + createdAt: "2026-06-21T11:00:00.000Z", + }, + ]); + + await tools.fn_review_step("call-1", { step: 1, type: "code", step_name: "Implement", baseline: "abc" }); + + const options = mockedReviewStep.mock.calls[0]?.[7] as any; + expect(options.userComments).toEqual([ + expect.objectContaining({ id: "c-user", text: "Please keep the old API export", author: "user" }), + ]); + }); + it("rejects out-of-range steps without reviewer call", async () => { mockedReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "ok", summary: "ok" } as any); const { tools, store } = await captureTools(); diff --git a/packages/engine/src/__tests__/merger-ai.test.ts b/packages/engine/src/__tests__/merger-ai.test.ts index 7cbd67cdb6..5ce35ecb82 100644 --- a/packages/engine/src/__tests__/merger-ai.test.ts +++ b/packages/engine/src/__tests__/merger-ai.test.ts @@ -28,6 +28,7 @@ import { parseReviewVerdict, buildMergeSystemPrompt, buildMergePrompt, + buildReviewPrompt, buildReviewSystemPrompt, REVIEW_VERDICT_MARKER, AiMergeBlockedError, @@ -152,6 +153,59 @@ describe("parseReviewVerdict", () => { expect(p).toContain("Verify before committing"); }); + it("merge prompt includes user comments when present and omits the section when absent", () => { + const baseInput = { + taskId: "FN-1", + branch: "fusion/fn-1", + integrationBranch: "main", + tipSha: "abc1234567890", + includeTaskId: true, + trailers: ["Fusion-Task-Id: FN-1"], + }; + + const withComments = buildMergePrompt({ + ...baseInput, + userComments: [{ + id: "c1", + text: "Please keep the old API export", + author: "user", + createdAt: "2026-06-21T10:00:00.000Z", + }], + }); + const withoutComments = buildMergePrompt(baseInput); + + expect(withComments).toContain("## User Comments"); + expect(withComments).toContain("Please keep the old API export"); + expect(withoutComments).not.toContain("## User Comments"); + }); + + it("review prompt includes user comments when present and omits the section when absent", () => { + const baseInput = { + taskId: "FN-1", + branch: "fusion/fn-1", + integrationBranch: "main", + tipSha: "abc1234567890", + squashSha: "def1234567890", + diffStat: "file.ts | 1 +", + priorReasons: [], + }; + + const withComments = buildReviewPrompt({ + ...baseInput, + userComments: [{ + id: "c1", + text: "Please preserve the public export", + author: "user", + createdAt: "2026-06-21T10:00:00.000Z", + }], + }); + const withoutComments = buildReviewPrompt(baseInput); + + expect(withComments).toContain("## User Comments"); + expect(withComments).toContain("Please preserve the public export"); + expect(withoutComments).not.toContain("## User Comments"); + }); + it("merge prompt requires subject, body summary, and diff-stat in commit message", () => { const prompt = buildMergePrompt({ taskId: "FN-1", diff --git a/packages/engine/src/__tests__/merger-prompt-and-utils.test.ts b/packages/engine/src/__tests__/merger-prompt-and-utils.test.ts index 8580238e69..2c98cb8f2c 100644 --- a/packages/engine/src/__tests__/merger-prompt-and-utils.test.ts +++ b/packages/engine/src/__tests__/merger-prompt-and-utils.test.ts @@ -849,6 +849,35 @@ describe("buildMergePrompt — truncation behavior", () => { expect(prompt).not.toContain("Be sure to include"); }); + it("includes user comments when present and omits the section when absent", async () => { + const { buildMergePrompt } = await import("../merger.js"); + + const withComments = buildMergePrompt({ + taskId: "FN-001", + branch: "fusion/fn-001", + commitLog: "- feat: something", + diffStat: "1 file changed", + hasConflicts: false, + userComments: [{ + id: "c1", + text: "Please keep the old API export", + author: "user", + createdAt: "2026-06-21T10:00:00.000Z", + }], + }); + const withoutComments = buildMergePrompt({ + taskId: "FN-001", + branch: "fusion/fn-001", + commitLog: "- feat: something", + diffStat: "1 file changed", + hasConflicts: false, + }); + + expect(withComments).toContain("## User Comments"); + expect(withComments).toContain("Please keep the old API export"); + expect(withoutComments).not.toContain("## User Comments"); + }); + it("includes source issue reference guidance when provided", async () => { const { buildMergePrompt } = await import("../merger.js"); diff --git a/packages/engine/src/__tests__/reviewer.test.ts b/packages/engine/src/__tests__/reviewer.test.ts index f1c9d09124..e542f24285 100644 --- a/packages/engine/src/__tests__/reviewer.test.ts +++ b/packages/engine/src/__tests__/reviewer.test.ts @@ -1009,7 +1009,7 @@ describe("reviewStep — user comments in spec review", () => { expect(capturedPrompt).not.toContain("User Comment Coverage"); }); - it("does not include user comments for non-spec review types", async () => { + it.each(["plan", "code"] as const)("includes user comments for %s reviews without spec coverage gating", async (reviewType) => { let capturedPrompt = ""; mockedCreateFnAgent.mockResolvedValue({ session: { @@ -1036,14 +1036,41 @@ describe("reviewStep — user comments in spec review", () => { ]; await reviewStep( - "/tmp/worktree", "FN-050", 1, "Implementation", "code", + "/tmp/worktree", "FN-050", 1, "Implementation", reviewType, "# Task: FN-050\n\n## Mission\nDo something", - "abc123", + reviewType === "code" ? "abc123" : undefined, { userComments }, ); - // Code reviews should not have user comment coverage checks - expect(capturedPrompt).not.toContain("User Comment Coverage"); + expect(capturedPrompt).toContain("## User Comments"); + expect(capturedPrompt).toContain("Some user feedback"); + expect(capturedPrompt).not.toContain("User Comment Coverage (MANDATORY)"); + }); + + it.each(["plan", "code"] as const)("omits the user comments section for %s reviews when no comments are provided", async (reviewType) => { + let capturedPrompt = ""; + mockedCreateFnAgent.mockResolvedValue({ + session: { + prompt: vi.fn().mockImplementation(async (prompt: string) => { + capturedPrompt = prompt; + }), + subscribe: vi.fn().mockImplementation((cb: any) => { + cb({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: "### Verdict: APPROVE\n### Summary\nOK" }, + }); + }), + dispose: vi.fn(), + }, + } as any); + + await reviewStep( + "/tmp/worktree", "FN-050", 1, "Implementation", reviewType, + "# Task: FN-050\n\n## Mission\nDo something", + reviewType === "code" ? "abc123" : undefined, + ); + + expect(capturedPrompt).not.toContain("## User Comments"); }); it("includes assigned worktree boundary instructions for code reviews", async () => { diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 5587d8d4b1..9b2838a8a3 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -555,6 +555,29 @@ describe("buildSpecificationPrompt", () => { expect(prompt).toContain("Missing comment coverage is a spec quality failure"); }); + it("pins task-detail chat comments as planning-agent context", () => { + const taskWithChatComment: TaskDetail = { + ...baseTask, + comments: [ + { + id: "chat-1", + text: "Please keep the old API export in the generated spec", + author: "user", + createdAt: "2026-06-21T15:30:00.000Z", + }, + ], + }; + + const prompt = buildSpecificationPrompt( + taskWithChatComment, + ".fusion/tasks/KB-001/PROMPT.md", + ); + + expect(prompt).toContain("## User Comments"); + expect(prompt).toContain("Please keep the old API export in the generated spec"); + expect(prompt).toContain("Address every comment"); + }); + it("excludes agent/system comments from user comments section", () => { const taskWithMixedComments: TaskDetail = { ...baseTask, diff --git a/packages/engine/src/agent-user-comments.ts b/packages/engine/src/agent-user-comments.ts new file mode 100644 index 0000000000..db5385d30b --- /dev/null +++ b/packages/engine/src/agent-user-comments.ts @@ -0,0 +1,60 @@ +import type { TaskComment } from "@fusion/core"; + +const DEFAULT_USER_COMMENT_LIMIT = 20; + +function commentTimestamp(comment: TaskComment): string { + return comment.updatedAt || comment.createdAt; +} + +function timestampMs(comment: TaskComment): number { + const parsed = Date.parse(commentTimestamp(comment)); + return Number.isFinite(parsed) ? parsed : 0; +} + +function quoteCommentText(text: string): string[] { + const normalized = text.trim(); + if (!normalized) return ["> (empty comment)"]; + return normalized.split(/\r?\n/).map((line) => `> ${line}`); +} + +/** + * FNXC:AgentSteering 2026-06-22-00:05: + * Task-detail chat and user comments must reach every agent lane that builds prompts: executor, merger, reviewer, and planner. This helper is the canonical formatter for next-prompt delivery outside the executor's live steering injection path, so merger and reviewer prompts do not drift or duplicate comment logic. + */ +export function selectUserCommentsForAgentContext( + task: { comments?: TaskComment[] }, + opts: { limit?: number } = {}, +): TaskComment[] { + const limit = opts.limit ?? DEFAULT_USER_COMMENT_LIMIT; + if (!task.comments || task.comments.length === 0 || limit <= 0) return []; + + const byId = new Map(); + for (const comment of task.comments) { + if (comment.author !== "user") continue; + byId.set(comment.id, comment); + } + + return [...byId.values()] + .sort((a, b) => timestampMs(a) - timestampMs(b)) + .slice(-limit); +} + +export function buildUserCommentsPromptSection( + comments: TaskComment[], + opts: { heading?: string; intro?: string } = {}, +): string { + if (comments.length === 0) return ""; + + const heading = opts.heading ?? "## User Comments"; + const intro = opts.intro ?? "The following user comments were posted on this task. Consider and address this user feedback when completing your agent pass."; + const lines = [heading, "", intro, ""]; + + for (const comment of comments) { + const timestamp = commentTimestamp(comment); + lines.push(`**${comment.author}** — ${timestamp}`); + lines.push(...quoteCommentText(comment.text)); + lines.push(""); + } + + return lines.join("\n").trimEnd(); +} diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 7d47460d42..250dde27ce 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -76,6 +76,7 @@ import { } from "./agent-session-helpers.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; import { reviewStep, type ReviewVerdict } from "./reviewer.js"; +import { selectUserCommentsForAgentContext } from "./agent-user-comments.js"; import { resolveSandboxBackend } from "./sandbox/index.js"; import type { SandboxBackend } from "./sandbox/types.js"; import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@earendil-works/pi-coding-agent"; @@ -11175,7 +11176,9 @@ export class TaskExecutor { // Merge per-task effective workflow settings (U3, KTD-3) so the // validator model-lane reads below pick up workflow values; this tool // closure re-fetches independently. Behavior-inert by default. - const settings = await mergeEffectiveSettings(store, detail, await store.getSettings()); + const latestDetailForReview = await store.getTask(taskId); + const userComments = selectUserCommentsForAgentContext(latestDetailForReview); + const settings = await mergeEffectiveSettings(store, latestDetailForReview, await store.getSettings()); // Run the reviewer via semaphore.runNested so its slot accounting // is honest: activeCount transiently bumps to reflect the second // agent session, but the reviewer doesn't enter the wait queue @@ -11195,10 +11198,10 @@ export class TaskExecutor { defaultModelId: settings.defaultModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: detail.thinkingLevel ?? settings.defaultThinkingLevel, + defaultThinkingLevel: latestDetailForReview.thinkingLevel ?? settings.defaultThinkingLevel, // Task-level validator override (from task) - taskValidatorProvider: detail.validatorModelProvider, - taskValidatorModelId: detail.validatorModelId, + taskValidatorProvider: latestDetailForReview.validatorModelProvider, + taskValidatorModelId: latestDetailForReview.validatorModelId, // Project-level validator override projectValidatorProvider: settings.validatorProvider, projectValidatorModelId: settings.validatorModelId, @@ -11213,7 +11216,8 @@ export class TaskExecutor { projectDefaultOverrideModelId: settings.defaultModelIdOverride, store, taskId, - task: detail, + task: latestDetailForReview, + userComments: userComments.length > 0 ? userComments : undefined, agentPrompts: settings.agentPrompts, agentStore: this.options.agentStore, rootDir: this.rootDir, diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index de07b7f074..b23b32da39 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -50,8 +50,10 @@ import { type MergeResult, type Settings, type Task, + type TaskComment, type TaskStore, } from "@fusion/core"; +import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.js"; import { resolveTaskWorkingBranch } from "./worktree-names.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js"; @@ -504,6 +506,7 @@ export function buildMergePrompt(input: { /** Required trailers to append (board association). */ trailers: string[]; correctiveReasons?: string[]; + userComments?: TaskComment[]; }): string { const subjectShape = input.includeTaskId ? `"${input.taskId}: "` @@ -528,6 +531,10 @@ export function buildMergePrompt(input: { "If `git merge --squash` reports the branch is already up to date (nothing to", "merge), do nothing and leave HEAD unchanged.", ]; + const userCommentsSection = buildUserCommentsPromptSection(input.userComments ?? []); + if (userCommentsSection) { + lines.push("", userCommentsSection); + } if (input.correctiveReasons && input.correctiveReasons.length > 0) { lines.push( "", @@ -579,6 +586,7 @@ export function buildReviewPrompt(input: { squashSha: string; diffStat: string; priorReasons?: string[]; + userComments?: TaskComment[]; }): string { const lines = [ `Review the squash merge for task ${input.taskId} (branch ${input.branch} → ${input.integrationBranch}).`, @@ -593,6 +601,10 @@ export function buildReviewPrompt(input: { "Files changed (git diff --stat):", input.diffStat.trim() || "(none reported)", ]; + const userCommentsSection = buildUserCommentsPromptSection(input.userComments ?? []); + if (userCommentsSection) { + lines.push("", userCommentsSection); + } if (input.priorReasons && input.priorReasons.length > 0) { lines.push( "", @@ -1120,7 +1132,7 @@ export async function runAiMerge( // 2 + 3. Merge + review loop (corrective passes). const squashSha = await mergeAndReview({ mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, - maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal: options.signal, + maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, store, signal: options.signal, }); if (!squashSha) { @@ -1218,9 +1230,10 @@ async function mergeAndReview(input: { audit: RunAuditor; log: (message: string) => Promise; setStatus: (status: string | null) => Promise; + store: TaskStore; signal?: AbortSignal; }): Promise { - const { mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal } = input; + const { mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, store, signal } = input; let priorReasons: string[] = []; for (let attempt = 0; ; attempt++) { @@ -1234,9 +1247,12 @@ async function mergeAndReview(input: { await setStatus("merging"); await log(`AI merge: corrective re-merge (pass ${attempt}/${maxPasses}) addressing: ${priorReasons.join("; ")}`); } + const latestTaskForMergePrompt = await store.getTask(taskId); + const mergeUserComments = selectUserCommentsForAgentContext(latestTaskForMergePrompt); await mergeAgent(mergeRoot, buildMergePrompt({ taskId, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, correctiveReasons: priorReasons.length ? priorReasons : undefined, + userComments: mergeUserComments, })); let head = await git(["rev-parse", "HEAD"], mergeRoot); @@ -1250,8 +1266,11 @@ async function mergeAndReview(input: { await setStatus("reviewing"); const diffStat = await git(["diff", "--stat", `${tipSha}..${head}`], mergeRoot); + const latestTaskForReviewPrompt = await store.getTask(taskId); + const reviewUserComments = selectUserCommentsForAgentContext(latestTaskForReviewPrompt); const verdict = parseReviewVerdict(await reviewAgent(mergeRoot, buildReviewPrompt({ taskId, branch, integrationBranch, tipSha, squashSha: head, diffStat, priorReasons, + userComments: reviewUserComments, }))); await audit.git({ type: "merge:ai-review-verdict", diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index feee8e6cd2..a4f8958d9c 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -4,6 +4,7 @@ import * as childProcess from "node:child_process"; import { promisify } from "node:util"; import { IDENTITY_GUARD_BYPASS_ENV } from "./worktree-hooks.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; +import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.js"; // Internal git plumbing intentionally bypasses sandbox backends. const execAsync = promisify(exec); @@ -100,6 +101,7 @@ import { type PostMergeAuditMode, type TaskSourceIssue, type Task, + type TaskComment, type TaskDetail, type AutostashOrphanRecord, normalizeMergeAdvanceAutoSyncMode, @@ -371,6 +373,9 @@ const MERGE_COMMIT_LOG_MAX_CHARS = 5000; /** Maximum characters for diff stat in merge prompt — prevents context overflow on large diffs */ const MERGE_DIFF_STAT_MAX_CHARS = 3000; +/** Maximum characters for user comments in merge prompt — preserves steering context without crowding merge instructions. */ +const MERGE_USER_COMMENTS_MAX_CHARS = 4000; + /** * @deprecated Use summarizeVerificationOutput from verification-utils.js instead */ @@ -11909,6 +11914,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo try { // Build appropriate prompt + const latestTaskForMergePrompt = await store.getTask(taskId); + const userComments = selectUserCommentsForAgentContext(latestTaskForMergePrompt); const prompt = buildMergePrompt({ taskId, branch, @@ -11921,6 +11928,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo authorArg, sourceIssueRef, preMergeRebaseFallthrough, + userComments, }); // Attempt prompting with fresh session (first attempt). @@ -11952,6 +11960,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo // The fall-through preamble is preserved (it's the safety constraint, // not bulk context) so the AI's truncated retry still knows main's // deletions are authoritative. + const latestTaskForTruncatedMergePrompt = await store.getTask(taskId); + const truncatedUserComments = selectUserCommentsForAgentContext(latestTaskForTruncatedMergePrompt); const truncatedPrompt = buildMergePrompt({ taskId, branch, @@ -11964,6 +11974,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo authorArg, sourceIssueRef, preMergeRebaseFallthrough, + userComments: truncatedUserComments, }); try { @@ -12097,14 +12108,19 @@ interface MergePromptParams { * gate; this preamble gives the AI a fighting chance to do the right * thing on its first try. */ preMergeRebaseFallthrough?: string; + userComments?: TaskComment[]; } export function buildMergePrompt(params: MergePromptParams): string { - const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, sourceIssueRef, testCommand, buildCommand, authorArg, preMergeRebaseFallthrough } = params; + const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, sourceIssueRef, testCommand, buildCommand, authorArg, preMergeRebaseFallthrough, userComments } = params; // Apply truncation to prevent context overflow for large branches/diffs const truncatedCommitLog = truncateWithEllipsis(commitLog, MERGE_COMMIT_LOG_MAX_CHARS); const truncatedDiffStat = truncateWithEllipsis(diffStat, MERGE_DIFF_STAT_MAX_CHARS); + const userCommentsSection = truncateWithEllipsis( + buildUserCommentsPromptSection(userComments ?? []), + MERGE_USER_COMMENTS_MAX_CHARS, + ); const parts: string[] = []; @@ -12156,6 +12172,10 @@ export function buildMergePrompt(params: MergePromptParams): string { ); } + if (userCommentsSection) { + parts.push("", userCommentsSection); + } + if (hasConflicts) { parts.push( "", diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index 04e4d2cc5c..558111ee9c 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -34,6 +34,7 @@ import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { createMemoryGetTool, createMemorySearchTool, createWebFetchTool } from "./agent-tools.js"; +import { buildUserCommentsPromptSection } from "./agent-user-comments.js"; export type ReviewType = "plan" | "code" | "spec"; export type ReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE"; @@ -750,6 +751,12 @@ function buildReviewRequest( "Read relevant source files to understand the current codebase state.", "Check for risks, missing edge cases, and gaps in the plan.", ); + const userCommentsSection = buildUserCommentsPromptSection(userComments ?? [], { + intro: "The following user comments were posted on this task. Account for this feedback when assessing the plan; raise concerns when the plan conflicts with or ignores relevant user feedback.", + }); + if (userCommentsSection) { + parts.push("", userCommentsSection); + } } else { parts.push( "## What to review", @@ -761,6 +768,12 @@ function buildReviewRequest( "Verify that implementation changes are in this worktree. If you find changes or commits in the primary project checkout or any other path, issue REVISE unless the outside path is an expected project-root exception such as .fusion/memory/ files, task attachments, or explicitly documented Fusion metadata.", "", ); + const userCommentsSection = buildUserCommentsPromptSection(userComments ?? [], { + intro: "The following user comments were posted on this task. Account for this feedback when reviewing the code; raise concerns when the implementation conflicts with or ignores relevant user feedback.", + }); + if (userCommentsSection) { + parts.push(userCommentsSection, ""); + } if (baseline) { parts.push( "To see the changes for this step, run:", From 744bb384dcb04b0697cc26574f03e6f6cab24e70 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:54:38 -0700 Subject: [PATCH 064/265] feat(dashboard): Usage back to header; Skills/Memory after Mailbox; tests updated - Usage (Activity) removed from the right dock and rendered as a non-mobile header button (left of the right-sidebar toggle) that opens the UsageIndicator as a header-anchored modal. - Left sidebar: Skills and Memory moved to immediately after Mailbox. - Updated overflowViewRegistry / Header / tablet-header / LeftSidebarNav tests to the new behavior (all green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/tablet-header-controls.test.tsx | 204 ++++++++---------- packages/dashboard/app/components/Header.tsx | 16 ++ .../app/components/LeftSidebarNav.tsx | 16 +- .../app/components/__tests__/Header.test.tsx | 29 ++- .../__tests__/LeftSidebarNav.test.tsx | 83 ++++++- .../__tests__/overflowViewRegistry.test.tsx | 100 ++++++--- .../app/components/overflowViewRegistry.tsx | 11 - 7 files changed, 285 insertions(+), 174 deletions(-) diff --git a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx index e25c42a16e..6aef4c5e81 100644 --- a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx +++ b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx @@ -176,9 +176,19 @@ describe("tablet header controls", () => { expect(screen.queryByTitle("Automation")).toBeNull(); }); - it("does not render usage button inline on tablet", () => { - renderTabletHeader({ onOpenUsage: noop }); - expect(screen.queryByTitle("View usage")).toBeNull(); + it("renders the header usage button to the left of the right-dock toggle on tablet", () => { + const onOpenUsage = vi.fn(); + renderTabletHeader({ onOpenUsage, rightDockAvailable: true, onToggleRightDock: noop }); + + const usageBtn = screen.getByTestId("header-usage-btn"); + expect(usageBtn.getAttribute("title")).toBe("View usage"); + // Sits immediately to the left of the right-dock toggle. + expect(usageBtn.nextElementSibling).toBe(screen.getByTestId("header-right-dock-toggle")); + + const mockRect = { x: 0, y: 0, top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect; + (usageBtn as HTMLButtonElement).getBoundingClientRect = vi.fn(() => mockRect); + fireEvent.click(usageBtn); + expect(onOpenUsage).toHaveBeenCalledWith(mockRect); }); it("does not render activity log button inline on tablet", () => { @@ -201,115 +211,79 @@ describe("tablet header controls", () => { expect(screen.queryByTitle("Workflows")).toBeNull(); }); - // ── Overflow menu on tablet ──────────────────────────────────── + // ── Right-sidebar toggle replaces the three-dots overflow on tablet ───── + // + // FNXC:Navigation 2026-06-22-01:44: + // The tablet three-dots compact overflow menu was retired: the mobile-only + // overflow trigger gate (isMobile && !hideFullNav) means tablet no longer + // renders "More header actions" or any header overflow menu. Instead, the + // non-mobile right-sidebar show/hide toggle (header-right-dock-toggle) owns + // that header slot. Tablet tool actions live in the right dock, not the header. - it("renders overflow menu trigger on tablet", () => { + it("does not render the three-dots overflow trigger on tablet", () => { renderTabletHeader(); - expect(screen.getByTitle("More header actions")).toBeDefined(); + expect(screen.queryByTitle("More header actions")).toBeNull(); + expect(document.querySelector(".compact-overflow-trigger")).toBeNull(); }); - it("overflow menu contains settings on tablet", () => { - renderTabletHeader(); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByText("Settings")).toBeDefined(); + it("renders the right-sidebar toggle on tablet when the dock is available", () => { + renderTabletHeader({ rightDockAvailable: true, onToggleRightDock: noop }); + expect(screen.getByTestId("header-right-dock-toggle")).toBeDefined(); + expect(screen.queryByTitle("More header actions")).toBeNull(); }); - it("overflow menu omits planning on tablet", () => { + it("toggles the right sidebar from the tablet header toggle", () => { + const onToggleRightDock = vi.fn(); + renderTabletHeader({ rightDockAvailable: true, onToggleRightDock }); + fireEvent.click(screen.getByTestId("header-right-dock-toggle")); + expect(onToggleRightDock).toHaveBeenCalledTimes(1); + }); + + it("reflects the right-sidebar open state on the tablet toggle", () => { + renderTabletHeader({ rightDockAvailable: true, rightDockOpen: true, onToggleRightDock: noop }); + const toggle = screen.getByTestId("header-right-dock-toggle"); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(toggle.getAttribute("title")).toBe("Hide right sidebar"); + }); + + it("does not render the right-sidebar toggle on tablet when the dock is unavailable", () => { + renderTabletHeader({ onToggleRightDock: noop }); + expect(screen.queryByTestId("header-right-dock-toggle")).toBeNull(); + }); + + it("does not render planning affordances in the tablet header", () => { renderTabletHeader(); - fireEvent.click(screen.getByTitle("More header actions")); expect(screen.queryByTestId("overflow-planning-btn")).toBeNull(); + expect(screen.queryByTitle("Create a task with AI planning")).toBeNull(); }); - it("overflow menu contains GitHub import on tablet", () => { + it("does not render GitHub import inline or in any overflow on tablet", () => { renderTabletHeader(); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByText("Import from GitHub")).toBeDefined(); + expect(screen.queryByText("Import from GitHub")).toBeNull(); }); - it("overflow menu omits terminal launcher and scripts on tablet", () => { + it("does not render terminal launcher and scripts affordances on tablet", () => { renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop }); - fireEvent.click(screen.getByTitle("More header actions")); expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull(); expect(screen.queryByTestId("overflow-terminal-submenu-toggle")).toBeNull(); expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull(); }); - it("overflow menu contains automation on tablet", () => { - renderTabletHeader({ onOpenSchedules: noop }); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByText("Automation")).toBeDefined(); - }); - - it("overflow menu contains usage on tablet when provided", () => { - renderTabletHeader({ onOpenUsage: noop }); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByTestId("overflow-usage-btn")).toBeDefined(); - }); - - it("overflow menu contains activity log on tablet when provided", () => { - renderTabletHeader({ onOpenActivityLog: noop }); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByTestId("overflow-activity-log-btn")).toBeDefined(); - }); - - it("overflow menu contains files on tablet when provided", () => { - renderTabletHeader({ onOpenFiles: noop }); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByTestId("overflow-files-btn")).toBeDefined(); - }); - - it("overflow menu contains git manager on tablet when provided", () => { - renderTabletHeader({ onOpenGitManager: noop }); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByTestId("overflow-git-btn")).toBeDefined(); - }); - - it("overflow menu contains workflows on tablet when provided", () => { - renderTabletHeader({ onOpenWorkflowEditor: noop }); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByTestId("overflow-workflow-steps-btn")).toBeDefined(); - }); - - // ── Overflow menu callbacks work on tablet ───────────────────── - - it("calls onOpenSettings from overflow menu on tablet", () => { - const onOpenSettings = vi.fn(); - renderTabletHeader({ onOpenSettings }); - fireEvent.click(screen.getByTitle("More header actions")); - fireEvent.click(screen.getByText("Settings")); - expect(onOpenSettings).toHaveBeenCalled(); - }); - - it("calls onOpenUsage from overflow menu on tablet", () => { - const onOpenUsage = vi.fn(); - renderTabletHeader({ onOpenUsage }); - fireEvent.click(screen.getByTitle("More header actions")); - fireEvent.click(screen.getByTestId("overflow-usage-btn")); - expect(onOpenUsage).toHaveBeenCalled(); - }); - - it("closes overflow menu after selecting an action on tablet", () => { - renderTabletHeader(); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByRole("menu")).toBeDefined(); - fireEvent.click(screen.getByText("Settings")); - expect(screen.queryByRole("menu")).toBeNull(); - }); - - it("closes overflow menu on outside click on tablet", () => { - renderTabletHeader(); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByRole("menu")).toBeDefined(); - fireEvent.mouseDown(document.body); - expect(screen.queryByRole("menu")).toBeNull(); - }); - - it("closes overflow menu on Escape key on tablet", () => { - renderTabletHeader(); - fireEvent.click(screen.getByTitle("More header actions")); - expect(screen.getByRole("menu")).toBeDefined(); - fireEvent.keyDown(document, { key: "Escape" }); - expect(screen.queryByRole("menu")).toBeNull(); + it("does not render automation, usage, activity log, files, git, or workflow header items on tablet", () => { + renderTabletHeader({ + onOpenSchedules: noop, + onOpenUsage: noop, + onOpenActivityLog: noop, + onOpenFiles: noop, + onOpenGitManager: noop, + onOpenWorkflowEditor: noop, + }); + expect(screen.queryByText("Automation")).toBeNull(); + expect(screen.queryByTestId("overflow-usage-btn")).toBeNull(); + expect(screen.queryByTestId("overflow-activity-log-btn")).toBeNull(); + expect(screen.queryByTestId("overflow-files-btn")).toBeNull(); + expect(screen.queryByTestId("overflow-git-btn")).toBeNull(); + expect(screen.queryByTestId("overflow-workflow-steps-btn")).toBeNull(); }); // ── Search on tablet ─────────────────────────────────────────── @@ -393,7 +367,7 @@ describe("tablet header controls", () => { expect(screen.queryByTestId("back-to-projects-btn")).toBeNull(); }); - it("does not show projects entry in overflow menu on tablet", () => { + it("does not show a projects overflow entry on tablet (no header overflow exists)", () => { const projects = [ { id: "1", name: "Project One", path: "/path/one", status: "active" as const }, { id: "2", name: "Project Two", path: "/path/two", status: "active" as const }, @@ -404,7 +378,7 @@ describe("tablet header controls", () => { currentProject: projects[0], onViewAllProjects, }); - fireEvent.click(screen.getByTitle("More header actions")); + expect(screen.queryByTitle("More header actions")).toBeNull(); expect(screen.queryByTestId("overflow-project-selector-btn")).toBeNull(); }); @@ -466,9 +440,9 @@ describe("tablet header controls", () => { // ── Terminal launcher relocation regression tests ───────────── describe("terminal launcher relocation on tablet", () => { - it("keeps terminal launcher affordances out of the tablet header overflow", () => { + it("keeps terminal launcher affordances out of the tablet header (no overflow exists)", () => { renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }); - fireEvent.click(screen.getByTitle("More header actions")); + expect(screen.queryByTitle("More header actions")).toBeNull(); expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull(); expect(screen.queryByTestId("overflow-terminal-submenu-toggle")).toBeNull(); expect(screen.queryByTestId("overflow-script-item-build")).toBeNull(); @@ -476,10 +450,15 @@ describe("tablet header controls", () => { }); }); - // ── Settings is the last overflow menu item ──────────────────── + // ── No header overflow menu remains on tablet ────────────────── + // + // FNXC:Navigation 2026-06-22-01:44: + // The Settings-last overflow ordering invariant no longer applies on tablet + // because the three-dots overflow menu is mobile-only. Tablet renders no + // .mobile-overflow-menu and no menu role; Settings lives in the right dock. - describe("overflow menu ordering on tablet", () => { - it("Settings is the last item in the tablet overflow menu when all optional items are present", () => { + describe("no overflow menu on tablet", () => { + it("renders no header overflow menu on tablet even when all optional items are provided", () => { const { container } = renderTabletHeader({ onOpenUsage: noop, onOpenActivityLog: noop, @@ -488,26 +467,15 @@ describe("tablet header controls", () => { onOpenGitManager: noop, }); - fireEvent.click(screen.getByTitle("More header actions")); - - // Get all menu items inside the overflow menu - const menu = container.querySelector(".mobile-overflow-menu")!; - const menuItems = Array.from(menu.querySelectorAll("button.mobile-overflow-item")); - - // The last menu item should be Settings - const lastItem = menuItems[menuItems.length - 1]; - expect(lastItem.textContent).toBe("Settings"); + expect(container.querySelector(".mobile-overflow-menu")).toBeNull(); + expect(screen.queryByRole("menu")).toBeNull(); + expect(screen.queryByTitle("More header actions")).toBeNull(); }); - it("Settings is the last item in the tablet overflow menu when optional items are absent", () => { - renderTabletHeader(); - fireEvent.click(screen.getByTitle("More header actions")); - - const menu = screen.getByRole("menu"); - const menuItems = Array.from(menu.querySelectorAll("button[role='menuitem']")); - - const lastItem = menuItems[menuItems.length - 1]; - expect(lastItem.textContent).toBe("Settings"); + it("renders no header overflow menu on tablet when optional items are absent", () => { + const { container } = renderTabletHeader(); + expect(container.querySelector(".mobile-overflow-menu")).toBeNull(); + expect(screen.queryByRole("menu")).toBeNull(); }); }); }); diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 103056365b..608a086f55 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -974,6 +974,22 @@ export function Header({ {/* Plugin UI slot for header actions */} + {/* + FNXC:Navigation 2026-06-22-00:50: + Usage (Activity) lives in the top header to the left of the right-sidebar toggle and opens the UsageIndicator as a header-anchored modal (not inline in the dock). Non-mobile only; mobile keeps its own usage button in the bottom-nav layout. + */} + {!isMobile && onOpenUsage && ( + + )} + {/* FNXC:Navigation 2026-06-22-00:00: Non-mobile surfaces (desktop + tablet) get a single right-sidebar show/hide toggle that owns the right dock visibility. It replaces the tablet three-dots overflow; the dock is fully hidden when closed and reopened from here. Mobile is intentionally excluded — it keeps its existing overflow menu untouched and has no right dock. diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index 9aec1a5800..26367121aa 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -340,6 +340,16 @@ export function LeftSidebarNav({ dot: view !== "mailbox" && mailboxPendingApprovalCount > 0 ? "pending" : view !== "mailbox" && mailboxUnreadCount > 0 ? "online" : undefined, onSelect: () => onChangeView("mailbox"), }, + /* + FNXC:Navigation 2026-06-22-00:50: + Skills and Memory sit directly after Mailbox (still flag-gated by showSkillsTab / memoryView). + */ + ...(showSkillsTab + ? [{ id: "skills", label: t("header.skillsView", "Skills"), view: "skills" as TaskView, isActive: view === "skills", icon: Zap, testId: "sidebar-nav-skills", onSelect: () => onChangeView("skills") }] + : []), + ...(experimentalFeatures?.memoryView + ? [{ id: "memory", label: t("header.memoryView", "Memory"), view: "memory" as TaskView, isActive: view === "memory", icon: Brain, testId: "sidebar-nav-memory", onSelect: () => onChangeView("memory") }] + : []), { id: "planning", /* @@ -416,12 +426,6 @@ export function LeftSidebarNav({ ...(experimentalFeatures?.researchView ? [{ id: "research", label: t("header.researchView", "Research"), view: "research" as TaskView, isActive: view === "research", icon: Search, testId: "sidebar-nav-research", onSelect: () => onChangeView("research") }] : []), - ...(showSkillsTab - ? [{ id: "skills", label: t("header.skillsView", "Skills"), view: "skills" as TaskView, isActive: view === "skills", icon: Zap, testId: "sidebar-nav-skills", onSelect: () => onChangeView("skills") }] - : []), - ...(experimentalFeatures?.memoryView - ? [{ id: "memory", label: t("header.memoryView", "Memory"), view: "memory" as TaskView, isActive: view === "memory", icon: Brain, testId: "sidebar-nav-memory", onSelect: () => onChangeView("memory") }] - : []), ...(experimentalFeatures?.evalsView ? [{ id: "evals", label: t("header.evalsView", "Evals"), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }] : []), diff --git a/packages/dashboard/app/components/__tests__/Header.test.tsx b/packages/dashboard/app/components/__tests__/Header.test.tsx index 0a60887446..9769e95675 100644 --- a/packages/dashboard/app/components/__tests__/Header.test.tsx +++ b/packages/dashboard/app/components/__tests__/Header.test.tsx @@ -580,10 +580,24 @@ describe("Header", () => { expect(screen.queryByTitle("View usage")).toBeNull(); }); - it("does not render usage button inline on desktop when onOpenUsage is provided", () => { - renderHeader({ onOpenUsage: vi.fn() }, "desktop"); - expect(screen.queryByTitle("View usage")).toBeNull(); + it("renders the header usage button to the left of the right-dock toggle on desktop when onOpenUsage is provided", () => { + renderHeader({ onOpenUsage: vi.fn(), rightDockAvailable: true, onToggleRightDock: noop }, "desktop"); + const usageBtn = screen.getByTestId("header-usage-btn"); + expect(usageBtn.getAttribute("title")).toBe("View usage"); + // Retired legacy toolbar testid stays gone. expect(screen.queryByTestId("desktop-header-usage-btn")).toBeNull(); + // Sits immediately to the left of the right-dock toggle. + expect(usageBtn.nextElementSibling).toBe(screen.getByTestId("header-right-dock-toggle")); + }); + + it("fires onOpenUsage with button bounds from the desktop header usage button", () => { + const onOpenUsage = vi.fn(); + renderHeader({ onOpenUsage }, "desktop"); + const usageBtn = screen.getByTestId("header-usage-btn") as HTMLButtonElement; + const mockRect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect; + usageBtn.getBoundingClientRect = vi.fn(() => mockRect); + fireEvent.click(usageBtn); + expect(onOpenUsage).toHaveBeenCalledWith(mockRect); }); it("does not render usage button inline on mobile when onOpenUsage is provided", () => { @@ -1399,7 +1413,11 @@ describe("Header", () => { }); describe("action ordering", () => { - it("Settings is the last inline action on desktop after engine controls moved to the footer", () => { + it("places only the Usage button after Settings on desktop after engine controls moved to the footer", () => { + /* + FNXC:Navigation 2026-06-22-12:00: + Usage moved back to the top header (left of the right-dock toggle), so it now renders after Settings in the inline header actions. Settings is the last inline action ONLY among the primary controls; the trailing Usage button (and the right-dock toggle when available) intentionally follow it. + */ const { container } = renderHeader({ onOpenUsage: noop, onOpenActivityLog: noop, @@ -1424,7 +1442,8 @@ describe("Header", () => { expect(settingsIdx).toBeGreaterThanOrEqual(0); const itemsAfterSettings = inlineItems.slice(settingsIdx + 1); - expect(itemsAfterSettings).toHaveLength(0); + // Only the relocated Usage button trails Settings (no right-dock toggle without rightDockAvailable). + expect(itemsAfterSettings.map((el) => el.getAttribute("data-testid"))).toEqual(["header-usage-btn"]); }); it("Settings is the last item in the mobile overflow menu", () => { diff --git a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx index 157059312d..004d2865a6 100644 --- a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx +++ b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx @@ -208,20 +208,22 @@ describe("LeftSidebarNav", () => { for (const testId of [ "sidebar-nav-board", "sidebar-nav-list", - "sidebar-nav-agents", "sidebar-nav-command-center", + "sidebar-nav-agents", + "sidebar-nav-chat", + "sidebar-nav-mailbox", "sidebar-nav-planning", "sidebar-nav-missions", - "sidebar-nav-chat", "sidebar-nav-documents", - "sidebar-nav-mailbox", - "sidebar-nav-evals", "sidebar-nav-goals", - "sidebar-nav-research", + "sidebar-nav-automations", + "sidebar-nav-import-tasks", + "sidebar-nav-workflows", "sidebar-nav-insights", + "sidebar-nav-research", "sidebar-nav-skills", "sidebar-nav-memory", - "sidebar-nav-devserver", + "sidebar-nav-evals", "sidebar-nav-plugin-fusion-plugin-primary-primary-view", "sidebar-nav-plugin-fusion-plugin-overflow-overflow-view", "sidebar-nav-settings", @@ -231,11 +233,68 @@ describe("LeftSidebarNav", () => { expect(screen.getByTestId("sidebar-nav-documents")).toHaveTextContent("Artifacts"); expect(screen.getByTestId("sidebar-nav-planning")).toHaveTextContent("Planning"); + expect(screen.getByTestId("sidebar-nav-import-tasks")).toHaveTextContent("Import Tasks"); expect(screen.queryByTestId("sidebar-nav-stash-recovery")).toBeNull(); + /* + FNXC:Navigation 2026-06-22-12:00: + Import Tasks renders a custom GitHub octocat SVG (lucide-react has no Github export), not a lucide icon. The octocat path is the discriminator. + */ + const importIconSvg = screen.getByTestId("sidebar-nav-import-tasks").querySelector("svg"); + expect(importIconSvg).not.toBeNull(); + expect(importIconSvg?.getAttribute("viewBox")).toBe("0 0 24 24"); + expect(importIconSvg?.querySelector("path")?.getAttribute("d")).toContain("M12 2C6.477 2 2 6.484 2 12.017"); + + /* + FNXC:Navigation 2026-06-22-12:00: + Dev Server moved to the right dock; the sidebar no longer renders a devserver entry even when the devServerView flag is on. + */ + expect(screen.queryByTestId("sidebar-nav-devserver")).toBeNull(); + const primaryNav = screen.getByRole("navigation", { name: "Primary navigation" }); + + /* + FNXC:Navigation 2026-06-22-12:00: + The sidebar collapsed its two placement sections into ONE explicitly-ordered list; the `--secondary` section is gone. + */ + expect(primaryNav.querySelectorAll(".left-sidebar-nav__section")).toHaveLength(1); + expect(primaryNav.querySelector(".left-sidebar-nav__section--secondary")).toBeNull(); + + /* + FNXC:Navigation 2026-06-22-12:00: + Assert the intentional single-list order (top to bottom) for the entries present under the default render flags. + command-center precedes agents; skills/memory (flag-gated) sit immediately after mailbox and before planning; documents (Artifacts) follows missions; automations -> import-tasks -> workflows are contiguous after compound/goals. + */ const primaryButtons = within(primaryNav).getAllByRole("button"); - expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-planning"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-command-center")) + 1); + const orderedTestIds = [ + "sidebar-nav-board", + "sidebar-nav-list", + "sidebar-nav-command-center", + "sidebar-nav-agents", + "sidebar-nav-chat", + "sidebar-nav-mailbox", + "sidebar-nav-skills", + "sidebar-nav-memory", + "sidebar-nav-planning", + "sidebar-nav-missions", + "sidebar-nav-documents", + "sidebar-nav-goals", + "sidebar-nav-automations", + "sidebar-nav-import-tasks", + "sidebar-nav-workflows", + "sidebar-nav-insights", + "sidebar-nav-research", + "sidebar-nav-evals", + ]; + const orderedIndices = orderedTestIds.map((testId) => primaryButtons.indexOf(screen.getByTestId(testId))); + expect(orderedIndices).toEqual([...orderedIndices].sort((a, b) => a - b)); + expect(orderedIndices.every((index) => index >= 0)).toBe(true); + expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-command-center"))).toBeLessThan(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-agents"))); + expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-documents"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-missions")) + 1); + // Skills and Memory sit immediately after Mailbox and before Planning. + expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-skills"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-mailbox")) + 1); + expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-memory"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-skills")) + 1); + expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-planning"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-memory")) + 1); const sidebar = screen.getByTestId("left-sidebar-nav"); const footer = screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer"); @@ -292,9 +351,17 @@ describe("LeftSidebarNav", () => { expect(screen.queryByTestId("sidebar-nav-memory")).toBeNull(); expect(screen.queryByTestId("sidebar-nav-evals")).toBeNull(); expect(screen.queryByTestId("sidebar-nav-goals")).toBeNull(); - expect(screen.queryByTestId("sidebar-nav-devserver")).toBeNull(); expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-primary-primary-view")).toBeNull(); + /* + FNXC:Navigation 2026-06-22-12:00: + Unconditional left-sidebar destinations survive empty flags/props: automations, import-tasks (Import Tasks), and workflows are always present; devserver never renders here (right dock). + */ + expect(screen.getByTestId("sidebar-nav-automations")).toBeDefined(); + expect(screen.getByTestId("sidebar-nav-import-tasks")).toBeDefined(); + expect(screen.getByTestId("sidebar-nav-workflows")).toBeDefined(); + expect(screen.queryByTestId("sidebar-nav-devserver")).toBeNull(); + const sidebar = screen.getByTestId("left-sidebar-nav"); expect(screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer")).not.toBeNull(); expect(within(sidebar).getAllByRole("button").at(-1)).toBe(screen.getByTestId("sidebar-nav-settings")); diff --git a/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx b/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx index f72b834948..ae074bfe1d 100644 --- a/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx +++ b/packages/dashboard/app/components/__tests__/overflowViewRegistry.test.tsx @@ -3,42 +3,63 @@ import { getVisibleOverflowViewEntries, STATIC_OVERFLOW_VIEW_ENTRIES } from "../ import type { PluginDashboardViewEntry } from "../../api"; describe("overflowViewRegistry", () => { - it("exposes exactly the six static right-dock tool destinations", () => { - const entries = getVisibleOverflowViewEntries(); + it("exposes the static right-dock tool destinations in order", () => { + // devserver/todos are gated by their isVisible flags; enable them to see the full static set. + const entries = getVisibleOverflowViewEntries({ + experimentalFeatures: { devServerView: true }, + todosEnabled: true, + }); const keys = entries.map((entry) => entry.key); - expect(keys).toEqual(["usage", "activity-log", "github-import", "git-manager", "files", "automation"]); - expect(entries.map((entry) => entry.label)).toEqual([ - "Activity", - "Activity Log", - "Import from GitHub", - "Git Manager", - "Files", - "Automation", - ]); - expect(entries.filter((entry) => entry.render).map((entry) => entry.key)).toEqual(["files"]); - expect(entries.filter((entry) => entry.onActivate).map((entry) => entry.key)).toEqual([ - "usage", + expect(keys).toEqual([ + "files", "activity-log", - "github-import", "git-manager", - "automation", + "devserver", + "secrets", + "todos", + "pull-requests", ]); + expect(entries.map((entry) => entry.label)).toEqual([ + "Files", + "Activity Log", + "Git Manager", + "Dev Server", + "Secrets", + "Todos", + "Pull Requests", + ]); + // Every static dock destination renders inline; none use onActivate launcher actions anymore. + expect(entries.filter((entry) => entry.render).map((entry) => entry.key)).toEqual(keys); + expect(entries.filter((entry) => entry.onActivate)).toEqual([]); }); - it("does not expose left-sidebar content views in the right-dock registry", () => { + it("hides flag-gated dock tools when their flags are off", () => { + const keys = getVisibleOverflowViewEntries().map((entry) => entry.key); + + // devserver requires experimentalFeatures.devServerView; todos requires todosEnabled. + expect(keys).toEqual(["files", "activity-log", "git-manager", "secrets", "pull-requests"]); + expect(keys).not.toContain("devserver"); + expect(keys).not.toContain("todos"); + // Usage moved back to the top header; it is no longer a right-dock key. + expect(keys).not.toContain("usage"); + }); + + it("does not expose left-sidebar content views or removed dock tools in the registry", () => { + // github-import and automation were moved off the dock into left-sidebar / main views. const removedKeys = [ "documents", "research", "insights", "skills", "memory", - "secrets", "stash-recovery", "evals", "goalsView", - "todos", - "devserver", + "github-import", + "automation", + // Usage moved back to the top header; it is no longer exposed as a dock key. + "usage", ]; const keys = getVisibleOverflowViewEntries({ experimentalFeatures: { @@ -57,6 +78,10 @@ describe("overflowViewRegistry", () => { for (const removedKey of removedKeys) { expect(keys).not.toContain(removedKey); } + // secrets, todos, pull-requests, devserver are now PRESENT dock tools. + for (const presentKey of ["secrets", "todos", "pull-requests", "devserver"]) { + expect(keys).toContain(presentKey); + } }); it("adds only non-primary plugin views after static tool entries", () => { @@ -75,17 +100,40 @@ describe("overflowViewRegistry", () => { }, ]; - const entries = getVisibleOverflowViewEntries({ pluginDashboardViews }); + const entries = getVisibleOverflowViewEntries({ + experimentalFeatures: { devServerView: true }, + todosEnabled: true, + pluginDashboardViews, + }); expect(entries.map((entry) => entry.key)).toEqual([ - "usage", - "activity-log", - "github-import", - "git-manager", "files", - "automation", + "activity-log", + "git-manager", + "devserver", + "secrets", + "todos", + "pull-requests", "plugin:plugin-b:audit", "plugin:plugin-a:tools", ]); expect(entries.some((entry) => entry.key === "plugin:plugin-a:primary")).toBe(false); }); + + it("excludes the dependency-graph plugin from the right dock", () => { + const pluginDashboardViews: PluginDashboardViewEntry[] = [ + { + pluginId: "fusion-plugin-dependency-graph", + view: { viewId: "graph", label: "Dependency Graph", placement: "overflow", order: 1 }, + }, + { + pluginId: "plugin-c", + view: { viewId: "report", label: "Report", placement: "overflow", order: 2 }, + }, + ]; + + const keys = getVisibleOverflowViewEntries({ pluginDashboardViews }).map((entry) => entry.key); + + expect(keys).not.toContain("plugin:fusion-plugin-dependency-graph:graph"); + expect(keys).toContain("plugin:plugin-c:report"); + }); }); diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index ae60aad315..29f049b516 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -1,6 +1,5 @@ import { Suspense, lazy, type ComponentType, type ReactNode } from "react"; import { - Activity, CheckSquare, Folder, GitBranch, @@ -19,7 +18,6 @@ import type { DetailTaskTab, PluginDashboardViewContext } from "../plugins/types import { DockFilesView } from "./DockFilesView"; import { PageErrorBoundary } from "./ErrorBoundary"; import { getPluginNavIcon } from "./pluginNavIcon"; -import { UsageIndicator } from "./UsageIndicator"; import { ActivityLogModal } from "./ActivityLogModal"; import { GitManagerModal } from "./GitManagerModal"; @@ -124,15 +122,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ testId: "right-dock-tab-files", render: (props) => wrapOverflowView(), }, - { - key: "usage", - label: "Activity", - icon: Activity, - testId: "right-dock-tab-usage", - render: (props) => wrapOverflowView( - {}} projectId={props.projectId} presentation="embedded" />, - ), - }, { key: "activity-log", label: "Activity Log", From cf702c9bbd337d72e43e68bf3508f0e8ec4abdbb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:55:36 -0700 Subject: [PATCH 065/265] fix(dashboard): taller Git Manager dock tab strip with narrower icon-over-label tabs Each section tab stacks its icon over a small label so all tabs are visible at once in the narrow dock with minimal horizontal scrolling. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/app/components/ScriptsModal.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 54f587b0c6..0a7071fecc 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -1986,29 +1986,36 @@ The embedded Git Manager adapts to its container width, not the viewport, so the flex-direction: column; } + /* + FNXC:GitManager 2026-06-22-01:00: + The dock tab strip is TALLER and each tab is NARROWER (icon stacked over a small label) so all section tabs are visible at once in the narrow dock without much horizontal scrolling. + */ .gm-modal--embedded .gm-sidebar { flex: 0 0 auto; flex-direction: row; width: 100%; min-width: 0; - min-height: 0; + min-height: calc(var(--space-2xl) + var(--space-lg)); border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; overflow-y: hidden; - padding: var(--space-xs); + padding: var(--space-xs) calc(var(--space-xs) / 2); gap: calc(var(--space-xs) / 2); } .gm-modal--embedded .gm-nav-item { flex: 0 0 auto; - flex-direction: row; - gap: var(--space-xs); - padding: calc(var(--space-xs) / 2) var(--space-xs); + flex-direction: column; + align-items: center; + justify-content: center; + gap: calc(var(--space-xs) / 2); + padding: var(--space-xs) calc(var(--space-xs) / 2); border-left: none; border-bottom: 2px solid transparent; min-width: 0; font-size: var(--font-size-xs); + line-height: 1.1; white-space: nowrap; } From 125c5f5744093c3e5c1accd0385239c009dbec2e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 02:00:12 -0700 Subject: [PATCH 066/265] feat(dashboard): TodoView nav-stack in narrow dock; taller scrollable import preview; shared ViewHeader - TodoView collapses to a single-panel navigation stack (list -> items with Back) via container query when narrow (right dock); wide two-panel layout unchanged. - Embedded Import Tasks body scrolls vertically so the preview can be much taller (stacked layout gives the preview natural height; wide layout keeps internal scroll). - Add a shared ViewHeader component (icon + CC-style title + wrapping actions) for consistent main-view headers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/GitHubImportModal.css | 11 +- .../dashboard/app/components/TodoView.css | 101 ++++++++++++++++++ .../dashboard/app/components/TodoView.tsx | 26 ++++- .../dashboard/app/components/ViewHeader.css | 42 ++++++++ .../dashboard/app/components/ViewHeader.tsx | 28 +++++ .../components/__tests__/TodoView.test.tsx | 1 + 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 packages/dashboard/app/components/ViewHeader.css create mode 100644 packages/dashboard/app/components/ViewHeader.tsx diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index a5bd773631..d36795aec5 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -997,6 +997,15 @@ Fix (embedded variant only — modal path untouched): turn the embedded root int container-name: github-import-embedded; } +/* +FNXC:RightDockEmbedding 2026-06-22-01:00: +Embedded Import Tasks must scroll vertically so a long preview is fully reachable. The view body is the scroll container; in the stacked (narrow) layout the preview takes its natural (content) height and the body scrolls, so the preview can be much taller than the viewport. In the wide two-pane layout the preview keeps its own internal scroll. +*/ +.github-import-modal--embedded .github-import-modal__body { + min-height: 0; + overflow-y: auto; +} + /* Default (narrow container): single stacked column. */ .github-import-modal--embedded .github-import-workspace { flex-direction: column; @@ -1011,7 +1020,7 @@ Fix (embedded variant only — modal path untouched): turn the embedded root int } .github-import-modal--embedded .github-import-preview-pane { - flex: 1 1 auto; + flex: 0 0 auto; width: 100%; min-width: 0; } diff --git a/packages/dashboard/app/components/TodoView.css b/packages/dashboard/app/components/TodoView.css index 7534ed5444..5efba6855a 100644 --- a/packages/dashboard/app/components/TodoView.css +++ b/packages/dashboard/app/components/TodoView.css @@ -14,6 +14,20 @@ FN-6829 mounts Todos as a flex child of .project-content like GoalsView; grow, z width: 100%; overflow: hidden; padding: var(--space-lg); + /* + FNXC:TodosStyling 2026-06-22-00:00: + TodoView renders both in the wide main area and inside the narrow right dock (no width prop). Make it a query container so the layout switch is driven by the actual rendered width, not a viewport media query or a prop. Below the container breakpoint the two-panel split collapses into a single-panel navigation stack (see `@container todo-view (max-width: 520px)`). + */ + container-type: inline-size; + container-name: todo-view; +} + +/* +FNXC:TodosStyling 2026-06-22-00:00: +The narrow-stack Back button is hidden by default (wide two-panel layout shows both panels, so there is nothing to go "back" to). The narrow container query reveals it. +*/ +.todo-mobile-back-btn { + display: none; } .todo-view-header { @@ -401,6 +415,93 @@ FN-6829 mounts Todos as a flex child of .project-content like GoalsView; grow, z } } +/* +FNXC:TodosStyling 2026-06-22-00:00: +NARROW container (right dock): collapse the side-by-side split into a single-panel navigation stack. Exactly one panel shows at a time, full-width with its own internal scroll and no horizontal overflow. `data-mobile-stack-view` (set by the component from `mobileStackView`) decides which panel is visible: "list" shows the master list-selection panel; "detail" shows the items panel with the Back button revealed. Tap targets are enlarged for touch. 520px is tuned to the content: below it the sidebar's fixed width plus the items pane no longer fit comfortably. +*/ +@container todo-view (max-width: 520px) { + .todo-view-layout { + flex-direction: column; + min-height: 0; + overflow: hidden; + gap: 0; + } + + /* Single panel at a time: full-width, owns its vertical scroll. */ + .todo-view-sidebar, + .todo-view-main { + width: 100%; + min-width: 0; + flex: 1 1 auto; + border-right: none; + padding-right: 0; + overflow-x: hidden; + overflow-y: auto; + } + + .todo-view-sidebar { + border-bottom: none; + } + + /* On the list panel, hide the items pane; on the detail panel, hide the list pane. */ + .todo-view-layout[data-mobile-stack-view="list"] .todo-view-main { + display: none; + } + + .todo-view-layout[data-mobile-stack-view="detail"] .todo-view-sidebar { + display: none; + } + + /* Reveal the Back affordance only in the narrow stack. */ + .todo-mobile-back-btn { + display: inline-flex; + } + + /* Comfortable touch targets and full-width add controls in the stack. */ + .todo-list-item, + .todo-list-select-btn, + .todo-add-list-btn, + .todo-icon-btn, + .todo-item, + .todo-item-reorder-btn, + .todo-add-item-row .btn { + min-height: calc(var(--space-2xl) + var(--space-xs)); + } + + .todo-list-item-actions, + .todo-item-actions { + opacity: 1; + } + + .todo-item-actions { + margin-left: 0; + } + + .todo-add-item-row { + flex-wrap: wrap; + } + + .todo-add-item-row .btn { + width: 100%; + } + + /* Anchor the agent picker to the full stack width to avoid horizontal overflow. */ + .todo-agent-picker-trigger { + position: static; + } + + .todo-agent-picker-dropdown { + left: 0; + right: 0; + min-width: 100%; + max-height: calc(var(--space-2xl) * 8); + } + + .todo-agent-picker-item { + min-height: calc(var(--space-2xl) + var(--space-xs)); + } +} + @media (max-width: 768px) { .todo-view { padding: var(--space-md); diff --git a/packages/dashboard/app/components/TodoView.tsx b/packages/dashboard/app/components/TodoView.tsx index 47e7f6acd7..952f24bb39 100644 --- a/packages/dashboard/app/components/TodoView.tsx +++ b/packages/dashboard/app/components/TodoView.tsx @@ -8,6 +8,7 @@ import { X, ChevronUp, ChevronDown, + ChevronLeft, Loader2, ListChecks, Bot, @@ -80,6 +81,12 @@ export function TodoView({ const agentPickerRef = useRef(null); const { confirm } = useConfirm(); + /* + FNXC:Todos 2026-06-22-00:00: + TodoView mounts in the narrow right dock (no width prop) where the two side-by-side panels (list selection + items) cannot fit. The layout switch is driven by a CSS container query on `.todo-view` (container-name: todo-view), NOT a prop. In the NARROW container we render a single-panel navigation stack: the master list-selection panel first, and selecting a list navigates forward to its items panel with a Back affordance. `mobileStackView` tracks which panel the narrow stack shows; the WIDE two-panel layout ignores it entirely (both panels always render). Selecting a list pushes to "detail"; Back returns to "list". + */ + const [mobileStackView, setMobileStackView] = useState<"list" | "detail">("list"); + const selectedList = useMemo( () => lists.find((list) => list.id === selectedListId) ?? null, [lists, selectedListId], @@ -106,6 +113,13 @@ export function TodoView({ resetListDraftState(); resetItemDraftState(); setSelectedListId(listId); + // FNXC:Todos 2026-06-22-00:00: Narrow stack navigates forward to the items panel on selection; no-op visually in the wide two-panel layout. + setMobileStackView("detail"); + } + + // FNXC:Todos 2026-06-22-00:00: Narrow-stack Back affordance returns to the master list-selection panel. Inert in the wide layout where both panels are always visible. + function handleMobileBack(): void { + setMobileStackView("list"); } const loadAgents = useCallback(async () => { @@ -329,7 +343,7 @@ export function TodoView({ return (
{header} -
+