@reconcrap/boss-recommend-mcp 1.1.2 → 1.1.4

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.
@@ -1,152 +1,152 @@
1
- import assert from "node:assert/strict";
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import {
6
- RUN_MODE_ASYNC,
7
- RUN_STATE_PAUSED,
8
- RUN_STAGE_SCREEN,
9
- RUN_STATE_COMPLETED,
10
- RUN_STATE_QUEUED,
11
- RUN_STATE_RUNNING,
12
- cleanupExpiredRuns,
13
- createRunId,
14
- createRunStateSnapshot,
15
- getRunsDir,
16
- readRunState,
17
- touchRunHeartbeat,
18
- updateRunProgress,
19
- updateRunState,
20
- writeRunState
21
- } from "./run-state.js";
22
-
23
- function withTempHome(testFn) {
24
- const previous = process.env.BOSS_RECOMMEND_HOME;
25
- const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-run-state-"));
26
- process.env.BOSS_RECOMMEND_HOME = tempHome;
27
- try {
28
- testFn(tempHome);
29
- } finally {
30
- if (previous === undefined) {
31
- delete process.env.BOSS_RECOMMEND_HOME;
32
- } else {
33
- process.env.BOSS_RECOMMEND_HOME = previous;
34
- }
35
- fs.rmSync(tempHome, { recursive: true, force: true });
36
- }
37
- }
38
-
39
- function testRunStateLifecycle() {
40
- withTempHome(() => {
41
- const runId = createRunId();
42
- const queued = writeRunState(createRunStateSnapshot({
43
- runId,
44
- mode: RUN_MODE_ASYNC,
45
- state: RUN_STATE_QUEUED,
46
- stage: "preflight",
47
- context: {
48
- workspace_root: "C:/workspace",
49
- instruction: "筛选有 MCP 经验候选人",
50
- confirmation: {
51
- final_confirmed: true
52
- }
53
- },
54
- control: {
55
- pause_requested: false
56
- },
57
- resume: {
58
- checkpoint_path: `C:/workspace/.state/${runId}.checkpoint.json`,
59
- pause_control_path: `C:/workspace/.state/${runId}.json`,
60
- output_csv: "C:/workspace/result.csv"
61
- }
62
- }));
63
- assert.equal(queued.run_id, runId);
64
- assert.equal(queued.state, RUN_STATE_QUEUED);
65
- assert.equal(queued.context.workspace_root, "C:/workspace");
66
- assert.equal(queued.resume.output_csv, "C:/workspace/result.csv");
67
- assert.equal(queued.control.cancel_requested, false);
68
-
69
- const running = updateRunState(runId, {
70
- state: RUN_STATE_RUNNING,
71
- stage: RUN_STAGE_SCREEN,
72
- last_message: "screening in progress"
73
- });
74
- assert.equal(running.state, RUN_STATE_RUNNING);
75
- assert.equal(running.stage, RUN_STAGE_SCREEN);
76
- const heartbeatBeforeProgress = running.heartbeat_at;
77
-
78
- const progressed = updateRunProgress(runId, {
79
- processed: 7,
80
- passed: 2,
81
- skipped: 5,
82
- greet_count: 1
83
- });
84
- assert.equal(progressed.progress.processed, 7);
85
- assert.equal(progressed.progress.passed, 2);
86
- assert.equal(progressed.progress.skipped, 5);
87
- assert.equal(progressed.progress.greet_count, 1);
88
- assert.equal(progressed.heartbeat_at, heartbeatBeforeProgress);
89
-
90
- const paused = updateRunState(runId, {
91
- state: RUN_STATE_PAUSED,
92
- control: {
93
- pause_requested: true,
94
- pause_requested_at: "2026-01-01T00:00:00.000Z",
95
- pause_requested_by: "pause_recommend_pipeline_run",
96
- cancel_requested: true
97
- },
98
- resume: {
99
- output_csv: "C:/workspace/result-partial.csv",
100
- last_paused_at: "2026-01-01T00:00:01.000Z"
101
- }
102
- });
103
- assert.equal(paused.state, RUN_STATE_PAUSED);
104
- assert.equal(paused.control.pause_requested, true);
105
- assert.equal(paused.control.pause_requested_by, "pause_recommend_pipeline_run");
106
- assert.equal(paused.control.cancel_requested, true);
107
- assert.equal(paused.resume.output_csv, "C:/workspace/result-partial.csv");
108
-
109
- const heartbeated = touchRunHeartbeat(runId, "still running");
110
- assert.equal(heartbeated.last_message, "still running");
111
- assert.equal(Date.parse(heartbeated.heartbeat_at) >= Date.parse(heartbeatBeforeProgress), true);
112
-
113
- const completed = updateRunState(runId, {
114
- state: RUN_STATE_COMPLETED,
115
- stage: "finalize",
116
- result: {
117
- status: "COMPLETED",
118
- result: {
119
- processed_count: 7
120
- }
121
- }
122
- });
123
- assert.equal(completed.state, RUN_STATE_COMPLETED);
124
- assert.equal(completed.result.status, "COMPLETED");
125
-
126
- const reloaded = readRunState(runId);
127
- assert.equal(reloaded.state, RUN_STATE_COMPLETED);
128
- assert.equal(reloaded.progress.processed, 7);
129
- });
130
- }
131
-
132
- function testRunStateCleanup() {
133
- withTempHome(() => {
134
- const runId = createRunId();
135
- writeRunState(createRunStateSnapshot({ runId, mode: RUN_MODE_ASYNC }));
136
- const runFile = path.join(getRunsDir(), `${runId}.json`);
137
- const oldSeconds = Math.floor((Date.now() - 3 * 24 * 60 * 60 * 1000) / 1000);
138
- fs.utimesSync(runFile, oldSeconds, oldSeconds);
139
-
140
- const cleaned = cleanupExpiredRuns(1000);
141
- assert.equal(cleaned.removed.some((item) => item.endsWith(`${runId}.json`)), true);
142
- assert.equal(fs.existsSync(runFile), false);
143
- });
144
- }
145
-
146
- function main() {
147
- testRunStateLifecycle();
148
- testRunStateCleanup();
149
- console.log("run-state tests passed");
150
- }
151
-
152
- main();
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ RUN_MODE_ASYNC,
7
+ RUN_STATE_PAUSED,
8
+ RUN_STAGE_SCREEN,
9
+ RUN_STATE_COMPLETED,
10
+ RUN_STATE_QUEUED,
11
+ RUN_STATE_RUNNING,
12
+ cleanupExpiredRuns,
13
+ createRunId,
14
+ createRunStateSnapshot,
15
+ getRunsDir,
16
+ readRunState,
17
+ touchRunHeartbeat,
18
+ updateRunProgress,
19
+ updateRunState,
20
+ writeRunState
21
+ } from "./run-state.js";
22
+
23
+ function withTempHome(testFn) {
24
+ const previous = process.env.BOSS_RECOMMEND_HOME;
25
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-run-state-"));
26
+ process.env.BOSS_RECOMMEND_HOME = tempHome;
27
+ try {
28
+ testFn(tempHome);
29
+ } finally {
30
+ if (previous === undefined) {
31
+ delete process.env.BOSS_RECOMMEND_HOME;
32
+ } else {
33
+ process.env.BOSS_RECOMMEND_HOME = previous;
34
+ }
35
+ fs.rmSync(tempHome, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ function testRunStateLifecycle() {
40
+ withTempHome(() => {
41
+ const runId = createRunId();
42
+ const queued = writeRunState(createRunStateSnapshot({
43
+ runId,
44
+ mode: RUN_MODE_ASYNC,
45
+ state: RUN_STATE_QUEUED,
46
+ stage: "preflight",
47
+ context: {
48
+ workspace_root: "C:/workspace",
49
+ instruction: "筛选有 MCP 经验候选人",
50
+ confirmation: {
51
+ final_confirmed: true
52
+ }
53
+ },
54
+ control: {
55
+ pause_requested: false
56
+ },
57
+ resume: {
58
+ checkpoint_path: `C:/workspace/.state/${runId}.checkpoint.json`,
59
+ pause_control_path: `C:/workspace/.state/${runId}.json`,
60
+ output_csv: "C:/workspace/result.csv"
61
+ }
62
+ }));
63
+ assert.equal(queued.run_id, runId);
64
+ assert.equal(queued.state, RUN_STATE_QUEUED);
65
+ assert.equal(queued.context.workspace_root, "C:/workspace");
66
+ assert.equal(queued.resume.output_csv, "C:/workspace/result.csv");
67
+ assert.equal(queued.control.cancel_requested, false);
68
+
69
+ const running = updateRunState(runId, {
70
+ state: RUN_STATE_RUNNING,
71
+ stage: RUN_STAGE_SCREEN,
72
+ last_message: "screening in progress"
73
+ });
74
+ assert.equal(running.state, RUN_STATE_RUNNING);
75
+ assert.equal(running.stage, RUN_STAGE_SCREEN);
76
+ const heartbeatBeforeProgress = running.heartbeat_at;
77
+
78
+ const progressed = updateRunProgress(runId, {
79
+ processed: 7,
80
+ passed: 2,
81
+ skipped: 5,
82
+ greet_count: 1
83
+ });
84
+ assert.equal(progressed.progress.processed, 7);
85
+ assert.equal(progressed.progress.passed, 2);
86
+ assert.equal(progressed.progress.skipped, 5);
87
+ assert.equal(progressed.progress.greet_count, 1);
88
+ assert.equal(progressed.heartbeat_at, heartbeatBeforeProgress);
89
+
90
+ const paused = updateRunState(runId, {
91
+ state: RUN_STATE_PAUSED,
92
+ control: {
93
+ pause_requested: true,
94
+ pause_requested_at: "2026-01-01T00:00:00.000Z",
95
+ pause_requested_by: "pause_recommend_pipeline_run",
96
+ cancel_requested: true
97
+ },
98
+ resume: {
99
+ output_csv: "C:/workspace/result-partial.csv",
100
+ last_paused_at: "2026-01-01T00:00:01.000Z"
101
+ }
102
+ });
103
+ assert.equal(paused.state, RUN_STATE_PAUSED);
104
+ assert.equal(paused.control.pause_requested, true);
105
+ assert.equal(paused.control.pause_requested_by, "pause_recommend_pipeline_run");
106
+ assert.equal(paused.control.cancel_requested, true);
107
+ assert.equal(paused.resume.output_csv, "C:/workspace/result-partial.csv");
108
+
109
+ const heartbeated = touchRunHeartbeat(runId, "still running");
110
+ assert.equal(heartbeated.last_message, "still running");
111
+ assert.equal(Date.parse(heartbeated.heartbeat_at) >= Date.parse(heartbeatBeforeProgress), true);
112
+
113
+ const completed = updateRunState(runId, {
114
+ state: RUN_STATE_COMPLETED,
115
+ stage: "finalize",
116
+ result: {
117
+ status: "COMPLETED",
118
+ result: {
119
+ processed_count: 7
120
+ }
121
+ }
122
+ });
123
+ assert.equal(completed.state, RUN_STATE_COMPLETED);
124
+ assert.equal(completed.result.status, "COMPLETED");
125
+
126
+ const reloaded = readRunState(runId);
127
+ assert.equal(reloaded.state, RUN_STATE_COMPLETED);
128
+ assert.equal(reloaded.progress.processed, 7);
129
+ });
130
+ }
131
+
132
+ function testRunStateCleanup() {
133
+ withTempHome(() => {
134
+ const runId = createRunId();
135
+ writeRunState(createRunStateSnapshot({ runId, mode: RUN_MODE_ASYNC }));
136
+ const runFile = path.join(getRunsDir(), `${runId}.json`);
137
+ const oldSeconds = Math.floor((Date.now() - 3 * 24 * 60 * 60 * 1000) / 1000);
138
+ fs.utimesSync(runFile, oldSeconds, oldSeconds);
139
+
140
+ const cleaned = cleanupExpiredRuns(1000);
141
+ assert.equal(cleaned.removed.some((item) => item.endsWith(`${runId}.json`)), true);
142
+ assert.equal(fs.existsSync(runFile), false);
143
+ });
144
+ }
145
+
146
+ function main() {
147
+ testRunStateLifecycle();
148
+ testRunStateCleanup();
149
+ console.log("run-state tests passed");
150
+ }
151
+
152
+ main();
@@ -12,6 +12,7 @@ const CSV_HEADER = ["姓名", "最高学历学校", "最高学历专业", "最
12
12
  const RESUME_CAPTURE_WAIT_MS = 60000;
13
13
  const RESUME_CAPTURE_MAX_ATTEMPTS = 4;
14
14
  const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
15
+ const MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES = 10;
15
16
 
16
17
  function log(...args) {
17
18
  console.error(...args);
@@ -31,6 +32,9 @@ function normalizePostAction(value) {
31
32
  if (!normalized) return null;
32
33
  if (["favorite", "fav", "收藏"].includes(normalized)) return "favorite";
33
34
  if (["greet", "chat", "打招呼", "直接沟通", "沟通"].includes(normalized)) return "greet";
35
+ if (["none", "noop", "no-op", "什么也不做", "不做任何操作", "不操作", "仅筛选", "只筛选"].includes(normalized)) {
36
+ return "none";
37
+ }
34
38
  return null;
35
39
  }
36
40
 
@@ -210,10 +214,11 @@ async function promptMissingInputs(args) {
210
214
  if (!(args.postActionConfirmed === true && args.postAction)) {
211
215
  args.postAction = await askWithValidation(
212
216
  ask,
213
- "本次通过人选统一执行什么动作?请输入 1(收藏) 2(直接沟通): ",
217
+ "本次通过人选统一执行什么动作?请输入 1(收藏) / 2(直接沟通) / 3(什么也不做): ",
214
218
  (value) => {
215
219
  if (value === "1") return "favorite";
216
220
  if (value === "2") return "greet";
221
+ if (value === "3") return "none";
217
222
  return null;
218
223
  }
219
224
  );
@@ -289,9 +294,10 @@ async function promptPostAction() {
289
294
  });
290
295
  const ask = (question) => new Promise((resolve) => rl.question(question, resolve));
291
296
  try {
292
- const answer = normalizeText(await ask("本次通过人选统一执行什么动作?请输入 1(收藏) 2(直接沟通): "));
297
+ const answer = normalizeText(await ask("本次通过人选统一执行什么动作?请输入 1(收藏) / 2(直接沟通) / 3(什么也不做): "));
293
298
  if (answer === "1") return "favorite";
294
299
  if (answer === "2") return "greet";
300
+ if (answer === "3") return "none";
295
301
  throw new Error("INVALID_POST_ACTION_CONFIRMATION");
296
302
  } finally {
297
303
  rl.close();
@@ -1048,6 +1054,8 @@ class RecommendScreenCli {
1048
1054
  this.skippedCount = 0;
1049
1055
  this.greetCount = 0;
1050
1056
  this.greetLimitFallbackCount = 0;
1057
+ this.consecutiveResumeCaptureFailures = 0;
1058
+ this.resumeCaptureFailureStreakKeys = [];
1051
1059
  this.restCounter = 0;
1052
1060
  this.restThreshold = 25 + Math.floor(Math.random() * 8);
1053
1061
  this.checkpointPath = this.args.checkpointPath ? path.resolve(this.args.checkpointPath) : null;
@@ -1105,6 +1113,49 @@ class RecommendScreenCli {
1105
1113
  };
1106
1114
  }
1107
1115
 
1116
+ resetResumeCaptureFailureStreak() {
1117
+ this.consecutiveResumeCaptureFailures = 0;
1118
+ this.resumeCaptureFailureStreakKeys = [];
1119
+ }
1120
+
1121
+ recordResumeCaptureFailure(candidateKey) {
1122
+ this.consecutiveResumeCaptureFailures += 1;
1123
+ if (candidateKey) {
1124
+ this.resumeCaptureFailureStreakKeys.push(candidateKey);
1125
+ }
1126
+ }
1127
+
1128
+ rollbackResumeCaptureFailureStreak(currentCandidateKey = null) {
1129
+ const streakKeys = Array.from(new Set([
1130
+ ...this.resumeCaptureFailureStreakKeys,
1131
+ ...(currentCandidateKey ? [currentCandidateKey] : [])
1132
+ ].filter(Boolean)));
1133
+ const rollbackCount = streakKeys.length;
1134
+ if (rollbackCount <= 0) {
1135
+ this.resetResumeCaptureFailureStreak();
1136
+ return {
1137
+ rollback_count: 0,
1138
+ processed_count: this.processedCount,
1139
+ skipped_count: this.skippedCount,
1140
+ rolled_back_keys: []
1141
+ };
1142
+ }
1143
+
1144
+ this.processedCount = Math.max(0, this.processedCount - rollbackCount);
1145
+ this.skippedCount = Math.max(0, this.skippedCount - rollbackCount);
1146
+ for (const key of streakKeys) {
1147
+ this.processedKeys.delete(key);
1148
+ this.discoveredKeys.delete(key);
1149
+ }
1150
+ this.resetResumeCaptureFailureStreak();
1151
+ return {
1152
+ rollback_count: rollbackCount,
1153
+ processed_count: this.processedCount,
1154
+ skipped_count: this.skippedCount,
1155
+ rolled_back_keys: streakKeys
1156
+ };
1157
+ }
1158
+
1108
1159
  saveCheckpoint() {
1109
1160
  if (!this.checkpointPath) return;
1110
1161
  const payload = this.buildCheckpointPayload();
@@ -1930,6 +1981,7 @@ class RecommendScreenCli {
1930
1981
  this.scrollRetryCount = 0;
1931
1982
  this.processedCount += 1;
1932
1983
  log(`处理第 ${this.processedCount} 位候选人: ${nextCandidate.name || nextCandidate.geek_id}`);
1984
+ let shouldMarkProcessed = true;
1933
1985
 
1934
1986
  try {
1935
1987
  await this.clickCandidate(nextCandidate);
@@ -1939,6 +1991,7 @@ class RecommendScreenCli {
1939
1991
  }
1940
1992
 
1941
1993
  const capture = await this.captureResumeImage(nextCandidate);
1994
+ this.resetResumeCaptureFailureStreak();
1942
1995
  const screening = await this.callVisionModel(capture.stitchedImage);
1943
1996
  log(`筛选结果: ${screening.passed ? "通过" : "不通过"}`);
1944
1997
 
@@ -1955,7 +2008,9 @@ class RecommendScreenCli {
1955
2008
  }
1956
2009
  const actionResult = effectiveAction === "favorite"
1957
2010
  ? await this.favoriteCandidate()
1958
- : await this.greetCandidate();
2011
+ : effectiveAction === "greet"
2012
+ ? await this.greetCandidate()
2013
+ : { actionTaken: "none" };
1959
2014
  if (actionResult.actionTaken === "greet") {
1960
2015
  this.greetCount += 1;
1961
2016
  }
@@ -1977,7 +2032,30 @@ class RecommendScreenCli {
1977
2032
  } catch (error) {
1978
2033
  this.skippedCount += 1;
1979
2034
  log(`候选人处理失败: ${error.code || error.message}`);
1980
- if (["RESUME_CAPTURE_FAILED", "VISION_MODEL_FAILED"].includes(error.code)) {
2035
+ if (error.code === "RESUME_CAPTURE_FAILED") {
2036
+ this.recordResumeCaptureFailure(nextCandidate.key);
2037
+ log(
2038
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 简历截图失败,` +
2039
+ `已跳过当前候选人;连续失败 ${this.consecutiveResumeCaptureFailures}/${MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES}`
2040
+ );
2041
+ if (this.consecutiveResumeCaptureFailures >= MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES) {
2042
+ shouldMarkProcessed = false;
2043
+ const rollback = this.rollbackResumeCaptureFailureStreak(nextCandidate.key);
2044
+ throw this.buildError(
2045
+ "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT",
2046
+ `连续 ${MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES} 位候选人简历捕获失败,已停止运行以避免错误跳过。` +
2047
+ `已回滚这 ${rollback.rollback_count} 个失败样本的计数;最后错误: ${error.message || error}`,
2048
+ true,
2049
+ {
2050
+ cause_code: error.code,
2051
+ rollback
2052
+ }
2053
+ );
2054
+ }
2055
+ } else {
2056
+ this.resetResumeCaptureFailureStreak();
2057
+ }
2058
+ if (["VISION_MODEL_FAILED"].includes(error.code)) {
1981
2059
  throw error;
1982
2060
  }
1983
2061
  } finally {
@@ -1985,7 +2063,9 @@ class RecommendScreenCli {
1985
2063
  if (!closed) {
1986
2064
  throw this.buildError("DETAIL_CLOSE_FAILED", "详情页未能正确关闭");
1987
2065
  }
1988
- this.processedKeys.add(nextCandidate.key);
2066
+ if (shouldMarkProcessed) {
2067
+ this.processedKeys.add(nextCandidate.key);
2068
+ }
1989
2069
  }
1990
2070
 
1991
2071
  await this.takeBreakIfNeeded();
@@ -2050,7 +2130,7 @@ async function main() {
2050
2130
  console.log(JSON.stringify({
2051
2131
  status: "COMPLETED",
2052
2132
  result: {
2053
- usage: "node boss-recommend-screen-cli.cjs --criteria \"有 MCP 开发经验\" --post-action greet --max-greet-count 10 --post-action-confirmed true --baseurl <url> --apikey <key> --model <model> --port 9222 --output <csv-path> --checkpoint-path <checkpoint.json> --pause-control-path <pause-control.json> [--resume]"
2133
+ usage: "node boss-recommend-screen-cli.cjs --criteria \"有 MCP 开发经验\" --post-action <favorite|greet|none> --max-greet-count 10 --post-action-confirmed true --baseurl <url> --apikey <key> --model <model> --port 9222 --output <csv-path> --checkpoint-path <checkpoint.json> --pause-control-path <pause-control.json> [--resume]"
2054
2134
  }
2055
2135
  }));
2056
2136
  return;
@@ -2062,17 +2142,30 @@ async function main() {
2062
2142
  console.log(JSON.stringify(result));
2063
2143
  }
2064
2144
 
2065
- main().catch((error) => {
2066
- const payload = {
2067
- status: "FAILED",
2068
- error: {
2069
- code: error.code || "RECOMMEND_SCREEN_FAILED",
2070
- message: error.message || "推荐页筛选执行失败。",
2071
- retryable: error.retryable !== false
2072
- },
2073
- result: error.partial_result || null
2145
+ if (require.main === module) {
2146
+ main().catch((error) => {
2147
+ const payload = {
2148
+ status: "FAILED",
2149
+ error: {
2150
+ code: error.code || "RECOMMEND_SCREEN_FAILED",
2151
+ message: error.message || "推荐页筛选执行失败。",
2152
+ retryable: error.retryable !== false
2153
+ },
2154
+ result: error.partial_result || null
2155
+ };
2156
+ console.log(JSON.stringify(payload));
2157
+ process.exitCode = 1;
2158
+ });
2159
+ } else {
2160
+ module.exports = {
2161
+ RecommendScreenCli,
2162
+ parseArgs,
2163
+ promptMissingInputs,
2164
+ __testables: {
2165
+ MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES,
2166
+ RESUME_CAPTURE_MAX_ATTEMPTS,
2167
+ RESUME_CAPTURE_WAIT_MS
2168
+ }
2074
2169
  };
2075
- console.log(JSON.stringify(payload));
2076
- process.exitCode = 1;
2077
- });
2170
+ }
2078
2171