@xiaohhhh1/canvas-agent 0.4.73 → 0.4.75
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.
- package/agent-instructions.md +5 -2
- package/dist/agent/codex-client.d.ts +53 -2
- package/dist/agent/codex-client.js +299 -60
- package/dist/agent/codex.d.ts +25 -0
- package/dist/agent/codex.js +179 -29
- package/dist/workflow/manager.d.ts +7 -1
- package/dist/workflow/manager.js +43 -21
- package/package.json +1 -1
package/agent-instructions.md
CHANGED
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
- 用户要求打开带货任务时,调用 `open_flow_c_website`,不要输出或询问 Local URL、连接令牌。
|
|
18
18
|
- 网站把脚本任务交给本机 Codex 后,使用 `flow_c_get_script_task` 读取完整、服务器持久化的任务,再用 `flow_c_submit_script_chunk` 每次回传 1–30 条。不要要求用户复制 JSON、选择交接文件或把短期令牌发到聊天中。
|
|
19
19
|
- 大批量脚本按本机助手指定的序号分段创作;批量大小不能成为降低质量、缩短脚本或套用模板的理由。每条都必须独立构思、符合目标市场自然语言习惯、保持紧凑但清晰可说完的 10 秒节奏。
|
|
20
|
+
- 三种制作方式 `storyboard`、`visual-storyboard`、`first-frame-chain` 共用完全相同、与媒体模式无关的脚本层:同一套爆款蓝图/带货形式库选择、商品适配、目标地区当地化、口播、完整脚本和局部 0–10 秒段。制作方式及分镜布局不得进入脚本创作提示,不得据此重写、过滤、重选或简化脚本。
|
|
20
21
|
- 10 秒仍走原有单段结构。只有任务明确给出 20 或 30 秒时,才先写一份完整总脚本,再拆成 2 或 3 个各自从 0–10 秒计时、可独立生成的段落;同人物同场景用 `continue` 并固定人物、服装、音色身份、场景、光线与商品状态,明确换人物或场景才用 `reset`。音色身份需详细到年龄、音高、音质、语速、口音、停连和说话习惯,情绪随剧情变化,整体仍保持快节奏。
|
|
21
22
|
- 写脚本和回传草案不创建付费任务。只有客户在网站审阅并确认费用后,中心服务才可开始故事板和视频生成。
|
|
22
23
|
|
|
23
24
|
- 用户明确选择流程 C 时,必须新建独立画布、清单和归档目录,不得混用流程 A 或 B 的资产。
|
|
24
|
-
-
|
|
25
|
+
- 脚本定稿后才按已持久化制作方式分流媒体控制:`storyboard` 使用同一局部脚本和原始同 SKU 商品图生成 9:16 `director-table-scripted-v1` 导演总表;`first-frame-chain` 使用同一局部脚本生成干净 9:16 首帧,并按既有首帧/尾帧连续控制执行。
|
|
26
|
+
- 新 `visual-storyboard` 使用同一局部脚本和原始同 SKU 商品图生成原生横向 16:9 `visual-storyboard-landscape-v2` 纯视觉分镜图。镜头按时间行优先排列(从左到右,再到下一排):1–4 镜单排;5–8 镜为 3+2、3+3、4+3、4+4 的平衡两排。每排铺满且每格都是真实镜头,不得添加空白、占位格、连续性小块、元数据栏、技术块、脚本、标签、时间码或字幕;镜头景别、镜头、运镜、动作和切点保持原脚本不变,关键内容放在竖屏安全核心。已持久化 `visual-storyboard-adaptive-v1` 仍按原 9:16 历史合同恢复,不得升级或重解释为 v2。
|
|
27
|
+
- Omni 成片在三种制作方式下始终是单一全屏竖向 9:16。纯视觉 v2 按同一行优先顺序把横板每格重建为完整动态镜头,禁止把横板、网格、接缝、边框、空白、分屏、画中画、联系表或幻灯片渲染进成片;20/30 秒仍逐段生成后只做 stream-copy 无损拼接。
|
|
25
28
|
- 原始商品图是唯一商品标准:每一格都必须保持颜色、外形、文字或 Logo、比例、部件和数量一致。分镜通过逐格检查前,严禁提交视频任务。
|
|
26
|
-
-
|
|
29
|
+
- 视频阶段必须按已持久化制作方式提供控制图和原始商品图:导演总表与纯视觉分镜图固定为 `[当前故事板, ...原商品图]`;首帧模式沿用其首帧/尾帧引用合同。技术失败、超时、明显商品不一致或明显分镜错误时按中心既有有界策略处理;提示词、参考图或平台合规问题才暂停等待用户修改。
|
|
27
30
|
- 未配置流程 C 的第三方视频接口时,只能完成分镜准备和验收,明确说明“待管理员配置视频接口”,不得假装已提交或产生扣费。
|
|
@@ -5,10 +5,40 @@ export type CodexModelSettings = {
|
|
|
5
5
|
model?: string;
|
|
6
6
|
reasoningEffort?: string;
|
|
7
7
|
};
|
|
8
|
+
export type CodexAppPhase = "spawn" | "initialize" | "idle" | "thread/start" | "turn/start" | "turn/running" | "terminating" | "exited";
|
|
9
|
+
export type CodexAppProcessFailureDetails = {
|
|
10
|
+
phase: CodexAppPhase | string;
|
|
11
|
+
code: number | null;
|
|
12
|
+
signal: NodeJS.Signals | null;
|
|
13
|
+
stderr: string;
|
|
14
|
+
cause?: unknown;
|
|
15
|
+
source?: "spawn" | "exit" | "stdin" | "timeout";
|
|
16
|
+
diagnosticId?: string;
|
|
17
|
+
cleanupConfirmed?: boolean;
|
|
18
|
+
cleanupBarrier?: Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
/** 可安全向网页和本机状态暴露的 app-server 进程故障。 */
|
|
21
|
+
export declare class CodexAppProcessError extends Error {
|
|
22
|
+
readonly phase: string;
|
|
23
|
+
readonly code: number | null;
|
|
24
|
+
readonly signal: NodeJS.Signals | null;
|
|
25
|
+
readonly stderr: string;
|
|
26
|
+
readonly source: "spawn" | "exit" | "stdin" | "timeout";
|
|
27
|
+
readonly diagnosticId: string;
|
|
28
|
+
cleanupConfirmed: boolean;
|
|
29
|
+
cleanupBarrier?: Promise<void>;
|
|
30
|
+
constructor(details: CodexAppProcessFailureDetails);
|
|
31
|
+
}
|
|
32
|
+
export declare function isCodexAppProcessError(error: unknown): error is CodexAppProcessError;
|
|
33
|
+
/** 去掉凭证、图片、用户目录及控制符,只保留有界的 stderr 尾部。 */
|
|
34
|
+
export declare function sanitizeCodexDiagnosticText(value: unknown, maxChars?: number): string;
|
|
35
|
+
/** Append stderr only after redaction so the in-memory tail never holds raw payloads. */
|
|
36
|
+
export declare function appendCodexDiagnosticTail(current: string, chunk: unknown, maxChars?: number): string;
|
|
8
37
|
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
|
9
38
|
export declare class CodexAppClient {
|
|
10
39
|
private child;
|
|
11
40
|
private emit;
|
|
41
|
+
private onExit;
|
|
12
42
|
private nextId;
|
|
13
43
|
private buffer;
|
|
14
44
|
private currentThreadId;
|
|
@@ -22,10 +52,20 @@ export declare class CodexAppClient {
|
|
|
22
52
|
private pendingDeltas;
|
|
23
53
|
private plansByTurn;
|
|
24
54
|
private approvalRequests;
|
|
55
|
+
private phase;
|
|
56
|
+
private stderrTail;
|
|
57
|
+
private logicalClosed;
|
|
58
|
+
private osExitSeen;
|
|
59
|
+
private failDelivered;
|
|
60
|
+
private terminationExpected;
|
|
61
|
+
private exitDelivered;
|
|
62
|
+
private terminalError;
|
|
63
|
+
private exitPromise;
|
|
64
|
+
private resolveExit;
|
|
25
65
|
/** 保存 app-server 子进程和事件出口。 */
|
|
26
66
|
private constructor();
|
|
27
67
|
/** 启动并初始化 Codex app-server。 */
|
|
28
|
-
static start(emit: AgentEmit, onExit: () => void): Promise<CodexAppClient>;
|
|
68
|
+
static start(emit: AgentEmit, onExit: () => void, initializeTimeoutMs?: number): Promise<CodexAppClient>;
|
|
29
69
|
/** 创建新的 Codex 线程。 */
|
|
30
70
|
startThread(cwd?: string, permissionMode?: AgentPermissionMode, modelSettings?: CodexModelSettings): Promise<import("./codex-protocol.js").CodexThread>;
|
|
31
71
|
/** 恢复已有 Codex 线程。 */
|
|
@@ -51,7 +91,12 @@ export declare class CodexAppClient {
|
|
|
51
91
|
/** 中断当前正在运行的 Codex turn。 */
|
|
52
92
|
interruptCurrentTurn(): Promise<boolean>;
|
|
53
93
|
/** 终止失去响应的 app-server,让后续队列可以在新进程继续。 */
|
|
54
|
-
terminate(message?: string
|
|
94
|
+
terminate(message?: string, diagnostics?: {
|
|
95
|
+
persistDiagnostic?: boolean;
|
|
96
|
+
phase?: CodexAppPhase | string;
|
|
97
|
+
}): Promise<boolean>;
|
|
98
|
+
/** 仅供 worker 隔离等待真实 OS exit;不包含 stderr 或其它诊断内容。 */
|
|
99
|
+
waitForExit(): Promise<void>;
|
|
55
100
|
/** 回复网页端已经确认的 Codex 权限请求。 */
|
|
56
101
|
resolveApproval(requestId: string, decision: string): boolean;
|
|
57
102
|
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
|
|
@@ -83,6 +128,12 @@ export declare class CodexAppClient {
|
|
|
83
128
|
private resolve;
|
|
84
129
|
/** 拒绝指定 JSON-RPC 请求。 */
|
|
85
130
|
private reject;
|
|
131
|
+
/** 只在内存保存 stderr 尾部;原文绝不直接发往网页或写入日志。 */
|
|
132
|
+
private recordStderr;
|
|
133
|
+
/** error/exit/stdin 统一且只执行一次的进程收尾。 */
|
|
134
|
+
private handleProcessFailure;
|
|
135
|
+
private noteOsExit;
|
|
136
|
+
private deliverExit;
|
|
86
137
|
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
|
87
138
|
private failAll;
|
|
88
139
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
2
4
|
import { createRequire } from "node:module";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { VERSION } from "../config.js";
|
|
7
|
+
import { CONFIG_DIR, VERSION } from "../config.js";
|
|
6
8
|
import { logger } from "../utils/logger.js";
|
|
7
9
|
import { field } from "../utils/value.js";
|
|
8
10
|
const canvasAgentMcp = canvasAgentMcpCommand();
|
|
@@ -10,10 +12,89 @@ const require = createRequire(import.meta.url);
|
|
|
10
12
|
const STREAM_UPDATE_INTERVAL_MS = 40;
|
|
11
13
|
const TURN_RECONCILE_INTERVAL_MS = 5_000;
|
|
12
14
|
const APP_SERVER_INITIALIZE_TIMEOUT_MS = 30_000;
|
|
15
|
+
const CODEX_STDERR_TAIL_CHARS = 32 * 1024;
|
|
16
|
+
const CODEX_VISIBLE_DIAGNOSTIC_CHARS = 2_048;
|
|
17
|
+
const CODEX_DIAGNOSTIC_FILE_BYTES = 512 * 1024;
|
|
18
|
+
const CODEX_DIAGNOSTIC_ENTRY_BYTES = 12 * 1024;
|
|
19
|
+
let diagnosticWriteQueue = Promise.resolve();
|
|
20
|
+
/** 可安全向网页和本机状态暴露的 app-server 进程故障。 */
|
|
21
|
+
export class CodexAppProcessError extends Error {
|
|
22
|
+
phase;
|
|
23
|
+
code;
|
|
24
|
+
signal;
|
|
25
|
+
stderr;
|
|
26
|
+
source;
|
|
27
|
+
diagnosticId;
|
|
28
|
+
cleanupConfirmed;
|
|
29
|
+
cleanupBarrier;
|
|
30
|
+
constructor(details) {
|
|
31
|
+
const stderr = sanitizeCodexDiagnosticText(details.stderr, 8_192);
|
|
32
|
+
const source = details.source || "exit";
|
|
33
|
+
const diagnosticId = details.diagnosticId || randomUUID();
|
|
34
|
+
const summary = [
|
|
35
|
+
`Codex app-server process failure (phase=${details.phase}`,
|
|
36
|
+
`code=${details.code ?? "none"}`,
|
|
37
|
+
`signal=${details.signal ?? "none"}`,
|
|
38
|
+
`diagnosticId=${diagnosticId})`,
|
|
39
|
+
].join(", ");
|
|
40
|
+
super(summary);
|
|
41
|
+
this.name = "CodexAppProcessError";
|
|
42
|
+
this.phase = details.phase;
|
|
43
|
+
this.code = details.code;
|
|
44
|
+
this.signal = details.signal;
|
|
45
|
+
this.stderr = stderr;
|
|
46
|
+
this.source = source;
|
|
47
|
+
this.diagnosticId = diagnosticId;
|
|
48
|
+
this.cleanupConfirmed = details.cleanupConfirmed !== false;
|
|
49
|
+
this.cleanupBarrier = details.cleanupBarrier;
|
|
50
|
+
if (details.cause !== undefined)
|
|
51
|
+
this.cause = details.cause;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function isCodexAppProcessError(error) {
|
|
55
|
+
return error instanceof CodexAppProcessError || Boolean(error && typeof error === "object" && error.name === "CodexAppProcessError");
|
|
56
|
+
}
|
|
57
|
+
/** 去掉凭证、图片、用户目录及控制符,只保留有界的 stderr 尾部。 */
|
|
58
|
+
export function sanitizeCodexDiagnosticText(value, maxChars = CODEX_VISIBLE_DIAGNOSTIC_CHARS) {
|
|
59
|
+
const budget = Math.max(0, Math.floor(maxChars));
|
|
60
|
+
if (!budget)
|
|
61
|
+
return "";
|
|
62
|
+
let text = String(value || "")
|
|
63
|
+
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
|
|
64
|
+
.replace(/data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=_-]+/gi, "data:image/[REDACTED]")
|
|
65
|
+
// `trim()` below can remove the only space when "Bearer " and its
|
|
66
|
+
// value arrive in separate stderr chunks, so also redact the joined
|
|
67
|
+
// continuation form before it can enter the retained tail.
|
|
68
|
+
.replace(/\bBearer\s*[^\s"',;]+/gi, "Bearer [REDACTED]")
|
|
69
|
+
.replace(/\bsk-[a-z0-9_-]+/gi, "sk-[REDACTED]")
|
|
70
|
+
// stderr can arrive in arbitrary chunks. If the previous chunk ended
|
|
71
|
+
// inside a redacted value, discard every token-like continuation first
|
|
72
|
+
// so later assignment rules stay idempotent.
|
|
73
|
+
.replace(/["']?\[REDACTED(?:_LONG_TOKEN)?\]["']?[a-z0-9._~+/_=-]+/gi, "[REDACTED]")
|
|
74
|
+
.replace(/(["']?)([a-z][a-z0-9_]*(?:_token|_secret|_password|_api_key))\1(\s*[:=]\s*)(?:\[REDACTED(?:_LONG_TOKEN)?\]|"[^"]*"|'[^']*'|[^\s,;\]}]+)/gi, '$1$2$1$3"[REDACTED]"')
|
|
75
|
+
.replace(/([?&](?:access_token|refresh_token|connect_token|token|api_?key|secret|password)=)[^&\s]+/gi, "$1[REDACTED]")
|
|
76
|
+
.replace(/(["']?(?:authorization|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|connect[-_ ]?token|cookie|session|password|secret)["']?\s*[=:]\s*)["'][^"']*["']/gi, "$1\"[REDACTED]\"")
|
|
77
|
+
.replace(/((?:authorization|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|connect[-_ ]?token|cookie|session|password|secret)\s*[=:]\s*)(?:\[REDACTED(?:_LONG_TOKEN)?\]|[^\s"',;\]}]+)/gi, "$1[REDACTED]")
|
|
78
|
+
// A tail slice can begin in the middle of an image payload or unknown
|
|
79
|
+
// credential and therefore no longer contain a recognisable prefix.
|
|
80
|
+
.replace(/(?<![a-z0-9])[a-z0-9._~+/_=-]{32,}(?![a-z0-9])/gi, "[REDACTED_LONG_TOKEN]")
|
|
81
|
+
.replace(/[a-z]:[\\/]Users[\\/][^\\/]+(?=[\\/])/gi, "%USERPROFILE%")
|
|
82
|
+
.replace(/\/(?:Users|home)\/[^/\s]+/g, "$HOME")
|
|
83
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
84
|
+
.trim();
|
|
85
|
+
if (text.length > budget)
|
|
86
|
+
text = text.slice(-budget);
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
/** Append stderr only after redaction so the in-memory tail never holds raw payloads. */
|
|
90
|
+
export function appendCodexDiagnosticTail(current, chunk, maxChars = CODEX_STDERR_TAIL_CHARS) {
|
|
91
|
+
return sanitizeCodexDiagnosticText(`${current}${String(chunk || "")}`, maxChars);
|
|
92
|
+
}
|
|
13
93
|
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
|
14
94
|
export class CodexAppClient {
|
|
15
95
|
child;
|
|
16
96
|
emit;
|
|
97
|
+
onExit;
|
|
17
98
|
nextId = 1;
|
|
18
99
|
buffer = "";
|
|
19
100
|
currentThreadId = "";
|
|
@@ -27,55 +108,89 @@ export class CodexAppClient {
|
|
|
27
108
|
pendingDeltas = new Map();
|
|
28
109
|
plansByTurn = new Map();
|
|
29
110
|
approvalRequests = new Map();
|
|
111
|
+
phase = "spawn";
|
|
112
|
+
stderrTail = "";
|
|
113
|
+
logicalClosed = false;
|
|
114
|
+
osExitSeen = false;
|
|
115
|
+
failDelivered = false;
|
|
116
|
+
terminationExpected = false;
|
|
117
|
+
exitDelivered = false;
|
|
118
|
+
terminalError = null;
|
|
119
|
+
exitPromise;
|
|
120
|
+
resolveExit;
|
|
30
121
|
/** 保存 app-server 子进程和事件出口。 */
|
|
31
|
-
constructor(child, emit) {
|
|
122
|
+
constructor(child, emit, onExit) {
|
|
32
123
|
this.child = child;
|
|
33
124
|
this.emit = emit;
|
|
125
|
+
this.onExit = onExit;
|
|
126
|
+
this.exitPromise = new Promise((resolve) => { this.resolveExit = resolve; });
|
|
34
127
|
}
|
|
35
128
|
/** 启动并初始化 Codex app-server。 */
|
|
36
|
-
static async start(emit, onExit) {
|
|
129
|
+
static async start(emit, onExit, initializeTimeoutMs = APP_SERVER_INITIALIZE_TIMEOUT_MS) {
|
|
37
130
|
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
|
|
38
131
|
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
39
|
-
const client = new CodexAppClient(child, emit);
|
|
132
|
+
const client = new CodexAppClient(child, emit, onExit);
|
|
40
133
|
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
|
41
134
|
child.stderr?.on("data", (chunk) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
135
|
+
client.recordStderr(chunk.toString());
|
|
136
|
+
});
|
|
137
|
+
child.once("error", (error) => {
|
|
138
|
+
client.handleProcessFailure(new CodexAppProcessError({ phase: client.phase, code: null, signal: null, stderr: `${client.stderrTail}\n${error.message}`, cause: error, source: "spawn" }));
|
|
45
139
|
});
|
|
46
|
-
child.
|
|
47
|
-
|
|
48
|
-
emit("agent_error", { message: error.message });
|
|
140
|
+
child.once("exit", (code, signal) => {
|
|
141
|
+
client.handleProcessFailure(new CodexAppProcessError({ phase: client.phase, code, signal, stderr: client.stderrTail, source: "exit" }), true);
|
|
49
142
|
});
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
|
143
|
+
// spawn 失败时 Node 可能只有 error + close 而没有 exit;close 表示 stdio
|
|
144
|
+
// 已关闭,可安全解除 worker 的 OS-exit 隔离。正常 exit + close 由幂等保护处理。
|
|
145
|
+
child.once("close", () => {
|
|
146
|
+
client.noteOsExit();
|
|
55
147
|
});
|
|
56
148
|
try {
|
|
57
|
-
|
|
149
|
+
client.phase = "initialize";
|
|
150
|
+
await withTimeout(client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } }), Math.max(1, Math.min(APP_SERVER_INITIALIZE_TIMEOUT_MS, initializeTimeoutMs)), "Codex app-server initialize timed out");
|
|
58
151
|
client.notify("initialized");
|
|
152
|
+
client.phase = "idle";
|
|
59
153
|
return client;
|
|
60
154
|
}
|
|
61
155
|
catch (error) {
|
|
62
|
-
|
|
63
|
-
|
|
156
|
+
const processError = isCodexAppProcessError(error) ? error : new CodexAppProcessError({ phase: "initialize", code: child.exitCode, signal: child.signalCode, stderr: `${client.stderrTail}\n${error instanceof Error ? error.message : String(error)}`, cause: error, source: "timeout" });
|
|
157
|
+
if (!isCodexAppProcessError(error))
|
|
158
|
+
void persistCodexProcessDiagnostic(processError);
|
|
159
|
+
processError.cleanupConfirmed = await client.terminate("Codex app-server 初始化失败,已回收当前进程");
|
|
160
|
+
if (!processError.cleanupConfirmed)
|
|
161
|
+
processError.cleanupBarrier = client.waitForExit();
|
|
162
|
+
throw processError;
|
|
64
163
|
}
|
|
65
164
|
}
|
|
66
165
|
/** 创建新的 Codex 线程。 */
|
|
67
166
|
async startThread(cwd, permissionMode = "request", modelSettings = {}) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
167
|
+
this.stderrTail = "";
|
|
168
|
+
this.phase = "thread/start";
|
|
169
|
+
try {
|
|
170
|
+
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode, modelSettings), ...(cwd ? { cwd } : {}), ...(modelSettings.model ? { model: modelSettings.model } : {}), threadSource: "user" });
|
|
171
|
+
if (!thread.id)
|
|
172
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
173
|
+
return thread;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
if (!this.logicalClosed)
|
|
177
|
+
this.phase = "idle";
|
|
178
|
+
}
|
|
72
179
|
}
|
|
73
180
|
/** 恢复已有 Codex 线程。 */
|
|
74
181
|
async resumeThread(threadId, cwd, permissionMode = "request", modelSettings = {}) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
182
|
+
this.stderrTail = "";
|
|
183
|
+
this.phase = "thread/start";
|
|
184
|
+
try {
|
|
185
|
+
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode, modelSettings), ...(cwd ? { cwd } : {}), ...(modelSettings.model ? { model: modelSettings.model } : {}) });
|
|
186
|
+
if (!thread.id)
|
|
187
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
188
|
+
return thread;
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
if (!this.logicalClosed)
|
|
192
|
+
this.phase = "idle";
|
|
193
|
+
}
|
|
79
194
|
}
|
|
80
195
|
/** 查询 Codex 线程列表。 */
|
|
81
196
|
listThreads(params) {
|
|
@@ -103,25 +218,33 @@ export class CodexAppClient {
|
|
|
103
218
|
/** 启动一个 Codex turn 并等待完成通知。 */
|
|
104
219
|
async startTurn(threadId, prompt, images, permissionMode, onTurn, outputSchema, modelSettings = {}) {
|
|
105
220
|
this.currentThreadId = threadId;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
221
|
+
this.phase = "turn/start";
|
|
222
|
+
try {
|
|
223
|
+
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(modelSettings.model ? { model: modelSettings.model } : {}), ...(modelSettings.reasoningEffort ? { effort: modelSettings.reasoningEffort } : {}), ...(outputSchema ? { outputSchema } : {}) });
|
|
224
|
+
const turnId = turn.id;
|
|
225
|
+
if (!turnId)
|
|
226
|
+
throw new Error("Codex app-server 没有返回 turn id");
|
|
227
|
+
this.currentTurnId = turnId;
|
|
228
|
+
this.phase = "turn/running";
|
|
229
|
+
onTurn?.(turnId);
|
|
230
|
+
const completed = this.completedTurns.get(turnId);
|
|
231
|
+
if (this.completedTurns.has(turnId)) {
|
|
232
|
+
this.completedTurns.delete(turnId);
|
|
233
|
+
this.currentThreadId = "";
|
|
234
|
+
this.currentTurnId = "";
|
|
235
|
+
if (completed?.error)
|
|
236
|
+
throw completed.error;
|
|
237
|
+
return completed?.text || "";
|
|
238
|
+
}
|
|
239
|
+
return await new Promise((resolve, reject) => {
|
|
240
|
+
this.activeTurns.set(turnId, { resolve, reject });
|
|
241
|
+
this.scheduleTurnReconciliation(threadId, turnId);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
if (!this.logicalClosed)
|
|
246
|
+
this.phase = "idle";
|
|
120
247
|
}
|
|
121
|
-
return await new Promise((resolve, reject) => {
|
|
122
|
-
this.activeTurns.set(turnId, { resolve, reject });
|
|
123
|
-
this.scheduleTurnReconciliation(threadId, turnId);
|
|
124
|
-
});
|
|
125
248
|
}
|
|
126
249
|
/** 中断当前正在运行的 Codex turn。 */
|
|
127
250
|
async interruptCurrentTurn() {
|
|
@@ -140,15 +263,40 @@ export class CodexAppClient {
|
|
|
140
263
|
}
|
|
141
264
|
}
|
|
142
265
|
/** 终止失去响应的 app-server,让后续队列可以在新进程继续。 */
|
|
143
|
-
terminate(message = "Codex app-server was restarted") {
|
|
144
|
-
this.
|
|
145
|
-
if (this.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
this.
|
|
266
|
+
async terminate(message = "Codex app-server was restarted", diagnostics = {}) {
|
|
267
|
+
const failurePhase = diagnostics.phase || this.phase;
|
|
268
|
+
if (!this.logicalClosed) {
|
|
269
|
+
this.terminationExpected = true;
|
|
270
|
+
this.logicalClosed = true;
|
|
271
|
+
this.terminalError = new Error(message);
|
|
272
|
+
this.phase = "terminating";
|
|
273
|
+
this.failAll(this.terminalError);
|
|
274
|
+
this.deliverExit();
|
|
275
|
+
}
|
|
276
|
+
if (diagnostics.persistDiagnostic) {
|
|
277
|
+
void persistCodexProcessDiagnostic(new CodexAppProcessError({ phase: failurePhase, code: this.child.exitCode, signal: this.child.signalCode, stderr: `${this.stderrTail}\n${message}`, source: "timeout" }));
|
|
278
|
+
}
|
|
279
|
+
if (this.osExitSeen || this.child.exitCode !== null) {
|
|
280
|
+
this.noteOsExit();
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (!this.child.killed)
|
|
150
284
|
this.child.kill();
|
|
151
|
-
|
|
285
|
+
let timer;
|
|
286
|
+
try {
|
|
287
|
+
return await Promise.race([
|
|
288
|
+
this.exitPromise.then(() => true),
|
|
289
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), 2_000); }),
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
if (timer)
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** 仅供 worker 隔离等待真实 OS exit;不包含 stderr 或其它诊断内容。 */
|
|
298
|
+
waitForExit() {
|
|
299
|
+
return this.exitPromise;
|
|
152
300
|
}
|
|
153
301
|
/** 回复网页端已经确认的 Codex 权限请求。 */
|
|
154
302
|
resolveApproval(requestId, decision) {
|
|
@@ -166,8 +314,18 @@ export class CodexAppClient {
|
|
|
166
314
|
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
|
|
167
315
|
request(method, params) {
|
|
168
316
|
const id = this.nextId++;
|
|
169
|
-
|
|
170
|
-
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
if (this.logicalClosed || this.terminalError)
|
|
319
|
+
return reject(this.terminalError || new Error("Codex app-server is closed"));
|
|
320
|
+
this.pending.set(id, { resolve: (result) => resolve(result), reject });
|
|
321
|
+
try {
|
|
322
|
+
this.write({ id, method, params });
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
this.pending.delete(id);
|
|
326
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
171
329
|
}
|
|
172
330
|
/** 发送无需响应的 JSON-RPC 通知。 */
|
|
173
331
|
notify(method, params) {
|
|
@@ -179,7 +337,24 @@ export class CodexAppClient {
|
|
|
179
337
|
const params = field(value, "params");
|
|
180
338
|
if (method)
|
|
181
339
|
logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
|
|
182
|
-
this.child.stdin
|
|
340
|
+
const stdin = this.child.stdin;
|
|
341
|
+
if (!stdin || stdin.destroyed || !stdin.writable) {
|
|
342
|
+
const error = new CodexAppProcessError({ phase: this.phase, code: this.child.exitCode, signal: this.child.signalCode, stderr: this.stderrTail, source: "stdin" });
|
|
343
|
+
this.handleProcessFailure(error);
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
stdin.write(`${JSON.stringify(value)}\n`, (cause) => {
|
|
348
|
+
if (!cause)
|
|
349
|
+
return;
|
|
350
|
+
this.handleProcessFailure(new CodexAppProcessError({ phase: this.phase, code: this.child.exitCode, signal: this.child.signalCode, stderr: `${this.stderrTail}\n${cause.message}`, cause, source: "stdin" }));
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (cause) {
|
|
354
|
+
const error = new CodexAppProcessError({ phase: this.phase, code: this.child.exitCode, signal: this.child.signalCode, stderr: `${this.stderrTail}\n${cause instanceof Error ? cause.message : String(cause)}`, cause, source: "stdin" });
|
|
355
|
+
this.handleProcessFailure(error);
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
183
358
|
}
|
|
184
359
|
/** 按行解析 app-server 标准输出。 */
|
|
185
360
|
read(chunk) {
|
|
@@ -191,8 +366,8 @@ export class CodexAppClient {
|
|
|
191
366
|
this.handle(JSON.parse(line));
|
|
192
367
|
}
|
|
193
368
|
catch (error) {
|
|
194
|
-
logger.warn("Invalid Codex app-server output", { error, line });
|
|
195
|
-
this.emit("agent_log", { text:
|
|
369
|
+
logger.warn("Invalid Codex app-server output", { error: error instanceof Error ? error.message : String(error), bytes: line.length });
|
|
370
|
+
this.emit("agent_log", { text: "Codex app-server 返回了无法解析的本地响应;为保护提示词和凭证,原始内容仅丢弃不展示" });
|
|
196
371
|
}
|
|
197
372
|
});
|
|
198
373
|
}
|
|
@@ -395,14 +570,55 @@ export class CodexAppClient {
|
|
|
395
570
|
if (pending)
|
|
396
571
|
(this.pending.delete(id), pending.reject(new Error(message)));
|
|
397
572
|
}
|
|
573
|
+
/** 只在内存保存 stderr 尾部;原文绝不直接发往网页或写入日志。 */
|
|
574
|
+
recordStderr(chunk) {
|
|
575
|
+
this.stderrTail = appendCodexDiagnosticTail(this.stderrTail, chunk);
|
|
576
|
+
}
|
|
577
|
+
/** error/exit/stdin 统一且只执行一次的进程收尾。 */
|
|
578
|
+
handleProcessFailure(error, exitSeen = false) {
|
|
579
|
+
if (exitSeen)
|
|
580
|
+
this.noteOsExit();
|
|
581
|
+
if (this.logicalClosed)
|
|
582
|
+
return;
|
|
583
|
+
this.logicalClosed = true;
|
|
584
|
+
this.terminalError = error;
|
|
585
|
+
const expected = this.terminationExpected;
|
|
586
|
+
if (exitSeen)
|
|
587
|
+
this.phase = "exited";
|
|
588
|
+
if (!expected) {
|
|
589
|
+
this.failAll(error);
|
|
590
|
+
void persistCodexProcessDiagnostic(error);
|
|
591
|
+
logger.warn("Codex app-server process failure", { message: error.message });
|
|
592
|
+
this.emit("agent_log", { text: error.message });
|
|
593
|
+
if (this.child.exitCode === null && !this.child.killed)
|
|
594
|
+
this.child.kill();
|
|
595
|
+
}
|
|
596
|
+
this.deliverExit();
|
|
597
|
+
}
|
|
598
|
+
noteOsExit() {
|
|
599
|
+
if (this.osExitSeen)
|
|
600
|
+
return;
|
|
601
|
+
this.osExitSeen = true;
|
|
602
|
+
this.phase = "exited";
|
|
603
|
+
this.resolveExit();
|
|
604
|
+
}
|
|
605
|
+
deliverExit() {
|
|
606
|
+
if (this.exitDelivered)
|
|
607
|
+
return;
|
|
608
|
+
this.exitDelivered = true;
|
|
609
|
+
this.onExit();
|
|
610
|
+
}
|
|
398
611
|
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
|
399
|
-
failAll(
|
|
400
|
-
|
|
612
|
+
failAll(error) {
|
|
613
|
+
if (this.failDelivered)
|
|
614
|
+
return;
|
|
615
|
+
this.failDelivered = true;
|
|
616
|
+
[...this.pending.values()].forEach((item) => item.reject(error));
|
|
401
617
|
[...this.activeTurns.entries()].forEach(([turnId, item]) => {
|
|
402
618
|
if (item.reconcileTimer)
|
|
403
619
|
clearTimeout(item.reconcileTimer);
|
|
404
620
|
this.activeTurns.delete(turnId);
|
|
405
|
-
item.reject(
|
|
621
|
+
item.reject(error);
|
|
406
622
|
});
|
|
407
623
|
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
|
|
408
624
|
this.pending.clear();
|
|
@@ -415,6 +631,29 @@ export class CodexAppClient {
|
|
|
415
631
|
this.currentTurnId = "";
|
|
416
632
|
}
|
|
417
633
|
}
|
|
634
|
+
/** 将脱敏诊断写入用户目录;单日有界,写入失败不影响脚本恢复。 */
|
|
635
|
+
function persistCodexProcessDiagnostic(error) {
|
|
636
|
+
diagnosticWriteQueue = diagnosticWriteQueue.catch(() => undefined).then(async () => {
|
|
637
|
+
const directory = path.join(CONFIG_DIR, "logs");
|
|
638
|
+
const file = path.join(directory, `codex-app-server-diagnostics-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
639
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
640
|
+
const currentBytes = await fs.stat(file).then((value) => value.size).catch(() => 0);
|
|
641
|
+
const entry = `${JSON.stringify({
|
|
642
|
+
at: new Date().toISOString(),
|
|
643
|
+
diagnosticId: error.diagnosticId,
|
|
644
|
+
phase: error.phase,
|
|
645
|
+
source: error.source,
|
|
646
|
+
code: error.code,
|
|
647
|
+
signal: error.signal,
|
|
648
|
+
stderr: sanitizeCodexDiagnosticText(error.stderr, 8_192),
|
|
649
|
+
})}\n`;
|
|
650
|
+
const bytes = Buffer.byteLength(entry);
|
|
651
|
+
if (bytes > CODEX_DIAGNOSTIC_ENTRY_BYTES || currentBytes + bytes > CODEX_DIAGNOSTIC_FILE_BYTES)
|
|
652
|
+
return;
|
|
653
|
+
await fs.appendFile(file, entry, { encoding: "utf8", mode: 0o600 });
|
|
654
|
+
}).catch((error) => logger.debug("Failed to persist Codex process diagnostic", { error }));
|
|
655
|
+
return diagnosticWriteQueue;
|
|
656
|
+
}
|
|
418
657
|
async function withTimeout(promise, timeoutMs, message) {
|
|
419
658
|
let timer;
|
|
420
659
|
try {
|
package/dist/agent/codex.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CodexAppProcessError } from "./codex-client.js";
|
|
1
2
|
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
|
|
2
3
|
type CodexRunOptions = {
|
|
3
4
|
threadId?: string;
|
|
@@ -29,19 +30,34 @@ export type CodexWorkflowRunResult = {
|
|
|
29
30
|
ok: false;
|
|
30
31
|
error: string;
|
|
31
32
|
retryable: boolean;
|
|
33
|
+
failureKind: "contract" | "transport" | "timeout" | "turn";
|
|
32
34
|
timings: {
|
|
33
35
|
queueWaitMs: number;
|
|
34
36
|
threadStartMs: number;
|
|
35
37
|
modelMs: number;
|
|
36
38
|
};
|
|
37
39
|
};
|
|
40
|
+
type CodexWorkflowFailureKind = Extract<CodexWorkflowRunResult, {
|
|
41
|
+
ok: false;
|
|
42
|
+
}>["failureKind"];
|
|
38
43
|
export declare const FLOW_C_CODEX_MODEL = "gpt-5.6-terra";
|
|
39
44
|
export declare const FLOW_C_CODEX_REASONING_EFFORT = "medium";
|
|
40
45
|
export declare const FLOW_C_CODEX_WORKER_CONCURRENCY: number;
|
|
46
|
+
export declare const FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS = 3;
|
|
41
47
|
export declare function flowCCodexWorkerStatus(): {
|
|
42
48
|
active: number;
|
|
43
49
|
limit: number;
|
|
44
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* 记录尚未确认 OS exit 的 worker 进程。worker 可以释放给队列,但在对应
|
|
53
|
+
* barrier 完成前只能等待/失败,绝不能启动替代 app-server。
|
|
54
|
+
*/
|
|
55
|
+
export declare class WorkflowCodexLaneQuarantine {
|
|
56
|
+
private barriers;
|
|
57
|
+
quarantine(workerIndex: number, barrier: Promise<void>): Promise<undefined>;
|
|
58
|
+
wait(workerIndex: number, timeoutMs: number): Promise<boolean>;
|
|
59
|
+
isQuarantined(workerIndex: number): boolean;
|
|
60
|
+
}
|
|
45
61
|
export { summarizeCodexThread } from "./codex-history.js";
|
|
46
62
|
/** 将 Codex turn 加入串行队列并等待执行完成。 */
|
|
47
63
|
export declare function runCodexTurn(prompt: string, emit: AgentEmit, attachments?: AgentAttachment[], options?: CodexRunOptions): Promise<CodexRunResult>;
|
|
@@ -57,6 +73,15 @@ export declare function runCodexWorkflowTurn(prompt: string, emit: AgentEmit, op
|
|
|
57
73
|
onWorkerStart?: () => void;
|
|
58
74
|
onWorkerFinish?: () => void;
|
|
59
75
|
}): Promise<CodexWorkflowRunResult>;
|
|
76
|
+
/** A deadline exhausted after process recovery is terminal for this handoff wave. */
|
|
77
|
+
export declare function isWorkflowFailureRetryable(failureKind: CodexWorkflowFailureKind, cleanupConfirmed: boolean, processRecoveryObserved: boolean): boolean;
|
|
78
|
+
type CodexProcessRetryOptions = {
|
|
79
|
+
maxAttempts?: number;
|
|
80
|
+
backoffMs?: number | ((failedAttempt: number) => number);
|
|
81
|
+
onRetry?: (error: CodexAppProcessError, failedAttempt: number) => Promise<void> | void;
|
|
82
|
+
};
|
|
83
|
+
/** 只重试明确的 app-server 进程/stdio 故障;结构契约和普通模型失败均立即上抛。 */
|
|
84
|
+
export declare function runCodexProcessRetries<T>(operation: (attempt: number) => Promise<T>, options?: CodexProcessRetryOptions): Promise<T>;
|
|
60
85
|
/**
|
|
61
86
|
* 给 Flow C 的完整 app-server 链路设置硬截止时间。超时后不再等待原 Promise
|
|
62
87
|
* 自行结束;否则初始化或 thread/start 永不返回时会永久占住 worker lane。
|
package/dist/agent/codex.js
CHANGED
|
@@ -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
|
|
86
|
+
const deadlineAt = Date.now() + Math.max(1, options.timeoutMs);
|
|
45
87
|
let threadStartMs = 0;
|
|
46
|
-
let
|
|
47
|
-
let app
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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;
|
|
@@ -54,7 +54,7 @@ type ReferenceStyleCard = {
|
|
|
54
54
|
categories?: unknown;
|
|
55
55
|
};
|
|
56
56
|
};
|
|
57
|
-
type StoryboardLayoutVersion = "director-table-scripted-v1" | "visual-storyboard-adaptive-v1" | "hybrid-three-anchor-v1" | "legacy-five-row-v1";
|
|
57
|
+
type StoryboardLayoutVersion = "director-table-scripted-v1" | "visual-storyboard-landscape-v2" | "visual-storyboard-adaptive-v1" | "hybrid-three-anchor-v1" | "legacy-five-row-v1";
|
|
58
58
|
type FlowCProductionMode = "storyboard" | "visual-storyboard" | "first-frame-chain";
|
|
59
59
|
type ProductInput = {
|
|
60
60
|
productIndex: number;
|
|
@@ -150,6 +150,7 @@ type ScriptTask = {
|
|
|
150
150
|
type ScriptChunkResult = {
|
|
151
151
|
error?: string;
|
|
152
152
|
terminal: boolean;
|
|
153
|
+
terminalKind?: "contract" | "transport" | "timeout" | "turn";
|
|
153
154
|
replanOrdinals?: number[];
|
|
154
155
|
};
|
|
155
156
|
/** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
|
|
@@ -371,6 +372,11 @@ export declare function terminalScriptChunkError(results: Array<{
|
|
|
371
372
|
error?: string;
|
|
372
373
|
terminal?: boolean;
|
|
373
374
|
}>): string;
|
|
375
|
+
export declare function terminalScriptChunkFailure<T extends {
|
|
376
|
+
error?: string;
|
|
377
|
+
terminal?: boolean;
|
|
378
|
+
terminalKind?: ScriptChunkResult["terminalKind"];
|
|
379
|
+
}>(results: T[]): T | undefined;
|
|
374
380
|
export declare function scriptCreativeReplanOrdinals(results: unknown[]): number[];
|
|
375
381
|
/** Keep a mixed reset signal after an exact-duplicate rewrite finishes. */
|
|
376
382
|
export declare function preserveScriptRecoveryReplans(result: ScriptChunkResult, error: unknown): ScriptChunkResult;
|
package/dist/workflow/manager.js
CHANGED
|
@@ -309,9 +309,12 @@ export class WorkflowManager {
|
|
|
309
309
|
}));
|
|
310
310
|
const results = pipelineResults.flat();
|
|
311
311
|
task = await this.scriptTask(id);
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
314
|
-
|
|
312
|
+
const terminalFailure = terminalScriptChunkFailure(results);
|
|
313
|
+
if (terminalFailure) {
|
|
314
|
+
if (terminalFailure.terminalKind === "transport")
|
|
315
|
+
throw new Error(`创意或脚本阶段的本机 Codex 进程连续异常且自动恢复未成功,已停止当前任务且未提交缺失脚本(${terminalFailure.error})`);
|
|
316
|
+
throw new Error(`创意或脚本结构化契约被 Codex 拒绝,已停止自动重试(${terminalFailure.error})`);
|
|
317
|
+
}
|
|
315
318
|
const replanOrdinals = scriptCreativeReplanOrdinals(results);
|
|
316
319
|
if (replanOrdinals.length) {
|
|
317
320
|
recordCreativeReplanAttempts(creativeReplanAttempts, replanOrdinals);
|
|
@@ -319,9 +322,12 @@ export class WorkflowManager {
|
|
|
319
322
|
record.updatedAt = now();
|
|
320
323
|
this.save();
|
|
321
324
|
const replanResults = await Promise.all(replanOrdinals.map((ordinal) => this.runCandidateChunk(id, task, [ordinal], workspace.workspacePath)));
|
|
322
|
-
const
|
|
323
|
-
if (
|
|
324
|
-
|
|
325
|
+
const replanTerminalFailure = terminalScriptChunkFailure(replanResults);
|
|
326
|
+
if (replanTerminalFailure) {
|
|
327
|
+
if (replanTerminalFailure.terminalKind === "transport")
|
|
328
|
+
throw new Error(`创意重新选题时本机 Codex 进程异常且自动恢复未成功,已停止当前任务(${replanTerminalFailure.error})`);
|
|
329
|
+
throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalFailure.error})`);
|
|
330
|
+
}
|
|
325
331
|
const replanError = replanResults.find((result) => result.error)?.error;
|
|
326
332
|
if (replanError)
|
|
327
333
|
throw new Error(`创意重新选题失败(${replanError}),请点击重试`);
|
|
@@ -350,9 +356,11 @@ export class WorkflowManager {
|
|
|
350
356
|
this.save();
|
|
351
357
|
const results = await Promise.all(wave.chunks.map((ordinals) => this.runScriptChunk(id, task, ordinals, workspace.workspacePath)));
|
|
352
358
|
task = await this.scriptTask(id);
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
|
|
359
|
+
const terminalFailure = terminalScriptChunkFailure(results);
|
|
360
|
+
if (terminalFailure) {
|
|
361
|
+
if (terminalFailure.terminalKind === "transport")
|
|
362
|
+
throw new Error(`本机 Codex 脚本引擎连续异常且自动恢复未成功,已停止当前任务且未提交缺失脚本。诊断已保存在本机 Agent 日志中(${terminalFailure.error})`);
|
|
363
|
+
throw new Error(`本机脚本结构化契约被 Codex 拒绝,已停止自动重试且未提交缺失脚本。请先升级或修复 Canvas Agent,再手动重试(${terminalFailure.error})`);
|
|
356
364
|
}
|
|
357
365
|
const replanOrdinals = scriptCreativeReplanOrdinals(results);
|
|
358
366
|
if (replanOrdinals.length) {
|
|
@@ -361,9 +369,12 @@ export class WorkflowManager {
|
|
|
361
369
|
record.updatedAt = now();
|
|
362
370
|
this.save();
|
|
363
371
|
const replanResults = await Promise.all(replanOrdinals.map((ordinal) => this.runCandidateChunk(id, task, [ordinal], workspace.workspacePath)));
|
|
364
|
-
const
|
|
365
|
-
if (
|
|
366
|
-
|
|
372
|
+
const replanTerminalFailure = terminalScriptChunkFailure(replanResults);
|
|
373
|
+
if (replanTerminalFailure) {
|
|
374
|
+
if (replanTerminalFailure.terminalKind === "transport")
|
|
375
|
+
throw new Error(`创意重新选题时本机 Codex 进程异常且自动恢复未成功,已停止当前任务(${replanTerminalFailure.error})`);
|
|
376
|
+
throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalFailure.error})`);
|
|
377
|
+
}
|
|
367
378
|
const replanError = replanResults.find((result) => result.error)?.error;
|
|
368
379
|
if (replanError)
|
|
369
380
|
throw new Error(`创意重新选题失败(${replanError}),请点击重试`);
|
|
@@ -504,8 +515,10 @@ export class WorkflowManager {
|
|
|
504
515
|
onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
|
|
505
516
|
});
|
|
506
517
|
this.emitScriptStage(id, ordinals, "candidate_model", result.timings.queueWaitMs + result.timings.threadStartMs + result.timings.modelMs);
|
|
507
|
-
if (!result.ok
|
|
508
|
-
return { error: result.
|
|
518
|
+
if (!result.ok)
|
|
519
|
+
return { error: result.error, terminal: !result.retryable, ...(!result.retryable ? { terminalKind: result.failureKind } : {}) };
|
|
520
|
+
if (!result.text)
|
|
521
|
+
return { error: "Codex 未返回创意候选", terminal: false };
|
|
509
522
|
try {
|
|
510
523
|
const groups = parseFlowCCreativeCandidateOutput(result.text, ordinals);
|
|
511
524
|
const startedAt = Date.now();
|
|
@@ -541,8 +554,10 @@ export class WorkflowManager {
|
|
|
541
554
|
this.emitScriptStage(id, ordinals, "queue_wait", result.timings.queueWaitMs);
|
|
542
555
|
this.emitScriptStage(id, ordinals, "thread_start", result.timings.threadStartMs);
|
|
543
556
|
this.emitScriptStage(id, ordinals, "model", result.timings.modelMs);
|
|
544
|
-
if (!result.ok
|
|
545
|
-
return { error: result.
|
|
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 };
|
|
546
561
|
try {
|
|
547
562
|
const parseStartedAt = Date.now();
|
|
548
563
|
const selected = selectedCandidatesForOrdinals(task, ordinals);
|
|
@@ -579,9 +594,11 @@ export class WorkflowManager {
|
|
|
579
594
|
if (!pendingRevisionOrdinals.length)
|
|
580
595
|
return { terminal: false };
|
|
581
596
|
const revisionResults = await Promise.all(pendingRevisionOrdinals.map((ordinal) => this.runScriptChunk(id, refreshedTask, [ordinal], cwd, 0, revisionAttempt + 1)));
|
|
582
|
-
const
|
|
597
|
+
const revisionTerminalFailure = terminalScriptChunkFailure(revisionResults);
|
|
598
|
+
const revisionError = revisionTerminalFailure?.error || revisionResults.map((result) => result.error).filter(Boolean).join(";");
|
|
583
599
|
return {
|
|
584
|
-
terminal: Boolean(
|
|
600
|
+
terminal: Boolean(revisionTerminalFailure),
|
|
601
|
+
...(revisionTerminalFailure?.terminalKind ? { terminalKind: revisionTerminalFailure.terminalKind } : {}),
|
|
585
602
|
...(revisionError ? { error: revisionError } : {}),
|
|
586
603
|
...(scriptCreativeReplanOrdinals(revisionResults).length ? { replanOrdinals: scriptCreativeReplanOrdinals(revisionResults) } : {}),
|
|
587
604
|
};
|
|
@@ -605,9 +622,11 @@ export class WorkflowManager {
|
|
|
605
622
|
if (!pendingRewriteOrdinals.length)
|
|
606
623
|
return preserveScriptRecoveryReplans({ terminal: false }, error);
|
|
607
624
|
const rewriteResults = await Promise.all(pendingRewriteOrdinals.map((ordinal) => this.runScriptChunk(id, refreshedTask, [ordinal], cwd, rewriteAttempt + 1)));
|
|
608
|
-
const
|
|
625
|
+
const rewriteTerminalFailure = terminalScriptChunkFailure(rewriteResults);
|
|
626
|
+
const rewriteError = rewriteTerminalFailure?.error || rewriteResults.map((result) => result.error).filter(Boolean).join(";");
|
|
609
627
|
const rewriteResult = {
|
|
610
|
-
terminal: Boolean(
|
|
628
|
+
terminal: Boolean(rewriteTerminalFailure),
|
|
629
|
+
...(rewriteTerminalFailure?.terminalKind ? { terminalKind: rewriteTerminalFailure.terminalKind } : {}),
|
|
611
630
|
...(rewriteError ? { error: rewriteError } : {}),
|
|
612
631
|
...(scriptCreativeReplanOrdinals(rewriteResults).length ? { replanOrdinals: scriptCreativeReplanOrdinals(rewriteResults) } : {}),
|
|
613
632
|
};
|
|
@@ -793,7 +812,10 @@ export function compareScriptQueueRecords(left, right) {
|
|
|
793
812
|
}
|
|
794
813
|
/** A deterministic response-format failure stops fallback isolation immediately. */
|
|
795
814
|
export function terminalScriptChunkError(results) {
|
|
796
|
-
return results
|
|
815
|
+
return terminalScriptChunkFailure(results)?.error || "";
|
|
816
|
+
}
|
|
817
|
+
export function terminalScriptChunkFailure(results) {
|
|
818
|
+
return results.find((result) => result.terminal);
|
|
797
819
|
}
|
|
798
820
|
export function scriptCreativeReplanOrdinals(results) {
|
|
799
821
|
return [...new Set(results.flatMap((result) => (result && typeof result === "object" ? result.replanOrdinals || [] : [])).filter((ordinal) => Number.isInteger(ordinal) && ordinal > 0))].sort((left, right) => left - right);
|