diff --git a/scripts/unit-smoke.mjs b/scripts/unit-smoke.mjs index 6015adc..c1a6779 100644 --- a/scripts/unit-smoke.mjs +++ b/scripts/unit-smoke.mjs @@ -107,6 +107,31 @@ t('并发关闭/缺省:一律显式 --concurrency 1(防引擎默认 8 漂移 assert.equal(absent[absent.indexOf('--concurrency') + 1], '1'); }); +t('timeoutMinutes:传 360 → --timeout 360', () => { + const args = buildReviewArgs({ command: 'scan', repoPath: '/repo', timeoutMinutes: 360 }); + assert.ok(args.includes('--timeout'), '应含 --timeout,实际:' + args.join(' ')); + assert.equal(args[args.indexOf('--timeout') + 1], '360'); +}); +t('timeoutMinutes:不传 → 无 --timeout(引擎默认 15 分钟)', () => { + const args = buildReviewArgs({ command: 'scan', repoPath: '/repo' }); + assert.ok(!args.includes('--timeout'), '不应含 --timeout,实际:' + args.join(' ')); +}); +t('timeoutMinutes:传 0 → --timeout 0(unlimited)', () => { + const args = buildReviewArgs({ command: 'scan', repoPath: '/repo', timeoutMinutes: 0 }); + assert.ok(args.includes('--timeout'), '应含 --timeout,实际:' + args.join(' ')); + assert.equal(args[args.indexOf('--timeout') + 1], '0'); +}); +t('timeoutMinutes:传负数 → 无 --timeout(引擎默认 15 分钟)', () => { + const args = buildReviewArgs({ command: 'scan', repoPath: '/repo', timeoutMinutes: -1 }); + assert.ok(!args.includes('--timeout'), '不应含 --timeout,实际:' + args.join(' ')); +}); +t('timeoutMinutes:传 NaN / Infinity → 无 --timeout(引擎默认 15 分钟)', () => { + const nanArgs = buildReviewArgs({ command: 'scan', repoPath: '/repo', timeoutMinutes: NaN }); + assert.ok(!nanArgs.includes('--timeout'), 'NaN 不应含 --timeout,实际:' + nanArgs.join(' ')); + const infArgs = buildReviewArgs({ command: 'scan', repoPath: '/repo', timeoutMinutes: Infinity }); + assert.ok(!infArgs.includes('--timeout'), 'Infinity 不应含 --timeout,实际:' + infArgs.join(' ')); +}); + /* ---------- 2. 敏感路径 ---------- */ t('敏感命中:.env / .env.production', () => { assert.equal(isSensitivePath('.env'), true); diff --git a/src/index.js b/src/index.js index a78f213..197c20f 100644 --- a/src/index.js +++ b/src/index.js @@ -106,7 +106,7 @@ function taskFromArgs(args, cfg) { } /** - * 后台投递一次评审(serve 编排执行,不阻塞前台、不受会话超时约束)。 + * 后台投递一次评审(serve 编排执行,引擎 --timeout 默认 15 分钟,插件进程级兜底 6 小时)。 * * @param {object} args 工具参数。 * @param {object} cfg 归一化配置。 @@ -163,7 +163,7 @@ async function dispatchReview(args, cfg, opts = {}) { `- 命令: codecheck ${task.command}${task.repoPath ? ` --repo ${task.repoPath}` : ''}`, `- 任务簿: ${taskPath}`, '', - '评审在后台 serve 中执行,**不阻塞会话、不受超时限制**(慢模型也没关系)。', + '评审超时由引擎 --timeout 控制(默认 15 分钟),插件进程级兜底 6 小时。', '随时用 code_review_status 工具查状态取结果;完成后报告自动落盘留档。', ].join('\n'), }; @@ -228,7 +228,7 @@ export function createReviewTool(ctx, getConfig) { '对代码仓库执行智能代码审查:调用引擎 codecheck(open-code-review 的 fork)读取 git diff 或做全目录扫描,', '用评审模型按三个固定视角挑刺:① 安全与脱敏(硬编码密钥/口令/PII/内部信息泄露 + 安全漏洞);', '② 找 bug(逻辑/边界/异常/并发);③ 冗余与任务无关代码(scope creep/死代码)。', - '【后台执行】发起后立即返回任务 id(任务卡),评审由后台 serve 异步执行:不阻塞会话、不受会话超时限制、慢模型也没关系。', + '【后台执行】发起后立即返回任务 id(任务卡),评审由后台 serve 异步执行:引擎 --timeout 默认 15 分钟,插件进程级兜底 6 小时。', '随后用 code_review_status 工具查状态取结果;完成后报告自动落盘留档(会话内仍可拿完整问题清单)。', '参数:repo_path(仓库路径,省略时尝试取会话工作目录,取不到任务失败并提示);mode(review 模式:uncommitted 工作区未提交 / branch 分支对比 / commit 指定提交;或 scan 全目录扫描);', 'base_ref(branch 模式的对比基线,默认 main);head_ref(branch 模式的目标分支);commit(commit 模式的提交号);', diff --git a/src/review-exec.js b/src/review-exec.js index 31ef573..990e154 100644 --- a/src/review-exec.js +++ b/src/review-exec.js @@ -156,6 +156,13 @@ async function executeReviewInner(task) { excludePaths: task.excludePaths, concurrencyEnabled: cfg.concurrency_enabled === true, concurrency: cfg.concurrency, + // 引擎 --timeout:从进程级兜底 review_timeout_ms 折算为分钟(issue #9)。 + // 经 normalizeConfig 后 review_timeout_ms ∈ [1, REVIEW_TIMEOUT_MAX](见 config.js 钳制), + // 此处 ≥0 分支为防御性守卫(0 值正常流程走不到):非有限/负数 → undefined, + // 不加 --timeout flag,引擎回退默认 15 分钟。 + timeoutMinutes: Number.isFinite(cfg.review_timeout_ms) && cfg.review_timeout_ms >= 0 + ? cfg.review_timeout_ms / 60_000 + : undefined, }); // 4. 评审模型解析 + key 注入(零落盘:key 只进子进程 env)。 diff --git a/src/runner.js b/src/runner.js index 93f4764..17b3916 100644 --- a/src/runner.js +++ b/src/runner.js @@ -184,6 +184,10 @@ function commandOnPath(cmd) { * @param {boolean} [opts.concurrencyEnabled] 并发评审开关。 * false(默认)= 单线顺序审查;true = 并行审查(token 消耗增加,用户侧成本控制)。 * @param {number} [opts.concurrency] 并发广度(1..32,默认 8);仅开关开启时生效。 + * @param {number} [opts.timeoutMinutes] 引擎 --timeout(并发任务总预算,分钟); + * ≥0 时透传(0 = unlimited,引擎 --timeout 0 语义); + * 不传或非有限/负数 → 不加 --timeout(引擎默认 15 分钟); + * 正值不足 1 分钟按 1 分钟计(防亚分钟值 round 成 0 误开 unlimited)。 * @returns {string[]} 参数字符串数组。 */ export function buildReviewArgs(opts) { @@ -222,6 +226,14 @@ export function buildReviewArgs(opts) { concurrency = CONCURRENCY_MIN; // 单线 } args.push('--concurrency', String(concurrency)); + // 引擎 --timeout:并发任务总预算(分钟),0=unlimited,引擎默认 15 分钟。 + // 数据契约:非有限(NaN/Infinity)或负数不加该 flag(引擎回退自身默认 15 分钟); + // 正值不足 1 分钟按 1 分钟计(防 0.167min round 成 0 误开 unlimited,与用户意图相反)。 + if (typeof opts.timeoutMinutes === 'number' && Number.isFinite(opts.timeoutMinutes) && opts.timeoutMinutes >= 0) { + // 显式 0 保留 unlimited 语义;其余正值至少 1 分钟。 + const minutes = opts.timeoutMinutes === 0 ? 0 : Math.max(1, Math.round(opts.timeoutMinutes)); + args.push('--timeout', String(minutes)); + } // 会话内返回:agent 受众 = 仅摘要(无进度噪音,干净短输出)。 args.push('--audience', 'agent'); return args;