@reconcrap/boss-recommend-mcp 2.1.9 → 2.1.10

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/README.md CHANGED
@@ -82,12 +82,13 @@ curl -fsSL https://raw.githubusercontent.com/reconcrap-cpu/boss-recommend-mcp/ma
82
82
  MCP 工具:
83
83
 
84
84
  - `list_recommend_jobs`(只读读取推荐页岗位下拉框,返回可直接用于 cron/一次性任务的 `job_names`)
85
- - `prepare_recommend_pipeline_run`(只校验完整 payload;不启动筛选。若现在运行,返回 `READY + cron_ready=true` 后继续调用 `run_recommend` / `start_recommend_pipeline_run`;只有定时任务才继续 schedule)
86
- - `schedule_recommend_pipeline_run`(创建 package-owned 定时任务;保存已 READY 的完整 payload,启动 detached scheduler,到点后直接调用 `start_recommend_pipeline_run`)
85
+ - `run_recommend`(`start_recommend_pipeline_run` 的短别名;用户已经确认且要现在启动时优先调用)
86
+ - `start_recommend_pipeline_run`(异步启动;先经过前置门禁,通过后返回 `ACCEPTED + run_id`)
87
+ - `prepare_recommend_pipeline_run`(只校验完整 payload;不启动筛选。主要用于显式预检或定时任务前校验;若现在运行,READY 后继续调用 `run_recommend` / `start_recommend_pipeline_run`)
88
+ - `schedule_recommend_pipeline_run`(只用于用户明确要求稍后/cron/定时;保存已 READY 的完整 payload,启动 detached scheduler,到点后直接调用 `start_recommend_pipeline_run`)
87
89
  - `get_recommend_scheduled_run`(查询 package-owned 定时任务;到点后会显示内层 `run_id` 和 run 快照)
88
- - `run_recommend`(`start_recommend_pipeline_run` 的短别名;适合 Trae/Trae-CN 在 prepare READY 后正式启动)
89
- - `start_recommend_pipeline_run`(异步启动;同样先经过前置门禁,通过后返回 run_id)
90
- - `get_recommend_pipeline_run`(轮询 run_id 状态)
90
+ - `get_recommend_pipeline_run`(用已知 `run_id` 轮询状态)
91
+ - `list_recommend_pipeline_runs`(只读列出最近 run 摘要并返回 `latest_run`;忘记 `run_id` 时用它恢复,不要读磁盘 JSON
91
92
  - `cancel_recommend_pipeline_run`(取消运行中任务)
92
93
  - `pause_recommend_pipeline_run`(请求暂停 run;会在当前候选人处理完成后进入 paused)
93
94
  - `resume_recommend_pipeline_run`(继续 paused run;沿用同 run_id 与同 CSV)
@@ -265,7 +266,7 @@ BOSS_RECOMMEND_MCP_CONFIG_TARGETS # JSON 数组或系统 path 分隔路径列
265
266
  BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS # JSON 数组或系统 path 分隔路径列表,指定额外 skills 根目录
266
267
  ```
267
268
 
268
- 推荐运行入口是 MCP 工具 `run_recommend` / `start_recommend_pipeline_run`。在 Trae/Trae-CN 这类普通 MCP 宿主中,`prepare_recommend_pipeline_run` 返回 `READY + cron_ready=true` 后,若用户要现在运行,应继续调用 `run_recommend` `start_recommend_pipeline_run`,不要改用 terminal/shell/run_command/CLI/manual JSON-RPC,也不要用短延迟 `schedule_recommend_pipeline_run` 冒充立即启动。`prepare` 能返回结果就证明该宿主已经可以调用本 MCP server。
269
+ 推荐运行入口是 MCP 工具 `run_recommend` / `start_recommend_pipeline_run`。在 Trae/Trae-CN 这类普通 MCP 宿主中,用户完成总确认并要求现在运行时,应直接调用 `run_recommend` 或 `start_recommend_pipeline_run`。`prepare_recommend_pipeline_run` 只做显式预检或定时任务前校验;如果已经调用过 prepare 且返回 `READY + cron_ready=true`,下一步仍然必须调用 `run_recommend` / `start_recommend_pipeline_run`,不要改用 terminal/shell/run_command/PowerShell/CLI/manual JSON-RPC,也不要用短延迟 `schedule_recommend_pipeline_run` 冒充立即启动。`prepare` 能返回结果就证明该宿主已经可以调用本 MCP server。
269
270
 
270
271
  只有宿主是 QClaw 这类真正 shell-only agent、没有把 MCP tools 暴露给模型、且当前会话从未成功调用过 `boss-recommend/prepare_recommend_pipeline_run` 时,才使用 CDP-only CLI fallback。CLI fallback 也必须显式传本次用户确认的 rest level:
271
272
 
@@ -477,10 +478,11 @@ Trae-CN / 长对话防循环建议:
477
478
 
478
479
  说明:
479
480
 
480
- - `run_recommend` 与 `start_recommend_pipeline_run` 是同一个异步 MCP 启动入口,但不会跳过同步确认流程。
481
- - `prepare_recommend_pipeline_run` / `boss-recommend-mcp prepare-run` 只做参数门禁;它不启动筛选。普通 MCP 宿主现在运行时,prepare READY 后继续调用 `run_recommend` / `start_recommend_pipeline_run`,不要改用 CLI fallback。
481
+ - `run_recommend` 与 `start_recommend_pipeline_run` 是同一个异步 MCP 启动入口,但不会跳过同步确认流程;普通 MCP 宿主现在运行时优先直接调用它们。
482
+ - `prepare_recommend_pipeline_run` / `boss-recommend-mcp prepare-run` 只做参数门禁;它不启动筛选。普通 MCP 宿主只有在显式预检或定时任务准备时才需要先调用 prepare;prepare READY 后继续调用 `run_recommend` / `start_recommend_pipeline_run`,不要改用 CLI fallback。
482
483
  - `prepare_recommend_pipeline_run` 的 READY 响应会带 `prepared_only=true`、`run_started=false`、`recommended_next_tool=start_recommend_pipeline_run`、`alternate_next_tool=run_recommend` 和 `next_action.do_not_call_prepare_again=true`;agent 应直接照这个字段继续下一步。
483
484
  - `schedule_recommend_pipeline_run` / `boss-recommend-mcp schedule-run` 是推荐页定时启动的唯一推荐路径;它创建真实 package-owned detached scheduler,并返回 `schedule_id`。
485
+ - 如果忘记了 `run_id`,调用 `list_recommend_pipeline_runs` 获取 `latest_run.run_id`;不要用 PowerShell、`Get-Content`、terminal、CLI 或手动读取 `~/.boss-recommend-mcp/runs/*.json` 来恢复状态。
484
486
  - 定时心跳默认 120 秒一次;`updated_at` 仍会在阶段或进度变化时刷新。
485
487
  - 每个 run 会持久化到 `~/.boss-recommend-mcp/runs/<run_id>.json`(可通过 `BOSS_RECOMMEND_HOME` 覆盖)。
486
488
  - screen 阶段会持久化 checkpoint:`~/.boss-recommend-mcp/runs/<run_id>.checkpoint.json`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.1.9",
3
+ "version": "2.1.10",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -18,6 +18,7 @@ description: "Use when users want Boss recommend-page filtering/screening via bo
18
18
  - recommend 失败时(如 `JOB_TRIGGER_NOT_FOUND/NO_RECOMMEND_IFRAME/BOSS_LOGIN_REQUIRED`)禁止降级到 recruit;先修 recommend 页面就绪/登录态。
19
19
 
20
20
  - **Trae-CN / 原生 MCP 启动强制规则**
21
+ - 用户完成总确认并要求“现在启动”时,优先直接调用 `boss-recommend/run_recommend` 或 `boss-recommend/start_recommend_pipeline_run`;不要为了即时启动先调用 prepare。
21
22
  - 如果当前会话里已经成功调用过 `boss-recommend/prepare_recommend_pipeline_run`,说明 Trae-CN 已经具备原生 MCP tool call 能力;下一步必须继续调用 `boss-recommend/start_recommend_pipeline_run` 或 `boss-recommend/run_recommend`。
22
23
  - `prepare_recommend_pipeline_run` 返回 `READY` 只代表参数校验通过,不代表已经启动;严禁再次调用 prepare 试图启动。
23
24
  - 在 Trae-CN、Trae、Codex、Cursor、Claude Code 等普通 MCP 宿主中,严禁用 `run_command`、终端、PowerShell、`npx ... run --detached`、手写 `tools/call` JSON-RPC 或任何 CLI fallback 启动 recommend run。
@@ -100,8 +101,18 @@ description: "Use when users want Boss recommend-page filtering/screening via bo
100
101
  - 用途:当用户需要为 cron / 一次性自动任务提前填写完整参数时,先用它读取推荐页岗位下拉框的全部可用岗位名;默认会复用/自动打开本机 9222 Chrome 并导航到推荐页。
101
102
  - 输出:优先把 `job_names` 里的值作为后续 `overrides.job` / `confirmation.job_value`。
102
103
  - 限制:只读岗位列表,不启动筛选任务;若返回 `BOSS_LOGIN_REQUIRED`,必须让用户在自动打开的 Chrome 完成登录后重试,本次 cron 不得创建。
104
+ - 主工具:`run_recommend` / `start_recommend_pipeline_run`
105
+ - 用途:用户完成最终总确认并要“现在启动”时调用;这是即时运行的首选入口,不需要先 prepare。
106
+ - 必填:`instruction`
107
+ - 关键输入:
108
+ - `confirmation`:新流程只需要 `{ "final_confirmed": true }`;旧版 `page_confirmed/page_value/.../job_confirmed/job_value` 仍兼容但不要主动制造逐项确认。
109
+ - `overrides`:`page_scope/school_tag/degree/gender/recent_not_view/criteria/job/target_count/post_action/max_greet_count`
110
+ - `human_behavior`:必须包含本次用户确认的 `restLevel`(例如 `{ "restLevel": "medium" }`)
111
+ - 不要传 `follow_up.chat`;该路径属于 legacy-only 行为
112
+ - 若返回 `ACCEPTED + run_id`:记录 `run_id` 并停止本轮,不自动轮询。
113
+ - 若返回 `NEED_INPUT` 或 `NEED_CONFIRMATION`:只追问 `pending_questions`。
103
114
  - 准备/门禁工具:`prepare_recommend_pipeline_run`
104
- - 用途:只校验参数是否完整,不启动筛选任务。
115
+ - 用途:只校验参数是否完整,不启动筛选任务;主要用于用户明确要求预检,或 cron/稍后/定时启动前校验。
105
116
  - 要求:若用户要“现在启动”,返回 `status=READY` 且 `cron_ready=true` 后,下一步必须调用 MCP 工具 `run_recommend` 或 `start_recommend_pipeline_run`,禁止改用 terminal/shell/run_command/CLI/manual JSON-RPC。只有用户要“稍后/cron/定时启动”时,才继续创建定时任务。
106
117
  - READY 响应会带 `prepared_only=true`、`run_started=false`、`recommended_next_tool=start_recommend_pipeline_run`、`alternate_next_tool=run_recommend`、`next_action.do_not_call_prepare_again=true`、`agent_guidance.native_mcp_required_after_prepare=true`;必须照这些字段继续,不得再次调用 prepare。
107
118
  - 若返回 `NEED_INPUT` / `NEED_CONFIRMATION` / `FAILED`:继续补齐 `pending_questions` 或修复登录/页面/config;不得先创建 cron。
@@ -112,13 +123,10 @@ description: "Use when users want Boss recommend-page filtering/screening via bo
112
123
  - 成功标准:必须返回 `status=SCHEDULED`、`schedule_created=true`、`schedule_id`、`run_at`。只有这个返回后,才可以告诉用户定时任务已创建。
113
124
  - Cron 查询工具:`get_recommend_scheduled_run`
114
125
  - 用途:用户问“任务是否启动/进度”时,先查 `schedule_id`。若到点后已启动,会返回内层 `run_id` 和 run 快照。
115
- - 主工具:`run_recommend` / `start_recommend_pipeline_run`
116
- - 必填:`instruction`
117
- - 关键输入:
118
- - `confirmation`:新流程只需要 `{ "final_confirmed": true }`;旧版 `page_confirmed/page_value/.../job_confirmed/job_value` 仍兼容但不要主动制造逐项确认。
119
- - `overrides`:`page_scope/school_tag/degree/gender/recent_not_view/criteria/job/target_count/post_action/max_greet_count`
120
- - `human_behavior`:必须包含本次用户确认的 `restLevel`(例如 `{ "restLevel": "medium" }`)
121
- - 不要传 `follow_up.chat`;该路径属于 legacy-only 行为
126
+ - 状态恢复工具:`list_recommend_pipeline_runs`
127
+ - 用途:忘记 `run_id` 或用户问“刚才那个任务怎么样”但上下文里没有 run_id 时调用;读取最近 run 摘要并返回 `latest_run`。
128
+ - 限制:只返回紧凑摘要,不包含大体积候选人 `results`;拿到 `latest_run.run_id` 后再用 `get_recommend_pipeline_run` / `cancel_recommend_pipeline_run`。
129
+ - Trae-CN 中禁止用 `run_command`、PowerShell、`Get-Content`、CLI 或直接读取 `~/.boss-recommend-mcp/runs/*.json` 来恢复 run_id 或状态。
122
130
 
123
131
  最小策略:
124
132
 
@@ -164,6 +172,7 @@ npx -y @reconcrap/boss-recommend-mcp@latest schedule-status --schedule-id <sched
164
172
 
165
173
  - 用户未明确要求“持续跟进”时,不自动 `sleep + get_recommend_pipeline_run`。
166
174
  - 用户要求查进度时,再用 `get_recommend_pipeline_run`。
175
+ - 如果当前对话已经丢失 `run_id`,先调用 `list_recommend_pipeline_runs`,再用 `latest_run.run_id` 调用 `get_recommend_pipeline_run`;不要改用终端读 JSON。
167
176
  - **长任务轮询节奏(强制)**:
168
177
  - 推荐任务可能运行数小时,禁止高频轮询。
169
178
  - 默认最小轮询间隔为 **30 分钟**(除非用户明确要求更频繁)。
package/src/cli.js CHANGED
@@ -24,8 +24,12 @@ import {
24
24
  resumeBossChatRunTool
25
25
  } from "./chat-mcp.js";
26
26
  import {
27
+ cancelRecommendPipelineRunTool,
28
+ getRecommendPipelineRunTool,
27
29
  listRecommendJobsTool,
30
+ pauseRecommendPipelineRunTool,
28
31
  prepareRecommendPipelineRunTool,
32
+ resumeRecommendPipelineRunTool,
29
33
  startRecommendPipelineRunTool
30
34
  } from "./recommend-mcp.js";
31
35
  import {
@@ -2971,6 +2975,33 @@ function scheduleStatusCli(options = {}) {
2971
2975
  }
2972
2976
  }
2973
2977
 
2978
+ function isTerminalRecommendCliState(state) {
2979
+ return ["completed", "failed", "canceled"].includes(String(state || "").trim().toLowerCase());
2980
+ }
2981
+
2982
+ async function monitorDetachedRecommendCliRunControls(runId) {
2983
+ const normalizedRunId = String(runId || "").trim();
2984
+ if (!normalizedRunId) return;
2985
+ while (true) {
2986
+ const payload = getRecommendPipelineRunTool({
2987
+ args: {
2988
+ run_id: normalizedRunId
2989
+ }
2990
+ });
2991
+ const state = String(payload?.run?.state || payload?.run?.status || "").trim().toLowerCase();
2992
+ if (isTerminalRecommendCliState(state)) return;
2993
+ const control = payload?.run?.control || {};
2994
+ if (control.cancel_requested === true) {
2995
+ cancelRecommendPipelineRunTool({ args: { run_id: normalizedRunId } });
2996
+ } else if (control.pause_requested === true && state === "running") {
2997
+ pauseRecommendPipelineRunTool({ args: { run_id: normalizedRunId } });
2998
+ } else if (control.pause_requested === false && state === "paused") {
2999
+ resumeRecommendPipelineRunTool({ args: { run_id: normalizedRunId } });
3000
+ }
3001
+ await sleepMs(1000);
3002
+ }
3003
+ }
3004
+
2974
3005
  async function runPipelineOnce(options = {}) {
2975
3006
  const workspaceRoot = getWorkspaceRoot(options);
2976
3007
  const { args, port } = buildRecommendRunCliInput(options);
@@ -2987,11 +3018,13 @@ async function runPipelineOnce(options = {}) {
2987
3018
  workspace_root: workspaceRoot,
2988
3019
  port
2989
3020
  }
2990
- });
2991
- if (result.status !== "ACCEPTED") {
2992
- process.exitCode = 1;
2993
- }
2994
- }
3021
+ });
3022
+ if (result.status !== "ACCEPTED") {
3023
+ process.exitCode = 1;
3024
+ } else if (process.env[detachedRecommendRunChildEnv] === "1") {
3025
+ await monitorDetachedRecommendCliRunControls(result.run_id);
3026
+ }
3027
+ }
2995
3028
 
2996
3029
  function buildRecommendJobListCliInput(options = {}) {
2997
3030
  const targetUrlIncludes = String(options["target-url-includes"] || options.target_url_includes || "").trim();
@@ -28,14 +28,24 @@ function createRunId(prefix = "run") {
28
28
  return `${prefix}_${Date.now().toString(36)}_${random}`;
29
29
  }
30
30
 
31
- function clone(value) {
32
- return JSON.parse(JSON.stringify(value));
33
- }
34
-
35
- function createDeferred() {
36
- let resolve;
37
- let reject;
38
- const promise = new Promise((promiseResolve, promiseReject) => {
31
+ function clone(value) {
32
+ return JSON.parse(JSON.stringify(value));
33
+ }
34
+
35
+ function errorDiagnostic(error) {
36
+ if (!error) return null;
37
+ const diagnostic = {
38
+ name: error?.name || "Error",
39
+ message: error?.message || String(error)
40
+ };
41
+ if (error?.code) diagnostic.code = error.code;
42
+ return diagnostic;
43
+ }
44
+
45
+ function createDeferred() {
46
+ let resolve;
47
+ let reject;
48
+ const promise = new Promise((promiseResolve, promiseReject) => {
39
49
  resolve = promiseResolve;
40
50
  reject = promiseReject;
41
51
  });
@@ -188,23 +198,20 @@ export function createRunLifecycleManager({
188
198
  summary: summary || entry.run.summary
189
199
  });
190
200
  }
191
- } catch (error) {
192
- if (error instanceof RunCanceledError || entry.controller.signal.aborted || entry.cancelRequested) {
193
- setStatus(entry, RUN_STATUS_CANCELED, {
194
- completedAt: now(),
195
- error: null
196
- });
197
- return;
198
- }
199
- setStatus(entry, RUN_STATUS_FAILED, {
200
- completedAt: now(),
201
- error: {
202
- name: error?.name || "Error",
203
- message: error?.message || String(error)
204
- }
205
- });
206
- }
207
- }
201
+ } catch (error) {
202
+ if (error instanceof RunCanceledError || entry.controller.signal.aborted || entry.cancelRequested) {
203
+ setStatus(entry, RUN_STATUS_CANCELED, {
204
+ completedAt: now(),
205
+ error: error instanceof RunCanceledError ? null : errorDiagnostic(error)
206
+ });
207
+ return;
208
+ }
209
+ setStatus(entry, RUN_STATUS_FAILED, {
210
+ completedAt: now(),
211
+ error: errorDiagnostic(error)
212
+ });
213
+ }
214
+ }
208
215
 
209
216
  function startRun({ runId: requestedRunId = "", name, pid = process.pid, context = {}, progress = {}, checkpoint = {}, task }) {
210
217
  if (typeof task !== "function") {
package/src/index.js CHANGED
@@ -101,6 +101,7 @@ const TOOL_GET_SCHEDULED_RUN = "get_recommend_scheduled_run";
101
101
  const TOOL_RUN_RECOMMEND = "run_recommend";
102
102
  const TOOL_START_RUN = "start_recommend_pipeline_run";
103
103
  const TOOL_GET_RUN = "get_recommend_pipeline_run";
104
+ const TOOL_LIST_RUNS = "list_recommend_pipeline_runs";
104
105
  const TOOL_CANCEL_RUN = "cancel_recommend_pipeline_run";
105
106
  const TOOL_PAUSE_RUN = "pause_recommend_pipeline_run";
106
107
  const TOOL_RESUME_RUN = "resume_recommend_pipeline_run";
@@ -371,6 +372,68 @@ function getRunArtifacts(runId) {
371
372
  };
372
373
  }
373
374
 
375
+ function isShutdownLikeError(error = {}) {
376
+ const text = normalizeText([
377
+ error?.code || "",
378
+ error?.message || error || ""
379
+ ].join(" "));
380
+ return /socket hang up|ECONNREFUSED|ECONNRESET|WebSocket is not open|Target closed|Session closed|Connection closed|RUN_PROCESS_EXITED|DETACHED_WORKER|RUN_WORKER/i.test(text);
381
+ }
382
+
383
+ function buildCanceledResultFromExisting(existing = {}, errorPayload = null, message = "流水线已取消。") {
384
+ const previousResult = existing.result && typeof existing.result === "object" ? existing.result : {};
385
+ const previousError = previousResult.error || existing.error || errorPayload || null;
386
+ return {
387
+ ...previousResult,
388
+ status: "CANCELED",
389
+ completion_reason: "canceled_by_user",
390
+ error: {
391
+ code: "PIPELINE_CANCELED",
392
+ message,
393
+ retryable: true,
394
+ shutdown_error: previousError || undefined
395
+ }
396
+ };
397
+ }
398
+
399
+ function finalizeRawRunStateAsCanceled(runId, existing = {}, {
400
+ errorPayload = null,
401
+ message = "流水线已取消。"
402
+ } = {}) {
403
+ const normalizedRunId = normalizeText(runId);
404
+ if (!normalizedRunId) return null;
405
+ const now = new Date().toISOString();
406
+ const current = existing && typeof existing === "object" ? existing : {};
407
+ const result = buildCanceledResultFromExisting(current, errorPayload, message);
408
+ return writeRawRunState(normalizedRunId, {
409
+ ...current,
410
+ run_id: normalizedRunId,
411
+ mode: current.mode || RUN_MODE_ASYNC,
412
+ state: RUN_STATE_CANCELED,
413
+ status: RUN_STATE_CANCELED,
414
+ stage: current.stage || RUN_STAGE_PREFLIGHT,
415
+ started_at: current.started_at || now,
416
+ updated_at: now,
417
+ heartbeat_at: now,
418
+ completed_at: current.completed_at || now,
419
+ pid: Number.isInteger(current.pid) && current.pid > 0 ? current.pid : process.pid,
420
+ progress: current.progress || {},
421
+ last_message: message,
422
+ context: current.context || {},
423
+ control: {
424
+ ...(current.control || {}),
425
+ pause_requested: false,
426
+ pause_requested_at: null,
427
+ pause_requested_by: null,
428
+ cancel_requested: false
429
+ },
430
+ resume: current.resume || {},
431
+ artifacts: current.artifacts || undefined,
432
+ error: result.error,
433
+ result
434
+ });
435
+ }
436
+
374
437
  function writeRawRunState(runId, payload) {
375
438
  const artifacts = getRunArtifacts(runId);
376
439
  fs.mkdirSync(path.dirname(artifacts.run_state_path), { recursive: true });
@@ -390,6 +453,110 @@ function readRawRunState(runId) {
390
453
  }
391
454
  }
392
455
 
456
+ function getRunSortTime(run = {}, fallbackMs = 0) {
457
+ for (const key of ["updated_at", "heartbeat_at", "completed_at", "started_at", "updatedAt", "completedAt", "startedAt"]) {
458
+ const ms = Date.parse(run?.[key] || "");
459
+ if (Number.isFinite(ms)) return ms;
460
+ }
461
+ return fallbackMs;
462
+ }
463
+
464
+ function compactRunForList(run = {}) {
465
+ const state = normalizeText(run.state || run.status);
466
+ const result = run.result && typeof run.result === "object" ? run.result : null;
467
+ const error = run.error || result?.error || null;
468
+ return {
469
+ run_id: normalizeText(run.run_id || run.runId),
470
+ state,
471
+ status: state,
472
+ stage: normalizeText(run.stage || run.phase),
473
+ mode: normalizeText(run.mode),
474
+ started_at: run.started_at || run.startedAt || null,
475
+ updated_at: run.updated_at || run.updatedAt || null,
476
+ heartbeat_at: run.heartbeat_at || null,
477
+ completed_at: run.completed_at || run.completedAt || null,
478
+ pid: Number.isInteger(run.pid) && run.pid > 0 ? run.pid : null,
479
+ progress: run.progress || {},
480
+ last_message: normalizeText(run.last_message || error?.message || ""),
481
+ control: {
482
+ pause_requested: run.control?.pause_requested === true,
483
+ cancel_requested: run.control?.cancel_requested === true
484
+ },
485
+ error: error ? {
486
+ code: normalizeText(error.code || ""),
487
+ message: normalizeText(error.message || error || "")
488
+ } : null,
489
+ result: result ? {
490
+ status: normalizeText(result.status || ""),
491
+ completion_reason: normalizeText(result.completion_reason || ""),
492
+ output_csv: normalizeText(result.output_csv || result.result?.output_csv || ""),
493
+ report_json: normalizeText(result.report_json || result.result?.report_json || ""),
494
+ checkpoint_path: normalizeText(result.checkpoint_path || result.result?.checkpoint_path || "")
495
+ } : null,
496
+ resume: {
497
+ checkpoint_path: normalizeText(run.resume?.checkpoint_path || ""),
498
+ output_csv: normalizeText(run.resume?.output_csv || ""),
499
+ worker_stdout_path: normalizeText(run.resume?.worker_stdout_path || ""),
500
+ worker_stderr_path: normalizeText(run.resume?.worker_stderr_path || "")
501
+ }
502
+ };
503
+ }
504
+
505
+ function normalizeRunStateFilter(args = {}) {
506
+ const rawStates = Array.isArray(args.states)
507
+ ? args.states
508
+ : args.state === undefined
509
+ ? []
510
+ : [args.state];
511
+ return new Set(rawStates.map((item) => normalizeText(item)).filter(Boolean));
512
+ }
513
+
514
+ function handleListRunsTool(args = {}) {
515
+ const limit = Math.max(1, Math.min(100, Number.parseInt(String(args.limit || 20), 10) || 20));
516
+ const stateFilter = normalizeRunStateFilter(args);
517
+ const runsDir = getRunsDir();
518
+ if (!fs.existsSync(runsDir)) {
519
+ return {
520
+ status: "OK",
521
+ runs: [],
522
+ latest_run: null,
523
+ count: 0,
524
+ total_matching: 0,
525
+ message: "No recommend run state directory exists yet."
526
+ };
527
+ }
528
+ const entries = fs.readdirSync(runsDir, { withFileTypes: true });
529
+ const runs = [];
530
+ for (const entry of entries) {
531
+ if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name.endsWith(".checkpoint.json")) continue;
532
+ const filePath = path.join(runsDir, entry.name);
533
+ const runId = entry.name.replace(/\.json$/, "");
534
+ const raw = readRawRunState(runId);
535
+ if (!raw) continue;
536
+ const state = normalizeText(raw.state || raw.status);
537
+ if (stateFilter.size > 0 && !stateFilter.has(state)) continue;
538
+ const stat = fs.statSync(filePath);
539
+ runs.push({
540
+ sort_ms: getRunSortTime(raw, Number(stat.mtimeMs || 0)),
541
+ run: compactRunForList(raw)
542
+ });
543
+ }
544
+ runs.sort((a, b) => b.sort_ms - a.sort_ms);
545
+ const compacted = runs.slice(0, limit).map((item) => item.run);
546
+ return {
547
+ status: "OK",
548
+ runs: compacted,
549
+ latest_run: compacted[0] || null,
550
+ count: compacted.length,
551
+ total_matching: runs.length,
552
+ limit,
553
+ filters: {
554
+ states: Array.from(stateFilter)
555
+ },
556
+ message: "Use latest_run.run_id with get_recommend_pipeline_run, cancel_recommend_pipeline_run, pause_recommend_pipeline_run, or resume_recommend_pipeline_run. In Trae-CN, do not use terminal, PowerShell, CLI, or filesystem scraping to recover run status."
557
+ };
558
+ }
559
+
393
560
  function patchRawRunState(runId, patch) {
394
561
  const current = readRawRunState(runId);
395
562
  if (!current) return null;
@@ -1134,18 +1301,18 @@ function createToolsSchema() {
1134
1301
  inputSchema: createListRecommendJobsInputSchema()
1135
1302
  },
1136
1303
  {
1137
- name: TOOL_PREPARE_RUN,
1138
- description: "只校验 Boss 推荐页流水线参数是否完整;不会启动筛选任务。若用户要现在运行,返回 READY/cron_ready=true 后必须继续调用本 MCP server run_recommend start_recommend_pipeline_run;只有定时/稍后运行才调用 schedule_recommend_pipeline_run。prepare 能返回结果就证明原生 MCP 可用,禁止改用 terminal/shell/run_command/CLI/manual JSON-RPC",
1304
+ name: TOOL_RUN_RECOMMEND,
1305
+ description: "立即通过原生 MCP 启动 Boss 推荐页筛选。它是 start_recommend_pipeline_run 的短别名;用户已经确认并要现在启动时,优先调用本工具,不需要先调用 prepare_recommend_pipeline_run。必须作为 MCP tool call 调用,禁止通过 terminal/shell/run_command/PowerShell/CLI/manual JSON-RPC 代替,也不要用 schedule_recommend_pipeline_run 冒充立即启动。",
1139
1306
  inputSchema: createRunInputSchema()
1140
1307
  },
1141
1308
  {
1142
- name: TOOL_RUN_RECOMMEND,
1143
- description: "立即通过原生 MCP 启动 Boss 推荐页筛选。它是 start_recommend_pipeline_run 的短别名,适合 Trae/Trae-CN 等代理在 prepare_recommend_pipeline_run 返回 READY 后继续正式运行;必须作为 MCP tool call 调用,禁止通过 terminal/shell/run_command/CLI/manual JSON-RPC 代替,也不要用 schedule_recommend_pipeline_run 冒充立即启动。",
1309
+ name: TOOL_START_RUN,
1310
+ description: "立即通过原生 MCP 异步启动 Boss 推荐页流水线(含同步门禁预检)。用户已经确认并要现在启动时,优先调用本工具或 run_recommend,不需要先调用 prepare_recommend_pipeline_run。必须作为 MCP tool call 调用,禁止通过 terminal/shell/run_command/PowerShell/CLI/manual JSON-RPC 代替,也不要用 schedule_recommend_pipeline_run 冒充立即启动。",
1144
1311
  inputSchema: createRunInputSchema()
1145
1312
  },
1146
1313
  {
1147
- name: TOOL_START_RUN,
1148
- description: "立即通过原生 MCP 异步启动 Boss 推荐页流水线(含同步门禁预检);prepare_recommend_pipeline_run 返回 READY 后,如果用户要现在运行就调用本工具或 run_recommend。必须作为 MCP tool call 调用,禁止通过 terminal/shell/run_command/CLI/manual JSON-RPC 代替,也不要用 schedule_recommend_pipeline_run 冒充立即启动。",
1314
+ name: TOOL_PREPARE_RUN,
1315
+ description: "只校验 Boss 推荐页流水线参数是否完整;不会启动筛选任务。主要用于显式预检或稍后/cron/定时启动前校验。若用户要现在运行,READY/cron_ready=true 后必须继续调用本 MCP server run_recommend start_recommend_pipeline_run;prepare 能返回结果就证明原生 MCP 可用,禁止改用 terminal/shell/run_command/PowerShell/CLI/manual JSON-RPC,也禁止再次调用 prepare 试图启动。",
1149
1316
  inputSchema: createRunInputSchema()
1150
1317
  },
1151
1318
  {
@@ -1165,21 +1332,50 @@ function createToolsSchema() {
1165
1332
  additionalProperties: false
1166
1333
  }
1167
1334
  },
1168
- {
1169
- name: TOOL_GET_RUN,
1170
- description: " run_id 查询异步/同步流水线运行状态快照。",
1171
- inputSchema: {
1172
- type: "object",
1173
- properties: {
1335
+ {
1336
+ name: TOOL_GET_RUN,
1337
+ description: "按已知 run_id 查询异步/同步流水线运行状态快照。若忘记 run_id,请先调用 list_recommend_pipeline_runs 找 latest_run;在 Trae-CN 中禁止用 terminal/PowerShell/CLI/filesystem scraping 查看 run JSON。",
1338
+ inputSchema: {
1339
+ type: "object",
1340
+ properties: {
1174
1341
  run_id: { type: "string" }
1175
1342
  },
1176
1343
  required: ["run_id"],
1177
- additionalProperties: false
1178
- }
1179
- },
1180
- {
1181
- name: TOOL_CANCEL_RUN,
1182
- description: "取消指定 run_id 的运行中流水线。",
1344
+ additionalProperties: false
1345
+ }
1346
+ },
1347
+ {
1348
+ name: TOOL_LIST_RUNS,
1349
+ description: "只读列出最近的 Boss 推荐页 run 状态摘要,并返回 latest_run。用于忘记 run_id 后恢复状态/取消/暂停;摘要不会包含大体积候选人 results。Trae-CN 中必须用本工具恢复 run_id,禁止用 terminal/PowerShell/CLI/Get-Content 读取 ~/.boss-recommend-mcp/runs。",
1350
+ inputSchema: {
1351
+ type: "object",
1352
+ properties: {
1353
+ limit: {
1354
+ type: "integer",
1355
+ minimum: 1,
1356
+ maximum: 100,
1357
+ description: "最多返回多少条最近 run;默认 20,最大 100。"
1358
+ },
1359
+ state: {
1360
+ type: "string",
1361
+ enum: ["queued", "running", "paused", "completed", "failed", "canceled"],
1362
+ description: "可选,只返回某个状态。"
1363
+ },
1364
+ states: {
1365
+ type: "array",
1366
+ items: {
1367
+ type: "string",
1368
+ enum: ["queued", "running", "paused", "completed", "failed", "canceled"]
1369
+ },
1370
+ description: "可选,只返回这些状态;与 state 同时传时取并集。"
1371
+ }
1372
+ },
1373
+ additionalProperties: false
1374
+ }
1375
+ },
1376
+ {
1377
+ name: TOOL_CANCEL_RUN,
1378
+ description: "取消指定 run_id 的运行中流水线。",
1183
1379
  inputSchema: {
1184
1380
  type: "object",
1185
1381
  properties: {
@@ -1779,6 +1975,12 @@ function markDetachedWorkerFailed(runId, error, options = {}) {
1779
1975
  ...errorToDetachedWorkerPayload(error, options.message),
1780
1976
  ...(options.code ? { code: options.code } : {})
1781
1977
  };
1978
+ if (existing.control?.cancel_requested === true && isShutdownLikeError(errorPayload)) {
1979
+ return finalizeRawRunStateAsCanceled(normalizedRunId, existing, {
1980
+ errorPayload,
1981
+ message: "流水线已取消;detached worker 在取消收尾时关闭了浏览器连接。"
1982
+ });
1983
+ }
1782
1984
  const previousResult = existing.result && typeof existing.result === "object" ? existing.result : {};
1783
1985
  const result = {
1784
1986
  ...previousResult,
@@ -1876,7 +2078,8 @@ function buildWorkerLaunchFailedPayload(message) {
1876
2078
 
1877
2079
  function finalizeCanceledRun(runId, snapshot) {
1878
2080
  const canceledResult = {
1879
- status: "FAILED",
2081
+ status: "CANCELED",
2082
+ completion_reason: "canceled_by_user",
1880
2083
  error: {
1881
2084
  code: "PIPELINE_CANCELED",
1882
2085
  message: "流水线已取消。",
@@ -2047,16 +2250,25 @@ async function executeTrackedPipeline({
2047
2250
  }
2048
2251
  );
2049
2252
  } catch (error) {
2050
- const canceled = Boolean(signal?.aborted) || error?.code === "PIPELINE_ABORTED";
2051
- if (canceled) {
2052
- const canceledResult = {
2053
- status: "FAILED",
2054
- error: {
2055
- code: "PIPELINE_CANCELED",
2056
- message: "流水线已取消。",
2057
- retryable: true
2058
- }
2059
- };
2253
+ const canceled = Boolean(signal?.aborted)
2254
+ || error?.code === "PIPELINE_ABORTED"
2255
+ || (isRunCancelRequested(runId) && isShutdownLikeError(error));
2256
+ if (canceled) {
2257
+ const canceledResult = {
2258
+ status: "CANCELED",
2259
+ completion_reason: "canceled_by_user",
2260
+ error: {
2261
+ code: "PIPELINE_CANCELED",
2262
+ message: "流水线已取消。",
2263
+ retryable: true,
2264
+ shutdown_error: isShutdownLikeError(error)
2265
+ ? {
2266
+ code: error?.code || "SHUTDOWN_ERROR",
2267
+ message: error?.message || String(error)
2268
+ }
2269
+ : undefined
2270
+ }
2271
+ };
2060
2272
  safeUpdateRunState(runId, {
2061
2273
  mode,
2062
2274
  state: RUN_STATE_CANCELED,
@@ -2105,21 +2317,35 @@ async function executeTrackedPipeline({
2105
2317
  };
2106
2318
  }
2107
2319
 
2108
- const terminalState = result?.status === "FAILED"
2109
- ? RUN_STATE_FAILED
2110
- : result?.status === "PAUSED"
2111
- ? (isRunCancelRequested(runId) ? RUN_STATE_CANCELED : RUN_STATE_PAUSED)
2112
- : RUN_STATE_COMPLETED;
2320
+ const failedAfterCancel = result?.status === "FAILED"
2321
+ && isRunCancelRequested(runId)
2322
+ && isShutdownLikeError(result?.error || result);
2323
+ const terminalState = failedAfterCancel
2324
+ ? RUN_STATE_CANCELED
2325
+ : result?.status === "FAILED"
2326
+ ? RUN_STATE_FAILED
2327
+ : result?.status === "PAUSED"
2328
+ ? (isRunCancelRequested(runId) ? RUN_STATE_CANCELED : RUN_STATE_PAUSED)
2329
+ : RUN_STATE_COMPLETED;
2113
2330
  const outputCsv = getOutputCsvFromResult(result) || resumeConfig.output_csv;
2114
2331
  const checkpointPath = normalizeText(result?.result?.checkpoint_path || resumeConfig.checkpoint_path);
2115
- const canceledError = terminalState === RUN_STATE_CANCELED
2116
- ? {
2117
- code: "PIPELINE_CANCELED",
2118
- message: "流水线已取消。",
2119
- retryable: true
2120
- }
2121
- : null;
2122
- safeUpdateRunState(runId, {
2332
+ const canceledError = terminalState === RUN_STATE_CANCELED
2333
+ ? {
2334
+ code: "PIPELINE_CANCELED",
2335
+ message: "流水线已取消。",
2336
+ retryable: true,
2337
+ shutdown_error: failedAfterCancel ? (result?.error || null) : undefined
2338
+ }
2339
+ : null;
2340
+ const finalResult = failedAfterCancel
2341
+ ? {
2342
+ ...(result || {}),
2343
+ status: "CANCELED",
2344
+ completion_reason: "canceled_by_user",
2345
+ error: canceledError
2346
+ }
2347
+ : result || null;
2348
+ safeUpdateRunState(runId, {
2123
2349
  mode,
2124
2350
  state: terminalState,
2125
2351
  stage: runtimeCallbacks.getLastStage(),
@@ -2147,13 +2373,13 @@ async function executeTrackedPipeline({
2147
2373
  : terminalState === RUN_STATE_CANCELED
2148
2374
  ? canceledError
2149
2375
  : null,
2150
- result: result || null
2151
- });
2152
- return {
2153
- result,
2154
- lastStage: runtimeCallbacks.getLastStage(),
2155
- state: terminalState
2156
- };
2376
+ result: finalResult
2377
+ });
2378
+ return {
2379
+ result: finalResult,
2380
+ lastStage: runtimeCallbacks.getLastStage(),
2381
+ state: terminalState
2382
+ };
2157
2383
  }
2158
2384
 
2159
2385
  function initializeRunStateOrThrow(runId, mode, workspaceRoot, args, pid = process.pid) {
@@ -2699,6 +2925,8 @@ async function handleRequest(message, workspaceRoot) {
2699
2925
  payload = await handleStartRunTool({ workspaceRoot, args });
2700
2926
  } else if (toolName === TOOL_GET_RUN) {
2701
2927
  payload = handleGetRunTool(args);
2928
+ } else if (toolName === TOOL_LIST_RUNS) {
2929
+ payload = handleListRunsTool(args);
2702
2930
  } else if (toolName === TOOL_CANCEL_RUN) {
2703
2931
  payload = handleCancelRunTool(args);
2704
2932
  } else if (toolName === TOOL_PAUSE_RUN) {
@@ -345,9 +345,9 @@ function normalizeErrorText(error = {}) {
345
345
  ].join(" "));
346
346
  }
347
347
 
348
- function classifyRecommendRecovery(error = {}) {
349
- const text = normalizeErrorText(error);
350
- if (!text) return null;
348
+ function classifyRecommendRecovery(error = {}) {
349
+ const text = normalizeErrorText(error);
350
+ if (!text) return null;
351
351
  if (/BOSS_LOGIN_REQUIRED/i.test(text)) return "login_required";
352
352
  if (/Could not find node with given id|No node with given id|Node is detached|Cannot find node|DETAIL_STALE_NODE|IMAGE_CAPTURE_STALE_NODE/i.test(text)) {
353
353
  return "transient_stale_dom";
@@ -358,8 +358,13 @@ function classifyRecommendRecovery(error = {}) {
358
358
  if (/(?:aborted|abort|timeout|timed out|fetch failed|socket|network|ECONNRESET|ETIMEDOUT|EAI_AGAIN)/i.test(text)) {
359
359
  return "transient_network_or_llm";
360
360
  }
361
- return null;
362
- }
361
+ return null;
362
+ }
363
+
364
+ function isCancelShutdownError(error = {}) {
365
+ const text = normalizeErrorText(error);
366
+ return /socket hang up|ECONNREFUSED|ECONNRESET|WebSocket is not open|Target closed|Session closed|Connection closed|RUN_PROCESS_EXITED|DETACHED_WORKER|RUN_WORKER/i.test(text);
367
+ }
363
368
 
364
369
  function buildConstrainedAgentRecovery(snapshot = {}, meta = {}, artifacts = null) {
365
370
  const error = snapshot?.error || snapshot?.result?.error || null;
@@ -574,15 +579,31 @@ function normalizeRunSnapshot(snapshot) {
574
579
  };
575
580
  }
576
581
 
577
- function mergePersistedControlRequest(normalized, existing) {
578
- const control = {
579
- ...(normalized?.control || {})
580
- };
581
- if (!normalized || TERMINAL_STATUSES.has(normalized.state)) return control;
582
- const existingControl = plainRecord(existing?.control);
583
- if (existingControl.cancel_requested === true) {
584
- return {
585
- ...control,
582
+ function mergePersistedControlRequest(normalized, existing) {
583
+ const control = {
584
+ ...(normalized?.control || {})
585
+ };
586
+ const existingControl = plainRecord(existing?.control);
587
+ if (!normalized) return control;
588
+ if (TERMINAL_STATUSES.has(normalized.state)) {
589
+ if (
590
+ normalized.state === RUN_STATUS_FAILED
591
+ && existingControl.cancel_requested === true
592
+ && isCancelShutdownError(normalized.error || normalized.result?.error || "")
593
+ ) {
594
+ return {
595
+ ...control,
596
+ pause_requested: true,
597
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
598
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "cancel_recommend_pipeline_run",
599
+ cancel_requested: true
600
+ };
601
+ }
602
+ return control;
603
+ }
604
+ if (existingControl.cancel_requested === true) {
605
+ return {
606
+ ...control,
586
607
  pause_requested: true,
587
608
  pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
588
609
  pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "cancel_recommend_pipeline_run",
@@ -605,22 +626,81 @@ function mergePersistedControlRequest(normalized, existing) {
605
626
  pause_requested_by: null,
606
627
  cancel_requested: false
607
628
  };
608
- }
609
- return control;
610
- }
611
-
612
- function persistRecommendRunSnapshot(snapshot, {
613
- persistActiveCheckpoint = false
614
- } = {}) {
615
- const normalized = normalizeRunSnapshot(snapshot);
616
- if (!normalized?.run_id) return normalized;
617
- const artifacts = getRecommendRunArtifacts(normalized.run_id);
618
- if (!artifacts) return normalized;
619
- const existing = readJsonFile(artifacts.run_state_path);
620
- normalized.control = mergePersistedControlRequest(normalized, existing);
621
- if (persistActiveCheckpoint) {
622
- persistRecommendCheckpointSnapshot(normalized);
623
- }
629
+ }
630
+ return control;
631
+ }
632
+
633
+ function cancelErrorFromShutdown(shutdownError = null) {
634
+ return {
635
+ code: "PIPELINE_CANCELED",
636
+ message: "流水线已取消。",
637
+ retryable: true,
638
+ shutdown_error: shutdownError || undefined
639
+ };
640
+ }
641
+
642
+ function coerceCanceledTerminalSnapshot(normalized, existing) {
643
+ const existingControl = plainRecord(existing?.control);
644
+ const shutdownError = normalized?.error || normalized?.result?.error || null;
645
+ const shouldWrapCanceledShutdown = (
646
+ normalized
647
+ && (
648
+ (
649
+ normalized.state === RUN_STATUS_FAILED
650
+ && existingControl.cancel_requested === true
651
+ )
652
+ || normalized.state === RUN_STATUS_CANCELED
653
+ )
654
+ && isCancelShutdownError(shutdownError || "")
655
+ );
656
+ if (
657
+ !shouldWrapCanceledShutdown
658
+ ) {
659
+ return normalized;
660
+ }
661
+ const canceledError = cancelErrorFromShutdown(shutdownError);
662
+ return {
663
+ ...normalized,
664
+ state: RUN_STATUS_CANCELED,
665
+ status: RUN_STATUS_CANCELED,
666
+ last_message: "流水线已取消;取消收尾时浏览器连接已关闭。",
667
+ control: {
668
+ pause_requested: false,
669
+ pause_requested_at: null,
670
+ pause_requested_by: null,
671
+ cancel_requested: false
672
+ },
673
+ error: canceledError,
674
+ result: normalized.result ? {
675
+ ...normalized.result,
676
+ status: "CANCELED",
677
+ completion_reason: "canceled_by_user",
678
+ error: canceledError
679
+ } : {
680
+ status: "CANCELED",
681
+ completion_reason: "canceled_by_user",
682
+ error: canceledError,
683
+ run_id: normalized.run_id,
684
+ processed_count: normalized.progress?.processed || 0,
685
+ screened_count: normalized.progress?.screened || normalized.progress?.processed || 0,
686
+ passed_count: normalized.progress?.passed || 0
687
+ }
688
+ };
689
+ }
690
+
691
+ function persistRecommendRunSnapshot(snapshot, {
692
+ persistActiveCheckpoint = false
693
+ } = {}) {
694
+ let normalized = normalizeRunSnapshot(snapshot);
695
+ if (!normalized?.run_id) return normalized;
696
+ const artifacts = getRecommendRunArtifacts(normalized.run_id);
697
+ if (!artifacts) return normalized;
698
+ const existing = readJsonFile(artifacts.run_state_path);
699
+ normalized.control = mergePersistedControlRequest(normalized, existing);
700
+ normalized = coerceCanceledTerminalSnapshot(normalized, existing);
701
+ if (persistActiveCheckpoint) {
702
+ persistRecommendCheckpointSnapshot(normalized);
703
+ }
624
704
  const payload = {
625
705
  run_id: normalized.run_id,
626
706
  mode: normalized.mode,
@@ -643,29 +723,59 @@ function persistRecommendRunSnapshot(snapshot, {
643
723
  summary: normalized.summary,
644
724
  artifacts: normalized.artifacts
645
725
  };
646
- writeJsonAtomic(artifacts.run_state_path, payload);
647
- return normalized;
648
- }
649
-
650
- function reconcilePersistedRecommendRunIfNeeded(persisted) {
651
- if (!persisted || typeof persisted !== "object") return persisted;
652
- const persistedState = normalizeText(persisted.state || persisted.status);
653
- if (TERMINAL_STATUSES.has(persistedState)) return persisted;
726
+ writeJsonAtomic(artifacts.run_state_path, payload);
727
+ return normalized;
728
+ }
729
+
730
+ function patchPersistedRecommendRunControl(runId, controlPatch = {}, {
731
+ message = ""
732
+ } = {}) {
733
+ const artifacts = getRecommendRunArtifacts(runId);
734
+ if (!artifacts) return null;
735
+ const current = readJsonFile(artifacts.run_state_path);
736
+ const state = normalizeText(current?.state || current?.status || "");
737
+ if (!current || TERMINAL_STATUSES.has(state)) return null;
738
+ const now = new Date().toISOString();
739
+ const patched = {
740
+ ...current,
741
+ updated_at: now,
742
+ heartbeat_at: current.heartbeat_at || now,
743
+ last_message: message || current.last_message || "",
744
+ control: {
745
+ ...(current.control || {}),
746
+ ...controlPatch
747
+ }
748
+ };
749
+ writeJsonAtomic(artifacts.run_state_path, patched);
750
+ return patched;
751
+ }
752
+
753
+ function reconcilePersistedRecommendRunIfNeeded(persisted) {
754
+ if (!persisted || typeof persisted !== "object") return persisted;
755
+ const persistedState = normalizeText(persisted.state || persisted.status);
756
+ if (TERMINAL_STATUSES.has(persistedState)) return persisted;
654
757
  if (isProcessAlive(persisted.pid)) return persisted;
655
758
 
656
- const runId = normalizeRunId(persisted.run_id || persisted.runId);
657
- const artifacts = getRecommendRunArtifacts(runId);
658
- const checkpoint = artifacts?.checkpoint_path ? readJsonFile(artifacts.checkpoint_path) : null;
659
- const now = new Date().toISOString();
660
- const error = {
661
- code: "RUN_PROCESS_EXITED",
662
- message: `检测到推荐任务进程已退出(pid=${persisted.pid || "unknown"}),已自动标记为失败。`,
663
- retryable: true
664
- };
665
- return persistRecommendRunSnapshot({
759
+ const runId = normalizeRunId(persisted.run_id || persisted.runId);
760
+ const artifacts = getRecommendRunArtifacts(runId);
761
+ const checkpoint = artifacts?.checkpoint_path ? readJsonFile(artifacts.checkpoint_path) : null;
762
+ const now = new Date().toISOString();
763
+ const cancelRequested = persisted.control?.cancel_requested === true;
764
+ const processExitedError = {
765
+ code: "RUN_PROCESS_EXITED",
766
+ message: `检测到推荐任务进程已退出(pid=${persisted.pid || "unknown"})。`,
767
+ retryable: true
768
+ };
769
+ const error = cancelRequested
770
+ ? cancelErrorFromShutdown(processExitedError)
771
+ : {
772
+ ...processExitedError,
773
+ message: `检测到推荐任务进程已退出(pid=${persisted.pid || "unknown"}),已自动标记为失败。`
774
+ };
775
+ return persistRecommendRunSnapshot({
666
776
  runId,
667
777
  name: persisted.name || runId,
668
- status: RUN_STATUS_FAILED,
778
+ status: cancelRequested ? RUN_STATUS_CANCELED : RUN_STATUS_FAILED,
669
779
  phase: persisted.stage || persisted.phase || "recommend:orphaned",
670
780
  progress: persisted.progress || {},
671
781
  context: persisted.context || {},
@@ -1813,20 +1923,45 @@ export function cancelRecommendPipelineRunTool({ args = {} } = {}) {
1813
1923
  }, runId);
1814
1924
  } catch {
1815
1925
  const persisted = readRecommendRunState(runId);
1816
- if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1817
- return {
1818
- status: "CANCEL_IGNORED",
1819
- run: persisted,
1820
- message: "目标任务已结束,无需取消。",
1926
+ if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1927
+ return {
1928
+ status: "CANCEL_IGNORED",
1929
+ run: persisted,
1930
+ message: "目标任务已结束,无需取消。",
1821
1931
  runtime_evaluate_used: false,
1822
1932
  method_summary: {},
1823
1933
  method_log: [],
1824
- chrome: null
1825
- };
1826
- }
1827
- return getRecommendPipelineRunTool({ args });
1828
- }
1829
- }
1934
+ chrome: null
1935
+ };
1936
+ }
1937
+ const cancelMessage = "已收到取消请求,将由 detached worker 在下一个安全边界停止。";
1938
+ const patched = patchPersistedRecommendRunControl(runId, {
1939
+ pause_requested: true,
1940
+ pause_requested_at: new Date().toISOString(),
1941
+ pause_requested_by: "cancel_recommend_pipeline_run",
1942
+ cancel_requested: true
1943
+ }, {
1944
+ message: cancelMessage
1945
+ });
1946
+ if (patched) {
1947
+ return {
1948
+ status: "CANCEL_REQUESTED",
1949
+ run: patched,
1950
+ message: cancelMessage,
1951
+ persistence: {
1952
+ source: "disk",
1953
+ active_control_available: false,
1954
+ detached_control_requested: true
1955
+ },
1956
+ runtime_evaluate_used: false,
1957
+ method_summary: {},
1958
+ method_log: [],
1959
+ chrome: null
1960
+ };
1961
+ }
1962
+ return getRecommendPipelineRunTool({ args });
1963
+ }
1964
+ }
1830
1965
 
1831
1966
  export function getRecommendMcpHealthSnapshot(runId) {
1832
1967
  const meta = getRecommendRunMeta(runId);