@reconcrap/boss-recommend-mcp 2.0.56 → 2.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
4
6
  import {
5
7
  assertNoForbiddenCdpCalls,
6
8
  bringPageToFront,
@@ -18,7 +20,8 @@ import {
18
20
  RUN_STATUS_CANCELED,
19
21
  RUN_STATUS_COMPLETED,
20
22
  RUN_STATUS_FAILED,
21
- RUN_STATUS_PAUSED
23
+ RUN_STATUS_PAUSED,
24
+ RUN_STATUS_RUNNING
22
25
  } from "./core/run/index.js";
23
26
  import {
24
27
  buildLegacyScreenInputRows,
@@ -46,12 +49,19 @@ const DEFAULT_RECRUIT_HOST = "127.0.0.1";
46
49
  const DEFAULT_RECRUIT_PORT = 9222;
47
50
  const TARGET_COUNT_SEMANTICS = "target_count means candidates that pass screening; scan continues until that many candidates pass or the list ends";
48
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;
49
54
 
50
55
  const TERMINAL_STATUSES = new Set([
51
56
  RUN_STATUS_COMPLETED,
52
57
  RUN_STATUS_FAILED,
53
58
  RUN_STATUS_CANCELED
54
59
  ]);
60
+ const STALE_PROCESS_STATUSES = new Set([
61
+ "queued",
62
+ "running",
63
+ RUN_STATUS_CANCELING
64
+ ]);
55
65
 
56
66
  let recruitWorkflowImpl = runRecruitWorkflow;
57
67
  let recruitConnectorImpl = connectRecruitChromeSession;
@@ -141,6 +151,9 @@ function getRecruitRunArtifacts(runId) {
141
151
  runs_dir: runsDir,
142
152
  output_dir: outputDir,
143
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`),
144
157
  checkpoint_path: path.join(runsDir, `${normalized}.checkpoint.json`),
145
158
  output_csv: path.join(outputDir, `${normalized}.results.csv`),
146
159
  report_json: path.join(outputDir, `${normalized}.report.json`)
@@ -215,6 +228,162 @@ function readRecruitRunState(runId) {
215
228
  return readJsonFile(artifacts.run_state_path);
216
229
  }
217
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
+
218
387
  function ensureRecruitRunArtifacts(snapshot) {
219
388
  const artifacts = getRecruitRunArtifacts(snapshot?.runId || snapshot?.run_id);
220
389
  if (!artifacts) return null;
@@ -307,6 +476,121 @@ function completionReason(status) {
307
476
  return null;
308
477
  }
309
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
+
310
594
  function buildLegacyRunResult(snapshot) {
311
595
  if (!snapshot) return null;
312
596
  const artifacts = ensureRecruitRunArtifacts(snapshot);
@@ -341,6 +625,8 @@ function buildLegacyRunResult(snapshot) {
341
625
  duration_sec: secondsBetween(snapshot.startedAt, snapshot.completedAt || snapshot.updatedAt),
342
626
  output_csv: summary?.output_csv || meta.outputCsvPath || artifacts?.output_csv || null,
343
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,
344
630
  round_count: 1,
345
631
  current_round_index: 1,
346
632
  checkpoint_path: snapshot.checkpoint?.checkpoint_path
@@ -661,6 +947,8 @@ function normalizeRunSnapshot(snapshot) {
661
947
  checkpoint_path: legacyResult?.checkpoint_path || meta.checkpointPath || artifacts?.checkpoint_path || null,
662
948
  pause_control_path: artifacts?.run_state_path || null,
663
949
  output_csv: legacyResult?.output_csv || null,
950
+ worker_stdout_path: artifacts?.worker_stdout_path || null,
951
+ worker_stderr_path: artifacts?.worker_stderr_path || null,
664
952
  resume_count: meta.resumeCount || 0,
665
953
  last_resumed_at: meta.lastResumedAt || null,
666
954
  last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
@@ -959,7 +1247,7 @@ function trackRecruitRun(runId) {
959
1247
  });
960
1248
  }
961
1249
 
962
- async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "" } = {}) {
1250
+ async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "", runId = "" } = {}) {
963
1251
  const parsed = parseRecruitPipelineRequest(args);
964
1252
  const gate = evaluateRecruitPipelineGate(parsed);
965
1253
  if (gate) return gate;
@@ -1025,7 +1313,10 @@ async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "" }
1025
1313
 
1026
1314
  let started;
1027
1315
  try {
1028
- started = recruitRunService.startRecruitRun(getRunOptions(args, parsed, session, configResolution));
1316
+ started = recruitRunService.startRecruitRun({
1317
+ ...getRunOptions(args, parsed, session, configResolution),
1318
+ runId
1319
+ });
1029
1320
  } catch (error) {
1030
1321
  await session.close?.();
1031
1322
  return {
@@ -1117,6 +1408,179 @@ export async function startRecruitPipelineRunTool({ workspaceRoot = "", args = {
1117
1408
  return attachMethodEvidence(started, started.run_id);
1118
1409
  }
1119
1410
 
1411
+ export async function startRecruitPipelineDetachedRunTool({ workspaceRoot = "", args = {} } = {}) {
1412
+ const normalizedArgs = {
1413
+ ...args,
1414
+ execution_mode: RUN_MODE_ASYNC
1415
+ };
1416
+ const parsed = parseRecruitPipelineRequest(normalizedArgs);
1417
+ const gate = evaluateRecruitPipelineGate(parsed);
1418
+ if (gate) return gate;
1419
+ const configResolution = resolveBossScreeningConfig(workspaceRoot);
1420
+ const screeningMode = normalizeScreeningModeArg(normalizedArgs);
1421
+ const debugTestOptions = collectRecruitDebugTestOptions(normalizedArgs);
1422
+ if (debugTestOptions.length && !isDebugTestMode(normalizedArgs)) {
1423
+ return {
1424
+ status: "FAILED",
1425
+ error: {
1426
+ code: "DEBUG_TEST_MODE_REQUIRED",
1427
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1428
+ retryable: false
1429
+ },
1430
+ debug_test_options: debugTestOptions
1431
+ };
1432
+ }
1433
+ if (screeningMode === "llm" && !configResolution.ok) {
1434
+ return {
1435
+ status: "FAILED",
1436
+ error: {
1437
+ code: "SCREEN_CONFIG_ERROR",
1438
+ message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1439
+ retryable: true
1440
+ },
1441
+ config_path: configResolution.config_path || null,
1442
+ candidate_paths: configResolution.candidate_paths || []
1443
+ };
1444
+ }
1445
+
1446
+ const runId = createDetachedRecruitRunId();
1447
+ const artifacts = getRecruitRunArtifacts(runId);
1448
+ const initial = buildInitialRecruitDetachedState(runId, {
1449
+ workspaceRoot,
1450
+ args: normalizedArgs,
1451
+ parsed,
1452
+ pid: globalThis.process?.pid
1453
+ });
1454
+ try {
1455
+ writeJsonAtomic(artifacts.detached_args_path, {
1456
+ domain: "recruit",
1457
+ run_id: runId,
1458
+ workspace_root: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
1459
+ args: clonePlain(normalizedArgs, {})
1460
+ });
1461
+ writeRecruitRunState(runId, initial);
1462
+ } catch (error) {
1463
+ return {
1464
+ status: "FAILED",
1465
+ error: {
1466
+ code: "RECRUIT_RUN_STATE_IO_ERROR",
1467
+ message: `Unable to write Boss search detached run state: ${error?.message || error}`,
1468
+ retryable: false
1469
+ }
1470
+ };
1471
+ }
1472
+
1473
+ try {
1474
+ const child = launchDetachedRecruitWorker(runId);
1475
+ const now = new Date().toISOString();
1476
+ const latest = readRecruitRunState(runId) || initial;
1477
+ const latestState = normalizeText(latest.state || latest.status);
1478
+ if (TERMINAL_STATUSES.has(latestState)) {
1479
+ return {
1480
+ status: "FAILED",
1481
+ error: latest.error || {
1482
+ code: "RECRUIT_WORKER_LAUNCH_FAILED",
1483
+ message: "Boss search detached worker exited during launch.",
1484
+ retryable: true
1485
+ },
1486
+ run: latest,
1487
+ runtime_evaluate_used: false,
1488
+ method_summary: {},
1489
+ method_log: [],
1490
+ chrome: null
1491
+ };
1492
+ }
1493
+ const queued = {
1494
+ ...latest,
1495
+ pid: child.pid || globalThis.process?.pid || null,
1496
+ updated_at: now,
1497
+ heartbeat_at: now,
1498
+ last_message: "Boss search detached worker launched."
1499
+ };
1500
+ writeRecruitRunState(runId, queued);
1501
+ return {
1502
+ status: "ACCEPTED",
1503
+ run_id: runId,
1504
+ state: "queued",
1505
+ run: queued,
1506
+ poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1507
+ review: parsed.review,
1508
+ message: "Boss search run started in a detached worker. It can continue after the MCP host returns or is recycled.",
1509
+ target_count_semantics: TARGET_COUNT_SEMANTICS,
1510
+ detached_worker: true,
1511
+ runtime_evaluate_used: false,
1512
+ method_summary: {},
1513
+ method_log: [],
1514
+ chrome: null
1515
+ };
1516
+ } catch (error) {
1517
+ const failed = markBossRecruitDetachedWorkerFailed(runId, error, {
1518
+ code: "RECRUIT_WORKER_LAUNCH_FAILED",
1519
+ message: "Unable to launch Boss search detached worker."
1520
+ });
1521
+ return {
1522
+ status: "FAILED",
1523
+ error: failed?.error || {
1524
+ code: "RECRUIT_WORKER_LAUNCH_FAILED",
1525
+ message: error?.message || "Unable to launch Boss search detached worker.",
1526
+ retryable: true
1527
+ },
1528
+ run: failed || readRecruitRunState(runId),
1529
+ runtime_evaluate_used: false,
1530
+ method_summary: {},
1531
+ method_log: [],
1532
+ chrome: null
1533
+ };
1534
+ }
1535
+ }
1536
+
1537
+ export async function runBossRecruitDetachedWorker({ runId } = {}) {
1538
+ const normalizedRunId = normalizeRunId(runId);
1539
+ if (!normalizedRunId) return { ok: false, error: "run_id is required" };
1540
+ const artifacts = getRecruitRunArtifacts(normalizedRunId);
1541
+ const spec = readJsonFile(artifacts?.detached_args_path || "");
1542
+ if (!spec) {
1543
+ const error = new Error(`Boss search detached args were not found for run_id=${normalizedRunId}`);
1544
+ markBossRecruitDetachedWorkerFailed(normalizedRunId, error, { code: "RECRUIT_WORKER_ARGS_MISSING" });
1545
+ return { ok: false, error: error.message };
1546
+ }
1547
+
1548
+ const started = await startRecruitPipelineRunInternal({
1549
+ ...(spec.args || {}),
1550
+ execution_mode: RUN_MODE_ASYNC
1551
+ }, {
1552
+ workspaceRoot: spec.workspace_root || "",
1553
+ runId: normalizedRunId
1554
+ });
1555
+ if (started?.status !== "ACCEPTED") {
1556
+ const failedError = started?.error || {
1557
+ code: "RECRUIT_WORKER_START_FAILED",
1558
+ message: started?.status || "Boss search detached worker failed to start.",
1559
+ retryable: true
1560
+ };
1561
+ markBossRecruitDetachedWorkerFailed(normalizedRunId, failedError, {
1562
+ code: failedError.code || "RECRUIT_WORKER_START_FAILED"
1563
+ });
1564
+ return { ok: false, error: failedError.message || "Boss search detached worker failed to start." };
1565
+ }
1566
+
1567
+ while (true) {
1568
+ const payload = getRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1569
+ const state = normalizeText(payload?.run?.state || payload?.run?.status || "");
1570
+ if (TERMINAL_STATUSES.has(state)) break;
1571
+ const persisted = readRecruitRunState(normalizedRunId);
1572
+ if (persisted?.control?.cancel_requested === true) {
1573
+ cancelRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1574
+ } else if (persisted?.control?.pause_requested === true && state === RUN_STATUS_RUNNING) {
1575
+ pauseRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1576
+ } else if (persisted?.control?.pause_requested === false && state === RUN_STATUS_PAUSED) {
1577
+ resumeRecruitPipelineRunTool({ args: { run_id: normalizedRunId } });
1578
+ }
1579
+ await sleep(DETACHED_WORKER_POLL_MS);
1580
+ }
1581
+ return { ok: true };
1582
+ }
1583
+
1120
1584
  export function getRecruitPipelineRunTool({ args = {} } = {}) {
1121
1585
  const runId = normalizeText(args.run_id);
1122
1586
  if (!runId) {
@@ -1139,12 +1603,14 @@ export function getRecruitPipelineRunTool({ args = {} } = {}) {
1139
1603
  } catch {
1140
1604
  const persisted = readRecruitRunState(runId);
1141
1605
  if (persisted) {
1606
+ const reconciled = reconcilePersistedRecruitRun(persisted);
1142
1607
  return {
1143
1608
  status: "RUN_STATUS",
1144
- run: persisted,
1609
+ run: reconciled.run,
1145
1610
  persistence: {
1146
1611
  source: "disk",
1147
- active_control_available: false
1612
+ active_control_available: false,
1613
+ stale_finalized: reconciled.stale_finalized === true
1148
1614
  },
1149
1615
  runtime_evaluate_used: false,
1150
1616
  method_summary: {},
@@ -1203,6 +1669,20 @@ export function pauseRecruitPipelineRunTool({ args = {} } = {}) {
1203
1669
  chrome: null
1204
1670
  };
1205
1671
  }
1672
+ if (persisted) {
1673
+ const reconciled = reconcilePersistedRecruitRun(persisted);
1674
+ if (reconciled.stale_finalized) return getRecruitPipelineRunTool({ args });
1675
+ return patchPersistedRecruitControl(runId, {
1676
+ pause_requested: true,
1677
+ pause_requested_at: new Date().toISOString(),
1678
+ pause_requested_by: "pause_recruit_pipeline_run",
1679
+ cancel_requested: false
1680
+ }, {
1681
+ status: "PAUSE_REQUESTED",
1682
+ message: "暂停请求已写入 detached search run 控制文件。",
1683
+ lastMessage: "暂停请求已写入 detached search run 控制文件。"
1684
+ }) || getRecruitPipelineRunTool({ args });
1685
+ }
1206
1686
  return getRecruitPipelineRunTool({ args });
1207
1687
  }
1208
1688
  }
@@ -1251,19 +1731,34 @@ export function resumeRecruitPipelineRunTool({ args = {} } = {}) {
1251
1731
  } catch {
1252
1732
  const persisted = readRecruitRunState(runId);
1253
1733
  if (persisted) {
1734
+ const reconciled = reconcilePersistedRecruitRun(persisted);
1735
+ const reconciledState = reconciled.run?.state || reconciled.run?.status;
1736
+ if (!TERMINAL_STATUSES.has(reconciledState)) {
1737
+ return patchPersistedRecruitControl(runId, {
1738
+ pause_requested: false,
1739
+ pause_requested_at: null,
1740
+ pause_requested_by: null,
1741
+ cancel_requested: false
1742
+ }, {
1743
+ status: "RESUME_REQUESTED",
1744
+ message: "恢复请求已写入 detached search run 控制文件。",
1745
+ lastMessage: "恢复请求已写入 detached search run 控制文件。"
1746
+ }) || getRecruitPipelineRunTool({ args });
1747
+ }
1254
1748
  return {
1255
1749
  status: TERMINAL_STATUSES.has(persisted.state) ? "FAILED" : "FAILED",
1256
1750
  error: {
1257
- code: TERMINAL_STATUSES.has(persisted.state) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
1258
- message: TERMINAL_STATUSES.has(persisted.state)
1751
+ code: TERMINAL_STATUSES.has(reconciledState) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
1752
+ message: TERMINAL_STATUSES.has(reconciledState)
1259
1753
  ? "目标任务已结束,无法继续。"
1260
1754
  : "该 run 只有磁盘快照,没有当前进程内的活动 CDP 会话,无法安全继续。",
1261
- retryable: !TERMINAL_STATUSES.has(persisted.state)
1755
+ retryable: !TERMINAL_STATUSES.has(reconciledState)
1262
1756
  },
1263
- run: persisted,
1757
+ run: reconciled.run,
1264
1758
  persistence: {
1265
1759
  source: "disk",
1266
- active_control_available: false
1760
+ active_control_available: false,
1761
+ stale_finalized: reconciled.stale_finalized === true
1267
1762
  },
1268
1763
  runtime_evaluate_used: false,
1269
1764
  method_summary: {},
@@ -1307,6 +1802,35 @@ export function cancelRecruitPipelineRunTool({ args = {} } = {}) {
1307
1802
  chrome: null
1308
1803
  };
1309
1804
  }
1805
+ if (persisted) {
1806
+ const reconciled = reconcilePersistedRecruitRun(persisted, { cancelStale: true });
1807
+ if (reconciled.stale_finalized) {
1808
+ return {
1809
+ status: "CANCEL_REQUESTED",
1810
+ run: reconciled.run,
1811
+ message: "该 search run 的后台进程已经不在,已将磁盘状态安全标记为 canceled 并生成结果文件。",
1812
+ persistence: {
1813
+ source: "disk",
1814
+ active_control_available: false,
1815
+ stale_finalized: true
1816
+ },
1817
+ runtime_evaluate_used: false,
1818
+ method_summary: {},
1819
+ method_log: [],
1820
+ chrome: null
1821
+ };
1822
+ }
1823
+ return patchPersistedRecruitControl(runId, {
1824
+ pause_requested: true,
1825
+ pause_requested_at: new Date().toISOString(),
1826
+ pause_requested_by: "cancel_recruit_pipeline_run",
1827
+ cancel_requested: true
1828
+ }, {
1829
+ status: "CANCEL_REQUESTED",
1830
+ message: "取消请求已写入 detached search run 控制文件。",
1831
+ lastMessage: "取消请求已写入 detached search run 控制文件。"
1832
+ }) || getRecruitPipelineRunTool({ args });
1833
+ }
1310
1834
  return getRecruitPipelineRunTool({ args });
1311
1835
  }
1312
1836
  }