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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user