@xiaohhhh1/canvas-agent 0.4.74 → 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.
@@ -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();
@@ -309,9 +338,14 @@ export class WorkflowManager {
309
338
  }));
310
339
  const results = pipelineResults.flat();
311
340
  task = await this.scriptTask(id);
312
- const terminalError = terminalScriptChunkError(results);
313
- if (terminalError)
314
- throw new Error(`创意或脚本结构化契约被 Codex 拒绝,已停止自动重试(${terminalError})`);
341
+ const terminalFailure = terminalScriptChunkFailure(results);
342
+ if (terminalFailure) {
343
+ if (terminalFailure.terminalKind === "delivery")
344
+ throw new Error(terminalFailure.error);
345
+ if (terminalFailure.terminalKind === "transport")
346
+ throw new Error(`创意或脚本阶段的本机 Codex 进程连续异常且自动恢复未成功,已停止当前任务且未提交缺失脚本(${terminalFailure.error})`);
347
+ throw new Error(`创意或脚本结构化契约被 Codex 拒绝,已停止自动重试(${terminalFailure.error})`);
348
+ }
315
349
  const replanOrdinals = scriptCreativeReplanOrdinals(results);
316
350
  if (replanOrdinals.length) {
317
351
  recordCreativeReplanAttempts(creativeReplanAttempts, replanOrdinals);
@@ -319,9 +353,14 @@ export class WorkflowManager {
319
353
  record.updatedAt = now();
320
354
  this.save();
321
355
  const replanResults = await Promise.all(replanOrdinals.map((ordinal) => this.runCandidateChunk(id, task, [ordinal], workspace.workspacePath)));
322
- const replanTerminalError = terminalScriptChunkError(replanResults);
323
- if (replanTerminalError)
324
- throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalError})`);
356
+ const replanTerminalFailure = terminalScriptChunkFailure(replanResults);
357
+ if (replanTerminalFailure) {
358
+ if (replanTerminalFailure.terminalKind === "delivery")
359
+ throw new Error(replanTerminalFailure.error);
360
+ if (replanTerminalFailure.terminalKind === "transport")
361
+ throw new Error(`创意重新选题时本机 Codex 进程异常且自动恢复未成功,已停止当前任务(${replanTerminalFailure.error})`);
362
+ throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalFailure.error})`);
363
+ }
325
364
  const replanError = replanResults.find((result) => result.error)?.error;
326
365
  if (replanError)
327
366
  throw new Error(`创意重新选题失败(${replanError}),请点击重试`);
@@ -350,9 +389,13 @@ export class WorkflowManager {
350
389
  this.save();
351
390
  const results = await Promise.all(wave.chunks.map((ordinals) => this.runScriptChunk(id, task, ordinals, workspace.workspacePath)));
352
391
  task = await this.scriptTask(id);
353
- const terminalError = terminalScriptChunkError(results);
354
- if (terminalError) {
355
- throw new Error(`本机脚本结构化契约被 Codex 拒绝,已停止自动重试且未提交缺失脚本。请先升级或修复 Canvas Agent,再手动重试(${terminalError})`);
392
+ const terminalFailure = terminalScriptChunkFailure(results);
393
+ if (terminalFailure) {
394
+ if (terminalFailure.terminalKind === "delivery")
395
+ throw new Error(terminalFailure.error);
396
+ if (terminalFailure.terminalKind === "transport")
397
+ throw new Error(`本机 Codex 脚本引擎连续异常且自动恢复未成功,已停止当前任务且未提交缺失脚本。诊断已保存在本机 Agent 日志中(${terminalFailure.error})`);
398
+ throw new Error(`本机脚本结构化契约被 Codex 拒绝,已停止自动重试且未提交缺失脚本。请先升级或修复 Canvas Agent,再手动重试(${terminalFailure.error})`);
356
399
  }
357
400
  const replanOrdinals = scriptCreativeReplanOrdinals(results);
358
401
  if (replanOrdinals.length) {
@@ -361,9 +404,14 @@ export class WorkflowManager {
361
404
  record.updatedAt = now();
362
405
  this.save();
363
406
  const replanResults = await Promise.all(replanOrdinals.map((ordinal) => this.runCandidateChunk(id, task, [ordinal], workspace.workspacePath)));
364
- const replanTerminalError = terminalScriptChunkError(replanResults);
365
- if (replanTerminalError)
366
- throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalError})`);
407
+ const replanTerminalFailure = terminalScriptChunkFailure(replanResults);
408
+ if (replanTerminalFailure) {
409
+ if (replanTerminalFailure.terminalKind === "delivery")
410
+ throw new Error(replanTerminalFailure.error);
411
+ if (replanTerminalFailure.terminalKind === "transport")
412
+ throw new Error(`创意重新选题时本机 Codex 进程异常且自动恢复未成功,已停止当前任务(${replanTerminalFailure.error})`);
413
+ throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalFailure.error})`);
414
+ }
367
415
  const replanError = replanResults.find((result) => result.error)?.error;
368
416
  if (replanError)
369
417
  throw new Error(`创意重新选题失败(${replanError}),请点击重试`);
@@ -394,15 +442,26 @@ export class WorkflowManager {
394
442
  throw new Error(`中心校验尚未通过(${record.receivedOrdinals.length}/${task.requested_count} 条),请点击重试`);
395
443
  }
396
444
  catch (error) {
397
- 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";
398
448
  record.message = error instanceof Error ? error.message : "本机 Codex 处理失败";
399
449
  logger.warn("Local Flow C script handoff paused", { handoffId: id, error: record.message });
400
450
  }
401
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
+ }
402
459
  record.updatedAt = now();
403
460
  record.activeChunks = 0;
404
461
  this.save();
405
462
  this.runningScripts.delete(id);
463
+ if (retry)
464
+ this.scheduleScript(id);
406
465
  }
407
466
  }
408
467
  /** Analyze each product once; image 1 owns identity and later images may only corroborate visible evidence. */
@@ -427,21 +486,37 @@ export class WorkflowManager {
427
486
  && missing.some((product) => (product.productImageUrlsInExactOrder || []).length > 1);
428
487
  const profileConcurrency = includesSupportingImages ? 1 : FLOW_C_CODEX_WORKER_CONCURRENCY;
429
488
  const workerCount = Math.min(missing.length, Math.max(1, profileConcurrency));
430
- await Promise.all(Array.from({ length: workerCount }, async () => {
431
- while (nextProduct < missing.length) {
432
- const product = missing[nextProduct++];
433
- const productIndex = Number(product.productIndex);
434
- let profile = cached.get(productIndex);
435
- if (!profile) {
436
- profile = await this.runProductExecutionProfile(id, product, cwd, profileContractVersion);
437
- cached.set(productIndex, profile);
438
- record.productProfiles = [...cached.values()].sort((left, right) => left.productIndex - right.productIndex);
439
- record.updatedAt = now();
440
- 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);
441
509
  }
442
- await this.submitProductProfileChunk(id, [profile], profileContractVersion);
510
+ }
511
+ catch (error) {
512
+ if (!stopped)
513
+ firstFailure = error;
514
+ stopped = true;
515
+ throw error;
443
516
  }
444
517
  }));
518
+ if (stopped)
519
+ throw firstFailure;
445
520
  task = await this.scriptTask(id);
446
521
  }
447
522
  const profiled = Number(task.product_profile_stage?.completed || task.product_execution_profiles?.length || 0);
@@ -504,8 +579,10 @@ export class WorkflowManager {
504
579
  onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
505
580
  });
506
581
  this.emitScriptStage(id, ordinals, "candidate_model", result.timings.queueWaitMs + result.timings.threadStartMs + result.timings.modelMs);
507
- if (!result.ok || !result.text)
508
- return { error: result.ok ? "Codex 未返回创意候选" : result.error, terminal: !result.ok && !result.retryable };
582
+ if (!result.ok)
583
+ return { error: result.error, terminal: !result.retryable, ...(!result.retryable ? { terminalKind: result.failureKind } : {}) };
584
+ if (!result.text)
585
+ return { error: "Codex 未返回创意候选", terminal: false };
509
586
  try {
510
587
  const groups = parseFlowCCreativeCandidateOutput(result.text, ordinals);
511
588
  const startedAt = Date.now();
@@ -519,31 +596,44 @@ export class WorkflowManager {
519
596
  }
520
597
  /** 一个 chunk 使用独立线程;解析与中心持久化失败不会影响并行 lane。 */
521
598
  async runScriptChunk(id, task, ordinals, cwd, rewriteAttempt = 0, revisionAttempt = 0) {
522
- const durationSeconds = Number(task.duration_seconds || 10);
523
- let prompt;
524
- try {
525
- prompt = scriptChunkPrompt(id, task, ordinals, rewriteAttempt);
526
- }
527
- catch (error) {
528
- if (isFlowCPromptPayloadTooLarge(error))
529
- return { error: error.message, terminal: false };
530
- throw error;
531
- }
532
- const result = await runCodexWorkflowTurn(prompt, this.emit, {
533
- cwd,
534
- permissionMode: "full",
535
- timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
536
- outputSchema: flowCScriptOutputSchema(durationSeconds, ordinals.length),
537
- onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
538
- onWorkerStart: () => { const record = this.scriptRecord(id); record.activeChunks = Number(record.activeChunks || 0) + 1; record.updatedAt = now(); this.save(); },
539
- onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
540
- });
541
- this.emitScriptStage(id, ordinals, "queue_wait", result.timings.queueWaitMs);
542
- this.emitScriptStage(id, ordinals, "thread_start", result.timings.threadStartMs);
543
- this.emitScriptStage(id, ordinals, "model", result.timings.modelMs);
544
- if (!result.ok || !result.text)
545
- return { error: result.ok ? "Codex 未返回可用的结构化脚本" : result.error, terminal: !result.ok && !result.retryable };
546
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 };
547
637
  const parseStartedAt = Date.now();
548
638
  const selected = selectedCandidatesForOrdinals(task, ordinals);
549
639
  const jobs = parseFlowCScriptOutput(result.text, ordinals).map((job) => ({
@@ -559,6 +649,14 @@ export class WorkflowManager {
559
649
  return { terminal: false };
560
650
  }
561
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
+ }
562
660
  const revisionOrdinals = candidateRevisionChangedOrdinals(error);
563
661
  if (revisionOrdinals.length && !creativeReplanOrdinals(error).length) {
564
662
  if (revisionAttempt >= FLOW_C_CANDIDATE_REVISION_MAX_ATTEMPTS) {
@@ -579,9 +677,11 @@ export class WorkflowManager {
579
677
  if (!pendingRevisionOrdinals.length)
580
678
  return { terminal: false };
581
679
  const revisionResults = await Promise.all(pendingRevisionOrdinals.map((ordinal) => this.runScriptChunk(id, refreshedTask, [ordinal], cwd, 0, revisionAttempt + 1)));
582
- const revisionError = terminalScriptChunkError(revisionResults) || revisionResults.map((result) => result.error).filter(Boolean).join(";");
680
+ const revisionTerminalFailure = terminalScriptChunkFailure(revisionResults);
681
+ const revisionError = revisionTerminalFailure?.error || revisionResults.map((result) => result.error).filter(Boolean).join(";");
583
682
  return {
584
- terminal: Boolean(terminalScriptChunkError(revisionResults)),
683
+ terminal: Boolean(revisionTerminalFailure),
684
+ ...(revisionTerminalFailure?.terminalKind ? { terminalKind: revisionTerminalFailure.terminalKind } : {}),
585
685
  ...(revisionError ? { error: revisionError } : {}),
586
686
  ...(scriptCreativeReplanOrdinals(revisionResults).length ? { replanOrdinals: scriptCreativeReplanOrdinals(revisionResults) } : {}),
587
687
  };
@@ -605,14 +705,18 @@ export class WorkflowManager {
605
705
  if (!pendingRewriteOrdinals.length)
606
706
  return preserveScriptRecoveryReplans({ terminal: false }, error);
607
707
  const rewriteResults = await Promise.all(pendingRewriteOrdinals.map((ordinal) => this.runScriptChunk(id, refreshedTask, [ordinal], cwd, rewriteAttempt + 1)));
608
- const rewriteError = terminalScriptChunkError(rewriteResults) || rewriteResults.map((result) => result.error).filter(Boolean).join(";");
708
+ const rewriteTerminalFailure = terminalScriptChunkFailure(rewriteResults);
709
+ const rewriteError = rewriteTerminalFailure?.error || rewriteResults.map((result) => result.error).filter(Boolean).join(";");
609
710
  const rewriteResult = {
610
- terminal: Boolean(terminalScriptChunkError(rewriteResults)),
711
+ terminal: Boolean(rewriteTerminalFailure),
712
+ ...(rewriteTerminalFailure?.terminalKind ? { terminalKind: rewriteTerminalFailure.terminalKind } : {}),
611
713
  ...(rewriteError ? { error: rewriteError } : {}),
612
714
  ...(scriptCreativeReplanOrdinals(rewriteResults).length ? { replanOrdinals: scriptCreativeReplanOrdinals(rewriteResults) } : {}),
613
715
  };
614
716
  return preserveScriptRecoveryReplans(rewriteResult, error);
615
717
  }
718
+ if (error instanceof CommerceRequestError && !creativeReplanOrdinals(error).length)
719
+ return { error: error.message, terminal: true, terminalKind: "delivery" };
616
720
  return {
617
721
  error: error instanceof Error ? `结构化脚本未通过本机校验:${error.message}` : "结构化脚本未通过本机校验",
618
722
  terminal: terminalScriptValidationError(error),
@@ -634,7 +738,7 @@ export class WorkflowManager {
634
738
  return;
635
739
  }
636
740
  catch (error) {
637
- if (jobs.length === 1)
741
+ if (jobs.length === 1 || error instanceof CommerceRequestError && ![400, 409, 422].includes(error.status || 0))
638
742
  throw error;
639
743
  let accepted = 0;
640
744
  const failures = [];
@@ -644,6 +748,8 @@ export class WorkflowManager {
644
748
  accepted += 1;
645
749
  }
646
750
  catch (jobError) {
751
+ if (jobError instanceof CommerceRequestError && ![400, 409, 422].includes(jobError.status || 0))
752
+ throw jobError;
647
753
  failures.push(jobError);
648
754
  }
649
755
  }
@@ -793,7 +899,10 @@ export function compareScriptQueueRecords(left, right) {
793
899
  }
794
900
  /** A deterministic response-format failure stops fallback isolation immediately. */
795
901
  export function terminalScriptChunkError(results) {
796
- return results.find((result) => result.terminal)?.error || "";
902
+ return terminalScriptChunkFailure(results)?.error || "";
903
+ }
904
+ export function terminalScriptChunkFailure(results) {
905
+ return results.find((result) => result.terminal);
797
906
  }
798
907
  export function scriptCreativeReplanOrdinals(results) {
799
908
  return [...new Set(results.flatMap((result) => (result && typeof result === "object" ? result.replanOrdinals || [] : [])).filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0))].sort((left, right) => left - right);
@@ -1304,23 +1413,11 @@ function safeDownloadUrl(value) { const url = new URL(String(value || "")); if (
1304
1413
  throw new Error("下载地址必须使用 HTTPS"); return url.toString(); }
1305
1414
  function safeName(value) { return String(value || "市场").replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-").replace(/[. ]+$/g, "").slice(0, 60) || "市场"; }
1306
1415
  function now() { return new Date().toISOString(); }
1307
- async function commerceJson(url, token, tokenHeader, init = {}) {
1308
- const headers = new Headers(init.headers);
1309
- headers.set("accept", "application/json");
1310
- headers.set(tokenHeader, token);
1311
- if (init.body)
1312
- headers.set("content-type", "application/json");
1313
- const response = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(120_000) });
1314
- const body = await response.json().catch(() => ({}));
1315
- if (!response.ok) {
1316
- const error = new Error(body.error || `中心接口返回 ${response.status}`);
1317
- error.status = response.status;
1318
- error.code = body.code;
1319
- error.resetOrdinals = body.resetOrdinals;
1320
- error.rewriteOrdinals = body.rewriteOrdinals;
1321
- throw error;
1322
- }
1323
- 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);
1324
1421
  }
1325
1422
  function loadState() {
1326
1423
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.74",
3
+ "version": "0.4.76",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",