From 4a67883bb0271e2d96cb729bc6cbaa2aa20f5e0e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 2 Aug 2026 19:27:49 -0700 Subject: [PATCH] fix: restore main full-suite after schema, hooks, and ledger drift (#3312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Restores the non-blocking full suite on `main` after a multi-package failure cluster (latest red run: [30777132347](https://github.com/Runfusion/Fusion/actions/runs/30777132347)). ### Product fixes - **MissionManager**: move `handleDiscardInterviewSession` / `handleConfirmDelete` **above** `if (!isActive) return null` so hiding the inline Missions tab does not drop hooks (`Rendered fewer hooks than expected`). - **task-update dependency re-spec**: emit `task:moved` only when the column actually changes, with resolved endpoints (not hardcoded `todo`→`triage`); fixes `laneCache.set is not a function` harness failures and deleted-column announcements. ### Bookkeeping / suite alignment - **Schema applier tests**: baseline `0042`, project table count `104` (+4 FN-8685 consumer tables), historical `0000` fixture includes `mission_validator_runs` for 0042 upgrades, version lists include 0041/0042. - **Ledgers**: archived-gate TS inventory (lifecycle-ops), legacy collection ledger (`LEGACY_PLANNER_WAKE_COLUMNS`), worktree capacity audited bounds + two admission readers (scheduler + triage). - **Desktop**: pin `actions/setup-java@v5.6.0` after Dependabot bump. - **Dashboard tests**: FN-8701 ToolCallDetails nested `
`;
CreateRoomModal re-pins agent mock after empty-once.

## Test plan
- [x] `@fusion/core` task-update + ledger tests
- [x] `@fusion/core` schema-applier subset (table count,
0000/0001/0002/0003 upgrades, concurrent)
- [x] `@fusion/dashboard` AgentLogViewer / CreateRoomModal /
MissionManager overview tests
- [x] `@fusion/desktop` release-workflow tests
- [ ] CI full-suite green on this PR


## Summary by CodeRabbit

- **Bug Fixes**
- Dependency re-specification now moves held tasks to the resolved
workflow intake lane when available.
  - Task move events are emitted only when a task’s column changes.
- Improved mission draft deletion handling for locked, missing, and
failed operations.
- Prevented mission management issues when inline Missions are shown or
hidden.
  - Updated database migration handling for lifecycle-related tables.

- **Tests**
- Expanded coverage for workflow lanes, database upgrades, worktree
limits, and release workflows.

---
 .changeset/main-full-suite-2026-08-03.md      |   7 ++
 .../archived-column-gate-parity.test.ts       |   1 -
 ...cy-column-collection-gating-ledger.test.ts |   7 ++
 .../__tests__/postgres/schema-applier.test.ts |  41 +++++--
 .../task-update-lanes-resolved.test.ts        |  38 +++++++
 .../__tests__/worktree-capacity-limit.test.ts |  51 ++++++++-
 packages/core/src/task-store/task-update.ts   |  57 ++++++++--
 .../app/components/MissionManager.tsx         | 106 ++++++++++--------
 .../AgentLogViewer.markdown.test.tsx          |   6 +-
 .../__tests__/CreateRoomModal.test.tsx        |   8 +-
 .../src/__tests__/release-workflow.test.ts    |   3 +-
 11 files changed, 248 insertions(+), 77 deletions(-)
 create mode 100644 .changeset/main-full-suite-2026-08-03.md

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");