From b701a49c05dffaa7ea96e52a179847a60372421f Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:14 -0700 Subject: [PATCH 01/19] Add linked provider cancellation helper --- frontend/lib/ai/requestAbort.mjs | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 frontend/lib/ai/requestAbort.mjs diff --git a/frontend/lib/ai/requestAbort.mjs b/frontend/lib/ai/requestAbort.mjs new file mode 100644 index 00000000..e6f121d5 --- /dev/null +++ b/frontend/lib/ai/requestAbort.mjs @@ -0,0 +1,37 @@ +export function createLinkedAbort({ signal = null, timeoutMs = 50_000 } = {}) { + const controller = new AbortController(); + let timedOut = false; + + const abortFromCaller = () => { + if (!controller.signal.aborted) controller.abort(); + }; + + if (signal?.aborted) { + abortFromCaller(); + } else if (signal?.addEventListener) { + signal.addEventListener("abort", abortFromCaller, { once: true }); + } + + const timeoutId = setTimeout(() => { + timedOut = true; + if (!controller.signal.aborted) controller.abort(); + }, Math.max(1, Number(timeoutMs) || 50_000)); + + return Object.freeze({ + signal: controller.signal, + cancelled: () => Boolean(signal?.aborted && !timedOut), + timedOut: () => timedOut, + cleanup() { + clearTimeout(timeoutId); + if (signal?.removeEventListener) signal.removeEventListener("abort", abortFromCaller); + }, + }); +} + +export function cancelledProviderRequestError() { + const error = new Error("Generation request was cancelled."); + error.name = "AbortError"; + error.code = "provider_request_cancelled"; + error.status = 499; + return error; +} From 2ec33b1b153e5137707488b1784336d981251d43 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:17 -0700 Subject: [PATCH 02/19] Normalize provider cancellation --- frontend/lib/ai/providerErrors.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/lib/ai/providerErrors.mjs b/frontend/lib/ai/providerErrors.mjs index 0df4b0d8..edb90f0d 100644 --- a/frontend/lib/ai/providerErrors.mjs +++ b/frontend/lib/ai/providerErrors.mjs @@ -6,6 +6,7 @@ const SAFE_CODES = Object.freeze({ QUOTA_EXCEEDED: "provider_quota_exceeded", PAYMENT_REQUIRED: "provider_payment_required", TIMEOUT: "provider_timeout", + CANCELLED: "provider_request_cancelled", UNAVAILABLE: "provider_unavailable", MALFORMED_RESPONSE: "provider_malformed_response", EMPTY_RESPONSE: "provider_empty_response", @@ -35,6 +36,9 @@ function classify(error) { if (error?.code === "provider_empty_response") { return { code: SAFE_CODES.EMPTY_RESPONSE, retryable: true, action: "retry_destination" }; } + if (error?.code === "provider_request_cancelled") { + return { code: SAFE_CODES.CANCELLED, retryable: true, action: "retry_destination" }; + } if (error?.name === "AbortError" || /timed?\s*out|timeout/.test(message)) { return { code: SAFE_CODES.TIMEOUT, retryable: true, action: "retry_destination" }; } @@ -81,6 +85,7 @@ function safeMessage(code, provider) { case SAFE_CODES.QUOTA_EXCEEDED: return `${label} reports that the current quota is exhausted.`; case SAFE_CODES.PAYMENT_REQUIRED: return `${label} requires billing or credits before generation can continue.`; case SAFE_CODES.TIMEOUT: return `${label} did not respond within the request limit.`; + case SAFE_CODES.CANCELLED: return "Generation was cancelled before the provider request completed."; case SAFE_CODES.UNAVAILABLE: return `${label} is temporarily unavailable.`; case SAFE_CODES.MALFORMED_RESPONSE: return `${label} returned a response that could not be validated as the required JSON contract.`; case SAFE_CODES.EMPTY_RESPONSE: return `${label} returned no usable model output.`; From 83df25fd1350e454936ae5e2714d88c0001610ba Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:21 -0700 Subject: [PATCH 03/19] Stop queued generation after cancellation --- frontend/lib/ai/generationConcurrency.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/lib/ai/generationConcurrency.mjs b/frontend/lib/ai/generationConcurrency.mjs index e3734f60..6e9146e1 100644 --- a/frontend/lib/ai/generationConcurrency.mjs +++ b/frontend/lib/ai/generationConcurrency.mjs @@ -1,6 +1,6 @@ export const DEFAULT_DESTINATION_CONCURRENCY = 2; -export async function mapWithConcurrency(items, worker, concurrency = DEFAULT_DESTINATION_CONCURRENCY) { +export async function mapWithConcurrency(items, worker, concurrency = DEFAULT_DESTINATION_CONCURRENCY, { signal = null } = {}) { const values = Array.isArray(items) ? items : []; if (!values.length) return []; if (typeof worker !== "function") throw new TypeError("mapWithConcurrency requires a worker function."); @@ -14,6 +14,7 @@ export async function mapWithConcurrency(items, worker, concurrency = DEFAULT_DE async function runLane() { while (cursor < values.length) { + if (signal?.aborted) break; const index = cursor; cursor += 1; results[index] = await worker(values[index], index); From bda8e6f5013a32222156f89439c4a139008be71e Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:25 -0700 Subject: [PATCH 04/19] Propagate cancellation through generation queue --- frontend/lib/ai/generateStudioPackage.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/lib/ai/generateStudioPackage.js b/frontend/lib/ai/generateStudioPackage.js index 3bbcc116..83573cf5 100644 --- a/frontend/lib/ai/generateStudioPackage.js +++ b/frontend/lib/ai/generateStudioPackage.js @@ -460,7 +460,15 @@ export async function generateStudioPackage(inputs) { provider: generator, modelOverride, config, - }), config.destinationConcurrency); + }), config.destinationConcurrency, { signal: config.signal }); + + if (config.signal?.aborted) { + const cancelled = new Error("Generation request was cancelled."); + cancelled.code = "provider_request_cancelled"; + cancelled.status = 499; + throw cancelled; + } + generatedDestinations = generatedDestinations.filter(Boolean); const generatedDraftMap = Object.fromEntries( generatedDestinations @@ -482,7 +490,13 @@ export async function generateStudioPackage(inputs) { provider: generator, modelOverride, config, - }), config.destinationConcurrency); + }), config.destinationConcurrency, { signal: config.signal }); + if (config.signal?.aborted) { + const cancelled = new Error("Generation request was cancelled."); + cancelled.code = "provider_request_cancelled"; + cancelled.status = 499; + throw cancelled; + } const replacements = new Map(revised.filter(Boolean).map((item) => [item.channel, item])); generatedDestinations = generatedDestinations.map((item) => replacements.get(item.channel) || item); } From 23dd58682c492a9dbfeedbb301f2cd6be55bb71a Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:28 -0700 Subject: [PATCH 05/19] Propagate request abort into generation --- frontend/app/api/launch_kit/route.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/app/api/launch_kit/route.js b/frontend/app/api/launch_kit/route.js index 464e1205..eb23ce97 100644 --- a/frontend/app/api/launch_kit/route.js +++ b/frontend/app/api/launch_kit/route.js @@ -218,6 +218,7 @@ export async function POST(request) { baseUrl: providerBaseUrl, modelName: providerModelName, allowServerKey: isOwner, + signal: request.signal, }, }); From 096640744738aaf2cab48616fb304fd50a7f7608 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:32 -0700 Subject: [PATCH 06/19] Persist cancelled destination state --- frontend/lib/studio/campaignState.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/frontend/lib/studio/campaignState.mjs b/frontend/lib/studio/campaignState.mjs index 27625d39..84e21770 100644 --- a/frontend/lib/studio/campaignState.mjs +++ b/frontend/lib/studio/campaignState.mjs @@ -215,6 +215,31 @@ export function campaignReducer(state, action) { }; } + case "MARK_CHANNELS_CANCELLED": { + const channels = Array.from(new Set((Array.isArray(action.channels) ? action.channels : []) + .map((channel) => String(channel || "").trim()) + .filter(Boolean))); + if (!channels.length) return state; + const nextChannelStates = { ...state.channelStates }; + let changed = false; + for (const channel of channels) { + const previous = nextChannelStates[channel] || {}; + nextChannelStates[channel] = { + ...previous, + status: "cancelled", + qualityStatus: "needs_review", + approved: false, + qualityRiskAccepted: false, + issues: Array.from(new Set([...(previous.issues || []), "Generation was cancelled. Existing draft content was preserved."])), + issueCodes: Array.from(new Set([...(previous.issueCodes || []), "generation_cancelled"])), + }; + changed = true; + } + return changed + ? { ...state, channelStates: nextChannelStates, revision: state.revision + 1 } + : state; + } + case "RESTORE_GENERATED": { const channel = String(action.channel || "").trim(); const generated = state.generatedPosts[channel]; From 0c83f567816a776361ddf28ac199e82c3f44d9e2 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:37 -0700 Subject: [PATCH 07/19] Expose cancelled destination status --- frontend/lib/studio/campaignStatus.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/lib/studio/campaignStatus.mjs b/frontend/lib/studio/campaignStatus.mjs index 52f5133b..2aa0296e 100644 --- a/frontend/lib/studio/campaignStatus.mjs +++ b/frontend/lib/studio/campaignStatus.mjs @@ -4,6 +4,7 @@ const CHANNEL_LABELS = Object.freeze({ edited: "Edited", needs_review: "Needs review", failed: "Generation failed", + cancelled: "Generation cancelled", stale: "Source changed", approved: "Approved", empty: "No draft", @@ -15,7 +16,8 @@ function text(value) { export function selectChannelStatus({ channelState = {}, isStale = false, content = "" } = {}) { let key = "generated"; - if (!text(content) && channelState.status === "failed") key = "failed"; + if (channelState.status === "cancelled") key = "cancelled"; + else if (!text(content) && channelState.status === "failed") key = "failed"; else if (!text(content)) key = "empty"; else if (isStale) key = "stale"; else if (channelState.status === "failed") key = "failed"; @@ -27,7 +29,7 @@ export function selectChannelStatus({ channelState = {}, isStale = false, conten return { key, label: CHANNEL_LABELS[key], - isBlocked: ["failed", "stale", "empty"].includes(key), + isBlocked: ["failed", "cancelled", "stale", "empty"].includes(key), isApproved: Boolean(channelState.approved), isEdited: Boolean(channelState.edited), }; @@ -64,7 +66,8 @@ export function selectCampaignStatus({ const editedCount = channelEntries.filter((item) => item.isEdited).length; const approvedCount = channelEntries.filter((item) => item.isApproved).length; const failedCount = channelEntries.filter((item) => item.key === "failed").length; - const needsReviewCount = channelEntries.filter((item) => !item.isApproved && ["generated", "regenerated", "needs_review", "edited"].includes(item.key)).length; + const cancelledCount = channelEntries.filter((item) => item.key === "cancelled").length; + const needsReviewCount = channelEntries.filter((item) => !item.isApproved && ["generated", "regenerated", "needs_review", "edited", "cancelled"].includes(item.key)).length; let campaignKey = "not_generated"; let campaignLabel = "Not generated"; @@ -93,6 +96,7 @@ export function selectCampaignStatus({ editedCount, approvedCount, failedCount, + cancelledCount, needsReviewCount, hasEditedDrafts: editedCount > 0, copyBlockedReason: isStale @@ -117,6 +121,7 @@ export function selectPublishAvailability({ if (isStale) return { ready: false, reason: "Source inputs changed. Regenerate before publishing." }; if (!hasContent) return { ready: false, reason: "This destination has no usable draft." }; if (channelStatus?.key === "failed") return { ready: false, reason: "Generation failed for this destination. Regenerate it before publishing." }; + if (channelStatus?.key === "cancelled") return { ready: false, reason: "Generation was cancelled for this destination. Retry it before publishing." }; if (!channelStatus?.isApproved) return { ready: false, reason: "Mark this draft approved before publishing or opening its handoff." }; if (isOverLimit) return { ready: false, reason: "This draft exceeds the destination character guide." }; if (!connectorReady && !manualRoute) return { ready: false, reason: "Connect the destination or use an available manual handoff." }; From ab8f8e834d22130a7838a352735fa76728ba8147 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:48 -0700 Subject: [PATCH 08/19] Add real browser generation cancellation --- frontend/app/page.js | 58 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/frontend/app/page.js b/frontend/app/page.js index b42f9665..846d7c6e 100644 --- a/frontend/app/page.js +++ b/frontend/app/page.js @@ -411,6 +411,7 @@ export default function Home() { reddit: { subreddit: "", title: "" }, }); const fileInputRef = useRef(null); + const generationAbortRef = useRef(null); const campaignApplication = useMemo(() => createBrowserCampaignApplication({ getStorage: () => window.localStorage, key: LIBRARY_KEY, @@ -880,7 +881,7 @@ ${extractedText}`); } } - async function requestGeneration(requestedChannels) { + async function requestGeneration(requestedChannels, signal = null) { if (!form.notes.trim() && !form.links.trim() && !form.repo.trim() && documentText.length === 0) { throw new Error("Add a brief, link, repository, or extractable text file before generating."); } @@ -898,6 +899,7 @@ ${extractedText}`); const response = await fetch("/api/launch_kit", { method: "POST", headers: authHeaders({ "Content-Type": "application/json" }), + signal, body: JSON.stringify({ project_name: form.projectName.trim() || "Untitled campaign", notes: form.notes.trim(), @@ -946,11 +948,29 @@ ${extractedText}`); return { accepted, nextGenerationRun, data }; } + function beginGenerationRequest() { + const controller = new AbortController(); + generationAbortRef.current = controller; + return controller; + } + + function finishGenerationRequest(controller) { + if (generationAbortRef.current === controller) generationAbortRef.current = null; + } + + function cancelGeneration() { + const controller = generationAbortRef.current; + if (!controller || controller.signal.aborted) return; + controller.abort(); + setMessage({ type: "warning", text: "Cancelling generation. Existing drafts will remain unchanged." }); + } + async function generateInitialCampaign() { + const controller = beginGenerationRequest(); setBusy(true); setMessage(null); try { - const generation = await requestGeneration(channels); + const generation = await requestGeneration(channels, controller.signal); if (generation.strategyBlocked) { setStrategyReview(generation.data.strategy_review); setStage("destinations"); @@ -979,9 +999,14 @@ ${extractedText}`); : `Campaign generated with ${data.providerUsed || provider.label}. Review and approve each destination before publishing.`, }); } catch (error) { + if (error?.name === "AbortError") { + setMessage({ type: "warning", text: "Generation cancelled before completion. No campaign draft was replaced." }); + return; + } const recovery = providerRecoveryMessage(error.providerError); setMessage({ type: "error", text: [error.message, recovery].filter(Boolean).join(" ") }); } finally { + finishGenerationRequest(controller); setBusy(false); } } @@ -995,10 +1020,11 @@ ${extractedText}`); } setRegenerationDialogOpen(false); + const controller = beginGenerationRequest(); setBusy(true); setMessage(null); try { - const generation = await requestGeneration(targetChannels); + const generation = await requestGeneration(targetChannels, controller.signal); if (generation.strategyBlocked) { setStrategyReview(generation.data.strategy_review); setStage("destinations"); @@ -1035,12 +1061,21 @@ ${extractedText}`); : "The previous campaign version was archived and all selected destinations were regenerated.", }); } catch (error) { + if (error?.name === "AbortError") { + dispatchCampaign({ type: "MARK_CHANNELS_CANCELLED", channels: targetChannels }); + setMessage({ + type: "warning", + text: "Generation cancelled. Existing drafts and edits were preserved; cancelled destinations can be retried.", + }); + return; + } const recovery = providerRecoveryMessage(error.providerError); setMessage({ type: "error", text: [error.message, recovery, "Existing drafts and edits were not changed."].filter(Boolean).join(" "), }); } finally { + finishGenerationRequest(controller); setBusy(false); } } @@ -2056,12 +2091,14 @@ async function exportZip() { {(channelStates[activeChannel]?.issues || []).length > 0 && (
{channelStates[activeChannel]?.status === "failed" ? "Generation failed" - : channelStates[activeChannel]?.status === "needs_review" + : channelStates[activeChannel]?.status === "cancelled" + ? "Generation cancelled" + : channelStates[activeChannel]?.status === "needs_review" ? "Unresolved quality issues" : "Generation notes"} @@ -2100,7 +2137,7 @@ async function exportZip() { onClick={() => void performRegeneration(REGENERATION_POLICIES.CHANNEL, activeChannel)} disabled={busy || !providerReadiness.ready} > - {channelStates[activeChannel]?.status === "failed" ? "Retry destination" : "Regenerate this channel"} + {["failed", "cancelled"].includes(channelStates[activeChannel]?.status) ? "Retry destination" : "Regenerate this channel"} {channelStates[activeChannel]?.edited && generatedPosts[activeChannel] && (
+ {busy && ( + + )} {stage !== "source" && (