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:
7
.changeset/main-full-suite-2026-08-03.md
Normal file
7
.changeset/main-full-suite-2026-08-03.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user