@reconcrap/boss-recommend-mcp 1.0.19 → 1.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.0.19",
3
+ "version": "1.1.0",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/adapters.js CHANGED
@@ -1599,6 +1599,38 @@ export async function runRecommendScreenCli({ workspaceRoot, screenParams, resum
1599
1599
  const pauseControlPath = normalizeText(resume?.pause_control_path || "")
1600
1600
  ? path.resolve(String(resume.pause_control_path))
1601
1601
  : null;
1602
+ const resumeRequested = resume?.resume === true;
1603
+ const requireCheckpoint = resume?.require_checkpoint === true;
1604
+ if (resumeRequested && requireCheckpoint) {
1605
+ if (!checkpointPath) {
1606
+ return {
1607
+ ok: false,
1608
+ paused: false,
1609
+ stdout: "",
1610
+ stderr: "",
1611
+ structured: null,
1612
+ summary: null,
1613
+ error: {
1614
+ code: "RESUME_CHECKPOINT_MISSING",
1615
+ message: "恢复执行缺少 checkpoint_path,无法从上次进度继续。"
1616
+ }
1617
+ };
1618
+ }
1619
+ if (!fs.existsSync(checkpointPath)) {
1620
+ return {
1621
+ ok: false,
1622
+ paused: false,
1623
+ stdout: "",
1624
+ stderr: "",
1625
+ structured: null,
1626
+ summary: null,
1627
+ error: {
1628
+ code: "RESUME_CHECKPOINT_MISSING",
1629
+ message: `恢复执行未找到 checkpoint 文件:${checkpointPath}`
1630
+ }
1631
+ };
1632
+ }
1633
+ }
1602
1634
 
1603
1635
  const cliPath = resolveRecommendScreenCliEntry(screenDir);
1604
1636
  const args = [
@@ -1641,7 +1673,7 @@ export async function runRecommendScreenCli({ workspaceRoot, screenParams, resum
1641
1673
  if (pauseControlPath) {
1642
1674
  args.push("--pause-control-path", pauseControlPath);
1643
1675
  }
1644
- if (resume?.resume === true) {
1676
+ if (resumeRequested) {
1645
1677
  args.push("--resume");
1646
1678
  }
1647
1679
 
package/src/index.js CHANGED
@@ -110,6 +110,14 @@ function getOutputCsvFromResult(result) {
110
110
  return null;
111
111
  }
112
112
 
113
+ function getCompletionReasonFromResult(result) {
114
+ const direct = normalizeText(result?.result?.completion_reason || "");
115
+ if (direct) return direct;
116
+ const partial = normalizeText(result?.partial_result?.completion_reason || "");
117
+ if (partial) return partial;
118
+ return null;
119
+ }
120
+
113
121
  function writeMessage(message, framing = FRAMING_LINE) {
114
122
  const body = JSON.stringify(message);
115
123
  if (framing === FRAMING_HEADER) {
@@ -496,7 +504,8 @@ async function executeTrackedPipeline({
496
504
  resume: resumeRun === true,
497
505
  checkpoint_path: normalizeText(existingSnapshot?.resume?.checkpoint_path || artifacts.checkpoint_path),
498
506
  pause_control_path: normalizeText(existingSnapshot?.resume?.pause_control_path || artifacts.run_state_path),
499
- output_csv: normalizeText(existingSnapshot?.resume?.output_csv || "") || null
507
+ output_csv: normalizeText(existingSnapshot?.resume?.output_csv || "") || null,
508
+ previous_completion_reason: getCompletionReasonFromResult(existingSnapshot?.result || null)
500
509
  };
501
510
  safeUpdateRunState(runId, {
502
511
  state: RUN_STATE_RUNNING,
package/src/pipeline.js CHANGED
@@ -805,90 +805,102 @@ export async function runRecommendPipeline(
805
805
  };
806
806
  }
807
807
 
808
- ensurePipelineNotAborted(runtimeHooks.signal);
809
- runtimeHooks.setStage("search", "岗位已确认,开始执行 recommend search。");
810
- runtimeHooks.heartbeat("search");
811
- const searchResult = await searchCli({
812
- workspaceRoot,
813
- searchParams: parsed.searchParams,
814
- selectedJob: selectedJobToken,
815
- runtime: runtimeHooks.adapterRuntime("search")
816
- });
817
- ensurePipelineNotAborted(runtimeHooks.signal);
818
- if (isProcessAbortError(searchResult.error)) {
819
- throw new PipelineAbortError(searchResult.error?.message || "推荐筛选已取消。");
820
- }
821
- if (!searchResult.ok) {
822
- const searchErrorCode = String(searchResult.error?.code || "");
823
- const searchErrorMessage = String(searchResult.error?.message || "");
824
- const loginRelatedSearchFailure = (
825
- searchErrorCode === "LOGIN_REQUIRED"
826
- || searchErrorCode === "NO_RECOMMEND_IFRAME"
827
- || searchErrorMessage.includes("LOGIN_REQUIRED")
828
- || searchErrorMessage.includes("NO_RECOMMEND_IFRAME")
829
- );
830
- if (loginRelatedSearchFailure) {
831
- const recheck = await ensureRecommendPageReady(workspaceRoot, {
832
- port: preflight.debug_port
833
- });
834
- if (recheck.state === "LOGIN_REQUIRED" || recheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT") {
835
- const guidance = buildChromeSetupGuidance({
836
- debugPort: preflight.debug_port,
837
- pageState: recheck.page_state
808
+ const resumeCompletionReason = normalizeText(resume?.previous_completion_reason || "").toLowerCase();
809
+ const isResumeRun = resume?.resume === true;
810
+ const resumeFromPausedBeforeScreen = isResumeRun && resumeCompletionReason === "paused_before_screen";
811
+ const skipSearchOnResume = isResumeRun && !resumeFromPausedBeforeScreen;
812
+ let searchSummary = null;
813
+
814
+ if (!skipSearchOnResume) {
815
+ ensurePipelineNotAborted(runtimeHooks.signal);
816
+ runtimeHooks.setStage("search", "岗位已确认,开始执行 recommend search。");
817
+ runtimeHooks.heartbeat("search");
818
+ const searchResult = await searchCli({
819
+ workspaceRoot,
820
+ searchParams: parsed.searchParams,
821
+ selectedJob: selectedJobToken,
822
+ runtime: runtimeHooks.adapterRuntime("search")
823
+ });
824
+ ensurePipelineNotAborted(runtimeHooks.signal);
825
+ if (isProcessAbortError(searchResult.error)) {
826
+ throw new PipelineAbortError(searchResult.error?.message || "推荐筛选已取消。");
827
+ }
828
+ if (!searchResult.ok) {
829
+ const searchErrorCode = String(searchResult.error?.code || "");
830
+ const searchErrorMessage = String(searchResult.error?.message || "");
831
+ const loginRelatedSearchFailure = (
832
+ searchErrorCode === "LOGIN_REQUIRED"
833
+ || searchErrorCode === "NO_RECOMMEND_IFRAME"
834
+ || searchErrorMessage.includes("LOGIN_REQUIRED")
835
+ || searchErrorMessage.includes("NO_RECOMMEND_IFRAME")
836
+ );
837
+ if (loginRelatedSearchFailure) {
838
+ const recheck = await ensureRecommendPageReady(workspaceRoot, {
839
+ port: preflight.debug_port
838
840
  });
839
- return buildFailedResponse(
840
- "BOSS_LOGIN_REQUIRED",
841
- "检测到当前 Boss 处于未登录状态,请先登录后再继续。登录页:https://www.zhipin.com/web/user/?ka=bticket",
842
- {
843
- search_params: parsed.searchParams,
844
- screen_params: parsed.screenParams,
845
- selected_job: selectedJob,
846
- required_user_action: "prepare_boss_recommend_page",
847
- guidance,
848
- diagnostics: {
849
- debug_port: preflight.debug_port,
850
- page_state: recheck.page_state,
851
- stdout: searchResult.stdout?.slice(-1000),
852
- stderr: searchResult.stderr?.slice(-1000),
853
- result: searchResult.structured || null
841
+ if (recheck.state === "LOGIN_REQUIRED" || recheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT") {
842
+ const guidance = buildChromeSetupGuidance({
843
+ debugPort: preflight.debug_port,
844
+ pageState: recheck.page_state
845
+ });
846
+ return buildFailedResponse(
847
+ "BOSS_LOGIN_REQUIRED",
848
+ "检测到当前 Boss 处于未登录状态,请先登录后再继续。登录页:https://www.zhipin.com/web/user/?ka=bticket",
849
+ {
850
+ search_params: parsed.searchParams,
851
+ screen_params: parsed.screenParams,
852
+ selected_job: selectedJob,
853
+ required_user_action: "prepare_boss_recommend_page",
854
+ guidance,
855
+ diagnostics: {
856
+ debug_port: preflight.debug_port,
857
+ page_state: recheck.page_state,
858
+ stdout: searchResult.stdout?.slice(-1000),
859
+ stderr: searchResult.stderr?.slice(-1000),
860
+ result: searchResult.structured || null
861
+ }
854
862
  }
855
- }
856
- );
863
+ );
864
+ }
857
865
  }
866
+ return buildFailedResponse(
867
+ searchResult.error?.code || "RECOMMEND_SEARCH_FAILED",
868
+ searchResult.error?.message || "推荐页筛选执行失败。",
869
+ {
870
+ search_params: parsed.searchParams,
871
+ screen_params: parsed.screenParams,
872
+ selected_job: selectedJob,
873
+ diagnostics: {
874
+ debug_port: preflight.debug_port,
875
+ stdout: searchResult.stdout?.slice(-1000),
876
+ stderr: searchResult.stderr?.slice(-1000),
877
+ result: searchResult.structured || null
878
+ }
879
+ }
880
+ );
858
881
  }
859
- return buildFailedResponse(
860
- searchResult.error?.code || "RECOMMEND_SEARCH_FAILED",
861
- searchResult.error?.message || "推荐页筛选执行失败。",
862
- {
882
+
883
+ searchSummary = searchResult.summary || {};
884
+ if (isPauseRequested(runtimeHooks)) {
885
+ return buildPausedResponse("已在 screen 阶段开始前暂停 Recommend 流水线。", {
863
886
  search_params: parsed.searchParams,
864
887
  screen_params: parsed.screenParams,
865
888
  selected_job: selectedJob,
866
- diagnostics: {
867
- debug_port: preflight.debug_port,
868
- stdout: searchResult.stdout?.slice(-1000),
869
- stderr: searchResult.stderr?.slice(-1000),
870
- result: searchResult.structured || null
871
- }
872
- }
873
- );
874
- }
875
-
876
- if (isPauseRequested(runtimeHooks)) {
877
- return buildPausedResponse("已在 screen 阶段开始前暂停 Recommend 流水线。", {
878
- search_params: parsed.searchParams,
879
- screen_params: parsed.screenParams,
880
- selected_job: selectedJob,
881
- partial_result: {
882
- candidate_count: searchResult.summary?.candidate_count ?? null,
883
- applied_filters: searchResult.summary?.applied_filters || parsed.searchParams,
884
- output_csv: resume?.output_csv || null,
885
- completion_reason: "paused_before_screen"
886
- }
887
- });
889
+ partial_result: {
890
+ candidate_count: searchSummary.candidate_count ?? null,
891
+ applied_filters: searchSummary.applied_filters || parsed.searchParams,
892
+ output_csv: resume?.output_csv || null,
893
+ completion_reason: "paused_before_screen"
894
+ }
895
+ });
896
+ }
897
+ ensurePipelineNotAborted(runtimeHooks.signal);
898
+ runtimeHooks.setStage("screen", "search 完成,开始执行 recommend screen。");
899
+ } else {
900
+ ensurePipelineNotAborted(runtimeHooks.signal);
901
+ runtimeHooks.setStage("screen", "检测到可续跑 checkpoint,跳过 search,直接恢复 recommend screen。");
888
902
  }
889
903
 
890
- ensurePipelineNotAborted(runtimeHooks.signal);
891
- runtimeHooks.setStage("screen", "search 完成,开始执行 recommend screen。");
892
904
  runtimeHooks.heartbeat("screen");
893
905
  const screenResult = await screenCli({
894
906
  workspaceRoot,
@@ -897,7 +909,8 @@ export async function runRecommendPipeline(
897
909
  checkpoint_path: resume?.checkpoint_path || null,
898
910
  pause_control_path: resume?.pause_control_path || null,
899
911
  output_csv: resume?.output_csv || null,
900
- resume: resume?.resume === true
912
+ resume: resume?.resume === true,
913
+ require_checkpoint: skipSearchOnResume
901
914
  },
902
915
  runtime: runtimeHooks.adapterRuntime("screen")
903
916
  });
@@ -936,7 +949,7 @@ export async function runRecommendPipeline(
936
949
  runtimeHooks.setStage("finalize", "screen 完成,正在汇总结果。");
937
950
  runtimeHooks.heartbeat("finalize");
938
951
  const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
939
- const searchSummary = searchResult.summary || {};
952
+ const finalSearchSummary = searchSummary || {};
940
953
  const screenSummary = screenResult.summary || {};
941
954
  runtimeHooks.progress("finalize", {
942
955
  processed: screenSummary.processed_count ?? 0,
@@ -949,17 +962,17 @@ export async function runRecommendPipeline(
949
962
  status: "COMPLETED",
950
963
  search_params: parsed.searchParams,
951
964
  screen_params: parsed.screenParams,
952
- result: {
953
- candidate_count: searchSummary.candidate_count ?? null,
954
- applied_filters: searchSummary.applied_filters || parsed.searchParams,
955
- processed_count: screenSummary.processed_count ?? 0,
956
- passed_count: screenSummary.passed_count ?? 0,
965
+ result: {
966
+ candidate_count: finalSearchSummary.candidate_count ?? null,
967
+ applied_filters: finalSearchSummary.applied_filters || parsed.searchParams,
968
+ processed_count: screenSummary.processed_count ?? 0,
969
+ passed_count: screenSummary.passed_count ?? 0,
957
970
  skipped_count: screenSummary.skipped_count ?? 0,
958
971
  duration_sec: durationSec,
959
972
  output_csv: screenSummary.output_csv || null,
960
973
  completion_reason: screenSummary.completion_reason || "screen_completed",
961
- page_state: searchSummary.page_state || pageCheck.page_state,
962
- selected_job: searchSummary.selected_job || selectedJob,
974
+ page_state: finalSearchSummary.page_state || pageCheck.page_state,
975
+ selected_job: finalSearchSummary.selected_job || selectedJob,
963
976
  post_action: parsed.screenParams.post_action,
964
977
  max_greet_count: parsed.screenParams.max_greet_count,
965
978
  greet_count: screenSummary.greet_count ?? 0,
@@ -1,5 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
- import { __testables as adapterTestables } from "./adapters.js";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { runRecommendScreenCli, __testables as adapterTestables } from "./adapters.js";
3
6
 
4
7
  const { runProcess, parseJsonOutput } = adapterTestables;
5
8
 
@@ -54,10 +57,53 @@ function testParsePausedStructuredOutput() {
54
57
  assert.equal(parsed?.result?.processed_count, 3);
55
58
  }
56
59
 
60
+ async function testResumeRequiresCheckpointFile() {
61
+ const previousHome = process.env.BOSS_RECOMMEND_HOME;
62
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-resume-"));
63
+ process.env.BOSS_RECOMMEND_HOME = tempHome;
64
+ try {
65
+ const configPath = path.join(tempHome, "screening-config.json");
66
+ fs.writeFileSync(configPath, JSON.stringify({
67
+ baseUrl: "https://api.openai.com/v1",
68
+ apiKey: "sk-test-valid",
69
+ model: "gpt-4.1-mini"
70
+ }, null, 2));
71
+
72
+ const missingCheckpoint = path.join(tempHome, "missing-checkpoint.json");
73
+ const result = await runRecommendScreenCli({
74
+ workspaceRoot: process.cwd(),
75
+ screenParams: {
76
+ criteria: "有MCP经验",
77
+ target_count: 10,
78
+ post_action: "favorite",
79
+ max_greet_count: null
80
+ },
81
+ resume: {
82
+ resume: true,
83
+ require_checkpoint: true,
84
+ checkpoint_path: missingCheckpoint,
85
+ pause_control_path: path.join(tempHome, "run-state.json"),
86
+ output_csv: path.join(tempHome, "resume.csv")
87
+ }
88
+ });
89
+
90
+ assert.equal(result.ok, false);
91
+ assert.equal(result.error?.code, "RESUME_CHECKPOINT_MISSING");
92
+ } finally {
93
+ if (previousHome === undefined) {
94
+ delete process.env.BOSS_RECOMMEND_HOME;
95
+ } else {
96
+ process.env.BOSS_RECOMMEND_HOME = previousHome;
97
+ }
98
+ fs.rmSync(tempHome, { recursive: true, force: true });
99
+ }
100
+ }
101
+
57
102
  async function main() {
58
103
  await testRunProcessHeartbeatAndOutput();
59
104
  await testRunProcessAbortSignal();
60
105
  testParsePausedStructuredOutput();
106
+ await testResumeRequiresCheckpointFile();
61
107
  console.log("adapters runtime tests passed");
62
108
  }
63
109
 
@@ -154,6 +154,104 @@ async function testPausedScreenResultShouldBubbleUp() {
154
154
  assert.equal(result.partial_result.completion_reason, "paused");
155
155
  }
156
156
 
157
+ async function testResumeFromScreenPauseShouldSkipSearch() {
158
+ let searchCalled = false;
159
+ let receivedResume = null;
160
+ const result = await runRecommendPipeline(
161
+ {
162
+ workspaceRoot: process.cwd(),
163
+ instruction: "test",
164
+ confirmation: createJobConfirmedConfirmation(),
165
+ overrides: {},
166
+ resume: {
167
+ resume: true,
168
+ output_csv: "C:/temp/resume.csv",
169
+ checkpoint_path: "C:/temp/checkpoint.json",
170
+ pause_control_path: "C:/temp/run.json",
171
+ previous_completion_reason: "paused"
172
+ }
173
+ },
174
+ {
175
+ parseRecommendInstruction: () => createParsed(),
176
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
177
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
178
+ listRecommendJobs: async () => createJobListResult(),
179
+ runRecommendSearchCli: async () => {
180
+ searchCalled = true;
181
+ return { ok: true, summary: { candidate_count: 9, applied_filters: {} } };
182
+ },
183
+ runRecommendScreenCli: async ({ resume }) => {
184
+ receivedResume = resume;
185
+ return {
186
+ ok: true,
187
+ summary: {
188
+ processed_count: 6,
189
+ passed_count: 2,
190
+ skipped_count: 4,
191
+ output_csv: "C:/temp/resume.csv",
192
+ completion_reason: "page_exhausted"
193
+ }
194
+ };
195
+ }
196
+ }
197
+ );
198
+
199
+ assert.equal(result.status, "COMPLETED");
200
+ assert.equal(searchCalled, false);
201
+ assert.equal(receivedResume.resume, true);
202
+ assert.equal(receivedResume.require_checkpoint, true);
203
+ assert.equal(result.result.candidate_count, null);
204
+ }
205
+
206
+ async function testResumeFromPausedBeforeScreenShouldRerunSearch() {
207
+ let searchCalled = false;
208
+ let receivedResume = null;
209
+ const result = await runRecommendPipeline(
210
+ {
211
+ workspaceRoot: process.cwd(),
212
+ instruction: "test",
213
+ confirmation: createJobConfirmedConfirmation(),
214
+ overrides: {},
215
+ resume: {
216
+ resume: true,
217
+ output_csv: "C:/temp/resume.csv",
218
+ checkpoint_path: "C:/temp/checkpoint.json",
219
+ pause_control_path: "C:/temp/run.json",
220
+ previous_completion_reason: "paused_before_screen"
221
+ }
222
+ },
223
+ {
224
+ parseRecommendInstruction: () => createParsed(),
225
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
226
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
227
+ listRecommendJobs: async () => createJobListResult(),
228
+ runRecommendSearchCli: async () => {
229
+ searchCalled = true;
230
+ return { ok: true, summary: { candidate_count: 9, applied_filters: { degree: ["本科"] } } };
231
+ },
232
+ runRecommendScreenCli: async ({ resume }) => {
233
+ receivedResume = resume;
234
+ return {
235
+ ok: true,
236
+ summary: {
237
+ processed_count: 4,
238
+ passed_count: 1,
239
+ skipped_count: 3,
240
+ output_csv: "C:/temp/resume.csv",
241
+ completion_reason: "page_exhausted"
242
+ }
243
+ };
244
+ }
245
+ }
246
+ );
247
+
248
+ assert.equal(result.status, "COMPLETED");
249
+ assert.equal(searchCalled, true);
250
+ assert.equal(receivedResume.resume, true);
251
+ assert.equal(receivedResume.require_checkpoint, false);
252
+ assert.equal(result.result.candidate_count, 9);
253
+ }
254
+
157
255
  async function testNeedConfirmationGate() {
158
256
  let preflightCalled = false;
159
257
  const result = await runRecommendPipeline(
@@ -833,6 +931,8 @@ async function testScreenConfigRecoveryStepShouldBeFirst() {
833
931
  async function main() {
834
932
  await testPauseRequestedBeforeScreenShouldReturnPaused();
835
933
  await testPausedScreenResultShouldBubbleUp();
934
+ await testResumeFromScreenPauseShouldSkipSearch();
935
+ await testResumeFromPausedBeforeScreenShouldRerunSearch();
836
936
  await testNeedConfirmationGate();
837
937
  await testNeedSchoolTagConfirmationGate();
838
938
  await testNeedTargetCountConfirmationGate();