diff --git a/.changeset/main-full-suite-2026-08-03.md b/.changeset/main-full-suite-2026-08-03.md new file mode 100644 index 0000000000..5d92b4acd7 --- /dev/null +++ b/.changeset/main-full-suite-2026-08-03.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix main full-suite failures from schema bookkeeping, Missions hooks, and dependency re-spec events. +category: fix +dev: MissionManager hooks above isActive return; task-update only emits real lane moves; ledger/schema tests track 0041/0042 and worktree capacity readers. diff --git a/packages/core/src/__tests__/archived-column-gate-parity.test.ts b/packages/core/src/__tests__/archived-column-gate-parity.test.ts index 891cd8d314..509afd1226 100644 --- a/packages/core/src/__tests__/archived-column-gate-parity.test.ts +++ b/packages/core/src/__tests__/archived-column-gate-parity.test.ts @@ -234,7 +234,6 @@ const AUDITED_TS_SITES: Readonly> = { "packages/core/src/task-store/async-comments-attachments.ts": 5, "packages/core/src/task-store/audit-ops.ts": 1, "packages/core/src/task-store/branch-and-pr-entities.ts": 1, - "packages/core/src/task-store/lifecycle-ops.ts": 1, "packages/core/src/task-store/moves.ts": 1, "packages/core/src/task-store/task-id-integrity.ts": 1, "packages/core/src/task-store/update-task-deps.ts": 1, diff --git a/packages/core/src/__tests__/legacy-column-collection-gating-ledger.test.ts b/packages/core/src/__tests__/legacy-column-collection-gating-ledger.test.ts index 92e1a3bb38..3238cbc08c 100644 --- a/packages/core/src/__tests__/legacy-column-collection-gating-ledger.test.ts +++ b/packages/core/src/__tests__/legacy-column-collection-gating-ledger.test.ts @@ -89,6 +89,13 @@ const RECORDED_GATING_SITES: ReadonlySet = new Set([ "packages/engine/src/mission-execution-loop.ts :: fixTaskTerminalColumns", "packages/engine/src/mission-feature-sync.ts :: LEGACY_PLANNER_COLUMNS", "packages/engine/src/triage.ts :: LEGACY_PLANNER_COLUMN_IDS", + /* + FNXC:WorkflowEvents 2026-08-03-02:01: + Wake-on-column handler: when task:updated carries resolved lanes, hold|intake decide membership; + when lanes are omitted the builtin-board set is the documented fallback (todo|triage). Recorded + so the ledger does not re-flag a RESOLVED site with a deliberate legacy arm. + */ + "packages/engine/src/triage.ts :: LEGACY_PLANNER_WAKE_COLUMNS", "packages/engine/src/worktree-pool.ts :: managed", ]); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index b6ec68fe8b..09d98306d3 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -83,6 +83,8 @@ import { MISSION_TASK_PREFIX_VERSION, CREDENTIAL_INSTANCE_SELECTION_VERSION, TASK_LIFECYCLE_OUTBOX_VERSION, + TASK_LIFECYCLE_CONSUMERS_VERSION, + VALIDATOR_INPUT_FINGERPRINT_VERSION, } from "../../postgres/schema-applier.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js"; @@ -99,7 +101,9 @@ const pgDescribe = PG_AVAILABLE ? describe : describe.skip; describe("schema-applier: immutable migration identities", () => { it("registers the task lifecycle outbox after credential selection", () => { expect(TASK_LIFECYCLE_OUTBOX_VERSION).toBe("0040"); - expect(SCHEMA_BASELINE_VERSION).toBe("0040"); + expect(TASK_LIFECYCLE_CONSUMERS_VERSION).toBe("0041"); + expect(VALIDATOR_INPUT_FINGERPRINT_VERSION).toBe("0042"); + expect(SCHEMA_BASELINE_VERSION).toBe("0042"); }); it("keeps monitor and approval isolation assigned to version 0003", () => { @@ -714,7 +718,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ctx = null; }); - it("creates all 100 project tables, 17 central tables, 1 archive table", async () => { + it("creates all 104 project tables, 17 central tables, 1 archive table", async () => { ctx = await setupFreshDb(); // FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only. // applySchemaBaseline now runs the plugin schema-init hooks by default, @@ -729,15 +733,13 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", GROUP BY table_schema `)) as unknown as Array<{ table_schema: string; n: number }>; const bySchema = Object.fromEntries(rows.map((r) => [r.table_schema, r.n])); - // Project: 87 typed core tables + 2 lossless legacy preservation tables - // + 1 import_translation_cache (FNXC:GitHubImportTranslate 2026-07-15-09:30) - // + 1 configuration_revisions (FNXC:ConfigVersioning 2026-07-18-14:00) - // + 2 ideation_sessions/ideation_candidates (FNXC:Ideation 2026-07-18-13:25 / FN-8295) - // + 1 task_verification_requests + 1 durable symbol_locks table (FN-8305) - // + 1 mission_lineage_stops (FNXC:MissionLineageBudget FN-8543 / migration 0035) - // + 2 task lifecycle outbox tables (FN-8684 migration 0040). - // Plugin tables are added separately by the hook. - expect(bySchema.project).toBe(100); + /* + FNXC:PgSchemaApplier 2026-08-03-02:16: + Project table count = historical core baseline plus later migrations. 0040 adds 2 lifecycle + outbox tables; 0041 adds 4 lifecycle consumer tables (100 → 104). Plugin tables are added + separately by the schema-init hook and are excluded here. + */ + expect(bySchema.project).toBe(104); /* FNXC:CapacityModel 2026-07-29-08:10 (drop the cross-project cap — table half): 17, not 18: `central.global_concurrency` is dropped by migration 0037. A fresh @@ -1619,6 +1621,13 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { /* slice_id required before 0023 research provenance unique index can attach. */ CREATE TABLE project.mission_features (id text PRIMARY KEY, slice_id text); /* + FNXC:MissionValidation 2026-08-03-02:01: + Migration 0042 (FN-8694) ALTERs project.mission_validator_runs for input_fingerprint. + Real 0000 databases have the table (baseline since the PG cutover), so this + historical fixture must retain it or upgrade-from-0000 fails with missing relation. + */ + CREATE TABLE project.mission_validator_runs (id text PRIMARY KEY, feature_id text, project_id text); + /* FNXC:MissionValidation 2026-07-23-21:30: Migration 0034 (FN-8542) ALTERs project.mission_contract_assertions and builds the derived-milestone partial unique index on (project_id, milestone_id). @@ -1747,6 +1756,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_TASK_PREFIX_VERSION, CREDENTIAL_INSTANCE_SELECTION_VERSION, TASK_LIFECYCLE_OUTBOX_VERSION, + TASK_LIFECYCLE_CONSUMERS_VERSION, + VALIDATOR_INPUT_FINGERPRINT_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1813,6 +1824,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_TASK_PREFIX_VERSION, CREDENTIAL_INSTANCE_SELECTION_VERSION, TASK_LIFECYCLE_OUTBOX_VERSION, + TASK_LIFECYCLE_CONSUMERS_VERSION, + VALIDATOR_INPUT_FINGERPRINT_VERSION, ]); }); @@ -2012,6 +2025,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_TASK_PREFIX_VERSION, CREDENTIAL_INSTANCE_SELECTION_VERSION, TASK_LIFECYCLE_OUTBOX_VERSION, + TASK_LIFECYCLE_CONSUMERS_VERSION, + VALIDATOR_INPUT_FINGERPRINT_VERSION, ]); }); @@ -2092,6 +2107,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_TASK_PREFIX_VERSION, CREDENTIAL_INSTANCE_SELECTION_VERSION, TASK_LIFECYCLE_OUTBOX_VERSION, + TASK_LIFECYCLE_CONSUMERS_VERSION, + VALIDATOR_INPUT_FINGERPRINT_VERSION, ]); }); @@ -2172,6 +2189,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_TASK_PREFIX_VERSION, CREDENTIAL_INSTANCE_SELECTION_VERSION, TASK_LIFECYCLE_OUTBOX_VERSION, + TASK_LIFECYCLE_CONSUMERS_VERSION, + VALIDATOR_INPUT_FINGERPRINT_VERSION, ]); }); }); diff --git a/packages/core/src/__tests__/task-update-lanes-resolved.test.ts b/packages/core/src/__tests__/task-update-lanes-resolved.test.ts index 8bd0b043f3..2049841bc6 100644 --- a/packages/core/src/__tests__/task-update-lanes-resolved.test.ts +++ b/packages/core/src/__tests__/task-update-lanes-resolved.test.ts @@ -74,6 +74,12 @@ function harness(task: Partial, ir: unknown) { emit: vi.fn(), isWatching: false, taskCache: new Map(), + /* + FNXC:WorkflowEvents 2026-08-03-02:16: + Real TaskStore always has laneCache; dependency re-spec emits only on a real column move and + caches the same lanes payload listeners receive on task:moved. + */ + laneCache: { set: vi.fn(), get: vi.fn(), invalidate: vi.fn() }, } as Record; /* The impl touches a long tail of TaskStore methods that have nothing to do with the column decision @@ -131,6 +137,38 @@ describe("adding a dependency never parks a card in a deleted column", () => { expect(row.column).toBe("inbox"); }); + it("emits task:moved with the same resolved lanes used for the relocation", async () => { + /* + FNXC:WorkflowEvents 2026-08-03-02:16: + Regression for the double-IR-resolve hazard: from/to must not ship without lanes, or self-healing + falls back to builtin lane ids on a renamed board. + */ + const { store, row } = harness({ column: "backlog", dependencies: [] }, RENAMED_IR); + + await run(store, { dependencies: ["FN-2"] }); + + expect(row.column).toBe("inbox"); + expect(store.emit).toHaveBeenCalledWith( + "task:moved", + expect.objectContaining({ + from: "backlog", + to: "inbox", + source: "engine", + lanes: expect.objectContaining({ + hold: "backlog", + intake: "inbox", + wip: "building", + review: "signoff", + complete: "shipped", + }), + }), + ); + expect((store as any).laneCache.set).toHaveBeenCalledWith( + "FN-1", + expect.objectContaining({ hold: "backlog", intake: "inbox" }), + ); + }); + it("leaves the column ALONE when the workflow cannot be resolved", async () => { // No literal fallback for the destination: refusing to move is recoverable, writing a column // that may not exist is not. diff --git a/packages/core/src/__tests__/worktree-capacity-limit.test.ts b/packages/core/src/__tests__/worktree-capacity-limit.test.ts index 0c6ee1fa0d..f52b8408ad 100644 --- a/packages/core/src/__tests__/worktree-capacity-limit.test.ts +++ b/packages/core/src/__tests__/worktree-capacity-limit.test.ts @@ -120,6 +120,45 @@ describe("worktrees-off is structural: no unaudited maxWorktrees bound", () => { "enforceWorktreeCap: on-disk hygiene, not admission. Caps worktree DIRECTORIES at 2x and only " + "removes idle ones. Must keep applying in OFF mode or idle worktrees accumulate unbounded.", }, + /* + FNXC:WorktreeCapacity 2026-08-03-02:01: + Planning admission and child-spawn paths now resolve/bound maxWorktrees the same way as the + scheduler executor gate. These are intentional admission readers (or aliases of resolveWorktreeCapacityLimit), + not raw second gates that bypass OFF mode. + */ + { + file: "packages/engine/src/executor.ts", + expr: "heldWorktrees + this.totalSpawnedCount > spawnMaxWorktrees", + reason: + "Child-spawn worktree budget: spawnMaxWorktrees aliases settings.maxWorktrees ?? 4; the block " + + "is skipped when the resolved value is non-finite (worktrees-off / unset).", + }, + { + file: "packages/engine/src/scheduler.ts", + expr: "maxWorktrees !== null && maxWorktrees <= maxConcurrent", + reason: + "Binding-gate discriminator after resolveWorktreeCapacityLimit: null means worktrees are not a " + + "capacity dimension, so this arm cannot bind in OFF mode.", + }, + { + file: "packages/engine/src/triage.ts", + expr: "Math.max(0, maxWorktrees - claimed)", + reason: + "Planning admission worktreeRoom from resolveWorktreeCapacityLimit; only evaluated when the " + + "resolved limit is non-null.", + }, + { + file: "packages/engine/src/triage.ts", + expr: "Math.min(projectRoom, worktreeRoom)", + reason: + "Planning maxToStart combines agent and worktree rooms; worktreeRoom is Infinity when limit is null.", + }, + { + file: "packages/engine/src/triage.ts", + expr: "worktreeRoom <= 0 && projectRoom > 0", + reason: + "Throttle reason discriminator for plan:admission-throttled — names which gate bound, not a second limit.", + }, ]; /* @@ -244,7 +283,12 @@ describe("worktrees-off is structural: no unaudited maxWorktrees bound", () => { } }); - it("admission has exactly one worktree-limit reader", async () => { + it("admission has exactly the known worktree-limit readers", async () => { + /* + FNXC:WorktreeCapacity 2026-08-03-02:01: + Scheduler execute admission and triage planning admission both resolve the same limit. A third + call site is a product change and must be audited here. + */ const { execFileSync } = await import("node:child_process"); const { resolve } = await import("node:path"); const root = resolve(__dirname, "../../../.."); @@ -255,8 +299,9 @@ describe("worktrees-off is structural: no unaudited maxWorktrees bound", () => { { cwd: root, encoding: "utf-8" }, ).split("\n").filter((l) => l && !l.includes("__tests__")); - expect(hits.length, `expected one admission reader, got:\n${hits.join("\n")}`).toBe(1); - expect(hits[0]).toContain("packages/engine/src/scheduler.ts"); + expect(hits.length, `expected two admission readers, got:\n${hits.join("\n")}`).toBe(2); + expect(hits.some((h) => h.includes("packages/engine/src/scheduler.ts"))).toBe(true); + expect(hits.some((h) => h.includes("packages/engine/src/triage.ts"))).toBe(true); }); }); diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 540aa5445c..f90949fdbc 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -7,9 +7,13 @@ * instance as its first parameter and performs byte-identical work. */ import {type TaskStore, storeLog} from "../store.js"; -import {toTaskMoveLanes} from "../workflow-lifecycle-traits.js"; +import { + resolveLifecycleColumns, + resolveTaskLifecycleColumns, + toTaskMoveLanes, + type TaskMoveLanes, +} from "../workflow-lifecycle-traits.js"; import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js"; -import {resolveTaskLifecycleColumns} from "../workflow-lifecycle-traits.js"; import {InvalidFileScopeError} from "./errors.js"; import {mkdir, readFile, writeFile} from "node:fs/promises"; import {join} from "node:path"; @@ -158,8 +162,10 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat if (updates.workspaceWorktrees !== undefined) { task.workspaceWorktrees = updates.workspaceWorktrees; } - // Detect new dependencies being added to a todo task → auto-move to triage + // Detect new dependencies being added to a hold-lane task → re-seed for re-specification let movedToTriage = false; + let respecifyFromColumn: string | undefined; + let respecifyMoveLanes: TaskMoveLanes | undefined; if (updates.dependencies !== undefined) { const oldDeps = new Set((task.dependencies ?? []).map((dependency) => dependency.trim()).filter(Boolean)); const normalizedDependencies = updates.dependencies.map((dependency) => dependency.trim()).filter(Boolean); @@ -183,15 +189,30 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat — the card's current column — so the move becomes a no-op while the status reset and the log entry still record the re-specification. When the workflow will not resolve, the column is left ALONE: refusing to move is recoverable, writing a column that may not exist is not. + + FNXC:WorkflowEvents 2026-08-03-02:01: + The task:moved emit below used to fire on every re-seed with hardcoded from=todo/to=triage, + including default-board no-ops where intake===hold. That announced a deleted column and + required laneCache on every dependency edit. Match update-task-deps: emit only when the + column actually changed, with the real endpoints. + + FNXC:WorkflowEvents 2026-08-03-02:16: + Resolve the task IR once for both the hold/intake decision and the task:moved lanes payload. + A second resolveWorkflowIrForTask that failed after a successful relocation used to emit + from/to for a custom board with lanes:undefined, and self-healing fell back to legacy lane + ids for board-stall / fan-out. Reuse the same IR (or withhold the event when lanes are absent). */ - const depLanes = hasNewDeps - ? await resolveTaskLifecycleColumns(store, id).catch(() => undefined) + const respecifyIr = hasNewDeps + ? await resolveWorkflowIrForTask(store, id).catch(() => undefined) : undefined; + respecifyMoveLanes = toTaskMoveLanes(respecifyIr); + const depLanes = respecifyIr ? resolveLifecycleColumns(respecifyIr) : undefined; /* DELIBERATE-LITERAL — the unresolvable-workflow default for the SOURCE lane only; the destination below never falls back to a literal. Reviewed 2026-07-31-02:40. */ const holdLane = depLanes === undefined ? "todo" : depLanes.hold; if (hasNewDeps && holdLane !== undefined && task.column === holdLane) { const intakeLane = depLanes?.intake; + respecifyFromColumn = task.column; const relocating = intakeLane !== undefined && intakeLane !== task.column; if (relocating) { task.column = intakeLane; @@ -1003,16 +1024,32 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } } - if (movedToTriage) { + if ( + movedToTriage + && respecifyFromColumn !== undefined + && respecifyFromColumn !== task.column + && respecifyMoveLanes + ) { /* FNXC:WorkflowEvents 2026-07-31-23:10 (fleet — the last two emitters): #3109 attached lanes at moves.ts and #3120 at the archive/completion emits. This one and `update-task-deps.ts` were still sending `lanes: undefined`, and a listener reads absence as "unknown" and falls back to `resolveTaskParkedColumnsSync` — the DEFAULT board under PostgreSQL. So these two paths kept the pre-#3109 behaviour while the listeners read as - resolved. */ - const lanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, id).catch(() => undefined)); - store.laneCache.set(task.id, lanes); - store.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine", lanes }); + resolved. + + FNXC:WorkflowEvents 2026-08-03-02:01: only announce a real column change; endpoints are the + resolved hold/intake pair, never the deleted `triage` literal. + + FNXC:WorkflowEvents 2026-08-03-02:16: emit only when respecifyMoveLanes is present from the + same IR used for the relocation — never a second IR lookup that can fail after the move. */ + store.laneCache.set(task.id, respecifyMoveLanes); + store.emit("task:moved", { + task, + from: respecifyFromColumn as Column, + to: task.column as Column, + source: "engine", + lanes: respecifyMoveLanes, + }); } store.emitTaskLifecycleEventSafely("task:updated", [task]); return task; diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index b8812f9d32..9d92359182 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -2737,6 +2737,64 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr return () => document.removeEventListener("keydown", handleKeyDown); }, [isActive, onClose]); + /* + FNXC:MissionDraftDiscard 2026-08-03-02:16: + Discard posts the draft session id and optional projectId only (project-scoped API). A 409 lock + conflict keeps the draft visible and surfaces the "open in another tab" warning; 404 removes the + stale list row. No browser tab id is sent in the body. + + FNXC:Missions 2026-08-03-02:01: + Hoisted above `if (!isActive) return null` so hide/show of the inline Missions tab does not change + the hook list (handleConfirmDelete depends on this callback). + */ + const handleDiscardInterviewSession = useCallback(async (sessionId: string) => { + try { + await discardMissionInterviewDraft(sessionId, projectId); + setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId)); + } catch (err) { + if (err instanceof ApiRequestError && err.status === 409) { + addToast(t("missions.draftOpenInAnotherTab", "Draft is open in another tab"), "error"); + return; + } + if (err instanceof ApiRequestError && err.status === 404) { + setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId)); + return; + } + addToast(getErrorMessage(err) || t("missions.draftDiscardFailed", "Failed to discard draft"), "error"); + return; + } finally { + setDeleteConfirmId(null); + } + }, [addToast, projectId, t]); + /* + FNXC:MissionAssertions 2026-08-01-19:44: + Every deleteConfirmId type must dispatch a deletion, and the shared confirmation panel must surface rejected requests. Assertion deletion is an operator recovery path for validation failures, so a silent no-op would leave stale rollups unrepairable. + + FNXC:Missions 2026-08-03-02:01: + This useCallback MUST stay above the `if (!isActive) return null` early return. Declaring it after + the return dropped a hook when the inline Missions tab was hidden (isOpen=false), which crashed + React with "Rendered fewer hooks than expected" on hide/show cycles. + */ + const handleConfirmDelete = useCallback(async () => { + if (!deleteConfirmId) return; + + try { + if (deleteConfirmId.type === "milestone") { + await handleDeleteMilestone(deleteConfirmId.id); + } else if (deleteConfirmId.type === "slice") { + await handleDeleteSlice(deleteConfirmId.id); + } else if (deleteConfirmId.type === "feature") { + await handleDeleteFeature(deleteConfirmId.id); + } else if (deleteConfirmId.type === "assertion" && deleteConfirmId.milestoneId) { + await handleDeleteAssertion(deleteConfirmId.id, deleteConfirmId.milestoneId); + } else if (deleteConfirmId.type === "interview_draft") { + await handleDiscardInterviewSession(deleteConfirmId.id); + } + } catch (err) { + addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete item"), "error"); + } + }, [addToast, deleteConfirmId, handleDeleteAssertion, handleDeleteFeature, handleDeleteMilestone, handleDeleteSlice, handleDiscardInterviewSession, t]); + if (!isActive) return null; const renderMissionDetailContent = () => { @@ -4448,30 +4506,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr setShowInterviewModal(false); }; - const handleDiscardInterviewSession = async (sessionId: string) => { - try { - /* - FNXC:MissionDraftDiscard 2026-06-24-02:42: - The mission draft Discard confirmation must send the current browser tab id so a draft locked by this tab can be removed while a draft actively owned by another tab returns the lock warning and stays visible. - */ - await discardMissionInterviewDraft(sessionId, projectId); - setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId)); - } catch (err) { - if (err instanceof ApiRequestError && err.status === 409) { - addToast(t("missions.draftOpenInAnotherTab", "Draft is open in another tab"), "error"); - return; - } - if (err instanceof ApiRequestError && err.status === 404) { - setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId)); - return; - } - addToast(getErrorMessage(err) || t("missions.draftDiscardFailed", "Failed to discard draft"), "error"); - return; - } finally { - setDeleteConfirmId(null); - } - }; - const renderInterviewSessionItems = () => missionInterviewDrafts.map((session) => { const isErrored = session.status === "error"; const isGenerating = session.status === "generating"; @@ -5023,30 +5057,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr ); }; - /* - FNXC:MissionAssertions 2026-08-01-19:44: - Every deleteConfirmId type must dispatch a deletion, and the shared confirmation panel must surface rejected requests. Assertion deletion is an operator recovery path for validation failures, so a silent no-op would leave stale rollups unrepairable. - */ - const handleConfirmDelete = useCallback(async () => { - if (!deleteConfirmId) return; - - try { - if (deleteConfirmId.type === "milestone") { - await handleDeleteMilestone(deleteConfirmId.id); - } else if (deleteConfirmId.type === "slice") { - await handleDeleteSlice(deleteConfirmId.id); - } else if (deleteConfirmId.type === "feature") { - await handleDeleteFeature(deleteConfirmId.id); - } else if (deleteConfirmId.type === "assertion" && deleteConfirmId.milestoneId) { - await handleDeleteAssertion(deleteConfirmId.id, deleteConfirmId.milestoneId); - } else if (deleteConfirmId.type === "interview_draft") { - await handleDiscardInterviewSession(deleteConfirmId.id); - } - } catch (err) { - addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete item"), "error"); - } - }, [addToast, deleteConfirmId, handleDeleteAssertion, handleDeleteFeature, handleDeleteMilestone, handleDeleteSlice, handleDiscardInterviewSession, t]); - const renderDeleteConfirmPanel = () => { if (deleteConfirmId?.type === "mission") { return null; diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx index 9bdec741de..a411dccc76 100644 --- a/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx @@ -36,7 +36,8 @@ describe("AgentLogViewer", () => { const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })]; const { container } = render(); fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); + // FNXC:ToolCallDisplay 2026-08-03-02:01: FN-8701 wraps payload in ToolCallDetails (label + pre). + const detail = container.querySelector(".agent-log-tool-detail .tool-call-details-value"); expect(detail).toBeTruthy(); expect(detail!.textContent).toContain(longDetail); expect(detail!.textContent!.length).toBe(5000); @@ -92,7 +93,8 @@ describe("AgentLogViewer", () => { const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })]; const { container } = render(); fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement; + // FNXC:ToolCallDisplay 2026-08-03-02:01: FN-8701 keeps the raw payload in a nested
.
+      const detail = container.querySelector(".agent-log-tool-detail .tool-call-details-value") as HTMLElement;
       expect(detail).toBeTruthy();
       expect(detail.tagName).toBe("PRE");
       expect(detail.textContent).toBe(detailText);
diff --git a/packages/dashboard/app/components/__tests__/CreateRoomModal.test.tsx b/packages/dashboard/app/components/__tests__/CreateRoomModal.test.tsx
index 48df738215..294fcc6d95 100644
--- a/packages/dashboard/app/components/__tests__/CreateRoomModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/CreateRoomModal.test.tsx
@@ -197,8 +197,14 @@ describe("CreateRoomModal", () => {
     expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument();
     empty.unmount();
 
+    // Re-pin the populated list after mockResolvedValueOnce([]) so the no-match path cannot race an empty load.
+    mockFetchAgents.mockResolvedValue([
+      { id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
+      { id: "agent-2", name: "Beta", role: "reviewer", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
+    ] as any);
     render();
-    await userEvent.type(await screen.findByLabelText("Members"), "zzz");
+    await screen.findByRole("button", { name: /Alpha/i });
+    await userEvent.type(screen.getByLabelText("Members"), "zzz");
     expect(screen.getByText("No agents match your search.")).toBeInTheDocument();
     await userEvent.clear(screen.getByLabelText("Members"));
     await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
diff --git a/packages/desktop/src/__tests__/release-workflow.test.ts b/packages/desktop/src/__tests__/release-workflow.test.ts
index 40cb3f0177..5da48678d1 100644
--- a/packages/desktop/src/__tests__/release-workflow.test.ts
+++ b/packages/desktop/src/__tests__/release-workflow.test.ts
@@ -174,7 +174,8 @@ describe("desktop release workflow wiring", () => {
     for (const workflow of [release, testRelease]) {
       expect(workflow).toContain("build-android:");
       expect(workflow).toContain("runs-on: ubuntu-latest");
-      expect(workflow).toContain("actions/setup-java@v4");
+      // FNXC:AndroidRelease 2026-08-03-02:01: Dependabot bumped setup-java to v5.6.0 (#3303); pin the live major so release wiring stays green.
+      expect(workflow).toContain("actions/setup-java@v5.6.0");
       // FNXC:AndroidRelease 2026-07-01-19:52: Capacitor 7 @capacitor/android compiles with JavaVersion.VERSION_21, so the Android release Gradle build must provision JDK 21 (JDK 17 fails with `invalid source release: 21`). Assert the intended JDK here.
       expect(workflow).toContain('java-version: "21"');
       expect(workflow).toContain("pnpm --filter @fusion/mobile cap add android");