@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.
@@ -3,12 +3,14 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { logger } from "../utils/logger.js";
5
5
  import { errorMessage, field } from "../utils/value.js";
6
- import { CodexAppClient } from "./codex-client.js";
6
+ import { CodexAppClient, isCodexAppProcessError } from "./codex-client.js";
7
7
  import { summarizeCodexThread, threadMessages } from "./codex-history.js";
8
8
  import { boundedWorkerConcurrency, WorkerPool } from "./worker-pool.js";
9
9
  export const FLOW_C_CODEX_MODEL = "gpt-5.6-terra";
10
10
  export const FLOW_C_CODEX_REASONING_EFFORT = "medium";
11
11
  export const FLOW_C_CODEX_WORKER_CONCURRENCY = boundedWorkerConcurrency(process.env.FLOW_C_SCRIPT_CONCURRENCY);
12
+ export const FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS = 3;
13
+ const FLOW_C_CODEX_MIN_START_BUDGET_MS = 5_000;
12
14
  export function flowCCodexWorkerStatus() { return { active: workflowCodexPool.activeCount, limit: FLOW_C_CODEX_WORKER_CONCURRENCY }; }
13
15
  let codexQueue = Promise.resolve();
14
16
  let codexApp = null;
@@ -17,6 +19,46 @@ let codexThreadId = "";
17
19
  const unmaterializedThreadIds = new Set();
18
20
  const workflowCodexPool = new WorkerPool(FLOW_C_CODEX_WORKER_CONCURRENCY);
19
21
  const workflowCodexApps = new Map();
22
+ /**
23
+ * 记录尚未确认 OS exit 的 worker 进程。worker 可以释放给队列,但在对应
24
+ * barrier 完成前只能等待/失败,绝不能启动替代 app-server。
25
+ */
26
+ export class WorkflowCodexLaneQuarantine {
27
+ barriers = new Map();
28
+ quarantine(workerIndex, barrier) {
29
+ const previous = this.barriers.get(workerIndex);
30
+ const combined = (previous ? Promise.all([previous, barrier]) : Promise.resolve(barrier))
31
+ .then(() => undefined, () => undefined);
32
+ this.barriers.set(workerIndex, combined);
33
+ void combined.then(() => {
34
+ if (this.barriers.get(workerIndex) === combined)
35
+ this.barriers.delete(workerIndex);
36
+ });
37
+ return combined;
38
+ }
39
+ async wait(workerIndex, timeoutMs) {
40
+ const barrier = this.barriers.get(workerIndex);
41
+ if (!barrier)
42
+ return true;
43
+ if (timeoutMs <= 0)
44
+ return false;
45
+ let timer;
46
+ try {
47
+ return await Promise.race([
48
+ barrier.then(() => true),
49
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
50
+ ]);
51
+ }
52
+ finally {
53
+ if (timer)
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+ isQuarantined(workerIndex) {
58
+ return this.barriers.has(workerIndex);
59
+ }
60
+ }
61
+ const workflowCodexLaneQuarantine = new WorkflowCodexLaneQuarantine();
20
62
  export { summarizeCodexThread } from "./codex-history.js";
21
63
  /** 将 Codex turn 加入串行队列并等待执行完成。 */
22
64
  export async function runCodexTurn(prompt, emit, attachments = [], options = {}) {
@@ -37,41 +79,104 @@ export async function interruptCodexTurn(threadId) {
37
79
  */
38
80
  export async function runCodexWorkflowTurn(prompt, emit, options) {
39
81
  if (!prompt.trim())
40
- return { ok: false, error: "Codex prompt is empty", retryable: false, timings: { queueWaitMs: 0, threadStartMs: 0, modelMs: 0 } };
82
+ return { ok: false, error: "Codex prompt is empty", retryable: false, failureKind: "contract", timings: { queueWaitMs: 0, threadStartMs: 0, modelMs: 0 } };
41
83
  return await workflowCodexPool.run(async (workerIndex, queueWaitMs) => {
42
84
  options.onWorkerStart?.();
43
85
  const modelSettings = { model: FLOW_C_CODEX_MODEL, reasoningEffort: FLOW_C_CODEX_REASONING_EFFORT };
44
- const threadStartedAt = Date.now();
86
+ const deadlineAt = Date.now() + Math.max(1, options.timeoutMs);
45
87
  let threadStartMs = 0;
46
- let modelStartedAt = 0;
47
- let app = workflowCodexApps.get(workerIndex);
88
+ let modelMs = 0;
89
+ let app;
90
+ let processAttempts = 0;
91
+ let processRecoveryObserved = false;
92
+ let cleanupConfirmed = true;
48
93
  let files = [];
49
94
  try {
50
- const operation = (async () => {
51
- files = await writeAttachmentFiles(options.attachments || []);
52
- if (!app)
53
- app = await startWorkflowCodexApp(workerIndex, options.appEmit || emit);
54
- const thread = await app.startThread(options.cwd, options.permissionMode || "request", modelSettings);
55
- const threadId = String(field(thread, "id") || "");
56
- options.onThread?.(threadId);
57
- threadStartMs = Date.now() - threadStartedAt;
58
- modelStartedAt = Date.now();
59
- return await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn, options.outputSchema, modelSettings);
60
- })();
61
- const result = await runBoundedWorkflowOperation(operation, options.timeoutMs, async () => {
62
- workflowCodexApps.delete(workerIndex);
63
- await app?.terminate("Flow C 脚本执行链路超时,已仅回收当前 worker");
95
+ files = await writeAttachmentFiles(options.attachments || []);
96
+ const text = await runCodexProcessRetries(async (attempt) => {
97
+ processAttempts = attempt;
98
+ const attemptStartedAt = Date.now();
99
+ let modelStartedAt = 0;
100
+ try {
101
+ const laneReady = await workflowCodexLaneQuarantine.wait(workerIndex, Math.max(0, deadlineAt - Date.now()));
102
+ if (!laneReady) {
103
+ cleanupConfirmed = false;
104
+ throw new CodexWorkflowTimeoutError();
105
+ }
106
+ const startupBudgetMs = deadlineAt - Date.now();
107
+ if (startupBudgetMs < FLOW_C_CODEX_MIN_START_BUDGET_MS)
108
+ throw new CodexWorkflowTimeoutError();
109
+ app = workflowCodexApps.get(workerIndex);
110
+ if (!app)
111
+ app = await startWorkflowCodexApp(workerIndex, options.appEmit || emit, startupBudgetMs);
112
+ const remainingMs = deadlineAt - Date.now();
113
+ if (remainingMs <= 0)
114
+ throw new CodexWorkflowTimeoutError();
115
+ const operation = (async () => {
116
+ const thread = await app.startThread(options.cwd, options.permissionMode || "request", modelSettings);
117
+ const threadId = String(field(thread, "id") || "");
118
+ options.onThread?.(threadId);
119
+ modelStartedAt = Date.now();
120
+ return await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn, options.outputSchema, modelSettings);
121
+ })();
122
+ const result = await runBoundedWorkflowOperation(operation, remainingMs, async () => {
123
+ const timedOutApp = app;
124
+ cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, timedOutApp, "Flow C 脚本执行链路超时,已仅回收当前 worker", true);
125
+ if (app === timedOutApp)
126
+ app = undefined;
127
+ });
128
+ if (result.timedOut)
129
+ throw new CodexWorkflowTimeoutError();
130
+ return result.value;
131
+ }
132
+ finally {
133
+ if (modelStartedAt)
134
+ modelMs += Date.now() - modelStartedAt;
135
+ else
136
+ threadStartMs += Date.now() - attemptStartedAt;
137
+ }
138
+ }, {
139
+ maxAttempts: FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS,
140
+ backoffMs: (failedAttempt) => Math.max(0, Math.min(failedAttempt === 1 ? 250 : 750, deadlineAt - Date.now())),
141
+ onRetry: async (error, failedAttempt) => {
142
+ processRecoveryObserved = true;
143
+ cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, `Flow C 本机脚本引擎第 ${failedAttempt}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次进程异常,正在换新进程重试`);
144
+ app = undefined;
145
+ if (!cleanupConfirmed)
146
+ throw error;
147
+ if (deadlineAt - Date.now() < FLOW_C_CODEX_MIN_START_BUDGET_MS)
148
+ throw new CodexWorkflowTimeoutError();
149
+ emit("agent_log", { text: `Flow C 本机脚本引擎异常,已回收当前 worker,将进行第 ${failedAttempt + 1}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次尝试(${error.message})` });
150
+ },
64
151
  });
65
- if (result.timedOut) {
66
- return { ok: false, error: "Flow C 脚本执行链路超过 8 分钟,已自动终止当前 worker", retryable: true, timings: { queueWaitMs, threadStartMs: threadStartMs || Date.now() - threadStartedAt, modelMs: modelStartedAt ? Date.now() - modelStartedAt : 0 } };
67
- }
68
- return { ok: true, text: result.value, timings: { queueWaitMs, threadStartMs, modelMs: Date.now() - modelStartedAt } };
152
+ return { ok: true, text, timings: { queueWaitMs, threadStartMs, modelMs } };
69
153
  }
70
154
  catch (error) {
71
155
  logger.error("Flow C Codex worker failed", { workerIndex, error });
72
- const message = errorMessage(error);
156
+ const processFailure = isCodexAppProcessError(error);
157
+ const contractFailure = isDeterministicWorkflowContractError(error);
158
+ const timeoutFailure = error instanceof CodexWorkflowTimeoutError;
159
+ const deadlineAfterProcessFailure = timeoutFailure && processRecoveryObserved;
160
+ const transportFailure = !contractFailure && (processFailure || deadlineAfterProcessFailure);
161
+ if (processFailure && error.cleanupConfirmed === false)
162
+ cleanupConfirmed = false;
163
+ if (processFailure && error.cleanupBarrier)
164
+ workflowCodexLaneQuarantine.quarantine(workerIndex, error.cleanupBarrier);
165
+ const failureKind = contractFailure ? "contract" : transportFailure ? "transport" : timeoutFailure ? "timeout" : "turn";
166
+ const rawMessage = errorMessage(error);
167
+ let message = deadlineAfterProcessFailure
168
+ ? `本机 Codex 脚本引擎进程异常后未能在原 8 分钟截止时间内完成恢复,已停止当前任务:${rawMessage}`
169
+ : transportFailure
170
+ ? `本机 Codex 脚本引擎已自动尝试 ${Math.max(1, processAttempts)}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次仍失败:${rawMessage}`
171
+ : timeoutFailure ? "Flow C 脚本执行链路超过 8 分钟,已自动终止当前 worker" : rawMessage;
172
+ if ((processFailure || timeoutFailure) && app) {
173
+ cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, "Flow C 脚本执行结束,正在确认当前 worker 已退出", timeoutFailure);
174
+ app = undefined;
175
+ }
176
+ if (!cleanupConfirmed)
177
+ message = `${message};旧 app-server 在 2 秒清理宽限内未确认退出,已停止自动启动替代进程`;
73
178
  emit("agent_error", { message });
74
- return { ok: false, error: message, retryable: !isDeterministicWorkflowContractError(message), timings: { queueWaitMs, threadStartMs: threadStartMs || Date.now() - threadStartedAt, modelMs: modelStartedAt ? Date.now() - modelStartedAt : 0 } };
179
+ return { ok: false, error: message, retryable: isWorkflowFailureRetryable(failureKind, cleanupConfirmed, processRecoveryObserved), failureKind, timings: { queueWaitMs, threadStartMs, modelMs } };
75
180
  }
76
181
  finally {
77
182
  await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
@@ -79,6 +184,38 @@ export async function runCodexWorkflowTurn(prompt, emit, options) {
79
184
  }
80
185
  });
81
186
  }
187
+ /** A deadline exhausted after process recovery is terminal for this handoff wave. */
188
+ export function isWorkflowFailureRetryable(failureKind, cleanupConfirmed, processRecoveryObserved) {
189
+ if (!cleanupConfirmed)
190
+ return false;
191
+ if (failureKind === "turn")
192
+ return true;
193
+ return failureKind === "timeout" && !processRecoveryObserved;
194
+ }
195
+ /** 只重试明确的 app-server 进程/stdio 故障;结构契约和普通模型失败均立即上抛。 */
196
+ export async function runCodexProcessRetries(operation, options = {}) {
197
+ const maxAttempts = Math.max(1, Math.floor(options.maxAttempts || FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS));
198
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
199
+ try {
200
+ return await operation(attempt);
201
+ }
202
+ catch (error) {
203
+ if (isDeterministicWorkflowContractError(error) || !isCodexAppProcessError(error) || error.cleanupConfirmed === false || attempt >= maxAttempts)
204
+ throw error;
205
+ await options.onRetry?.(error, attempt);
206
+ const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs(attempt) : Number(options.backoffMs || 0);
207
+ if (backoffMs > 0)
208
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
209
+ }
210
+ }
211
+ throw new Error("Codex app-server process retry loop ended unexpectedly");
212
+ }
213
+ class CodexWorkflowTimeoutError extends Error {
214
+ constructor() {
215
+ super("Flow C script workflow deadline exceeded");
216
+ this.name = "CodexWorkflowTimeoutError";
217
+ }
218
+ }
82
219
  /**
83
220
  * 给 Flow C 的完整 app-server 链路设置硬截止时间。超时后不再等待原 Promise
84
221
  * 自行结束;否则初始化或 thread/start 永不返回时会永久占住 worker lane。
@@ -107,18 +244,31 @@ export async function runBoundedWorkflowOperation(operation, timeoutMs, onTimeou
107
244
  }
108
245
  /** Contract/preflight 4xx errors are deterministic and must never burn every fallback chunk size. */
109
246
  export function isDeterministicWorkflowContractError(error) {
110
- const message = errorMessage(error);
247
+ const message = `${errorMessage(error)}\n${isCodexAppProcessError(error) ? error.stderr : ""}`;
111
248
  return /invalid_json_schema|invalid schema for response_format|text\.format\.schema|response[_ ]format[^\n]*(?:invalid|schema)/i.test(message);
112
249
  }
113
- async function startWorkflowCodexApp(workerIndex, emit) {
250
+ async function startWorkflowCodexApp(workerIndex, emit, initializeTimeoutMs) {
114
251
  let started;
115
252
  started = await CodexAppClient.start(emit, () => {
116
- if (!started || workflowCodexApps.get(workerIndex) === started)
253
+ if (!started)
254
+ return;
255
+ workflowCodexLaneQuarantine.quarantine(workerIndex, started.waitForExit());
256
+ if (workflowCodexApps.get(workerIndex) === started)
117
257
  workflowCodexApps.delete(workerIndex);
118
- });
258
+ }, initializeTimeoutMs);
119
259
  workflowCodexApps.set(workerIndex, started);
120
260
  return started;
121
261
  }
262
+ async function discardWorkflowCodexApp(workerIndex, app, message, persistDiagnostic = false) {
263
+ if (app && workflowCodexApps.get(workerIndex) === app)
264
+ workflowCodexApps.delete(workerIndex);
265
+ if (!app)
266
+ return true;
267
+ const confirmed = await app.terminate(message, { persistDiagnostic });
268
+ if (!confirmed)
269
+ workflowCodexLaneQuarantine.quarantine(workerIndex, app.waitForExit());
270
+ return confirmed;
271
+ }
122
272
  /** 强制回收失去响应的 app-server;只用于有界超时恢复。 */
123
273
  export async function restartCodexApp(message = "Codex 执行超时,正在重启本机脚本引擎") {
124
274
  const app = codexApp;
@@ -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,9 +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;
179
+ terminalKind?: "contract" | "transport" | "timeout" | "turn" | "delivery";
153
180
  replanOrdinals?: number[];
154
181
  };
155
182
  /** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
@@ -214,22 +241,12 @@ export declare class WorkflowManager {
214
241
  receivedOrdinals?: number[];
215
242
  requestedCount: number;
216
243
  status: string;
217
- } & {
218
- error?: string;
219
- code?: string;
220
- resetOrdinals?: unknown;
221
- rewriteOrdinals?: unknown;
222
244
  }>;
223
245
  submitCandidateChunk(idValue: unknown, groupsValue: unknown): Promise<{
224
246
  accepted: number;
225
247
  selected: number;
226
248
  requestedCount: number;
227
249
  selectedCandidates: SelectedCandidate[];
228
- } & {
229
- error?: string;
230
- code?: string;
231
- resetOrdinals?: unknown;
232
- rewriteOrdinals?: unknown;
233
250
  }>;
234
251
  submitProductProfileChunk(idValue: unknown, profilesValue: unknown[], contractVersion?: string): Promise<{
235
252
  accepted: number;
@@ -238,12 +255,8 @@ export declare class WorkflowManager {
238
255
  selected: number;
239
256
  requestedCount: number;
240
257
  status: string;
241
- } & {
242
- error?: string;
243
- code?: string;
244
- resetOrdinals?: unknown;
245
- rewriteOrdinals?: unknown;
246
258
  }>;
259
+ private scriptRequestOptions;
247
260
  downloadState(): {
248
261
  configured: boolean;
249
262
  directoryName: string | undefined;
@@ -371,6 +384,11 @@ export declare function terminalScriptChunkError(results: Array<{
371
384
  error?: string;
372
385
  terminal?: boolean;
373
386
  }>): string;
387
+ export declare function terminalScriptChunkFailure<T extends {
388
+ error?: string;
389
+ terminal?: boolean;
390
+ terminalKind?: ScriptChunkResult["terminalKind"];
391
+ }>(results: T[]): T | undefined;
374
392
  export declare function scriptCreativeReplanOrdinals(results: unknown[]): number[];
375
393
  /** Keep a mixed reset signal after an exact-duplicate rewrite finishes. */
376
394
  export declare function preserveScriptRecoveryReplans(result: ScriptChunkResult, error: unknown): ScriptChunkResult;
@@ -416,4 +434,5 @@ export declare function nextScriptPipelineWave(task: Pick<ScriptTask, "requested
416
434
  };
417
435
  export declare function immediateScriptOrdinals(task: Pick<ScriptTask, "selected_candidates">, receivedOrdinals: number[], candidateOrdinals: number[]): number[];
418
436
  export declare function missingOrdinals(total: number, received: number[]): number[];
437
+ export declare function mergeProductProfiles(local?: FlowCProductExecutionProfile[], confirmed?: FlowCProductExecutionProfile[]): FlowCProductExecutionProfile[];
419
438
  export {};