@reconcrap/boss-recommend-mcp 2.1.21 → 2.1.22

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.
Files changed (67) hide show
  1. package/README.md +5 -2
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/config/screening-config.example.json +33 -33
  4. package/package.json +8 -8
  5. package/scripts/install-macos.sh +280 -280
  6. package/scripts/postinstall.cjs +44 -44
  7. package/skills/boss-chat/README.md +42 -42
  8. package/skills/boss-chat/SKILL.md +106 -106
  9. package/skills/boss-recommend-pipeline/README.md +13 -13
  10. package/skills/boss-recommend-pipeline/SKILL.md +219 -214
  11. package/skills/boss-recruit-pipeline/README.md +19 -19
  12. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  13. package/src/chat-mcp.js +127 -127
  14. package/src/chat-runtime-config.js +775 -775
  15. package/src/cli.js +573 -573
  16. package/src/core/boss-cards/index.js +199 -199
  17. package/src/core/browser/index.js +2419 -2415
  18. package/src/core/capture/index.js +1201 -1201
  19. package/src/core/cv-acquisition/index.js +238 -238
  20. package/src/core/cv-capture-target/index.js +299 -299
  21. package/src/core/greet-quota/index.js +71 -71
  22. package/src/core/infinite-list/index.js +1326 -1326
  23. package/src/core/reporting/legacy-csv.js +334 -332
  24. package/src/core/run/index.js +32 -32
  25. package/src/core/run/timing.js +33 -33
  26. package/src/core/screening/index.js +2135 -2135
  27. package/src/core/self-heal/index.js +973 -973
  28. package/src/core/self-heal/viewport.js +564 -564
  29. package/src/detached-worker.js +99 -99
  30. package/src/domains/chat/cards.js +137 -137
  31. package/src/domains/chat/constants.js +9 -9
  32. package/src/domains/chat/detail.js +113 -113
  33. package/src/domains/chat/index.js +7 -7
  34. package/src/domains/chat/jobs.js +620 -620
  35. package/src/domains/chat/page-guard.js +122 -122
  36. package/src/domains/chat/roots.js +56 -56
  37. package/src/domains/chat/run-service.js +571 -571
  38. package/src/domains/common/account-rights-panel.js +314 -314
  39. package/src/domains/common/recovery-settle.js +159 -159
  40. package/src/domains/recommend/actions.js +472 -472
  41. package/src/domains/recommend/cards.js +243 -243
  42. package/src/domains/recommend/colleague-contact.js +333 -333
  43. package/src/domains/recommend/constants.js +228 -159
  44. package/src/domains/recommend/detail.js +650 -650
  45. package/src/domains/recommend/filters.js +748 -377
  46. package/src/domains/recommend/index.js +4 -3
  47. package/src/domains/recommend/jobs.js +542 -542
  48. package/src/domains/recommend/location.js +736 -0
  49. package/src/domains/recommend/refresh.js +504 -361
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +987 -854
  52. package/src/domains/recommend/scopes.js +246 -246
  53. package/src/domains/recruit/actions.js +277 -277
  54. package/src/domains/recruit/cards.js +74 -74
  55. package/src/domains/recruit/constants.js +236 -236
  56. package/src/domains/recruit/detail.js +588 -588
  57. package/src/domains/recruit/index.js +9 -9
  58. package/src/domains/recruit/instruction-parser.js +866 -866
  59. package/src/domains/recruit/refresh.js +45 -45
  60. package/src/domains/recruit/roots.js +68 -68
  61. package/src/domains/recruit/run-service.js +1620 -1620
  62. package/src/domains/recruit/search.js +3229 -3229
  63. package/src/index.js +13 -0
  64. package/src/parser.js +376 -8
  65. package/src/recommend-mcp.js +929 -915
  66. package/src/recommend-scheduler.js +496 -496
  67. package/src/recruit-mcp.js +2121 -2121
@@ -1,2121 +1,2121 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { spawn } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import {
7
- assertNoForbiddenCdpCalls,
8
- bringPageToFront,
9
- connectToChromeTargetOrOpen,
10
- createBossLoginRequiredError,
11
- detectBossLoginState,
12
- enableDomains,
13
- getMainFrameUrl,
14
- isBossLoginUrl,
15
- waitForMainFrameUrl,
16
- sleep
17
- } from "./core/browser/index.js";
18
- import {
19
- RUN_STATUS_CANCELING,
20
- RUN_STATUS_CANCELED,
21
- RUN_STATUS_COMPLETED,
22
- RUN_STATUS_FAILED,
23
- RUN_STATUS_PAUSED,
24
- RUN_STATUS_RUNNING
25
- } from "./core/run/index.js";
26
- import {
27
- buildLegacyScreenInputRows,
28
- cloneReportInput,
29
- writeLegacyScreenCsv
30
- } from "./core/reporting/legacy-csv.js";
31
- import {
32
- createRecruitRunService,
33
- parseRecruitInstruction,
34
- RECRUIT_TARGET_URL,
35
- runRecruitWorkflow,
36
- waitForRecruitSearchControls
37
- } from "./domains/recruit/index.js";
38
- import {
39
- resolveBossConfiguredOutputDir,
40
- resolveHumanBehaviorForRun,
41
- resolveBossScreeningConfig
42
- } from "./chat-runtime-config.js";
43
- import { DEFAULT_MAX_IMAGE_PAGES } from "./core/cv-acquisition/index.js";
44
-
45
- const RUN_MODE_ASYNC = "async";
46
- const RUN_MODE_SYNC = "sync";
47
- const DEFAULT_RECRUIT_POLL_AFTER_SEC = 10;
48
- const DEFAULT_RECRUIT_HOST = "127.0.0.1";
49
- const DEFAULT_RECRUIT_PORT = 9222;
50
- const TARGET_COUNT_SEMANTICS = "target_count means candidates that pass screening; scan continues until that many candidates pass or the list ends";
51
- const DEFAULT_RECRUIT_HOME_DIR = ".boss-recruit-mcp";
52
- const DETACHED_WORKER_SCRIPT = fileURLToPath(new URL("./detached-worker.js", import.meta.url));
53
- const DETACHED_WORKER_POLL_MS = 1000;
54
-
55
- const TERMINAL_STATUSES = new Set([
56
- RUN_STATUS_COMPLETED,
57
- RUN_STATUS_FAILED,
58
- RUN_STATUS_CANCELED
59
- ]);
60
- const STALE_PROCESS_STATUSES = new Set([
61
- "queued",
62
- "running",
63
- RUN_STATUS_CANCELING
64
- ]);
65
-
66
- let recruitWorkflowImpl = runRecruitWorkflow;
67
- let recruitConnectorImpl = connectRecruitChromeSession;
68
- let recruitRunService = createRecruitRunService({
69
- idPrefix: "mcp_recruit",
70
- workflow: (...args) => recruitWorkflowImpl(...args),
71
- onSnapshot: persistRecruitLifecycleSnapshot
72
- });
73
- const recruitRunMeta = new Map();
74
-
75
- function normalizeText(value) {
76
- return String(value || "").replace(/\s+/g, " ").trim();
77
- }
78
-
79
- function parsePositiveInteger(raw, fallback) {
80
- const parsed = Number.parseInt(String(raw || ""), 10);
81
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
82
- }
83
-
84
- function parseNonNegativeInteger(raw, fallback) {
85
- const parsed = Number.parseInt(String(raw ?? ""), 10);
86
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
87
- }
88
-
89
- function isDebugTestMode(args = {}) {
90
- return args.debug_test_mode === true || args.allow_debug_test_mode === true;
91
- }
92
-
93
- function normalizeScreeningModeArg(args = {}) {
94
- const raw = normalizeText(args.screening_mode || args.screeningMode || "");
95
- if (args.use_llm === false) return "deterministic";
96
- return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
97
- ? "deterministic"
98
- : "llm";
99
- }
100
-
101
- function collectRecruitDebugTestOptions(args = {}) {
102
- const reasons = [];
103
- if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
104
- if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
105
- if (args.dry_run_post_action === true) reasons.push("dry_run_post_action");
106
- if (args.execute_post_action === false) reasons.push("execute_post_action=false");
107
- return reasons;
108
- }
109
-
110
- function methodSummary(methodLog = []) {
111
- const summary = {};
112
- for (const entry of methodLog || []) {
113
- summary[entry.method] = (summary[entry.method] || 0) + 1;
114
- }
115
- return summary;
116
- }
117
-
118
- function normalizeExecutionMode(value) {
119
- return normalizeText(value).toLowerCase() === RUN_MODE_SYNC ? RUN_MODE_SYNC : RUN_MODE_ASYNC;
120
- }
121
-
122
- function clonePlain(value, fallback = null) {
123
- try {
124
- return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
125
- } catch {
126
- return fallback;
127
- }
128
- }
129
-
130
- function normalizeRunId(runId) {
131
- const normalized = normalizeText(runId);
132
- if (!normalized || normalized.includes("/") || normalized.includes("\\")) return "";
133
- return normalized;
134
- }
135
-
136
- function getRecruitStateHome() {
137
- const fromEnv = normalizeText(globalThis.process?.env?.BOSS_RECRUIT_HOME || "");
138
- return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), DEFAULT_RECRUIT_HOME_DIR);
139
- }
140
-
141
- function getRecruitRunsDir() {
142
- return path.join(getRecruitStateHome(), "runs");
143
- }
144
-
145
- function getRecruitRunArtifacts(runId) {
146
- const normalized = normalizeRunId(runId);
147
- if (!normalized) return null;
148
- const runsDir = getRecruitRunsDir();
149
- const outputDir = resolveBossConfiguredOutputDir("", runsDir);
150
- return {
151
- runs_dir: runsDir,
152
- output_dir: outputDir,
153
- run_state_path: path.join(runsDir, `${normalized}.json`),
154
- detached_args_path: path.join(runsDir, `${normalized}.detached-args.json`),
155
- worker_stdout_path: path.join(runsDir, `${normalized}.worker.stdout.log`),
156
- worker_stderr_path: path.join(runsDir, `${normalized}.worker.stderr.log`),
157
- checkpoint_path: path.join(runsDir, `${normalized}.checkpoint.json`),
158
- output_csv: path.join(outputDir, `${normalized}.results.csv`),
159
- report_json: path.join(outputDir, `${normalized}.report.json`)
160
- };
161
- }
162
-
163
- function ensureDirectory(dirPath) {
164
- fs.mkdirSync(dirPath, { recursive: true });
165
- }
166
-
167
- function writeJsonAtomic(filePath, payload) {
168
- ensureDirectory(path.dirname(filePath));
169
- const tempPath = `${filePath}.tmp`;
170
- fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
171
- fs.renameSync(tempPath, filePath);
172
- }
173
-
174
- function readJsonFile(filePath) {
175
- try {
176
- if (!fs.existsSync(filePath)) return null;
177
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
178
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
179
- } catch {
180
- return null;
181
- }
182
- }
183
-
184
- function selectedRecruitJobForCsv(meta = {}) {
185
- const keyword = normalizeText(
186
- meta.parsed?.proposed_keyword
187
- || meta.parsed?.searchParams?.keyword
188
- || meta.args?.confirmation?.keyword_value
189
- || meta.args?.overrides?.keyword
190
- || ""
191
- );
192
- return {
193
- value: keyword,
194
- title: keyword,
195
- label: keyword
196
- };
197
- }
198
-
199
- function buildRecruitCsvInputRows(snapshot = {}, meta = {}) {
200
- const searchParams = meta.parsed?.searchParams || snapshot.summary?.search_params || {};
201
- const screenParams = meta.parsed?.screenParams || {};
202
- return buildLegacyScreenInputRows({
203
- instruction: meta.args?.instruction || "",
204
- selectedPage: "search",
205
- selectedJob: selectedRecruitJobForCsv(meta),
206
- userSearchParams: cloneReportInput(searchParams, {}),
207
- effectiveSearchParams: cloneReportInput(searchParams, {}),
208
- screenParams: {
209
- criteria: screenParams.criteria || "",
210
- target_count: screenParams.target_count || snapshot.progress?.target_count || snapshot.context?.max_candidates || "",
211
- post_action: screenParams.post_action || "none",
212
- max_greet_count: screenParams.max_greet_count ?? ""
213
- },
214
- followUp: meta.args?.follow_up || meta.args?.overrides?.follow_up || null
215
- });
216
- }
217
-
218
- function writeRecruitLegacyCsvAtomic(filePath, rows = [], snapshot = {}, meta = {}) {
219
- writeLegacyScreenCsv(filePath, {
220
- inputRows: buildRecruitCsvInputRows(snapshot, meta),
221
- results: rows
222
- });
223
- }
224
-
225
- function readRecruitRunState(runId) {
226
- const artifacts = getRecruitRunArtifacts(runId);
227
- if (!artifacts) return null;
228
- return readJsonFile(artifacts.run_state_path);
229
- }
230
-
231
- function writeRecruitRunState(runId, payload) {
232
- const artifacts = getRecruitRunArtifacts(runId);
233
- if (!artifacts) return null;
234
- writeJsonAtomic(artifacts.run_state_path, payload);
235
- return payload;
236
- }
237
-
238
- function createDetachedRecruitRunId() {
239
- return `mcp_recruit_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
240
- }
241
-
242
- function isPidAlive(pid) {
243
- const numericPid = Number(pid);
244
- if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
245
- if (numericPid === globalThis.process?.pid) return true;
246
- try {
247
- globalThis.process.kill(numericPid, 0);
248
- return true;
249
- } catch (error) {
250
- return error?.code === "EPERM";
251
- }
252
- }
253
-
254
- function buildInitialRecruitDetachedState(runId, {
255
- workspaceRoot = "",
256
- args = {},
257
- parsed = {},
258
- pid = globalThis.process?.pid
259
- } = {}) {
260
- const artifacts = getRecruitRunArtifacts(runId);
261
- const now = new Date().toISOString();
262
- const targetCount = parsePositiveInteger(args.max_candidates, parsed.screenParams?.target_count || 10);
263
- return {
264
- run_id: runId,
265
- mode: RUN_MODE_ASYNC,
266
- state: "queued",
267
- status: "queued",
268
- stage: "queued",
269
- started_at: now,
270
- updated_at: now,
271
- heartbeat_at: now,
272
- completed_at: null,
273
- pid: Number.isInteger(pid) && pid > 0 ? pid : globalThis.process?.pid || null,
274
- progress: {
275
- target_count: targetCount,
276
- processed: 0,
277
- screened: 0,
278
- detail_opened: 0,
279
- llm_screened: 0,
280
- passed: 0,
281
- skipped: 0,
282
- greet_count: 0
283
- },
284
- last_message: "Boss search detached worker is queued.",
285
- context: {
286
- domain: "recruit",
287
- target_url: RECRUIT_TARGET_URL,
288
- workspace_root: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
289
- instruction: args.instruction || "",
290
- confirmation: clonePlain(args.confirmation || {}, {}),
291
- overrides: clonePlain(args.overrides || {}, {}),
292
- search_params: clonePlain(parsed.searchParams || {}, {}),
293
- criteria_present: Boolean(parsed.screenParams?.criteria),
294
- max_candidates: targetCount,
295
- target_count_semantics: TARGET_COUNT_SEMANTICS,
296
- detached_worker: true,
297
- rounds: []
298
- },
299
- control: {
300
- pause_requested: false,
301
- pause_requested_at: null,
302
- pause_requested_by: null,
303
- cancel_requested: false
304
- },
305
- resume: {
306
- checkpoint_path: artifacts?.checkpoint_path || null,
307
- pause_control_path: artifacts?.run_state_path || null,
308
- output_csv: null,
309
- worker_stdout_path: artifacts?.worker_stdout_path || null,
310
- worker_stderr_path: artifacts?.worker_stderr_path || null,
311
- resume_count: 0,
312
- last_resumed_at: null,
313
- last_paused_at: null
314
- },
315
- error: null,
316
- result: null,
317
- summary: null,
318
- artifacts
319
- };
320
- }
321
-
322
- function patchPersistedRecruitControl(runId, controlPatch = {}, {
323
- status = "RUN_STATUS",
324
- message = "",
325
- lastMessage = ""
326
- } = {}) {
327
- const current = readRecruitRunState(runId);
328
- if (!current) return null;
329
- const state = normalizeText(current.state || current.status);
330
- if (TERMINAL_STATUSES.has(state)) return null;
331
- const now = new Date().toISOString();
332
- const patched = {
333
- ...current,
334
- updated_at: now,
335
- heartbeat_at: now,
336
- last_message: lastMessage || message || current.last_message || "",
337
- control: {
338
- ...(current.control || {}),
339
- ...controlPatch
340
- }
341
- };
342
- writeRecruitRunState(runId, patched);
343
- return {
344
- status,
345
- run: patched,
346
- message,
347
- persistence: {
348
- source: "disk",
349
- active_control_available: false,
350
- detached_control_requested: true
351
- },
352
- runtime_evaluate_used: false,
353
- method_summary: {},
354
- method_log: [],
355
- chrome: null
356
- };
357
- }
358
-
359
- function launchDetachedRecruitWorker(runId) {
360
- const artifacts = getRecruitRunArtifacts(runId);
361
- if (!artifacts) throw new Error("Invalid recruit run_id");
362
- fs.mkdirSync(path.dirname(artifacts.worker_stdout_path), { recursive: true });
363
- const stdoutFd = fs.openSync(artifacts.worker_stdout_path, "a");
364
- const stderrFd = fs.openSync(artifacts.worker_stderr_path, "a");
365
- let child;
366
- try {
367
- child = spawn(globalThis.process.execPath, [
368
- DETACHED_WORKER_SCRIPT,
369
- "--domain",
370
- "recruit",
371
- "--run-id",
372
- runId
373
- ], {
374
- detached: true,
375
- stdio: ["ignore", stdoutFd, stderrFd],
376
- windowsHide: true,
377
- env: globalThis.process.env
378
- });
379
- } finally {
380
- fs.closeSync(stdoutFd);
381
- fs.closeSync(stderrFd);
382
- }
383
- if (typeof child?.unref === "function") child.unref();
384
- return child;
385
- }
386
-
387
- function ensureRecruitRunArtifacts(snapshot) {
388
- const artifacts = getRecruitRunArtifacts(snapshot?.runId || snapshot?.run_id);
389
- if (!artifacts) return null;
390
-
391
- const meta = getRecruitRunMeta(snapshot?.runId || snapshot?.run_id);
392
- const checkpoint = snapshot?.checkpoint && typeof snapshot.checkpoint === "object"
393
- ? snapshot.checkpoint
394
- : {};
395
- writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
396
- if (meta) meta.checkpointPath = artifacts.checkpoint_path;
397
-
398
- const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
399
- const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
400
- const artifactSummary = summary || (checkpointResults.length ? {
401
- domain: "recruit",
402
- partial: true,
403
- partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
404
- results: checkpointResults
405
- } : null);
406
- if (artifactSummary) {
407
- const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
408
- writeRecruitLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
409
- writeJsonAtomic(artifacts.report_json, {
410
- run_id: snapshot.runId || snapshot.run_id,
411
- status: snapshot.status || snapshot.state,
412
- phase: snapshot.phase || snapshot.stage,
413
- progress: snapshot.progress || {},
414
- context: snapshot.context || {},
415
- checkpoint,
416
- summary: artifactSummary,
417
- generated_at: new Date().toISOString()
418
- });
419
- if (meta) {
420
- meta.outputCsvPath = artifacts.output_csv;
421
- meta.reportJsonPath = artifacts.report_json;
422
- }
423
- }
424
-
425
- return artifacts;
426
- }
427
-
428
- function persistRecruitCheckpointSnapshot(normalized) {
429
- const artifacts = getRecruitRunArtifacts(normalized?.run_id || normalized?.runId);
430
- if (!artifacts) return;
431
- const checkpoint = normalized?.checkpoint && typeof normalized.checkpoint === "object"
432
- ? normalized.checkpoint
433
- : {};
434
- writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
435
- const meta = getRecruitRunMeta(normalized?.run_id || normalized?.runId);
436
- if (meta) meta.checkpointPath = artifacts.checkpoint_path;
437
- }
438
-
439
- function toIsoOrNull(value) {
440
- const normalized = normalizeText(value);
441
- return normalized || null;
442
- }
443
-
444
- function secondsBetween(startedAt, endedAt) {
445
- const startMs = Date.parse(startedAt || "");
446
- const endMs = Date.parse(endedAt || "") || Date.now();
447
- if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
448
- return Math.max(1, Math.round((endMs - startMs) / 1000));
449
- }
450
-
451
- function normalizeLegacyProgress(progress = {}, summary = null) {
452
- const processed = Number.isInteger(progress.processed)
453
- ? progress.processed
454
- : Number.isInteger(summary?.processed)
455
- ? summary.processed
456
- : 0;
457
- const passed = Number.isInteger(progress.passed)
458
- ? progress.passed
459
- : Number.isInteger(summary?.passed)
460
- ? summary.passed
461
- : 0;
462
- return {
463
- ...progress,
464
- processed,
465
- passed,
466
- skipped: Number.isInteger(progress.skipped) ? progress.skipped : Math.max(processed - passed, 0),
467
- greet_count: Number.isInteger(progress.greet_count) ? progress.greet_count : 0
468
- };
469
- }
470
-
471
- function completionReason(status) {
472
- if (status === RUN_STATUS_COMPLETED) return "completed";
473
- if (status === RUN_STATUS_CANCELED) return "canceled_by_user";
474
- if (status === RUN_STATUS_FAILED) return "failed";
475
- if (status === RUN_STATUS_PAUSED) return "paused";
476
- return null;
477
- }
478
-
479
- function snapshotFromPersistedRecruitRun(persisted = {}) {
480
- return {
481
- runId: persisted.run_id || persisted.runId,
482
- name: persisted.name || persisted.run_id || persisted.runId,
483
- status: persisted.status || persisted.state,
484
- phase: persisted.stage || persisted.phase,
485
- progress: persisted.progress || {},
486
- context: persisted.context || {},
487
- checkpoint: persisted.checkpoint || {},
488
- startedAt: persisted.started_at || persisted.startedAt,
489
- updatedAt: persisted.updated_at || persisted.updatedAt,
490
- completedAt: persisted.completed_at || persisted.completedAt || null,
491
- error: persisted.error || null,
492
- summary: persisted.summary || null
493
- };
494
- }
495
-
496
- function attachLegacyArtifactsToPersistedRecruitRun(persisted = {}) {
497
- const runId = normalizeRunId(persisted.run_id || persisted.runId);
498
- if (!runId) return persisted;
499
- const snapshot = snapshotFromPersistedRecruitRun(persisted);
500
- const result = buildLegacyRunResult(snapshot);
501
- const artifacts = getRecruitRunArtifacts(runId);
502
- const next = {
503
- ...persisted,
504
- result,
505
- resume: {
506
- ...(persisted.resume || {}),
507
- checkpoint_path: result?.checkpoint_path || persisted.resume?.checkpoint_path || artifacts?.checkpoint_path || null,
508
- output_csv: result?.output_csv || persisted.resume?.output_csv || artifacts?.output_csv || null,
509
- worker_stdout_path: artifacts?.worker_stdout_path || persisted.resume?.worker_stdout_path || null,
510
- worker_stderr_path: artifacts?.worker_stderr_path || persisted.resume?.worker_stderr_path || null
511
- },
512
- artifacts: artifacts || persisted.artifacts || null
513
- };
514
- return writeRecruitRunState(runId, next);
515
- }
516
-
517
- function finalizePersistedRecruitRun(persisted = {}, {
518
- status = RUN_STATUS_FAILED,
519
- error = null,
520
- message = ""
521
- } = {}) {
522
- const runId = normalizeRunId(persisted.run_id || persisted.runId);
523
- if (!runId) return persisted;
524
- const now = new Date().toISOString();
525
- const normalizedError = status === RUN_STATUS_FAILED
526
- ? {
527
- name: error?.name || "Error",
528
- code: error?.code || "STALE_RUN_PROCESS_EXITED",
529
- message: error?.message || message || "Boss search run process exited before it wrote a terminal state."
530
- }
531
- : null;
532
- const next = {
533
- ...persisted,
534
- run_id: runId,
535
- state: status,
536
- status,
537
- stage: persisted.stage || persisted.phase || "recruit:stale",
538
- updated_at: now,
539
- heartbeat_at: now,
540
- completed_at: persisted.completed_at || now,
541
- last_message: normalizedError?.message || message || status,
542
- control: {
543
- ...(persisted.control || {}),
544
- cancel_requested: false
545
- },
546
- error: normalizedError,
547
- summary: persisted.summary || null
548
- };
549
- return attachLegacyArtifactsToPersistedRecruitRun(next);
550
- }
551
-
552
- function reconcilePersistedRecruitRun(persisted = {}, { cancelStale = false } = {}) {
553
- const status = persisted.status || persisted.state;
554
- if (STALE_PROCESS_STATUSES.has(status) && !isPidAlive(persisted.pid)) {
555
- const shouldCancel = cancelStale || status === RUN_STATUS_CANCELING || persisted.control?.cancel_requested === true;
556
- return {
557
- run: finalizePersistedRecruitRun(persisted, {
558
- status: shouldCancel ? RUN_STATUS_CANCELED : RUN_STATUS_FAILED,
559
- error: shouldCancel ? null : {
560
- code: "STALE_RUN_PROCESS_EXITED",
561
- message: `Boss search run process is no longer alive for pid=${persisted.pid || "unknown"}.`
562
- },
563
- message: shouldCancel
564
- ? "Boss search run was canceled after its worker process was no longer active."
565
- : `Boss search run process is no longer alive for pid=${persisted.pid || "unknown"}.`
566
- }),
567
- stale_finalized: true
568
- };
569
- }
570
- return { run: persisted };
571
- }
572
-
573
- export function markBossRecruitDetachedWorkerFailed(runId, error, options = {}) {
574
- const normalizedRunId = normalizeRunId(runId);
575
- if (!normalizedRunId) return null;
576
- const persisted = readRecruitRunState(normalizedRunId) || buildInitialRecruitDetachedState(normalizedRunId, {});
577
- const state = normalizeText(persisted.state || persisted.status);
578
- if (TERMINAL_STATUSES.has(state)) return persisted;
579
- const errorPayload = {
580
- name: error?.name || "Error",
581
- code: options.code || error?.code || "RECRUIT_WORKER_UNHANDLED_EXCEPTION",
582
- message: normalizeText(error?.message || error || options.message) || "Boss search detached worker exited unexpectedly."
583
- };
584
- if (normalizeText(error?.stack || "")) {
585
- errorPayload.stack = String(error.stack).slice(0, 8000);
586
- }
587
- return finalizePersistedRecruitRun(persisted, {
588
- status: RUN_STATUS_FAILED,
589
- error: errorPayload,
590
- message: errorPayload.message
591
- });
592
- }
593
-
594
- function buildLegacyRunResult(snapshot) {
595
- if (!snapshot) return null;
596
- const artifacts = ensureRecruitRunArtifacts(snapshot);
597
- const meta = getRecruitRunMeta(snapshot.runId);
598
- const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
599
- const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
600
- const resultRows = Array.isArray(summary?.results)
601
- ? summary.results
602
- : Array.isArray(checkpoint.results)
603
- ? checkpoint.results
604
- : [];
605
- const progress = normalizeLegacyProgress(snapshot.progress, summary);
606
- const targetCount = Number.isInteger(progress.target_count)
607
- ? progress.target_count
608
- : Number.isInteger(snapshot.context?.max_candidates)
609
- ? snapshot.context.max_candidates
610
- : null;
611
- return {
612
- target_count: targetCount,
613
- processed_count: progress.processed,
614
- passed_count: progress.passed,
615
- screened_count: Number.isInteger(progress.screened)
616
- ? progress.screened
617
- : Number.isInteger(summary?.screened)
618
- ? summary.screened
619
- : progress.processed,
620
- detail_opened: Number.isInteger(progress.detail_opened)
621
- ? progress.detail_opened
622
- : Number.isInteger(summary?.detail_opened)
623
- ? summary.detail_opened
624
- : 0,
625
- duration_sec: secondsBetween(snapshot.startedAt, snapshot.completedAt || snapshot.updatedAt),
626
- output_csv: summary?.output_csv || meta.outputCsvPath || artifacts?.output_csv || null,
627
- report_json: summary?.report_json || meta.reportJsonPath || artifacts?.report_json || null,
628
- worker_stdout_path: artifacts?.worker_stdout_path || null,
629
- worker_stderr_path: artifacts?.worker_stderr_path || null,
630
- round_count: 1,
631
- current_round_index: 1,
632
- checkpoint_path: snapshot.checkpoint?.checkpoint_path
633
- || snapshot.checkpoint?.path
634
- || meta.checkpointPath
635
- || artifacts?.checkpoint_path
636
- || null,
637
- completion_reason: completionReason(snapshot.status),
638
- target_count_semantics: TARGET_COUNT_SEMANTICS,
639
- run_id: snapshot.runId,
640
- results: resultRows
641
- };
642
- }
643
-
644
- function createTargetCountSchema(description) {
645
- return {
646
- oneOf: [
647
- { type: "integer", minimum: 1 },
648
- { type: "string", pattern: "^[1-9][0-9]*$" }
649
- ],
650
- description
651
- };
652
- }
653
-
654
- function createHumanBehaviorInputSchema(description = "可选,search/recruit 可靠性实验用节奏配置;默认 paced_with_rests/on") {
655
- return {
656
- type: "object",
657
- properties: {
658
- enabled: { type: "boolean" },
659
- profile: {
660
- type: "string",
661
- enum: ["baseline", "paced", "paced_with_rests"]
662
- },
663
- clickMovement: { type: "boolean" },
664
- textEntry: { type: "boolean" },
665
- listScrollJitter: { type: "boolean" },
666
- shortRest: { type: "boolean" },
667
- batchRest: { type: "boolean" },
668
- actionCooldown: { type: "boolean" },
669
- restLevel: {
670
- type: "string",
671
- enum: ["low", "medium", "high"],
672
- description: "本次 run 的休息强度:low 保持旧策略;medium 约 5 小时/700 人累计休息 30 分钟;high 约 5 小时/700 人累计休息 1 小时"
673
- },
674
- rest_level: {
675
- type: "string",
676
- enum: ["low", "medium", "high"],
677
- description: "兼容字段;优先使用 restLevel"
678
- }
679
- },
680
- additionalProperties: false,
681
- description
682
- };
683
- }
684
-
685
- export function createRecruitPipelineInputSchema() {
686
- return {
687
- type: "object",
688
- properties: {
689
- instruction: {
690
- type: "string",
691
- description: "用户自然语言招聘指令"
692
- },
693
- execution_mode: {
694
- type: "string",
695
- enum: [RUN_MODE_ASYNC, RUN_MODE_SYNC],
696
- description: "执行模式;默认 async。"
697
- },
698
- confirmation: {
699
- type: "object",
700
- description: "搜索页确认状态。新流程建议在用户看过总览后传 final_confirmed=true;逐字段 *_confirmed 为兼容旧调用保留。",
701
- properties: {
702
- final_confirmed: {
703
- type: "boolean",
704
- description: "用户已确认包含岗位、关键词、城市、学历/院校、是否过滤已看、criteria、目标人数、动作和 restLevel 的总览。"
705
- },
706
- job_confirmed: { type: "boolean" },
707
- job_value: { type: "string" },
708
- keyword_confirmed: { type: "boolean" },
709
- keyword_value: { type: "string" },
710
- search_params_confirmed: { type: "boolean" },
711
- criteria_confirmed: { type: "boolean" },
712
- criteria_value: { type: "string" },
713
- skip_recent_colleague_contacted_confirmed: { type: "boolean" },
714
- skip_recent_colleague_contacted_value: { type: "boolean" },
715
- filter_recent_colleague_contacted_confirmed: { type: "boolean" },
716
- filter_recent_colleague_contacted_value: {
717
- type: "boolean",
718
- description: "是否过滤近期已被同事触达的人选;true 会开启搜索页“近30天未和同事交换简历”。"
719
- },
720
- post_action_confirmed: { type: "boolean" },
721
- post_action_value: {
722
- type: "string",
723
- enum: ["greet", "none"]
724
- },
725
- max_greet_count_value: { type: "integer", minimum: 1 },
726
- use_default_for_missing: { type: "boolean" }
727
- },
728
- additionalProperties: false
729
- },
730
- overrides: {
731
- type: "object",
732
- properties: {
733
- job: { type: "string" },
734
- job_title: { type: "string" },
735
- selected_job: { type: "string" },
736
- city: { type: "string" },
737
- degree: {
738
- anyOf: [
739
- { type: "string" },
740
- { type: "array", items: { type: "string" }, minItems: 1 }
741
- ]
742
- },
743
- degrees: {
744
- anyOf: [
745
- { type: "string" },
746
- { type: "array", items: { type: "string" }, minItems: 1 }
747
- ]
748
- },
749
- filter_recent_viewed: { type: "boolean" },
750
- skip_recent_colleague_contacted: {
751
- type: "boolean",
752
- description: "显式 true 时开启 Boss 的“近30天未和同事交换简历”过滤;false 会确保该过滤取消;未提供时不默认开启。"
753
- },
754
- filter_recent_colleague_contacted: {
755
- type: "boolean",
756
- description: "是否过滤近期已被同事触达的人选;true 会开启搜索页“近30天未和同事交换简历”;false 会确保该过滤取消。"
757
- },
758
- recent_colleague_contacted: {
759
- anyOf: [
760
- { type: "boolean" },
761
- { type: "string" }
762
- ],
763
- description: "同事近期触达筛选别名;可填 不限/不过滤/过滤。"
764
- },
765
- recent_not_view: {
766
- anyOf: [
767
- { type: "boolean" },
768
- { type: "string" }
769
- ]
770
- },
771
- schools: {
772
- anyOf: [
773
- { type: "array", items: { type: "string" } },
774
- { type: "string" }
775
- ]
776
- },
777
- school_tag: {
778
- anyOf: [
779
- { type: "array", items: { type: "string" } },
780
- { type: "string" }
781
- ]
782
- },
783
- school_tags: {
784
- anyOf: [
785
- { type: "array", items: { type: "string" } },
786
- { type: "string" }
787
- ]
788
- },
789
- experience: {
790
- anyOf: [
791
- { type: "string" },
792
- {
793
- type: "object",
794
- properties: {
795
- mode: { type: "string" },
796
- label: { type: "string" },
797
- option: { type: "string" },
798
- start: { anyOf: [{ type: "string" }, { type: "integer" }] },
799
- end: { anyOf: [{ type: "string" }, { type: "integer" }] },
800
- min: { anyOf: [{ type: "string" }, { type: "integer" }] },
801
- max: { anyOf: [{ type: "string" }, { type: "integer" }] },
802
- from: { anyOf: [{ type: "string" }, { type: "integer" }] },
803
- to: { anyOf: [{ type: "string" }, { type: "integer" }] },
804
- start_value: { type: "integer" },
805
- end_value: { type: "integer" }
806
- },
807
- additionalProperties: false
808
- }
809
- ]
810
- },
811
- experiences: {
812
- anyOf: [
813
- { type: "array", items: { type: "string" } },
814
- { type: "string" }
815
- ]
816
- },
817
- experience_range: {
818
- anyOf: [
819
- { type: "string" },
820
- {
821
- type: "object",
822
- properties: {
823
- mode: { type: "string" },
824
- label: { type: "string" },
825
- option: { type: "string" },
826
- start: { anyOf: [{ type: "string" }, { type: "integer" }] },
827
- end: { anyOf: [{ type: "string" }, { type: "integer" }] },
828
- min: { anyOf: [{ type: "string" }, { type: "integer" }] },
829
- max: { anyOf: [{ type: "string" }, { type: "integer" }] },
830
- from: { anyOf: [{ type: "string" }, { type: "integer" }] },
831
- to: { anyOf: [{ type: "string" }, { type: "integer" }] },
832
- start_value: { type: "integer" },
833
- end_value: { type: "integer" }
834
- },
835
- additionalProperties: false
836
- }
837
- ]
838
- },
839
- experience_start: {
840
- anyOf: [
841
- { type: "string" },
842
- { type: "integer" }
843
- ]
844
- },
845
- experience_end: {
846
- anyOf: [
847
- { type: "string" },
848
- { type: "integer" }
849
- ]
850
- },
851
- gender: { type: "string" },
852
- age: {
853
- anyOf: [
854
- { type: "string" },
855
- {
856
- type: "object",
857
- properties: {
858
- mode: { type: "string" },
859
- label: { type: "string" },
860
- option: { type: "string" },
861
- min: { anyOf: [{ type: "string" }, { type: "integer" }] },
862
- max: { anyOf: [{ type: "string" }, { type: "integer" }] },
863
- start: { anyOf: [{ type: "string" }, { type: "integer" }] },
864
- end: { anyOf: [{ type: "string" }, { type: "integer" }] },
865
- from: { anyOf: [{ type: "string" }, { type: "integer" }] },
866
- to: { anyOf: [{ type: "string" }, { type: "integer" }] }
867
- },
868
- additionalProperties: false
869
- }
870
- ]
871
- },
872
- ages: {
873
- anyOf: [
874
- { type: "array", items: { type: "string" } },
875
- { type: "string" }
876
- ]
877
- },
878
- age_range: {
879
- anyOf: [
880
- { type: "string" },
881
- {
882
- type: "object",
883
- properties: {
884
- mode: { type: "string" },
885
- label: { type: "string" },
886
- option: { type: "string" },
887
- min: { anyOf: [{ type: "string" }, { type: "integer" }] },
888
- max: { anyOf: [{ type: "string" }, { type: "integer" }] },
889
- start: { anyOf: [{ type: "string" }, { type: "integer" }] },
890
- end: { anyOf: [{ type: "string" }, { type: "integer" }] },
891
- from: { anyOf: [{ type: "string" }, { type: "integer" }] },
892
- to: { anyOf: [{ type: "string" }, { type: "integer" }] }
893
- },
894
- additionalProperties: false
895
- }
896
- ]
897
- },
898
- age_min: {
899
- anyOf: [
900
- { type: "string" },
901
- { type: "integer" }
902
- ]
903
- },
904
- age_max: {
905
- anyOf: [
906
- { type: "string" },
907
- { type: "integer" }
908
- ]
909
- },
910
- min_age: {
911
- anyOf: [
912
- { type: "string" },
913
- { type: "integer" }
914
- ]
915
- },
916
- max_age: {
917
- anyOf: [
918
- { type: "string" },
919
- { type: "integer" }
920
- ]
921
- },
922
- keyword: { type: "string" },
923
- target_count: { type: "integer", minimum: 1 },
924
- criteria: { type: "string" },
925
- post_action: {
926
- type: "string",
927
- enum: ["greet", "none"]
928
- },
929
- max_greet_count: { type: "integer", minimum: 1 }
930
- },
931
- additionalProperties: false
932
- },
933
- host: {
934
- type: "string",
935
- description: "可选,Chrome 调试 host;默认 127.0.0.1"
936
- },
937
- port: {
938
- type: "integer",
939
- minimum: 1,
940
- description: "可选,Chrome 调试端口;默认 9222"
941
- },
942
- target_url_includes: {
943
- type: "string",
944
- description: "可选,Chrome target URL 匹配片段;默认 Boss search 页"
945
- },
946
- allow_navigate: {
947
- type: "boolean",
948
- description: "找不到 search target 时,是否允许复用 Boss chat target 并导航到 search;默认 true"
949
- },
950
- reset_search: {
951
- type: "boolean",
952
- description: "执行前是否重置 Boss search frame;默认 true"
953
- },
954
- slow_live: {
955
- type: "boolean",
956
- description: "VPN/慢页面模式:放宽 live DOM 等待时间"
957
- },
958
- human_behavior: createHumanBehaviorInputSchema("可选,search/recruit 可靠性实验用节奏配置;默认 paced_with_rests/on"),
959
- humanBehavior: createHumanBehaviorInputSchema("兼容字段;优先使用 human_behavior"),
960
- human_behavior_enabled: {
961
- type: "boolean",
962
- description: "兼容字段;true 等同启用 paced 默认配置,false 等同 baseline"
963
- },
964
- human_behavior_profile: {
965
- type: "string",
966
- enum: ["baseline", "paced", "paced_with_rests"],
967
- description: "可选实验 profile:baseline/paced/paced_with_rests"
968
- },
969
- safe_pacing: {
970
- type: "boolean",
971
- description: "兼容字段;true 启用 paced,false 关闭"
972
- },
973
- batch_rest_enabled: {
974
- type: "boolean",
975
- description: "兼容字段;true 启用 paced_with_rests 的候选人短休/批次休息"
976
- },
977
- max_candidates: createTargetCountSchema("本次最多处理候选人数;默认使用解析出的 target_count"),
978
- detail_limit: {
979
- type: "integer",
980
- minimum: 0,
981
- description: "打开详情/CV 的人数上限;默认跟随 max_candidates。detail_limit=0 属于调试路径,需要 debug_test_mode=true"
982
- },
983
- debug_test_mode: {
984
- type: "boolean",
985
- description: "高级测试开关;默认 false。只有显式为 true 时才允许 deterministic/local scorer、detail_limit=0 等调试路径"
986
- },
987
- screening_mode: {
988
- type: "string",
989
- enum: ["llm", "deterministic"],
990
- description: "筛选引擎;默认 llm。deterministic 仅限 debug_test_mode=true"
991
- },
992
- use_llm: {
993
- type: "boolean",
994
- description: "兼容字段;默认 true。use_llm=false 等同 deterministic,仅限 debug_test_mode=true"
995
- },
996
- llm_timeout_ms: {
997
- type: "integer",
998
- minimum: 1000,
999
- description: "可选,单个候选人的 LLM 调用超时"
1000
- },
1001
- llm_image_limit: {
1002
- type: "integer",
1003
- minimum: 1,
1004
- description: "可选,传给 LLM 的图片简历截图页数上限"
1005
- },
1006
- llm_image_detail: {
1007
- type: "string",
1008
- description: "可选,图片输入 detail,默认 low"
1009
- },
1010
- delay_ms: {
1011
- type: "integer",
1012
- minimum: 0,
1013
- description: "候选人之间的延迟;live pause/resume 测试可增大它"
1014
- },
1015
- execute_post_action: {
1016
- type: "boolean",
1017
- description: "可选,是否实际执行通过后的 search 后置动作 greet;默认 true"
1018
- },
1019
- dry_run_post_action: {
1020
- type: "boolean",
1021
- description: "可选,只验证 search 打招呼动作发现/配额/可点击路径,不实际点击"
1022
- },
1023
- action_timeout_ms: {
1024
- type: "integer",
1025
- minimum: 1000,
1026
- description: "可选,等待详情页 greet 控件出现的超时时间"
1027
- },
1028
- action_interval_ms: {
1029
- type: "integer",
1030
- minimum: 100,
1031
- description: "可选,轮询详情页 greet 控件的间隔"
1032
- },
1033
- action_after_click_delay_ms: {
1034
- type: "integer",
1035
- minimum: 0,
1036
- description: "可选,点击 greet 后等待页面状态稳定的时间"
1037
- }
1038
- },
1039
- required: ["instruction"],
1040
- additionalProperties: false
1041
- };
1042
- }
1043
-
1044
- export function createRecruitRunIdInputSchema() {
1045
- return {
1046
- type: "object",
1047
- properties: {
1048
- run_id: { type: "string" }
1049
- },
1050
- required: ["run_id"],
1051
- additionalProperties: false
1052
- };
1053
- }
1054
-
1055
- export function validateRecruitPipelineArgs(args) {
1056
- if (!args || typeof args !== "object") return "arguments must be an object";
1057
- if (!args.instruction || typeof args.instruction !== "string") {
1058
- return "instruction is required and must be a string";
1059
- }
1060
- return null;
1061
- }
1062
-
1063
- function buildRequiredConfirmations(parsedResult) {
1064
- const confirmations = [];
1065
- if (parsedResult.needs_search_params_confirmation) confirmations.push("search_params");
1066
- if (parsedResult.needs_keyword_confirmation) confirmations.push("keyword");
1067
- if (parsedResult.needs_recent_viewed_filter_confirmation) confirmations.push("filter_recent_viewed");
1068
- if (parsedResult.needs_skip_recent_colleague_contacted_confirmation) confirmations.push("filter_recent_colleague_contacted");
1069
- if (parsedResult.needs_criteria_confirmation) confirmations.push("criteria");
1070
- if (parsedResult.has_unresolved_missing_fields) confirmations.push("missing_fields_or_defaults");
1071
- if ((parsedResult.suspicious_fields || []).length) confirmations.push("suspicious_fields");
1072
- return confirmations;
1073
- }
1074
-
1075
- function buildNeedInputResponse(parsedResult) {
1076
- return {
1077
- status: "NEED_INPUT",
1078
- missing_fields: parsedResult.missing_fields,
1079
- proposed_keyword: parsedResult.proposed_keyword,
1080
- required_confirmations: buildRequiredConfirmations(parsedResult),
1081
- search_params: parsedResult.searchParams,
1082
- screen_params: parsedResult.screenParams,
1083
- pending_questions: parsedResult.pending_questions,
1084
- review: parsedResult.review,
1085
- error: {
1086
- code: "MISSING_REQUIRED_FIELDS",
1087
- message: "缺少必要字段。请先补齐缺失项;若要按默认值继续,必须先明确确认默认值及其风险。",
1088
- retryable: true
1089
- }
1090
- };
1091
- }
1092
-
1093
- function buildNeedConfirmationResponse(parsedResult) {
1094
- return {
1095
- status: "NEED_CONFIRMATION",
1096
- proposed_keyword: parsedResult.proposed_keyword,
1097
- required_confirmations: buildRequiredConfirmations(parsedResult),
1098
- search_params: {
1099
- ...parsedResult.searchParams,
1100
- keyword: parsedResult.proposed_keyword || parsedResult.searchParams.keyword
1101
- },
1102
- screen_params: parsedResult.screenParams,
1103
- pending_questions: parsedResult.pending_questions,
1104
- review: parsedResult.review
1105
- };
1106
- }
1107
-
1108
- function parseRecruitPipelineRequest(args = {}) {
1109
- const parsed = parseRecruitInstruction({
1110
- instruction: args.instruction,
1111
- confirmation: args.confirmation,
1112
- overrides: args.overrides
1113
- });
1114
- const criteriaOverride = normalizeText(args.overrides?.criteria || "");
1115
- if (criteriaOverride) {
1116
- parsed.screenParams = {
1117
- ...parsed.screenParams,
1118
- criteria: criteriaOverride
1119
- };
1120
- parsed.review = {
1121
- ...parsed.review,
1122
- current_screen_params: {
1123
- ...(parsed.review?.current_screen_params || {}),
1124
- criteria: criteriaOverride
1125
- }
1126
- };
1127
- }
1128
- return parsed;
1129
- }
1130
-
1131
- function evaluateRecruitPipelineGate(parsed) {
1132
- if (parsed.has_unresolved_missing_fields) return buildNeedInputResponse(parsed);
1133
- if (
1134
- parsed.needs_keyword_confirmation
1135
- || parsed.needs_recent_viewed_filter_confirmation
1136
- || parsed.needs_skip_recent_colleague_contacted_confirmation
1137
- || parsed.needs_criteria_confirmation
1138
- || parsed.needs_search_params_confirmation
1139
- || (parsed.suspicious_fields || []).length > 0
1140
- ) {
1141
- return buildNeedConfirmationResponse(parsed);
1142
- }
1143
- return null;
1144
- }
1145
-
1146
- function normalizeRunSnapshot(snapshot) {
1147
- if (!snapshot) return null;
1148
- const meta = getRecruitRunMeta(snapshot.runId);
1149
- const artifacts = getRecruitRunArtifacts(snapshot.runId);
1150
- const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
1151
- const progress = normalizeLegacyProgress(snapshot.progress, summary);
1152
- const legacyResult = (
1153
- TERMINAL_STATUSES.has(snapshot.status)
1154
- || snapshot.status === RUN_STATUS_PAUSED
1155
- ) ? buildLegacyRunResult({ ...snapshot, progress }) : null;
1156
- const oldContext = {
1157
- workspace_root: meta.workspaceRoot || null,
1158
- instruction: meta.args?.instruction || "",
1159
- confirmation: clonePlain(meta.args?.confirmation || {}, {}),
1160
- overrides: clonePlain(meta.args?.overrides || {}, {}),
1161
- rounds: []
1162
- };
1163
- return {
1164
- ...snapshot,
1165
- progress,
1166
- run_id: snapshot.runId,
1167
- mode: meta.mode || RUN_MODE_ASYNC,
1168
- state: snapshot.status,
1169
- stage: snapshot.phase,
1170
- started_at: snapshot.startedAt,
1171
- updated_at: snapshot.updatedAt,
1172
- completed_at: toIsoOrNull(snapshot.completedAt),
1173
- heartbeat_at: snapshot.updatedAt,
1174
- pid: globalThis.process?.pid || null,
1175
- last_message: snapshot.error?.message || snapshot.phase || null,
1176
- context: {
1177
- ...(snapshot.context || {}),
1178
- ...oldContext,
1179
- shared_run_context: snapshot.context || {}
1180
- },
1181
- control: {
1182
- pause_requested: snapshot.status === RUN_STATUS_PAUSED,
1183
- pause_requested_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null,
1184
- pause_requested_by: snapshot.status === RUN_STATUS_PAUSED ? "pause_recruit_pipeline_run" : null,
1185
- cancel_requested: snapshot.status === RUN_STATUS_CANCELING
1186
- },
1187
- resume: {
1188
- checkpoint_path: legacyResult?.checkpoint_path || meta.checkpointPath || artifacts?.checkpoint_path || null,
1189
- pause_control_path: artifacts?.run_state_path || null,
1190
- output_csv: legacyResult?.output_csv || null,
1191
- worker_stdout_path: artifacts?.worker_stdout_path || null,
1192
- worker_stderr_path: artifacts?.worker_stderr_path || null,
1193
- resume_count: meta.resumeCount || 0,
1194
- last_resumed_at: meta.lastResumedAt || null,
1195
- last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
1196
- },
1197
- result: legacyResult,
1198
- artifacts
1199
- };
1200
- }
1201
-
1202
- function persistRecruitRunSnapshot(snapshot, {
1203
- persistActiveCheckpoint = false
1204
- } = {}) {
1205
- const normalized = normalizeRunSnapshot(snapshot);
1206
- if (!normalized?.run_id) return normalized;
1207
- const artifacts = getRecruitRunArtifacts(normalized.run_id);
1208
- if (!artifacts) return normalized;
1209
- if (persistActiveCheckpoint) {
1210
- persistRecruitCheckpointSnapshot(normalized);
1211
- }
1212
- const payload = {
1213
- run_id: normalized.run_id,
1214
- mode: normalized.mode,
1215
- state: normalized.state,
1216
- status: normalized.status,
1217
- stage: normalized.stage,
1218
- started_at: normalized.started_at,
1219
- updated_at: normalized.updated_at,
1220
- heartbeat_at: normalized.heartbeat_at,
1221
- completed_at: normalized.completed_at,
1222
- pid: normalized.pid,
1223
- progress: normalized.progress,
1224
- last_message: normalized.last_message,
1225
- context: normalized.context,
1226
- control: normalized.control,
1227
- resume: normalized.resume,
1228
- error: normalized.error,
1229
- result: normalized.result,
1230
- summary: normalized.summary,
1231
- artifacts: normalized.artifacts
1232
- };
1233
- writeJsonAtomic(artifacts.run_state_path, payload);
1234
- return normalized;
1235
- }
1236
-
1237
- function persistRecruitLifecycleSnapshot(snapshot, event = {}) {
1238
- return persistRecruitRunSnapshot(snapshot, {
1239
- persistActiveCheckpoint: event?.type === "checkpoint"
1240
- });
1241
- }
1242
-
1243
- function getRecruitRunMeta(runId) {
1244
- return recruitRunMeta.get(runId) || {};
1245
- }
1246
-
1247
- function attachMethodEvidence(payload, runId) {
1248
- const meta = getRecruitRunMeta(runId);
1249
- return {
1250
- ...payload,
1251
- runtime_evaluate_used: false,
1252
- method_summary: methodSummary(meta.methodLog || []),
1253
- method_log: meta.methodLog || [],
1254
- chrome: meta.chrome || null
1255
- };
1256
- }
1257
-
1258
- async function waitForRecruitSearchControlsOrLogin(client, {
1259
- timeoutMs = 90000,
1260
- intervalMs = 300
1261
- } = {}) {
1262
- const started = Date.now();
1263
- let lastControls = null;
1264
- while (Date.now() - started <= timeoutMs) {
1265
- const loginDetection = await detectBossLoginState(client).catch(() => null);
1266
- if (loginDetection?.requires_login) {
1267
- return {
1268
- ok: false,
1269
- reason: "login_required",
1270
- loginDetection
1271
- };
1272
- }
1273
- const remainingMs = Math.max(1, timeoutMs - (Date.now() - started));
1274
- lastControls = await waitForRecruitSearchControls(client, {
1275
- timeoutMs: Math.min(remainingMs, 1500),
1276
- intervalMs
1277
- });
1278
- if (lastControls.ok) return lastControls;
1279
- await sleep(intervalMs);
1280
- }
1281
- return lastControls || { ok: false, reason: "timeout" };
1282
- }
1283
-
1284
- async function connectRecruitChromeSession({
1285
- host = DEFAULT_RECRUIT_HOST,
1286
- port = DEFAULT_RECRUIT_PORT,
1287
- targetUrlIncludes = RECRUIT_TARGET_URL,
1288
- allowNavigate = true,
1289
- slowLive = false
1290
- } = {}) {
1291
- const session = await connectToChromeTargetOrOpen({
1292
- host,
1293
- port,
1294
- targetUrlIncludes,
1295
- targetUrl: RECRUIT_TARGET_URL,
1296
- allowNavigate,
1297
- slowLive,
1298
- fallbackTargetPredicate: (target) => (
1299
- target?.type === "page"
1300
- && String(target?.url || "").includes("zhipin.com")
1301
- )
1302
- });
1303
-
1304
- const { client, target } = session;
1305
- await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
1306
- if (typeof client?.Network?.setCacheDisabled === "function") {
1307
- await client.Network.setCacheDisabled({ cacheDisabled: true });
1308
- }
1309
- await bringPageToFront(client);
1310
-
1311
- const targetUrl = String(target?.url || "");
1312
- let navigation = {
1313
- navigated: false,
1314
- url: targetUrl
1315
- };
1316
- if (allowNavigate && !targetUrl.includes(targetUrlIncludes)) {
1317
- await client.Page.navigate({ url: RECRUIT_TARGET_URL });
1318
- const settleMs = slowLive ? 8000 : 3000;
1319
- const waited = await waitForMainFrameUrl(
1320
- client,
1321
- (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
1322
- { timeoutMs: settleMs, intervalMs: 500 }
1323
- );
1324
- navigation = {
1325
- navigated: true,
1326
- url: RECRUIT_TARGET_URL,
1327
- settle_ms: settleMs,
1328
- observed_url: waited.url || null,
1329
- observed_url_ok: waited.ok
1330
- };
1331
- }
1332
- let currentUrl = await getMainFrameUrl(client).catch(() => targetUrl);
1333
- if (allowNavigate && !String(currentUrl || "").includes(RECRUIT_TARGET_URL) && !isBossLoginUrl(currentUrl)) {
1334
- await client.Page.navigate({ url: RECRUIT_TARGET_URL });
1335
- const settleMs = slowLive ? 8000 : 3000;
1336
- const waited = await waitForMainFrameUrl(
1337
- client,
1338
- (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
1339
- { timeoutMs: settleMs, intervalMs: 500 }
1340
- );
1341
- navigation = {
1342
- navigated: true,
1343
- url: RECRUIT_TARGET_URL,
1344
- settle_ms: settleMs,
1345
- observed_url: waited.url || null,
1346
- observed_url_ok: waited.ok,
1347
- reason: "observed_url_mismatch"
1348
- };
1349
- currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
1350
- }
1351
- const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
1352
- requires_login: isBossLoginUrl(currentUrl),
1353
- reason: "login_detection_failed",
1354
- current_url: currentUrl
1355
- }));
1356
- if (loginDetection.requires_login) {
1357
- await session.close?.();
1358
- throw createBossLoginRequiredError({
1359
- domain: "search",
1360
- currentUrl: loginDetection.current_url || currentUrl,
1361
- targetUrl: RECRUIT_TARGET_URL,
1362
- loginDetection,
1363
- chrome: session.chrome || null
1364
- });
1365
- }
1366
- if (!String(currentUrl || "").includes(RECRUIT_TARGET_URL)) {
1367
- await session.close?.();
1368
- throw new Error(`Boss search page did not navigate to ${RECRUIT_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
1369
- }
1370
-
1371
- const controls = await waitForRecruitSearchControlsOrLogin(client, {
1372
- timeoutMs: slowLive ? 180000 : 90000,
1373
- intervalMs: 300
1374
- });
1375
- if (controls.loginDetection?.requires_login) {
1376
- await session.close?.();
1377
- throw createBossLoginRequiredError({
1378
- domain: "search",
1379
- currentUrl: controls.loginDetection.current_url || currentUrl,
1380
- targetUrl: RECRUIT_TARGET_URL,
1381
- loginDetection: controls.loginDetection,
1382
- chrome: session.chrome || null
1383
- });
1384
- }
1385
- if (!controls.ok) {
1386
- const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
1387
- const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
1388
- requires_login: isBossLoginUrl(latestUrl),
1389
- reason: "login_detection_failed",
1390
- current_url: latestUrl
1391
- }));
1392
- if (latestLoginDetection.requires_login) {
1393
- await session.close?.();
1394
- throw createBossLoginRequiredError({
1395
- domain: "search",
1396
- currentUrl: latestLoginDetection.current_url || latestUrl,
1397
- targetUrl: RECRUIT_TARGET_URL,
1398
- loginDetection: latestLoginDetection,
1399
- chrome: session.chrome || null
1400
- });
1401
- }
1402
- throw new Error("Boss recruit search page did not expose ready search controls");
1403
- }
1404
-
1405
- return {
1406
- ...session,
1407
- navigation,
1408
- controls
1409
- };
1410
- }
1411
-
1412
- function getRunOptions(args, parsed, session, configResolution = null) {
1413
- const slowLive = args.slow_live === true;
1414
- const targetCount = parsePositiveInteger(args.max_candidates, parsed.screenParams.target_count || 10);
1415
- const screeningMode = normalizeScreeningModeArg(args);
1416
- const humanBehavior = resolveHumanBehaviorForRun(args, configResolution?.config || {});
1417
- const executePostAction = args.dry_run_post_action === true
1418
- ? false
1419
- : args.execute_post_action !== false;
1420
- return {
1421
- client: session.client,
1422
- targetUrl: RECRUIT_TARGET_URL,
1423
- criteria: parsed.screenParams.criteria,
1424
- searchParams: parsed.searchParams,
1425
- maxCandidates: targetCount,
1426
- detailLimit: parseNonNegativeInteger(args.detail_limit, targetCount),
1427
- closeDetail: true,
1428
- delayMs: Math.max(0, parsePositiveInteger(args.delay_ms, 0)),
1429
- cardTimeoutMs: slowLive ? 180000 : 90000,
1430
- resetBeforeSearch: args.reset_search !== false,
1431
- resetTimeoutMs: slowLive ? 300000 : 180000,
1432
- cityOptionTimeoutMs: slowLive ? 60000 : 30000,
1433
- maxImagePages: parsePositiveInteger(args.max_image_pages, DEFAULT_MAX_IMAGE_PAGES),
1434
- screeningMode,
1435
- llmConfig: screeningMode === "llm" && configResolution?.ok ? {
1436
- ...configResolution.config
1437
- } : null,
1438
- llmTimeoutMs: parsePositiveInteger(
1439
- args.llm_timeout_ms,
1440
- parsePositiveInteger(configResolution?.config?.llmTimeoutMs || configResolution?.config?.timeoutMs, slowLive ? 180000 : 120000)
1441
- ),
1442
- llmImageLimit: parsePositiveInteger(
1443
- args.llm_image_limit,
1444
- parsePositiveInteger(configResolution?.config?.llmImageLimit || configResolution?.config?.imageLimit, 8)
1445
- ),
1446
- llmImageDetail: normalizeText(
1447
- args.llm_image_detail || configResolution?.config?.llmImageDetail || configResolution?.config?.imageDetail
1448
- ) || "low",
1449
- imageOutputDir: resolveBossConfiguredOutputDir("", getRecruitRunsDir()),
1450
- humanRestEnabled: humanBehavior.restEnabled,
1451
- humanBehavior,
1452
- postAction: parsed.screenParams?.post_action || "none",
1453
- maxGreetCount: Number.isInteger(parsed.screenParams?.max_greet_count)
1454
- ? parsed.screenParams.max_greet_count
1455
- : null,
1456
- executePostAction,
1457
- actionTimeoutMs: parsePositiveInteger(args.action_timeout_ms, slowLive ? 12000 : 8000),
1458
- actionIntervalMs: parsePositiveInteger(args.action_interval_ms, 400),
1459
- actionAfterClickDelayMs: parseNonNegativeInteger(args.action_after_click_delay_ms, 900),
1460
- name: "mcp-recruit-pipeline-run"
1461
- };
1462
- }
1463
-
1464
- async function closeRecruitRunSession(runId) {
1465
- const meta = recruitRunMeta.get(runId);
1466
- if (!meta || meta.closed) return;
1467
- try {
1468
- assertNoForbiddenCdpCalls(meta.methodLog || []);
1469
- } finally {
1470
- meta.closed = true;
1471
- try {
1472
- await meta.session?.close?.();
1473
- } catch {
1474
- // Nothing actionable for the caller once the run has settled.
1475
- }
1476
- }
1477
- }
1478
-
1479
- async function waitForRecruitRunTerminal(runId) {
1480
- while (true) {
1481
- try {
1482
- const snapshot = recruitRunService.getRecruitRun(runId);
1483
- if (TERMINAL_STATUSES.has(snapshot.status)) return snapshot;
1484
- } catch {
1485
- return null;
1486
- }
1487
- await sleep(1000);
1488
- }
1489
- }
1490
-
1491
- function trackRecruitRun(runId) {
1492
- waitForRecruitRunTerminal(runId)
1493
- .then((terminal) => {
1494
- if (terminal) persistRecruitRunSnapshot(terminal);
1495
- })
1496
- .catch(() => null)
1497
- .finally(() => {
1498
- closeRecruitRunSession(runId).catch(() => {});
1499
- });
1500
- }
1501
-
1502
- async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "", runId = "" } = {}) {
1503
- const parsed = parseRecruitPipelineRequest(args);
1504
- const gate = evaluateRecruitPipelineGate(parsed);
1505
- if (gate) return gate;
1506
- const configResolution = resolveBossScreeningConfig(workspaceRoot);
1507
- const screeningMode = normalizeScreeningModeArg(args);
1508
- const debugTestOptions = collectRecruitDebugTestOptions(args);
1509
- if (debugTestOptions.length && !isDebugTestMode(args)) {
1510
- return {
1511
- status: "FAILED",
1512
- error: {
1513
- code: "DEBUG_TEST_MODE_REQUIRED",
1514
- message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1515
- retryable: false
1516
- },
1517
- debug_test_options: debugTestOptions
1518
- };
1519
- }
1520
- if (screeningMode === "llm" && !configResolution.ok) {
1521
- return {
1522
- status: "FAILED",
1523
- error: {
1524
- code: "SCREEN_CONFIG_ERROR",
1525
- message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1526
- retryable: true
1527
- },
1528
- config_path: configResolution.config_path || null,
1529
- candidate_paths: configResolution.candidate_paths || []
1530
- };
1531
- }
1532
- const host = normalizeText(args.host) || DEFAULT_RECRUIT_HOST;
1533
- const port = parsePositiveInteger(
1534
- args.port,
1535
- configResolution.ok ? configResolution.config.debugPort : DEFAULT_RECRUIT_PORT
1536
- );
1537
-
1538
- let session;
1539
- try {
1540
- session = await recruitConnectorImpl({
1541
- host,
1542
- port,
1543
- targetUrlIncludes: normalizeText(args.target_url_includes) || RECRUIT_TARGET_URL,
1544
- allowNavigate: args.allow_navigate !== false,
1545
- slowLive: args.slow_live === true
1546
- });
1547
- } catch (error) {
1548
- const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
1549
- return {
1550
- status: "FAILED",
1551
- error: {
1552
- code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_SEARCH_PAGE_NOT_READY",
1553
- message: error?.message || "Boss recruit search page is not ready",
1554
- requires_login: Boolean(error?.requires_login),
1555
- login_url: error?.login_url || null,
1556
- login_detection: error?.login_detection || null,
1557
- chrome: error?.chrome || null,
1558
- current_url: error?.current_url || null,
1559
- target_url: error?.target_url || RECRUIT_TARGET_URL,
1560
- retryable: true
1561
- },
1562
- chrome: error?.chrome || null
1563
- };
1564
- }
1565
-
1566
- let started;
1567
- try {
1568
- started = recruitRunService.startRecruitRun({
1569
- ...getRunOptions(args, parsed, session, configResolution),
1570
- runId
1571
- });
1572
- } catch (error) {
1573
- await session.close?.();
1574
- return {
1575
- status: "FAILED",
1576
- error: {
1577
- code: "RECRUIT_RUN_START_FAILED",
1578
- message: error?.message || "Failed to start recruit run",
1579
- retryable: true
1580
- }
1581
- };
1582
- }
1583
-
1584
- recruitRunMeta.set(started.runId, {
1585
- session,
1586
- methodLog: session.methodLog || [],
1587
- mode: normalizeExecutionMode(args.execution_mode),
1588
- workspaceRoot: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
1589
- args: clonePlain(args, {}),
1590
- chrome: {
1591
- host,
1592
- port,
1593
- target_url: session.target?.url || RECRUIT_TARGET_URL,
1594
- target_id: session.target?.id || null,
1595
- auto_launch: session.chrome || null
1596
- },
1597
- parsed
1598
- });
1599
- trackRecruitRun(started.runId);
1600
- const persistedStarted = persistRecruitRunSnapshot(started);
1601
-
1602
- return {
1603
- status: "ACCEPTED",
1604
- run_id: persistedStarted.run_id,
1605
- state: persistedStarted.state,
1606
- run: persistedStarted,
1607
- poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1608
- review: parsed.review,
1609
- message: parsed.screenParams?.post_action === "greet"
1610
- ? `Recruit pipeline run started through shared CDP-only recruit service with post_action=greet${args.dry_run_post_action === true ? " in dry-run mode" : ""}.`
1611
- : "Recruit pipeline run started through shared CDP-only recruit service.",
1612
- post_action: {
1613
- requested: parsed.screenParams?.post_action || "none",
1614
- execute_post_action: args.dry_run_post_action === true ? false : args.execute_post_action !== false,
1615
- max_greet_count: Number.isInteger(parsed.screenParams?.max_greet_count) ? parsed.screenParams.max_greet_count : null
1616
- }
1617
- };
1618
- }
1619
-
1620
- export async function runRecruitPipelineTool({ workspaceRoot = "", args = {} } = {}) {
1621
- const mode = normalizeExecutionMode(args.execution_mode);
1622
- const started = await startRecruitPipelineRunInternal({
1623
- ...args,
1624
- execution_mode: mode
1625
- }, { workspaceRoot });
1626
- if (started.status !== "ACCEPTED") return started;
1627
- if (mode !== RUN_MODE_SYNC) return attachMethodEvidence(started, started.run_id);
1628
-
1629
- const final = await waitForRecruitRunTerminal(started.run_id);
1630
- await closeRecruitRunSession(started.run_id);
1631
- const normalizedFinal = persistRecruitRunSnapshot(final);
1632
- const legacyResult = normalizedFinal?.result || buildLegacyRunResult(final);
1633
- const finalStatus = final?.status === RUN_STATUS_COMPLETED
1634
- ? "COMPLETED"
1635
- : final?.status === RUN_STATUS_CANCELED
1636
- ? "CANCELED"
1637
- : "FAILED";
1638
- return attachMethodEvidence({
1639
- status: finalStatus,
1640
- run_id: started.run_id,
1641
- run: normalizedFinal,
1642
- result: legacyResult,
1643
- partial_result: finalStatus === "CANCELED" ? legacyResult : undefined,
1644
- diagnostics: finalStatus === "FAILED"
1645
- ? {
1646
- run_id: started.run_id,
1647
- last_stage: normalizedFinal?.stage || "recruit:unknown"
1648
- }
1649
- : undefined,
1650
- summary: final?.summary || null,
1651
- error: finalStatus === "CANCELED"
1652
- ? {
1653
- code: "PIPELINE_CANCELED",
1654
- message: "流水线已取消。",
1655
- retryable: true
1656
- }
1657
- : final?.error || null
1658
- }, started.run_id);
1659
- }
1660
-
1661
- export async function startRecruitPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
1662
- const started = await startRecruitPipelineRunInternal({
1663
- ...args,
1664
- execution_mode: RUN_MODE_ASYNC
1665
- }, { workspaceRoot });
1666
- if (started.status !== "ACCEPTED") return started;
1667
- return attachMethodEvidence(started, started.run_id);
1668
- }
1669
-
1670
- export async function startRecruitPipelineDetachedRunTool({ workspaceRoot = "", args = {} } = {}) {
1671
- const normalizedArgs = {
1672
- ...args,
1673
- execution_mode: RUN_MODE_ASYNC
1674
- };
1675
- const parsed = parseRecruitPipelineRequest(normalizedArgs);
1676
- const gate = evaluateRecruitPipelineGate(parsed);
1677
- if (gate) return gate;
1678
- const configResolution = resolveBossScreeningConfig(workspaceRoot);
1679
- const screeningMode = normalizeScreeningModeArg(normalizedArgs);
1680
- const debugTestOptions = collectRecruitDebugTestOptions(normalizedArgs);
1681
- if (debugTestOptions.length && !isDebugTestMode(normalizedArgs)) {
1682
- return {
1683
- status: "FAILED",
1684
- error: {
1685
- code: "DEBUG_TEST_MODE_REQUIRED",
1686
- message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1687
- retryable: false
1688
- },
1689
- debug_test_options: debugTestOptions
1690
- };
1691
- }
1692
- if (screeningMode === "llm" && !configResolution.ok) {
1693
- return {
1694
- status: "FAILED",
1695
- error: {
1696
- code: "SCREEN_CONFIG_ERROR",
1697
- message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1698
- retryable: true
1699
- },
1700
- config_path: configResolution.config_path || null,
1701
- candidate_paths: configResolution.candidate_paths || []
1702
- };
1703
- }
1704
-
1705
- const runId = createDetachedRecruitRunId();
1706
- const artifacts = getRecruitRunArtifacts(runId);
1707
- const initial = buildInitialRecruitDetachedState(runId, {
1708
- workspaceRoot,
1709
- args: normalizedArgs,
1710
- parsed,
1711
- pid: globalThis.process?.pid
1712
- });
1713
- try {
1714
- writeJsonAtomic(artifacts.detached_args_path, {
1715
- domain: "recruit",
1716
- run_id: runId,
1717
- workspace_root: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
1718
- args: clonePlain(normalizedArgs, {})
1719
- });
1720
- writeRecruitRunState(runId, initial);
1721
- } catch (error) {
1722
- return {
1723
- status: "FAILED",
1724
- error: {
1725
- code: "RECRUIT_RUN_STATE_IO_ERROR",
1726
- message: `Unable to write Boss search detached run state: ${error?.message || error}`,
1727
- retryable: false
1728
- }
1729
- };
1730
- }
1731
-
1732
- try {
1733
- const child = launchDetachedRecruitWorker(runId);
1734
- const now = new Date().toISOString();
1735
- const latest = readRecruitRunState(runId) || initial;
1736
- const latestState = normalizeText(latest.state || latest.status);
1737
- if (TERMINAL_STATUSES.has(latestState)) {
1738
- return {
1739
- status: "FAILED",
1740
- error: latest.error || {
1741
- code: "RECRUIT_WORKER_LAUNCH_FAILED",
1742
- message: "Boss search detached worker exited during launch.",
1743
- retryable: true
1744
- },
1745
- run: latest,
1746
- runtime_evaluate_used: false,
1747
- method_summary: {},
1748
- method_log: [],
1749
- chrome: null
1750
- };
1751
- }
1752
- const queued = {
1753
- ...latest,
1754
- pid: child.pid || globalThis.process?.pid || null,
1755
- updated_at: now,
1756
- heartbeat_at: now,
1757
- last_message: "Boss search detached worker launched."
1758
- };
1759
- writeRecruitRunState(runId, queued);
1760
- return {
1761
- status: "ACCEPTED",
1762
- run_id: runId,
1763
- state: "queued",
1764
- run: queued,
1765
- poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1766
- review: parsed.review,
1767
- message: "Boss search run started in a detached worker. It can continue after the MCP host returns or is recycled.",
1768
- target_count_semantics: TARGET_COUNT_SEMANTICS,
1769
- detached_worker: true,
1770
- runtime_evaluate_used: false,
1771
- method_summary: {},
1772
- method_log: [],
1773
- chrome: null
1774
- };
1775
- } catch (error) {
1776
- const failed = markBossRecruitDetachedWorkerFailed(runId, error, {
1777
- code: "RECRUIT_WORKER_LAUNCH_FAILED",
1778
- message: "Unable to launch Boss search detached worker."
1779
- });
1780
- return {
1781
- status: "FAILED",
1782
- error: failed?.error || {
1783
- code: "RECRUIT_WORKER_LAUNCH_FAILED",
1784
- message: error?.message || "Unable to launch Boss search detached worker.",
1785
- retryable: true
1786
- },
1787
- run: failed || readRecruitRunState(runId),
1788
- runtime_evaluate_used: false,
1789
- method_summary: {},
1790
- method_log: [],
1791
- chrome: null
1792
- };
1793
- }
1794
- }
1795
-
1796
- export async function runBossRecruitDetachedWorker({ runId } = {}) {
1797
- const normalizedRunId = normalizeRunId(runId);
1798
- if (!normalizedRunId) return { ok: false, error: "run_id is required" };
1799
- const artifacts = getRecruitRunArtifacts(normalizedRunId);
1800
- const spec = readJsonFile(artifacts?.detached_args_path || "");
1801
- if (!spec) {
1802
- const error = new Error(`Boss search detached args were not found for run_id=${normalizedRunId}`);
1803
- markBossRecruitDetachedWorkerFailed(normalizedRunId, error, { code: "RECRUIT_WORKER_ARGS_MISSING" });
1804
- return { ok: false, error: error.message };
1805
- }
1806
-
1807
- const started = await startRecruitPipelineRunInternal({
1808
- ...(spec.args || {}),
1809
- execution_mode: RUN_MODE_ASYNC
1810
- }, {
1811
- workspaceRoot: spec.workspace_root || "",
1812
- runId: normalizedRunId
1813
- });
1814
- if (started?.status !== "ACCEPTED") {
1815
- const failedError = started?.error || {
1816
- code: "RECRUIT_WORKER_START_FAILED",
1817
- message: started?.status || "Boss search detached worker failed to start.",
1818
- retryable: true
1819
- };
1820
- markBossRecruitDetachedWorkerFailed(normalizedRunId, failedError, {
1821
- code: failedError.code || "RECRUIT_WORKER_START_FAILED"
1822
- });
1823
- return { ok: false, error: failedError.message || "Boss search detached worker failed to start." };
1824
- }
1825
-
1826
- while (true) {
1827
- const payload = getRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1828
- const state = normalizeText(payload?.run?.state || payload?.run?.status || "");
1829
- if (TERMINAL_STATUSES.has(state)) break;
1830
- const persisted = readRecruitRunState(normalizedRunId);
1831
- if (persisted?.control?.cancel_requested === true) {
1832
- cancelRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1833
- } else if (persisted?.control?.pause_requested === true && state === RUN_STATUS_RUNNING) {
1834
- pauseRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1835
- } else if (persisted?.control?.pause_requested === false && state === RUN_STATUS_PAUSED) {
1836
- resumeRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1837
- }
1838
- await sleep(DETACHED_WORKER_POLL_MS);
1839
- }
1840
- return { ok: true };
1841
- }
1842
-
1843
- export function getRecruitPipelineRunTool({ args = {} } = {}) {
1844
- const runId = normalizeText(args.run_id);
1845
- if (!runId) {
1846
- return {
1847
- status: "FAILED",
1848
- error: {
1849
- code: "INVALID_RUN_ID",
1850
- message: "run_id is required",
1851
- retryable: false
1852
- }
1853
- };
1854
- }
1855
- try {
1856
- const run = recruitRunService.getRecruitRun(runId);
1857
- const normalizedRun = persistRecruitRunSnapshot(run);
1858
- return attachMethodEvidence({
1859
- status: "RUN_STATUS",
1860
- run: normalizedRun
1861
- }, runId);
1862
- } catch {
1863
- const persisted = readRecruitRunState(runId);
1864
- if (persisted) {
1865
- const reconciled = reconcilePersistedRecruitRun(persisted);
1866
- return {
1867
- status: "RUN_STATUS",
1868
- run: reconciled.run,
1869
- persistence: {
1870
- source: "disk",
1871
- active_control_available: false,
1872
- stale_finalized: reconciled.stale_finalized === true
1873
- },
1874
- runtime_evaluate_used: false,
1875
- method_summary: {},
1876
- method_log: [],
1877
- chrome: null
1878
- };
1879
- }
1880
- return {
1881
- status: "FAILED",
1882
- error: {
1883
- code: "RUN_NOT_FOUND",
1884
- message: `No recruit run found for run_id=${runId}`,
1885
- retryable: false
1886
- }
1887
- };
1888
- }
1889
- }
1890
-
1891
- export function pauseRecruitPipelineRunTool({ args = {} } = {}) {
1892
- const runId = normalizeText(args.run_id);
1893
- try {
1894
- const before = recruitRunService.getRecruitRun(runId);
1895
- if (TERMINAL_STATUSES.has(before.status)) {
1896
- const normalizedBefore = persistRecruitRunSnapshot(before);
1897
- return attachMethodEvidence({
1898
- status: "PAUSE_IGNORED",
1899
- run: normalizedBefore,
1900
- message: "目标任务已结束,无需暂停。"
1901
- }, runId);
1902
- }
1903
- if (before.status === RUN_STATUS_PAUSED) {
1904
- const normalizedBefore = persistRecruitRunSnapshot(before);
1905
- return attachMethodEvidence({
1906
- status: "PAUSE_IGNORED",
1907
- run: normalizedBefore,
1908
- message: "目标任务已经处于 paused 状态。"
1909
- }, runId);
1910
- }
1911
- const run = recruitRunService.pauseRecruitRun(runId);
1912
- const normalizedRun = persistRecruitRunSnapshot(run);
1913
- return attachMethodEvidence({
1914
- status: "PAUSE_REQUESTED",
1915
- run: normalizedRun,
1916
- message: "暂停请求已接收,将在当前候选人处理完成后进入 paused。"
1917
- }, runId);
1918
- } catch {
1919
- const persisted = readRecruitRunState(runId);
1920
- if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1921
- return {
1922
- status: "PAUSE_IGNORED",
1923
- run: persisted,
1924
- message: "目标任务已结束,无需暂停。",
1925
- runtime_evaluate_used: false,
1926
- method_summary: {},
1927
- method_log: [],
1928
- chrome: null
1929
- };
1930
- }
1931
- if (persisted) {
1932
- const reconciled = reconcilePersistedRecruitRun(persisted);
1933
- if (reconciled.stale_finalized) return getRecruitPipelineRunTool({ args });
1934
- return patchPersistedRecruitControl(runId, {
1935
- pause_requested: true,
1936
- pause_requested_at: new Date().toISOString(),
1937
- pause_requested_by: "pause_recruit_pipeline_run",
1938
- cancel_requested: false
1939
- }, {
1940
- status: "PAUSE_REQUESTED",
1941
- message: "暂停请求已写入 detached search run 控制文件。",
1942
- lastMessage: "暂停请求已写入 detached search run 控制文件。"
1943
- }) || getRecruitPipelineRunTool({ args });
1944
- }
1945
- return getRecruitPipelineRunTool({ args });
1946
- }
1947
- }
1948
-
1949
- export function resumeRecruitPipelineRunTool({ args = {} } = {}) {
1950
- const runId = normalizeText(args.run_id);
1951
- try {
1952
- const before = recruitRunService.getRecruitRun(runId);
1953
- if (TERMINAL_STATUSES.has(before.status)) {
1954
- const normalizedBefore = persistRecruitRunSnapshot(before);
1955
- return attachMethodEvidence({
1956
- status: "FAILED",
1957
- error: {
1958
- code: "RUN_ALREADY_TERMINATED",
1959
- message: "目标任务已结束,无法继续。",
1960
- retryable: false
1961
- },
1962
- run: normalizedBefore
1963
- }, runId);
1964
- }
1965
- if (before.status !== RUN_STATUS_PAUSED) {
1966
- const normalizedBefore = persistRecruitRunSnapshot(before);
1967
- return attachMethodEvidence({
1968
- status: "FAILED",
1969
- error: {
1970
- code: "RUN_NOT_PAUSED",
1971
- message: "仅 paused 状态的 run 才能继续。",
1972
- retryable: true
1973
- },
1974
- run: normalizedBefore
1975
- }, runId);
1976
- }
1977
- const run = recruitRunService.resumeRecruitRun(runId);
1978
- const meta = getRecruitRunMeta(runId);
1979
- if (meta) {
1980
- meta.resumeCount = (meta.resumeCount || 0) + 1;
1981
- meta.lastResumedAt = new Date().toISOString();
1982
- }
1983
- const normalizedRun = persistRecruitRunSnapshot(run);
1984
- return attachMethodEvidence({
1985
- status: "RESUME_REQUESTED",
1986
- run: normalizedRun,
1987
- poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1988
- message: "已恢复 Boss 招聘流水线,请使用 get_recruit_pipeline_run 按需轮询。"
1989
- }, runId);
1990
- } catch {
1991
- const persisted = readRecruitRunState(runId);
1992
- if (persisted) {
1993
- const reconciled = reconcilePersistedRecruitRun(persisted);
1994
- const reconciledState = reconciled.run?.state || reconciled.run?.status;
1995
- if (!TERMINAL_STATUSES.has(reconciledState)) {
1996
- return patchPersistedRecruitControl(runId, {
1997
- pause_requested: false,
1998
- pause_requested_at: null,
1999
- pause_requested_by: null,
2000
- cancel_requested: false
2001
- }, {
2002
- status: "RESUME_REQUESTED",
2003
- message: "恢复请求已写入 detached search run 控制文件。",
2004
- lastMessage: "恢复请求已写入 detached search run 控制文件。"
2005
- }) || getRecruitPipelineRunTool({ args });
2006
- }
2007
- return {
2008
- status: TERMINAL_STATUSES.has(persisted.state) ? "FAILED" : "FAILED",
2009
- error: {
2010
- code: TERMINAL_STATUSES.has(reconciledState) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
2011
- message: TERMINAL_STATUSES.has(reconciledState)
2012
- ? "目标任务已结束,无法继续。"
2013
- : "该 run 只有磁盘快照,没有当前进程内的活动 CDP 会话,无法安全继续。",
2014
- retryable: !TERMINAL_STATUSES.has(reconciledState)
2015
- },
2016
- run: reconciled.run,
2017
- persistence: {
2018
- source: "disk",
2019
- active_control_available: false,
2020
- stale_finalized: reconciled.stale_finalized === true
2021
- },
2022
- runtime_evaluate_used: false,
2023
- method_summary: {},
2024
- method_log: [],
2025
- chrome: null
2026
- };
2027
- }
2028
- return getRecruitPipelineRunTool({ args });
2029
- }
2030
- }
2031
-
2032
- export function cancelRecruitPipelineRunTool({ args = {} } = {}) {
2033
- const runId = normalizeText(args.run_id);
2034
- try {
2035
- const before = recruitRunService.getRecruitRun(runId);
2036
- if (TERMINAL_STATUSES.has(before.status)) {
2037
- const normalizedBefore = persistRecruitRunSnapshot(before);
2038
- return attachMethodEvidence({
2039
- status: "CANCEL_IGNORED",
2040
- run: normalizedBefore,
2041
- message: "目标任务已结束,无需取消。"
2042
- }, runId);
2043
- }
2044
- const run = recruitRunService.cancelRecruitRun(runId);
2045
- const normalizedRun = persistRecruitRunSnapshot(run);
2046
- return attachMethodEvidence({
2047
- status: "CANCEL_REQUESTED",
2048
- run: normalizedRun,
2049
- message: "已收到取消请求,将在当前候选人处理完成后安全停止。"
2050
- }, runId);
2051
- } catch {
2052
- const persisted = readRecruitRunState(runId);
2053
- if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
2054
- return {
2055
- status: "CANCEL_IGNORED",
2056
- run: persisted,
2057
- message: "目标任务已结束,无需取消。",
2058
- runtime_evaluate_used: false,
2059
- method_summary: {},
2060
- method_log: [],
2061
- chrome: null
2062
- };
2063
- }
2064
- if (persisted) {
2065
- const reconciled = reconcilePersistedRecruitRun(persisted, { cancelStale: true });
2066
- if (reconciled.stale_finalized) {
2067
- return {
2068
- status: "CANCEL_REQUESTED",
2069
- run: reconciled.run,
2070
- message: "该 search run 的后台进程已经不在,已将磁盘状态安全标记为 canceled 并生成结果文件。",
2071
- persistence: {
2072
- source: "disk",
2073
- active_control_available: false,
2074
- stale_finalized: true
2075
- },
2076
- runtime_evaluate_used: false,
2077
- method_summary: {},
2078
- method_log: [],
2079
- chrome: null
2080
- };
2081
- }
2082
- return patchPersistedRecruitControl(runId, {
2083
- pause_requested: true,
2084
- pause_requested_at: new Date().toISOString(),
2085
- pause_requested_by: "cancel_recruit_pipeline_run",
2086
- cancel_requested: true
2087
- }, {
2088
- status: "CANCEL_REQUESTED",
2089
- message: "取消请求已写入 detached search run 控制文件。",
2090
- lastMessage: "取消请求已写入 detached search run 控制文件。"
2091
- }) || getRecruitPipelineRunTool({ args });
2092
- }
2093
- return getRecruitPipelineRunTool({ args });
2094
- }
2095
- }
2096
-
2097
- export function __setRecruitMcpConnectorForTests(nextConnector) {
2098
- recruitConnectorImpl = typeof nextConnector === "function" ? nextConnector : connectRecruitChromeSession;
2099
- }
2100
-
2101
- export function __setRecruitMcpWorkflowForTests(nextWorkflow) {
2102
- recruitWorkflowImpl = typeof nextWorkflow === "function" ? nextWorkflow : runRecruitWorkflow;
2103
- recruitRunService = createRecruitRunService({
2104
- idPrefix: "mcp_recruit",
2105
- workflow: (...args) => recruitWorkflowImpl(...args),
2106
- onSnapshot: persistRecruitLifecycleSnapshot
2107
- });
2108
- }
2109
-
2110
- export function __resetRecruitMcpStateForTests() {
2111
- for (const meta of recruitRunMeta.values()) {
2112
- try {
2113
- meta.session?.close?.();
2114
- } catch {
2115
- // Best-effort test cleanup.
2116
- }
2117
- }
2118
- recruitRunMeta.clear();
2119
- __setRecruitMcpConnectorForTests(null);
2120
- __setRecruitMcpWorkflowForTests(null);
2121
- }
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ assertNoForbiddenCdpCalls,
8
+ bringPageToFront,
9
+ connectToChromeTargetOrOpen,
10
+ createBossLoginRequiredError,
11
+ detectBossLoginState,
12
+ enableDomains,
13
+ getMainFrameUrl,
14
+ isBossLoginUrl,
15
+ waitForMainFrameUrl,
16
+ sleep
17
+ } from "./core/browser/index.js";
18
+ import {
19
+ RUN_STATUS_CANCELING,
20
+ RUN_STATUS_CANCELED,
21
+ RUN_STATUS_COMPLETED,
22
+ RUN_STATUS_FAILED,
23
+ RUN_STATUS_PAUSED,
24
+ RUN_STATUS_RUNNING
25
+ } from "./core/run/index.js";
26
+ import {
27
+ buildLegacyScreenInputRows,
28
+ cloneReportInput,
29
+ writeLegacyScreenCsv
30
+ } from "./core/reporting/legacy-csv.js";
31
+ import {
32
+ createRecruitRunService,
33
+ parseRecruitInstruction,
34
+ RECRUIT_TARGET_URL,
35
+ runRecruitWorkflow,
36
+ waitForRecruitSearchControls
37
+ } from "./domains/recruit/index.js";
38
+ import {
39
+ resolveBossConfiguredOutputDir,
40
+ resolveHumanBehaviorForRun,
41
+ resolveBossScreeningConfig
42
+ } from "./chat-runtime-config.js";
43
+ import { DEFAULT_MAX_IMAGE_PAGES } from "./core/cv-acquisition/index.js";
44
+
45
+ const RUN_MODE_ASYNC = "async";
46
+ const RUN_MODE_SYNC = "sync";
47
+ const DEFAULT_RECRUIT_POLL_AFTER_SEC = 10;
48
+ const DEFAULT_RECRUIT_HOST = "127.0.0.1";
49
+ const DEFAULT_RECRUIT_PORT = 9222;
50
+ const TARGET_COUNT_SEMANTICS = "target_count means candidates that pass screening; scan continues until that many candidates pass or the list ends";
51
+ const DEFAULT_RECRUIT_HOME_DIR = ".boss-recruit-mcp";
52
+ const DETACHED_WORKER_SCRIPT = fileURLToPath(new URL("./detached-worker.js", import.meta.url));
53
+ const DETACHED_WORKER_POLL_MS = 1000;
54
+
55
+ const TERMINAL_STATUSES = new Set([
56
+ RUN_STATUS_COMPLETED,
57
+ RUN_STATUS_FAILED,
58
+ RUN_STATUS_CANCELED
59
+ ]);
60
+ const STALE_PROCESS_STATUSES = new Set([
61
+ "queued",
62
+ "running",
63
+ RUN_STATUS_CANCELING
64
+ ]);
65
+
66
+ let recruitWorkflowImpl = runRecruitWorkflow;
67
+ let recruitConnectorImpl = connectRecruitChromeSession;
68
+ let recruitRunService = createRecruitRunService({
69
+ idPrefix: "mcp_recruit",
70
+ workflow: (...args) => recruitWorkflowImpl(...args),
71
+ onSnapshot: persistRecruitLifecycleSnapshot
72
+ });
73
+ const recruitRunMeta = new Map();
74
+
75
+ function normalizeText(value) {
76
+ return String(value || "").replace(/\s+/g, " ").trim();
77
+ }
78
+
79
+ function parsePositiveInteger(raw, fallback) {
80
+ const parsed = Number.parseInt(String(raw || ""), 10);
81
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
82
+ }
83
+
84
+ function parseNonNegativeInteger(raw, fallback) {
85
+ const parsed = Number.parseInt(String(raw ?? ""), 10);
86
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
87
+ }
88
+
89
+ function isDebugTestMode(args = {}) {
90
+ return args.debug_test_mode === true || args.allow_debug_test_mode === true;
91
+ }
92
+
93
+ function normalizeScreeningModeArg(args = {}) {
94
+ const raw = normalizeText(args.screening_mode || args.screeningMode || "");
95
+ if (args.use_llm === false) return "deterministic";
96
+ return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
97
+ ? "deterministic"
98
+ : "llm";
99
+ }
100
+
101
+ function collectRecruitDebugTestOptions(args = {}) {
102
+ const reasons = [];
103
+ if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
104
+ if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
105
+ if (args.dry_run_post_action === true) reasons.push("dry_run_post_action");
106
+ if (args.execute_post_action === false) reasons.push("execute_post_action=false");
107
+ return reasons;
108
+ }
109
+
110
+ function methodSummary(methodLog = []) {
111
+ const summary = {};
112
+ for (const entry of methodLog || []) {
113
+ summary[entry.method] = (summary[entry.method] || 0) + 1;
114
+ }
115
+ return summary;
116
+ }
117
+
118
+ function normalizeExecutionMode(value) {
119
+ return normalizeText(value).toLowerCase() === RUN_MODE_SYNC ? RUN_MODE_SYNC : RUN_MODE_ASYNC;
120
+ }
121
+
122
+ function clonePlain(value, fallback = null) {
123
+ try {
124
+ return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
125
+ } catch {
126
+ return fallback;
127
+ }
128
+ }
129
+
130
+ function normalizeRunId(runId) {
131
+ const normalized = normalizeText(runId);
132
+ if (!normalized || normalized.includes("/") || normalized.includes("\\")) return "";
133
+ return normalized;
134
+ }
135
+
136
+ function getRecruitStateHome() {
137
+ const fromEnv = normalizeText(globalThis.process?.env?.BOSS_RECRUIT_HOME || "");
138
+ return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), DEFAULT_RECRUIT_HOME_DIR);
139
+ }
140
+
141
+ function getRecruitRunsDir() {
142
+ return path.join(getRecruitStateHome(), "runs");
143
+ }
144
+
145
+ function getRecruitRunArtifacts(runId) {
146
+ const normalized = normalizeRunId(runId);
147
+ if (!normalized) return null;
148
+ const runsDir = getRecruitRunsDir();
149
+ const outputDir = resolveBossConfiguredOutputDir("", runsDir);
150
+ return {
151
+ runs_dir: runsDir,
152
+ output_dir: outputDir,
153
+ run_state_path: path.join(runsDir, `${normalized}.json`),
154
+ detached_args_path: path.join(runsDir, `${normalized}.detached-args.json`),
155
+ worker_stdout_path: path.join(runsDir, `${normalized}.worker.stdout.log`),
156
+ worker_stderr_path: path.join(runsDir, `${normalized}.worker.stderr.log`),
157
+ checkpoint_path: path.join(runsDir, `${normalized}.checkpoint.json`),
158
+ output_csv: path.join(outputDir, `${normalized}.results.csv`),
159
+ report_json: path.join(outputDir, `${normalized}.report.json`)
160
+ };
161
+ }
162
+
163
+ function ensureDirectory(dirPath) {
164
+ fs.mkdirSync(dirPath, { recursive: true });
165
+ }
166
+
167
+ function writeJsonAtomic(filePath, payload) {
168
+ ensureDirectory(path.dirname(filePath));
169
+ const tempPath = `${filePath}.tmp`;
170
+ fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
171
+ fs.renameSync(tempPath, filePath);
172
+ }
173
+
174
+ function readJsonFile(filePath) {
175
+ try {
176
+ if (!fs.existsSync(filePath)) return null;
177
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
178
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function selectedRecruitJobForCsv(meta = {}) {
185
+ const keyword = normalizeText(
186
+ meta.parsed?.proposed_keyword
187
+ || meta.parsed?.searchParams?.keyword
188
+ || meta.args?.confirmation?.keyword_value
189
+ || meta.args?.overrides?.keyword
190
+ || ""
191
+ );
192
+ return {
193
+ value: keyword,
194
+ title: keyword,
195
+ label: keyword
196
+ };
197
+ }
198
+
199
+ function buildRecruitCsvInputRows(snapshot = {}, meta = {}) {
200
+ const searchParams = meta.parsed?.searchParams || snapshot.summary?.search_params || {};
201
+ const screenParams = meta.parsed?.screenParams || {};
202
+ return buildLegacyScreenInputRows({
203
+ instruction: meta.args?.instruction || "",
204
+ selectedPage: "search",
205
+ selectedJob: selectedRecruitJobForCsv(meta),
206
+ userSearchParams: cloneReportInput(searchParams, {}),
207
+ effectiveSearchParams: cloneReportInput(searchParams, {}),
208
+ screenParams: {
209
+ criteria: screenParams.criteria || "",
210
+ target_count: screenParams.target_count || snapshot.progress?.target_count || snapshot.context?.max_candidates || "",
211
+ post_action: screenParams.post_action || "none",
212
+ max_greet_count: screenParams.max_greet_count ?? ""
213
+ },
214
+ followUp: meta.args?.follow_up || meta.args?.overrides?.follow_up || null
215
+ });
216
+ }
217
+
218
+ function writeRecruitLegacyCsvAtomic(filePath, rows = [], snapshot = {}, meta = {}) {
219
+ writeLegacyScreenCsv(filePath, {
220
+ inputRows: buildRecruitCsvInputRows(snapshot, meta),
221
+ results: rows
222
+ });
223
+ }
224
+
225
+ function readRecruitRunState(runId) {
226
+ const artifacts = getRecruitRunArtifacts(runId);
227
+ if (!artifacts) return null;
228
+ return readJsonFile(artifacts.run_state_path);
229
+ }
230
+
231
+ function writeRecruitRunState(runId, payload) {
232
+ const artifacts = getRecruitRunArtifacts(runId);
233
+ if (!artifacts) return null;
234
+ writeJsonAtomic(artifacts.run_state_path, payload);
235
+ return payload;
236
+ }
237
+
238
+ function createDetachedRecruitRunId() {
239
+ return `mcp_recruit_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
240
+ }
241
+
242
+ function isPidAlive(pid) {
243
+ const numericPid = Number(pid);
244
+ if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
245
+ if (numericPid === globalThis.process?.pid) return true;
246
+ try {
247
+ globalThis.process.kill(numericPid, 0);
248
+ return true;
249
+ } catch (error) {
250
+ return error?.code === "EPERM";
251
+ }
252
+ }
253
+
254
+ function buildInitialRecruitDetachedState(runId, {
255
+ workspaceRoot = "",
256
+ args = {},
257
+ parsed = {},
258
+ pid = globalThis.process?.pid
259
+ } = {}) {
260
+ const artifacts = getRecruitRunArtifacts(runId);
261
+ const now = new Date().toISOString();
262
+ const targetCount = parsePositiveInteger(args.max_candidates, parsed.screenParams?.target_count || 10);
263
+ return {
264
+ run_id: runId,
265
+ mode: RUN_MODE_ASYNC,
266
+ state: "queued",
267
+ status: "queued",
268
+ stage: "queued",
269
+ started_at: now,
270
+ updated_at: now,
271
+ heartbeat_at: now,
272
+ completed_at: null,
273
+ pid: Number.isInteger(pid) && pid > 0 ? pid : globalThis.process?.pid || null,
274
+ progress: {
275
+ target_count: targetCount,
276
+ processed: 0,
277
+ screened: 0,
278
+ detail_opened: 0,
279
+ llm_screened: 0,
280
+ passed: 0,
281
+ skipped: 0,
282
+ greet_count: 0
283
+ },
284
+ last_message: "Boss search detached worker is queued.",
285
+ context: {
286
+ domain: "recruit",
287
+ target_url: RECRUIT_TARGET_URL,
288
+ workspace_root: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
289
+ instruction: args.instruction || "",
290
+ confirmation: clonePlain(args.confirmation || {}, {}),
291
+ overrides: clonePlain(args.overrides || {}, {}),
292
+ search_params: clonePlain(parsed.searchParams || {}, {}),
293
+ criteria_present: Boolean(parsed.screenParams?.criteria),
294
+ max_candidates: targetCount,
295
+ target_count_semantics: TARGET_COUNT_SEMANTICS,
296
+ detached_worker: true,
297
+ rounds: []
298
+ },
299
+ control: {
300
+ pause_requested: false,
301
+ pause_requested_at: null,
302
+ pause_requested_by: null,
303
+ cancel_requested: false
304
+ },
305
+ resume: {
306
+ checkpoint_path: artifacts?.checkpoint_path || null,
307
+ pause_control_path: artifacts?.run_state_path || null,
308
+ output_csv: null,
309
+ worker_stdout_path: artifacts?.worker_stdout_path || null,
310
+ worker_stderr_path: artifacts?.worker_stderr_path || null,
311
+ resume_count: 0,
312
+ last_resumed_at: null,
313
+ last_paused_at: null
314
+ },
315
+ error: null,
316
+ result: null,
317
+ summary: null,
318
+ artifacts
319
+ };
320
+ }
321
+
322
+ function patchPersistedRecruitControl(runId, controlPatch = {}, {
323
+ status = "RUN_STATUS",
324
+ message = "",
325
+ lastMessage = ""
326
+ } = {}) {
327
+ const current = readRecruitRunState(runId);
328
+ if (!current) return null;
329
+ const state = normalizeText(current.state || current.status);
330
+ if (TERMINAL_STATUSES.has(state)) return null;
331
+ const now = new Date().toISOString();
332
+ const patched = {
333
+ ...current,
334
+ updated_at: now,
335
+ heartbeat_at: now,
336
+ last_message: lastMessage || message || current.last_message || "",
337
+ control: {
338
+ ...(current.control || {}),
339
+ ...controlPatch
340
+ }
341
+ };
342
+ writeRecruitRunState(runId, patched);
343
+ return {
344
+ status,
345
+ run: patched,
346
+ message,
347
+ persistence: {
348
+ source: "disk",
349
+ active_control_available: false,
350
+ detached_control_requested: true
351
+ },
352
+ runtime_evaluate_used: false,
353
+ method_summary: {},
354
+ method_log: [],
355
+ chrome: null
356
+ };
357
+ }
358
+
359
+ function launchDetachedRecruitWorker(runId) {
360
+ const artifacts = getRecruitRunArtifacts(runId);
361
+ if (!artifacts) throw new Error("Invalid recruit run_id");
362
+ fs.mkdirSync(path.dirname(artifacts.worker_stdout_path), { recursive: true });
363
+ const stdoutFd = fs.openSync(artifacts.worker_stdout_path, "a");
364
+ const stderrFd = fs.openSync(artifacts.worker_stderr_path, "a");
365
+ let child;
366
+ try {
367
+ child = spawn(globalThis.process.execPath, [
368
+ DETACHED_WORKER_SCRIPT,
369
+ "--domain",
370
+ "recruit",
371
+ "--run-id",
372
+ runId
373
+ ], {
374
+ detached: true,
375
+ stdio: ["ignore", stdoutFd, stderrFd],
376
+ windowsHide: true,
377
+ env: globalThis.process.env
378
+ });
379
+ } finally {
380
+ fs.closeSync(stdoutFd);
381
+ fs.closeSync(stderrFd);
382
+ }
383
+ if (typeof child?.unref === "function") child.unref();
384
+ return child;
385
+ }
386
+
387
+ function ensureRecruitRunArtifacts(snapshot) {
388
+ const artifacts = getRecruitRunArtifacts(snapshot?.runId || snapshot?.run_id);
389
+ if (!artifacts) return null;
390
+
391
+ const meta = getRecruitRunMeta(snapshot?.runId || snapshot?.run_id);
392
+ const checkpoint = snapshot?.checkpoint && typeof snapshot.checkpoint === "object"
393
+ ? snapshot.checkpoint
394
+ : {};
395
+ writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
396
+ if (meta) meta.checkpointPath = artifacts.checkpoint_path;
397
+
398
+ const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
399
+ const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
400
+ const artifactSummary = summary || (checkpointResults.length ? {
401
+ domain: "recruit",
402
+ partial: true,
403
+ partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
404
+ results: checkpointResults
405
+ } : null);
406
+ if (artifactSummary) {
407
+ const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
408
+ writeRecruitLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
409
+ writeJsonAtomic(artifacts.report_json, {
410
+ run_id: snapshot.runId || snapshot.run_id,
411
+ status: snapshot.status || snapshot.state,
412
+ phase: snapshot.phase || snapshot.stage,
413
+ progress: snapshot.progress || {},
414
+ context: snapshot.context || {},
415
+ checkpoint,
416
+ summary: artifactSummary,
417
+ generated_at: new Date().toISOString()
418
+ });
419
+ if (meta) {
420
+ meta.outputCsvPath = artifacts.output_csv;
421
+ meta.reportJsonPath = artifacts.report_json;
422
+ }
423
+ }
424
+
425
+ return artifacts;
426
+ }
427
+
428
+ function persistRecruitCheckpointSnapshot(normalized) {
429
+ const artifacts = getRecruitRunArtifacts(normalized?.run_id || normalized?.runId);
430
+ if (!artifacts) return;
431
+ const checkpoint = normalized?.checkpoint && typeof normalized.checkpoint === "object"
432
+ ? normalized.checkpoint
433
+ : {};
434
+ writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
435
+ const meta = getRecruitRunMeta(normalized?.run_id || normalized?.runId);
436
+ if (meta) meta.checkpointPath = artifacts.checkpoint_path;
437
+ }
438
+
439
+ function toIsoOrNull(value) {
440
+ const normalized = normalizeText(value);
441
+ return normalized || null;
442
+ }
443
+
444
+ function secondsBetween(startedAt, endedAt) {
445
+ const startMs = Date.parse(startedAt || "");
446
+ const endMs = Date.parse(endedAt || "") || Date.now();
447
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
448
+ return Math.max(1, Math.round((endMs - startMs) / 1000));
449
+ }
450
+
451
+ function normalizeLegacyProgress(progress = {}, summary = null) {
452
+ const processed = Number.isInteger(progress.processed)
453
+ ? progress.processed
454
+ : Number.isInteger(summary?.processed)
455
+ ? summary.processed
456
+ : 0;
457
+ const passed = Number.isInteger(progress.passed)
458
+ ? progress.passed
459
+ : Number.isInteger(summary?.passed)
460
+ ? summary.passed
461
+ : 0;
462
+ return {
463
+ ...progress,
464
+ processed,
465
+ passed,
466
+ skipped: Number.isInteger(progress.skipped) ? progress.skipped : Math.max(processed - passed, 0),
467
+ greet_count: Number.isInteger(progress.greet_count) ? progress.greet_count : 0
468
+ };
469
+ }
470
+
471
+ function completionReason(status) {
472
+ if (status === RUN_STATUS_COMPLETED) return "completed";
473
+ if (status === RUN_STATUS_CANCELED) return "canceled_by_user";
474
+ if (status === RUN_STATUS_FAILED) return "failed";
475
+ if (status === RUN_STATUS_PAUSED) return "paused";
476
+ return null;
477
+ }
478
+
479
+ function snapshotFromPersistedRecruitRun(persisted = {}) {
480
+ return {
481
+ runId: persisted.run_id || persisted.runId,
482
+ name: persisted.name || persisted.run_id || persisted.runId,
483
+ status: persisted.status || persisted.state,
484
+ phase: persisted.stage || persisted.phase,
485
+ progress: persisted.progress || {},
486
+ context: persisted.context || {},
487
+ checkpoint: persisted.checkpoint || {},
488
+ startedAt: persisted.started_at || persisted.startedAt,
489
+ updatedAt: persisted.updated_at || persisted.updatedAt,
490
+ completedAt: persisted.completed_at || persisted.completedAt || null,
491
+ error: persisted.error || null,
492
+ summary: persisted.summary || null
493
+ };
494
+ }
495
+
496
+ function attachLegacyArtifactsToPersistedRecruitRun(persisted = {}) {
497
+ const runId = normalizeRunId(persisted.run_id || persisted.runId);
498
+ if (!runId) return persisted;
499
+ const snapshot = snapshotFromPersistedRecruitRun(persisted);
500
+ const result = buildLegacyRunResult(snapshot);
501
+ const artifacts = getRecruitRunArtifacts(runId);
502
+ const next = {
503
+ ...persisted,
504
+ result,
505
+ resume: {
506
+ ...(persisted.resume || {}),
507
+ checkpoint_path: result?.checkpoint_path || persisted.resume?.checkpoint_path || artifacts?.checkpoint_path || null,
508
+ output_csv: result?.output_csv || persisted.resume?.output_csv || artifacts?.output_csv || null,
509
+ worker_stdout_path: artifacts?.worker_stdout_path || persisted.resume?.worker_stdout_path || null,
510
+ worker_stderr_path: artifacts?.worker_stderr_path || persisted.resume?.worker_stderr_path || null
511
+ },
512
+ artifacts: artifacts || persisted.artifacts || null
513
+ };
514
+ return writeRecruitRunState(runId, next);
515
+ }
516
+
517
+ function finalizePersistedRecruitRun(persisted = {}, {
518
+ status = RUN_STATUS_FAILED,
519
+ error = null,
520
+ message = ""
521
+ } = {}) {
522
+ const runId = normalizeRunId(persisted.run_id || persisted.runId);
523
+ if (!runId) return persisted;
524
+ const now = new Date().toISOString();
525
+ const normalizedError = status === RUN_STATUS_FAILED
526
+ ? {
527
+ name: error?.name || "Error",
528
+ code: error?.code || "STALE_RUN_PROCESS_EXITED",
529
+ message: error?.message || message || "Boss search run process exited before it wrote a terminal state."
530
+ }
531
+ : null;
532
+ const next = {
533
+ ...persisted,
534
+ run_id: runId,
535
+ state: status,
536
+ status,
537
+ stage: persisted.stage || persisted.phase || "recruit:stale",
538
+ updated_at: now,
539
+ heartbeat_at: now,
540
+ completed_at: persisted.completed_at || now,
541
+ last_message: normalizedError?.message || message || status,
542
+ control: {
543
+ ...(persisted.control || {}),
544
+ cancel_requested: false
545
+ },
546
+ error: normalizedError,
547
+ summary: persisted.summary || null
548
+ };
549
+ return attachLegacyArtifactsToPersistedRecruitRun(next);
550
+ }
551
+
552
+ function reconcilePersistedRecruitRun(persisted = {}, { cancelStale = false } = {}) {
553
+ const status = persisted.status || persisted.state;
554
+ if (STALE_PROCESS_STATUSES.has(status) && !isPidAlive(persisted.pid)) {
555
+ const shouldCancel = cancelStale || status === RUN_STATUS_CANCELING || persisted.control?.cancel_requested === true;
556
+ return {
557
+ run: finalizePersistedRecruitRun(persisted, {
558
+ status: shouldCancel ? RUN_STATUS_CANCELED : RUN_STATUS_FAILED,
559
+ error: shouldCancel ? null : {
560
+ code: "STALE_RUN_PROCESS_EXITED",
561
+ message: `Boss search run process is no longer alive for pid=${persisted.pid || "unknown"}.`
562
+ },
563
+ message: shouldCancel
564
+ ? "Boss search run was canceled after its worker process was no longer active."
565
+ : `Boss search run process is no longer alive for pid=${persisted.pid || "unknown"}.`
566
+ }),
567
+ stale_finalized: true
568
+ };
569
+ }
570
+ return { run: persisted };
571
+ }
572
+
573
+ export function markBossRecruitDetachedWorkerFailed(runId, error, options = {}) {
574
+ const normalizedRunId = normalizeRunId(runId);
575
+ if (!normalizedRunId) return null;
576
+ const persisted = readRecruitRunState(normalizedRunId) || buildInitialRecruitDetachedState(normalizedRunId, {});
577
+ const state = normalizeText(persisted.state || persisted.status);
578
+ if (TERMINAL_STATUSES.has(state)) return persisted;
579
+ const errorPayload = {
580
+ name: error?.name || "Error",
581
+ code: options.code || error?.code || "RECRUIT_WORKER_UNHANDLED_EXCEPTION",
582
+ message: normalizeText(error?.message || error || options.message) || "Boss search detached worker exited unexpectedly."
583
+ };
584
+ if (normalizeText(error?.stack || "")) {
585
+ errorPayload.stack = String(error.stack).slice(0, 8000);
586
+ }
587
+ return finalizePersistedRecruitRun(persisted, {
588
+ status: RUN_STATUS_FAILED,
589
+ error: errorPayload,
590
+ message: errorPayload.message
591
+ });
592
+ }
593
+
594
+ function buildLegacyRunResult(snapshot) {
595
+ if (!snapshot) return null;
596
+ const artifacts = ensureRecruitRunArtifacts(snapshot);
597
+ const meta = getRecruitRunMeta(snapshot.runId);
598
+ const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
599
+ const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
600
+ const resultRows = Array.isArray(summary?.results)
601
+ ? summary.results
602
+ : Array.isArray(checkpoint.results)
603
+ ? checkpoint.results
604
+ : [];
605
+ const progress = normalizeLegacyProgress(snapshot.progress, summary);
606
+ const targetCount = Number.isInteger(progress.target_count)
607
+ ? progress.target_count
608
+ : Number.isInteger(snapshot.context?.max_candidates)
609
+ ? snapshot.context.max_candidates
610
+ : null;
611
+ return {
612
+ target_count: targetCount,
613
+ processed_count: progress.processed,
614
+ passed_count: progress.passed,
615
+ screened_count: Number.isInteger(progress.screened)
616
+ ? progress.screened
617
+ : Number.isInteger(summary?.screened)
618
+ ? summary.screened
619
+ : progress.processed,
620
+ detail_opened: Number.isInteger(progress.detail_opened)
621
+ ? progress.detail_opened
622
+ : Number.isInteger(summary?.detail_opened)
623
+ ? summary.detail_opened
624
+ : 0,
625
+ duration_sec: secondsBetween(snapshot.startedAt, snapshot.completedAt || snapshot.updatedAt),
626
+ output_csv: summary?.output_csv || meta.outputCsvPath || artifacts?.output_csv || null,
627
+ report_json: summary?.report_json || meta.reportJsonPath || artifacts?.report_json || null,
628
+ worker_stdout_path: artifacts?.worker_stdout_path || null,
629
+ worker_stderr_path: artifacts?.worker_stderr_path || null,
630
+ round_count: 1,
631
+ current_round_index: 1,
632
+ checkpoint_path: snapshot.checkpoint?.checkpoint_path
633
+ || snapshot.checkpoint?.path
634
+ || meta.checkpointPath
635
+ || artifacts?.checkpoint_path
636
+ || null,
637
+ completion_reason: completionReason(snapshot.status),
638
+ target_count_semantics: TARGET_COUNT_SEMANTICS,
639
+ run_id: snapshot.runId,
640
+ results: resultRows
641
+ };
642
+ }
643
+
644
+ function createTargetCountSchema(description) {
645
+ return {
646
+ oneOf: [
647
+ { type: "integer", minimum: 1 },
648
+ { type: "string", pattern: "^[1-9][0-9]*$" }
649
+ ],
650
+ description
651
+ };
652
+ }
653
+
654
+ function createHumanBehaviorInputSchema(description = "可选,search/recruit 可靠性实验用节奏配置;默认 paced_with_rests/on") {
655
+ return {
656
+ type: "object",
657
+ properties: {
658
+ enabled: { type: "boolean" },
659
+ profile: {
660
+ type: "string",
661
+ enum: ["baseline", "paced", "paced_with_rests"]
662
+ },
663
+ clickMovement: { type: "boolean" },
664
+ textEntry: { type: "boolean" },
665
+ listScrollJitter: { type: "boolean" },
666
+ shortRest: { type: "boolean" },
667
+ batchRest: { type: "boolean" },
668
+ actionCooldown: { type: "boolean" },
669
+ restLevel: {
670
+ type: "string",
671
+ enum: ["low", "medium", "high"],
672
+ description: "本次 run 的休息强度:low 保持旧策略;medium 约 5 小时/700 人累计休息 30 分钟;high 约 5 小时/700 人累计休息 1 小时"
673
+ },
674
+ rest_level: {
675
+ type: "string",
676
+ enum: ["low", "medium", "high"],
677
+ description: "兼容字段;优先使用 restLevel"
678
+ }
679
+ },
680
+ additionalProperties: false,
681
+ description
682
+ };
683
+ }
684
+
685
+ export function createRecruitPipelineInputSchema() {
686
+ return {
687
+ type: "object",
688
+ properties: {
689
+ instruction: {
690
+ type: "string",
691
+ description: "用户自然语言招聘指令"
692
+ },
693
+ execution_mode: {
694
+ type: "string",
695
+ enum: [RUN_MODE_ASYNC, RUN_MODE_SYNC],
696
+ description: "执行模式;默认 async。"
697
+ },
698
+ confirmation: {
699
+ type: "object",
700
+ description: "搜索页确认状态。新流程建议在用户看过总览后传 final_confirmed=true;逐字段 *_confirmed 为兼容旧调用保留。",
701
+ properties: {
702
+ final_confirmed: {
703
+ type: "boolean",
704
+ description: "用户已确认包含岗位、关键词、城市、学历/院校、是否过滤已看、criteria、目标人数、动作和 restLevel 的总览。"
705
+ },
706
+ job_confirmed: { type: "boolean" },
707
+ job_value: { type: "string" },
708
+ keyword_confirmed: { type: "boolean" },
709
+ keyword_value: { type: "string" },
710
+ search_params_confirmed: { type: "boolean" },
711
+ criteria_confirmed: { type: "boolean" },
712
+ criteria_value: { type: "string" },
713
+ skip_recent_colleague_contacted_confirmed: { type: "boolean" },
714
+ skip_recent_colleague_contacted_value: { type: "boolean" },
715
+ filter_recent_colleague_contacted_confirmed: { type: "boolean" },
716
+ filter_recent_colleague_contacted_value: {
717
+ type: "boolean",
718
+ description: "是否过滤近期已被同事触达的人选;true 会开启搜索页“近30天未和同事交换简历”。"
719
+ },
720
+ post_action_confirmed: { type: "boolean" },
721
+ post_action_value: {
722
+ type: "string",
723
+ enum: ["greet", "none"]
724
+ },
725
+ max_greet_count_value: { type: "integer", minimum: 1 },
726
+ use_default_for_missing: { type: "boolean" }
727
+ },
728
+ additionalProperties: false
729
+ },
730
+ overrides: {
731
+ type: "object",
732
+ properties: {
733
+ job: { type: "string" },
734
+ job_title: { type: "string" },
735
+ selected_job: { type: "string" },
736
+ city: { type: "string" },
737
+ degree: {
738
+ anyOf: [
739
+ { type: "string" },
740
+ { type: "array", items: { type: "string" }, minItems: 1 }
741
+ ]
742
+ },
743
+ degrees: {
744
+ anyOf: [
745
+ { type: "string" },
746
+ { type: "array", items: { type: "string" }, minItems: 1 }
747
+ ]
748
+ },
749
+ filter_recent_viewed: { type: "boolean" },
750
+ skip_recent_colleague_contacted: {
751
+ type: "boolean",
752
+ description: "显式 true 时开启 Boss 的“近30天未和同事交换简历”过滤;false 会确保该过滤取消;未提供时不默认开启。"
753
+ },
754
+ filter_recent_colleague_contacted: {
755
+ type: "boolean",
756
+ description: "是否过滤近期已被同事触达的人选;true 会开启搜索页“近30天未和同事交换简历”;false 会确保该过滤取消。"
757
+ },
758
+ recent_colleague_contacted: {
759
+ anyOf: [
760
+ { type: "boolean" },
761
+ { type: "string" }
762
+ ],
763
+ description: "同事近期触达筛选别名;可填 不限/不过滤/过滤。"
764
+ },
765
+ recent_not_view: {
766
+ anyOf: [
767
+ { type: "boolean" },
768
+ { type: "string" }
769
+ ]
770
+ },
771
+ schools: {
772
+ anyOf: [
773
+ { type: "array", items: { type: "string" } },
774
+ { type: "string" }
775
+ ]
776
+ },
777
+ school_tag: {
778
+ anyOf: [
779
+ { type: "array", items: { type: "string" } },
780
+ { type: "string" }
781
+ ]
782
+ },
783
+ school_tags: {
784
+ anyOf: [
785
+ { type: "array", items: { type: "string" } },
786
+ { type: "string" }
787
+ ]
788
+ },
789
+ experience: {
790
+ anyOf: [
791
+ { type: "string" },
792
+ {
793
+ type: "object",
794
+ properties: {
795
+ mode: { type: "string" },
796
+ label: { type: "string" },
797
+ option: { type: "string" },
798
+ start: { anyOf: [{ type: "string" }, { type: "integer" }] },
799
+ end: { anyOf: [{ type: "string" }, { type: "integer" }] },
800
+ min: { anyOf: [{ type: "string" }, { type: "integer" }] },
801
+ max: { anyOf: [{ type: "string" }, { type: "integer" }] },
802
+ from: { anyOf: [{ type: "string" }, { type: "integer" }] },
803
+ to: { anyOf: [{ type: "string" }, { type: "integer" }] },
804
+ start_value: { type: "integer" },
805
+ end_value: { type: "integer" }
806
+ },
807
+ additionalProperties: false
808
+ }
809
+ ]
810
+ },
811
+ experiences: {
812
+ anyOf: [
813
+ { type: "array", items: { type: "string" } },
814
+ { type: "string" }
815
+ ]
816
+ },
817
+ experience_range: {
818
+ anyOf: [
819
+ { type: "string" },
820
+ {
821
+ type: "object",
822
+ properties: {
823
+ mode: { type: "string" },
824
+ label: { type: "string" },
825
+ option: { type: "string" },
826
+ start: { anyOf: [{ type: "string" }, { type: "integer" }] },
827
+ end: { anyOf: [{ type: "string" }, { type: "integer" }] },
828
+ min: { anyOf: [{ type: "string" }, { type: "integer" }] },
829
+ max: { anyOf: [{ type: "string" }, { type: "integer" }] },
830
+ from: { anyOf: [{ type: "string" }, { type: "integer" }] },
831
+ to: { anyOf: [{ type: "string" }, { type: "integer" }] },
832
+ start_value: { type: "integer" },
833
+ end_value: { type: "integer" }
834
+ },
835
+ additionalProperties: false
836
+ }
837
+ ]
838
+ },
839
+ experience_start: {
840
+ anyOf: [
841
+ { type: "string" },
842
+ { type: "integer" }
843
+ ]
844
+ },
845
+ experience_end: {
846
+ anyOf: [
847
+ { type: "string" },
848
+ { type: "integer" }
849
+ ]
850
+ },
851
+ gender: { type: "string" },
852
+ age: {
853
+ anyOf: [
854
+ { type: "string" },
855
+ {
856
+ type: "object",
857
+ properties: {
858
+ mode: { type: "string" },
859
+ label: { type: "string" },
860
+ option: { type: "string" },
861
+ min: { anyOf: [{ type: "string" }, { type: "integer" }] },
862
+ max: { anyOf: [{ type: "string" }, { type: "integer" }] },
863
+ start: { anyOf: [{ type: "string" }, { type: "integer" }] },
864
+ end: { anyOf: [{ type: "string" }, { type: "integer" }] },
865
+ from: { anyOf: [{ type: "string" }, { type: "integer" }] },
866
+ to: { anyOf: [{ type: "string" }, { type: "integer" }] }
867
+ },
868
+ additionalProperties: false
869
+ }
870
+ ]
871
+ },
872
+ ages: {
873
+ anyOf: [
874
+ { type: "array", items: { type: "string" } },
875
+ { type: "string" }
876
+ ]
877
+ },
878
+ age_range: {
879
+ anyOf: [
880
+ { type: "string" },
881
+ {
882
+ type: "object",
883
+ properties: {
884
+ mode: { type: "string" },
885
+ label: { type: "string" },
886
+ option: { type: "string" },
887
+ min: { anyOf: [{ type: "string" }, { type: "integer" }] },
888
+ max: { anyOf: [{ type: "string" }, { type: "integer" }] },
889
+ start: { anyOf: [{ type: "string" }, { type: "integer" }] },
890
+ end: { anyOf: [{ type: "string" }, { type: "integer" }] },
891
+ from: { anyOf: [{ type: "string" }, { type: "integer" }] },
892
+ to: { anyOf: [{ type: "string" }, { type: "integer" }] }
893
+ },
894
+ additionalProperties: false
895
+ }
896
+ ]
897
+ },
898
+ age_min: {
899
+ anyOf: [
900
+ { type: "string" },
901
+ { type: "integer" }
902
+ ]
903
+ },
904
+ age_max: {
905
+ anyOf: [
906
+ { type: "string" },
907
+ { type: "integer" }
908
+ ]
909
+ },
910
+ min_age: {
911
+ anyOf: [
912
+ { type: "string" },
913
+ { type: "integer" }
914
+ ]
915
+ },
916
+ max_age: {
917
+ anyOf: [
918
+ { type: "string" },
919
+ { type: "integer" }
920
+ ]
921
+ },
922
+ keyword: { type: "string" },
923
+ target_count: { type: "integer", minimum: 1 },
924
+ criteria: { type: "string" },
925
+ post_action: {
926
+ type: "string",
927
+ enum: ["greet", "none"]
928
+ },
929
+ max_greet_count: { type: "integer", minimum: 1 }
930
+ },
931
+ additionalProperties: false
932
+ },
933
+ host: {
934
+ type: "string",
935
+ description: "可选,Chrome 调试 host;默认 127.0.0.1"
936
+ },
937
+ port: {
938
+ type: "integer",
939
+ minimum: 1,
940
+ description: "可选,Chrome 调试端口;默认 9222"
941
+ },
942
+ target_url_includes: {
943
+ type: "string",
944
+ description: "可选,Chrome target URL 匹配片段;默认 Boss search 页"
945
+ },
946
+ allow_navigate: {
947
+ type: "boolean",
948
+ description: "找不到 search target 时,是否允许复用 Boss chat target 并导航到 search;默认 true"
949
+ },
950
+ reset_search: {
951
+ type: "boolean",
952
+ description: "执行前是否重置 Boss search frame;默认 true"
953
+ },
954
+ slow_live: {
955
+ type: "boolean",
956
+ description: "VPN/慢页面模式:放宽 live DOM 等待时间"
957
+ },
958
+ human_behavior: createHumanBehaviorInputSchema("可选,search/recruit 可靠性实验用节奏配置;默认 paced_with_rests/on"),
959
+ humanBehavior: createHumanBehaviorInputSchema("兼容字段;优先使用 human_behavior"),
960
+ human_behavior_enabled: {
961
+ type: "boolean",
962
+ description: "兼容字段;true 等同启用 paced 默认配置,false 等同 baseline"
963
+ },
964
+ human_behavior_profile: {
965
+ type: "string",
966
+ enum: ["baseline", "paced", "paced_with_rests"],
967
+ description: "可选实验 profile:baseline/paced/paced_with_rests"
968
+ },
969
+ safe_pacing: {
970
+ type: "boolean",
971
+ description: "兼容字段;true 启用 paced,false 关闭"
972
+ },
973
+ batch_rest_enabled: {
974
+ type: "boolean",
975
+ description: "兼容字段;true 启用 paced_with_rests 的候选人短休/批次休息"
976
+ },
977
+ max_candidates: createTargetCountSchema("本次最多处理候选人数;默认使用解析出的 target_count"),
978
+ detail_limit: {
979
+ type: "integer",
980
+ minimum: 0,
981
+ description: "打开详情/CV 的人数上限;默认跟随 max_candidates。detail_limit=0 属于调试路径,需要 debug_test_mode=true"
982
+ },
983
+ debug_test_mode: {
984
+ type: "boolean",
985
+ description: "高级测试开关;默认 false。只有显式为 true 时才允许 deterministic/local scorer、detail_limit=0 等调试路径"
986
+ },
987
+ screening_mode: {
988
+ type: "string",
989
+ enum: ["llm", "deterministic"],
990
+ description: "筛选引擎;默认 llm。deterministic 仅限 debug_test_mode=true"
991
+ },
992
+ use_llm: {
993
+ type: "boolean",
994
+ description: "兼容字段;默认 true。use_llm=false 等同 deterministic,仅限 debug_test_mode=true"
995
+ },
996
+ llm_timeout_ms: {
997
+ type: "integer",
998
+ minimum: 1000,
999
+ description: "可选,单个候选人的 LLM 调用超时"
1000
+ },
1001
+ llm_image_limit: {
1002
+ type: "integer",
1003
+ minimum: 1,
1004
+ description: "可选,传给 LLM 的图片简历截图页数上限"
1005
+ },
1006
+ llm_image_detail: {
1007
+ type: "string",
1008
+ description: "可选,图片输入 detail,默认 low"
1009
+ },
1010
+ delay_ms: {
1011
+ type: "integer",
1012
+ minimum: 0,
1013
+ description: "候选人之间的延迟;live pause/resume 测试可增大它"
1014
+ },
1015
+ execute_post_action: {
1016
+ type: "boolean",
1017
+ description: "可选,是否实际执行通过后的 search 后置动作 greet;默认 true"
1018
+ },
1019
+ dry_run_post_action: {
1020
+ type: "boolean",
1021
+ description: "可选,只验证 search 打招呼动作发现/配额/可点击路径,不实际点击"
1022
+ },
1023
+ action_timeout_ms: {
1024
+ type: "integer",
1025
+ minimum: 1000,
1026
+ description: "可选,等待详情页 greet 控件出现的超时时间"
1027
+ },
1028
+ action_interval_ms: {
1029
+ type: "integer",
1030
+ minimum: 100,
1031
+ description: "可选,轮询详情页 greet 控件的间隔"
1032
+ },
1033
+ action_after_click_delay_ms: {
1034
+ type: "integer",
1035
+ minimum: 0,
1036
+ description: "可选,点击 greet 后等待页面状态稳定的时间"
1037
+ }
1038
+ },
1039
+ required: ["instruction"],
1040
+ additionalProperties: false
1041
+ };
1042
+ }
1043
+
1044
+ export function createRecruitRunIdInputSchema() {
1045
+ return {
1046
+ type: "object",
1047
+ properties: {
1048
+ run_id: { type: "string" }
1049
+ },
1050
+ required: ["run_id"],
1051
+ additionalProperties: false
1052
+ };
1053
+ }
1054
+
1055
+ export function validateRecruitPipelineArgs(args) {
1056
+ if (!args || typeof args !== "object") return "arguments must be an object";
1057
+ if (!args.instruction || typeof args.instruction !== "string") {
1058
+ return "instruction is required and must be a string";
1059
+ }
1060
+ return null;
1061
+ }
1062
+
1063
+ function buildRequiredConfirmations(parsedResult) {
1064
+ const confirmations = [];
1065
+ if (parsedResult.needs_search_params_confirmation) confirmations.push("search_params");
1066
+ if (parsedResult.needs_keyword_confirmation) confirmations.push("keyword");
1067
+ if (parsedResult.needs_recent_viewed_filter_confirmation) confirmations.push("filter_recent_viewed");
1068
+ if (parsedResult.needs_skip_recent_colleague_contacted_confirmation) confirmations.push("filter_recent_colleague_contacted");
1069
+ if (parsedResult.needs_criteria_confirmation) confirmations.push("criteria");
1070
+ if (parsedResult.has_unresolved_missing_fields) confirmations.push("missing_fields_or_defaults");
1071
+ if ((parsedResult.suspicious_fields || []).length) confirmations.push("suspicious_fields");
1072
+ return confirmations;
1073
+ }
1074
+
1075
+ function buildNeedInputResponse(parsedResult) {
1076
+ return {
1077
+ status: "NEED_INPUT",
1078
+ missing_fields: parsedResult.missing_fields,
1079
+ proposed_keyword: parsedResult.proposed_keyword,
1080
+ required_confirmations: buildRequiredConfirmations(parsedResult),
1081
+ search_params: parsedResult.searchParams,
1082
+ screen_params: parsedResult.screenParams,
1083
+ pending_questions: parsedResult.pending_questions,
1084
+ review: parsedResult.review,
1085
+ error: {
1086
+ code: "MISSING_REQUIRED_FIELDS",
1087
+ message: "缺少必要字段。请先补齐缺失项;若要按默认值继续,必须先明确确认默认值及其风险。",
1088
+ retryable: true
1089
+ }
1090
+ };
1091
+ }
1092
+
1093
+ function buildNeedConfirmationResponse(parsedResult) {
1094
+ return {
1095
+ status: "NEED_CONFIRMATION",
1096
+ proposed_keyword: parsedResult.proposed_keyword,
1097
+ required_confirmations: buildRequiredConfirmations(parsedResult),
1098
+ search_params: {
1099
+ ...parsedResult.searchParams,
1100
+ keyword: parsedResult.proposed_keyword || parsedResult.searchParams.keyword
1101
+ },
1102
+ screen_params: parsedResult.screenParams,
1103
+ pending_questions: parsedResult.pending_questions,
1104
+ review: parsedResult.review
1105
+ };
1106
+ }
1107
+
1108
+ function parseRecruitPipelineRequest(args = {}) {
1109
+ const parsed = parseRecruitInstruction({
1110
+ instruction: args.instruction,
1111
+ confirmation: args.confirmation,
1112
+ overrides: args.overrides
1113
+ });
1114
+ const criteriaOverride = normalizeText(args.overrides?.criteria || "");
1115
+ if (criteriaOverride) {
1116
+ parsed.screenParams = {
1117
+ ...parsed.screenParams,
1118
+ criteria: criteriaOverride
1119
+ };
1120
+ parsed.review = {
1121
+ ...parsed.review,
1122
+ current_screen_params: {
1123
+ ...(parsed.review?.current_screen_params || {}),
1124
+ criteria: criteriaOverride
1125
+ }
1126
+ };
1127
+ }
1128
+ return parsed;
1129
+ }
1130
+
1131
+ function evaluateRecruitPipelineGate(parsed) {
1132
+ if (parsed.has_unresolved_missing_fields) return buildNeedInputResponse(parsed);
1133
+ if (
1134
+ parsed.needs_keyword_confirmation
1135
+ || parsed.needs_recent_viewed_filter_confirmation
1136
+ || parsed.needs_skip_recent_colleague_contacted_confirmation
1137
+ || parsed.needs_criteria_confirmation
1138
+ || parsed.needs_search_params_confirmation
1139
+ || (parsed.suspicious_fields || []).length > 0
1140
+ ) {
1141
+ return buildNeedConfirmationResponse(parsed);
1142
+ }
1143
+ return null;
1144
+ }
1145
+
1146
+ function normalizeRunSnapshot(snapshot) {
1147
+ if (!snapshot) return null;
1148
+ const meta = getRecruitRunMeta(snapshot.runId);
1149
+ const artifacts = getRecruitRunArtifacts(snapshot.runId);
1150
+ const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
1151
+ const progress = normalizeLegacyProgress(snapshot.progress, summary);
1152
+ const legacyResult = (
1153
+ TERMINAL_STATUSES.has(snapshot.status)
1154
+ || snapshot.status === RUN_STATUS_PAUSED
1155
+ ) ? buildLegacyRunResult({ ...snapshot, progress }) : null;
1156
+ const oldContext = {
1157
+ workspace_root: meta.workspaceRoot || null,
1158
+ instruction: meta.args?.instruction || "",
1159
+ confirmation: clonePlain(meta.args?.confirmation || {}, {}),
1160
+ overrides: clonePlain(meta.args?.overrides || {}, {}),
1161
+ rounds: []
1162
+ };
1163
+ return {
1164
+ ...snapshot,
1165
+ progress,
1166
+ run_id: snapshot.runId,
1167
+ mode: meta.mode || RUN_MODE_ASYNC,
1168
+ state: snapshot.status,
1169
+ stage: snapshot.phase,
1170
+ started_at: snapshot.startedAt,
1171
+ updated_at: snapshot.updatedAt,
1172
+ completed_at: toIsoOrNull(snapshot.completedAt),
1173
+ heartbeat_at: snapshot.updatedAt,
1174
+ pid: globalThis.process?.pid || null,
1175
+ last_message: snapshot.error?.message || snapshot.phase || null,
1176
+ context: {
1177
+ ...(snapshot.context || {}),
1178
+ ...oldContext,
1179
+ shared_run_context: snapshot.context || {}
1180
+ },
1181
+ control: {
1182
+ pause_requested: snapshot.status === RUN_STATUS_PAUSED,
1183
+ pause_requested_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null,
1184
+ pause_requested_by: snapshot.status === RUN_STATUS_PAUSED ? "pause_recruit_pipeline_run" : null,
1185
+ cancel_requested: snapshot.status === RUN_STATUS_CANCELING
1186
+ },
1187
+ resume: {
1188
+ checkpoint_path: legacyResult?.checkpoint_path || meta.checkpointPath || artifacts?.checkpoint_path || null,
1189
+ pause_control_path: artifacts?.run_state_path || null,
1190
+ output_csv: legacyResult?.output_csv || null,
1191
+ worker_stdout_path: artifacts?.worker_stdout_path || null,
1192
+ worker_stderr_path: artifacts?.worker_stderr_path || null,
1193
+ resume_count: meta.resumeCount || 0,
1194
+ last_resumed_at: meta.lastResumedAt || null,
1195
+ last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
1196
+ },
1197
+ result: legacyResult,
1198
+ artifacts
1199
+ };
1200
+ }
1201
+
1202
+ function persistRecruitRunSnapshot(snapshot, {
1203
+ persistActiveCheckpoint = false
1204
+ } = {}) {
1205
+ const normalized = normalizeRunSnapshot(snapshot);
1206
+ if (!normalized?.run_id) return normalized;
1207
+ const artifacts = getRecruitRunArtifacts(normalized.run_id);
1208
+ if (!artifacts) return normalized;
1209
+ if (persistActiveCheckpoint) {
1210
+ persistRecruitCheckpointSnapshot(normalized);
1211
+ }
1212
+ const payload = {
1213
+ run_id: normalized.run_id,
1214
+ mode: normalized.mode,
1215
+ state: normalized.state,
1216
+ status: normalized.status,
1217
+ stage: normalized.stage,
1218
+ started_at: normalized.started_at,
1219
+ updated_at: normalized.updated_at,
1220
+ heartbeat_at: normalized.heartbeat_at,
1221
+ completed_at: normalized.completed_at,
1222
+ pid: normalized.pid,
1223
+ progress: normalized.progress,
1224
+ last_message: normalized.last_message,
1225
+ context: normalized.context,
1226
+ control: normalized.control,
1227
+ resume: normalized.resume,
1228
+ error: normalized.error,
1229
+ result: normalized.result,
1230
+ summary: normalized.summary,
1231
+ artifacts: normalized.artifacts
1232
+ };
1233
+ writeJsonAtomic(artifacts.run_state_path, payload);
1234
+ return normalized;
1235
+ }
1236
+
1237
+ function persistRecruitLifecycleSnapshot(snapshot, event = {}) {
1238
+ return persistRecruitRunSnapshot(snapshot, {
1239
+ persistActiveCheckpoint: event?.type === "checkpoint"
1240
+ });
1241
+ }
1242
+
1243
+ function getRecruitRunMeta(runId) {
1244
+ return recruitRunMeta.get(runId) || {};
1245
+ }
1246
+
1247
+ function attachMethodEvidence(payload, runId) {
1248
+ const meta = getRecruitRunMeta(runId);
1249
+ return {
1250
+ ...payload,
1251
+ runtime_evaluate_used: false,
1252
+ method_summary: methodSummary(meta.methodLog || []),
1253
+ method_log: meta.methodLog || [],
1254
+ chrome: meta.chrome || null
1255
+ };
1256
+ }
1257
+
1258
+ async function waitForRecruitSearchControlsOrLogin(client, {
1259
+ timeoutMs = 90000,
1260
+ intervalMs = 300
1261
+ } = {}) {
1262
+ const started = Date.now();
1263
+ let lastControls = null;
1264
+ while (Date.now() - started <= timeoutMs) {
1265
+ const loginDetection = await detectBossLoginState(client).catch(() => null);
1266
+ if (loginDetection?.requires_login) {
1267
+ return {
1268
+ ok: false,
1269
+ reason: "login_required",
1270
+ loginDetection
1271
+ };
1272
+ }
1273
+ const remainingMs = Math.max(1, timeoutMs - (Date.now() - started));
1274
+ lastControls = await waitForRecruitSearchControls(client, {
1275
+ timeoutMs: Math.min(remainingMs, 1500),
1276
+ intervalMs
1277
+ });
1278
+ if (lastControls.ok) return lastControls;
1279
+ await sleep(intervalMs);
1280
+ }
1281
+ return lastControls || { ok: false, reason: "timeout" };
1282
+ }
1283
+
1284
+ async function connectRecruitChromeSession({
1285
+ host = DEFAULT_RECRUIT_HOST,
1286
+ port = DEFAULT_RECRUIT_PORT,
1287
+ targetUrlIncludes = RECRUIT_TARGET_URL,
1288
+ allowNavigate = true,
1289
+ slowLive = false
1290
+ } = {}) {
1291
+ const session = await connectToChromeTargetOrOpen({
1292
+ host,
1293
+ port,
1294
+ targetUrlIncludes,
1295
+ targetUrl: RECRUIT_TARGET_URL,
1296
+ allowNavigate,
1297
+ slowLive,
1298
+ fallbackTargetPredicate: (target) => (
1299
+ target?.type === "page"
1300
+ && String(target?.url || "").includes("zhipin.com")
1301
+ )
1302
+ });
1303
+
1304
+ const { client, target } = session;
1305
+ await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
1306
+ if (typeof client?.Network?.setCacheDisabled === "function") {
1307
+ await client.Network.setCacheDisabled({ cacheDisabled: true });
1308
+ }
1309
+ await bringPageToFront(client);
1310
+
1311
+ const targetUrl = String(target?.url || "");
1312
+ let navigation = {
1313
+ navigated: false,
1314
+ url: targetUrl
1315
+ };
1316
+ if (allowNavigate && !targetUrl.includes(targetUrlIncludes)) {
1317
+ await client.Page.navigate({ url: RECRUIT_TARGET_URL });
1318
+ const settleMs = slowLive ? 8000 : 3000;
1319
+ const waited = await waitForMainFrameUrl(
1320
+ client,
1321
+ (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
1322
+ { timeoutMs: settleMs, intervalMs: 500 }
1323
+ );
1324
+ navigation = {
1325
+ navigated: true,
1326
+ url: RECRUIT_TARGET_URL,
1327
+ settle_ms: settleMs,
1328
+ observed_url: waited.url || null,
1329
+ observed_url_ok: waited.ok
1330
+ };
1331
+ }
1332
+ let currentUrl = await getMainFrameUrl(client).catch(() => targetUrl);
1333
+ if (allowNavigate && !String(currentUrl || "").includes(RECRUIT_TARGET_URL) && !isBossLoginUrl(currentUrl)) {
1334
+ await client.Page.navigate({ url: RECRUIT_TARGET_URL });
1335
+ const settleMs = slowLive ? 8000 : 3000;
1336
+ const waited = await waitForMainFrameUrl(
1337
+ client,
1338
+ (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
1339
+ { timeoutMs: settleMs, intervalMs: 500 }
1340
+ );
1341
+ navigation = {
1342
+ navigated: true,
1343
+ url: RECRUIT_TARGET_URL,
1344
+ settle_ms: settleMs,
1345
+ observed_url: waited.url || null,
1346
+ observed_url_ok: waited.ok,
1347
+ reason: "observed_url_mismatch"
1348
+ };
1349
+ currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
1350
+ }
1351
+ const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
1352
+ requires_login: isBossLoginUrl(currentUrl),
1353
+ reason: "login_detection_failed",
1354
+ current_url: currentUrl
1355
+ }));
1356
+ if (loginDetection.requires_login) {
1357
+ await session.close?.();
1358
+ throw createBossLoginRequiredError({
1359
+ domain: "search",
1360
+ currentUrl: loginDetection.current_url || currentUrl,
1361
+ targetUrl: RECRUIT_TARGET_URL,
1362
+ loginDetection,
1363
+ chrome: session.chrome || null
1364
+ });
1365
+ }
1366
+ if (!String(currentUrl || "").includes(RECRUIT_TARGET_URL)) {
1367
+ await session.close?.();
1368
+ throw new Error(`Boss search page did not navigate to ${RECRUIT_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
1369
+ }
1370
+
1371
+ const controls = await waitForRecruitSearchControlsOrLogin(client, {
1372
+ timeoutMs: slowLive ? 180000 : 90000,
1373
+ intervalMs: 300
1374
+ });
1375
+ if (controls.loginDetection?.requires_login) {
1376
+ await session.close?.();
1377
+ throw createBossLoginRequiredError({
1378
+ domain: "search",
1379
+ currentUrl: controls.loginDetection.current_url || currentUrl,
1380
+ targetUrl: RECRUIT_TARGET_URL,
1381
+ loginDetection: controls.loginDetection,
1382
+ chrome: session.chrome || null
1383
+ });
1384
+ }
1385
+ if (!controls.ok) {
1386
+ const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
1387
+ const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
1388
+ requires_login: isBossLoginUrl(latestUrl),
1389
+ reason: "login_detection_failed",
1390
+ current_url: latestUrl
1391
+ }));
1392
+ if (latestLoginDetection.requires_login) {
1393
+ await session.close?.();
1394
+ throw createBossLoginRequiredError({
1395
+ domain: "search",
1396
+ currentUrl: latestLoginDetection.current_url || latestUrl,
1397
+ targetUrl: RECRUIT_TARGET_URL,
1398
+ loginDetection: latestLoginDetection,
1399
+ chrome: session.chrome || null
1400
+ });
1401
+ }
1402
+ throw new Error("Boss recruit search page did not expose ready search controls");
1403
+ }
1404
+
1405
+ return {
1406
+ ...session,
1407
+ navigation,
1408
+ controls
1409
+ };
1410
+ }
1411
+
1412
+ function getRunOptions(args, parsed, session, configResolution = null) {
1413
+ const slowLive = args.slow_live === true;
1414
+ const targetCount = parsePositiveInteger(args.max_candidates, parsed.screenParams.target_count || 10);
1415
+ const screeningMode = normalizeScreeningModeArg(args);
1416
+ const humanBehavior = resolveHumanBehaviorForRun(args, configResolution?.config || {});
1417
+ const executePostAction = args.dry_run_post_action === true
1418
+ ? false
1419
+ : args.execute_post_action !== false;
1420
+ return {
1421
+ client: session.client,
1422
+ targetUrl: RECRUIT_TARGET_URL,
1423
+ criteria: parsed.screenParams.criteria,
1424
+ searchParams: parsed.searchParams,
1425
+ maxCandidates: targetCount,
1426
+ detailLimit: parseNonNegativeInteger(args.detail_limit, targetCount),
1427
+ closeDetail: true,
1428
+ delayMs: Math.max(0, parsePositiveInteger(args.delay_ms, 0)),
1429
+ cardTimeoutMs: slowLive ? 180000 : 90000,
1430
+ resetBeforeSearch: args.reset_search !== false,
1431
+ resetTimeoutMs: slowLive ? 300000 : 180000,
1432
+ cityOptionTimeoutMs: slowLive ? 60000 : 30000,
1433
+ maxImagePages: parsePositiveInteger(args.max_image_pages, DEFAULT_MAX_IMAGE_PAGES),
1434
+ screeningMode,
1435
+ llmConfig: screeningMode === "llm" && configResolution?.ok ? {
1436
+ ...configResolution.config
1437
+ } : null,
1438
+ llmTimeoutMs: parsePositiveInteger(
1439
+ args.llm_timeout_ms,
1440
+ parsePositiveInteger(configResolution?.config?.llmTimeoutMs || configResolution?.config?.timeoutMs, slowLive ? 180000 : 120000)
1441
+ ),
1442
+ llmImageLimit: parsePositiveInteger(
1443
+ args.llm_image_limit,
1444
+ parsePositiveInteger(configResolution?.config?.llmImageLimit || configResolution?.config?.imageLimit, 8)
1445
+ ),
1446
+ llmImageDetail: normalizeText(
1447
+ args.llm_image_detail || configResolution?.config?.llmImageDetail || configResolution?.config?.imageDetail
1448
+ ) || "low",
1449
+ imageOutputDir: resolveBossConfiguredOutputDir("", getRecruitRunsDir()),
1450
+ humanRestEnabled: humanBehavior.restEnabled,
1451
+ humanBehavior,
1452
+ postAction: parsed.screenParams?.post_action || "none",
1453
+ maxGreetCount: Number.isInteger(parsed.screenParams?.max_greet_count)
1454
+ ? parsed.screenParams.max_greet_count
1455
+ : null,
1456
+ executePostAction,
1457
+ actionTimeoutMs: parsePositiveInteger(args.action_timeout_ms, slowLive ? 12000 : 8000),
1458
+ actionIntervalMs: parsePositiveInteger(args.action_interval_ms, 400),
1459
+ actionAfterClickDelayMs: parseNonNegativeInteger(args.action_after_click_delay_ms, 900),
1460
+ name: "mcp-recruit-pipeline-run"
1461
+ };
1462
+ }
1463
+
1464
+ async function closeRecruitRunSession(runId) {
1465
+ const meta = recruitRunMeta.get(runId);
1466
+ if (!meta || meta.closed) return;
1467
+ try {
1468
+ assertNoForbiddenCdpCalls(meta.methodLog || []);
1469
+ } finally {
1470
+ meta.closed = true;
1471
+ try {
1472
+ await meta.session?.close?.();
1473
+ } catch {
1474
+ // Nothing actionable for the caller once the run has settled.
1475
+ }
1476
+ }
1477
+ }
1478
+
1479
+ async function waitForRecruitRunTerminal(runId) {
1480
+ while (true) {
1481
+ try {
1482
+ const snapshot = recruitRunService.getRecruitRun(runId);
1483
+ if (TERMINAL_STATUSES.has(snapshot.status)) return snapshot;
1484
+ } catch {
1485
+ return null;
1486
+ }
1487
+ await sleep(1000);
1488
+ }
1489
+ }
1490
+
1491
+ function trackRecruitRun(runId) {
1492
+ waitForRecruitRunTerminal(runId)
1493
+ .then((terminal) => {
1494
+ if (terminal) persistRecruitRunSnapshot(terminal);
1495
+ })
1496
+ .catch(() => null)
1497
+ .finally(() => {
1498
+ closeRecruitRunSession(runId).catch(() => {});
1499
+ });
1500
+ }
1501
+
1502
+ async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "", runId = "" } = {}) {
1503
+ const parsed = parseRecruitPipelineRequest(args);
1504
+ const gate = evaluateRecruitPipelineGate(parsed);
1505
+ if (gate) return gate;
1506
+ const configResolution = resolveBossScreeningConfig(workspaceRoot);
1507
+ const screeningMode = normalizeScreeningModeArg(args);
1508
+ const debugTestOptions = collectRecruitDebugTestOptions(args);
1509
+ if (debugTestOptions.length && !isDebugTestMode(args)) {
1510
+ return {
1511
+ status: "FAILED",
1512
+ error: {
1513
+ code: "DEBUG_TEST_MODE_REQUIRED",
1514
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1515
+ retryable: false
1516
+ },
1517
+ debug_test_options: debugTestOptions
1518
+ };
1519
+ }
1520
+ if (screeningMode === "llm" && !configResolution.ok) {
1521
+ return {
1522
+ status: "FAILED",
1523
+ error: {
1524
+ code: "SCREEN_CONFIG_ERROR",
1525
+ message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1526
+ retryable: true
1527
+ },
1528
+ config_path: configResolution.config_path || null,
1529
+ candidate_paths: configResolution.candidate_paths || []
1530
+ };
1531
+ }
1532
+ const host = normalizeText(args.host) || DEFAULT_RECRUIT_HOST;
1533
+ const port = parsePositiveInteger(
1534
+ args.port,
1535
+ configResolution.ok ? configResolution.config.debugPort : DEFAULT_RECRUIT_PORT
1536
+ );
1537
+
1538
+ let session;
1539
+ try {
1540
+ session = await recruitConnectorImpl({
1541
+ host,
1542
+ port,
1543
+ targetUrlIncludes: normalizeText(args.target_url_includes) || RECRUIT_TARGET_URL,
1544
+ allowNavigate: args.allow_navigate !== false,
1545
+ slowLive: args.slow_live === true
1546
+ });
1547
+ } catch (error) {
1548
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
1549
+ return {
1550
+ status: "FAILED",
1551
+ error: {
1552
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_SEARCH_PAGE_NOT_READY",
1553
+ message: error?.message || "Boss recruit search page is not ready",
1554
+ requires_login: Boolean(error?.requires_login),
1555
+ login_url: error?.login_url || null,
1556
+ login_detection: error?.login_detection || null,
1557
+ chrome: error?.chrome || null,
1558
+ current_url: error?.current_url || null,
1559
+ target_url: error?.target_url || RECRUIT_TARGET_URL,
1560
+ retryable: true
1561
+ },
1562
+ chrome: error?.chrome || null
1563
+ };
1564
+ }
1565
+
1566
+ let started;
1567
+ try {
1568
+ started = recruitRunService.startRecruitRun({
1569
+ ...getRunOptions(args, parsed, session, configResolution),
1570
+ runId
1571
+ });
1572
+ } catch (error) {
1573
+ await session.close?.();
1574
+ return {
1575
+ status: "FAILED",
1576
+ error: {
1577
+ code: "RECRUIT_RUN_START_FAILED",
1578
+ message: error?.message || "Failed to start recruit run",
1579
+ retryable: true
1580
+ }
1581
+ };
1582
+ }
1583
+
1584
+ recruitRunMeta.set(started.runId, {
1585
+ session,
1586
+ methodLog: session.methodLog || [],
1587
+ mode: normalizeExecutionMode(args.execution_mode),
1588
+ workspaceRoot: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
1589
+ args: clonePlain(args, {}),
1590
+ chrome: {
1591
+ host,
1592
+ port,
1593
+ target_url: session.target?.url || RECRUIT_TARGET_URL,
1594
+ target_id: session.target?.id || null,
1595
+ auto_launch: session.chrome || null
1596
+ },
1597
+ parsed
1598
+ });
1599
+ trackRecruitRun(started.runId);
1600
+ const persistedStarted = persistRecruitRunSnapshot(started);
1601
+
1602
+ return {
1603
+ status: "ACCEPTED",
1604
+ run_id: persistedStarted.run_id,
1605
+ state: persistedStarted.state,
1606
+ run: persistedStarted,
1607
+ poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1608
+ review: parsed.review,
1609
+ message: parsed.screenParams?.post_action === "greet"
1610
+ ? `Recruit pipeline run started through shared CDP-only recruit service with post_action=greet${args.dry_run_post_action === true ? " in dry-run mode" : ""}.`
1611
+ : "Recruit pipeline run started through shared CDP-only recruit service.",
1612
+ post_action: {
1613
+ requested: parsed.screenParams?.post_action || "none",
1614
+ execute_post_action: args.dry_run_post_action === true ? false : args.execute_post_action !== false,
1615
+ max_greet_count: Number.isInteger(parsed.screenParams?.max_greet_count) ? parsed.screenParams.max_greet_count : null
1616
+ }
1617
+ };
1618
+ }
1619
+
1620
+ export async function runRecruitPipelineTool({ workspaceRoot = "", args = {} } = {}) {
1621
+ const mode = normalizeExecutionMode(args.execution_mode);
1622
+ const started = await startRecruitPipelineRunInternal({
1623
+ ...args,
1624
+ execution_mode: mode
1625
+ }, { workspaceRoot });
1626
+ if (started.status !== "ACCEPTED") return started;
1627
+ if (mode !== RUN_MODE_SYNC) return attachMethodEvidence(started, started.run_id);
1628
+
1629
+ const final = await waitForRecruitRunTerminal(started.run_id);
1630
+ await closeRecruitRunSession(started.run_id);
1631
+ const normalizedFinal = persistRecruitRunSnapshot(final);
1632
+ const legacyResult = normalizedFinal?.result || buildLegacyRunResult(final);
1633
+ const finalStatus = final?.status === RUN_STATUS_COMPLETED
1634
+ ? "COMPLETED"
1635
+ : final?.status === RUN_STATUS_CANCELED
1636
+ ? "CANCELED"
1637
+ : "FAILED";
1638
+ return attachMethodEvidence({
1639
+ status: finalStatus,
1640
+ run_id: started.run_id,
1641
+ run: normalizedFinal,
1642
+ result: legacyResult,
1643
+ partial_result: finalStatus === "CANCELED" ? legacyResult : undefined,
1644
+ diagnostics: finalStatus === "FAILED"
1645
+ ? {
1646
+ run_id: started.run_id,
1647
+ last_stage: normalizedFinal?.stage || "recruit:unknown"
1648
+ }
1649
+ : undefined,
1650
+ summary: final?.summary || null,
1651
+ error: finalStatus === "CANCELED"
1652
+ ? {
1653
+ code: "PIPELINE_CANCELED",
1654
+ message: "流水线已取消。",
1655
+ retryable: true
1656
+ }
1657
+ : final?.error || null
1658
+ }, started.run_id);
1659
+ }
1660
+
1661
+ export async function startRecruitPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
1662
+ const started = await startRecruitPipelineRunInternal({
1663
+ ...args,
1664
+ execution_mode: RUN_MODE_ASYNC
1665
+ }, { workspaceRoot });
1666
+ if (started.status !== "ACCEPTED") return started;
1667
+ return attachMethodEvidence(started, started.run_id);
1668
+ }
1669
+
1670
+ export async function startRecruitPipelineDetachedRunTool({ workspaceRoot = "", args = {} } = {}) {
1671
+ const normalizedArgs = {
1672
+ ...args,
1673
+ execution_mode: RUN_MODE_ASYNC
1674
+ };
1675
+ const parsed = parseRecruitPipelineRequest(normalizedArgs);
1676
+ const gate = evaluateRecruitPipelineGate(parsed);
1677
+ if (gate) return gate;
1678
+ const configResolution = resolveBossScreeningConfig(workspaceRoot);
1679
+ const screeningMode = normalizeScreeningModeArg(normalizedArgs);
1680
+ const debugTestOptions = collectRecruitDebugTestOptions(normalizedArgs);
1681
+ if (debugTestOptions.length && !isDebugTestMode(normalizedArgs)) {
1682
+ return {
1683
+ status: "FAILED",
1684
+ error: {
1685
+ code: "DEBUG_TEST_MODE_REQUIRED",
1686
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1687
+ retryable: false
1688
+ },
1689
+ debug_test_options: debugTestOptions
1690
+ };
1691
+ }
1692
+ if (screeningMode === "llm" && !configResolution.ok) {
1693
+ return {
1694
+ status: "FAILED",
1695
+ error: {
1696
+ code: "SCREEN_CONFIG_ERROR",
1697
+ message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1698
+ retryable: true
1699
+ },
1700
+ config_path: configResolution.config_path || null,
1701
+ candidate_paths: configResolution.candidate_paths || []
1702
+ };
1703
+ }
1704
+
1705
+ const runId = createDetachedRecruitRunId();
1706
+ const artifacts = getRecruitRunArtifacts(runId);
1707
+ const initial = buildInitialRecruitDetachedState(runId, {
1708
+ workspaceRoot,
1709
+ args: normalizedArgs,
1710
+ parsed,
1711
+ pid: globalThis.process?.pid
1712
+ });
1713
+ try {
1714
+ writeJsonAtomic(artifacts.detached_args_path, {
1715
+ domain: "recruit",
1716
+ run_id: runId,
1717
+ workspace_root: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
1718
+ args: clonePlain(normalizedArgs, {})
1719
+ });
1720
+ writeRecruitRunState(runId, initial);
1721
+ } catch (error) {
1722
+ return {
1723
+ status: "FAILED",
1724
+ error: {
1725
+ code: "RECRUIT_RUN_STATE_IO_ERROR",
1726
+ message: `Unable to write Boss search detached run state: ${error?.message || error}`,
1727
+ retryable: false
1728
+ }
1729
+ };
1730
+ }
1731
+
1732
+ try {
1733
+ const child = launchDetachedRecruitWorker(runId);
1734
+ const now = new Date().toISOString();
1735
+ const latest = readRecruitRunState(runId) || initial;
1736
+ const latestState = normalizeText(latest.state || latest.status);
1737
+ if (TERMINAL_STATUSES.has(latestState)) {
1738
+ return {
1739
+ status: "FAILED",
1740
+ error: latest.error || {
1741
+ code: "RECRUIT_WORKER_LAUNCH_FAILED",
1742
+ message: "Boss search detached worker exited during launch.",
1743
+ retryable: true
1744
+ },
1745
+ run: latest,
1746
+ runtime_evaluate_used: false,
1747
+ method_summary: {},
1748
+ method_log: [],
1749
+ chrome: null
1750
+ };
1751
+ }
1752
+ const queued = {
1753
+ ...latest,
1754
+ pid: child.pid || globalThis.process?.pid || null,
1755
+ updated_at: now,
1756
+ heartbeat_at: now,
1757
+ last_message: "Boss search detached worker launched."
1758
+ };
1759
+ writeRecruitRunState(runId, queued);
1760
+ return {
1761
+ status: "ACCEPTED",
1762
+ run_id: runId,
1763
+ state: "queued",
1764
+ run: queued,
1765
+ poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1766
+ review: parsed.review,
1767
+ message: "Boss search run started in a detached worker. It can continue after the MCP host returns or is recycled.",
1768
+ target_count_semantics: TARGET_COUNT_SEMANTICS,
1769
+ detached_worker: true,
1770
+ runtime_evaluate_used: false,
1771
+ method_summary: {},
1772
+ method_log: [],
1773
+ chrome: null
1774
+ };
1775
+ } catch (error) {
1776
+ const failed = markBossRecruitDetachedWorkerFailed(runId, error, {
1777
+ code: "RECRUIT_WORKER_LAUNCH_FAILED",
1778
+ message: "Unable to launch Boss search detached worker."
1779
+ });
1780
+ return {
1781
+ status: "FAILED",
1782
+ error: failed?.error || {
1783
+ code: "RECRUIT_WORKER_LAUNCH_FAILED",
1784
+ message: error?.message || "Unable to launch Boss search detached worker.",
1785
+ retryable: true
1786
+ },
1787
+ run: failed || readRecruitRunState(runId),
1788
+ runtime_evaluate_used: false,
1789
+ method_summary: {},
1790
+ method_log: [],
1791
+ chrome: null
1792
+ };
1793
+ }
1794
+ }
1795
+
1796
+ export async function runBossRecruitDetachedWorker({ runId } = {}) {
1797
+ const normalizedRunId = normalizeRunId(runId);
1798
+ if (!normalizedRunId) return { ok: false, error: "run_id is required" };
1799
+ const artifacts = getRecruitRunArtifacts(normalizedRunId);
1800
+ const spec = readJsonFile(artifacts?.detached_args_path || "");
1801
+ if (!spec) {
1802
+ const error = new Error(`Boss search detached args were not found for run_id=${normalizedRunId}`);
1803
+ markBossRecruitDetachedWorkerFailed(normalizedRunId, error, { code: "RECRUIT_WORKER_ARGS_MISSING" });
1804
+ return { ok: false, error: error.message };
1805
+ }
1806
+
1807
+ const started = await startRecruitPipelineRunInternal({
1808
+ ...(spec.args || {}),
1809
+ execution_mode: RUN_MODE_ASYNC
1810
+ }, {
1811
+ workspaceRoot: spec.workspace_root || "",
1812
+ runId: normalizedRunId
1813
+ });
1814
+ if (started?.status !== "ACCEPTED") {
1815
+ const failedError = started?.error || {
1816
+ code: "RECRUIT_WORKER_START_FAILED",
1817
+ message: started?.status || "Boss search detached worker failed to start.",
1818
+ retryable: true
1819
+ };
1820
+ markBossRecruitDetachedWorkerFailed(normalizedRunId, failedError, {
1821
+ code: failedError.code || "RECRUIT_WORKER_START_FAILED"
1822
+ });
1823
+ return { ok: false, error: failedError.message || "Boss search detached worker failed to start." };
1824
+ }
1825
+
1826
+ while (true) {
1827
+ const payload = getRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1828
+ const state = normalizeText(payload?.run?.state || payload?.run?.status || "");
1829
+ if (TERMINAL_STATUSES.has(state)) break;
1830
+ const persisted = readRecruitRunState(normalizedRunId);
1831
+ if (persisted?.control?.cancel_requested === true) {
1832
+ cancelRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1833
+ } else if (persisted?.control?.pause_requested === true && state === RUN_STATUS_RUNNING) {
1834
+ pauseRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1835
+ } else if (persisted?.control?.pause_requested === false && state === RUN_STATUS_PAUSED) {
1836
+ resumeRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1837
+ }
1838
+ await sleep(DETACHED_WORKER_POLL_MS);
1839
+ }
1840
+ return { ok: true };
1841
+ }
1842
+
1843
+ export function getRecruitPipelineRunTool({ args = {} } = {}) {
1844
+ const runId = normalizeText(args.run_id);
1845
+ if (!runId) {
1846
+ return {
1847
+ status: "FAILED",
1848
+ error: {
1849
+ code: "INVALID_RUN_ID",
1850
+ message: "run_id is required",
1851
+ retryable: false
1852
+ }
1853
+ };
1854
+ }
1855
+ try {
1856
+ const run = recruitRunService.getRecruitRun(runId);
1857
+ const normalizedRun = persistRecruitRunSnapshot(run);
1858
+ return attachMethodEvidence({
1859
+ status: "RUN_STATUS",
1860
+ run: normalizedRun
1861
+ }, runId);
1862
+ } catch {
1863
+ const persisted = readRecruitRunState(runId);
1864
+ if (persisted) {
1865
+ const reconciled = reconcilePersistedRecruitRun(persisted);
1866
+ return {
1867
+ status: "RUN_STATUS",
1868
+ run: reconciled.run,
1869
+ persistence: {
1870
+ source: "disk",
1871
+ active_control_available: false,
1872
+ stale_finalized: reconciled.stale_finalized === true
1873
+ },
1874
+ runtime_evaluate_used: false,
1875
+ method_summary: {},
1876
+ method_log: [],
1877
+ chrome: null
1878
+ };
1879
+ }
1880
+ return {
1881
+ status: "FAILED",
1882
+ error: {
1883
+ code: "RUN_NOT_FOUND",
1884
+ message: `No recruit run found for run_id=${runId}`,
1885
+ retryable: false
1886
+ }
1887
+ };
1888
+ }
1889
+ }
1890
+
1891
+ export function pauseRecruitPipelineRunTool({ args = {} } = {}) {
1892
+ const runId = normalizeText(args.run_id);
1893
+ try {
1894
+ const before = recruitRunService.getRecruitRun(runId);
1895
+ if (TERMINAL_STATUSES.has(before.status)) {
1896
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1897
+ return attachMethodEvidence({
1898
+ status: "PAUSE_IGNORED",
1899
+ run: normalizedBefore,
1900
+ message: "目标任务已结束,无需暂停。"
1901
+ }, runId);
1902
+ }
1903
+ if (before.status === RUN_STATUS_PAUSED) {
1904
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1905
+ return attachMethodEvidence({
1906
+ status: "PAUSE_IGNORED",
1907
+ run: normalizedBefore,
1908
+ message: "目标任务已经处于 paused 状态。"
1909
+ }, runId);
1910
+ }
1911
+ const run = recruitRunService.pauseRecruitRun(runId);
1912
+ const normalizedRun = persistRecruitRunSnapshot(run);
1913
+ return attachMethodEvidence({
1914
+ status: "PAUSE_REQUESTED",
1915
+ run: normalizedRun,
1916
+ message: "暂停请求已接收,将在当前候选人处理完成后进入 paused。"
1917
+ }, runId);
1918
+ } catch {
1919
+ const persisted = readRecruitRunState(runId);
1920
+ if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1921
+ return {
1922
+ status: "PAUSE_IGNORED",
1923
+ run: persisted,
1924
+ message: "目标任务已结束,无需暂停。",
1925
+ runtime_evaluate_used: false,
1926
+ method_summary: {},
1927
+ method_log: [],
1928
+ chrome: null
1929
+ };
1930
+ }
1931
+ if (persisted) {
1932
+ const reconciled = reconcilePersistedRecruitRun(persisted);
1933
+ if (reconciled.stale_finalized) return getRecruitPipelineRunTool({ args });
1934
+ return patchPersistedRecruitControl(runId, {
1935
+ pause_requested: true,
1936
+ pause_requested_at: new Date().toISOString(),
1937
+ pause_requested_by: "pause_recruit_pipeline_run",
1938
+ cancel_requested: false
1939
+ }, {
1940
+ status: "PAUSE_REQUESTED",
1941
+ message: "暂停请求已写入 detached search run 控制文件。",
1942
+ lastMessage: "暂停请求已写入 detached search run 控制文件。"
1943
+ }) || getRecruitPipelineRunTool({ args });
1944
+ }
1945
+ return getRecruitPipelineRunTool({ args });
1946
+ }
1947
+ }
1948
+
1949
+ export function resumeRecruitPipelineRunTool({ args = {} } = {}) {
1950
+ const runId = normalizeText(args.run_id);
1951
+ try {
1952
+ const before = recruitRunService.getRecruitRun(runId);
1953
+ if (TERMINAL_STATUSES.has(before.status)) {
1954
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1955
+ return attachMethodEvidence({
1956
+ status: "FAILED",
1957
+ error: {
1958
+ code: "RUN_ALREADY_TERMINATED",
1959
+ message: "目标任务已结束,无法继续。",
1960
+ retryable: false
1961
+ },
1962
+ run: normalizedBefore
1963
+ }, runId);
1964
+ }
1965
+ if (before.status !== RUN_STATUS_PAUSED) {
1966
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1967
+ return attachMethodEvidence({
1968
+ status: "FAILED",
1969
+ error: {
1970
+ code: "RUN_NOT_PAUSED",
1971
+ message: "仅 paused 状态的 run 才能继续。",
1972
+ retryable: true
1973
+ },
1974
+ run: normalizedBefore
1975
+ }, runId);
1976
+ }
1977
+ const run = recruitRunService.resumeRecruitRun(runId);
1978
+ const meta = getRecruitRunMeta(runId);
1979
+ if (meta) {
1980
+ meta.resumeCount = (meta.resumeCount || 0) + 1;
1981
+ meta.lastResumedAt = new Date().toISOString();
1982
+ }
1983
+ const normalizedRun = persistRecruitRunSnapshot(run);
1984
+ return attachMethodEvidence({
1985
+ status: "RESUME_REQUESTED",
1986
+ run: normalizedRun,
1987
+ poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1988
+ message: "已恢复 Boss 招聘流水线,请使用 get_recruit_pipeline_run 按需轮询。"
1989
+ }, runId);
1990
+ } catch {
1991
+ const persisted = readRecruitRunState(runId);
1992
+ if (persisted) {
1993
+ const reconciled = reconcilePersistedRecruitRun(persisted);
1994
+ const reconciledState = reconciled.run?.state || reconciled.run?.status;
1995
+ if (!TERMINAL_STATUSES.has(reconciledState)) {
1996
+ return patchPersistedRecruitControl(runId, {
1997
+ pause_requested: false,
1998
+ pause_requested_at: null,
1999
+ pause_requested_by: null,
2000
+ cancel_requested: false
2001
+ }, {
2002
+ status: "RESUME_REQUESTED",
2003
+ message: "恢复请求已写入 detached search run 控制文件。",
2004
+ lastMessage: "恢复请求已写入 detached search run 控制文件。"
2005
+ }) || getRecruitPipelineRunTool({ args });
2006
+ }
2007
+ return {
2008
+ status: TERMINAL_STATUSES.has(persisted.state) ? "FAILED" : "FAILED",
2009
+ error: {
2010
+ code: TERMINAL_STATUSES.has(reconciledState) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
2011
+ message: TERMINAL_STATUSES.has(reconciledState)
2012
+ ? "目标任务已结束,无法继续。"
2013
+ : "该 run 只有磁盘快照,没有当前进程内的活动 CDP 会话,无法安全继续。",
2014
+ retryable: !TERMINAL_STATUSES.has(reconciledState)
2015
+ },
2016
+ run: reconciled.run,
2017
+ persistence: {
2018
+ source: "disk",
2019
+ active_control_available: false,
2020
+ stale_finalized: reconciled.stale_finalized === true
2021
+ },
2022
+ runtime_evaluate_used: false,
2023
+ method_summary: {},
2024
+ method_log: [],
2025
+ chrome: null
2026
+ };
2027
+ }
2028
+ return getRecruitPipelineRunTool({ args });
2029
+ }
2030
+ }
2031
+
2032
+ export function cancelRecruitPipelineRunTool({ args = {} } = {}) {
2033
+ const runId = normalizeText(args.run_id);
2034
+ try {
2035
+ const before = recruitRunService.getRecruitRun(runId);
2036
+ if (TERMINAL_STATUSES.has(before.status)) {
2037
+ const normalizedBefore = persistRecruitRunSnapshot(before);
2038
+ return attachMethodEvidence({
2039
+ status: "CANCEL_IGNORED",
2040
+ run: normalizedBefore,
2041
+ message: "目标任务已结束,无需取消。"
2042
+ }, runId);
2043
+ }
2044
+ const run = recruitRunService.cancelRecruitRun(runId);
2045
+ const normalizedRun = persistRecruitRunSnapshot(run);
2046
+ return attachMethodEvidence({
2047
+ status: "CANCEL_REQUESTED",
2048
+ run: normalizedRun,
2049
+ message: "已收到取消请求,将在当前候选人处理完成后安全停止。"
2050
+ }, runId);
2051
+ } catch {
2052
+ const persisted = readRecruitRunState(runId);
2053
+ if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
2054
+ return {
2055
+ status: "CANCEL_IGNORED",
2056
+ run: persisted,
2057
+ message: "目标任务已结束,无需取消。",
2058
+ runtime_evaluate_used: false,
2059
+ method_summary: {},
2060
+ method_log: [],
2061
+ chrome: null
2062
+ };
2063
+ }
2064
+ if (persisted) {
2065
+ const reconciled = reconcilePersistedRecruitRun(persisted, { cancelStale: true });
2066
+ if (reconciled.stale_finalized) {
2067
+ return {
2068
+ status: "CANCEL_REQUESTED",
2069
+ run: reconciled.run,
2070
+ message: "该 search run 的后台进程已经不在,已将磁盘状态安全标记为 canceled 并生成结果文件。",
2071
+ persistence: {
2072
+ source: "disk",
2073
+ active_control_available: false,
2074
+ stale_finalized: true
2075
+ },
2076
+ runtime_evaluate_used: false,
2077
+ method_summary: {},
2078
+ method_log: [],
2079
+ chrome: null
2080
+ };
2081
+ }
2082
+ return patchPersistedRecruitControl(runId, {
2083
+ pause_requested: true,
2084
+ pause_requested_at: new Date().toISOString(),
2085
+ pause_requested_by: "cancel_recruit_pipeline_run",
2086
+ cancel_requested: true
2087
+ }, {
2088
+ status: "CANCEL_REQUESTED",
2089
+ message: "取消请求已写入 detached search run 控制文件。",
2090
+ lastMessage: "取消请求已写入 detached search run 控制文件。"
2091
+ }) || getRecruitPipelineRunTool({ args });
2092
+ }
2093
+ return getRecruitPipelineRunTool({ args });
2094
+ }
2095
+ }
2096
+
2097
+ export function __setRecruitMcpConnectorForTests(nextConnector) {
2098
+ recruitConnectorImpl = typeof nextConnector === "function" ? nextConnector : connectRecruitChromeSession;
2099
+ }
2100
+
2101
+ export function __setRecruitMcpWorkflowForTests(nextWorkflow) {
2102
+ recruitWorkflowImpl = typeof nextWorkflow === "function" ? nextWorkflow : runRecruitWorkflow;
2103
+ recruitRunService = createRecruitRunService({
2104
+ idPrefix: "mcp_recruit",
2105
+ workflow: (...args) => recruitWorkflowImpl(...args),
2106
+ onSnapshot: persistRecruitLifecycleSnapshot
2107
+ });
2108
+ }
2109
+
2110
+ export function __resetRecruitMcpStateForTests() {
2111
+ for (const meta of recruitRunMeta.values()) {
2112
+ try {
2113
+ meta.session?.close?.();
2114
+ } catch {
2115
+ // Best-effort test cleanup.
2116
+ }
2117
+ }
2118
+ recruitRunMeta.clear();
2119
+ __setRecruitMcpConnectorForTests(null);
2120
+ __setRecruitMcpWorkflowForTests(null);
2121
+ }