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" && (