@xiaohhhh1/canvas-agent 0.4.75 → 0.4.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ export declare class Logger {
3
3
  readonly enabled: boolean;
4
4
  readonly filePath: string;
5
5
  private readonly logger;
6
+ private failureLogger?;
6
7
  /** 根据命令行 Debug 参数初始化日志输出。 */
7
8
  constructor();
8
9
  /** 输出 Debug 级别日志。 */
@@ -13,5 +14,7 @@ export declare class Logger {
13
14
  warn(message: string, details?: unknown): void;
14
15
  /** 输出 Error 级别日志。 */
15
16
  error(message: string, details?: unknown): void;
17
+ /** Always persist minimal, caller-whitelisted failure metadata; no request bodies or URLs. */
18
+ failure(message: string, details: Record<string, unknown>): void;
16
19
  }
17
20
  export declare const logger: Logger;
@@ -9,6 +9,7 @@ export class Logger {
9
9
  enabled = process.argv.includes("--debug");
10
10
  filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
11
11
  logger;
12
+ failureLogger;
12
13
  /** 根据命令行 Debug 参数初始化日志输出。 */
13
14
  constructor() {
14
15
  if (!this.enabled) {
@@ -53,6 +54,23 @@ export class Logger {
53
54
  else
54
55
  this.logger?.error(message, { details: sanitize(details) });
55
56
  }
57
+ /** Always persist minimal, caller-whitelisted failure metadata; no request bodies or URLs. */
58
+ failure(message, details) {
59
+ try {
60
+ if (!this.failureLogger) {
61
+ const directory = path.join(os.homedir(), ".infinite-canvas", "logs");
62
+ fs.mkdirSync(directory, { recursive: true });
63
+ this.failureLogger = winston.createLogger({
64
+ level: "warn",
65
+ format: format.combine(format.timestamp(), format.json()),
66
+ transports: [new transports.File({ filename: path.join(directory, "canvas-agent-failures.log"), maxsize: 1_000_000, maxFiles: 3, options: { flags: "a", mode: 0o600 } })],
67
+ });
68
+ this.failureLogger.on("error", () => { });
69
+ }
70
+ this.failureLogger.warn(message, sanitize(details));
71
+ }
72
+ catch { /* Failure logging must never lose a saved result or mask the request error. */ }
73
+ }
56
74
  }
57
75
  /** 将日志详情格式化为紧凑的单行文本。 */
58
76
  function formatDetails(details) {
@@ -0,0 +1,34 @@
1
+ export type CommerceRequestDiagnostic = {
2
+ stage: string;
3
+ method: string;
4
+ requestId: string;
5
+ name: string;
6
+ cause?: {
7
+ code: string;
8
+ };
9
+ status?: number;
10
+ attempt: number;
11
+ elapsedMs: number;
12
+ at: string;
13
+ };
14
+ export declare class CommerceRequestError extends Error {
15
+ readonly diagnostic: CommerceRequestDiagnostic;
16
+ readonly retryable: boolean;
17
+ status?: number;
18
+ code?: string;
19
+ resetOrdinals?: unknown;
20
+ rewriteOrdinals?: unknown;
21
+ constructor(message: string, diagnostic: CommerceRequestDiagnostic, retryable: boolean);
22
+ }
23
+ type RequestOptions = {
24
+ expiresAt?: string;
25
+ onFailure?: (diagnostic: CommerceRequestDiagnostic) => void;
26
+ };
27
+ type Transport = {
28
+ fetch: typeof fetch;
29
+ sleep: (ms: number) => Promise<void>;
30
+ now: () => number;
31
+ };
32
+ /** Only read-only requests and the two server-deduplicated handoff deliveries may retry. */
33
+ export declare function commerceJson<T>(url: string, token: string, tokenHeader: string, init?: RequestInit, options?: RequestOptions, transport?: Transport): Promise<T>;
34
+ export {};
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export class CommerceRequestError extends Error {
3
+ diagnostic;
4
+ retryable;
5
+ status;
6
+ code;
7
+ resetOrdinals;
8
+ rewriteOrdinals;
9
+ constructor(message, diagnostic, retryable) {
10
+ super(message);
11
+ this.diagnostic = diagnostic;
12
+ this.retryable = retryable;
13
+ this.name = "CommerceRequestError";
14
+ }
15
+ }
16
+ const defaultTransport = { fetch: (...args) => fetch(...args), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now: () => Date.now() };
17
+ const safeCode = (value) => typeof value === "string" && /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(value) ? value : undefined;
18
+ /** Only read-only requests and the two server-deduplicated handoff deliveries may retry. */
19
+ export async function commerceJson(url, token, tokenHeader, init = {}, options = {}, transport = defaultTransport) {
20
+ const method = String(init.method || "GET").toUpperCase();
21
+ const pathname = new URL(url).pathname;
22
+ const handoff = /^\/api\/commerce\/workflow-script-handoffs\/[0-9a-f-]{36}\/(task|product-profiles|draft-chunks)$/.exec(pathname);
23
+ const canRetry = Boolean(handoff && (method === "GET" && handoff[1] === "task" || method === "POST" && typeof init.body === "string" && ["product-profiles", "draft-chunks"].includes(handoff[1])));
24
+ const stage = handoff?.[1] || "commerce-request";
25
+ const requestId = randomUUID();
26
+ const headers = new Headers(init.headers);
27
+ headers.set("accept", "application/json");
28
+ headers.set(tokenHeader, token);
29
+ headers.set("x-request-id", requestId);
30
+ if (init.body)
31
+ headers.set("content-type", "application/json");
32
+ // Capture the exact serialized payload once; retries never rebuild model output.
33
+ const request = { ...init, method, headers };
34
+ const startedAt = transport.now();
35
+ const expiresAt = options.expiresAt === undefined ? Infinity : typeof options.expiresAt === "string" ? Date.parse(options.expiresAt) : NaN;
36
+ const invalidCapability = options.expiresAt !== undefined && !Number.isFinite(expiresAt);
37
+ for (let attempt = 1;; attempt += 1) {
38
+ let status;
39
+ let body = {};
40
+ let expired = false;
41
+ try {
42
+ if (invalidCapability)
43
+ throw new Error("InvalidCapability");
44
+ if (init.signal?.aborted)
45
+ throw new Error("AbortError");
46
+ const remaining = expiresAt - transport.now();
47
+ if (remaining <= 0) {
48
+ expired = true;
49
+ throw new Error("CapabilityExpired");
50
+ }
51
+ const response = await transport.fetch(url, { ...request, signal: AbortSignal.timeout(Math.max(1, Math.min(120_000, Number.isFinite(remaining) ? remaining : 120_000))) });
52
+ status = response.status;
53
+ // A lost/truncated successful response is ambiguous delivery, never an empty success.
54
+ try {
55
+ const value = await response.json();
56
+ if (!value || typeof value !== "object" || Array.isArray(value))
57
+ throw new SyntaxError("InvalidResponse");
58
+ body = value;
59
+ }
60
+ catch (error) {
61
+ if (response.ok)
62
+ throw error;
63
+ }
64
+ if (!response.ok)
65
+ throw new Error("HTTPError");
66
+ if (handoff)
67
+ validateHandoffResponse(stage, body, pathname.split("/").at(-2));
68
+ return body;
69
+ }
70
+ catch (cause) {
71
+ const aborted = Boolean(init.signal?.aborted);
72
+ const error = cause;
73
+ const causeCode = safeCode(error?.cause?.code) || safeCode(error?.code);
74
+ const diagnostic = {
75
+ stage, method, requestId, name: invalidCapability ? "InvalidCapability" : expired ? "CapabilityExpired" : aborted ? "AbortError" : status && status >= 400 ? "HTTPError" : safeCode(error?.name) || "Error",
76
+ ...(causeCode ? { cause: { code: causeCode } } : {}), ...(status ? { status } : {}),
77
+ attempt, elapsedMs: Math.max(0, transport.now() - startedAt), at: new Date(transport.now()).toISOString(),
78
+ };
79
+ const contractFailure = /invalid_json_schema|invalid_response_format|invalid response_format/i.test(`${body.code || ""} ${body.error || ""}`);
80
+ const retryable = !invalidCapability && !expired && !aborted && !contractFailure && (status === undefined || status >= 200 && status < 300 || [408, 429, 500, 502, 503, 504].includes(status));
81
+ const message = invalidCapability ? "脚本交接有效期无效,请在网页重新授权交接"
82
+ : expired ? "脚本交接能力已过期,请在网页重新授权交接"
83
+ : aborted ? "中心请求已取消,已保留本机结果"
84
+ : status && status >= 400 ? String(body.error || `中心接口返回 ${status}`).split(token).join("[REDACTED]").replace(/https?:\/\/\S+/gi, "[URL]").slice(0, 500)
85
+ : `中心${stage === "task" ? "任务读取" : "结果回传"}连接失败,已保留本机结果,请重试(诊断 ${requestId})`;
86
+ const failure = new CommerceRequestError(message, diagnostic, retryable);
87
+ failure.status = status;
88
+ failure.code = invalidCapability ? "WORKFLOW_CAPABILITY_INVALID" : expired ? "WORKFLOW_CAPABILITY_EXPIRED" : aborted ? "WORKFLOW_REQUEST_ABORTED" : safeCode(body.code);
89
+ failure.resetOrdinals = body.resetOrdinals;
90
+ failure.rewriteOrdinals = body.rewriteOrdinals;
91
+ options.onFailure?.(diagnostic);
92
+ if (!canRetry || !retryable || attempt >= 3 || init.signal?.aborted)
93
+ throw failure;
94
+ const delay = attempt === 1 ? 500 : 1500;
95
+ if (transport.now() + delay >= expiresAt)
96
+ throw failure;
97
+ await transport.sleep(delay);
98
+ }
99
+ }
100
+ }
101
+ function validateHandoffResponse(stage, body, handoffId) {
102
+ if (stage === "task") {
103
+ const task = body.handoff;
104
+ if (task && task.id === handoffId && Array.isArray(task.received_ordinals))
105
+ return;
106
+ }
107
+ else {
108
+ const counts = stage === "draft-chunks" ? ["accepted", "received", "requestedCount"] : ["accepted", "profiled", "requestedProducts", "selected", "requestedCount"];
109
+ if (counts.every((field) => Number.isInteger(body[field]) && Number(body[field]) >= 0) && typeof body.status === "string"
110
+ && (stage !== "draft-chunks" || Array.isArray(body.receivedOrdinals)))
111
+ return;
112
+ }
113
+ throw new SyntaxError("InvalidHandoffResponse");
114
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AgentEmit } from "../agent/types.js";
2
2
  import { type CanvasAgentConfig } from "../config.js";
3
3
  import { type FlowCProductExecutionProfile } from "./product-profile.js";
4
+ import { type CommerceRequestDiagnostic } from "./commerce-http.js";
4
5
  export declare const FLOW_C_CODEX_TURN_TIMEOUT_MS: number;
5
6
  export declare const FLOW_C_SELECTED_BLUEPRINT_PROMPT_MAX_CHARS = 36000;
6
7
  export declare const FLOW_C_SCRIPT_REWRITE_MAX_ATTEMPTS = 2;
@@ -24,6 +25,9 @@ type ScriptRecord = {
24
25
  chunkSize?: number;
25
26
  activeChunks?: number;
26
27
  productProfiles?: FlowCProductExecutionProfile[];
28
+ pendingScriptJobs?: DraftJob[];
29
+ lastFailure?: CommerceRequestDiagnostic;
30
+ retryRequested?: boolean;
27
31
  priorityAt?: string;
28
32
  updatedAt: string;
29
33
  };
@@ -147,10 +151,32 @@ type ScriptTask = {
147
151
  received_ordinals: number[];
148
152
  expires_at: string;
149
153
  };
154
+ type DraftJob = {
155
+ ordinal: number;
156
+ productIndex: number;
157
+ sellingFormId: string;
158
+ script: string;
159
+ expectedCandidateRevision?: string;
160
+ masterScript?: string;
161
+ creativePlan?: Record<string, unknown>;
162
+ executionBindings?: Record<string, unknown>;
163
+ creativeTags?: Record<string, unknown>;
164
+ voiceProfile?: Record<string, string>;
165
+ qualityGate?: Record<string, string>;
166
+ segmentVoiceovers?: string[][];
167
+ segment?: {
168
+ script: string;
169
+ continuityMode: "continue" | "reset";
170
+ };
171
+ segments?: Array<{
172
+ script: string;
173
+ continuityMode: "continue" | "reset";
174
+ }>;
175
+ };
150
176
  type ScriptChunkResult = {
151
177
  error?: string;
152
178
  terminal: boolean;
153
- terminalKind?: "contract" | "transport" | "timeout" | "turn";
179
+ terminalKind?: "contract" | "transport" | "timeout" | "turn" | "delivery";
154
180
  replanOrdinals?: number[];
155
181
  };
156
182
  /** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
@@ -215,22 +241,12 @@ export declare class WorkflowManager {
215
241
  receivedOrdinals?: number[];
216
242
  requestedCount: number;
217
243
  status: string;
218
- } & {
219
- error?: string;
220
- code?: string;
221
- resetOrdinals?: unknown;
222
- rewriteOrdinals?: unknown;
223
244
  }>;
224
245
  submitCandidateChunk(idValue: unknown, groupsValue: unknown): Promise<{
225
246
  accepted: number;
226
247
  selected: number;
227
248
  requestedCount: number;
228
249
  selectedCandidates: SelectedCandidate[];
229
- } & {
230
- error?: string;
231
- code?: string;
232
- resetOrdinals?: unknown;
233
- rewriteOrdinals?: unknown;
234
250
  }>;
235
251
  submitProductProfileChunk(idValue: unknown, profilesValue: unknown[], contractVersion?: string): Promise<{
236
252
  accepted: number;
@@ -239,12 +255,8 @@ export declare class WorkflowManager {
239
255
  selected: number;
240
256
  requestedCount: number;
241
257
  status: string;
242
- } & {
243
- error?: string;
244
- code?: string;
245
- resetOrdinals?: unknown;
246
- rewriteOrdinals?: unknown;
247
258
  }>;
259
+ private scriptRequestOptions;
248
260
  downloadState(): {
249
261
  configured: boolean;
250
262
  directoryName: string | undefined;
@@ -422,4 +434,5 @@ export declare function nextScriptPipelineWave(task: Pick<ScriptTask, "requested
422
434
  };
423
435
  export declare function immediateScriptOrdinals(task: Pick<ScriptTask, "selected_candidates">, receivedOrdinals: number[], candidateOrdinals: number[]): number[];
424
436
  export declare function missingOrdinals(total: number, received: number[]): number[];
437
+ export declare function mergeProductProfiles(local?: FlowCProductExecutionProfile[], confirmed?: FlowCProductExecutionProfile[]): FlowCProductExecutionProfile[];
425
438
  export {};
@@ -13,6 +13,7 @@ import { FLOW_C_SCRIPT_CHUNK_MAX, flowCScriptChunks, flowCScriptChunkSizes } fro
13
13
  import { FLOW_C_CREATIVE_CANDIDATE_CONTRACT_VERSION, flowCCreativeCandidateOutputSchema, parseFlowCCreativeCandidateOutput } from "./creative-candidates.js";
14
14
  import { FLOW_C_LEGACY_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION, FLOW_C_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION, flowCProductExecutionProfileOutputSchema, parseFlowCProductExecutionProfileOutput } from "./product-profile.js";
15
15
  import { flowCScriptOutputSchema, parseFlowCScriptOutput } from "./script-output.js";
16
+ import { commerceJson, CommerceRequestError } from "./commerce-http.js";
16
17
  const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
17
18
  export const FLOW_C_CODEX_TURN_TIMEOUT_MS = 8 * 60 * 1000;
18
19
  export const FLOW_C_SELECTED_BLUEPRINT_PROMPT_MAX_CHARS = 36_000;
@@ -67,6 +68,8 @@ export class WorkflowManager {
67
68
  chunkSize: previous?.chunkSize,
68
69
  activeChunks: 0,
69
70
  productProfiles: previous?.productProfiles || [],
71
+ pendingScriptJobs: previous?.pendingScriptJobs || [],
72
+ lastFailure: previous?.lastFailure,
70
73
  priorityAt: now(),
71
74
  message: previous?.status === "complete" ? previous.message : "已进入本机 Codex 队列",
72
75
  updatedAt: now(),
@@ -78,6 +81,15 @@ export class WorkflowManager {
78
81
  retryScript(idValue) {
79
82
  const id = workflowId(idValue, "脚本交接 ID");
80
83
  const record = this.scriptRecord(id);
84
+ if (this.runningScripts.has(id)) {
85
+ record.retryRequested = true;
86
+ record.priorityAt = now();
87
+ record.message = "当前任务仍在处理,结束后优先重试";
88
+ record.updatedAt = now();
89
+ this.save();
90
+ return this.scriptStatus(id);
91
+ }
92
+ delete record.retryRequested;
81
93
  record.status = "queued";
82
94
  record.activeChunks = 0;
83
95
  // A failed manual attempt may leave a very large or interrupted thread.
@@ -97,12 +109,13 @@ export class WorkflowManager {
97
109
  /** MCP 读取服务端持久化的完整任务,不向模型暴露令牌。 */
98
110
  async scriptTask(idValue) {
99
111
  const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
100
- const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/task`, record.accessToken, "x-workflow-handoff-token");
112
+ const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/task`, record.accessToken, "x-workflow-handoff-token", {}, this.scriptRequestOptions(record));
101
113
  const task = data.handoff;
102
114
  record.requestedCount = Number(task.requested_count || 0);
103
- record.receivedOrdinals = Array.isArray(task.received_ordinals) ? task.received_ordinals.map(Number).filter(Number.isInteger).sort((left, right) => left - right) : [];
104
- if (Array.isArray(task.product_execution_profiles))
105
- record.productProfiles = task.product_execution_profiles;
115
+ record.receivedOrdinals = [...new Set([...record.receivedOrdinals, ...task.received_ordinals.map(Number).filter(Number.isInteger)])].sort((left, right) => left - right);
116
+ task.received_ordinals = record.receivedOrdinals;
117
+ record.productProfiles = mergeProductProfiles(record.productProfiles, task.product_execution_profiles);
118
+ record.pendingScriptJobs = (record.pendingScriptJobs || []).filter((job) => !record.receivedOrdinals.includes(job.ordinal));
106
119
  record.expiresAt = task.expires_at;
107
120
  record.updatedAt = now();
108
121
  this.save();
@@ -114,7 +127,13 @@ export class WorkflowManager {
114
127
  if (!Array.isArray(jobsValue) || !jobsValue.length || jobsValue.length > FLOW_C_SCRIPT_CHUNK_MAX)
115
128
  throw new Error(`每段必须包含 1–${FLOW_C_SCRIPT_CHUNK_MAX} 条脚本`);
116
129
  const jobs = jobsValue;
117
- const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/draft-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ jobs }) });
130
+ const pending = new Map((record.pendingScriptJobs || []).map((job) => [job.ordinal, job]));
131
+ for (const job of jobs)
132
+ if (!record.receivedOrdinals.includes(job.ordinal))
133
+ pending.set(job.ordinal, structuredClone(job));
134
+ record.pendingScriptJobs = [...pending.values()].sort((left, right) => left.ordinal - right.ordinal);
135
+ this.save();
136
+ const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/draft-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ jobs }) }, this.scriptRequestOptions(record));
118
137
  if (Array.isArray(data.receivedOrdinals))
119
138
  record.receivedOrdinals = [...new Set([...record.receivedOrdinals, ...data.receivedOrdinals.map(Number).filter(Number.isInteger)])].sort((left, right) => left - right);
120
139
  else {
@@ -125,6 +144,7 @@ export class WorkflowManager {
125
144
  record.receivedOrdinals = [...accepted].sort((a, b) => a - b);
126
145
  }
127
146
  record.requestedCount = Number(data.requestedCount || record.requestedCount);
147
+ record.pendingScriptJobs = (record.pendingScriptJobs || []).filter((job) => !record.receivedOrdinals.includes(job.ordinal));
128
148
  record.status = data.status === "ready" ? "complete" : "running";
129
149
  record.message = data.status === "ready" ? `全部 ${record.requestedCount} 条高质量脚本已回传` : `已回传 ${data.received}/${data.requestedCount} 条脚本`;
130
150
  record.updatedAt = now();
@@ -135,13 +155,21 @@ export class WorkflowManager {
135
155
  const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
136
156
  if (!Array.isArray(groupsValue) || !groupsValue.length || groupsValue.length > 10)
137
157
  throw new Error("每个候选子批必须包含 1–10 个 ordinal 组");
138
- return commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/candidate-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ contractVersion: FLOW_C_CREATIVE_CANDIDATE_CONTRACT_VERSION, groups: groupsValue }) });
158
+ return commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/candidate-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ contractVersion: FLOW_C_CREATIVE_CANDIDATE_CONTRACT_VERSION, groups: groupsValue }) }, this.scriptRequestOptions(record));
139
159
  }
140
160
  async submitProductProfileChunk(idValue, profilesValue, contractVersion = FLOW_C_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION) {
141
161
  const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
142
162
  if (!Array.isArray(profilesValue) || profilesValue.length > 10)
143
163
  throw new Error("每个商品执行档案子批最多 10 个产品");
144
- return commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/product-profiles`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ contractVersion, profiles: profilesValue }) });
164
+ return commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/product-profiles`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ contractVersion, profiles: profilesValue }) }, this.scriptRequestOptions(record));
165
+ }
166
+ scriptRequestOptions(record) {
167
+ return { expiresAt: record.expiresAt, onFailure: (diagnostic) => {
168
+ record.lastFailure = diagnostic;
169
+ record.updatedAt = now();
170
+ this.save();
171
+ logger.failure("Flow C center request failed", { handoffId: record.id, ...diagnostic });
172
+ } };
145
173
  }
146
174
  downloadState() {
147
175
  return {
@@ -260,6 +288,7 @@ export class WorkflowManager {
260
288
  const record = this.scriptRecord(id);
261
289
  try {
262
290
  delete record.priorityAt;
291
+ delete record.retryRequested;
263
292
  record.status = "running";
264
293
  record.message = "正在读取完整产品清单";
265
294
  record.updatedAt = now();
@@ -311,6 +340,8 @@ export class WorkflowManager {
311
340
  task = await this.scriptTask(id);
312
341
  const terminalFailure = terminalScriptChunkFailure(results);
313
342
  if (terminalFailure) {
343
+ if (terminalFailure.terminalKind === "delivery")
344
+ throw new Error(terminalFailure.error);
314
345
  if (terminalFailure.terminalKind === "transport")
315
346
  throw new Error(`创意或脚本阶段的本机 Codex 进程连续异常且自动恢复未成功,已停止当前任务且未提交缺失脚本(${terminalFailure.error})`);
316
347
  throw new Error(`创意或脚本结构化契约被 Codex 拒绝,已停止自动重试(${terminalFailure.error})`);
@@ -324,6 +355,8 @@ export class WorkflowManager {
324
355
  const replanResults = await Promise.all(replanOrdinals.map((ordinal) => this.runCandidateChunk(id, task, [ordinal], workspace.workspacePath)));
325
356
  const replanTerminalFailure = terminalScriptChunkFailure(replanResults);
326
357
  if (replanTerminalFailure) {
358
+ if (replanTerminalFailure.terminalKind === "delivery")
359
+ throw new Error(replanTerminalFailure.error);
327
360
  if (replanTerminalFailure.terminalKind === "transport")
328
361
  throw new Error(`创意重新选题时本机 Codex 进程异常且自动恢复未成功,已停止当前任务(${replanTerminalFailure.error})`);
329
362
  throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalFailure.error})`);
@@ -358,6 +391,8 @@ export class WorkflowManager {
358
391
  task = await this.scriptTask(id);
359
392
  const terminalFailure = terminalScriptChunkFailure(results);
360
393
  if (terminalFailure) {
394
+ if (terminalFailure.terminalKind === "delivery")
395
+ throw new Error(terminalFailure.error);
361
396
  if (terminalFailure.terminalKind === "transport")
362
397
  throw new Error(`本机 Codex 脚本引擎连续异常且自动恢复未成功,已停止当前任务且未提交缺失脚本。诊断已保存在本机 Agent 日志中(${terminalFailure.error})`);
363
398
  throw new Error(`本机脚本结构化契约被 Codex 拒绝,已停止自动重试且未提交缺失脚本。请先升级或修复 Canvas Agent,再手动重试(${terminalFailure.error})`);
@@ -371,6 +406,8 @@ export class WorkflowManager {
371
406
  const replanResults = await Promise.all(replanOrdinals.map((ordinal) => this.runCandidateChunk(id, task, [ordinal], workspace.workspacePath)));
372
407
  const replanTerminalFailure = terminalScriptChunkFailure(replanResults);
373
408
  if (replanTerminalFailure) {
409
+ if (replanTerminalFailure.terminalKind === "delivery")
410
+ throw new Error(replanTerminalFailure.error);
374
411
  if (replanTerminalFailure.terminalKind === "transport")
375
412
  throw new Error(`创意重新选题时本机 Codex 进程异常且自动恢复未成功,已停止当前任务(${replanTerminalFailure.error})`);
376
413
  throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalFailure.error})`);
@@ -405,15 +442,26 @@ export class WorkflowManager {
405
442
  throw new Error(`中心校验尚未通过(${record.receivedOrdinals.length}/${task.requested_count} 条),请点击重试`);
406
443
  }
407
444
  catch (error) {
408
- record.status = error instanceof ExpiredCapabilityError ? "expired" : "error";
445
+ const expired = error instanceof ExpiredCapabilityError || error instanceof CommerceRequestError && error.code === "WORKFLOW_CAPABILITY_EXPIRED"
446
+ || record.lastFailure?.name === "CapabilityExpired" && Date.parse(record.expiresAt || "") <= Date.now();
447
+ record.status = expired ? "expired" : "error";
409
448
  record.message = error instanceof Error ? error.message : "本机 Codex 处理失败";
410
449
  logger.warn("Local Flow C script handoff paused", { handoffId: id, error: record.message });
411
450
  }
412
451
  finally {
452
+ const retry = record.retryRequested && record.status !== "complete";
453
+ delete record.retryRequested;
454
+ if (retry) {
455
+ record.status = "queued";
456
+ delete record.threadId;
457
+ record.message = "已保存本机结果,正在优先重试";
458
+ }
413
459
  record.updatedAt = now();
414
460
  record.activeChunks = 0;
415
461
  this.save();
416
462
  this.runningScripts.delete(id);
463
+ if (retry)
464
+ this.scheduleScript(id);
417
465
  }
418
466
  }
419
467
  /** Analyze each product once; image 1 owns identity and later images may only corroborate visible evidence. */
@@ -438,21 +486,37 @@ export class WorkflowManager {
438
486
  && missing.some((product) => (product.productImageUrlsInExactOrder || []).length > 1);
439
487
  const profileConcurrency = includesSupportingImages ? 1 : FLOW_C_CODEX_WORKER_CONCURRENCY;
440
488
  const workerCount = Math.min(missing.length, Math.max(1, profileConcurrency));
441
- await Promise.all(Array.from({ length: workerCount }, async () => {
442
- while (nextProduct < missing.length) {
443
- const product = missing[nextProduct++];
444
- const productIndex = Number(product.productIndex);
445
- let profile = cached.get(productIndex);
446
- if (!profile) {
447
- profile = await this.runProductExecutionProfile(id, product, cwd, profileContractVersion);
448
- cached.set(productIndex, profile);
449
- record.productProfiles = [...cached.values()].sort((left, right) => left.productIndex - right.productIndex);
450
- record.updatedAt = now();
451
- this.save();
489
+ let stopped = false;
490
+ let firstFailure;
491
+ // Do not release the handoff lock while a sibling model turn can
492
+ // still produce a result. A quick retry must see its durable cache.
493
+ await Promise.allSettled(Array.from({ length: workerCount }, async () => {
494
+ try {
495
+ while (!stopped && nextProduct < missing.length) {
496
+ const product = missing[nextProduct++];
497
+ const productIndex = Number(product.productIndex);
498
+ let profile = cached.get(productIndex);
499
+ if (!profile) {
500
+ profile = await this.runProductExecutionProfile(id, product, cwd, profileContractVersion);
501
+ cached.set(productIndex, profile);
502
+ record.productProfiles = [...cached.values()].sort((left, right) => left.productIndex - right.productIndex);
503
+ record.updatedAt = now();
504
+ this.save();
505
+ }
506
+ if (stopped)
507
+ return;
508
+ await this.submitProductProfileChunk(id, [profile], profileContractVersion);
452
509
  }
453
- await this.submitProductProfileChunk(id, [profile], profileContractVersion);
510
+ }
511
+ catch (error) {
512
+ if (!stopped)
513
+ firstFailure = error;
514
+ stopped = true;
515
+ throw error;
454
516
  }
455
517
  }));
518
+ if (stopped)
519
+ throw firstFailure;
456
520
  task = await this.scriptTask(id);
457
521
  }
458
522
  const profiled = Number(task.product_profile_stage?.completed || task.product_execution_profiles?.length || 0);
@@ -532,33 +596,44 @@ export class WorkflowManager {
532
596
  }
533
597
  /** 一个 chunk 使用独立线程;解析与中心持久化失败不会影响并行 lane。 */
534
598
  async runScriptChunk(id, task, ordinals, cwd, rewriteAttempt = 0, revisionAttempt = 0) {
535
- const durationSeconds = Number(task.duration_seconds || 10);
536
- let prompt;
537
- try {
538
- prompt = scriptChunkPrompt(id, task, ordinals, rewriteAttempt);
539
- }
540
- catch (error) {
541
- if (isFlowCPromptPayloadTooLarge(error))
542
- return { error: error.message, terminal: false };
543
- throw error;
544
- }
545
- const result = await runCodexWorkflowTurn(prompt, this.emit, {
546
- cwd,
547
- permissionMode: "full",
548
- timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
549
- outputSchema: flowCScriptOutputSchema(durationSeconds, ordinals.length),
550
- onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
551
- onWorkerStart: () => { const record = this.scriptRecord(id); record.activeChunks = Number(record.activeChunks || 0) + 1; record.updatedAt = now(); this.save(); },
552
- onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
553
- });
554
- this.emitScriptStage(id, ordinals, "queue_wait", result.timings.queueWaitMs);
555
- this.emitScriptStage(id, ordinals, "thread_start", result.timings.threadStartMs);
556
- this.emitScriptStage(id, ordinals, "model", result.timings.modelMs);
557
- if (!result.ok)
558
- return { error: result.error, terminal: !result.retryable, ...(!result.retryable ? { terminalKind: result.failureKind } : {}) };
559
- if (!result.text)
560
- return { error: "Codex 未返回可用的结构化脚本", terminal: false };
561
599
  try {
600
+ const record = this.scriptRecord(id);
601
+ ordinals = ordinals.filter((ordinal) => !record.receivedOrdinals.includes(ordinal));
602
+ if (!ordinals.length)
603
+ return { terminal: false };
604
+ const cached = (record.pendingScriptJobs || []).filter((job) => ordinals.includes(job.ordinal));
605
+ if (cached.length) {
606
+ await this.submitGeneratedScriptJobs(id, cached);
607
+ ordinals = ordinals.filter((ordinal) => !record.receivedOrdinals.includes(ordinal));
608
+ if (!ordinals.length)
609
+ return { terminal: false };
610
+ }
611
+ const durationSeconds = Number(task.duration_seconds || 10);
612
+ let prompt;
613
+ try {
614
+ prompt = scriptChunkPrompt(id, task, ordinals, rewriteAttempt);
615
+ }
616
+ catch (error) {
617
+ if (isFlowCPromptPayloadTooLarge(error))
618
+ return { error: error.message, terminal: false };
619
+ throw error;
620
+ }
621
+ const result = await runCodexWorkflowTurn(prompt, this.emit, {
622
+ cwd,
623
+ permissionMode: "full",
624
+ timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
625
+ outputSchema: flowCScriptOutputSchema(durationSeconds, ordinals.length),
626
+ onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
627
+ onWorkerStart: () => { const record = this.scriptRecord(id); record.activeChunks = Number(record.activeChunks || 0) + 1; record.updatedAt = now(); this.save(); },
628
+ onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
629
+ });
630
+ this.emitScriptStage(id, ordinals, "queue_wait", result.timings.queueWaitMs);
631
+ this.emitScriptStage(id, ordinals, "thread_start", result.timings.threadStartMs);
632
+ this.emitScriptStage(id, ordinals, "model", result.timings.modelMs);
633
+ if (!result.ok)
634
+ return { error: result.error, terminal: !result.retryable, ...(!result.retryable ? { terminalKind: result.failureKind } : {}) };
635
+ if (!result.text)
636
+ return { error: "Codex 未返回可用的结构化脚本", terminal: false };
562
637
  const parseStartedAt = Date.now();
563
638
  const selected = selectedCandidatesForOrdinals(task, ordinals);
564
639
  const jobs = parseFlowCScriptOutput(result.text, ordinals).map((job) => ({
@@ -574,6 +649,14 @@ export class WorkflowManager {
574
649
  return { terminal: false };
575
650
  }
576
651
  catch (error) {
652
+ // Only explicit semantic rejection may discard the affected saved output.
653
+ // Network/response loss, auth failures and validation stops retain it.
654
+ const invalidated = [...candidateRevisionChangedOrdinals(error), ...scriptRewriteOrdinals(error), ...creativeReplanOrdinals(error)];
655
+ if (invalidated.length) {
656
+ const record = this.scriptRecord(id);
657
+ record.pendingScriptJobs = (record.pendingScriptJobs || []).filter((job) => !invalidated.includes(job.ordinal));
658
+ this.save();
659
+ }
577
660
  const revisionOrdinals = candidateRevisionChangedOrdinals(error);
578
661
  if (revisionOrdinals.length && !creativeReplanOrdinals(error).length) {
579
662
  if (revisionAttempt >= FLOW_C_CANDIDATE_REVISION_MAX_ATTEMPTS) {
@@ -632,6 +715,8 @@ export class WorkflowManager {
632
715
  };
633
716
  return preserveScriptRecoveryReplans(rewriteResult, error);
634
717
  }
718
+ if (error instanceof CommerceRequestError && !creativeReplanOrdinals(error).length)
719
+ return { error: error.message, terminal: true, terminalKind: "delivery" };
635
720
  return {
636
721
  error: error instanceof Error ? `结构化脚本未通过本机校验:${error.message}` : "结构化脚本未通过本机校验",
637
722
  terminal: terminalScriptValidationError(error),
@@ -653,7 +738,7 @@ export class WorkflowManager {
653
738
  return;
654
739
  }
655
740
  catch (error) {
656
- if (jobs.length === 1)
741
+ if (jobs.length === 1 || error instanceof CommerceRequestError && ![400, 409, 422].includes(error.status || 0))
657
742
  throw error;
658
743
  let accepted = 0;
659
744
  const failures = [];
@@ -663,6 +748,8 @@ export class WorkflowManager {
663
748
  accepted += 1;
664
749
  }
665
750
  catch (jobError) {
751
+ if (jobError instanceof CommerceRequestError && ![400, 409, 422].includes(jobError.status || 0))
752
+ throw jobError;
666
753
  failures.push(jobError);
667
754
  }
668
755
  }
@@ -1326,23 +1413,11 @@ function safeDownloadUrl(value) { const url = new URL(String(value || "")); if (
1326
1413
  throw new Error("下载地址必须使用 HTTPS"); return url.toString(); }
1327
1414
  function safeName(value) { return String(value || "市场").replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-").replace(/[. ]+$/g, "").slice(0, 60) || "市场"; }
1328
1415
  function now() { return new Date().toISOString(); }
1329
- async function commerceJson(url, token, tokenHeader, init = {}) {
1330
- const headers = new Headers(init.headers);
1331
- headers.set("accept", "application/json");
1332
- headers.set(tokenHeader, token);
1333
- if (init.body)
1334
- headers.set("content-type", "application/json");
1335
- const response = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(120_000) });
1336
- const body = await response.json().catch(() => ({}));
1337
- if (!response.ok) {
1338
- const error = new Error(body.error || `中心接口返回 ${response.status}`);
1339
- error.status = response.status;
1340
- error.code = body.code;
1341
- error.resetOrdinals = body.resetOrdinals;
1342
- error.rewriteOrdinals = body.rewriteOrdinals;
1343
- throw error;
1344
- }
1345
- return body;
1416
+ export function mergeProductProfiles(local = [], confirmed = []) {
1417
+ const merged = new Map(local.map((profile) => [Number(profile.productIndex), profile]));
1418
+ for (const profile of confirmed || [])
1419
+ merged.set(Number(profile.productIndex), profile);
1420
+ return [...merged.values()].sort((left, right) => left.productIndex - right.productIndex);
1346
1421
  }
1347
1422
  function loadState() {
1348
1423
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.75",
3
+ "version": "0.4.76",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",