diff --git a/.changeset/fn-9062-workspace-land-status.md b/.changeset/fn-9062-workspace-land-status.md new file mode 100644 index 0000000000..d43060a7ab --- /dev/null +++ b/.changeset/fn-9062-workspace-land-status.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show per-repository landing status and failure detail in workspace task details. +category: feature +dev: Adds `landFailure`, `workspace-land-failure.ts`, and `deriveWorkspaceRepoStatus`. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ce520922a4..a2c872740a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2452,3 +2452,7 @@ The shared Task Detail Definition view shows the persisted spec alignment, lates Saving or removing API keys, completing OAuth login/manual-code flows, logging out, and removing credential instances invalidate that registry's generation and clear both windows. If a credential change happens while an uncancellable refresh is already running, the model list temporarily serves its retained rows rather than overlapping the refresh. Once that old refresh settles, the first following request starts a current-credential refresh with no additional cache-window wait. The Memory view also includes a fourth **Knowledge Graph** tab. It provides capped search, node detail, edge and neighbor drill-down, bounded shortest-path navigation, and explicit artifact rebuilding for the deterministic project knowledge graph. + +### Workspace per-repository land status + +Task Detail shows each acquired workspace repository as **landed**, **pending**, or **failed**. Landed repositories include a short commit SHA; a partial land shows the aggregate landed count and retained task failure detail. The compact TaskCard chip deliberately remains count-only. The engine records a durable per-repository failure for both `landWorkspaceTask` failed-result branches and self-healing's unrecoverable partial-land park; busy leases, aborts, persist-after-advance recovery, and empty merges intentionally do not record one. Older partial-land rows and empty repositories remain pending, with any available failure detail shown only in the aggregate block. diff --git a/packages/core/src/types/task/task-core.ts b/packages/core/src/types/task/task-core.ts index b126823b86..7456e92b0a 100644 --- a/packages/core/src/types/task/task-core.ts +++ b/packages/core/src/types/task/task-core.ts @@ -722,8 +722,17 @@ export interface Task { * `revertBoundarySha` is the integration-branch commit after a completed git-mode revert * (the revert commit, or pre-revert HEAD when already reverted). A proven landing at or behind * it is stale and must re-land; `landedSha` remains for attribution and diff consumers. + * + * FNXC:Workspace 2026-08-15-07:05: + * `landFailure` is a display-only durable breadcrumb for dashboard per-repo status. It never + * participates in landed predicates, land control flow, retries, or park decisions, and a + * later `landedSha` supersedes it. Exactly the two `landWorkspaceTask` failed-result seams and + * self-healing's unrecoverable FORK-A park write it; busy, abort, persist-after-advance, and + * empty-merge paths deliberately do not. Legacy rows without it render pending because task + * error prose is never parsed for attribution. FN-9047/FN-9048 stale-state clearing must drop + * it alongside `landedSha`. */ - workspaceWorktrees?: Record; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 106786c1f9..e28d45fef3 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -3873,6 +3873,39 @@ Read-only list/placeholder only — not the deferred rich per-repo-status compon .workspace-worktrees-branch { color: var(--text-muted, inherit); } +.workspace-worktrees-status { + border-radius: var(--radius-sm); + padding: 0 var(--space-xs); + font-family: var(--font-mono, monospace); + font-weight: 600; +} +.workspace-worktrees-status--landed { + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 15%, transparent); +} +.workspace-worktrees-status--pending { + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 15%, transparent); +} +.workspace-worktrees-status--failed, +.workspace-worktrees-failure, +.workspace-worktrees-failure-message { + color: var(--color-error); + background: var(--status-error-bg); +} +.workspace-worktrees-failure, +.workspace-worktrees-failure-message { + padding: var(--space-xs); + border-radius: var(--radius-sm); +} +.workspace-worktrees-sha { + color: var(--color-success); +} +@media (max-width: 768px) { + .workspace-worktrees-item--wrapping { + gap: var(--space-xs); + } +} /* FNXC:TaskDetailTerminal 2026-07-10-00:00: diff --git a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx index 90625a96eb..eb66e6fbb5 100644 --- a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx +++ b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx @@ -2,91 +2,67 @@ 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)`. +FNXC:Workspace 2026-08-15-07:05: +Task Detail lifts the former flat-list ceiling with durable landed/pending/failed repository +status. TaskCard remains count-only because its dense layout cannot safely grow a per-repo list; +legacy and empty rows stay pending because task error prose cannot attribute a repository failure. */ -/** - * 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); } +type WorkspaceEntry = NonNullable[string]; +type WorkspaceStatus = "landed" | "pending" | "failed"; + +export function deriveWorkspaceRepoStatus( + entry: WorkspaceEntry, + repoRelPath: string, + mergeDetails?: Task["mergeDetails"], +): { status: WorkspaceStatus; landedSha?: string; failureMessage?: string } { + const landedSha = entry.landedSha ?? mergeDetails?.workspaceLandedShas?.[repoRelPath]; + if (landedSha) return { status: "landed", landedSha }; + if (entry.landFailure) return { status: "failed", failureMessage: entry.landFailure.message }; + return { status: "pending" }; +} + interface WorkspaceWorktreesSummaryProps { - task: Pick; - /** Compact variant for the dense TaskCard surface (placeholder only). */ + task: Pick; 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 }); + const statuses = repos.map(([repoRelPath, entry]) => ({ repoRelPath, entry, ...deriveWorkspaceRepoStatus(entry, repoRelPath, task.mergeDetails) })); + const landedCount = statuses.filter(({ status }) => status === "landed").length; + const hasStatusEvidence = statuses.some(({ status }) => status !== "pending"); + const fullyLanded = landedCount === repos.length; + const placeholder = hasStatusEvidence + ? t("tasks.workspaceReposLanded", "{{landed}} of {{count}} repos landed", { landed: landedCount, count: repos.length }) + : t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length }); if (compact) { - return ( -
- - {t("tasks.workspace", "Workspace")} - {placeholder} - -
- ); + return
{t("tasks.workspace", "Workspace")}{t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length })}
; } - return ( -
-
- {placeholder} -
-
    - {repos.map(([repoRelPath, info]) => ( -
  • - - {repoRelPath} - - - {info.worktreePath} - - - {info.branch} - -
  • - ))} -
-
- ); + return
+
{placeholder}
+ {!fullyLanded && task.error &&
{task.error}
} +
    + {statuses.map(({ repoRelPath, entry, status, landedSha, failureMessage }) =>
  • + {repoRelPath} + {status} + {landedSha && {landedSha.slice(0, 8)}} + {entry.worktreePath} + {entry.branch} + {failureMessage && {failureMessage}} +
  • )} +
+
; } diff --git a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx index dfd23f3875..0a474479d9 100644 --- a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; -import { WorkspaceWorktreesSummary, isWorkspaceTask } from "../WorkspaceWorktreesSummary"; +import { WorkspaceWorktreesSummary, deriveWorkspaceRepoStatus, isWorkspaceTask } from "../WorkspaceWorktreesSummary"; /* FNXC:Workspace 2026-06-21-00:00: @@ -73,6 +73,50 @@ describe("WorkspaceWorktreesSummary", () => { expect(screen.queryByText("/wt/repo-a")).toBeNull(); }); + it("renders landed, failed, and pending statuses with the partial-land detail", () => { + render(); + expect(screen.getByText("abcdef12")).toBeTruthy(); + expect(screen.getAllByTestId("workspace-repo-status-landed")).toHaveLength(1); + expect(screen.getAllByTestId("workspace-repo-status-failed")).toHaveLength(1); + expect(screen.getAllByTestId("workspace-repo-status-pending")).toHaveLength(1); + expect(screen.getByText("squash failed: conflict")).toBeTruthy(); + expect(screen.getByText(/1 of 3 repos landed/i)).toBeTruthy(); + expect(screen.getByTestId("workspace-partial-land-detail")).toHaveTextContent("Workspace partial-land failed"); + expect(screen.getByRole("list").firstElementChild).toHaveClass("workspace-worktrees-item--wrapping"); + }); + + it("renders legacy partial land failures as pending without parsing task.error", () => { + render(); + expect(screen.getByText("abcdef12")).toBeTruthy(); + expect(screen.getAllByTestId("workspace-repo-status-pending")).toHaveLength(1); + expect(screen.queryByTestId("workspace-repo-status-failed")).toBeNull(); + expect(screen.getByTestId("workspace-partial-land-detail")).toHaveTextContent("repo-b conflict"); + }); + + it("keeps compact mode free of per-repository status details", () => { + render(); + expect(screen.queryByTestId(/workspace-repo-status/)).toBeNull(); + expect(screen.queryByText("abcdef12")).toBeNull(); + expect(screen.queryByText("stale")).toBeNull(); + }); + + it("derives landed proof before failure, including finalize recovery proof", () => { + const failure = { message: "failed", at: "now" }; + expect(deriveWorkspaceRepoStatus({ worktreePath: "/wt", branch: "x", landedSha: "landed", landFailure: failure }, "repo-a")).toMatchObject({ status: "landed", landedSha: "landed" }); + expect(deriveWorkspaceRepoStatus({ worktreePath: "/wt", branch: "x", landFailure: failure }, "repo-a", { workspaceLandedShas: { "repo-a": "recovered" } })).toMatchObject({ status: "landed", landedSha: "recovered" }); + expect(deriveWorkspaceRepoStatus({ worktreePath: "/wt", branch: "x", landFailure: failure }, "repo-a")).toMatchObject({ status: "failed" }); + expect(deriveWorkspaceRepoStatus({ worktreePath: "/wt", branch: "x" }, "repo-a")).toMatchObject({ status: "pending" }); + }); + it("renders nothing for a single-repo task, leaving existing rendering unchanged", () => { const { container } = render(); expect(container.firstChild).toBeNull(); diff --git a/packages/engine/src/__tests__/self-healing-workspace.test.ts b/packages/engine/src/__tests__/self-healing-workspace.test.ts index 830baf3c84..1c9fbb5e35 100644 --- a/packages/engine/src/__tests__/self-healing-workspace.test.ts +++ b/packages/engine/src/__tests__/self-healing-workspace.test.ts @@ -440,6 +440,25 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => { expect(store.enqueued).not.toContain(TASK_ID); }); + it("FORK-A: persists failure breadcrumbs for every concurrently unrecoverable repo", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + // Both branches are absent and neither repository has a landing proof. + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: "landed-a" }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + const entries = store.tasks.get(TASK_ID)?.workspaceWorktrees; + + expect(n).toBe(1); + expect(store.tasks.get(TASK_ID)?.status).toBe("failed"); + expect(entries?.["repo-a"]?.landFailure).toMatchObject({ branch: BRANCH }); + expect(entries?.["repo-b"]?.landFailure).toMatchObject({ branch: BRANCH }); + }); + it("skips a restored fully-disposed workspace task after restore clears its map", async () => { /* FNXC:WorkspaceArchiveRestore 2026-08-15-05:39: diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index 960de43319..c9c16e5e02 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -275,6 +275,7 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, }); + (store.getTask as ReturnType).mockResolvedValue(task); const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { mergeAgent: squashMergeAgent(BRANCH), reviewAgent: approveReviewAgent, @@ -285,6 +286,11 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { expect(byRepo["repo-a"].status).toBe("landed"); expect(byRepo["repo-b"].status).toBe("failed"); expect(byRepo["repo-b"].error).toMatch(/conflict/i); + expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, expect.objectContaining({ + workspaceWorktrees: expect.objectContaining({ + "repo-b": expect.objectContaining({ landFailure: expect.objectContaining({ message: expect.stringMatching(/conflict/i), branch: BRANCH }) }), + }), + })); // Repo A landed locally (its ref advanced). expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore); diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index 8f2ee56fd2..80fbc34c63 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -106,6 +106,7 @@ module so self-healing can import the predicate without re-entering the self-hea import cycle (merger-ai-worktree imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing). */ import { isRepoLanded, findProvenLandedCommit, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js"; +import { persistWorkspaceRepoLandFailure } from "./workspace-land-failure.js"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { getCommitTaskOwnership, detectAlreadyLandedOnMain } from "./already-merged-detector.js"; import { resolveLegacyAiMergeRootPath } from "../worktree/worktree-paths.js"; @@ -2011,6 +2012,9 @@ export async function landWorkspaceTask( } catch (err: unknown) { const message = getErrorMessage(err); await log(`AI merge (workspace): failed to resolve integration branch for sub-repo ${repoRel}: ${message}`); + // FNXC:Workspace 2026-08-15-07:05: failure breadcrumbs are UI-only best effort; unlike + // landedSha, losing one cannot cause a double squash, so it must not affect this result. + await persistWorkspaceRepoLandFailure(store, taskId, repoRel, { message, at: new Date().toISOString(), branch: entry.branch }).catch(() => undefined); repos.push({ repo: repoRel, repoRootDir, integrationBranch: "", branch: entry.branch, status: "failed", error: message }); allLanded = false; break; @@ -2142,6 +2146,7 @@ export async function landWorkspaceTask( 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); + await persistWorkspaceRepoLandFailure(store, taskId, repoRel, { message, at: new Date().toISOString(), branch: entry.branch }).catch(() => undefined); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "failed", error: message }); allLanded = false; // Stop on first failure and return a partial result. The already-landed repos' @@ -2259,7 +2264,7 @@ async function persistRepoLandedSha( if (!entry) return; // entry vanished — nothing to merge into // FNXC:Workspace 2026-08-15-06:45: a new landing is strictly after its revert boundary, // so clear that invalidation marker while retaining the fresh landedSha as normal proof. - const next = { ...current, [repoRel]: { ...entry, landedSha, revertBoundarySha: undefined } }; + const next = { ...current, [repoRel]: { ...entry, landedSha, landFailure: undefined, revertBoundarySha: undefined } }; await store.updateTask(taskId, { workspaceWorktrees: next }); } diff --git a/packages/engine/src/merge/workspace-land-failure.ts b/packages/engine/src/merge/workspace-land-failure.ts new file mode 100644 index 0000000000..a011815618 --- /dev/null +++ b/packages/engine/src/merge/workspace-land-failure.ts @@ -0,0 +1,21 @@ +import type { TaskStore } from "@fusion/core"; + +/** + * FNXC:Workspace 2026-08-15-07:05: + * Persist a display-only per-repository landing failure without altering merge control flow. + * A fresh read preserves concurrent updates to sibling workspace entries and callers swallow errors. + */ +export async function persistWorkspaceRepoLandFailure( + store: TaskStore, + taskId: string, + repoRel: string, + failure: { message: string; at: string; branch?: string }, +): Promise { + const latest = await store.getTask(taskId); + const current = latest?.workspaceWorktrees ?? {}; + const entry = current[repoRel]; + if (!entry) return; + await store.updateTask(taskId, { + workspaceWorktrees: { ...current, [repoRel]: { ...entry, landFailure: failure } }, + }); +} diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index a0a3b34724..ec305dc495 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -94,6 +94,7 @@ imported it from merger-ai while merger-ai imports `MIN_TEMP_WORKTREE_REAP_AGE_M self-healing — a real import cycle. Importing from the predicate module breaks the cycle. */ import { isRepoLanded } from "./merge/workspace-land-predicate.js"; +import { persistWorkspaceRepoLandFailure } from "./merge/workspace-land-failure.js"; import { getCommitTaskOwnership } from "./merge/already-merged-detector.js"; import { getTaskCompletionBlockerForStore } from "./execution/task-completion.js"; import { shouldReclaimWedgedMerge } from "./merge/merge-reclaim-policy.js"; @@ -10172,6 +10173,21 @@ const movedTask = await this.store.moveTask(task.id, completeLane); if (unrecoverableRepos.length > 0) { // FORK-A: at least one repo is proven branch-gone and not 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.`; + /* + FNXC:Workspace 2026-08-15-07:17: + This is the third and final writer of the display-only failure breadcrumb. Persist each + repo sequentially because the helper fresh-reads then replaces the JSON map; concurrent + writes could otherwise lose sibling breadcrumbs. Best-effort persistence must never + influence FORK-A's pre-existing park classification or decision. + */ + for (const repoRel of unrecoverableRepos) { + const entry = workspaceWorktrees[repoRel]; + await persistWorkspaceRepoLandFailure(this.store, task.id, repoRel, { + message: "Workspace branch is gone and the repository is provably not landed; manual intervention required.", + at: new Date().toISOString(), + branch: entry?.branch, + }).catch(() => undefined); + } await this.store.updateTask(task.id, { status: "failed", error }); await this.store.logEntry(task.id, error); await auditor.database({