fix(agent): sync detail replacement task status

This commit is contained in:
134 2026-08-01 15:59:32 +08:00
parent c988f2dee0
commit c5c49b52a9
9 changed files with 724 additions and 45 deletions

View File

@ -4,6 +4,7 @@ import {
createCanvasImageTask,
enqueueCanvasImageTask,
getCanvasImageTaskForUser,
getCanvasDetailReplacementTasksForUserContext,
} from "@/lib/canvas/image-task-queue";
import {
generateCanvasImage,
@ -87,6 +88,19 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const taskId = String(searchParams.get("taskId") || "").trim();
const chatId = String(searchParams.get("chatId") || "").trim();
const projectId = String(searchParams.get("projectId") || "").trim();
if (!taskId && chatId) {
const tasks = await getCanvasDetailReplacementTasksForUserContext({
userId: session.user.id,
chatId,
projectId: projectId || null,
});
return NextResponse.json({
success: true,
tasks,
});
}
if (!taskId) {
return NextResponse.json(
{ success: false, error: "缺少 taskId 参数" },

View File

@ -118,6 +118,14 @@ import {
isReplacementResultMode,
shouldShowAgentGridResultSection,
} from "@/lib/agents/generation-result-visibility";
import {
buildLatestAgentReplacementTaskMap,
getAgentReplacementTaskResultImageUrl,
isAgentReplacementTaskInPendingRun,
resolveAgentReplacementCardStatus,
type AgentReplacementCardStatus,
type AgentReplacementTaskSnapshot,
} from "@/lib/agents/replacement-task-status";
import {
defaultAgentWorkflowBusinessModeSettings,
normalizeAgentWorkflowBusinessModeSettings,
@ -2401,14 +2409,12 @@ const IMAGE_OVERLAY_DANGER_TEXT_CLASS =
const IMAGE_OVERLAY_DANGER_ICON_CLASS =
"absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-rose-200/55 bg-zinc-950/82 text-rose-50 shadow-[0_12px_34px_rgba(0,0,0,0.50)] backdrop-blur-md transition-colors hover:border-rose-100 hover:bg-rose-500/35";
type ReplacementCardStatus =
| "waiting"
| "queued"
| "generating"
| "completed"
| "passthrough"
| "fallback"
| "missing";
type ReplacementCardStatus = AgentReplacementCardStatus;
interface AgentReplacementTasksResponse {
success?: boolean;
tasks?: AgentReplacementTaskSnapshot[];
}
type ReplacementModeType = Extract<
AgentGenerationResultModeType,
@ -2482,7 +2488,9 @@ function areReplacementResultStatesEqual(
);
}
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
function inferReplacementCardStatusFromText(
text: string | null | undefined
): "completed" | "passthrough" | "fallback" | null {
const normalized = normalizeAssistantText(String(text || ""))
.replace(/\s+/g, " ")
.trim();
@ -2511,30 +2519,19 @@ function getReplacementCardStatus(params: {
generatedCount: number;
isGenerating: boolean;
activeRunIndex?: number | null;
taskStatus?: AgentReplacementTaskSnapshot["status"] | null;
isRequestedInPendingRun?: boolean;
}): ReplacementCardStatus {
if (
typeof params.activeRunIndex === "number" &&
params.activeRunIndex >= 0 &&
params.generatedCount <= params.activeRunIndex
) {
if (params.isGenerating) {
return params.generatedCount === params.activeRunIndex ? "generating" : "queued";
}
if (!params.resultUrl && params.generatedCount > 0) {
return "missing";
}
}
if (params.resultUrl) {
return inferReplacementCardStatusFromText(params.resultText) || "completed";
}
if (params.isGenerating) {
return params.index === params.generatedCount ? "generating" : "queued";
}
if (params.generatedCount > 0 && params.index >= params.generatedCount) {
return "missing";
}
return "waiting";
return resolveAgentReplacementCardStatus({
index: params.index,
resultUrl: params.resultUrl,
persistedResultStatus: inferReplacementCardStatusFromText(params.resultText),
generatedCount: params.generatedCount,
isGenerating: params.isGenerating,
activeRunIndex: params.activeRunIndex,
taskStatus: params.taskStatus,
isRequestedInPendingRun: params.isRequestedInPendingRun === true,
});
}
function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
@ -2550,6 +2547,11 @@ function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
label: "失败已沿用",
className: "border-amber-100/80 bg-amber-500/92 text-zinc-950",
};
case "failed":
return {
label: "生成失败",
className: "border-rose-100/80 bg-rose-600/92 text-white",
};
case "completed":
return {
label: "已生成",
@ -3472,6 +3474,54 @@ export function AgentGenerationWorkspaceDarkV2({
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
const isReplacementPreset =
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
const replacementTasksUrl =
currentResultModeType === "detail_replacement" && rawChatId
? `/api/canvas/generate/image?chatId=${encodeURIComponent(
rawChatId
)}${
isValidChatUuid(projectId)
? `&projectId=${encodeURIComponent(projectId)}`
: ""
}`
: null;
const {
data: replacementTasksData,
mutate: refreshReplacementTasks,
} = useSWR<AgentReplacementTasksResponse>(replacementTasksUrl, fetcher, {
refreshInterval: (latestData) =>
pendingGeneration ||
latestData?.tasks?.some(
(task) => task.status === "pending" || task.status === "running"
)
? 2500
: 0,
revalidateOnFocus: true,
});
const replacementTaskSnapshots = useMemo(
() =>
Array.isArray(replacementTasksData?.tasks)
? replacementTasksData.tasks.filter(
(task) => task.modeType === "detail_replacement"
)
: [],
[replacementTasksData?.tasks]
);
const latestDetailTaskByCardId = useMemo(
() =>
buildLatestAgentReplacementTaskMap({
tasks: replacementTaskSnapshots,
modeType: "detail_replacement",
}),
[replacementTaskSnapshots]
);
const hasActiveReplacementTasks = replacementTaskSnapshots.some(
(task) => task.status === "pending" || task.status === "running"
);
const hasActiveDetailReplacementTasks = replacementTaskSnapshots.some(
(task) =>
task.modeType === "detail_replacement" &&
(task.status === "pending" || task.status === "running")
);
const isProductOnlyDetailReplacementMode =
currentResultModeType === "detail_replacement" &&
detailReplacementStrategy === "product_only";
@ -4703,6 +4753,74 @@ export function AgentGenerationWorkspaceDarkV2({
latestReplacementResults,
skuReplacementReferenceCards,
]);
useEffect(() => {
const succeededTasks = replacementTaskSnapshots.filter(
(task) =>
task.modeType === "detail_replacement" &&
task.status === "succeeded" &&
Boolean(getAgentReplacementTaskResultImageUrl(task.result))
);
if (succeededTasks.length === 0) {
return;
}
const referenceCardById = new Map(
detailReplacementReferenceCards.map((card) => [card.id, card])
);
setReplacementResultState((current) => {
const nextDetailState = { ...current.detail_replacement };
let changed = false;
succeededTasks.forEach((task) => {
const referenceCard = referenceCardById.get(task.referenceId);
const resultUrl = getAgentReplacementTaskResultImageUrl(task.result);
if (!referenceCard || !resultUrl) {
return;
}
const taskUpdatedAt = Date.parse(
task.completedAt || task.startedAt || task.createdAt
);
const previousRecord = nextDetailState[task.referenceId];
if (
previousRecord?.url === resultUrl ||
(Number.isFinite(taskUpdatedAt) &&
Number(previousRecord?.updatedAt || 0) > taskUpdatedAt)
) {
return;
}
nextDetailState[task.referenceId] = {
url: resultUrl,
text: "已从后台详情替换任务恢复生图结果。",
status: "completed",
updatedAt: Number.isFinite(taskUpdatedAt) ? taskUpdatedAt : Date.now(),
compareSourceUrl: normalizeResultCompareSourceUrl(
resultUrl,
previousRecord?.url ||
referenceCard.imageUrl ||
referenceCard.previewUrl ||
previousRecord?.compareSourceUrl ||
null
),
referenceImageUrl:
referenceCard.imageUrl || previousRecord?.referenceImageUrl || null,
referencePreviewUrl:
referenceCard.previewUrl || previousRecord?.referencePreviewUrl || null,
label: referenceCard.label || previousRecord?.label || null,
};
changed = true;
});
if (!changed) {
return current;
}
return {
...current,
detail_replacement: nextDetailState,
};
});
}, [detailReplacementReferenceCards, replacementTaskSnapshots]);
const detailReplacementResultUrls = useMemo(
() => {
const urlsFromCards = detailReplacementReferenceCards
@ -4787,6 +4905,32 @@ export function AgentGenerationWorkspaceDarkV2({
return 0;
}
if (pendingGeneration.modeType === "detail_replacement") {
const completedCardIds = new Set<string>();
latestReplacementResults.forEach((result, index) => {
const cardId =
result.replacementCardId || pendingGeneration.requestedCardIds?.[index] || null;
if (cardId) {
completedCardIds.add(cardId);
}
});
(pendingGeneration.requestedCardIds || []).forEach((cardId) => {
const task = latestDetailTaskByCardId.get(cardId);
if (
task &&
(task.status === "succeeded" || task.status === "failed") &&
isAgentReplacementTaskInPendingRun({
task,
pendingStartedAt: pendingGeneration.timestamp,
requestMessageId: pendingGeneration.requestMessageId,
})
) {
completedCardIds.add(cardId);
}
});
return completedCardIds.size;
}
if (
pendingGeneration.modeType !== "detail_generation" &&
userMessageCount <= pendingGeneration.userTurnCountAtStart
@ -4806,14 +4950,17 @@ export function AgentGenerationWorkspaceDarkV2({
}, [
currentSceneGeneratedUrls.length,
detailGenerationRunResults.length,
latestReplacementResults.length,
latestDetailTaskByCardId,
latestReplacementResults,
pendingGeneration,
userMessageCount,
]);
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
const isAnyGenerationRunning =
transportIsGenerating || Boolean(pendingGeneration) || hasActiveReplacementTasks;
const isDetailResultModeGenerating =
pendingGeneration?.modeType === "detail_replacement" ||
(currentResultModeType === "detail_replacement" && transportIsGenerating);
(currentResultModeType === "detail_replacement" &&
(transportIsGenerating || hasActiveDetailReplacementTasks));
const isSkuResultModeGenerating =
pendingGeneration?.modeType === "sku_replacement" ||
(currentResultModeType === "sku_replacement" && transportIsGenerating);
@ -4915,12 +5062,25 @@ export function AgentGenerationWorkspaceDarkV2({
Array.isArray(pendingGeneration.requestedCardIds)
? pendingGeneration.requestedCardIds.indexOf(card.id)
: -1;
const latestTask = latestDetailTaskByCardId.get(card.id) || null;
const isRequestedInPendingRun = activeRunIndex >= 0;
const currentRunTask =
!pendingGeneration ||
pendingGeneration.modeType !== "detail_replacement" ||
isAgentReplacementTaskInPendingRun({
task: latestTask,
pendingStartedAt: pendingGeneration.timestamp,
requestMessageId: pendingGeneration.requestMessageId,
})
? latestTask
: null;
return {
...card,
index,
resultUrl,
resultText,
compareSourceUrl,
taskQueueStatus: currentRunTask?.queueStatus || null,
queuePosition:
activeRunIndex >= 0 && pendingGeneration?.modeType === "detail_replacement"
? activeRunIndex + 1
@ -4939,14 +5099,18 @@ export function AgentGenerationWorkspaceDarkV2({
: 0,
isGenerating: isDetailResultModeGenerating,
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
taskStatus: currentRunTask?.status || null,
isRequestedInPendingRun,
}),
};
}),
[
detailReplacementReferenceCards,
isDetailResultModeGenerating,
latestDetailTaskByCardId,
latestReplacementResults,
normalizedDeletedResultCollections.detail_replacement,
pendingGeneration,
pendingGeneration?.modeType,
pendingGeneration?.requestedCardIds,
replacementResultState.detail_replacement,
@ -5436,6 +5600,9 @@ export function AgentGenerationWorkspaceDarkV2({
) {
return;
}
if (transportIsGenerating) {
return;
}
const requestAccepted = isAgentGenerationRequestAccepted({
pending: pendingGeneration,
@ -5483,7 +5650,13 @@ export function AgentGenerationWorkspaceDarkV2({
const timerId = window.setTimeout(clearUnacceptedGeneration, remainingMs);
return () => window.clearTimeout(timerId);
}, [historyMessages, pendingGeneration, serverUserMessageCount, stop]);
}, [
historyMessages,
pendingGeneration,
serverUserMessageCount,
stop,
transportIsGenerating,
]);
useEffect(() => {
if (!pendingGeneration) {
@ -6452,6 +6625,11 @@ export function AgentGenerationWorkspaceDarkV2({
setPendingGeneration(null);
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
});
if (replacementModeType === "detail_replacement") {
window.setTimeout(() => {
void refreshReplacementTasks();
}, 500);
}
if (selectedAgentPreset?.type !== "image") {
setInput("");
}
@ -6484,6 +6662,7 @@ export function AgentGenerationWorkspaceDarkV2({
executableModelIdSet,
lockedWorkflowModelId,
requiredProductUploadCount,
refreshReplacementTasks,
requiresPresetSelection,
selectedAgentPreset,
selectedAgentPresetId,
@ -10336,10 +10515,16 @@ export function AgentGenerationWorkspaceDarkV2({
{isPending
? card.status === "generating"
? "正在重做这一屏,新的结果回来后会直接覆盖当前展示。"
: Number(card.taskQueueStatus?.queuePosition || 0) > 0
? `已进入生图队列,前方还有 ${card.taskQueueStatus?.queuePosition} 个同模型任务。`
: card.queuePosition && card.queueTotal
? `当前批次位次 ${card.queuePosition}/${card.queueTotal},前面的屏完成后会继续处理这一屏。`
: "前面的屏生成完成后,会继续处理这一屏。"
: card.resultUrl
? `当前批次位次 ${card.queuePosition}/${card.queueTotal},已进入生图队列。`
: "已进入生图队列,正在等待可用并发。"
: card.status === "failed"
? card.resultUrl
? "本次重新生成失败,已保留上一次结果,可再次重试。"
: "本次生成失败,可点击开始处理重新生成。"
: card.resultUrl
? card.status === "passthrough"
? "当前参考图未检测到可替换产品,本屏先沿用参考图展示;如需继续调整,仍可在当前页改字或重新生图。"
: card.status === "fallback"

View File

@ -18,11 +18,14 @@ export function isAgentGenerationRequestAccepted(params: {
const requestMessageId = String(
params.pending.requestMessageId || ""
).trim();
if (requestMessageId) {
return params.serverMessages.some(
if (
requestMessageId &&
params.serverMessages.some(
(message) =>
message.role === "user" && message.id === requestMessageId
);
)
) {
return true;
}
const serverUserMessageCount = params.serverMessages.filter(

View File

@ -0,0 +1,182 @@
export type AgentReplacementTaskModeType =
| "detail_replacement"
| "sku_replacement";
export type AgentReplacementTaskStatus =
| "pending"
| "running"
| "succeeded"
| "failed";
export interface AgentReplacementTaskSnapshot {
id: string;
modeType: AgentReplacementTaskModeType;
referenceId: string;
messageId?: string | null;
status: AgentReplacementTaskStatus;
error: string | null;
result: unknown;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
queueStatus?: {
queuePosition?: number | null;
sameModelPendingCount?: number | null;
sameModelRunningCount?: number | null;
concurrencyLimit?: number | null;
estimatedWaitMs?: number | null;
} | null;
}
export type AgentReplacementCardStatus =
| "waiting"
| "queued"
| "generating"
| "completed"
| "passthrough"
| "fallback"
| "failed"
| "missing";
function toTimestamp(value: string | null | undefined) {
const timestamp = Date.parse(String(value || ""));
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function buildLatestAgentReplacementTaskMap(params: {
tasks: AgentReplacementTaskSnapshot[];
modeType: AgentReplacementTaskModeType;
}) {
const result = new Map<string, AgentReplacementTaskSnapshot>();
const orderedTasks = [...params.tasks].sort(
(left, right) => toTimestamp(right.createdAt) - toTimestamp(left.createdAt)
);
for (const task of orderedTasks) {
if (
task.modeType !== params.modeType ||
!task.referenceId ||
result.has(task.referenceId)
) {
continue;
}
result.set(task.referenceId, task);
}
return result;
}
export function isAgentReplacementTaskInPendingRun(params: {
task: AgentReplacementTaskSnapshot | null | undefined;
pendingStartedAt?: number | null;
requestMessageId?: string | null;
clockSkewAllowanceMs?: number;
}) {
if (!params.task) {
return false;
}
const taskMessageId = String(params.task.messageId || "").trim();
const requestMessageId = String(params.requestMessageId || "").trim();
if (taskMessageId && requestMessageId) {
return taskMessageId === requestMessageId;
}
if (!Number.isFinite(Number(params.pendingStartedAt))) {
return true;
}
const taskCreatedAt = toTimestamp(params.task.createdAt);
const pendingStartedAt = Number(params.pendingStartedAt);
const clockSkewAllowanceMs = Math.max(0, params.clockSkewAllowanceMs ?? 15_000);
return taskCreatedAt >= pendingStartedAt - clockSkewAllowanceMs;
}
export function getAgentReplacementTaskResultImageUrl(result: unknown) {
if (!result || typeof result !== "object" || Array.isArray(result)) {
return null;
}
const images = (result as { images?: unknown }).images;
if (!Array.isArray(images)) {
return null;
}
for (const image of images) {
if (!image || typeof image !== "object" || Array.isArray(image)) {
continue;
}
const record = image as Record<string, unknown>;
const originalUrl = String(record.originalUrl || "").trim();
if (/^https?:\/\//i.test(originalUrl)) {
return originalUrl;
}
const thumbnailUrl = String(record.thumbnailUrl || "").trim();
if (/^https?:\/\//i.test(thumbnailUrl)) {
return thumbnailUrl;
}
}
return null;
}
export function resolveAgentReplacementCardStatus(params: {
resultUrl: string | null;
persistedResultStatus?: "completed" | "passthrough" | "fallback" | null;
taskStatus?: AgentReplacementTaskStatus | null;
isRequestedInPendingRun: boolean;
isGenerating: boolean;
index: number;
generatedCount: number;
activeRunIndex?: number | null;
}): AgentReplacementCardStatus {
if (params.taskStatus === "pending") {
return "queued";
}
if (params.taskStatus === "running") {
return "generating";
}
if (params.resultUrl) {
if (
params.persistedResultStatus === "passthrough" ||
params.persistedResultStatus === "fallback"
) {
return params.persistedResultStatus;
}
if (params.taskStatus === "failed") {
return "failed";
}
return "completed";
}
if (params.taskStatus === "failed") {
return "failed";
}
if (params.taskStatus === "succeeded") {
return "missing";
}
if (params.isRequestedInPendingRun) {
return "queued";
}
if (
typeof params.activeRunIndex === "number" &&
params.activeRunIndex >= 0 &&
params.generatedCount <= params.activeRunIndex
) {
if (params.isGenerating) {
return params.generatedCount === params.activeRunIndex ? "generating" : "queued";
}
if (params.generatedCount > 0) {
return "missing";
}
}
if (params.isGenerating) {
return params.index === params.generatedCount ? "generating" : "queued";
}
if (params.generatedCount > 0 && params.index >= params.generatedCount) {
return "missing";
}
return "waiting";
}

View File

@ -28,6 +28,7 @@ interface FalImageParams {
systemPrompt?: string;
thinkingLevel?: string;
syncMode?: boolean;
requestTimeoutMs?: number;
}
interface UploadedImage {
@ -915,12 +916,24 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
const fal = await createFalClientFromApiKey(apiKey);
const requestTimeoutMs = Math.max(
30_000,
Math.min(
DEFAULT_TIMEOUT_MS,
Number(options.imageParams?.requestTimeoutMs || DEFAULT_TIMEOUT_MS)
)
);
const abortController = new AbortController();
const abortTimeoutId = setTimeout(() => {
abortController.abort();
}, requestTimeoutMs);
let result: any;
try {
result = await fal.subscribe(request.falEndpointId, {
input: request.input,
logs: true,
timeout: DEFAULT_TIMEOUT_MS,
timeout: requestTimeoutMs,
abortSignal: abortController.signal,
onQueueUpdate: (update: any) => {
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
for (const log of update.logs) {
@ -934,6 +947,8 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
} catch (error) {
console.error("[Fal Image] Request failed:", summarizeFalError(error));
throw createDetailedFalError(error);
} finally {
clearTimeout(abortTimeoutId);
}
const text = await normalizeFalResultToMarkdown({

View File

@ -219,6 +219,8 @@ const SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO = 0.26;
const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO = 0.55;
const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO = 0.5;
const CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS = 10 * 60 * 1000;
const CANVAS_DETAIL_REPLACEMENT_PROVIDER_BATCH_TIMEOUT_MS = 3 * 60 * 1000;
const CANVAS_DETAIL_REPLACEMENT_FAL_REQUEST_TIMEOUT_MS = 170 * 1000;
const CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS = 3 * 60 * 1000;
const CANVAS_SCALE_ADJUST_FINAL_PROVIDER_BATCH_TIMEOUT_MS = 5 * 60 * 1000;
const CANVAS_SCALE_ADJUST_SNAPGEN_REQUEST_TIMEOUT_MS = 90 * 1000;
@ -260,6 +262,17 @@ function isCanvasScaleAdjustDebugRequest(
);
}
function isCanvasDetailReplacementDebugRequest(
debugLabel: string,
debugMeta?: Record<string, unknown>
) {
const operation = String(debugMeta?.operation || "");
return (
debugLabel === "agent-detail-replacement" ||
operation === "agent-detail-replacement"
);
}
function getCanvasProviderBatchTimeoutMs(params: {
debugLabel: string;
debugMeta?: Record<string, unknown>;
@ -271,6 +284,10 @@ function getCanvasProviderBatchTimeoutMs(params: {
: CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS;
}
if (isCanvasDetailReplacementDebugRequest(params.debugLabel, params.debugMeta)) {
return CANVAS_DETAIL_REPLACEMENT_PROVIDER_BATCH_TIMEOUT_MS;
}
return CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS;
}
@ -291,6 +308,15 @@ function getCanvasProviderRuntimeParams(params: {
};
}
if (
params.providerType === "fal-image" &&
isCanvasDetailReplacementDebugRequest(params.debugLabel, params.debugMeta)
) {
return {
requestTimeoutMs: CANVAS_DETAIL_REPLACEMENT_FAL_REQUEST_TIMEOUT_MS,
};
}
return {};
}

View File

@ -50,6 +50,22 @@ export type CanvasImageTaskQueueStatus = {
source: "model_queue" | "batch_queue";
};
export type CanvasDetailReplacementTaskContextSnapshot = {
id: string;
status: CanvasImageTaskStatus;
modeType: "detail_replacement";
referenceId: string;
messageId: string | null;
error: string | null;
result: unknown;
attempts: number;
startedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
queueStatus: CanvasImageTaskQueueStatus | null;
};
interface PendingTaskEntry {
taskId: string;
modelKey: string;
@ -75,6 +91,7 @@ interface QueueState {
}
const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000;
const DETAIL_REPLACEMENT_RUNNING_TIMEOUT_MS = 18 * 60 * 1000;
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 15 * 60 * 1000;
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000;
@ -133,6 +150,9 @@ function getCanvasImageRunningTimeoutMs(request: unknown) {
const debugMeta = getRecord(record?.debugMeta);
const debugLabel = String(record?.debugLabel || "");
const operation = String(debugMeta?.operation || "");
const isDetailReplacement =
debugLabel === "agent-detail-replacement" ||
operation === "agent-detail-replacement";
const isScaleAdjust =
debugLabel === "canvas-scale-adjust" ||
operation === "scale-adjust" ||
@ -140,8 +160,11 @@ function getCanvasImageRunningTimeoutMs(request: unknown) {
Boolean(debugMeta?.scaleAdjustPatchForcedForShrink) ||
Boolean(record?.scaleAdjustPatchComposite);
return isScaleAdjust
? SCALE_ADJUST_RUNNING_TIMEOUT_MS
if (isScaleAdjust) {
return SCALE_ADJUST_RUNNING_TIMEOUT_MS;
}
return isDetailReplacement
? DETAIL_REPLACEMENT_RUNNING_TIMEOUT_MS
: DEFAULT_RUNNING_TIMEOUT_MS;
}
@ -889,3 +912,86 @@ export async function getCanvasImageTaskForUser(params: {
}),
};
}
export async function getCanvasDetailReplacementTasksForUserContext(params: {
userId: string;
chatId: string;
projectId?: string | null;
limit?: number;
}): Promise<CanvasDetailReplacementTaskContextSnapshot[]> {
const chatId = String(params.chatId || "").trim();
const projectId = String(params.projectId || "").trim();
if (!chatId) {
return [];
}
const contextConditions = [
eq(canvasImageTask.userId, params.userId),
sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'chatId', '') = ${chatId}`,
sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'operation', '') = 'agent-detail-replacement'`,
];
if (projectId) {
contextConditions.push(
sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'projectId', '') = ${projectId}`
);
}
const rows = await db
.select({
id: canvasImageTask.id,
status: canvasImageTask.status,
request: canvasImageTask.request,
result: canvasImageTask.result,
error: canvasImageTask.error,
attempts: canvasImageTask.attempts,
startedAt: canvasImageTask.startedAt,
completedAt: canvasImageTask.completedAt,
createdAt: canvasImageTask.createdAt,
updatedAt: canvasImageTask.updatedAt,
})
.from(canvasImageTask)
.where(and(...contextConditions))
.orderBy(desc(canvasImageTask.createdAt))
.limit(Math.max(1, Math.min(128, Math.floor(params.limit || 128))));
const snapshots: Array<CanvasDetailReplacementTaskContextSnapshot | null> = await Promise.all(
rows.map(async (row) => {
const request = getRecord(row.request);
const debugMeta = getRecord(request?.debugMeta);
const referenceId = String(debugMeta?.referenceId || "").trim();
const messageId = String(debugMeta?.messageId || "").trim() || null;
if (!referenceId) {
return null;
}
const activeTask =
row.status === "pending" || row.status === "running"
? await getCanvasImageTaskForUser({
taskId: row.id,
userId: params.userId,
})
: null;
const status = (activeTask?.status || row.status) as CanvasImageTaskStatus;
return {
id: row.id,
status,
modeType: "detail_replacement" as const,
referenceId,
messageId,
error: activeTask?.error || row.error || null,
result: activeTask?.result || row.result || null,
attempts: activeTask?.attempts || row.attempts,
startedAt: activeTask?.startedAt || row.startedAt,
completedAt: activeTask?.completedAt || row.completedAt,
createdAt: activeTask?.createdAt || row.createdAt,
updatedAt: activeTask?.updatedAt || row.updatedAt,
queueStatus: activeTask?.queueStatus || null,
} satisfies CanvasDetailReplacementTaskContextSnapshot;
})
);
return snapshots.filter(
(task): task is CanvasDetailReplacementTaskContextSnapshot => task !== null
);
}

View File

@ -33,6 +33,16 @@ expect(
}),
"the exact persisted request message must acknowledge the request"
);
expect(
isAgentGenerationRequestAccepted({
pending,
serverMessages: [
{ id: "request-1", role: "user" },
{ id: "server-replaced-request-id", role: "user" },
],
}),
"a newly persisted user turn must acknowledge the request even when its server id changes"
);
expect(
isAgentGenerationRequestAccepted({
pending: {

View File

@ -0,0 +1,138 @@
import {
buildLatestAgentReplacementTaskMap,
getAgentReplacementTaskResultImageUrl,
isAgentReplacementTaskInPendingRun,
resolveAgentReplacementCardStatus,
type AgentReplacementTaskSnapshot,
} from "@/lib/agents/replacement-task-status";
function expect(condition: boolean, message: string) {
if (!condition) {
throw new Error(message);
}
}
function createTask(
overrides: Partial<AgentReplacementTaskSnapshot>
): AgentReplacementTaskSnapshot {
return {
id: "task-1",
modeType: "detail_replacement",
referenceId: "detail-1",
messageId: "request-1",
status: "pending",
error: null,
result: null,
createdAt: "2026-08-01T08:00:00.000Z",
startedAt: null,
completedAt: null,
queueStatus: null,
...overrides,
};
}
const pendingTask = createTask({ status: "pending" });
const runningTask = createTask({ status: "running" });
const failedTask = createTask({ status: "failed", error: "provider timeout" });
expect(
resolveAgentReplacementCardStatus({
resultUrl: null,
taskStatus: pendingTask.status,
isRequestedInPendingRun: true,
isGenerating: true,
index: 0,
generatedCount: 0,
}) === "queued",
"a database pending task must display as queued"
);
expect(
resolveAgentReplacementCardStatus({
resultUrl: null,
taskStatus: runningTask.status,
isRequestedInPendingRun: true,
isGenerating: true,
index: 0,
generatedCount: 0,
}) === "generating",
"a database running task must display as generating"
);
expect(
resolveAgentReplacementCardStatus({
resultUrl: null,
taskStatus: failedTask.status,
isRequestedInPendingRun: false,
isGenerating: false,
index: 0,
generatedCount: 0,
}) === "failed",
"a failed database task must display as failed"
);
expect(
resolveAgentReplacementCardStatus({
resultUrl: "https://cdn.example.com/previous-result.png",
taskStatus: failedTask.status,
isRequestedInPendingRun: false,
isGenerating: false,
index: 0,
generatedCount: 0,
}) === "failed",
"a failed rerun must retain the prior image while displaying the failed status"
);
expect(
resolveAgentReplacementCardStatus({
resultUrl: null,
taskStatus: null,
isRequestedInPendingRun: true,
isGenerating: true,
index: 0,
generatedCount: 0,
}) === "queued",
"a submitted card must display as queued before its database task is visible"
);
const newerTask = createTask({
id: "task-2",
status: "succeeded",
createdAt: "2026-08-01T08:05:00.000Z",
completedAt: "2026-08-01T08:06:00.000Z",
result: {
images: [
{
originalUrl: "https://cdn.example.com/detail-result.png",
thumbnailUrl: "https://cdn.example.com/detail-result-thumb.png",
},
],
},
});
const latestTaskMap = buildLatestAgentReplacementTaskMap({
tasks: [pendingTask, newerTask],
modeType: "detail_replacement",
});
expect(
latestTaskMap.get("detail-1")?.id === newerTask.id,
"the latest task for each detail card must win"
);
expect(
getAgentReplacementTaskResultImageUrl(newerTask.result) ===
"https://cdn.example.com/detail-result.png",
"a succeeded task must expose its original result image"
);
expect(
!isAgentReplacementTaskInPendingRun({
task: pendingTask,
pendingStartedAt: Date.parse("2026-08-01T08:02:00.000Z"),
requestMessageId: "request-2",
}),
"an old task must not control a newly submitted detail replacement run"
);
expect(
isAgentReplacementTaskInPendingRun({
task: newerTask,
pendingStartedAt: Date.parse("2026-08-01T08:04:59.000Z"),
requestMessageId: "request-1",
}),
"the task created for the current run must control its detail card"
);
console.log("agent replacement task status tests passed");