fix: restore main full-suite after schema, hooks, and ledger drift (#3312)

## 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 `<pre>`;
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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-08-02 19:27:49 -07:00
committed by GitHub
parent 5931e10345
commit 4a67883bb0
11 changed files with 248 additions and 77 deletions

View File

@@ -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.

View File

@@ -234,7 +234,6 @@ const AUDITED_TS_SITES: Readonly<Record<string, number>> = {
"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,

View File

@@ -89,6 +89,13 @@ const RECORDED_GATING_SITES: ReadonlySet<string> = 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",
]);

View File

@@ -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,
]);
});
});

View File

@@ -74,6 +74,12 @@ function harness(task: Partial<Task>, 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<string, unknown>;
/*
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.

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -36,7 +36,8 @@ describe("AgentLogViewer", () => {
const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
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(<AgentLogViewer entries={entries} loading={false} />);
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 <pre>.
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);

View File

@@ -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(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
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 }));

View File

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