@reconcrap/boss-recommend-mcp 2.1.2 → 2.1.3

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
@@ -83,6 +83,8 @@ MCP 工具:
83
83
 
84
84
  - `list_recommend_jobs`(只读读取推荐页岗位下拉框,返回可直接用于 cron/一次性任务的 `job_names`)
85
85
  - `prepare_recommend_pipeline_run`(只校验完整 payload 是否已可用于 cron/一次性任务;不启动筛选,返回 `READY + cron_ready=true` 后才应创建 cron)
86
+ - `schedule_recommend_pipeline_run`(创建 package-owned 定时任务;保存已 READY 的完整 payload,启动 detached scheduler,到点后直接调用 `start_recommend_pipeline_run`)
87
+ - `get_recommend_scheduled_run`(查询 package-owned 定时任务;到点后会显示内层 `run_id` 和 run 快照)
86
88
  - `start_recommend_pipeline_run`(异步启动;同样先经过前置门禁,通过后返回 run_id)
87
89
  - `get_recommend_pipeline_run`(轮询 run_id 状态)
88
90
  - `cancel_recommend_pipeline_run`(取消运行中任务)
@@ -120,15 +122,18 @@ Cron / 一次性定时任务设置建议先在设置阶段完成 Chrome/登录/
120
122
 
121
123
  ```bash
122
124
  boss-recommend-mcp prepare-run --instruction-file boss-recommend-instruction.txt --overrides-file boss-recommend-overrides.json --confirmation-file boss-recommend-confirmation.json --slow-live --port 9222
125
+ boss-recommend-mcp schedule-run --schedule-delay-minutes 10 --instruction-file boss-recommend-instruction.txt --overrides-file boss-recommend-overrides.json --confirmation-file boss-recommend-confirmation.json --slow-live --port 9222
126
+ boss-recommend-mcp schedule-status --schedule-id <schedule_id>
123
127
  ```
124
128
 
125
- 只有输出 `status: "READY"` 且 `cron_ready: true` 后,才把同一份 payload 写入 cron 并在到点时调用 `start_recommend_pipeline_run` / `boss-recommend-mcp run --detached`。如果设置阶段返回 `BOSS_LOGIN_REQUIRED`、`NEED_INPUT` `NEED_CONFIRMATION`,先让用户登录或补齐确认,不要创建 cron
129
+ 只有 `prepare-run` 输出 `status: "READY"` 且 `cron_ready: true` 后,才继续调用 `schedule-run`。只有 `schedule-run` 输出 `status: "SCHEDULED"` 且带有 `schedule_id` 后,才算定时任务真的创建成功。不要让外部 AI harness 自己拼 `/tmp/*.log` shell cron 或未来对话提醒;那类 cron 容易丢失 JSON/file 参数并在到点后重新卡确认门禁。
126
130
 
127
131
  状态机:
128
132
 
129
133
  - `NEED_INPUT`
130
134
  - `NEED_CONFIRMATION`
131
135
  - `READY`(仅准备工具)
136
+ - `SCHEDULED`(仅定时工具)
132
137
  - `COMPLETED`
133
138
  - `FAILED`
134
139
 
@@ -269,6 +274,16 @@ npx -y @reconcrap/boss-recommend-mcp@latest run --detached --instruction-file bo
269
274
 
270
275
  `--detached` 会让父进程输出 `ACCEPTED + run_id` 后退出,子进程继续持有 Chrome DevTools 会话并执行长任务。岗位发现可以使用只读 CLI:
271
276
 
277
+ 如果是稍后启动/cron/定时任务,不要手写系统 cron;用 package-owned scheduler:
278
+
279
+ ```bash
280
+ npx -y @reconcrap/boss-recommend-mcp@latest prepare-run --instruction-file boss-recommend-instruction.txt --overrides-file boss-recommend-overrides.json --confirmation-file boss-recommend-confirmation.json --slow-live --port 9222
281
+ npx -y @reconcrap/boss-recommend-mcp@latest schedule-run --schedule-delay-minutes 10 --instruction-file boss-recommend-instruction.txt --overrides-file boss-recommend-overrides.json --confirmation-file boss-recommend-confirmation.json --slow-live --port 9222
282
+ npx -y @reconcrap/boss-recommend-mcp@latest schedule-status --schedule-id <schedule_id>
283
+ ```
284
+
285
+ `schedule-run` 会保存同一份已验证 payload 并启动 detached scheduler worker;到点后 worker 会直接调用包内 `start_recommend_pipeline_run`,不会重新让 AI harness 拼参数。
286
+
272
287
  ```bash
273
288
  npx -y @reconcrap/boss-recommend-mcp@latest list-jobs --slow-live --port 9222
274
289
  # 源码模式(GitHub clone 后)
@@ -463,6 +478,7 @@ Trae-CN / 长对话防循环建议:
463
478
 
464
479
  - `start_recommend_pipeline_run` 为异步入口,但不会跳过同步确认流程。
465
480
  - `prepare_recommend_pipeline_run` / `boss-recommend-mcp prepare-run` 用于 cron 设置阶段;它不启动筛选,只确认 payload 已经不需要到点后再问用户。
481
+ - `schedule_recommend_pipeline_run` / `boss-recommend-mcp schedule-run` 是推荐页定时启动的唯一推荐路径;它创建真实 package-owned detached scheduler,并返回 `schedule_id`。
466
482
  - 定时心跳默认 120 秒一次;`updated_at` 仍会在阶段或进度变化时刷新。
467
483
  - 每个 run 会持久化到 `~/.boss-recommend-mcp/runs/<run_id>.json`(可通过 `BOSS_RECOMMEND_HOME` 覆盖)。
468
484
  - 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.2",
3
+ "version": "2.1.3",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -79,11 +79,11 @@
79
79
  "live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js"
80
80
  },
81
81
  "files": [
82
- "bin",
83
- "config/screening-config.example.json",
84
- "skills",
85
- "scripts/install-macos.sh",
86
- "scripts/postinstall.cjs",
82
+ "bin",
83
+ "config/screening-config.example.json",
84
+ "skills",
85
+ "scripts/install-macos.sh",
86
+ "scripts/postinstall.cjs",
87
87
  "src/core",
88
88
  "src/domains",
89
89
  "src/chat-mcp.js",
@@ -93,6 +93,7 @@
93
93
  "src/index.js",
94
94
  "src/parser.js",
95
95
  "src/recommend-mcp.js",
96
+ "src/recommend-scheduler.js",
96
97
  "src/recruit-mcp.js",
97
98
  "src/run-state.js",
98
99
  "README.md"
@@ -94,8 +94,14 @@ description: "Use when users want Boss recommend-page filtering/screening via bo
94
94
  - 限制:只读岗位列表,不启动筛选任务;若返回 `BOSS_LOGIN_REQUIRED`,必须让用户在自动打开的 Chrome 完成登录后重试,本次 cron 不得创建。
95
95
  - Cron 准备工具:`prepare_recommend_pipeline_run`
96
96
  - 用途:只校验参数是否已可用于 cron / 一次性任务,不启动筛选任务。
97
- - 要求:只有返回 `status=READY` 且 `cron_ready=true` 后,才允许创建 cron。
97
+ - 要求:只有返回 `status=READY` 且 `cron_ready=true` 后,才允许继续创建定时任务。
98
98
  - 若返回 `NEED_INPUT` / `NEED_CONFIRMATION` / `FAILED`:继续补齐 `pending_questions` 或修复登录/页面/config;不得先创建 cron。
99
+ - Cron 创建工具:`schedule_recommend_pipeline_run`
100
+ - 用途:保存已经 READY 的完整 payload,并启动 package-owned detached scheduler;到点后由包内 worker 直接调用 `start_recommend_pipeline_run`。
101
+ - 必填:同 `start_recommend_pipeline_run` 的完整 payload,另加 `schedule_delay_minutes` / `schedule_delay_seconds` / `schedule_run_at` 之一。
102
+ - 成功标准:必须返回 `status=SCHEDULED`、`schedule_created=true`、`schedule_id`、`run_at`。只有这个返回后,才可以告诉用户定时任务已创建。
103
+ - Cron 查询工具:`get_recommend_scheduled_run`
104
+ - 用途:用户问“任务是否启动/进度”时,先查 `schedule_id`。若到点后已启动,会返回内层 `run_id` 和 run 快照。
99
105
  - 主工具:`start_recommend_pipeline_run`
100
106
  - 必填:`instruction`
101
107
  - 关键输入:
@@ -119,16 +125,30 @@ description: "Use when users want Boss recommend-page filtering/screening via bo
119
125
  2. 收集 Stage A 全部筛选项、`target_count`、`post_action`、`max_greet_count`(如需要)和本次 `rest_level`。
120
126
  3. 调用 `list_recommend_jobs`;若 Chrome 未打开,工具会尝试自动打开本机 9222 Chrome 并进入推荐页。若返回 `BOSS_LOGIN_REQUIRED` 或页面不可用,停止 cron 创建,让用户登录/修复后重试。
121
127
  4. 用 `job_names` 中的精确岗位名完成岗位确认,并完成最终总确认,写入 `job_confirmed=true` 与 `final_confirmed=true`。
122
- 5. 调用 `prepare_recommend_pipeline_run` 传入将来要执行的完整 payload;只有 `READY + cron_ready=true` 才能创建 cron。
123
- 6. cron 到点时只调用 `start_recommend_pipeline_run`,并传入准备阶段验证过的同一份 payload。到点后若 Chrome/登录异常,应作为运行失败处理,不得再向用户追问参数。
128
+ 5. 调用 `prepare_recommend_pipeline_run` 传入将来要执行的完整 payload;只有 `READY + cron_ready=true` 才能继续。
129
+ 6. 立即调用 `schedule_recommend_pipeline_run`,传入同一份完整 payload 和 `schedule_delay_minutes` / `schedule_run_at`。禁止让 OpenClaw 自己写 `/tmp/*.log` shell cron、自然语言提醒、或未来对话回调来代替此工具。
130
+ 7. 只有拿到 `SCHEDULED + schedule_id` 后才告诉用户定时任务已创建。回复必须包含 `schedule_id`,而不是只说“10 分钟后会启动”。
131
+ 8. 到点后由 package-owned detached scheduler 启动;若 Chrome/登录异常,应作为 schedule/run 失败处理,不得再向用户追问参数。
124
132
 
125
- Shell-only OpenClaw/QClaw cron 设置同样先运行:
133
+ Shell-only OpenClaw/QClaw cron 设置同样先运行 prepare:
126
134
 
127
135
  ```powershell
128
136
  npx -y @reconcrap/boss-recommend-mcp@latest prepare-run --instruction-file .\boss-recommend-instruction.txt --overrides-file .\boss-recommend-overrides.json --confirmation-file .\boss-recommend-confirmation.json --slow-live --port 9222
129
137
  ```
130
138
 
131
- 仅当上述命令输出 `READY` 且 `cron_ready=true` 后,才把对应的 detached `run` 命令写入 cron。
139
+ 仅当上述命令输出 `READY` 且 `cron_ready=true` 后,才允许继续创建定时任务。
140
+
141
+ 然后必须用 package-owned scheduler 创建定时任务,不要手写系统 cron:
142
+
143
+ ```powershell
144
+ npx -y @reconcrap/boss-recommend-mcp@latest schedule-run --schedule-delay-minutes 10 --instruction-file .\boss-recommend-instruction.txt --overrides-file .\boss-recommend-overrides.json --confirmation-file .\boss-recommend-confirmation.json --slow-live --port 9222
145
+ ```
146
+
147
+ 用户查询时:
148
+
149
+ ```powershell
150
+ npx -y @reconcrap/boss-recommend-mcp@latest schedule-status --schedule-id <schedule_id>
151
+ ```
132
152
 
133
153
  ## Async Run Policy
134
154
 
@@ -173,14 +193,15 @@ npx -y @reconcrap/boss-recommend-mcp@latest prepare-run --instruction-file .\bos
173
193
  推荐做法:
174
194
 
175
195
  1. 将锁定的用户原文写入 instruction 文件,将已确认参数写入 `overrides` 与 `confirmation` JSON 文件。
176
- 2. 先用 `prepare-run` 校验完整 payload 已 cron-ready;未返回 `READY + cron_ready=true` 不得创建 cron 或启动 run。
177
- 3. detached CLI 启动,让父命令返回启动证据,子进程继续持有 CDP 会话:
196
+ 2. 先用 `prepare-run` 校验完整 payload 已 cron-ready;未返回 `READY + cron_ready=true` 不得创建定时任务或启动 run。
197
+ 3. 若用户要“现在启动”,用 detached CLI 启动,让父命令返回启动证据,子进程继续持有 CDP 会话:
178
198
 
179
199
  ```powershell
180
200
  npx -y @reconcrap/boss-recommend-mcp@latest run --detached --instruction-file .\boss-recommend-instruction.txt --overrides-file .\boss-recommend-overrides.json --confirmation-file .\boss-recommend-confirmation.json --slow-live --port 9222
181
201
  ```
182
202
 
183
- 4. 若返回 `ACCEPTED + run_id`,即任务已启动;记录 `run_id/stdout_path/stderr_path`。若返回 `NEED_INPUT` `NEED_CONFIRMATION`,说明 cron 设置阶段漏掉了准备门禁,应回到 `prepare-run` 补齐,不能在到点后继续问用户确认。
203
+ 4. 若用户要“稍后/cron/定时启动”,用 `schedule-run`,不是 `run --detached`。若 `schedule-run` 未返回 `SCHEDULED + schedule_id`,不得告诉用户定时任务已创建。
204
+ 5. 若即时 `run --detached` 返回 `ACCEPTED + run_id`,即任务已启动;记录 `run_id/stdout_path/stderr_path`。若返回 `NEED_INPUT` 或 `NEED_CONFIRMATION`,说明设置阶段漏掉了准备门禁,应回到 `prepare-run` 补齐,不能在到点后继续问用户确认。
184
205
 
185
206
  兼容路径:
186
207
 
package/src/cli.js CHANGED
@@ -28,6 +28,10 @@ import {
28
28
  prepareRecommendPipelineRunTool,
29
29
  startRecommendPipelineRunTool
30
30
  } from "./recommend-mcp.js";
31
+ import {
32
+ getRecommendScheduledRunTool,
33
+ scheduleRecommendPipelineRunTool
34
+ } from "./recommend-scheduler.js";
31
35
  import {
32
36
  getBossScreenConfigResolution,
33
37
  resolveBossChatRuntimeLayout as resolveCdpBossChatRuntimeLayout,
@@ -2694,6 +2698,8 @@ function printHelp() {
2694
2698
  console.log(" boss-recommend-mcp Start the MCP server");
2695
2699
  console.log(" boss-recommend-mcp start Start the MCP server");
2696
2700
  console.log(" boss-recommend-mcp prepare-run Validate a cron-ready recommend run payload without starting screening");
2701
+ console.log(" boss-recommend-mcp schedule-run Create a package-owned delayed recommend run");
2702
+ console.log(" boss-recommend-mcp schedule-status Check a package-owned delayed recommend run");
2697
2703
  console.log(" boss-recommend-mcp run Start a CDP-only recommend run through the shared run service");
2698
2704
  console.log(" boss-recommend-mcp list-jobs CDP-only list of exact recommend job names for cron/one-shot inputs");
2699
2705
  console.log(" boss-recommend-mcp chat <subcommand> Run CDP-only boss-chat health/prepare/status commands");
@@ -2711,6 +2717,8 @@ function printHelp() {
2711
2717
  console.log("");
2712
2718
  console.log("Run command:");
2713
2719
  console.log(" boss-recommend-mcp prepare-run --instruction \"...\" --overrides-file overrides.json --confirmation-file confirmation.json");
2720
+ console.log(" boss-recommend-mcp schedule-run --schedule-delay-minutes 10 --instruction-file boss-recommend-instruction.txt --overrides-file overrides.json --confirmation-file confirmation.json");
2721
+ console.log(" boss-recommend-mcp schedule-status --schedule-id <id>");
2714
2722
  console.log(" boss-recommend-mcp run --instruction \"推荐页上筛选211男生,近14天没有,有大模型平台经验\" --overrides-file overrides.json --confirmation-file confirmation.json");
2715
2723
  console.log(" boss-recommend-mcp run --detached --instruction \"...\" --overrides-file overrides.json --confirmation-file confirmation.json");
2716
2724
  console.log(" boss-recommend-mcp list-jobs --slow-live --port 9222");
@@ -2907,6 +2915,62 @@ async function preparePipelineOnce(options = {}) {
2907
2915
  }
2908
2916
  }
2909
2917
 
2918
+ function addScheduleOptions(args, options = {}) {
2919
+ const scheduleId = String(options["schedule-id"] || options.schedule_id || options.scheduleId || "").trim();
2920
+ if (scheduleId) args.schedule_id = scheduleId;
2921
+ const runAt = String(options["schedule-run-at"] || options.schedule_run_at || options.scheduleRunAt || options["run-at"] || options.run_at || "").trim();
2922
+ if (runAt) args.schedule_run_at = runAt;
2923
+ const delayMinutes = parseNonNegativeInteger(options["schedule-delay-minutes"] ?? options.schedule_delay_minutes ?? options.scheduleDelayMinutes);
2924
+ if (delayMinutes !== undefined) args.schedule_delay_minutes = delayMinutes;
2925
+ const delaySeconds = parseNonNegativeInteger(options["schedule-delay-seconds"] ?? options.schedule_delay_seconds ?? options.scheduleDelaySeconds);
2926
+ if (delaySeconds !== undefined) args.schedule_delay_seconds = delaySeconds;
2927
+ return args;
2928
+ }
2929
+
2930
+ async function schedulePipelineOnce(options = {}) {
2931
+ const workspaceRoot = getWorkspaceRoot(options);
2932
+ const { args, port } = buildRecommendRunCliInput(options);
2933
+ addScheduleOptions(args, options);
2934
+ const result = await scheduleRecommendPipelineRunTool({
2935
+ workspaceRoot,
2936
+ args
2937
+ });
2938
+ printJson({
2939
+ ...result,
2940
+ cli: {
2941
+ ...(result.cli || {}),
2942
+ command: "schedule-run",
2943
+ cdp_only: true,
2944
+ package_owned_scheduler: true,
2945
+ workspace_root: workspaceRoot,
2946
+ port
2947
+ }
2948
+ });
2949
+ if (result.status !== "SCHEDULED") {
2950
+ process.exitCode = 1;
2951
+ }
2952
+ }
2953
+
2954
+ function scheduleStatusCli(options = {}) {
2955
+ const scheduleId = String(options["schedule-id"] || options.schedule_id || options.scheduleId || "").trim();
2956
+ const result = getRecommendScheduledRunTool({
2957
+ args: {
2958
+ schedule_id: scheduleId
2959
+ }
2960
+ });
2961
+ printJson({
2962
+ ...result,
2963
+ cli: {
2964
+ command: "schedule-status",
2965
+ cdp_only: true,
2966
+ package_owned_scheduler: true
2967
+ }
2968
+ });
2969
+ if (result.status !== "OK") {
2970
+ process.exitCode = 1;
2971
+ }
2972
+ }
2973
+
2910
2974
  async function runPipelineOnce(options = {}) {
2911
2975
  const workspaceRoot = getWorkspaceRoot(options);
2912
2976
  const { args, port } = buildRecommendRunCliInput(options);
@@ -3120,6 +3184,39 @@ export async function runCli(argv = process.argv) {
3120
3184
  process.exitCode = 1;
3121
3185
  }
3122
3186
  break;
3187
+ case "schedule-run":
3188
+ case "schedule":
3189
+ try {
3190
+ await schedulePipelineOnce(options);
3191
+ } catch (error) {
3192
+ printJson({
3193
+ status: "FAILED",
3194
+ schedule_created: false,
3195
+ error: {
3196
+ code: "INVALID_CLI_INPUT",
3197
+ message: error.message || "Invalid CLI input",
3198
+ retryable: false
3199
+ }
3200
+ });
3201
+ process.exitCode = 1;
3202
+ }
3203
+ break;
3204
+ case "schedule-status":
3205
+ case "scheduled-run":
3206
+ try {
3207
+ scheduleStatusCli(options);
3208
+ } catch (error) {
3209
+ printJson({
3210
+ status: "FAILED",
3211
+ error: {
3212
+ code: "INVALID_CLI_INPUT",
3213
+ message: error.message || "Invalid CLI input",
3214
+ retryable: false
3215
+ }
3216
+ });
3217
+ process.exitCode = 1;
3218
+ }
3219
+ break;
3123
3220
  case "list-jobs":
3124
3221
  case "jobs":
3125
3222
  case "recommend-jobs":
@@ -3282,6 +3379,8 @@ export const __testables = {
3282
3379
  resolveBossChatRuntimeLayout: resolveCdpBossChatRuntimeLayout,
3283
3380
  runBossChatCliCommand,
3284
3381
  preparePipelineOnce,
3382
+ schedulePipelineOnce,
3383
+ scheduleStatusCli,
3285
3384
  runPipelineOnce
3286
3385
  };
3287
3386
 
package/src/index.js CHANGED
@@ -39,6 +39,12 @@ import {
39
39
  startRecruitPipelineRunTool,
40
40
  validateRecruitPipelineArgs
41
41
  } from "./recruit-mcp.js";
42
+ import {
43
+ __setRecommendSchedulerSpawnForTests,
44
+ getRecommendScheduledRunTool,
45
+ runScheduledRecommendWorker,
46
+ scheduleRecommendPipelineRunTool
47
+ } from "./recommend-scheduler.js";
42
48
  import {
43
49
  __resetRecommendMcpStateForTests,
44
50
  __setRecommendMcpConnectorForTests,
@@ -90,6 +96,8 @@ const require = createRequire(import.meta.url);
90
96
  const { version: SERVER_VERSION } = require("../package.json");
91
97
 
92
98
  const TOOL_PREPARE_RUN = "prepare_recommend_pipeline_run";
99
+ const TOOL_SCHEDULE_RUN = "schedule_recommend_pipeline_run";
100
+ const TOOL_GET_SCHEDULED_RUN = "get_recommend_scheduled_run";
93
101
  const TOOL_START_RUN = "start_recommend_pipeline_run";
94
102
  const TOOL_GET_RUN = "get_recommend_pipeline_run";
95
103
  const TOOL_CANCEL_RUN = "cancel_recommend_pipeline_run";
@@ -1083,6 +1091,36 @@ function createListRecommendJobsInputSchema() {
1083
1091
  };
1084
1092
  }
1085
1093
 
1094
+ function createScheduleRunInputSchema() {
1095
+ const base = createRunInputSchema();
1096
+ return {
1097
+ ...base,
1098
+ properties: {
1099
+ ...base.properties,
1100
+ schedule_id: {
1101
+ type: "string",
1102
+ description: "可选,自定义定时任务 id;默认自动生成"
1103
+ },
1104
+ schedule_run_at: {
1105
+ type: "string",
1106
+ description: "ISO 时间字符串;到点后由 package-owned detached scheduler 启动已准备好的 payload"
1107
+ },
1108
+ schedule_delay_minutes: {
1109
+ type: "number",
1110
+ minimum: 0,
1111
+ description: "从现在开始延迟多少分钟后启动;适合 OpenClaw cron/定时任务设置"
1112
+ },
1113
+ schedule_delay_seconds: {
1114
+ type: "number",
1115
+ minimum: 0,
1116
+ description: "从现在开始延迟多少秒后启动;主要用于短延迟或测试"
1117
+ }
1118
+ },
1119
+ required: ["instruction"],
1120
+ additionalProperties: false
1121
+ };
1122
+ }
1123
+
1086
1124
  function createToolsSchema() {
1087
1125
  return [
1088
1126
  {
@@ -1095,6 +1133,23 @@ function createToolsSchema() {
1095
1133
  description: "只校验 Boss 推荐页流水线参数是否已可用于 cron/一次性任务;不会启动筛选任务。只有返回 READY/cron_ready=true 后才应创建定时任务。",
1096
1134
  inputSchema: createRunInputSchema()
1097
1135
  },
1136
+ {
1137
+ name: TOOL_SCHEDULE_RUN,
1138
+ description: "创建 package-owned Boss 推荐页定时任务:先校验 READY/cron_ready,再保存完整 payload,并由 detached scheduler 到点直接启动,不再依赖 AI harness 自己拼 shell cron。",
1139
+ inputSchema: createScheduleRunInputSchema()
1140
+ },
1141
+ {
1142
+ name: TOOL_GET_SCHEDULED_RUN,
1143
+ description: "查询 package-owned 推荐页定时任务状态;返回 schedule_id、worker 状态、到点后启动的 run_id 与运行快照。",
1144
+ inputSchema: {
1145
+ type: "object",
1146
+ properties: {
1147
+ schedule_id: { type: "string" }
1148
+ },
1149
+ required: ["schedule_id"],
1150
+ additionalProperties: false
1151
+ }
1152
+ },
1098
1153
  {
1099
1154
  name: TOOL_START_RUN,
1100
1155
  description: "异步启动 Boss 推荐页流水线(含同步门禁预检);只有在前置确认与页面就绪通过后才返回 run_id。",
@@ -2626,6 +2681,10 @@ async function handleRequest(message, workspaceRoot) {
2626
2681
  payload = await listRecommendJobsTool({ workspaceRoot, args });
2627
2682
  } else if (toolName === TOOL_PREPARE_RUN) {
2628
2683
  payload = prepareRecommendPipelineRunTool({ workspaceRoot, args });
2684
+ } else if (toolName === TOOL_SCHEDULE_RUN) {
2685
+ payload = await scheduleRecommendPipelineRunTool({ workspaceRoot, args });
2686
+ } else if (toolName === TOOL_GET_SCHEDULED_RUN) {
2687
+ payload = getRecommendScheduledRunTool({ args });
2629
2688
  } else if (toolName === TOOL_START_RUN) {
2630
2689
  payload = await handleStartRunTool({ workspaceRoot, args });
2631
2690
  } else if (toolName === TOOL_GET_RUN) {
@@ -2825,6 +2884,12 @@ export const __testables = {
2825
2884
  resetRecommendMcpStateForTests() {
2826
2885
  __resetRecommendMcpStateForTests();
2827
2886
  },
2887
+ setRecommendSchedulerSpawnForTests(nextImpl) {
2888
+ __setRecommendSchedulerSpawnForTests(nextImpl);
2889
+ },
2890
+ runScheduledRecommendWorkerForTests(options = {}) {
2891
+ return runScheduledRecommendWorker(options);
2892
+ },
2828
2893
  setChatMcpConnectorForTests(nextImpl) {
2829
2894
  forceChatInProcForTests = typeof nextImpl === "function";
2830
2895
  __setChatMcpConnectorForTests(nextImpl);
@@ -0,0 +1,482 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { getStateHome } from "./run-state.js";
7
+ import {
8
+ getRecommendPipelineRunTool,
9
+ prepareRecommendPipelineRunTool,
10
+ startRecommendPipelineRunTool
11
+ } from "./recommend-mcp.js";
12
+
13
+ const SCHEDULE_WORKER_FLAG = "--schedule-worker";
14
+ const SCHEDULE_ID_FLAG = "--schedule-id";
15
+ const TERMINAL_SCHEDULE_STATES = new Set(["completed", "failed", "canceled"]);
16
+ const TERMINAL_RUN_STATES = new Set(["completed", "failed", "canceled"]);
17
+
18
+ let spawnProcessImpl = spawn;
19
+
20
+ function normalizeText(value) {
21
+ return String(value || "").replace(/\s+/g, " ").trim();
22
+ }
23
+
24
+ function clonePlain(value, fallback = null) {
25
+ try {
26
+ return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
27
+ } catch {
28
+ return fallback;
29
+ }
30
+ }
31
+
32
+ function nowIso() {
33
+ return new Date().toISOString();
34
+ }
35
+
36
+ function sleep(ms) {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ function parseNonNegativeNumber(raw, fallback = null) {
41
+ if (raw === undefined || raw === null || raw === "") return fallback;
42
+ const parsed = Number(raw);
43
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
44
+ }
45
+
46
+ function parseRunAt(args = {}) {
47
+ const direct = normalizeText(args.schedule_run_at || args.scheduleRunAt || args.run_at || args.runAt);
48
+ if (direct) {
49
+ const timestamp = Date.parse(direct);
50
+ if (!Number.isFinite(timestamp)) {
51
+ return {
52
+ ok: false,
53
+ error: `Invalid schedule_run_at: ${direct}`
54
+ };
55
+ }
56
+ return {
57
+ ok: true,
58
+ runAtMs: timestamp,
59
+ source: "schedule_run_at"
60
+ };
61
+ }
62
+
63
+ const delaySeconds = parseNonNegativeNumber(args.schedule_delay_seconds ?? args.scheduleDelaySeconds, null);
64
+ if (delaySeconds !== null) {
65
+ return {
66
+ ok: true,
67
+ runAtMs: Date.now() + Math.round(delaySeconds * 1000),
68
+ source: "schedule_delay_seconds"
69
+ };
70
+ }
71
+
72
+ const delayMinutes = parseNonNegativeNumber(args.schedule_delay_minutes ?? args.scheduleDelayMinutes, null);
73
+ if (delayMinutes !== null) {
74
+ return {
75
+ ok: true,
76
+ runAtMs: Date.now() + Math.round(delayMinutes * 60 * 1000),
77
+ source: "schedule_delay_minutes"
78
+ };
79
+ }
80
+
81
+ return {
82
+ ok: false,
83
+ error: "schedule_run_at or schedule_delay_minutes/schedule_delay_seconds is required"
84
+ };
85
+ }
86
+
87
+ function safeIdPart(value) {
88
+ return normalizeText(value).replace(/[^a-zA-Z0-9_.-]/g, "_");
89
+ }
90
+
91
+ function createScheduleId(raw = "") {
92
+ const requested = safeIdPart(raw);
93
+ if (requested) return requested;
94
+ const suffix = Math.random().toString(36).slice(2, 10);
95
+ return `mcp_recommend_schedule_${Date.now().toString(36)}_${suffix}`;
96
+ }
97
+
98
+ function getSchedulesDir() {
99
+ return path.join(getStateHome(), "schedules");
100
+ }
101
+
102
+ function getScheduleArtifacts(scheduleId) {
103
+ const id = safeIdPart(scheduleId);
104
+ if (!id) throw new Error("schedule_id is required");
105
+ return {
106
+ schedule_path: path.join(getSchedulesDir(), `${id}.json`),
107
+ worker_stdout_path: path.join(getSchedulesDir(), `${id}.worker.stdout.log`),
108
+ worker_stderr_path: path.join(getSchedulesDir(), `${id}.worker.stderr.log`)
109
+ };
110
+ }
111
+
112
+ function writeJsonAtomic(filePath, payload) {
113
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
114
+ const tempPath = `${filePath}.tmp`;
115
+ fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
116
+ fs.renameSync(tempPath, filePath);
117
+ return payload;
118
+ }
119
+
120
+ function readJsonFile(filePath) {
121
+ try {
122
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
123
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function readSchedule(scheduleId) {
130
+ const artifacts = getScheduleArtifacts(scheduleId);
131
+ return readJsonFile(artifacts.schedule_path);
132
+ }
133
+
134
+ function writeSchedule(scheduleId, patch) {
135
+ const artifacts = getScheduleArtifacts(scheduleId);
136
+ const current = readJsonFile(artifacts.schedule_path) || {};
137
+ return writeJsonAtomic(artifacts.schedule_path, {
138
+ ...current,
139
+ ...patch,
140
+ schedule_id: scheduleId,
141
+ updated_at: nowIso()
142
+ });
143
+ }
144
+
145
+ function isProcessAlive(pid) {
146
+ if (!Number.isInteger(pid) || pid <= 0) return false;
147
+ try {
148
+ process.kill(pid, 0);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ function stripScheduleArgs(args = {}) {
156
+ const cloned = clonePlain(args, {});
157
+ for (const key of [
158
+ "schedule_id",
159
+ "scheduleId",
160
+ "schedule_run_at",
161
+ "scheduleRunAt",
162
+ "run_at",
163
+ "runAt",
164
+ "schedule_delay_seconds",
165
+ "scheduleDelaySeconds",
166
+ "schedule_delay_minutes",
167
+ "scheduleDelayMinutes"
168
+ ]) {
169
+ delete cloned[key];
170
+ }
171
+ return cloned;
172
+ }
173
+
174
+ function buildFailedSchedulePayload(error, extra = {}) {
175
+ return {
176
+ status: "FAILED",
177
+ schedule_created: false,
178
+ error: {
179
+ code: "RECOMMEND_SCHEDULE_FAILED",
180
+ message: error?.message || String(error || "Unable to schedule recommend run"),
181
+ retryable: true
182
+ },
183
+ ...extra
184
+ };
185
+ }
186
+
187
+ function launchScheduleWorker(scheduleId) {
188
+ const artifacts = getScheduleArtifacts(scheduleId);
189
+ fs.mkdirSync(path.dirname(artifacts.worker_stdout_path), { recursive: true });
190
+ const stdoutFd = fs.openSync(artifacts.worker_stdout_path, "a");
191
+ const stderrFd = fs.openSync(artifacts.worker_stderr_path, "a");
192
+ let child;
193
+ try {
194
+ child = spawnProcessImpl(process.execPath, [
195
+ thisFilePath,
196
+ SCHEDULE_WORKER_FLAG,
197
+ SCHEDULE_ID_FLAG,
198
+ scheduleId
199
+ ], {
200
+ cwd: process.cwd(),
201
+ detached: true,
202
+ stdio: ["ignore", stdoutFd, stderrFd],
203
+ windowsHide: true,
204
+ env: process.env
205
+ });
206
+ } finally {
207
+ fs.closeSync(stdoutFd);
208
+ fs.closeSync(stderrFd);
209
+ }
210
+ if (typeof child?.unref === "function") child.unref();
211
+ return {
212
+ pid: child.pid || null,
213
+ stdoutPath: artifacts.worker_stdout_path,
214
+ stderrPath: artifacts.worker_stderr_path
215
+ };
216
+ }
217
+
218
+ export async function scheduleRecommendPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
219
+ const runArgs = stripScheduleArgs(args);
220
+ const prepared = prepareRecommendPipelineRunTool({ workspaceRoot, args: runArgs });
221
+ if (prepared.status !== "READY" || prepared.cron_ready !== true) {
222
+ return {
223
+ ...prepared,
224
+ status: prepared.status || "FAILED",
225
+ schedule_created: false,
226
+ cron_ready: false,
227
+ message: "Recommend schedule was not created because the run payload is not READY."
228
+ };
229
+ }
230
+
231
+ const due = parseRunAt(args);
232
+ if (!due.ok) {
233
+ return {
234
+ status: "FAILED",
235
+ schedule_created: false,
236
+ cron_ready: true,
237
+ error: {
238
+ code: "INVALID_SCHEDULE_TIME",
239
+ message: due.error,
240
+ retryable: false
241
+ },
242
+ prepare: prepared
243
+ };
244
+ }
245
+
246
+ const scheduleId = createScheduleId(args.schedule_id || args.scheduleId);
247
+ const artifacts = getScheduleArtifacts(scheduleId);
248
+ const runAtIso = new Date(due.runAtMs).toISOString();
249
+ const createdAt = nowIso();
250
+ try {
251
+ writeJsonAtomic(artifacts.schedule_path, {
252
+ schedule_id: scheduleId,
253
+ state: "scheduled",
254
+ status: "scheduled",
255
+ created_at: createdAt,
256
+ updated_at: createdAt,
257
+ run_at: runAtIso,
258
+ run_at_ms: due.runAtMs,
259
+ time_source: due.source,
260
+ workspace_root: path.resolve(workspaceRoot || process.cwd()),
261
+ args: runArgs,
262
+ prepare: prepared,
263
+ worker_stdout_path: artifacts.worker_stdout_path,
264
+ worker_stderr_path: artifacts.worker_stderr_path,
265
+ pid: null,
266
+ run_id: null,
267
+ run: null,
268
+ error: null
269
+ });
270
+ } catch (error) {
271
+ return buildFailedSchedulePayload(error, { prepare: prepared });
272
+ }
273
+
274
+ let worker;
275
+ try {
276
+ worker = launchScheduleWorker(scheduleId);
277
+ } catch (error) {
278
+ writeSchedule(scheduleId, {
279
+ state: "failed",
280
+ status: "failed",
281
+ error: {
282
+ code: "SCHEDULE_WORKER_LAUNCH_FAILED",
283
+ message: error?.message || String(error || "Unable to launch schedule worker"),
284
+ retryable: true
285
+ }
286
+ });
287
+ return buildFailedSchedulePayload(error, {
288
+ schedule_created: true,
289
+ schedule_id: scheduleId,
290
+ schedule: readSchedule(scheduleId),
291
+ prepare: prepared
292
+ });
293
+ }
294
+
295
+ const schedule = writeSchedule(scheduleId, {
296
+ state: "scheduled",
297
+ status: "scheduled",
298
+ pid: worker.pid,
299
+ worker_stdout_path: worker.stdoutPath,
300
+ worker_stderr_path: worker.stderrPath
301
+ });
302
+
303
+ return {
304
+ status: "SCHEDULED",
305
+ schedule_created: true,
306
+ cron_ready: true,
307
+ schedule_id: scheduleId,
308
+ run_at: runAtIso,
309
+ run_at_ms: due.runAtMs,
310
+ worker_pid: worker.pid,
311
+ worker_stdout_path: worker.stdoutPath,
312
+ worker_stderr_path: worker.stderrPath,
313
+ schedule,
314
+ prepare: prepared,
315
+ message: "Recommend run schedule created. The package-owned detached scheduler will start the prepared payload at run_at."
316
+ };
317
+ }
318
+
319
+ export function getRecommendScheduledRunTool({ args = {} } = {}) {
320
+ const scheduleId = safeIdPart(args.schedule_id || args.scheduleId);
321
+ if (!scheduleId) {
322
+ return {
323
+ status: "FAILED",
324
+ error: {
325
+ code: "INVALID_SCHEDULE_ID",
326
+ message: "schedule_id is required",
327
+ retryable: false
328
+ }
329
+ };
330
+ }
331
+ const schedule = readSchedule(scheduleId);
332
+ if (!schedule) {
333
+ return {
334
+ status: "FAILED",
335
+ error: {
336
+ code: "SCHEDULE_NOT_FOUND",
337
+ message: `schedule_id=${scheduleId} not found`,
338
+ retryable: false
339
+ }
340
+ };
341
+ }
342
+ let next = schedule;
343
+ if (!TERMINAL_SCHEDULE_STATES.has(normalizeText(schedule.state || schedule.status)) && schedule.pid && !isProcessAlive(schedule.pid)) {
344
+ next = writeSchedule(scheduleId, {
345
+ state: "failed",
346
+ status: "failed",
347
+ completed_at: nowIso(),
348
+ error: {
349
+ code: "SCHEDULE_WORKER_EXITED",
350
+ message: `Scheduled worker process exited before reaching a terminal state (pid=${schedule.pid}).`,
351
+ retryable: true
352
+ }
353
+ });
354
+ }
355
+ return {
356
+ status: "OK",
357
+ schedule_id: scheduleId,
358
+ schedule: next
359
+ };
360
+ }
361
+
362
+ export async function runScheduledRecommendWorker({ scheduleId }) {
363
+ const normalizedScheduleId = safeIdPart(scheduleId);
364
+ if (!normalizedScheduleId) return { ok: false, error: "schedule_id is required" };
365
+ let schedule = readSchedule(normalizedScheduleId);
366
+ if (!schedule) return { ok: false, error: `schedule_id=${normalizedScheduleId} not found` };
367
+ const runAtMs = Number(schedule.run_at_ms);
368
+ if (!Number.isFinite(runAtMs)) {
369
+ writeSchedule(normalizedScheduleId, {
370
+ state: "failed",
371
+ status: "failed",
372
+ completed_at: nowIso(),
373
+ error: {
374
+ code: "INVALID_SCHEDULE_STATE",
375
+ message: "schedule is missing a valid run_at_ms",
376
+ retryable: false
377
+ }
378
+ });
379
+ return { ok: false, error: "schedule is missing a valid run_at_ms" };
380
+ }
381
+
382
+ schedule = writeSchedule(normalizedScheduleId, {
383
+ state: "waiting",
384
+ status: "waiting",
385
+ pid: process.pid,
386
+ worker_started_at: nowIso()
387
+ });
388
+
389
+ while (Date.now() < runAtMs) {
390
+ await sleep(Math.min(30_000, Math.max(50, runAtMs - Date.now())));
391
+ const latest = readSchedule(normalizedScheduleId);
392
+ if (normalizeText(latest?.state) === "canceled") return { ok: true, canceled: true };
393
+ }
394
+
395
+ writeSchedule(normalizedScheduleId, {
396
+ state: "launching",
397
+ status: "launching",
398
+ launch_started_at: nowIso()
399
+ });
400
+
401
+ const started = await startRecommendPipelineRunTool({
402
+ workspaceRoot: schedule.workspace_root,
403
+ args: clonePlain(schedule.args, {})
404
+ });
405
+ if (started.status !== "ACCEPTED") {
406
+ writeSchedule(normalizedScheduleId, {
407
+ state: "failed",
408
+ status: "failed",
409
+ completed_at: nowIso(),
410
+ launch_payload: started,
411
+ error: started.error || {
412
+ code: "RECOMMEND_START_NOT_ACCEPTED",
413
+ message: started.status || "start_recommend_pipeline_run did not return ACCEPTED",
414
+ retryable: true
415
+ }
416
+ });
417
+ return { ok: false, error: started.error?.message || started.status || "not accepted" };
418
+ }
419
+
420
+ writeSchedule(normalizedScheduleId, {
421
+ state: "running",
422
+ status: "running",
423
+ run_id: started.run_id,
424
+ run: started.run || null,
425
+ launch_payload: started,
426
+ launched_at: nowIso()
427
+ });
428
+
429
+ while (true) {
430
+ const payload = getRecommendPipelineRunTool({ args: { run_id: started.run_id } });
431
+ const runState = normalizeText(payload?.run?.state || payload?.run?.status);
432
+ writeSchedule(normalizedScheduleId, {
433
+ state: runState && TERMINAL_RUN_STATES.has(runState) ? runState : "running",
434
+ status: runState && TERMINAL_RUN_STATES.has(runState) ? runState : "running",
435
+ run_id: started.run_id,
436
+ run: payload?.run || null,
437
+ last_poll_at: nowIso(),
438
+ completed_at: runState && TERMINAL_RUN_STATES.has(runState) ? nowIso() : undefined,
439
+ error: runState === "failed" ? (payload?.run?.error || payload?.error || null) : null
440
+ });
441
+ if (TERMINAL_RUN_STATES.has(runState)) break;
442
+ await sleep(1000);
443
+ }
444
+ return { ok: true, run_id: started.run_id };
445
+ }
446
+
447
+ export function __setRecommendSchedulerSpawnForTests(nextImpl) {
448
+ spawnProcessImpl = typeof nextImpl === "function" ? nextImpl : spawn;
449
+ }
450
+
451
+ function parseScheduleWorkerOptions(argv = process.argv.slice(2)) {
452
+ if (!Array.isArray(argv) || !argv.includes(SCHEDULE_WORKER_FLAG)) return null;
453
+ const idIndex = argv.indexOf(SCHEDULE_ID_FLAG);
454
+ return {
455
+ scheduleId: idIndex >= 0 ? normalizeText(argv[idIndex + 1]) : ""
456
+ };
457
+ }
458
+
459
+ const thisFilePath = fileURLToPath(import.meta.url);
460
+ if (process.argv[1] && path.resolve(process.argv[1]) === thisFilePath) {
461
+ const options = parseScheduleWorkerOptions(process.argv.slice(2));
462
+ if (options) {
463
+ runScheduledRecommendWorker(options).then((result) => {
464
+ if (!result?.ok) process.exitCode = 1;
465
+ }).catch((error) => {
466
+ try {
467
+ writeSchedule(options.scheduleId, {
468
+ state: "failed",
469
+ status: "failed",
470
+ completed_at: nowIso(),
471
+ error: {
472
+ code: "SCHEDULE_WORKER_UNHANDLED_ERROR",
473
+ message: error?.message || String(error || "schedule worker failed"),
474
+ retryable: true
475
+ }
476
+ });
477
+ } catch {}
478
+ console.error("[boss-recommend-mcp] scheduled recommend worker failed", error);
479
+ process.exitCode = 1;
480
+ });
481
+ }
482
+ }