@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 +17 -1
- package/package.json +7 -6
- package/skills/boss-recommend-pipeline/SKILL.md +29 -8
- package/src/cli.js +99 -0
- package/src/index.js +65 -0
- package/src/recommend-scheduler.js +482 -0
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
|
-
|
|
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.
|
|
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`
|
|
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`
|
|
123
|
-
6.
|
|
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`
|
|
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`
|
|
177
|
-
3.
|
|
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.
|
|
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
|
+
}
|