@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.
package/src/pipeline.js CHANGED
@@ -1,112 +1,116 @@
1
- import path from "node:path";
2
- import { parseRecommendInstruction } from "./parser.js";
1
+ import path from "node:path";
2
+ import { parseRecommendInstruction } from "./parser.js";
3
3
  import {
4
4
  attemptPipelineAutoRepair,
5
5
  ensureBossRecommendPageReady,
6
6
  listRecommendJobs,
7
+ reloadBossRecommendPage,
7
8
  runPipelinePreflight,
8
9
  runRecommendSearchCli,
9
10
  runRecommendScreenCli
10
11
  } from "./adapters.js";
11
-
12
- function dedupe(values = []) {
13
- return [...new Set(values.filter(Boolean))];
14
- }
15
-
16
- function normalizeText(value) {
17
- return String(value || "").replace(/\s+/g, " ").trim();
18
- }
19
-
20
- function normalizeJobTitle(value) {
21
- const text = normalizeText(value);
22
- if (!text) return "";
23
- const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
24
- const strippedRange = byGap
25
- .replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
26
- .trim();
27
- const strippedSingle = strippedRange
28
- .replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
29
- .trim();
30
- return strippedSingle || byGap;
31
- }
32
-
33
- function normalizeJobOptions(jobOptions = []) {
34
- const normalized = [];
35
- const seen = new Set();
36
- for (const item of jobOptions) {
37
- if (!item || typeof item !== "object") continue;
38
- const value = normalizeText(item.value);
39
- const label = normalizeText(item.label);
40
- const title = normalizeJobTitle(item.title || label);
41
- const optionKey = value || title || label;
42
- if (!optionKey || seen.has(optionKey)) continue;
43
- seen.add(optionKey);
44
- normalized.push({
45
- value: value || null,
46
- title: title || label || null,
47
- label: label || title || null,
48
- current: item.current === true
49
- });
50
- }
51
- return normalized;
52
- }
53
-
54
- function resolveSelectedJob(jobOptions = [], requestedRaw) {
55
- const requested = normalizeText(requestedRaw);
56
- if (!requested) {
57
- return { job: null, ambiguous: false, candidates: [] };
58
- }
59
- const requestedTitle = normalizeJobTitle(requested).toLowerCase();
60
- const requestedLower = requested.toLowerCase();
61
- const byValue = jobOptions.find((item) => normalizeText(item.value || "").toLowerCase() === requestedLower);
62
- if (byValue) return { job: byValue, ambiguous: false, candidates: [] };
63
- const byTitle = jobOptions.find((item) => normalizeJobTitle(item.title || "").toLowerCase() === requestedTitle);
64
- if (byTitle) return { job: byTitle, ambiguous: false, candidates: [] };
65
- const byLabel = jobOptions.find((item) => normalizeText(item.label || "").toLowerCase() === requestedLower);
66
- if (byLabel) return { job: byLabel, ambiguous: false, candidates: [] };
67
- const partialMatches = jobOptions.filter((item) => {
68
- const title = normalizeJobTitle(item.title || "").toLowerCase();
69
- const label = normalizeText(item.label || "").toLowerCase();
70
- return (
71
- (title && (title.includes(requestedTitle) || requestedTitle.includes(title)))
72
- || (label && (label.includes(requestedLower) || requestedLower.includes(label)))
73
- );
74
- });
75
- if (partialMatches.length === 1) {
76
- return { job: partialMatches[0], ambiguous: false, candidates: [] };
77
- }
78
- if (partialMatches.length > 1) {
79
- return {
80
- job: null,
81
- ambiguous: true,
82
- candidates: partialMatches.map((item) => item.title || item.label || "").filter(Boolean)
83
- };
84
- }
85
- return { job: null, ambiguous: false, candidates: [] };
86
- }
87
12
 
88
- function buildJobPendingQuestion(jobOptions = [], selectedHint = null, reason = null) {
89
- const options = jobOptions.map((item) => ({
90
- label: item.title || item.label || item.value,
91
- value: item.value || item.title || item.label
92
- }));
93
- return {
94
- field: "job",
95
- question: reason
96
- || "已识别当前推荐页岗位列表,请确认本次要执行的岗位。确认后会先点击该岗位,再开始 search 和 screen。",
97
- value: normalizeText(selectedHint) || null,
98
- options
99
- };
100
- }
101
-
102
- function failedCheckSet(checks = []) {
13
+ const FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY = "近14天没有";
14
+ const MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS = 5;
15
+
16
+ function dedupe(values = []) {
17
+ return [...new Set(values.filter(Boolean))];
18
+ }
19
+
20
+ function normalizeText(value) {
21
+ return String(value || "").replace(/\s+/g, " ").trim();
22
+ }
23
+
24
+ function normalizeJobTitle(value) {
25
+ const text = normalizeText(value);
26
+ if (!text) return "";
27
+ const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
28
+ const strippedRange = byGap
29
+ .replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
30
+ .trim();
31
+ const strippedSingle = strippedRange
32
+ .replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
33
+ .trim();
34
+ return strippedSingle || byGap;
35
+ }
36
+
37
+ function normalizeJobOptions(jobOptions = []) {
38
+ const normalized = [];
39
+ const seen = new Set();
40
+ for (const item of jobOptions) {
41
+ if (!item || typeof item !== "object") continue;
42
+ const value = normalizeText(item.value);
43
+ const label = normalizeText(item.label);
44
+ const title = normalizeJobTitle(item.title || label);
45
+ const optionKey = value || title || label;
46
+ if (!optionKey || seen.has(optionKey)) continue;
47
+ seen.add(optionKey);
48
+ normalized.push({
49
+ value: value || null,
50
+ title: title || label || null,
51
+ label: label || title || null,
52
+ current: item.current === true
53
+ });
54
+ }
55
+ return normalized;
56
+ }
57
+
58
+ function resolveSelectedJob(jobOptions = [], requestedRaw) {
59
+ const requested = normalizeText(requestedRaw);
60
+ if (!requested) {
61
+ return { job: null, ambiguous: false, candidates: [] };
62
+ }
63
+ const requestedTitle = normalizeJobTitle(requested).toLowerCase();
64
+ const requestedLower = requested.toLowerCase();
65
+ const byValue = jobOptions.find((item) => normalizeText(item.value || "").toLowerCase() === requestedLower);
66
+ if (byValue) return { job: byValue, ambiguous: false, candidates: [] };
67
+ const byTitle = jobOptions.find((item) => normalizeJobTitle(item.title || "").toLowerCase() === requestedTitle);
68
+ if (byTitle) return { job: byTitle, ambiguous: false, candidates: [] };
69
+ const byLabel = jobOptions.find((item) => normalizeText(item.label || "").toLowerCase() === requestedLower);
70
+ if (byLabel) return { job: byLabel, ambiguous: false, candidates: [] };
71
+ const partialMatches = jobOptions.filter((item) => {
72
+ const title = normalizeJobTitle(item.title || "").toLowerCase();
73
+ const label = normalizeText(item.label || "").toLowerCase();
74
+ return (
75
+ (title && (title.includes(requestedTitle) || requestedTitle.includes(title)))
76
+ || (label && (label.includes(requestedLower) || requestedLower.includes(label)))
77
+ );
78
+ });
79
+ if (partialMatches.length === 1) {
80
+ return { job: partialMatches[0], ambiguous: false, candidates: [] };
81
+ }
82
+ if (partialMatches.length > 1) {
83
+ return {
84
+ job: null,
85
+ ambiguous: true,
86
+ candidates: partialMatches.map((item) => item.title || item.label || "").filter(Boolean)
87
+ };
88
+ }
89
+ return { job: null, ambiguous: false, candidates: [] };
90
+ }
91
+
92
+ function buildJobPendingQuestion(jobOptions = [], selectedHint = null, reason = null) {
93
+ const options = jobOptions.map((item) => ({
94
+ label: item.title || item.label || item.value,
95
+ value: item.value || item.title || item.label
96
+ }));
97
+ return {
98
+ field: "job",
99
+ question: reason
100
+ || "已识别当前推荐页岗位列表,请确认本次要执行的岗位。确认后会先点击该岗位,再开始 search 和 screen。",
101
+ value: normalizeText(selectedHint) || null,
102
+ options
103
+ };
104
+ }
105
+
106
+ function failedCheckSet(checks = []) {
103
107
  const failed = checks
104
108
  .filter((item) => item && item.ok === false && typeof item.key === "string")
105
109
  .map((item) => item.key);
106
110
  return new Set(failed);
107
111
  }
108
112
 
109
- function collectNpmInstallDirs(checks = [], workspaceRoot) {
113
+ function collectNpmInstallDirs(checks = [], workspaceRoot) {
110
114
  const npmCheckKeys = new Set([
111
115
  "npm_dep_chrome_remote_interface_search",
112
116
  "npm_dep_chrome_remote_interface_screen",
@@ -116,101 +120,101 @@ function collectNpmInstallDirs(checks = [], workspaceRoot) {
116
120
  .filter((item) => item && item.ok === false && npmCheckKeys.has(item.key))
117
121
  .map((item) => item.install_cwd)
118
122
  .filter((value) => typeof value === "string" && value.trim());
119
- if (dirs.length > 0) return dedupe(dirs);
120
- return workspaceRoot ? [workspaceRoot] : [];
121
- }
122
-
123
- function quoteForCommand(value) {
124
- return JSON.stringify(String(value));
125
- }
126
-
127
- function buildNpmInstallCommands(checks = [], workspaceRoot) {
128
- const dirs = collectNpmInstallDirs(checks, workspaceRoot);
129
- const commands = [];
130
- for (const dir of dirs) {
131
- commands.push(`npm install --prefix ${quoteForCommand(dir)}`);
132
- }
133
- return commands;
134
- }
135
-
136
- function getNodeInstallCommands() {
137
- if (process.platform === "win32") {
138
- return [
139
- "winget install OpenJS.NodeJS.LTS",
140
- "node --version"
141
- ];
142
- }
143
- if (process.platform === "darwin") {
144
- return [
145
- "brew install node",
146
- "node --version"
147
- ];
148
- }
149
- return [
150
- "使用系统包管理器安装 Node.js >= 18(例如 apt / yum / brew)",
151
- "node --version"
152
- ];
153
- }
154
-
155
- function getPythonInstallCommands() {
156
- if (process.platform === "win32") {
157
- return [
158
- "winget install Python.Python.3.12",
159
- "python --version"
160
- ];
161
- }
162
- if (process.platform === "darwin") {
163
- return [
164
- "brew install python",
165
- "python3 --version",
166
- "若系统无 python 命令,请在当前终端建立 python -> python3 别名后重试。"
167
- ];
168
- }
169
- return [
170
- "使用系统包管理器安装 Python(例如 apt / yum / brew)",
171
- "python --version"
172
- ];
173
- }
123
+ if (dirs.length > 0) return dedupe(dirs);
124
+ return workspaceRoot ? [workspaceRoot] : [];
125
+ }
126
+
127
+ function quoteForCommand(value) {
128
+ return JSON.stringify(String(value));
129
+ }
130
+
131
+ function buildNpmInstallCommands(checks = [], workspaceRoot) {
132
+ const dirs = collectNpmInstallDirs(checks, workspaceRoot);
133
+ const commands = [];
134
+ for (const dir of dirs) {
135
+ commands.push(`npm install --prefix ${quoteForCommand(dir)}`);
136
+ }
137
+ return commands;
138
+ }
139
+
140
+ function getNodeInstallCommands() {
141
+ if (process.platform === "win32") {
142
+ return [
143
+ "winget install OpenJS.NodeJS.LTS",
144
+ "node --version"
145
+ ];
146
+ }
147
+ if (process.platform === "darwin") {
148
+ return [
149
+ "brew install node",
150
+ "node --version"
151
+ ];
152
+ }
153
+ return [
154
+ "使用系统包管理器安装 Node.js >= 18(例如 apt / yum / brew)",
155
+ "node --version"
156
+ ];
157
+ }
158
+
159
+ function getPythonInstallCommands() {
160
+ if (process.platform === "win32") {
161
+ return [
162
+ "winget install Python.Python.3.12",
163
+ "python --version"
164
+ ];
165
+ }
166
+ if (process.platform === "darwin") {
167
+ return [
168
+ "brew install python",
169
+ "python3 --version",
170
+ "若系统无 python 命令,请在当前终端建立 python -> python3 别名后重试。"
171
+ ];
172
+ }
173
+ return [
174
+ "使用系统包管理器安装 Python(例如 apt / yum / brew)",
175
+ "python --version"
176
+ ];
177
+ }
174
178
 
175
179
  function formatCommandBlock(commands = []) {
176
180
  return commands.map((command) => `- ${command}`).join("\n");
177
181
  }
178
182
 
179
- function buildPreflightRecovery(checks = [], workspaceRoot) {
180
- const failed = failedCheckSet(checks);
181
- if (failed.size === 0) return null;
182
-
183
- const needScreenConfig = failed.has("screen_config");
184
- const needNode = failed.has("node_cli");
185
- const needNpm = (
186
- failed.has("npm_dep_chrome_remote_interface_search")
187
- || failed.has("npm_dep_chrome_remote_interface_screen")
188
- || failed.has("npm_dep_ws")
183
+ function buildPreflightRecovery(checks = [], workspaceRoot) {
184
+ const failed = failedCheckSet(checks);
185
+ if (failed.size === 0) return null;
186
+
187
+ const needScreenConfig = failed.has("screen_config");
188
+ const needNode = failed.has("node_cli");
189
+ const needNpm = (
190
+ failed.has("npm_dep_chrome_remote_interface_search")
191
+ || failed.has("npm_dep_chrome_remote_interface_screen")
192
+ || failed.has("npm_dep_ws")
189
193
  );
190
194
  const needPython = failed.has("python_cli");
191
195
  const needPillow = failed.has("python_pillow");
192
-
193
- const ordered_steps = [];
194
- if (needScreenConfig) {
195
- const configCheck = checks.find((item) => item?.key === "screen_config");
196
- ordered_steps.push({
197
- id: "fill_screening_config",
198
- title: "填写 screening-config.json(baseUrl / apiKey / model)",
199
- blocked_by: [],
200
- commands: [
201
- `打开并填写:${configCheck?.path || "~/.boss-recommend-mcp/screening-config.json"}`,
202
- "确认 baseUrl、apiKey、model 都是可用值(不要保留模板占位符)。"
203
- ]
204
- });
205
- }
206
- if (needNode) {
207
- ordered_steps.push({
208
- id: "install_nodejs",
209
- title: "安装 Node.js >= 18",
210
- blocked_by: [],
211
- commands: getNodeInstallCommands()
212
- });
213
- }
196
+
197
+ const ordered_steps = [];
198
+ if (needScreenConfig) {
199
+ const configCheck = checks.find((item) => item?.key === "screen_config");
200
+ ordered_steps.push({
201
+ id: "fill_screening_config",
202
+ title: "填写 screening-config.json(baseUrl / apiKey / model)",
203
+ blocked_by: [],
204
+ commands: [
205
+ `打开并填写:${configCheck?.path || "~/.boss-recommend-mcp/screening-config.json"}`,
206
+ "确认 baseUrl、apiKey、model 都是可用值(不要保留模板占位符)。"
207
+ ]
208
+ });
209
+ }
210
+ if (needNode) {
211
+ ordered_steps.push({
212
+ id: "install_nodejs",
213
+ title: "安装 Node.js >= 18",
214
+ blocked_by: [],
215
+ commands: getNodeInstallCommands()
216
+ });
217
+ }
214
218
  if (needNpm) {
215
219
  ordered_steps.push({
216
220
  id: "install_npm_dependencies",
@@ -219,14 +223,14 @@ function buildPreflightRecovery(checks = [], workspaceRoot) {
219
223
  commands: buildNpmInstallCommands(checks, workspaceRoot)
220
224
  });
221
225
  }
222
- if (needPython) {
223
- ordered_steps.push({
224
- id: "install_python",
225
- title: "安装 Python(确保 python 命令可用)",
226
- blocked_by: [],
227
- commands: getPythonInstallCommands()
228
- });
229
- }
226
+ if (needPython) {
227
+ ordered_steps.push({
228
+ id: "install_python",
229
+ title: "安装 Python(确保 python 命令可用)",
230
+ blocked_by: [],
231
+ commands: getPythonInstallCommands()
232
+ });
233
+ }
230
234
  if (needPillow) {
231
235
  ordered_steps.push({
232
236
  id: "install_pillow",
@@ -239,21 +243,21 @@ function buildPreflightRecovery(checks = [], workspaceRoot) {
239
243
  });
240
244
  }
241
245
 
242
- const promptLines = [
243
- "你是环境修复 agent。请先读取 diagnostics.checks,再严格按下面顺序执行,不要并行跳步:",
244
- "1) node_cli 失败 -> 先安装 Node.js,未成功前禁止执行 npm install。",
245
- "2) npm_dep_* 失败 -> 再安装 npm 依赖(chrome-remote-interface / ws)。",
246
- "3) python_cli 失败 -> 安装 Python 并确保 python 命令可用。",
247
- "4) python_pillow 失败 -> 最后安装 Pillow。",
248
- "每一步完成后都重新运行 doctor,直到所有检查通过后再重试流水线。"
249
- ];
250
- if (needScreenConfig) {
251
- promptLines.splice(
252
- 1,
253
- 0,
254
- "0) 若 screen_config 失败:先让用户提供并填写 baseUrl、apiKey、model(不得使用模板占位符)。"
255
- );
256
- }
246
+ const promptLines = [
247
+ "你是环境修复 agent。请先读取 diagnostics.checks,再严格按下面顺序执行,不要并行跳步:",
248
+ "1) node_cli 失败 -> 先安装 Node.js,未成功前禁止执行 npm install。",
249
+ "2) npm_dep_* 失败 -> 再安装 npm 依赖(chrome-remote-interface / ws)。",
250
+ "3) python_cli 失败 -> 安装 Python 并确保 python 命令可用。",
251
+ "4) python_pillow 失败 -> 最后安装 Pillow。",
252
+ "每一步完成后都重新运行 doctor,直到所有检查通过后再重试流水线。"
253
+ ];
254
+ if (needScreenConfig) {
255
+ promptLines.splice(
256
+ 1,
257
+ 0,
258
+ "0) 若 screen_config 失败:先让用户提供并填写 baseUrl、apiKey、model(不得使用模板占位符)。"
259
+ );
260
+ }
257
261
 
258
262
  if (needNpm) {
259
263
  const npmCommands = buildNpmInstallCommands(checks, workspaceRoot);
@@ -270,8 +274,8 @@ function buildPreflightRecovery(checks = [], workspaceRoot) {
270
274
  };
271
275
  }
272
276
 
273
- function buildRequiredConfirmations(parsedResult) {
274
- const confirmations = [];
277
+ function buildRequiredConfirmations(parsedResult) {
278
+ const confirmations = [];
275
279
  if (parsedResult.needs_filters_confirmation) confirmations.push("filters");
276
280
  if (parsedResult.needs_school_tag_confirmation) confirmations.push("school_tag");
277
281
  if (parsedResult.needs_degree_confirmation) confirmations.push("degree");
@@ -279,10 +283,10 @@ function buildRequiredConfirmations(parsedResult) {
279
283
  if (parsedResult.needs_recent_not_view_confirmation) confirmations.push("recent_not_view");
280
284
  if (parsedResult.needs_criteria_confirmation) confirmations.push("criteria");
281
285
  if (parsedResult.needs_target_count_confirmation) confirmations.push("target_count");
282
- if (parsedResult.needs_post_action_confirmation) confirmations.push("post_action");
283
- if (parsedResult.needs_max_greet_count_confirmation) confirmations.push("max_greet_count");
284
- return confirmations;
285
- }
286
+ if (parsedResult.needs_post_action_confirmation) confirmations.push("post_action");
287
+ if (parsedResult.needs_max_greet_count_confirmation) confirmations.push("max_greet_count");
288
+ return confirmations;
289
+ }
286
290
 
287
291
  function buildNeedInputResponse(parsedResult) {
288
292
  return {
@@ -301,8 +305,8 @@ function buildNeedInputResponse(parsedResult) {
301
305
  };
302
306
  }
303
307
 
304
- function buildNeedConfirmationResponse(parsedResult) {
305
- return {
308
+ function buildNeedConfirmationResponse(parsedResult) {
309
+ return {
306
310
  status: "NEED_CONFIRMATION",
307
311
  required_confirmations: buildRequiredConfirmations(parsedResult),
308
312
  search_params: parsedResult.searchParams,
@@ -313,220 +317,222 @@ function buildNeedConfirmationResponse(parsedResult) {
313
317
  max_greet_count: parsedResult.proposed_max_greet_count || parsedResult.screenParams.max_greet_count
314
318
  },
315
319
  pending_questions: parsedResult.pending_questions,
316
- review: parsedResult.review
317
- };
318
- }
319
-
320
- function buildFinalReviewQuestion({ searchParams, screenParams, selectedJob }) {
321
- return {
322
- field: "final_review",
323
- question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/筛选条件/筛选 criteria/目标人数/post_action/max_greet_count)无误。",
324
- value: {
325
- job: selectedJob?.title || selectedJob?.label || selectedJob?.value || null,
326
- search_params: searchParams,
327
- screen_params: screenParams
328
- }
329
- };
330
- }
331
-
332
- function buildFailedResponse(code, message, extra = {}) {
333
- return {
334
- status: "FAILED",
320
+ review: parsedResult.review
321
+ };
322
+ }
323
+
324
+ function buildFinalReviewQuestion({ searchParams, screenParams, selectedJob }) {
325
+ return {
326
+ field: "final_review",
327
+ question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/筛选条件/筛选 criteria/目标人数/post_action/max_greet_count)无误。",
328
+ value: {
329
+ job: selectedJob?.title || selectedJob?.label || selectedJob?.value || null,
330
+ search_params: searchParams,
331
+ screen_params: screenParams
332
+ }
333
+ };
334
+ }
335
+
336
+ function buildFailedResponse(code, message, extra = {}) {
337
+ return {
338
+ status: "FAILED",
335
339
  error: {
336
340
  code,
337
341
  message,
338
342
  retryable: true
339
343
  },
340
- ...extra
341
- };
342
- }
343
-
344
- function buildPausedResponse(message, extra = {}) {
345
- return {
346
- status: "PAUSED",
347
- message: normalizeText(message || "") || "Recommend 流水线已暂停。",
348
- ...extra
349
- };
350
- }
351
-
352
- class PipelineAbortError extends Error {
353
- constructor(message = "Pipeline execution aborted") {
354
- super(message);
355
- this.name = "PipelineAbortError";
356
- this.code = "PIPELINE_ABORTED";
357
- }
358
- }
359
-
360
- function isAbortSignalTriggered(signal) {
361
- return Boolean(signal && signal.aborted);
362
- }
363
-
364
- function ensurePipelineNotAborted(signal) {
365
- if (isAbortSignalTriggered(signal)) {
366
- throw new PipelineAbortError("Pipeline execution aborted by caller.");
367
- }
368
- }
369
-
370
- function safeInvokeRuntimeCallback(callback, payload) {
371
- if (typeof callback !== "function") return;
372
- try {
373
- callback(payload);
374
- } catch {
375
- // Keep pipeline stable even if runtime callback fails.
376
- }
377
- }
378
-
379
- function createPipelineRuntime(runtime = null) {
380
- const signal = runtime?.signal;
381
- const heartbeatIntervalMs = Number.isFinite(runtime?.heartbeatIntervalMs) && runtime.heartbeatIntervalMs > 0
382
- ? runtime.heartbeatIntervalMs
383
- : 10_000;
384
- const isPauseRequested = typeof runtime?.isPauseRequested === "function"
385
- ? runtime.isPauseRequested
386
- : () => false;
387
-
388
- function setStage(stage, message = null) {
389
- safeInvokeRuntimeCallback(runtime?.onStage, {
390
- stage,
391
- message: normalizeText(message || "") || null,
392
- at: new Date().toISOString()
393
- });
394
- }
395
-
396
- function heartbeat(stage, details = null) {
397
- safeInvokeRuntimeCallback(runtime?.onHeartbeat, {
398
- stage,
399
- details: details || null,
400
- at: new Date().toISOString()
401
- });
402
- }
403
-
404
- function output(stage, event) {
405
- safeInvokeRuntimeCallback(runtime?.onOutput, {
406
- stage,
407
- ...(event || {}),
408
- at: new Date().toISOString()
409
- });
410
- }
411
-
412
- function progress(stage, payload) {
413
- safeInvokeRuntimeCallback(runtime?.onProgress, {
414
- stage,
415
- ...(payload || {}),
416
- at: new Date().toISOString()
417
- });
418
- }
419
-
420
- function adapterRuntime(stage) {
421
- return {
422
- signal,
423
- heartbeatIntervalMs,
424
- onOutput: (event) => output(stage, event),
425
- onHeartbeat: (event) => heartbeat(stage, event),
426
- onProgress: (payload) => progress(stage, payload)
427
- };
428
- }
429
-
430
- return {
431
- signal,
432
- heartbeatIntervalMs,
433
- isPauseRequested,
434
- setStage,
435
- heartbeat,
436
- output,
437
- progress,
438
- adapterRuntime
439
- };
440
- }
441
-
442
- function isProcessAbortError(errorLike) {
443
- const code = normalizeText(errorLike?.code || "").toUpperCase();
444
- return code === "PROCESS_ABORTED" || code === "ABORTED";
445
- }
446
-
447
- function isPauseRequested(runtimeHooks) {
448
- try {
449
- return runtimeHooks?.isPauseRequested?.() === true;
450
- } catch {
451
- return false;
452
- }
453
- }
454
-
455
- function buildChromeSetupGuidance({ debugPort, pageState }) {
456
- const expectedUrl = pageState?.expected_url || "https://www.zhipin.com/web/chat/recommend";
457
- const loginUrl = pageState?.login_url || "https://www.zhipin.com/web/user/?ka=bticket";
458
- const currentUrl = pageState?.current_url || null;
459
- const state = pageState?.state || "UNKNOWN";
460
- const isPortIssue = state === "DEBUG_PORT_UNREACHABLE";
461
- const needsLogin = state === "LOGIN_REQUIRED" || state === "LOGIN_REQUIRED_AFTER_REDIRECT";
462
- const launchAttempt = pageState?.launch_attempt || null;
463
- const launchLine = launchAttempt?.ok
464
- ? `已自动启动 Chrome(--remote-debugging-port=${debugPort},--user-data-dir=${launchAttempt.user_data_dir || "auto"})。`
465
- : null;
466
- const launchExample = process.platform === "win32"
467
- ? `chrome.exe --remote-debugging-port=${debugPort} --user-data-dir=<profile-dir>`
468
- : process.platform === "darwin"
469
- ? `'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' --remote-debugging-port=${debugPort} --user-data-dir=<profile-dir>`
470
- : `google-chrome --remote-debugging-port=${debugPort} --user-data-dir=<profile-dir>`;
471
- const steps = [
472
- `请先在可连接到 DevTools 端口 ${debugPort} 的 Chrome 实例中完成以下操作:`,
473
- ...(launchLine ? [launchLine] : []),
474
- "1) 确认当前 Chrome 与本次运行使用同一个远程调试端口。",
475
- isPortIssue
476
- ? `2) 若端口不可连接,请用远程调试方式启动 Chrome(示例:${launchExample})。`
477
- : "2) 确认端口可连接且浏览器窗口保持打开。",
478
- needsLogin
479
- ? `3) 当前检测到 Boss 未登录,请先打开并完成登录:${loginUrl}`
480
- : "3) 如 Boss 登录态失效,请先重新登录。",
481
- `4) 登录完成后先导航并停留在推荐页:${expectedUrl}`,
482
- "5) 完成后回复“已就绪”,我会继续执行并优先自动导航到推荐页。"
483
- ];
484
- return {
485
- debug_port: debugPort,
486
- expected_url: expectedUrl,
487
- current_url: currentUrl,
488
- page_state: state,
489
- agent_prompt: steps.join("\n")
490
- };
491
- }
492
-
344
+ ...extra
345
+ };
346
+ }
347
+
348
+ function buildPausedResponse(message, extra = {}) {
349
+ return {
350
+ status: "PAUSED",
351
+ message: normalizeText(message || "") || "Recommend 流水线已暂停。",
352
+ ...extra
353
+ };
354
+ }
355
+
356
+ class PipelineAbortError extends Error {
357
+ constructor(message = "Pipeline execution aborted") {
358
+ super(message);
359
+ this.name = "PipelineAbortError";
360
+ this.code = "PIPELINE_ABORTED";
361
+ }
362
+ }
363
+
364
+ function isAbortSignalTriggered(signal) {
365
+ return Boolean(signal && signal.aborted);
366
+ }
367
+
368
+ function ensurePipelineNotAborted(signal) {
369
+ if (isAbortSignalTriggered(signal)) {
370
+ throw new PipelineAbortError("Pipeline execution aborted by caller.");
371
+ }
372
+ }
373
+
374
+ function safeInvokeRuntimeCallback(callback, payload) {
375
+ if (typeof callback !== "function") return;
376
+ try {
377
+ callback(payload);
378
+ } catch {
379
+ // Keep pipeline stable even if runtime callback fails.
380
+ }
381
+ }
382
+
383
+ function createPipelineRuntime(runtime = null) {
384
+ const signal = runtime?.signal;
385
+ const heartbeatIntervalMs = Number.isFinite(runtime?.heartbeatIntervalMs) && runtime.heartbeatIntervalMs > 0
386
+ ? runtime.heartbeatIntervalMs
387
+ : 10_000;
388
+ const isPauseRequested = typeof runtime?.isPauseRequested === "function"
389
+ ? runtime.isPauseRequested
390
+ : () => false;
391
+
392
+ function setStage(stage, message = null) {
393
+ safeInvokeRuntimeCallback(runtime?.onStage, {
394
+ stage,
395
+ message: normalizeText(message || "") || null,
396
+ at: new Date().toISOString()
397
+ });
398
+ }
399
+
400
+ function heartbeat(stage, details = null) {
401
+ safeInvokeRuntimeCallback(runtime?.onHeartbeat, {
402
+ stage,
403
+ details: details || null,
404
+ at: new Date().toISOString()
405
+ });
406
+ }
407
+
408
+ function output(stage, event) {
409
+ safeInvokeRuntimeCallback(runtime?.onOutput, {
410
+ stage,
411
+ ...(event || {}),
412
+ at: new Date().toISOString()
413
+ });
414
+ }
415
+
416
+ function progress(stage, payload) {
417
+ safeInvokeRuntimeCallback(runtime?.onProgress, {
418
+ stage,
419
+ ...(payload || {}),
420
+ at: new Date().toISOString()
421
+ });
422
+ }
423
+
424
+ function adapterRuntime(stage) {
425
+ return {
426
+ signal,
427
+ heartbeatIntervalMs,
428
+ onOutput: (event) => output(stage, event),
429
+ onHeartbeat: (event) => heartbeat(stage, event),
430
+ onProgress: (payload) => progress(stage, payload)
431
+ };
432
+ }
433
+
434
+ return {
435
+ signal,
436
+ heartbeatIntervalMs,
437
+ isPauseRequested,
438
+ setStage,
439
+ heartbeat,
440
+ output,
441
+ progress,
442
+ adapterRuntime
443
+ };
444
+ }
445
+
446
+ function isProcessAbortError(errorLike) {
447
+ const code = normalizeText(errorLike?.code || "").toUpperCase();
448
+ return code === "PROCESS_ABORTED" || code === "ABORTED";
449
+ }
450
+
451
+ function isPauseRequested(runtimeHooks) {
452
+ try {
453
+ return runtimeHooks?.isPauseRequested?.() === true;
454
+ } catch {
455
+ return false;
456
+ }
457
+ }
458
+
459
+ function buildChromeSetupGuidance({ debugPort, pageState }) {
460
+ const expectedUrl = pageState?.expected_url || "https://www.zhipin.com/web/chat/recommend";
461
+ const loginUrl = pageState?.login_url || "https://www.zhipin.com/web/user/?ka=bticket";
462
+ const currentUrl = pageState?.current_url || null;
463
+ const state = pageState?.state || "UNKNOWN";
464
+ const isPortIssue = state === "DEBUG_PORT_UNREACHABLE";
465
+ const needsLogin = state === "LOGIN_REQUIRED" || state === "LOGIN_REQUIRED_AFTER_REDIRECT";
466
+ const launchAttempt = pageState?.launch_attempt || null;
467
+ const launchLine = launchAttempt?.ok
468
+ ? `已自动启动 Chrome(--remote-debugging-port=${debugPort},--user-data-dir=${launchAttempt.user_data_dir || "auto"})。`
469
+ : null;
470
+ const launchExample = process.platform === "win32"
471
+ ? `chrome.exe --remote-debugging-port=${debugPort} --user-data-dir=<profile-dir>`
472
+ : process.platform === "darwin"
473
+ ? `'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' --remote-debugging-port=${debugPort} --user-data-dir=<profile-dir>`
474
+ : `google-chrome --remote-debugging-port=${debugPort} --user-data-dir=<profile-dir>`;
475
+ const steps = [
476
+ `请先在可连接到 DevTools 端口 ${debugPort} 的 Chrome 实例中完成以下操作:`,
477
+ ...(launchLine ? [launchLine] : []),
478
+ "1) 确认当前 Chrome 与本次运行使用同一个远程调试端口。",
479
+ isPortIssue
480
+ ? `2) 若端口不可连接,请用远程调试方式启动 Chrome(示例:${launchExample})。`
481
+ : "2) 确认端口可连接且浏览器窗口保持打开。",
482
+ needsLogin
483
+ ? `3) 当前检测到 Boss 未登录,请先打开并完成登录:${loginUrl}`
484
+ : "3) 如 Boss 登录态失效,请先重新登录。",
485
+ `4) 登录完成后先导航并停留在推荐页:${expectedUrl}`,
486
+ "5) 完成后回复“已就绪”,我会继续执行并优先自动导航到推荐页。"
487
+ ];
488
+ return {
489
+ debug_port: debugPort,
490
+ expected_url: expectedUrl,
491
+ current_url: currentUrl,
492
+ page_state: state,
493
+ agent_prompt: steps.join("\n")
494
+ };
495
+ }
496
+
493
497
  const defaultDependencies = {
494
498
  attemptPipelineAutoRepair,
495
499
  parseRecommendInstruction,
496
500
  ensureBossRecommendPageReady,
497
501
  listRecommendJobs,
502
+ reloadBossRecommendPage,
498
503
  runPipelinePreflight,
499
504
  runRecommendSearchCli,
500
505
  runRecommendScreenCli
501
506
  };
502
-
503
- export async function runRecommendPipeline(
504
- { workspaceRoot, instruction, confirmation, overrides, resume = null },
505
- dependencies = defaultDependencies,
506
- runtime = null
507
- ) {
508
- const injectedDependencies = dependencies || {};
509
- const resolvedDependencies = { ...defaultDependencies, ...(dependencies || {}) };
507
+
508
+ export async function runRecommendPipeline(
509
+ { workspaceRoot, instruction, confirmation, overrides, resume = null },
510
+ dependencies = defaultDependencies,
511
+ runtime = null
512
+ ) {
513
+ const injectedDependencies = dependencies || {};
514
+ const resolvedDependencies = { ...defaultDependencies, ...(dependencies || {}) };
510
515
  const {
511
516
  attemptPipelineAutoRepair: attemptAutoRepair,
512
517
  parseRecommendInstruction: parseInstruction,
513
518
  ensureBossRecommendPageReady: ensureRecommendPageReady,
514
519
  listRecommendJobs: listJobs,
520
+ reloadBossRecommendPage: reloadRecommendPage,
515
521
  runPipelinePreflight: runPreflight,
516
522
  runRecommendSearchCli: searchCli,
517
523
  runRecommendScreenCli: screenCli
518
524
  } = resolvedDependencies;
519
- const runtimeHooks = createPipelineRuntime(runtime);
520
- ensurePipelineNotAborted(runtimeHooks.signal);
521
-
522
- const startedAt = Date.now();
523
- const parsed = parseInstruction({ instruction, confirmation, overrides });
525
+ const runtimeHooks = createPipelineRuntime(runtime);
526
+ ensurePipelineNotAborted(runtimeHooks.signal);
527
+
528
+ const startedAt = Date.now();
529
+ const parsed = parseInstruction({ instruction, confirmation, overrides });
524
530
 
525
531
  if (parsed.missing_fields.length > 0) {
526
532
  return buildNeedInputResponse(parsed);
527
533
  }
528
534
 
529
- if (
535
+ if (
530
536
  parsed.needs_filters_confirmation
531
537
  || parsed.needs_school_tag_confirmation
532
538
  || parsed.needs_degree_confirmation
@@ -536,448 +542,569 @@ export async function runRecommendPipeline(
536
542
  || parsed.needs_target_count_confirmation
537
543
  || parsed.needs_post_action_confirmation
538
544
  || parsed.needs_max_greet_count_confirmation
539
- ) {
540
- return buildNeedConfirmationResponse(parsed);
541
- }
542
-
543
- ensurePipelineNotAborted(runtimeHooks.signal);
544
- runtimeHooks.setStage("preflight", "开始执行 preflight 检查。");
545
- runtimeHooks.heartbeat("preflight");
546
-
547
- let preflight = runPreflight(workspaceRoot);
548
- let autoRepair = null;
549
- const shouldAttemptAutoRepair = (
550
- dependencies === defaultDependencies
551
- || Object.prototype.hasOwnProperty.call(injectedDependencies, "attemptPipelineAutoRepair")
552
- );
553
- if (!preflight.ok) {
554
- if (shouldAttemptAutoRepair && typeof attemptAutoRepair === "function") {
555
- autoRepair = attemptAutoRepair(workspaceRoot, preflight);
556
- if (autoRepair?.preflight) {
557
- preflight = autoRepair.preflight;
558
- }
559
- }
560
- }
545
+ ) {
546
+ return buildNeedConfirmationResponse(parsed);
547
+ }
548
+
549
+ ensurePipelineNotAborted(runtimeHooks.signal);
550
+ runtimeHooks.setStage("preflight", "开始执行 preflight 检查。");
551
+ runtimeHooks.heartbeat("preflight");
552
+
553
+ let preflight = runPreflight(workspaceRoot);
554
+ let autoRepair = null;
555
+ const shouldAttemptAutoRepair = (
556
+ dependencies === defaultDependencies
557
+ || Object.prototype.hasOwnProperty.call(injectedDependencies, "attemptPipelineAutoRepair")
558
+ );
559
+ if (!preflight.ok) {
560
+ if (shouldAttemptAutoRepair && typeof attemptAutoRepair === "function") {
561
+ autoRepair = attemptAutoRepair(workspaceRoot, preflight);
562
+ if (autoRepair?.preflight) {
563
+ preflight = autoRepair.preflight;
564
+ }
565
+ }
566
+ }
567
+
568
+ if (!preflight.ok) {
569
+ runtimeHooks.heartbeat("preflight", {
570
+ status: "failed"
571
+ });
572
+ const screenConfigCheck = preflight.checks?.find((item) => item?.key === "screen_config" && item?.ok === false);
573
+ const screenConfigPath = String(screenConfigCheck?.path || "");
574
+ const screenConfigDir = screenConfigPath ? path.dirname(screenConfigPath) : null;
575
+ const screenConfigReason = String(screenConfigCheck?.reason || "").trim().toUpperCase();
576
+ const screenConfigMessage = String(screenConfigCheck?.message || "");
577
+ const screenConfigHasPlaceholder = (
578
+ screenConfigReason.includes("PLACEHOLDER")
579
+ || /占位符|默认模板值|replace-with-openai-api-key/i.test(screenConfigMessage)
580
+ );
581
+ const recovery = buildPreflightRecovery(preflight.checks, workspaceRoot);
582
+ return buildFailedResponse(
583
+ "PIPELINE_PREFLIGHT_FAILED",
584
+ "Recommend 流水线运行前检查失败,请先修复缺失的本地依赖或配置文件。",
585
+ {
586
+ search_params: parsed.searchParams,
587
+ screen_params: parsed.screenParams,
588
+ required_user_action: screenConfigCheck
589
+ ? (screenConfigHasPlaceholder ? "confirm_screening_config_updated" : "provide_screening_config")
590
+ : undefined,
591
+ guidance: screenConfigCheck
592
+ ? {
593
+ config_path: screenConfigCheck.path,
594
+ config_dir: screenConfigDir,
595
+ agent_prompt: [
596
+ ...(screenConfigHasPlaceholder
597
+ ? [
598
+ "检测到 screening-config.json 仍包含默认占位词,当前禁止继续执行。",
599
+ `请引导用户在以下目录修改配置文件:${screenConfigDir || "(unknown)"}`,
600
+ `配置文件路径:${screenConfigCheck.path}`,
601
+ "必须替换为真实可用值:baseUrl、apiKey、model(不要保留任何模板占位符)。",
602
+ "修改完成后,必须先让用户明确回复“已修改完成”,再继续下一步。"
603
+ ]
604
+ : [
605
+ "请先让用户填写 screening-config.json 的以下字段:",
606
+ "1) baseUrl",
607
+ "2) apiKey",
608
+ "3) model",
609
+ `配置文件路径:${screenConfigCheck.path}`,
610
+ "注意:不要使用模板占位符(例如 replace-with-openai-api-key),也不要由 agent 自行猜测或代填示例值。必须向用户逐项确认真实可用值后再重试。"
611
+ ])
612
+ ].join("\n")
613
+ }
614
+ : undefined,
615
+ diagnostics: {
616
+ checks: preflight.checks,
617
+ debug_port: preflight.debug_port,
618
+ config_resolution: preflight.config_resolution,
619
+ auto_repair: autoRepair,
620
+ recovery
621
+ }
622
+ }
623
+ );
624
+ }
625
+
626
+ ensurePipelineNotAborted(runtimeHooks.signal);
627
+ runtimeHooks.setStage("page_ready", "preflight 完成,开始检查 recommend 页面就绪状态。");
628
+ runtimeHooks.heartbeat("page_ready");
629
+
630
+ const pageCheck = await ensureRecommendPageReady(workspaceRoot, {
631
+ port: preflight.debug_port
632
+ });
633
+ ensurePipelineNotAborted(runtimeHooks.signal);
634
+ if (!pageCheck.ok) {
635
+ const loginRelated = new Set(["LOGIN_REQUIRED", "LOGIN_REQUIRED_AFTER_REDIRECT"]);
636
+ const connectivityRelated = new Set(["DEBUG_PORT_UNREACHABLE"]);
637
+ const guidance = buildChromeSetupGuidance({
638
+ debugPort: preflight.debug_port,
639
+ pageState: pageCheck.page_state
640
+ });
641
+ return buildFailedResponse(
642
+ connectivityRelated.has(pageCheck.state)
643
+ ? "BOSS_CHROME_NOT_CONNECTED"
644
+ : loginRelated.has(pageCheck.state)
645
+ ? "BOSS_LOGIN_REQUIRED"
646
+ : "BOSS_RECOMMEND_PAGE_NOT_READY",
647
+ loginRelated.has(pageCheck.state)
648
+ ? `开始执行搜索和筛选前,请先在端口 ${preflight.debug_port} 的 Chrome 完成 Boss 登录并停留在 recommend 页面。`
649
+ : connectivityRelated.has(pageCheck.state)
650
+ ? `开始执行搜索和筛选前,需要先连接到端口 ${preflight.debug_port} 的 Chrome 远程调试实例。`
651
+ : `开始执行搜索和筛选前,请先在端口 ${preflight.debug_port} 的 Chrome 停留在 Boss recommend 页面。`,
652
+ {
653
+ search_params: parsed.searchParams,
654
+ screen_params: parsed.screenParams,
655
+ required_user_action: "prepare_boss_recommend_page",
656
+ guidance,
657
+ diagnostics: {
658
+ debug_port: preflight.debug_port,
659
+ page_state: pageCheck.page_state
660
+ }
661
+ }
662
+ );
663
+ }
664
+
665
+ runtimeHooks.setStage("job_list", "页面就绪,开始读取岗位列表。");
666
+ runtimeHooks.heartbeat("job_list");
667
+ const jobListResult = await listJobs({
668
+ workspaceRoot,
669
+ port: preflight.debug_port,
670
+ runtime: runtimeHooks.adapterRuntime("job_list")
671
+ });
672
+ ensurePipelineNotAborted(runtimeHooks.signal);
673
+ if (isProcessAbortError(jobListResult.error)) {
674
+ throw new PipelineAbortError(jobListResult.error?.message || "岗位列表读取已取消。");
675
+ }
676
+ if (!jobListResult.ok) {
677
+ const jobListErrorCode = String(jobListResult.error?.code || "");
678
+ const jobListErrorMessage = String(jobListResult.error?.message || "");
679
+ const pageReadinessFailure = (
680
+ jobListErrorCode === "JOB_TRIGGER_NOT_FOUND"
681
+ || jobListErrorCode === "NO_RECOMMEND_IFRAME"
682
+ || jobListErrorCode === "LOGIN_REQUIRED"
683
+ || jobListErrorMessage.includes("JOB_TRIGGER_NOT_FOUND")
684
+ || jobListErrorMessage.includes("NO_RECOMMEND_IFRAME")
685
+ || jobListErrorMessage.includes("LOGIN_REQUIRED")
686
+ );
687
+ if (pageReadinessFailure) {
688
+ const recheck = await ensureRecommendPageReady(workspaceRoot, {
689
+ port: preflight.debug_port
690
+ });
691
+ const loginRelated = new Set(["LOGIN_REQUIRED", "LOGIN_REQUIRED_AFTER_REDIRECT"]);
692
+ const connectivityRelated = new Set(["DEBUG_PORT_UNREACHABLE"]);
693
+ const guidance = buildChromeSetupGuidance({
694
+ debugPort: preflight.debug_port,
695
+ pageState: recheck.page_state
696
+ });
697
+ if (!recheck.ok || loginRelated.has(recheck.state) || connectivityRelated.has(recheck.state)) {
698
+ return buildFailedResponse(
699
+ connectivityRelated.has(recheck.state)
700
+ ? "BOSS_CHROME_NOT_CONNECTED"
701
+ : loginRelated.has(recheck.state)
702
+ ? "BOSS_LOGIN_REQUIRED"
703
+ : "BOSS_RECOMMEND_PAGE_NOT_READY",
704
+ loginRelated.has(recheck.state)
705
+ ? `检测到当前 Boss 处于未登录状态,请先登录后再继续。登录页:https://www.zhipin.com/web/user/?ka=bticket`
706
+ : connectivityRelated.has(recheck.state)
707
+ ? `读取岗位列表前需要先连接到端口 ${preflight.debug_port} 的 Chrome 远程调试实例。`
708
+ : `读取岗位列表前,请先在端口 ${preflight.debug_port} 的 Chrome 停留在 Boss recommend 页面。`,
709
+ {
710
+ search_params: parsed.searchParams,
711
+ screen_params: parsed.screenParams,
712
+ required_user_action: "prepare_boss_recommend_page",
713
+ guidance,
714
+ diagnostics: {
715
+ debug_port: preflight.debug_port,
716
+ page_state: recheck.page_state,
717
+ stdout: jobListResult.stdout?.slice(-1000),
718
+ stderr: jobListResult.stderr?.slice(-1000),
719
+ result: jobListResult.structured || null
720
+ }
721
+ }
722
+ );
723
+ }
724
+ }
725
+ return buildFailedResponse(
726
+ jobListResult.error?.code || "RECOMMEND_JOB_LIST_FAILED",
727
+ jobListResult.error?.message || "读取推荐岗位列表失败,无法开始筛选。",
728
+ {
729
+ search_params: parsed.searchParams,
730
+ screen_params: parsed.screenParams,
731
+ diagnostics: {
732
+ debug_port: preflight.debug_port,
733
+ stdout: jobListResult.stdout?.slice(-1000),
734
+ stderr: jobListResult.stderr?.slice(-1000),
735
+ result: jobListResult.structured || null
736
+ }
737
+ }
738
+ );
739
+ }
740
+ const jobOptions = normalizeJobOptions(jobListResult.jobs);
741
+ if (jobOptions.length === 0) {
742
+ return buildFailedResponse(
743
+ "RECOMMEND_JOB_LIST_EMPTY",
744
+ "未识别到可选岗位,暂时无法开始筛选。",
745
+ {
746
+ search_params: parsed.searchParams,
747
+ screen_params: parsed.screenParams,
748
+ diagnostics: {
749
+ debug_port: preflight.debug_port,
750
+ result: jobListResult.structured || null
751
+ }
752
+ }
753
+ );
754
+ }
755
+
756
+ const selectedJobHint = normalizeText(confirmation?.job_value || parsed.job_selection_hint || "");
757
+ const selectedJobResolution = resolveSelectedJob(jobOptions, selectedJobHint);
758
+ const jobConfirmed = confirmation?.job_confirmed === true;
759
+ if (!jobConfirmed || !selectedJobResolution.job) {
760
+ const reason = selectedJobResolution.ambiguous
761
+ ? `你提供的岗位“${selectedJobHint}”匹配到多个选项:${selectedJobResolution.candidates.join(" / ")}。请明确选择其中一个岗位。`
762
+ : selectedJobHint
763
+ ? `未在当前岗位列表中找到“${selectedJobHint}”,请从以下岗位中重新确认一个。`
764
+ : "已识别当前推荐页岗位列表,请先确认本次要执行的岗位;确认后会先点击该岗位,再开始 search 和 screen。";
765
+ const pendingQuestions = (parsed.pending_questions || []).filter((item) => item?.field !== "job");
766
+ pendingQuestions.push(buildJobPendingQuestion(jobOptions, selectedJobHint, reason));
767
+ const requiredConfirmations = dedupe([...buildRequiredConfirmations(parsed), "job"]);
768
+ return {
769
+ status: "NEED_CONFIRMATION",
770
+ required_confirmations: requiredConfirmations,
771
+ search_params: parsed.searchParams,
772
+ screen_params: {
773
+ ...parsed.screenParams,
774
+ target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
775
+ post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
776
+ max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
777
+ },
778
+ pending_questions: pendingQuestions,
779
+ review: parsed.review,
780
+ job_options: jobOptions
781
+ };
782
+ }
783
+ const selectedJob = selectedJobResolution.job;
784
+ const selectedJobToken = selectedJob.value || selectedJob.title || selectedJob.label;
785
+ if (confirmation?.final_confirmed !== true) {
786
+ const pendingQuestions = (parsed.pending_questions || []).filter((item) => item?.field !== "final_review");
787
+ pendingQuestions.push(buildFinalReviewQuestion({
788
+ searchParams: parsed.searchParams,
789
+ screenParams: {
790
+ ...parsed.screenParams,
791
+ target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
792
+ post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
793
+ max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
794
+ },
795
+ selectedJob
796
+ }));
797
+ return {
798
+ status: "NEED_CONFIRMATION",
799
+ required_confirmations: dedupe([...buildRequiredConfirmations(parsed), "final_review"]),
800
+ search_params: parsed.searchParams,
801
+ screen_params: {
802
+ ...parsed.screenParams,
803
+ target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
804
+ post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
805
+ max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
806
+ },
807
+ selected_job: selectedJob,
808
+ pending_questions: pendingQuestions,
809
+ review: parsed.review,
810
+ job_options: jobOptions
811
+ };
812
+ }
813
+
814
+ const resumeCompletionReason = normalizeText(resume?.previous_completion_reason || "").toLowerCase();
815
+ const isResumeRun = resume?.resume === true;
816
+ const resumeFromPausedBeforeScreen = isResumeRun && resumeCompletionReason === "paused_before_screen";
817
+ const skipSearchOnResume = isResumeRun && !resumeFromPausedBeforeScreen;
818
+ let effectiveSearchParams = { ...parsed.searchParams };
819
+ let searchSummary = null;
820
+ let shouldRunSearch = !skipSearchOnResume;
821
+ let screenAutoRecoveryCount = 0;
822
+ let lastAutoRecovery = null;
823
+ let currentResumeConfig = {
824
+ checkpoint_path: resume?.checkpoint_path || null,
825
+ pause_control_path: resume?.pause_control_path || null,
826
+ output_csv: resume?.output_csv || null,
827
+ resume: resume?.resume === true,
828
+ require_checkpoint: skipSearchOnResume
829
+ };
561
830
 
562
- if (!preflight.ok) {
563
- runtimeHooks.heartbeat("preflight", {
564
- status: "failed"
565
- });
566
- const screenConfigCheck = preflight.checks?.find((item) => item?.key === "screen_config" && item?.ok === false);
567
- const screenConfigPath = String(screenConfigCheck?.path || "");
568
- const screenConfigDir = screenConfigPath ? path.dirname(screenConfigPath) : null;
569
- const screenConfigReason = String(screenConfigCheck?.reason || "").trim().toUpperCase();
570
- const screenConfigMessage = String(screenConfigCheck?.message || "");
571
- const screenConfigHasPlaceholder = (
572
- screenConfigReason.includes("PLACEHOLDER")
573
- || /占位符|默认模板值|replace-with-openai-api-key/i.test(screenConfigMessage)
574
- );
575
- const recovery = buildPreflightRecovery(preflight.checks, workspaceRoot);
576
- return buildFailedResponse(
577
- "PIPELINE_PREFLIGHT_FAILED",
578
- "Recommend 流水线运行前检查失败,请先修复缺失的本地依赖或配置文件。",
579
- {
580
- search_params: parsed.searchParams,
581
- screen_params: parsed.screenParams,
582
- required_user_action: screenConfigCheck
583
- ? (screenConfigHasPlaceholder ? "confirm_screening_config_updated" : "provide_screening_config")
584
- : undefined,
585
- guidance: screenConfigCheck
586
- ? {
587
- config_path: screenConfigCheck.path,
588
- config_dir: screenConfigDir,
589
- agent_prompt: [
590
- ...(screenConfigHasPlaceholder
591
- ? [
592
- "检测到 screening-config.json 仍包含默认占位词,当前禁止继续执行。",
593
- `请引导用户在以下目录修改配置文件:${screenConfigDir || "(unknown)"}`,
594
- `配置文件路径:${screenConfigCheck.path}`,
595
- "必须替换为真实可用值:baseUrl、apiKey、model(不要保留任何模板占位符)。",
596
- "修改完成后,必须先让用户明确回复“已修改完成”,再继续下一步。"
597
- ]
598
- : [
599
- "请先让用户填写 screening-config.json 的以下字段:",
600
- "1) baseUrl",
601
- "2) apiKey",
602
- "3) model",
603
- `配置文件路径:${screenConfigCheck.path}`,
604
- "注意:不要使用模板占位符(例如 replace-with-openai-api-key),也不要由 agent 自行猜测或代填示例值。必须向用户逐项确认真实可用值后再重试。"
605
- ])
606
- ].join("\n")
607
- }
608
- : undefined,
609
- diagnostics: {
610
- checks: preflight.checks,
611
- debug_port: preflight.debug_port,
612
- config_resolution: preflight.config_resolution,
613
- auto_repair: autoRepair,
614
- recovery
615
- }
831
+ while (true) {
832
+ if (shouldRunSearch) {
833
+ ensurePipelineNotAborted(runtimeHooks.signal);
834
+ runtimeHooks.setStage(
835
+ "search",
836
+ screenAutoRecoveryCount > 0
837
+ ? `自动恢复第 ${screenAutoRecoveryCount} 次:重新执行 recommend search(强制 recent_not_view=${FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY})。`
838
+ : "岗位已确认,开始执行 recommend search。"
839
+ );
840
+ runtimeHooks.heartbeat("search", lastAutoRecovery);
841
+ const searchResult = await searchCli({
842
+ workspaceRoot,
843
+ searchParams: effectiveSearchParams,
844
+ selectedJob: selectedJobToken,
845
+ runtime: runtimeHooks.adapterRuntime("search")
846
+ });
847
+ ensurePipelineNotAborted(runtimeHooks.signal);
848
+ if (isProcessAbortError(searchResult.error)) {
849
+ throw new PipelineAbortError(searchResult.error?.message || "推荐筛选已取消。");
616
850
  }
617
- );
618
- }
619
-
620
- ensurePipelineNotAborted(runtimeHooks.signal);
621
- runtimeHooks.setStage("page_ready", "preflight 完成,开始检查 recommend 页面就绪状态。");
622
- runtimeHooks.heartbeat("page_ready");
623
-
624
- const pageCheck = await ensureRecommendPageReady(workspaceRoot, {
625
- port: preflight.debug_port
626
- });
627
- ensurePipelineNotAborted(runtimeHooks.signal);
628
- if (!pageCheck.ok) {
629
- const loginRelated = new Set(["LOGIN_REQUIRED", "LOGIN_REQUIRED_AFTER_REDIRECT"]);
630
- const connectivityRelated = new Set(["DEBUG_PORT_UNREACHABLE"]);
631
- const guidance = buildChromeSetupGuidance({
632
- debugPort: preflight.debug_port,
633
- pageState: pageCheck.page_state
634
- });
635
- return buildFailedResponse(
636
- connectivityRelated.has(pageCheck.state)
637
- ? "BOSS_CHROME_NOT_CONNECTED"
638
- : loginRelated.has(pageCheck.state)
639
- ? "BOSS_LOGIN_REQUIRED"
640
- : "BOSS_RECOMMEND_PAGE_NOT_READY",
641
- loginRelated.has(pageCheck.state)
642
- ? `开始执行搜索和筛选前,请先在端口 ${preflight.debug_port} 的 Chrome 完成 Boss 登录并停留在 recommend 页面。`
643
- : connectivityRelated.has(pageCheck.state)
644
- ? `开始执行搜索和筛选前,需要先连接到端口 ${preflight.debug_port} 的 Chrome 远程调试实例。`
645
- : `开始执行搜索和筛选前,请先在端口 ${preflight.debug_port} 的 Chrome 停留在 Boss recommend 页面。`,
646
- {
647
- search_params: parsed.searchParams,
648
- screen_params: parsed.screenParams,
649
- required_user_action: "prepare_boss_recommend_page",
650
- guidance,
651
- diagnostics: {
652
- debug_port: preflight.debug_port,
653
- page_state: pageCheck.page_state
851
+ if (!searchResult.ok) {
852
+ const searchErrorCode = String(searchResult.error?.code || "");
853
+ const searchErrorMessage = String(searchResult.error?.message || "");
854
+ const loginRelatedSearchFailure = (
855
+ searchErrorCode === "LOGIN_REQUIRED"
856
+ || searchErrorCode === "NO_RECOMMEND_IFRAME"
857
+ || searchErrorMessage.includes("LOGIN_REQUIRED")
858
+ || searchErrorMessage.includes("NO_RECOMMEND_IFRAME")
859
+ );
860
+ if (loginRelatedSearchFailure) {
861
+ const recheck = await ensureRecommendPageReady(workspaceRoot, {
862
+ port: preflight.debug_port
863
+ });
864
+ if (recheck.state === "LOGIN_REQUIRED" || recheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT") {
865
+ const guidance = buildChromeSetupGuidance({
866
+ debugPort: preflight.debug_port,
867
+ pageState: recheck.page_state
868
+ });
869
+ return buildFailedResponse(
870
+ "BOSS_LOGIN_REQUIRED",
871
+ "检测到当前 Boss 处于未登录状态,请先登录后再继续。登录页:https://www.zhipin.com/web/user/?ka=bticket",
872
+ {
873
+ search_params: effectiveSearchParams,
874
+ screen_params: parsed.screenParams,
875
+ selected_job: selectedJob,
876
+ required_user_action: "prepare_boss_recommend_page",
877
+ guidance,
878
+ diagnostics: {
879
+ debug_port: preflight.debug_port,
880
+ page_state: recheck.page_state,
881
+ stdout: searchResult.stdout?.slice(-1000),
882
+ stderr: searchResult.stderr?.slice(-1000),
883
+ result: searchResult.structured || null,
884
+ auto_recovery: lastAutoRecovery
885
+ }
886
+ }
887
+ );
888
+ }
654
889
  }
655
- }
656
- );
657
- }
658
-
659
- runtimeHooks.setStage("job_list", "页面就绪,开始读取岗位列表。");
660
- runtimeHooks.heartbeat("job_list");
661
- const jobListResult = await listJobs({
662
- workspaceRoot,
663
- port: preflight.debug_port,
664
- runtime: runtimeHooks.adapterRuntime("job_list")
665
- });
666
- ensurePipelineNotAborted(runtimeHooks.signal);
667
- if (isProcessAbortError(jobListResult.error)) {
668
- throw new PipelineAbortError(jobListResult.error?.message || "岗位列表读取已取消。");
669
- }
670
- if (!jobListResult.ok) {
671
- const jobListErrorCode = String(jobListResult.error?.code || "");
672
- const jobListErrorMessage = String(jobListResult.error?.message || "");
673
- const pageReadinessFailure = (
674
- jobListErrorCode === "JOB_TRIGGER_NOT_FOUND"
675
- || jobListErrorCode === "NO_RECOMMEND_IFRAME"
676
- || jobListErrorCode === "LOGIN_REQUIRED"
677
- || jobListErrorMessage.includes("JOB_TRIGGER_NOT_FOUND")
678
- || jobListErrorMessage.includes("NO_RECOMMEND_IFRAME")
679
- || jobListErrorMessage.includes("LOGIN_REQUIRED")
680
- );
681
- if (pageReadinessFailure) {
682
- const recheck = await ensureRecommendPageReady(workspaceRoot, {
683
- port: preflight.debug_port
684
- });
685
- const loginRelated = new Set(["LOGIN_REQUIRED", "LOGIN_REQUIRED_AFTER_REDIRECT"]);
686
- const connectivityRelated = new Set(["DEBUG_PORT_UNREACHABLE"]);
687
- const guidance = buildChromeSetupGuidance({
688
- debugPort: preflight.debug_port,
689
- pageState: recheck.page_state
690
- });
691
- if (!recheck.ok || loginRelated.has(recheck.state) || connectivityRelated.has(recheck.state)) {
692
890
  return buildFailedResponse(
693
- connectivityRelated.has(recheck.state)
694
- ? "BOSS_CHROME_NOT_CONNECTED"
695
- : loginRelated.has(recheck.state)
696
- ? "BOSS_LOGIN_REQUIRED"
697
- : "BOSS_RECOMMEND_PAGE_NOT_READY",
698
- loginRelated.has(recheck.state)
699
- ? `检测到当前 Boss 处于未登录状态,请先登录后再继续。登录页:https://www.zhipin.com/web/user/?ka=bticket`
700
- : connectivityRelated.has(recheck.state)
701
- ? `读取岗位列表前需要先连接到端口 ${preflight.debug_port} 的 Chrome 远程调试实例。`
702
- : `读取岗位列表前,请先在端口 ${preflight.debug_port} 的 Chrome 停留在 Boss recommend 页面。`,
891
+ searchResult.error?.code || "RECOMMEND_SEARCH_FAILED",
892
+ searchResult.error?.message || "推荐页筛选执行失败。",
703
893
  {
704
- search_params: parsed.searchParams,
894
+ search_params: effectiveSearchParams,
705
895
  screen_params: parsed.screenParams,
706
- required_user_action: "prepare_boss_recommend_page",
707
- guidance,
896
+ selected_job: selectedJob,
708
897
  diagnostics: {
709
898
  debug_port: preflight.debug_port,
710
- page_state: recheck.page_state,
711
- stdout: jobListResult.stdout?.slice(-1000),
712
- stderr: jobListResult.stderr?.slice(-1000),
713
- result: jobListResult.structured || null
899
+ stdout: searchResult.stdout?.slice(-1000),
900
+ stderr: searchResult.stderr?.slice(-1000),
901
+ result: searchResult.structured || null,
902
+ auto_recovery: lastAutoRecovery
714
903
  }
715
904
  }
716
905
  );
717
906
  }
718
- }
719
- return buildFailedResponse(
720
- jobListResult.error?.code || "RECOMMEND_JOB_LIST_FAILED",
721
- jobListResult.error?.message || "读取推荐岗位列表失败,无法开始筛选。",
722
- {
723
- search_params: parsed.searchParams,
724
- screen_params: parsed.screenParams,
725
- diagnostics: {
726
- debug_port: preflight.debug_port,
727
- stdout: jobListResult.stdout?.slice(-1000),
728
- stderr: jobListResult.stderr?.slice(-1000),
729
- result: jobListResult.structured || null
730
- }
731
- }
732
- );
733
- }
734
- const jobOptions = normalizeJobOptions(jobListResult.jobs);
735
- if (jobOptions.length === 0) {
736
- return buildFailedResponse(
737
- "RECOMMEND_JOB_LIST_EMPTY",
738
- "未识别到可选岗位,暂时无法开始筛选。",
739
- {
740
- search_params: parsed.searchParams,
741
- screen_params: parsed.screenParams,
742
- diagnostics: {
743
- debug_port: preflight.debug_port,
744
- result: jobListResult.structured || null
745
- }
746
- }
747
- );
748
- }
749
-
750
- const selectedJobHint = normalizeText(confirmation?.job_value || parsed.job_selection_hint || "");
751
- const selectedJobResolution = resolveSelectedJob(jobOptions, selectedJobHint);
752
- const jobConfirmed = confirmation?.job_confirmed === true;
753
- if (!jobConfirmed || !selectedJobResolution.job) {
754
- const reason = selectedJobResolution.ambiguous
755
- ? `你提供的岗位“${selectedJobHint}”匹配到多个选项:${selectedJobResolution.candidates.join(" / ")}。请明确选择其中一个岗位。`
756
- : selectedJobHint
757
- ? `未在当前岗位列表中找到“${selectedJobHint}”,请从以下岗位中重新确认一个。`
758
- : "已识别当前推荐页岗位列表,请先确认本次要执行的岗位;确认后会先点击该岗位,再开始 search 和 screen。";
759
- const pendingQuestions = (parsed.pending_questions || []).filter((item) => item?.field !== "job");
760
- pendingQuestions.push(buildJobPendingQuestion(jobOptions, selectedJobHint, reason));
761
- const requiredConfirmations = dedupe([...buildRequiredConfirmations(parsed), "job"]);
762
- return {
763
- status: "NEED_CONFIRMATION",
764
- required_confirmations: requiredConfirmations,
765
- search_params: parsed.searchParams,
766
- screen_params: {
767
- ...parsed.screenParams,
768
- target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
769
- post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
770
- max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
771
- },
772
- pending_questions: pendingQuestions,
773
- review: parsed.review,
774
- job_options: jobOptions
775
- };
776
- }
777
- const selectedJob = selectedJobResolution.job;
778
- const selectedJobToken = selectedJob.value || selectedJob.title || selectedJob.label;
779
- if (confirmation?.final_confirmed !== true) {
780
- const pendingQuestions = (parsed.pending_questions || []).filter((item) => item?.field !== "final_review");
781
- pendingQuestions.push(buildFinalReviewQuestion({
782
- searchParams: parsed.searchParams,
783
- screenParams: {
784
- ...parsed.screenParams,
785
- target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
786
- post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
787
- max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
788
- },
789
- selectedJob
790
- }));
791
- return {
792
- status: "NEED_CONFIRMATION",
793
- required_confirmations: dedupe([...buildRequiredConfirmations(parsed), "final_review"]),
794
- search_params: parsed.searchParams,
795
- screen_params: {
796
- ...parsed.screenParams,
797
- target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
798
- post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
799
- max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
800
- },
801
- selected_job: selectedJob,
802
- pending_questions: pendingQuestions,
803
- review: parsed.review,
804
- job_options: jobOptions
805
- };
806
- }
807
907
 
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;
908
+ searchSummary = searchResult.summary || {};
909
+ if (isPauseRequested(runtimeHooks)) {
910
+ return buildPausedResponse("已在 screen 阶段开始前暂停 Recommend 流水线。", {
911
+ search_params: effectiveSearchParams,
912
+ screen_params: parsed.screenParams,
913
+ selected_job: selectedJob,
914
+ partial_result: {
915
+ candidate_count: searchSummary.candidate_count ?? null,
916
+ applied_filters: searchSummary.applied_filters || effectiveSearchParams,
917
+ output_csv: currentResumeConfig.output_csv || null,
918
+ completion_reason: "paused_before_screen"
919
+ }
920
+ });
921
+ }
922
+ ensurePipelineNotAborted(runtimeHooks.signal);
923
+ runtimeHooks.setStage("screen", "search 完成,开始执行 recommend screen。");
924
+ } else {
925
+ ensurePipelineNotAborted(runtimeHooks.signal);
926
+ runtimeHooks.setStage("screen", "检测到可续跑 checkpoint,跳过 search,直接恢复 recommend screen。");
927
+ }
813
928
 
814
- if (!skipSearchOnResume) {
815
- ensurePipelineNotAborted(runtimeHooks.signal);
816
- runtimeHooks.setStage("search", "岗位已确认,开始执行 recommend search。");
817
- runtimeHooks.heartbeat("search");
818
- const searchResult = await searchCli({
929
+ runtimeHooks.heartbeat("screen", lastAutoRecovery);
930
+ const screenResult = await screenCli({
819
931
  workspaceRoot,
820
- searchParams: parsed.searchParams,
821
- selectedJob: selectedJobToken,
822
- runtime: runtimeHooks.adapterRuntime("search")
932
+ screenParams: parsed.screenParams,
933
+ resume: currentResumeConfig,
934
+ runtime: runtimeHooks.adapterRuntime("screen")
823
935
  });
824
936
  ensurePipelineNotAborted(runtimeHooks.signal);
825
- if (isProcessAbortError(searchResult.error)) {
826
- throw new PipelineAbortError(searchResult.error?.message || "推荐筛选已取消。");
937
+ if (isProcessAbortError(screenResult.error)) {
938
+ throw new PipelineAbortError(screenResult.error?.message || "推荐筛选已取消。");
939
+ }
940
+ if (screenResult.paused) {
941
+ return buildPausedResponse("Recommend 流水线已暂停,可使用 resume 继续。", {
942
+ search_params: effectiveSearchParams,
943
+ screen_params: parsed.screenParams,
944
+ selected_job: selectedJob,
945
+ partial_result: screenResult.summary || screenResult.structured?.result || null
946
+ });
827
947
  }
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")
948
+ if (!screenResult.ok) {
949
+ const partialScreenResult = screenResult.summary || screenResult.structured?.result || null;
950
+ const resumeOutputCsv = normalizeText(partialScreenResult?.output_csv || currentResumeConfig.output_csv || "");
951
+ const hasCheckpointForRecovery = Boolean(normalizeText(currentResumeConfig.checkpoint_path || ""));
952
+ const shouldAutoRecover = (
953
+ screenResult.error?.code === "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT"
954
+ && screenAutoRecoveryCount < MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS
955
+ && hasCheckpointForRecovery
956
+ && Boolean(resumeOutputCsv)
836
957
  );
837
- if (loginRelatedSearchFailure) {
838
- const recheck = await ensureRecommendPageReady(workspaceRoot, {
958
+
959
+ if (shouldAutoRecover) {
960
+ screenAutoRecoveryCount += 1;
961
+ effectiveSearchParams = {
962
+ ...effectiveSearchParams,
963
+ recent_not_view: FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY
964
+ };
965
+ lastAutoRecovery = {
966
+ trigger: "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT",
967
+ attempt: screenAutoRecoveryCount,
968
+ max_attempts: MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS,
969
+ original_recent_not_view: parsed.searchParams.recent_not_view,
970
+ effective_recent_not_view: effectiveSearchParams.recent_not_view,
971
+ partial_result: partialScreenResult
972
+ ? {
973
+ processed_count: partialScreenResult.processed_count ?? null,
974
+ passed_count: partialScreenResult.passed_count ?? null,
975
+ skipped_count: partialScreenResult.skipped_count ?? null,
976
+ output_csv: partialScreenResult.output_csv || currentResumeConfig.output_csv || null
977
+ }
978
+ : null
979
+ };
980
+
981
+ runtimeHooks.setStage(
982
+ "screen_recovery",
983
+ `screen 连续截图失败,开始自动恢复(第 ${screenAutoRecoveryCount} 次):刷新 recommend 页面并重跑 search。`
984
+ );
985
+ runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
986
+
987
+ const reloadResult = typeof reloadRecommendPage === "function"
988
+ ? await reloadRecommendPage(workspaceRoot, {
989
+ port: preflight.debug_port
990
+ })
991
+ : null;
992
+ ensurePipelineNotAborted(runtimeHooks.signal);
993
+
994
+ lastAutoRecovery = {
995
+ ...lastAutoRecovery,
996
+ reload: reloadResult
997
+ ? {
998
+ ok: reloadResult.ok,
999
+ state: reloadResult.state || null,
1000
+ message: reloadResult.message || null,
1001
+ reloaded_url: reloadResult.reloaded_url || null
1002
+ }
1003
+ : null
1004
+ };
1005
+
1006
+ const recoveryPageCheck = await ensureRecommendPageReady(workspaceRoot, {
839
1007
  port: preflight.debug_port
840
1008
  });
841
- if (recheck.state === "LOGIN_REQUIRED" || recheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT") {
1009
+ ensurePipelineNotAborted(runtimeHooks.signal);
1010
+ if (!recoveryPageCheck.ok) {
842
1011
  const guidance = buildChromeSetupGuidance({
843
1012
  debugPort: preflight.debug_port,
844
- pageState: recheck.page_state
1013
+ pageState: recoveryPageCheck.page_state
845
1014
  });
846
1015
  return buildFailedResponse(
847
- "BOSS_LOGIN_REQUIRED",
848
- "检测到当前 Boss 处于未登录状态,请先登录后再继续。登录页:https://www.zhipin.com/web/user/?ka=bticket",
1016
+ recoveryPageCheck.state === "LOGIN_REQUIRED" || recoveryPageCheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
1017
+ ? "BOSS_LOGIN_REQUIRED"
1018
+ : recoveryPageCheck.state === "DEBUG_PORT_UNREACHABLE"
1019
+ ? "BOSS_CHROME_NOT_CONNECTED"
1020
+ : "BOSS_RECOMMEND_PAGE_NOT_READY",
1021
+ "自动恢复时无法重新就绪 recommend 页面,请先处理页面状态后再继续。",
849
1022
  {
850
- search_params: parsed.searchParams,
1023
+ search_params: effectiveSearchParams,
851
1024
  screen_params: parsed.screenParams,
852
1025
  selected_job: selectedJob,
1026
+ partial_result: partialScreenResult,
853
1027
  required_user_action: "prepare_boss_recommend_page",
854
1028
  guidance,
855
1029
  diagnostics: {
856
1030
  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
1031
+ page_state: recoveryPageCheck.page_state,
1032
+ stdout: screenResult.stdout?.slice(-1000),
1033
+ stderr: screenResult.stderr?.slice(-1000),
1034
+ result: screenResult.structured || null,
1035
+ auto_recovery: lastAutoRecovery
861
1036
  }
862
1037
  }
863
1038
  );
864
1039
  }
1040
+
1041
+ currentResumeConfig = {
1042
+ checkpoint_path: currentResumeConfig.checkpoint_path || null,
1043
+ pause_control_path: currentResumeConfig.pause_control_path || null,
1044
+ output_csv: resumeOutputCsv || null,
1045
+ resume: true,
1046
+ require_checkpoint: true
1047
+ };
1048
+ shouldRunSearch = true;
1049
+ searchSummary = null;
1050
+ continue;
865
1051
  }
1052
+
866
1053
  return buildFailedResponse(
867
- searchResult.error?.code || "RECOMMEND_SEARCH_FAILED",
868
- searchResult.error?.message || "推荐页筛选执行失败。",
1054
+ screenResult.error?.code || "RECOMMEND_SCREEN_FAILED",
1055
+ screenResult.error?.message || "推荐页筛选执行失败。",
869
1056
  {
870
- search_params: parsed.searchParams,
1057
+ search_params: effectiveSearchParams,
871
1058
  screen_params: parsed.screenParams,
872
1059
  selected_job: selectedJob,
1060
+ partial_result: partialScreenResult,
873
1061
  diagnostics: {
874
1062
  debug_port: preflight.debug_port,
875
- stdout: searchResult.stdout?.slice(-1000),
876
- stderr: searchResult.stderr?.slice(-1000),
877
- result: searchResult.structured || null
1063
+ stdout: screenResult.stdout?.slice(-1000),
1064
+ stderr: screenResult.stderr?.slice(-1000),
1065
+ result: screenResult.structured || null,
1066
+ auto_recovery: lastAutoRecovery
878
1067
  }
879
1068
  }
880
1069
  );
881
1070
  }
882
1071
 
883
- searchSummary = searchResult.summary || {};
884
- if (isPauseRequested(runtimeHooks)) {
885
- return buildPausedResponse("已在 screen 阶段开始前暂停 Recommend 流水线。", {
886
- search_params: parsed.searchParams,
887
- screen_params: parsed.screenParams,
888
- selected_job: selectedJob,
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。");
902
- }
1072
+ runtimeHooks.setStage("finalize", "screen 完成,正在汇总结果。");
1073
+ runtimeHooks.heartbeat("finalize");
1074
+ const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
1075
+ const finalSearchSummary = searchSummary || {};
1076
+ const screenSummary = screenResult.summary || {};
1077
+ runtimeHooks.progress("finalize", {
1078
+ processed: screenSummary.processed_count ?? 0,
1079
+ passed: screenSummary.passed_count ?? 0,
1080
+ skipped: screenSummary.skipped_count ?? 0,
1081
+ greet_count: screenSummary.greet_count ?? 0
1082
+ });
903
1083
 
904
- runtimeHooks.heartbeat("screen");
905
- const screenResult = await screenCli({
906
- workspaceRoot,
907
- screenParams: parsed.screenParams,
908
- resume: {
909
- checkpoint_path: resume?.checkpoint_path || null,
910
- pause_control_path: resume?.pause_control_path || null,
911
- output_csv: resume?.output_csv || null,
912
- resume: resume?.resume === true,
913
- require_checkpoint: skipSearchOnResume
914
- },
915
- runtime: runtimeHooks.adapterRuntime("screen")
916
- });
917
- ensurePipelineNotAborted(runtimeHooks.signal);
918
- if (isProcessAbortError(screenResult.error)) {
919
- throw new PipelineAbortError(screenResult.error?.message || "推荐筛选已取消。");
920
- }
921
- if (screenResult.paused) {
922
- return buildPausedResponse("Recommend 流水线已暂停,可使用 resume 继续。", {
923
- search_params: parsed.searchParams,
1084
+ return {
1085
+ status: "COMPLETED",
1086
+ search_params: effectiveSearchParams,
924
1087
  screen_params: parsed.screenParams,
925
- selected_job: selectedJob,
926
- partial_result: screenResult.summary || screenResult.structured?.result || null
927
- });
928
- }
929
- if (!screenResult.ok) {
930
- const partialScreenResult = screenResult.summary || screenResult.structured?.result || null;
931
- return buildFailedResponse(
932
- screenResult.error?.code || "RECOMMEND_SCREEN_FAILED",
933
- screenResult.error?.message || "推荐页筛选执行失败。",
934
- {
935
- search_params: parsed.searchParams,
936
- screen_params: parsed.screenParams,
937
- selected_job: selectedJob,
938
- partial_result: partialScreenResult,
939
- diagnostics: {
940
- debug_port: preflight.debug_port,
941
- stdout: screenResult.stdout?.slice(-1000),
942
- stderr: screenResult.stderr?.slice(-1000),
943
- result: screenResult.structured || null
944
- }
945
- }
946
- );
1088
+ result: {
1089
+ candidate_count: finalSearchSummary.candidate_count ?? null,
1090
+ applied_filters: finalSearchSummary.applied_filters || effectiveSearchParams,
1091
+ processed_count: screenSummary.processed_count ?? 0,
1092
+ passed_count: screenSummary.passed_count ?? 0,
1093
+ skipped_count: screenSummary.skipped_count ?? 0,
1094
+ duration_sec: durationSec,
1095
+ output_csv: screenSummary.output_csv || null,
1096
+ completion_reason: screenSummary.completion_reason || "screen_completed",
1097
+ page_state: finalSearchSummary.page_state || pageCheck.page_state,
1098
+ selected_job: finalSearchSummary.selected_job || selectedJob,
1099
+ post_action: parsed.screenParams.post_action,
1100
+ max_greet_count: parsed.screenParams.max_greet_count,
1101
+ greet_count: screenSummary.greet_count ?? 0,
1102
+ greet_limit_fallback_count: screenSummary.greet_limit_fallback_count ?? 0,
1103
+ auto_recovery: lastAutoRecovery
1104
+ },
1105
+ message: parsed.screenParams.post_action === "none"
1106
+ ? "Recommend 流水线已完成。本次 post_action=none:符合条件的人选仅记录到 CSV,不执行收藏或打招呼。"
1107
+ : "Recommend 流水线已完成。post_action 在运行开始时已一次性确认;若选择打招呼并设置上限,超出上限后会自动改为收藏。"
1108
+ };
947
1109
  }
948
-
949
- runtimeHooks.setStage("finalize", "screen 完成,正在汇总结果。");
950
- runtimeHooks.heartbeat("finalize");
951
- const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
952
- const finalSearchSummary = searchSummary || {};
953
- const screenSummary = screenResult.summary || {};
954
- runtimeHooks.progress("finalize", {
955
- processed: screenSummary.processed_count ?? 0,
956
- passed: screenSummary.passed_count ?? 0,
957
- skipped: screenSummary.skipped_count ?? 0,
958
- greet_count: screenSummary.greet_count ?? 0
959
- });
960
-
961
- return {
962
- status: "COMPLETED",
963
- search_params: parsed.searchParams,
964
- screen_params: parsed.screenParams,
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,
970
- skipped_count: screenSummary.skipped_count ?? 0,
971
- duration_sec: durationSec,
972
- output_csv: screenSummary.output_csv || null,
973
- completion_reason: screenSummary.completion_reason || "screen_completed",
974
- page_state: finalSearchSummary.page_state || pageCheck.page_state,
975
- selected_job: finalSearchSummary.selected_job || selectedJob,
976
- post_action: parsed.screenParams.post_action,
977
- max_greet_count: parsed.screenParams.max_greet_count,
978
- greet_count: screenSummary.greet_count ?? 0,
979
- greet_limit_fallback_count: screenSummary.greet_limit_fallback_count ?? 0
980
- },
981
- message: "Recommend 流水线已完成。post_action 在运行开始时已一次性确认;若选择打招呼并设置上限,超出上限后会自动改为收藏。"
982
- };
983
- }
1110
+ }