fix(dashboard): Stop cancels pending initial planning turns per-session

stopGeneration only aborted generations with an activeGenerations record.
A just-started session whose initial turn was still pending (registered
by start-streaming, not yet consumed by a stream connect) returned false
from Stop and the "stopped" generation sprang back to life on the next
stream connect. Stop now discards the pending turn too, and remains
strictly keyed to its session id so stopping one plan never affects other
concurrently generating sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 18:52:19 -07:00
parent 370a7a645d
commit 0e6108a1a5
2 changed files with 50 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Stopping a plan now also cancels generations that haven't started streaming yet.
category: fix
dev: `stopGeneration` discards a still-pending initial turn (registered by start-streaming but not yet consumed by a stream connect) instead of returning false and letting the "stopped" generation restart on the next connect; stops remain strictly per-session when multiple plans generate concurrently.

View File

@@ -103,4 +103,47 @@ describe("planning generation cancellation", () => {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(promptResolvedAfterAbort).toBe(true); expect(promptResolvedAfterAbort).toBe(true);
}); });
/*
FNXC:PlanningStopMultiSession 2026-07-23-23:50:
Stop must cancel a session whose initial turn is still PENDING (start-streaming registered
it, but no stream connect consumed it yet) and must never touch other sessions' generations.
Previously stop returned false here and the "stopped" turn sprang back to life on the next
stream connect.
*/
it("cancels a pending initial turn and leaves other sessions' generations untouched", async () => {
const prompt = vi.fn(async () => {});
__setCreateFnAgent(vi.fn(async () => ({
session: { state: { messages: [] }, prompt, dispose: vi.fn() },
})) as any);
const stoppedSessionId = await createSessionWithAgent(
"10.0.2.11",
"Plan that gets stopped before its stream connects",
"/tmp/project",
MOCK_TASK_STORE,
);
const survivorSessionId = await createSessionWithAgent(
"10.0.2.12",
"Plan that keeps generating",
"/tmp/project",
MOCK_TASK_STORE,
);
expect(planningStreamManager.hasPendingInitialTurn(stoppedSessionId)).toBe(true);
expect(stopGeneration(stoppedSessionId)).toBe(true);
// The discarded turn must not fire on a later stream connect.
expect(planningStreamManager.hasPendingInitialTurn(stoppedSessionId)).toBe(false);
expect(planningStreamManager.consumeInitialTurn(stoppedSessionId)).toBeUndefined();
expect(prompt).not.toHaveBeenCalled();
// The other session's pending turn is untouched and still runs normally.
expect(planningStreamManager.hasPendingInitialTurn(survivorSessionId)).toBe(true);
planningStreamManager.consumeInitialTurn(survivorSessionId)?.();
for (let i = 0; i < 20 && prompt.mock.calls.length === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(prompt).toHaveBeenCalled();
});
}); });