diff --git a/src/__tests__/integration/api/stakwork/stakwork-runs.test.ts b/src/__tests__/integration/api/stakwork/stakwork-runs.test.ts
index 7e121cd60a..96d8c73549 100644
--- a/src/__tests__/integration/api/stakwork/stakwork-runs.test.ts
+++ b/src/__tests__/integration/api/stakwork/stakwork-runs.test.ts
@@ -222,4 +222,52 @@ describe("POST /api/stakwork/ai/generate — idempotency guard", () => {
// Should succeed (201) — the completed run does not block a new one
expect(response.status).toBe(201);
});
+
+ it("broadcasts a Pusher run-update event on successful run creation", async () => {
+ const { pusherServer } = await import("@/lib/pusher");
+
+ const request = createAuthenticatedPostRequest(BASE_URL, user, {
+ type: "TASK_GENERATION",
+ featureId: feature.id,
+ workspaceId: workspace.id,
+ autoAccept: true,
+ params: { skipClarifyingQuestions: true },
+ });
+
+ const response = await POST(request);
+ expect(response.status).toBe(201);
+
+ expect(pusherServer.trigger).toHaveBeenCalledWith(
+ `workspace-${workspace.slug}`,
+ "stakwork-run-update",
+ expect.objectContaining({
+ type: "TASK_GENERATION",
+ status: WorkflowStatus.IN_PROGRESS,
+ featureId: feature.id,
+ }),
+ );
+ });
+
+ it("run creation succeeds even when Pusher broadcast throws", async () => {
+ const { pusherServer } = await import("@/lib/pusher");
+ vi.mocked(pusherServer.trigger).mockRejectedValueOnce(
+ new Error("Pusher unavailable"),
+ );
+
+ const request = createAuthenticatedPostRequest(BASE_URL, user, {
+ type: "TASK_GENERATION",
+ featureId: feature.id,
+ workspaceId: workspace.id,
+ autoAccept: true,
+ params: { skipClarifyingQuestions: true },
+ });
+
+ const response = await POST(request);
+ expect(response.status).toBe(201);
+
+ const createdRun = await db.stakworkRun.findFirst({
+ where: { featureId: feature.id, type: StakworkRunType.TASK_GENERATION },
+ });
+ expect(createdRun?.status).toBe(WorkflowStatus.IN_PROGRESS);
+ });
});
diff --git a/src/__tests__/unit/components/evals/FlagRunEvalModal.test.tsx b/src/__tests__/unit/components/evals/FlagRunEvalModal.test.tsx
index 568e9c8390..f4ad52892a 100644
--- a/src/__tests__/unit/components/evals/FlagRunEvalModal.test.tsx
+++ b/src/__tests__/unit/components/evals/FlagRunEvalModal.test.tsx
@@ -27,6 +27,7 @@ vi.mock("@/components/evals/CaptureEvalForm", () => ({
submitting,
onSelectRequirement,
selectedEvalSetId,
+ selectedRequirementId,
}: {
requirement: string;
reason: string;
@@ -37,7 +38,7 @@ vi.mock("@/components/evals/CaptureEvalForm", () => ({
onSelectRequirement?: (id: string | null) => void;
selectedEvalSetId?: string;
}) => (
-
+
{
fireEvent.click(screen.getByText("Generate Title").closest("button")!);
fireEvent.click(screen.getByRole("button", { name: /next/i }));
- // Wait for step 2 AND for the evals fetch to auto-select a set (non-empty data-evalset).
- // Clicking the existing-req button before the evals fetch resolves causes the
- // selectedEvalSetId effect to reset selectedRequirementId back to null.
+ // Wait for step 2, the evals fetch to auto-select a set, AND for any
+ // subsequent selectedEvalSetId-change effects (e.g. the auto-select
+ // CREATE_NEW_REQ effect in CaptureEvalForm) to finish settling so that
+ // clicking the existing-req button isn't immediately overwritten.
await waitFor(() => {
const form = screen.getByTestId("capture-eval-form");
expect(form.getAttribute("data-evalset")).not.toBe("");
+ // data-req will be "" (reset) or CREATE_NEW_REQ after effects settle;
+ // either way it must not be mid-flight (undefined is rendered as "").
+ expect(form.getAttribute("data-req")).not.toBeNull();
});
// Select an existing requirement via the mock button
diff --git a/src/__tests__/unit/hooks/useStakworkGeneration.test.ts b/src/__tests__/unit/hooks/useStakworkGeneration.test.ts
index 1be2fe2727..c05fbe3d6d 100644
--- a/src/__tests__/unit/hooks/useStakworkGeneration.test.ts
+++ b/src/__tests__/unit/hooks/useStakworkGeneration.test.ts
@@ -48,6 +48,96 @@ describe("useStakworkGeneration", () => {
} as Response);
});
+ describe("Pusher run-update event triggers refetch", () => {
+ it("calls queryLatestRun when a matching stakwork-run-update event arrives", async () => {
+ let capturedHandler: ((data: unknown) => void) | null = null;
+ mockChannel.bind.mockImplementation(
+ (event: string, handler: (data: unknown) => void) => {
+ if (event === "stakwork-run-update") {
+ capturedHandler = handler;
+ }
+ },
+ );
+
+ renderHook(() =>
+ useStakworkGeneration({
+ featureId,
+ type: "TASK_GENERATION",
+ enabled: true,
+ }),
+ );
+
+ // Wait for initial fetch
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockChannel.bind).toHaveBeenCalledWith(
+ "stakwork-run-update",
+ expect.any(Function),
+ );
+ expect(capturedHandler).not.toBeNull();
+
+ const fetchBefore = vi.mocked(global.fetch).mock.calls.length;
+
+ // Simulate a run-update event matching featureId + type (the creation broadcast)
+ await act(async () => {
+ capturedHandler!({
+ runId: "run-new",
+ type: "TASK_GENERATION",
+ status: "IN_PROGRESS",
+ featureId,
+ timestamp: new Date().toISOString(),
+ });
+ await Promise.resolve();
+ });
+
+ // fetch should have been called again to re-query the latest run
+ expect(vi.mocked(global.fetch).mock.calls.length).toBeGreaterThan(
+ fetchBefore,
+ );
+ });
+
+ it("does NOT call queryLatestRun when the event featureId does not match", async () => {
+ let capturedHandler: ((data: unknown) => void) | null = null;
+ mockChannel.bind.mockImplementation(
+ (event: string, handler: (data: unknown) => void) => {
+ if (event === "stakwork-run-update") {
+ capturedHandler = handler;
+ }
+ },
+ );
+
+ renderHook(() =>
+ useStakworkGeneration({
+ featureId,
+ type: "TASK_GENERATION",
+ enabled: true,
+ }),
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const fetchBefore = vi.mocked(global.fetch).mock.calls.length;
+
+ // Event for a different feature — should be ignored
+ await act(async () => {
+ capturedHandler!({
+ runId: "run-other",
+ type: "TASK_GENERATION",
+ status: "IN_PROGRESS",
+ featureId: "different-feature-id",
+ timestamp: new Date().toISOString(),
+ });
+ await Promise.resolve();
+ });
+
+ expect(vi.mocked(global.fetch).mock.calls.length).toBe(fetchBefore);
+ });
+ });
+
describe("error handling", () => {
it("should handle getPusherClient throwing error gracefully", async () => {
const pusher = await import("@/lib/pusher");
diff --git a/src/services/stakwork-run.ts b/src/services/stakwork-run.ts
index 5114e436b7..139a24bab1 100644
--- a/src/services/stakwork-run.ts
+++ b/src/services/stakwork-run.ts
@@ -423,6 +423,26 @@ export async function createStakworkRun(
},
});
+ // Notify all collaborators that generation has started so their
+ // Generate Tasks button disables immediately (same event + payload
+ // shape the webhook uses — useStakworkGeneration binds to it).
+ try {
+ await pusherServer.trigger(
+ getWorkspaceChannelName(workspace.slug),
+ PUSHER_EVENTS.STAKWORK_RUN_UPDATE,
+ {
+ runId: run.id,
+ type: input.type,
+ status: WorkflowStatus.IN_PROGRESS,
+ featureId: input.featureId ?? null,
+ timestamp: new Date(),
+ },
+ );
+ } catch (pusherError) {
+ // Non-fatal — webhook will broadcast subsequent status changes.
+ console.error("[createStakworkRun] Pusher broadcast failed:", pusherError);
+ }
+
return run;
} catch (error) {
// Update status to FAILED if Stakwork call fails