feat(FN-2849): merge fusion/fn-2849-2

- **Node routing & diff visibility** (FN-2840, FN-2910): Strengthened node routing with improved task diff visibility; `resolve-diff-base.ts` now prefers `merge-base` over potentially stale `baseCommitSha`; added `unavailable-node-policy` and `node-override-guard` tests
- **Concurrency races fixed**: Executor and merger now synchronize worktree lifecycle more defensively; reviewer pause/unpause fixed against TOCTOU races in `concurrency.ts`
- **Richer merge commit messages**: `merger.ts` and `ai-summarize.ts` enhanced to generate AI-summarized merge messages via the summarizer pipeline
- **Memory dream improvements**: `memory-dreams.ts` reads assistant text from session state when processing dreams; added coverage for undefined output edge cases
- **QuickChatFAB UI**: New floating action button component with chat integration (226 lines TSX, 140 lines CSS); `useQuickChat.ts` refactored for better state management; tests added
- **Task changes display**: `ChangesDiffModal` and `TaskChangesTab` now surface only files actually modified by the task, not the full diff baseline
- **Self-healing hooks**: `self-healing.ts` extended with additional recovery logic for agent runtime
- **Documentation**: Added `docs/multi-project.md` (central registry, isolation modes), `docs/architecture.md` (node settings sync API), `docs/task-management.md` (archive/restore), `docs/cli-reference.md`; updated `docs/settings-reference.md`
- **Gitignore**: Allow committed `.changeset/` directories
- Merged in FN-2734 and FN-2957 as part of this branch

Commits merged:
- test(FN-2849): add node routing route coverage
- fix(engine): close executor/merger concurrency races and reviewer pause TOCTOU
- fix(dashboard): prefer merge-base over outdated baseCommitSha
- fix(tui): surface visible feedback when copying a log entry
- feat(FN-2840): strengthen node routing and task diff visibility
- feat(FN-2910): merge fusion/fn-2910
- fix(dashboard): show only files actually changed by the task
- test(memory): cover dream session-state extraction and undefined output
- fix(gitignore): allow committed changesets
- fix(memory): read assistant text from session state when processing dreams
- feat(merger): generate richer merge commit messages via AI summarizer
- feat(FN-2734): merge fusion/fn-2734
- feat(FN-2957): merge fusion/fn-2957

Files changed:
.gitignore                                         |   5 +
 docs/architecture.md                               |  46 ++++
 docs/cli-reference.md                              |  21 ++
 docs/dashboard-guide.md                            |  10 +-
 docs/multi-project.md                              |  47 ++++
 docs/settings-reference.md                         |  32 ++-
 docs/task-management.md                            |  64 ++++++
 packages/cli/src/commands/dashboard-tui/app.tsx    |  40 +++-
 .../cli/src/commands/dashboard-tui/controller.ts   |  18 ++
 packages/cli/src/commands/dashboard-tui/state.ts   |   5 +
 packages/core/src/__tests__/memory-dreams.test.ts  |  22 ++
 .../core/src/__tests__/node-override-guard.test.ts |  24 +++
 packages/core/src/__tests__/store.test.ts          |  27 +++
 .../core/src/__tests__/task-node-override.test.ts  |  25 +++
 .../src/__tests__/unavailable-node-policy.test.ts  |  42 ++++
 packages/core/src/ai-summarize.ts                  |  34 ++-
 packages/core/src/memory-dreams.ts                 |   7 +-
 packages/dashboard/app/api/legacy.ts               |   3 +-
 .../dashboard/app/components/ChangesDiffModal.css  |   2 +
 .../dashboard/app/components/ChangesDiffModal.tsx  |   5 +-
 packages/dashboard/app/components/QuickChatFAB.css | 140 +++++++++++-
 packages/dashboard/app/components/QuickChatFAB.tsx | 226 +++++++++++++++++++-
 .../dashboard/app/components/TaskChangesTab.css    |   5 +
 .../dashboard/app/components/TaskChangesTab.tsx    |   5 +-
 .../components/__tests__/ChangesDiffModal.test.tsx |  31 +++
 .../app/components/__tests__/QuickChatFAB.test.tsx | 133 +++++++++++-
 .../components/__tests__/TaskChangesTab.test.tsx   |  71 +++++++
 .../app/hooks/__tests__/useQuickChat.test.ts       |  12 +-
 packages/dashboard/app/hooks/useQuickChat.ts       | 149 +++++++------
 .../src/__tests__/routes-file-diffs.test.ts        | 165 ++++++++++++++
 packages/dashboard/src/__tests__/routes.test.ts    | 236 +++++++++++++++++++--
 .../src/routes/register-session-diff-routes.ts     |  54 ++---
 .../src/routes/register-settings-memory-routes.ts  |  23 +-
 packages/dashboard/src/routes/resolve-diff-base.ts |  72 ++++++-
 .../engine/src/__tests__/project-engine.test.ts    |   8 +-
 packages/engine/src/agent-runtime.ts               |  15 ++
 packages/engine/src/agent-session-helpers.ts       |  14 +-
 packages/engine/src/concurrency.ts                 |  37 +++-
 packages/engine/src/executor.ts                    | 183 +++++++++++-----
 packages/engine/src/merger.ts                      |  97 +++++++--
 packages/engine/src/pi.ts                          |  11 +
 packages/engine/src/project-engine.ts              |  47 +++-
 packages/engine/src/reviewer.ts                    |  95 +++++++--
 packages/engine/src/self-healing.ts                |   8 +
 packages/engine/src/triage.ts                      |  10 +-
 45 files changed, 2054 insertions(+), 272 deletions(-)

Fusion-Task-Id: FN-2849
This commit is contained in:
Fusion
2026-04-29 11:33:17 -07:00
committed by gsxdsm
parent abad18ac14
commit 78b5a1b7ae

View File

@@ -3234,6 +3234,105 @@ describe("PATCH /tasks/:id", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: null });
});
it("allows changing nodeId on a triage task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "triage",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, nodeId: "node-xyz" });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: "node-xyz" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: "node-xyz" });
});
it("allows changing nodeId on an in-review task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "in-review",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, nodeId: "node-xyz" });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: "node-xyz" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: "node-xyz" });
});
it("allows changing nodeId on a done task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "done",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, nodeId: "node-xyz" });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: "node-xyz" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: "node-xyz" });
});
it("allows clearing nodeId on a triage task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "triage",
nodeId: "node-old",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, nodeId: undefined });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: null }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: null });
});
it("allows clearing nodeId on an in-review task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "in-review",
nodeId: "node-old",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, nodeId: undefined });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: null }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: null });
});
it("allows clearing nodeId on a done task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "done",
nodeId: "node-old",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, nodeId: undefined });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: null }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: null });
});
it("returns 409 when clearing nodeId on an in-progress task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
@@ -13900,6 +13999,20 @@ describe("GET /settings", () => {
expect(res.body.githubTokenConfigured).toBeUndefined();
});
it("returns defaultNodeId and unavailableNodePolicy when configured", async () => {
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
defaultNodeId: "node-abc",
unavailableNodePolicy: "fallback-local",
});
const res = await GET(buildApp(), "/api/settings");
expect(res.status).toBe(200);
expect(res.body.defaultNodeId).toBe("node-abc");
expect(res.body.unavailableNodePolicy).toBe("fallback-local");
});
it("returns 500 on store error", async () => {
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Config read failed"));
@@ -13940,6 +14053,70 @@ describe("PUT /settings", () => {
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8 });
});
it("updates defaultNodeId when provided", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, defaultNodeId: "node-abc" };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ defaultNodeId: "node-abc" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ defaultNodeId: "node-abc" });
});
it("clears defaultNodeId when null is provided", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, defaultNodeId: undefined };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ defaultNodeId: null }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ defaultNodeId: null });
});
it("updates unavailableNodePolicy to fallback-local", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, unavailableNodePolicy: "fallback-local" };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ unavailableNodePolicy: "fallback-local" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ unavailableNodePolicy: "fallback-local" });
});
it("updates unavailableNodePolicy to block", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, unavailableNodePolicy: "block" };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ unavailableNodePolicy: "block" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ unavailableNodePolicy: "block" });
});
it("strips server-owned fields before calling store.updateSettings", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 4 };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);