Skip to content

Commit cacc21f

Browse files
committed
fix(verify+flush): 修复代码审查确认的 9 个缺陷
核验管线(feat 2e14bde 的补丁): - 核验结论 max_tokens 从固定 500 改为随验收标准长度伸缩(500~2000)—— 此前条目越多越长(恰恰是最差产出)越容易截断 JSON,核验静默失效 - parseVerify: pass=false 但给不出任何未满足条目 → 判核验不可用(触发重试/跳过), 不再带着空清单烧一轮返工、显示"验收 ⚠️ 0 条未满足" - 条目内嵌换行压平成单行——CLI ⚠️ 行、步骤文件头引用块、SSE 逐行解析都按单行消费 - 核验调用加外层超时兜底(同主链路 withTimeout 哲学),connector 内部超时失灵时 不再让一次轻量核验挂死整条产线 - --compare 透传 --verify/--no-verify(此前 compare 模式下开关被静默忽略) 中断存档(feat 207b937 的补丁): - run() 结束立即摘除 SIGTERM/SIGINT 监听——此前跑完后(--materialize/--export 收尾期)Ctrl-C 会把已成功的运行二次存档成"被中断"的重复档案 - 步骤 settle 即写入 stepResultsSink,不等整批屏障——并行批次里先完成的步骤 在中断时不再被丢弃(产出和 token 白花) - --compare 补传 signalFlush(CLI compare 中断此前无痕消失) - resume 复用步骤把上次档案的角色名/验收标准/核验结果带回新档案—— 续跑的 metadata 不再丢验收记录 Studio SSE(feat 8ae801f 的补丁): - 空行终止 verify-item 窗口——正文里以 ⚠️ 开头的行不再被误吞成核验条目 test/verify.ts 新增 3 项(32 通过);全量 npm test 通过
1 parent 8ae801f commit cacc21f

6 files changed

Lines changed: 88 additions & 4 deletions

File tree

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ async function handleRun(): Promise<void> {
209209
const cmp = await compareWorkflowVsBaseline(resolve(filePath), inputs, {
210210
outputDir,
211211
quiet,
212+
verify: parseVerifyFlag(),
213+
signalFlush: true,
212214
genOverride: llmOverride,
213215
judgeLlm: judgeProvider
214216
? { provider: judgeProvider, model: judgeModel, timeout: 600_000 } as LLMConfig

src/core/executor.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export interface ExecutorOptions {
5050
* 供 SIGTERM/SIGINT 中断时把已完成步骤落盘成 metadata(否则中断的 run 无痕)。
5151
*/
5252
stepResultsSink?: StepResult[];
53+
/**
54+
* resume 复用步骤在上一次运行档案里的展示字段(agentName/acceptance/verification 等),
55+
* 由 run() 从旧 metadata 读出传入——续跑产生的新档案才不丢被复用步骤的验收记录。
56+
*/
57+
restoredStepMeta?: Map<string, Partial<StepResult>>;
5358
}
5459

5560
export async function executeDAG(dag: DAG, options: ExecutorOptions): Promise<WorkflowResult> {
@@ -111,9 +116,16 @@ export async function executeDAG(dag: DAG, options: ExecutorOptions): Promise<Wo
111116
node.result = node.step.output ? context.get(node.step.output) : undefined;
112117
node.startTime = Date.now();
113118
node.endTime = node.startTime;
119+
// 上一次运行档案里的展示字段(角色名/验收标准/核验结果)随复用一起带回,
120+
// 否则续跑的新档案里这些步骤全变成裸 id、验收记录凭空消失
121+
const prev = options.restoredStepMeta?.get(node.step.id);
114122
upsertStepResult(stepResults, {
115123
id: node.step.id,
116124
role: node.step.role,
125+
agentName: prev?.agentName,
126+
agentEmoji: prev?.agentEmoji,
127+
acceptance: prev?.acceptance,
128+
verification: prev?.verification,
117129
status: 'completed',
118130
output: node.result,
119131
output_var: node.step.output,
@@ -161,6 +173,26 @@ export async function executeDAG(dag: DAG, options: ExecutorOptions): Promise<Wo
161173
onStepStart,
162174
feedback: options.feedback,
163175
verify: options.verify,
176+
}).then(value => {
177+
// 中断兜底:settle 即写入 sink 一份最小记录,不等整批屏障——否则并行批次里
178+
// 先完成的步骤在 SIGTERM 时会被当作"未完成"丢弃(产出和 token 白花)。
179+
// 批次收尾的完整 upsert 会按 id 覆盖这份记录。
180+
if (options.stepResultsSink && node.status !== 'skipped') {
181+
upsertStepResult(stepResults, {
182+
id: node.step.id,
183+
role: node.step.role,
184+
agentName: node.agentName,
185+
agentEmoji: node.agentEmoji,
186+
status: 'completed',
187+
output: value,
188+
output_var: node.step.output,
189+
acceptance: node.acceptance ?? node.step.acceptance,
190+
verification: node.verification,
191+
duration: Date.now() - (node.startTime || Date.now()),
192+
tokens: node.tokenUsage || { input: 0, output: 0 },
193+
});
194+
}
195+
return value;
164196
}))
165197
);
166198

src/core/verify.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import type { LLMConfig, LLMConnector } from '../types.js';
1313
const VERIFY_TRUNC = 20000;
1414
const trunc = (s: string, n = VERIFY_TRUNC) => (s.length > n ? s.slice(0, n) + '\n…[截断]' : s);
1515

16+
// 核验调用的外层超时兜底(同 executor.withTimeout 的哲学:connector 内部超时失灵时
17+
// 不能让一次"轻量核验"挂死整条产线)。核验不值得等太久,超时按核验不可用处理。
18+
const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> =>
19+
ms <= 0 ? promise : Promise.race([
20+
promise,
21+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`核验超时 (${ms}ms)`)), ms).unref?.()),
22+
]);
23+
1624
export interface VerifyVerdict {
1725
pass: boolean;
1826
/** 未满足的条目(criterion=哪条标准,why=一句话原因) */
@@ -30,10 +38,16 @@ export function parseVerify(raw: string): VerifyVerdict | null {
3038
? j.failed
3139
.map((f: unknown) => {
3240
const o = (f ?? {}) as Record<string, unknown>;
33-
return { criterion: String(o.criterion ?? '').trim(), why: String(o.why ?? '').trim() };
41+
// 压平内嵌换行:criterion/why 的每个下游消费方(CLI ⚠️ 行、步骤文件头引用块、
42+
// SSE 逐行解析)都按单行处理,换行会逃出引用块/被误判为正文
43+
const flat = (v: unknown) => String(v ?? '').replace(/\s+/g, ' ').trim();
44+
return { criterion: flat(o.criterion), why: flat(o.why) };
3445
})
3546
.filter((f: { criterion: string; why: string }) => f.criterion || f.why)
3647
: [];
48+
// pass=false 却给不出任何未满足条目 → 无法指导返工,也没法向用户解释"哪里没过",
49+
// 视为本次核验不可用(触发第二次尝试/跳过),别带着空清单去返工
50+
if (j.pass !== true && failed.length === 0) return null;
3751
// 保守裁决:模型说 pass 但又列了未满足条目 → 以条目为准,算未通过
3852
return { pass: j.pass === true && failed.length === 0, failed };
3953
} catch {
@@ -73,14 +87,20 @@ export async function verifyAcceptance(
7387
].join('\n');
7488

7589
const tokens = { input: 0, output: 0 };
90+
// 结论 JSON 要逐字回抄未满足条目原文——上限必须随验收标准长度伸缩,
91+
// 否则条目越多/越长(恰恰是最差的产出)越容易截断 JSON、核验静默失效
92+
const maxTokens = Math.min(2000, 500 + Math.ceil(acceptance.length * 1.2));
7693
// 两次尝试:第二次换更严厉的 system 逼纯 JSON(同 compare.judgeOnce 的成熟套路)
7794
for (let attempt = 0; attempt < 2; attempt++) {
7895
const sys = attempt === 0
7996
? (zh ? '你是严格客观的验收员,只输出 JSON。' : 'You are a strict, objective reviewer. Output JSON only.')
8097
: (zh ? '你必须只输出一行纯 JSON,绝对不要代码块标记、前言或任何解释文字。'
8198
: 'You MUST output exactly one line of raw JSON. No code fences, no preamble, no explanation.');
8299
try {
83-
const res = await connector.chat(sys, prompt, { ...llm, max_tokens: 500, temperature: 0 });
100+
const res = await withTimeout(
101+
connector.chat(sys, prompt, { ...llm, max_tokens: maxTokens, temperature: 0 }),
102+
llm.timeout || 600_000,
103+
);
84104
tokens.input += res.usage.input_tokens;
85105
tokens.output += res.usage.output_tokens;
86106
const verdict = parseVerify(res.content);

src/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export async function run(
186186

187187
// Resume: 计算 skipStepIds + 兼容旧 output 变量名
188188
let skipStepIds: Set<string> | undefined;
189+
let restoredStepMeta: Map<string, Partial<import('./types.js').StepResult>> | undefined;
189190

190191
if (resumeDir) {
191192
// 兼容变量重命名:如果旧 metadata 的 output_var 和新 YAML 的 output 不同,
@@ -204,6 +205,14 @@ export async function run(
204205

205206
skipStepIds = computeResumeSkipIds(dag, getCompletedStepIds(resumeDir), fromStep);
206207

208+
// 被复用步骤的展示字段(角色名/验收标准/核验结果)从旧档案带回新档案,
209+
// 否则续跑产生的 metadata 里这些步骤全是裸 id、验收记录凭空消失
210+
restoredStepMeta = new Map(
211+
(metadata.steps as import('./types.js').StepResult[])
212+
.filter(s => s.status === 'completed')
213+
.map(s => [s.id, { agentName: s.agentName, agentEmoji: s.agentEmoji, acceptance: s.acceptance, verification: s.verification }])
214+
);
215+
207216
if (!options?.quiet) {
208217
console.log(` 恢复自: ${resumeDir}`);
209218
console.log(` 跳过已完成步骤: ${skipStepIds.size} 个`);
@@ -272,6 +281,7 @@ export async function run(
272281
// 或终端 Ctrl-C 的 run 会无痕消失,历史里无法「继续运行」。
273282
const partialSteps: import('./types.js').StepResult[] = [];
274283
const runStartedAt = Date.now();
284+
let flushHandler: ((signal: NodeJS.Signals) => void) | undefined;
275285
if (options?.signalFlush) {
276286
const flushAndExit = (signal: NodeJS.Signals) => {
277287
try {
@@ -309,7 +319,7 @@ export async function run(
309319
} catch { /* 落盘失败也必须退出 */ }
310320
process.exit(signal === 'SIGINT' ? 130 : 143);
311321
};
312-
// once:触发即自动移除;CLI 进程一次只跑一个工作流,正常结束后进程随即退出,无泄漏面
322+
flushHandler = flushAndExit;
313323
process.once('SIGTERM', flushAndExit);
314324
process.once('SIGINT', flushAndExit);
315325
}
@@ -321,6 +331,7 @@ export async function run(
321331
concurrency: workflow.concurrency || 2,
322332
inputs: inputMap,
323333
skipStepIds,
334+
restoredStepMeta,
324335
feedback: feedbackOption,
325336
verify: options?.verify ?? workflow.verify ?? true,
326337
stepResultsSink: partialSteps,
@@ -347,6 +358,13 @@ export async function run(
347358
},
348359
} satisfies ExecutorOptions);
349360

361+
// 工作流跑完立即摘除信号监听:本次运行即将正常存档,此后(--materialize/--export
362+
// 等收尾期)的 Ctrl-C 不能再把已成功的运行二次存档成"被中断"的重复档案
363+
if (flushHandler) {
364+
process.removeListener('SIGTERM', flushHandler);
365+
process.removeListener('SIGINT', flushHandler);
366+
}
367+
350368
result.name = workflow.name;
351369
// 源文件绝对路径随 metadata 存档——历史记录的"重跑/从某步续跑"靠它定位工作流
352370
result.file = resolve(workflowPath);
@@ -386,6 +404,10 @@ export async function compareWorkflowVsBaseline(
386404
genOverride?: Partial<import('./types.js').LLMConfig>;
387405
/** 评审模型(建议强模型);不传退回生成模型 */
388406
judgeLlm?: import('./types.js').LLMConfig;
407+
/** acceptance 自动核验开关,透传给内部 run()(CLI --verify/--no-verify 在 compare 模式同样生效) */
408+
verify?: boolean;
409+
/** SIGTERM/SIGINT 优雅存档,透传给内部 run()(仅 CLI 进程开;web in-process 调用勿开) */
410+
signalFlush?: boolean;
389411
},
390412
): Promise<{
391413
multiOutput: string;
@@ -404,6 +426,8 @@ export async function compareWorkflowVsBaseline(
404426
quiet: options?.quiet ?? true,
405427
outputDir: options?.outputDir,
406428
llmOverride: options?.genOverride,
429+
verify: options?.verify,
430+
signalFlush: options?.signalFlush,
407431
});
408432
const multiOutput = finalOutput(result);
409433

test/verify.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const p2 = parseVerify('{"pass": true, "failed": [{"criterion": "有风险章节
2626
assert(p2?.pass === false, 'parseVerify: pass=true 但列了未满足条目 → 以条目为准判未过(保守裁决)');
2727
assert(parseVerify('模型跑偏了没有输出 JSON') === null, 'parseVerify: 无 JSON → null');
2828
assert(parseVerify('{"pass": "yes"}') === null, 'parseVerify: pass 非布尔 → null');
29+
assert(parseVerify('{"pass": false}') === null, 'parseVerify: pass=false 但给不出条目 → 核验不可用(不做空清单返工)');
30+
assert(parseVerify('{"pass": false, "failed": [{}]}') === null, 'parseVerify: pass=false 且条目全空 → 核验不可用');
31+
const p3 = parseVerify('{"pass": false, "failed": [{"criterion": "1. 三节\\n2. 标风险", "why": "第\\n二节缺失"}]}');
32+
assert(p3?.failed[0].criterion === '1. 三节 2. 标风险' && p3.failed[0].why === '第 二节缺失', 'parseVerify: 条目内嵌换行被压平成单行(下游 CLI 行/文件头/SSE 都按单行消费)');
2933

3034
// ── buildReworkBlock / formatFailedItems ──
3135
const rb = buildReworkBlock([{ criterion: '包含风险章节', why: '整段缺失' }], '这是上一版产出全文');

web/server.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,9 @@ app.post('/api/run', (req, res) => {
588588

589589
function parseLine(raw) {
590590
const clean = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\r/g, '').trim();
591-
if (!clean) return;
591+
// ⚠️ 条目紧贴"完成 | … | 验收 ⚠️"行连续打印,首个空行即正文分界——
592+
// 空行必须关闭 verify-item 窗口,否则正文里以 ⚠️ 开头的行会被误吞成核验条目
593+
if (!clean) { inVerifyItems = false; return; }
592594

593595
// human_input / approval 节点暂停等待输入:引擎在 AO_WEB_INPUT 模式下发的机器标记。
594596
// 转成 await-input 事件,前端弹框,用户输入经 POST /api/run-input 写回子进程 stdin。

0 commit comments

Comments
 (0)