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/async-comments-attachments.ts": 5,
|
||||||
"packages/core/src/task-store/audit-ops.ts": 1,
|
"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/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/moves.ts": 1,
|
||||||
"packages/core/src/task-store/task-id-integrity.ts": 1,
|
"packages/core/src/task-store/task-id-integrity.ts": 1,
|
||||||
"packages/core/src/task-store/update-task-deps.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-execution-loop.ts :: fixTaskTerminalColumns",
|
||||||
"packages/engine/src/mission-feature-sync.ts :: LEGACY_PLANNER_COLUMNS",
|
"packages/engine/src/mission-feature-sync.ts :: LEGACY_PLANNER_COLUMNS",
|
||||||
"packages/engine/src/triage.ts :: LEGACY_PLANNER_COLUMN_IDS",
|
"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",
|
"packages/engine/src/worktree-pool.ts :: managed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ import {
|
|||||||
MISSION_TASK_PREFIX_VERSION,
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
||||||
TASK_LIFECYCLE_OUTBOX_VERSION,
|
TASK_LIFECYCLE_OUTBOX_VERSION,
|
||||||
|
TASK_LIFECYCLE_CONSUMERS_VERSION,
|
||||||
|
VALIDATOR_INPUT_FINGERPRINT_VERSION,
|
||||||
} from "../../postgres/schema-applier.js";
|
} from "../../postgres/schema-applier.js";
|
||||||
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
||||||
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.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", () => {
|
describe("schema-applier: immutable migration identities", () => {
|
||||||
it("registers the task lifecycle outbox after credential selection", () => {
|
it("registers the task lifecycle outbox after credential selection", () => {
|
||||||
expect(TASK_LIFECYCLE_OUTBOX_VERSION).toBe("0040");
|
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", () => {
|
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;
|
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();
|
ctx = await setupFreshDb();
|
||||||
// FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only.
|
// FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only.
|
||||||
// applySchemaBaseline now runs the plugin schema-init hooks by default,
|
// 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
|
GROUP BY table_schema
|
||||||
`)) as unknown as Array<{ table_schema: string; n: number }>;
|
`)) as unknown as Array<{ table_schema: string; n: number }>;
|
||||||
const bySchema = Object.fromEntries(rows.map((r) => [r.table_schema, r.n]));
|
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)
|
FNXC:PgSchemaApplier 2026-08-03-02:16:
|
||||||
// + 1 configuration_revisions (FNXC:ConfigVersioning 2026-07-18-14:00)
|
Project table count = historical core baseline plus later migrations. 0040 adds 2 lifecycle
|
||||||
// + 2 ideation_sessions/ideation_candidates (FNXC:Ideation 2026-07-18-13:25 / FN-8295)
|
outbox tables; 0041 adds 4 lifecycle consumer tables (100 → 104). Plugin tables are added
|
||||||
// + 1 task_verification_requests + 1 durable symbol_locks table (FN-8305)
|
separately by the schema-init hook and are excluded here.
|
||||||
// + 1 mission_lineage_stops (FNXC:MissionLineageBudget FN-8543 / migration 0035)
|
*/
|
||||||
// + 2 task lifecycle outbox tables (FN-8684 migration 0040).
|
expect(bySchema.project).toBe(104);
|
||||||
// Plugin tables are added separately by the hook.
|
|
||||||
expect(bySchema.project).toBe(100);
|
|
||||||
/*
|
/*
|
||||||
FNXC:CapacityModel 2026-07-29-08:10 (drop the cross-project cap — table half):
|
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
|
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. */
|
/* slice_id required before 0023 research provenance unique index can attach. */
|
||||||
CREATE TABLE project.mission_features (id text PRIMARY KEY, slice_id text);
|
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:
|
FNXC:MissionValidation 2026-07-23-21:30:
|
||||||
Migration 0034 (FN-8542) ALTERs project.mission_contract_assertions and builds
|
Migration 0034 (FN-8542) ALTERs project.mission_contract_assertions and builds
|
||||||
the derived-milestone partial unique index on (project_id, milestone_id).
|
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,
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
||||||
TASK_LIFECYCLE_OUTBOX_VERSION,
|
TASK_LIFECYCLE_OUTBOX_VERSION,
|
||||||
|
TASK_LIFECYCLE_CONSUMERS_VERSION,
|
||||||
|
VALIDATOR_INPUT_FINGERPRINT_VERSION,
|
||||||
]);
|
]);
|
||||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -1813,6 +1824,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
MISSION_TASK_PREFIX_VERSION,
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
||||||
TASK_LIFECYCLE_OUTBOX_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,
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
||||||
TASK_LIFECYCLE_OUTBOX_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,
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
||||||
TASK_LIFECYCLE_OUTBOX_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,
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
CREDENTIAL_INSTANCE_SELECTION_VERSION,
|
||||||
TASK_LIFECYCLE_OUTBOX_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(),
|
emit: vi.fn(),
|
||||||
isWatching: false,
|
isWatching: false,
|
||||||
taskCache: new Map(),
|
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>;
|
} as Record<string, unknown>;
|
||||||
/*
|
/*
|
||||||
The impl touches a long tail of TaskStore methods that have nothing to do with the column decision
|
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");
|
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 () => {
|
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
|
// No literal fallback for the destination: refusing to move is recoverable, writing a column
|
||||||
// that may not exist is not.
|
// 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 "
|
"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.",
|
+ "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 { execFileSync } = await import("node:child_process");
|
||||||
const { resolve } = await import("node:path");
|
const { resolve } = await import("node:path");
|
||||||
const root = resolve(__dirname, "../../../..");
|
const root = resolve(__dirname, "../../../..");
|
||||||
@@ -255,8 +299,9 @@ describe("worktrees-off is structural: no unaudited maxWorktrees bound", () => {
|
|||||||
{ cwd: root, encoding: "utf-8" },
|
{ cwd: root, encoding: "utf-8" },
|
||||||
).split("\n").filter((l) => l && !l.includes("__tests__"));
|
).split("\n").filter((l) => l && !l.includes("__tests__"));
|
||||||
|
|
||||||
expect(hits.length, `expected one admission reader, got:\n${hits.join("\n")}`).toBe(1);
|
expect(hits.length, `expected two admission readers, got:\n${hits.join("\n")}`).toBe(2);
|
||||||
expect(hits[0]).toContain("packages/engine/src/scheduler.ts");
|
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.
|
* instance as its first parameter and performs byte-identical work.
|
||||||
*/
|
*/
|
||||||
import {type TaskStore, storeLog} from "../store.js";
|
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 {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js";
|
||||||
import {resolveTaskLifecycleColumns} from "../workflow-lifecycle-traits.js";
|
|
||||||
import {InvalidFileScopeError} from "./errors.js";
|
import {InvalidFileScopeError} from "./errors.js";
|
||||||
import {mkdir, readFile, writeFile} from "node:fs/promises";
|
import {mkdir, readFile, writeFile} from "node:fs/promises";
|
||||||
import {join} from "node:path";
|
import {join} from "node:path";
|
||||||
@@ -158,8 +162,10 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
|
|||||||
if (updates.workspaceWorktrees !== undefined) {
|
if (updates.workspaceWorktrees !== undefined) {
|
||||||
task.workspaceWorktrees = updates.workspaceWorktrees;
|
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 movedToTriage = false;
|
||||||
|
let respecifyFromColumn: string | undefined;
|
||||||
|
let respecifyMoveLanes: TaskMoveLanes | undefined;
|
||||||
if (updates.dependencies !== undefined) {
|
if (updates.dependencies !== undefined) {
|
||||||
const oldDeps = new Set((task.dependencies ?? []).map((dependency) => dependency.trim()).filter(Boolean));
|
const oldDeps = new Set((task.dependencies ?? []).map((dependency) => dependency.trim()).filter(Boolean));
|
||||||
const normalizedDependencies = updates.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
|
— 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
|
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.
|
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
|
const respecifyIr = hasNewDeps
|
||||||
? await resolveTaskLifecycleColumns(store, id).catch(() => undefined)
|
? await resolveWorkflowIrForTask(store, id).catch(() => undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
respecifyMoveLanes = toTaskMoveLanes(respecifyIr);
|
||||||
|
const depLanes = respecifyIr ? resolveLifecycleColumns(respecifyIr) : undefined;
|
||||||
/* DELIBERATE-LITERAL — the unresolvable-workflow default for the SOURCE lane only; the
|
/* 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. */
|
destination below never falls back to a literal. Reviewed 2026-07-31-02:40. */
|
||||||
const holdLane = depLanes === undefined ? "todo" : depLanes.hold;
|
const holdLane = depLanes === undefined ? "todo" : depLanes.hold;
|
||||||
if (hasNewDeps && holdLane !== undefined && task.column === holdLane) {
|
if (hasNewDeps && holdLane !== undefined && task.column === holdLane) {
|
||||||
const intakeLane = depLanes?.intake;
|
const intakeLane = depLanes?.intake;
|
||||||
|
respecifyFromColumn = task.column;
|
||||||
const relocating = intakeLane !== undefined && intakeLane !== task.column;
|
const relocating = intakeLane !== undefined && intakeLane !== task.column;
|
||||||
if (relocating) {
|
if (relocating) {
|
||||||
task.column = intakeLane;
|
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):
|
/* 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
|
#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
|
`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
|
"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
|
PostgreSQL. So these two paths kept the pre-#3109 behaviour while the listeners read as
|
||||||
resolved. */
|
resolved.
|
||||||
const lanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, id).catch(() => undefined));
|
|
||||||
store.laneCache.set(task.id, lanes);
|
FNXC:WorkflowEvents 2026-08-03-02:01: only announce a real column change; endpoints are the
|
||||||
store.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine", lanes });
|
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]);
|
store.emitTaskLifecycleEventSafely("task:updated", [task]);
|
||||||
return task;
|
return task;
|
||||||
|
|||||||
@@ -2737,6 +2737,64 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [isActive, onClose]);
|
}, [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;
|
if (!isActive) return null;
|
||||||
|
|
||||||
const renderMissionDetailContent = () => {
|
const renderMissionDetailContent = () => {
|
||||||
@@ -4448,30 +4506,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
setShowInterviewModal(false);
|
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 renderInterviewSessionItems = () => missionInterviewDrafts.map((session) => {
|
||||||
const isErrored = session.status === "error";
|
const isErrored = session.status === "error";
|
||||||
const isGenerating = session.status === "generating";
|
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 = () => {
|
const renderDeleteConfirmPanel = () => {
|
||||||
if (deleteConfirmId?.type === "mission") {
|
if (deleteConfirmId?.type === "mission") {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ describe("AgentLogViewer", () => {
|
|||||||
const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })];
|
const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
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).toBeTruthy();
|
||||||
expect(detail!.textContent).toContain(longDetail);
|
expect(detail!.textContent).toContain(longDetail);
|
||||||
expect(detail!.textContent!.length).toBe(5000);
|
expect(detail!.textContent!.length).toBe(5000);
|
||||||
@@ -92,7 +93,8 @@ describe("AgentLogViewer", () => {
|
|||||||
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })];
|
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
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).toBeTruthy();
|
||||||
expect(detail.tagName).toBe("PRE");
|
expect(detail.tagName).toBe("PRE");
|
||||||
expect(detail.textContent).toBe(detailText);
|
expect(detail.textContent).toBe(detailText);
|
||||||
|
|||||||
@@ -197,8 +197,14 @@ describe("CreateRoomModal", () => {
|
|||||||
expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument();
|
expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument();
|
||||||
empty.unmount();
|
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()} />);
|
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();
|
expect(screen.getByText("No agents match your search.")).toBeInTheDocument();
|
||||||
await userEvent.clear(screen.getByLabelText("Members"));
|
await userEvent.clear(screen.getByLabelText("Members"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
|
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]) {
|
for (const workflow of [release, testRelease]) {
|
||||||
expect(workflow).toContain("build-android:");
|
expect(workflow).toContain("build-android:");
|
||||||
expect(workflow).toContain("runs-on: ubuntu-latest");
|
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.
|
// 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('java-version: "21"');
|
||||||
expect(workflow).toContain("pnpm --filter @fusion/mobile cap add android");
|
expect(workflow).toContain("pnpm --filter @fusion/mobile cap add android");
|
||||||
|
|||||||
Reference in New Issue
Block a user