## Summary Adds the **Stash memory backend** (`memory.backendType=stash`) that connects Fusion's agent memory to the [Stash](https://github.com/Fergana-Labs/stash) product — *knowledge bases for the agent era* ([product site: joinstash.ai](https://joinstash.ai)). Fusion becomes a first-class Stash client: complete chat sessions and finished tasks are captured into Stash, memory is recalled during chat, and Stash sessions are kept in sync with the dashboard (including deletes and archival). **Product:** <https://github.com/Fergana-Labs/stash> · [joinstash.ai](https://joinstash.ai) ## What's included ### 1. Stash memory backend (RUFU-068 / RUFU-121) - New `StashMemoryBackend` (`memory.backendType=stash`) with `stashUrl` / `stashApiKey` settings (global secrets-store `stash-api-key` + per-project override). - **Complete-chat-session capture** keyed by ChatSession id. - Sessions are classified into **per-project folders** (get-or-create, `external_key fusion-<projectId>`, 1h per-process cache) and **soft-deleted with their chat** via `DELETE /api/chat/sessions/:id`. - Per-conversation **memory-focus** read-time scoping (new `0066_chat_session_memory_focus.sql` migration — sequence renumbered 0059→0060→0061→0065→0066 as origin/main claimed the lower numbers); event metadata enriched with `project` / `project_name` / `chat_title`. - Recall queries normalized to single-keyword / explicit-OR ASCII (≤100 chars); shared normalizer export reused by per-turn recall. ### 2. Per-task executor transcript capture (RUFU-122) Finished or failed tasks upload their executor transcript (`agent-log.jsonl`) to Stash as a task session. ### 3. Bulk archive Stash sync (RUFU-125) Archived task-planner chats soft-delete their Stash sessions on bulk archival (paged). The snapshot of doomed session ids is taken *before* the local bulk delete, and the Stash sync runs fire-and-forget so a Stash stall can never delay local archival. ### 4. Per-chat "Preserve to Stash" backfill (RUFU-136) A per-chat action that backfills a chat's full history into Stash, with client-side idempotency and a pre-check that skips already-uploaded content (fail-closed, no duplicate upload on transport failure). - **Session-folder naming fix:** the first project folder is now named "Fusion — <project name>" instead of the bare "Fusion" fallback (the backfill now resolves the central-registry project name, best-effort, never blocking the upload). ### 5. Opt-in semantic (vector) recall (RUFU-126) `stashVectorSearch` setting (default `false` — **zero behavior change until enabled**). For multi-word queries the backend tries Stash's semantic-search endpoint first, then falls back byte-identically to the keyword path. Definitive 404/405/501/503 responses are negatively cached per process. Requires a patched Stash server (new endpoint + `sentence-transformers` + embedding backfill); unpatched servers are transparently bypassed after the first 404. ## Safety - **Opt-in / inert by default:** the default backend remains `qmd`; the Stash backend is inert until `memoryBackendType=stash` + `stashUrl` are set. - All Stash I/O is **best-effort, fail-closed, and non-blocking** — a Stash outage never blocks chat, task completion, or archival. No run-audit content is emitted. ## Testing - Backfill + delete-sync suites (20/20), Stash backend suite (68/68), executor memory / session capture suites, `memory-focus-recalling`, description-guard — all green. - `tsc` clean across core / engine / dashboard. - Live verification: bulk backfill of 21/24 chats completed; the "Preserve to Stash" action is idempotent on re-run. ## Changesets - `@runfusion/fusion` **minor** — Stash memory backend + capture (RUFU-068/121), per-task transcript (RUFU-122), bulk archive sync (RUFU-125), per-chat backfill (RUFU-136), opt-in vector search (RUFU-126) - `@runfusion/fusion` **patch** — backfill session-folder naming fix ## Rebase Note (2026-08-23) Rebased onto `origin/main` `3f448f7292` (v0.77.0-beta.7). Conflicts resolved additively: - `packages/core/src/postgres/schema-applier.ts` + test — upstream's 0062-0065 migrations (task/subtask splitting removal, AI merge review reconciliation, task repository scope, FN-149 review convergence) unioned with this PR's `chat_sessions.memory_focus` migration, which is **renumbered 0065 → 0066** (upstream's FN-149 shipped 0065 canonically on origin/main); `SCHEMA_BASELINE_VERSION` advances to `0066`. - `packages/dashboard/app/components/ChatView.tsx` — upstream's docked chat sidebar resize handlers unioned with the RUFU-136 "Preserve to Stash" backfill handler. - New commit: `settings.memory.*` stash-backend i18n keys added to all 6 secondary locales (RUFU-121/122 parity fix; `pnpm i18n:status` no longer reports any violation introduced by this PR). **Deploy note (operator environments that already ran a pre-rebase build of this PR):** the memory-focus SQL may already be in the schema under ledger row `0065`. Remap that row to `0066` (`UPDATE fusion_schema_migrations SET version = '0066' WHERE version = '0065';`) *before* first boot of a 0066-ceiling binary — otherwise the fresh upstream `0065_fn_149_review_convergence_stage.sql` would be skipped as "already applied". Clean databases (no prior memory-focus row) need no action. **CI note — Lint (lifecycle-column census) is red on the merge base:** `pnpm check:lifecycle-columns --strict` fails identically on pure `origin/main` `3f448f7292` with `packages/core/src/db/legacy-adoption.ts: 0 -> 3` (3 column guards in the U9b legacy-adoption table without a baseline entry or `DELIBERATE-LITERAL` marker). Verified by running the census on a clean origin/main checkout — inherited from the base, not introduced by this PR. Fix belongs upstream; tracked separately. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Stash memory integration with project configuration and optional semantic search. * Added per-chat memory focus controls and a `/focus` command. * Added “Preserve to Stash” for uploading complete chat history. * Added automatic chat, task transcript, and completion-event capture. * Added project-specific Stash session folders and archive/delete synchronization. * **Bug Fixes** * Improved Stash folder naming and handling of missing branches during no-commit tasks. * **Documentation** * Added setup, configuration, integration, vector-search, and performance guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Fusion <noreply@runfusion.ai> Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
440 lines
18 KiB
TypeScript
440 lines
18 KiB
TypeScript
/**
|
|
* FNXC:StashSessionCapture 2026-08-19-04:37:
|
|
* RUFU-122 Step 3 tests: the pure task-transcript builder
|
|
* (buildTaskTranscriptEvents — agent-log.jsonl entries -> ordered Stash
|
|
* memory events) and the restructured triggerTaskMemoryCapture (transcript +
|
|
* terminal anchor in one captureMemory call, gated by the operator-fixed
|
|
* executorSessionCapture* settings). The deterministic seam mirrors the
|
|
* RUFU-068 capture tests: a partial @fusion/core mock with captureMemory as a
|
|
* spy, so no network write is ever attempted.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AgentLogEntry } from "@fusion/core";
|
|
import {
|
|
buildTaskTranscriptEvents,
|
|
triggerTaskMemoryCapture,
|
|
TRANSCRIPT_CONTENT_MAX_CHARS,
|
|
} from "../executor/memory-capture.js";
|
|
import { TaskExecutorGraphFacades } from "../executor/task-executor-graph-facades.js";
|
|
|
|
/**
|
|
* Partial mock: keep every real @fusion/core symbol except `captureMemory`, which becomes a
|
|
* deterministic spy so capture paths never attempt a real (or dangling-url) network write.
|
|
*/
|
|
const { mockCaptureMemory, mockHandleGraphFailureImpl } = vi.hoisted(() => ({
|
|
mockCaptureMemory: vi.fn(),
|
|
mockHandleGraphFailureImpl: vi.fn(),
|
|
}));
|
|
vi.mock("@fusion/core", async (importOriginal) => {
|
|
const mod = await importOriginal<Record<string, unknown>>();
|
|
return { ...mod, captureMemory: mockCaptureMemory };
|
|
});
|
|
|
|
/*
|
|
FNXC:StashSessionCapture 2026-08-19-06:55:
|
|
(RUFU-122 Step 4) The handleGraphFailure facade is the choke point for every
|
|
GRAPH-LEVEL terminal failure (drift parks, settings-load failures,
|
|
non-execute-node failures) that terminalizes the task `status: "failed"` OUTSIDE
|
|
runImplementation's post-loop finally. The facade must re-read the task and fire
|
|
the shared triggerTaskMemoryCapture exactly once when the fresh row is terminally
|
|
failed — and a completion-seam fire on the SAME capturedMemoryTaskIds Set after
|
|
that must be a no-op (at most once per task across seams). The impl's parking
|
|
behavior itself is covered by the handle-graph-failure tests, so only
|
|
handleGraphFailureImpl is stubbed here; captureMemory stays the @fusion/core spy.
|
|
*/
|
|
vi.mock("../executor/impl-bindings.js", async (importOriginal) => {
|
|
const mod = await importOriginal<Record<string, unknown>>();
|
|
return { ...mod, handleGraphFailureImpl: mockHandleGraphFailureImpl };
|
|
});
|
|
|
|
const TASK = { id: "FN-99", title: "Ship feature", status: "done" } as any;
|
|
const PROJECT = { project: "proj-123", project_name: "proj-alpha" };
|
|
|
|
function entry(type: AgentLogEntry["type"], text: string, i: number): AgentLogEntry {
|
|
return {
|
|
timestamp: `2026-08-19T04:00:${String(i).padStart(2, "0")}.000Z`,
|
|
taskId: "FN-99",
|
|
text,
|
|
type,
|
|
};
|
|
}
|
|
|
|
describe("buildTaskTranscriptEvents (pure builder, RUFU-122)", () => {
|
|
it("maps tool / tool_result / tool_error one-to-one with the settled content rules", () => {
|
|
const events = buildTaskTranscriptEvents(
|
|
[
|
|
entry("tool", "fn_task_list", 1),
|
|
entry("tool_result", "3 tasks found", 2),
|
|
entry("tool_error", "boom", 3),
|
|
],
|
|
"FN-99",
|
|
"done",
|
|
PROJECT,
|
|
);
|
|
expect(events).toHaveLength(3);
|
|
expect(events[0]).toMatchObject({ event_type: "tool_use", content: "fn_task_list", tool_name: "fn_task_list" });
|
|
expect(events[0].created_at).toBe("2026-08-19T04:00:01.000Z");
|
|
expect(events[1]).toMatchObject({ event_type: "tool_result", content: "3 tasks found" });
|
|
// The error marker is part of the stored content (operator requirement).
|
|
expect(events[2]).toMatchObject({ event_type: "tool_error", content: "ERROR: boom" });
|
|
expect(events[2].created_at).toBe("2026-08-19T04:00:03.000Z");
|
|
});
|
|
|
|
it("glues consecutive text deltas into ONE assistant_message (first-entry timestamp/line)", () => {
|
|
const events = buildTaskTranscriptEvents(
|
|
[
|
|
entry("text", "Hello ", 1),
|
|
entry("text", "world", 2),
|
|
entry("text", "!", 3),
|
|
entry("tool", "fn_task_done", 4),
|
|
],
|
|
"FN-99",
|
|
"done",
|
|
PROJECT,
|
|
);
|
|
expect(events).toHaveLength(2);
|
|
expect(events[0]).toMatchObject({ event_type: "assistant_message", content: "Hello world!" });
|
|
// created_at and line come from the RUN'S FIRST entry, not the last.
|
|
expect(events[0].created_at).toBe("2026-08-19T04:00:01.000Z");
|
|
expect(events[0].metadata).toEqual({
|
|
taskId: "FN-99",
|
|
status: "done",
|
|
line: 1,
|
|
project: "proj-123",
|
|
project_name: "proj-alpha",
|
|
});
|
|
expect(events[1]).toMatchObject({ event_type: "tool_use", content: "fn_task_done" });
|
|
expect(events[1].metadata?.line).toBe(4);
|
|
});
|
|
|
|
it("keeps non-adjacent text runs as separate events", () => {
|
|
const events = buildTaskTranscriptEvents(
|
|
[
|
|
entry("text", "A", 1),
|
|
entry("tool", "read", 2),
|
|
entry("text", "B", 3),
|
|
entry("text", "C", 4),
|
|
],
|
|
"FN-99",
|
|
"failed",
|
|
PROJECT,
|
|
);
|
|
expect(events.map((e) => e.event_type)).toEqual(["assistant_message", "tool_use", "assistant_message"]);
|
|
expect(events[2]).toMatchObject({ content: "BC" });
|
|
expect(events[2].created_at).toBe("2026-08-19T04:00:03.000Z");
|
|
// The status parameter flows into every event's metadata.
|
|
expect(events[0].metadata?.status).toBe("failed");
|
|
});
|
|
|
|
it("skips thinking entries and (by default) status entries", () => {
|
|
const events = buildTaskTranscriptEvents(
|
|
[
|
|
entry("thinking", "pondering", 1),
|
|
entry("status", "Step 1 complete", 2),
|
|
entry("text", "done", 3),
|
|
],
|
|
"FN-99",
|
|
"done",
|
|
PROJECT,
|
|
);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({ event_type: "assistant_message", content: "done" });
|
|
expect(events[0].metadata?.line).toBe(3);
|
|
});
|
|
|
|
it("truncates event content to 4000 chars client-side", () => {
|
|
const long = "x".repeat(TRANSCRIPT_CONTENT_MAX_CHARS + 500);
|
|
const events = buildTaskTranscriptEvents(
|
|
[entry("text", long, 1), entry("tool_error", "y".repeat(TRANSCRIPT_CONTENT_MAX_CHARS + 100), 2)],
|
|
"FN-99",
|
|
"done",
|
|
PROJECT,
|
|
);
|
|
expect(events[0].content).toHaveLength(TRANSCRIPT_CONTENT_MAX_CHARS);
|
|
// tool_error truncation applies AFTER the "ERROR: " prefix is prepended.
|
|
expect(events[1].content).toHaveLength(TRANSCRIPT_CONTENT_MAX_CHARS);
|
|
expect(events[1].content.startsWith("ERROR: ")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("triggerTaskMemoryCapture (RUFU-122 transcript + anchor)", () => {
|
|
beforeEach(() => {
|
|
mockCaptureMemory.mockReset();
|
|
mockCaptureMemory.mockResolvedValue({ ok: true, inserted: 1, deduped: 0 });
|
|
});
|
|
|
|
function runTrigger(
|
|
settings: Record<string, unknown>,
|
|
entries: AgentLogEntry[],
|
|
task: any = TASK,
|
|
storeExtras: Record<string, unknown> = {},
|
|
anchorKind: "completion" | "failure" = "completion",
|
|
) {
|
|
return triggerTaskMemoryCapture(
|
|
{
|
|
store: {
|
|
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash", ...settings }),
|
|
getSecretsStore: async () => ({
|
|
revealSecret: async () => ({ key: "stash-api-key", plaintextValue: "k" }),
|
|
}),
|
|
getAgentLogs: async () => entries,
|
|
...storeExtras,
|
|
} as any,
|
|
capturedMemoryTaskIds: new Set<string>(),
|
|
rootDir: "/home/schindler/git/proj-alpha",
|
|
},
|
|
task,
|
|
anchorKind,
|
|
);
|
|
}
|
|
|
|
// Alternating text/tool so no two text entries are adjacent — each text is its
|
|
// own run, so 10 entries produce exactly 10 transcript events (lines 1..10).
|
|
const TEN = Array.from({ length: 10 }, (_, i) =>
|
|
i % 2 === 0 ? entry("text", `say ${i + 1}`, i + 1) : entry("tool", `tool ${i + 1}`, i + 1),
|
|
);
|
|
|
|
it("uploads the transcript in front of a task_completion anchor in one call", async () => {
|
|
await runTrigger({}, TEN, TASK, { getWorkflowSettingsProjectId: () => "proj-123" });
|
|
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
|
|
const [, , sessionId, events, meta] = mockCaptureMemory.mock.calls[0];
|
|
expect(sessionId).toBe("fusion-task-FN-99");
|
|
expect(meta).toMatchObject({ taskId: "FN-99" });
|
|
expect(events).toHaveLength(11); // 10 transcript + 1 anchor
|
|
expect(events.slice(0, 10).every((e) => ["assistant_message", "tool_use"].includes(e.event_type))).toBe(true);
|
|
// The anchor is LAST.
|
|
expect(events[10]).toMatchObject({
|
|
event_type: "task_completion",
|
|
content: "Ship feature",
|
|
metadata: {
|
|
taskId: "FN-99",
|
|
status: "done",
|
|
project: "proj-123",
|
|
project_name: "proj-alpha", // basename(rootDir) fallback
|
|
},
|
|
});
|
|
// Per-event metadata shape + project identity (no runtime identity supplied).
|
|
expect(events[0].metadata).toEqual({
|
|
taskId: "FN-99",
|
|
status: "done",
|
|
line: 1,
|
|
project: "proj-123",
|
|
project_name: "proj-alpha",
|
|
});
|
|
});
|
|
|
|
it("emits a task_failure anchor for a failed task", async () => {
|
|
// The anchor kind is the SEAM's identity (terminal-failure seam), not a
|
|
// task.status read — the engine never writes status "done" onto the row.
|
|
await runTrigger({}, [entry("text", "x", 1)], { ...TASK, status: "failed" }, {}, "failure");
|
|
const events = mockCaptureMemory.mock.calls[0][3];
|
|
expect(events[events.length - 1]).toMatchObject({
|
|
event_type: "task_failure",
|
|
content: "Ship feature",
|
|
metadata: { status: "failed" },
|
|
});
|
|
});
|
|
|
|
it("no log: getAgentLogs -> [] captures exactly the anchor, warns, never throws", async () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
await expect(runTrigger({}, [])).resolves.toBeUndefined();
|
|
const events = mockCaptureMemory.mock.calls[0][3];
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].event_type).toBe("task_completion");
|
|
expect(warn).toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it("executorSessionCaptureEnabled: false -> anchor only, no transcript (and no log read)", async () => {
|
|
const getAgentLogs = vi.fn(async () => TEN);
|
|
await runTrigger({ executorSessionCaptureEnabled: false }, TEN, TASK, { getAgentLogs });
|
|
const events = mockCaptureMemory.mock.calls[0][3];
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].event_type).toBe("task_completion");
|
|
expect(getAgentLogs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("executorSessionCaptureIncludeStatus: true -> status events present in log order", async () => {
|
|
const entries = [
|
|
entry("text", "work", 1),
|
|
entry("status", "Step 1 done", 2),
|
|
entry("tool", "read", 3),
|
|
entry("status", "Step 2 done", 4),
|
|
];
|
|
await runTrigger({ executorSessionCaptureIncludeStatus: true }, entries);
|
|
const events = mockCaptureMemory.mock.calls[0][3];
|
|
expect(events.map((e) => e.event_type)).toEqual([
|
|
"assistant_message",
|
|
"status",
|
|
"tool_use",
|
|
"status",
|
|
"task_completion",
|
|
]);
|
|
const statusEvents = events.filter((e) => e.event_type === "status");
|
|
expect(statusEvents.map((e) => e.content)).toEqual(["Step 1 done", "Step 2 done"]);
|
|
expect(statusEvents[0].metadata?.line).toBe(2);
|
|
expect(statusEvents[1].metadata?.line).toBe(4);
|
|
});
|
|
|
|
it("max-events cap keeps the MOST RECENT N transcript events (tail) + anchor", async () => {
|
|
await runTrigger({ executorSessionCaptureMaxEvents: 5 }, TEN);
|
|
const events = mockCaptureMemory.mock.calls[0][3];
|
|
expect(events).toHaveLength(6); // 5 transcript (tail) + 1 anchor
|
|
// The tail is entries 6..10 (1-based lines 6..10).
|
|
expect(events.slice(0, 5).map((e) => e.metadata?.line)).toEqual([6, 7, 8, 9, 10]);
|
|
expect(events[5].event_type).toBe("task_completion");
|
|
});
|
|
|
|
it("shared gate: capture fires at most once across the complete + terminal-failure seams", async () => {
|
|
// The completion seam (signalTaskComplete) and the terminal-failure seam
|
|
// (run-implementation finally → signalTaskTerminalFailed) share ONE
|
|
// capturedMemoryTaskIds Set — the spec's at-most-once guarantee across
|
|
// seams lives here, in the trigger, not in either caller.
|
|
const capturedMemoryTaskIds = new Set<string>();
|
|
const store = {
|
|
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash" }),
|
|
getSecretsStore: async () => ({ revealSecret: async () => ({ key: "stash-api-key", plaintextValue: "k" }) }),
|
|
getAgentLogs: async () => [entry("text", "working...", 1)],
|
|
} as any;
|
|
const deps = { store, capturedMemoryTaskIds, rootDir: "/home/schindler/git/proj-alpha" };
|
|
|
|
// Completion seam fires first (anchor kind "completion")...
|
|
await triggerTaskMemoryCapture(deps, TASK, "completion");
|
|
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
|
|
|
|
// ...then the terminal-failure seam fires on the same task — the shared
|
|
// gate must suppress the second capture.
|
|
await triggerTaskMemoryCapture(deps, { ...TASK, status: "failed" }, "failure");
|
|
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
|
|
const first = mockCaptureMemory.mock.calls[0][3];
|
|
expect(first[first.length - 1].event_type).toBe("task_completion");
|
|
|
|
// Reverse order: a failure captured first is not re-captured on completion.
|
|
mockCaptureMemory.mockClear();
|
|
const reverseDeps = { store, capturedMemoryTaskIds: new Set<string>(), rootDir: "/home/schindler/git/proj-alpha" };
|
|
await triggerTaskMemoryCapture(reverseDeps, { ...TASK, status: "failed" }, "failure");
|
|
await triggerTaskMemoryCapture(reverseDeps, TASK, "completion");
|
|
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
|
|
const second = mockCaptureMemory.mock.calls[0][3];
|
|
expect(second[second.length - 1].event_type).toBe("task_failure");
|
|
});
|
|
|
|
it("does nothing when memory is disabled", async () => {
|
|
await triggerTaskMemoryCapture(
|
|
{
|
|
store: { getSettings: async () => ({ memoryEnabled: false }) } as any,
|
|
capturedMemoryTaskIds: new Set<string>(),
|
|
rootDir: "/proj",
|
|
},
|
|
TASK,
|
|
);
|
|
expect(mockCaptureMemory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing for a non-stash backend (no capture, no secret read)", async () => {
|
|
const getSecretsStore = vi.fn(async () => ({
|
|
revealSecret: async () => ({ key: "stash-api-key", plaintextValue: "k" }),
|
|
}));
|
|
await runTrigger({}, TEN, TASK, {
|
|
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "qmd" }),
|
|
getSecretsStore,
|
|
});
|
|
expect(mockCaptureMemory).not.toHaveBeenCalled();
|
|
expect(getSecretsStore).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/*
|
|
FNXC:StashSessionCapture 2026-08-19-06:55:
|
|
(RUFU-122 Step 4) Facade-level coverage for the GRAPH terminal-failure capture
|
|
seam (spec Step 4): the handleGraphFailure facade re-reads the task after
|
|
handleGraphFailureImpl resolves and, when the fresh row is terminally failed,
|
|
fires the same shared trigger the completion and in-run-failure seams use.
|
|
handleGraphFailureImpl is stubbed (its parking behavior is tested elsewhere);
|
|
the real triggerTaskMemoryCapture runs against the captureMemory spy so the
|
|
assertions are on the actual captured events, not on the seam call itself.
|
|
*/
|
|
describe("handleGraphFailure facade terminal-failure seam (RUFU-122)", () => {
|
|
// Concrete host for the abstract facade chain; only the one abstract method
|
|
// (getActiveWorktreePaths) needs a body — the capture paths never touch it.
|
|
class TestGraphFacades extends TaskExecutorGraphFacades {
|
|
protected getActiveWorktreePaths(_taskId: string): string[] { return []; }
|
|
handleGraphFailureForTest(task: any, result: any) { return this.handleGraphFailure(task, result); }
|
|
}
|
|
|
|
function makeFacade(store: any): { facade: TestGraphFacades; capturedMemoryTaskIds: Set<string> } {
|
|
const facade = new TestGraphFacades() as any;
|
|
facade.store = store;
|
|
facade.rootDir = "/home/schindler/git/proj-alpha";
|
|
return { facade, capturedMemoryTaskIds: facade.capturedMemoryTaskIds };
|
|
}
|
|
|
|
const captureStoreExtras = {
|
|
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash" }),
|
|
getSecretsStore: async () => ({
|
|
revealSecret: async () => ({ key: "stash-api-key", plaintextValue: "k" }),
|
|
}),
|
|
getAgentLogs: async () => [entry("text", "working...", 1)],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockHandleGraphFailureImpl.mockReset();
|
|
mockHandleGraphFailureImpl.mockResolvedValue(undefined);
|
|
mockCaptureMemory.mockReset();
|
|
mockCaptureMemory.mockResolvedValue({ ok: true, inserted: 1, deduped: 0 });
|
|
});
|
|
|
|
it("fires the shared capture exactly once for a fresh failed task; a later completion seam is suppressed", async () => {
|
|
const FAILED = { ...TASK, status: "failed" };
|
|
const store = { getTask: async () => FAILED, ...captureStoreExtras } as any;
|
|
const { facade, capturedMemoryTaskIds } = makeFacade(store);
|
|
|
|
const result = await facade.handleGraphFailureForTest(FAILED, undefined);
|
|
expect(result).toBeUndefined();
|
|
expect(mockHandleGraphFailureImpl).toHaveBeenCalledTimes(1);
|
|
// The facade fires the trigger fire-and-forget (never blocks the graph-failure
|
|
// outcome), so the captureMemory write lands a few microtasks later — wait for it.
|
|
await vi.waitFor(() => expect(mockCaptureMemory).toHaveBeenCalledTimes(1));
|
|
expect(mockCaptureMemory.mock.calls[0][2]).toBe("fusion-task-FN-99");
|
|
const events = mockCaptureMemory.mock.calls[0][3];
|
|
expect(events[events.length - 1]).toMatchObject({
|
|
event_type: "task_failure",
|
|
content: "Ship feature",
|
|
metadata: { status: "failed" },
|
|
});
|
|
|
|
// The completion seam fires for the same task id on the SAME gate set the
|
|
// facade's deps bag read from the host — the shared gate suppresses it.
|
|
await triggerTaskMemoryCapture(
|
|
{ store, capturedMemoryTaskIds, rootDir: "/home/schindler/git/proj-alpha" },
|
|
TASK,
|
|
"completion",
|
|
);
|
|
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not fire capture when the fresh task is not terminally failed", async () => {
|
|
const PAUSED = { ...TASK, status: "paused" };
|
|
const store = { getTask: async () => PAUSED, ...captureStoreExtras } as any;
|
|
const { facade } = makeFacade(store);
|
|
|
|
await facade.handleGraphFailureForTest(PAUSED, undefined);
|
|
expect(mockHandleGraphFailureImpl).toHaveBeenCalledTimes(1);
|
|
expect(mockCaptureMemory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never alters the graph-failure outcome when the capture read throws", async () => {
|
|
const FAILED = { ...TASK, status: "failed" };
|
|
const store = {
|
|
getTask: async () => { throw new Error("store down"); },
|
|
...captureStoreExtras,
|
|
} as any;
|
|
const { facade } = makeFacade(store);
|
|
mockHandleGraphFailureImpl.mockResolvedValue("impl-sentinel");
|
|
|
|
const result = await facade.handleGraphFailureForTest(FAILED, undefined);
|
|
expect(result).toBe("impl-sentinel");
|
|
expect(mockCaptureMemory).not.toHaveBeenCalled();
|
|
});
|
|
});
|