@reconcrap/boss-recommend-mcp 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { createRequire } from "node:module";
4
- import process from "node:process";
5
- import { fileURLToPath } from "node:url";
3
+ import { spawn } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { runRecommendPipeline } from "./pipeline.js";
7
8
  import {
8
9
  RUN_MODE_ASYNC,
@@ -34,13 +35,16 @@ const TOOL_PAUSE_RUN = "pause_recommend_pipeline_run";
34
35
  const TOOL_RESUME_RUN = "resume_recommend_pipeline_run";
35
36
 
36
37
  const SERVER_NAME = "boss-recommend-mcp";
37
- const FRAMING_UNKNOWN = "unknown";
38
- const FRAMING_HEADER = "header";
39
- const FRAMING_LINE = "line";
40
-
41
- const activeAsyncRuns = new Map();
42
- let runPipelineImpl = runRecommendPipeline;
43
- const TERMINAL_RUN_STATES = new Set([RUN_STATE_COMPLETED, RUN_STATE_FAILED, RUN_STATE_CANCELED]);
38
+ const FRAMING_UNKNOWN = "unknown";
39
+ const FRAMING_HEADER = "header";
40
+ const FRAMING_LINE = "line";
41
+ const DETACHED_WORKER_FLAG = "--detached-worker";
42
+ const DETACHED_WORKER_RUN_ID_FLAG = "--run-id";
43
+ const DETACHED_WORKER_RESUME_FLAG = "--resume";
44
+
45
+ let runPipelineImpl = runRecommendPipeline;
46
+ let spawnProcessImpl = spawn;
47
+ const TERMINAL_RUN_STATES = new Set([RUN_STATE_COMPLETED, RUN_STATE_FAILED, RUN_STATE_CANCELED]);
44
48
 
45
49
  function normalizeText(value) {
46
50
  return String(value || "").replace(/\s+/g, " ").trim();
@@ -470,9 +474,6 @@ function reconcileOrphanRunIfNeeded(runId, snapshot) {
470
474
  if (snapshot.state === RUN_STATE_PAUSED) {
471
475
  return snapshot;
472
476
  }
473
- if (activeAsyncRuns.has(runId)) {
474
- return snapshot;
475
- }
476
477
  if (isProcessAlive(snapshot.pid)) {
477
478
  return snapshot;
478
479
  }
@@ -511,6 +512,71 @@ function reconcileOrphanRunIfNeeded(runId, snapshot) {
511
512
  });
512
513
  return recovered || readRunState(runId) || snapshot;
513
514
  }
515
+
516
+ function parseDetachedWorkerOptions(argv = process.argv.slice(2)) {
517
+ if (!Array.isArray(argv) || !argv.includes(DETACHED_WORKER_FLAG)) {
518
+ return null;
519
+ }
520
+ const runIdFlagIndex = argv.indexOf(DETACHED_WORKER_RUN_ID_FLAG);
521
+ const runId = runIdFlagIndex >= 0 ? normalizeText(argv[runIdFlagIndex + 1]) : "";
522
+ return {
523
+ runId,
524
+ resumeRun: argv.includes(DETACHED_WORKER_RESUME_FLAG)
525
+ };
526
+ }
527
+
528
+ function launchDetachedRunWorker({ runId, resumeRun = false }) {
529
+ const childArgs = [thisFilePath, DETACHED_WORKER_FLAG, DETACHED_WORKER_RUN_ID_FLAG, String(runId)];
530
+ if (resumeRun) {
531
+ childArgs.push(DETACHED_WORKER_RESUME_FLAG);
532
+ }
533
+ const child = spawnProcessImpl(process.execPath, childArgs, {
534
+ detached: true,
535
+ stdio: "ignore",
536
+ windowsHide: true,
537
+ env: process.env
538
+ });
539
+ if (typeof child?.unref === "function") {
540
+ child.unref();
541
+ }
542
+ return child;
543
+ }
544
+
545
+ function buildWorkerLaunchFailedPayload(message) {
546
+ return {
547
+ status: "FAILED",
548
+ error: {
549
+ code: "RUN_WORKER_LAUNCH_FAILED",
550
+ message,
551
+ retryable: true
552
+ }
553
+ };
554
+ }
555
+
556
+ function finalizeCanceledRun(runId, snapshot) {
557
+ const canceledResult = {
558
+ status: "FAILED",
559
+ error: {
560
+ code: "PIPELINE_CANCELED",
561
+ message: "流水线已取消。",
562
+ retryable: true
563
+ },
564
+ partial_result: snapshot?.result?.partial_result || snapshot?.result?.result || null
565
+ };
566
+ return safeUpdateRunState(runId, {
567
+ state: RUN_STATE_CANCELED,
568
+ stage: snapshot?.stage || RUN_STAGE_PREFLIGHT,
569
+ last_message: "流水线已取消。",
570
+ control: {
571
+ pause_requested: false,
572
+ pause_requested_at: null,
573
+ pause_requested_by: null,
574
+ cancel_requested: false
575
+ },
576
+ error: canceledResult.error,
577
+ result: canceledResult
578
+ }) || readRunState(runId) || snapshot;
579
+ }
514
580
 
515
581
  function createRuntimeCallbacks(runId, heartbeatIntervalMs) {
516
582
  let lastStage = RUN_STAGE_PREFLIGHT;
@@ -735,14 +801,14 @@ async function executeTrackedPipeline({
735
801
  };
736
802
  }
737
803
 
738
- function initializeRunStateOrThrow(runId, mode, workspaceRoot, args) {
739
- const artifacts = getRunArtifacts(runId);
740
- const snapshot = createRunStateSnapshot({
741
- runId,
742
- mode,
743
- state: "queued",
744
- stage: RUN_STAGE_PREFLIGHT,
745
- pid: process.pid,
804
+ function initializeRunStateOrThrow(runId, mode, workspaceRoot, args, pid = process.pid) {
805
+ const artifacts = getRunArtifacts(runId);
806
+ const snapshot = createRunStateSnapshot({
807
+ runId,
808
+ mode,
809
+ state: "queued",
810
+ stage: RUN_STAGE_PREFLIGHT,
811
+ pid,
746
812
  lastMessage: "流水线任务已创建,等待执行。",
747
813
  context: buildRunContext(workspaceRoot, args),
748
814
  control: {
@@ -759,28 +825,59 @@ function initializeRunStateOrThrow(runId, mode, workspaceRoot, args) {
759
825
  last_resumed_at: null,
760
826
  last_paused_at: null
761
827
  }
762
- });
763
- return writeRunState(snapshot);
764
- }
765
-
766
- function launchAsyncRun({ runId, mode, workspaceRoot, args, resumeRun = false }) {
767
- const abortController = new AbortController();
768
- const promise = executeTrackedPipeline({
769
- runId,
770
- mode,
771
- workspaceRoot,
772
- args,
773
- signal: abortController.signal,
774
- resumeRun
775
- }).finally(() => {
776
- activeAsyncRuns.delete(runId);
777
- });
778
- activeAsyncRuns.set(runId, {
779
- abortController,
780
- promise
781
- });
782
- return { abortController, promise };
783
- }
828
+ });
829
+ return writeRunState(snapshot);
830
+ }
831
+
832
+ async function runDetachedWorker({ runId, resumeRun = false, workerPid = process.pid }) {
833
+ const normalizedRunId = normalizeText(runId);
834
+ if (!normalizedRunId) {
835
+ return { ok: false, error: "run_id is required" };
836
+ }
837
+ const snapshot = readRunState(normalizedRunId);
838
+ if (!snapshot) {
839
+ return { ok: false, error: `run_id=${normalizedRunId} not found` };
840
+ }
841
+
842
+ const executionContext = resolveRunContext(snapshot);
843
+ if (!executionContext) {
844
+ const failedPayload = {
845
+ code: "RUN_CONTEXT_MISSING",
846
+ message: "run 缺少可恢复的执行上下文,无法继续。",
847
+ retryable: false
848
+ };
849
+ safeUpdateRunState(normalizedRunId, {
850
+ state: RUN_STATE_FAILED,
851
+ stage: snapshot.stage || RUN_STAGE_PREFLIGHT,
852
+ last_message: failedPayload.message,
853
+ error: failedPayload,
854
+ result: {
855
+ status: "FAILED",
856
+ error: failedPayload
857
+ }
858
+ });
859
+ return { ok: false, error: failedPayload.message };
860
+ }
861
+
862
+ safeUpdateRunState(normalizedRunId, {
863
+ pid: Number.isInteger(workerPid) && workerPid > 0 ? workerPid : process.pid,
864
+ mode: RUN_MODE_ASYNC,
865
+ state: "queued",
866
+ last_message: resumeRun
867
+ ? "detached worker 已启动,准备恢复执行。"
868
+ : "detached worker 已启动,准备执行。"
869
+ });
870
+
871
+ await executeTrackedPipeline({
872
+ runId: normalizedRunId,
873
+ mode: RUN_MODE_ASYNC,
874
+ workspaceRoot: executionContext.workspaceRoot,
875
+ args: executionContext.args,
876
+ signal: new AbortController().signal,
877
+ resumeRun
878
+ });
879
+ return { ok: true };
880
+ }
784
881
 
785
882
  async function handleStartRunTool({ workspaceRoot, args }) {
786
883
  const precheckArgs = buildAsyncPrecheckArgs(args);
@@ -814,36 +911,57 @@ async function handleStartRunTool({ workspaceRoot, args }) {
814
911
  return precheckResult;
815
912
  }
816
913
 
817
- cleanupExpiredRuns();
818
- const runId = createRunId();
819
- try {
820
- initializeRunStateOrThrow(runId, RUN_MODE_ASYNC, workspaceRoot, args);
821
- } catch (error) {
822
- return {
823
- status: "FAILED",
914
+ cleanupExpiredRuns();
915
+ const runId = createRunId();
916
+ try {
917
+ initializeRunStateOrThrow(runId, RUN_MODE_ASYNC, workspaceRoot, args, process.pid);
918
+ } catch (error) {
919
+ return {
920
+ status: "FAILED",
824
921
  error: {
825
922
  code: "RUN_STATE_IO_ERROR",
826
923
  message: `无法写入运行状态目录:${error.message || "unknown"}`,
827
924
  retryable: false
828
- }
829
- };
830
- }
831
-
832
- launchAsyncRun({
833
- runId,
834
- mode: RUN_MODE_ASYNC,
835
- workspaceRoot,
836
- args
837
- });
838
-
839
- return {
840
- status: "ACCEPTED",
841
- run_id: runId,
842
- state: "queued",
843
- poll_after_sec: getDefaultPollAfterSec(),
844
- message: "异步流水线已启动。默认不自动轮询;如需进度请按需调用 get_recommend_pipeline_run。"
845
- };
846
- }
925
+ }
926
+ };
927
+ }
928
+
929
+ let worker;
930
+ try {
931
+ worker = launchDetachedRunWorker({
932
+ runId,
933
+ resumeRun: false
934
+ });
935
+ } catch (error) {
936
+ const failedMessage = `无法启动 detached 运行进程:${error?.message || "unknown"}`;
937
+ safeUpdateRunState(runId, {
938
+ state: RUN_STATE_FAILED,
939
+ stage: RUN_STAGE_PREFLIGHT,
940
+ last_message: failedMessage,
941
+ error: {
942
+ code: "RUN_WORKER_LAUNCH_FAILED",
943
+ message: failedMessage,
944
+ retryable: true
945
+ },
946
+ result: buildWorkerLaunchFailedPayload(failedMessage)
947
+ });
948
+ return buildWorkerLaunchFailedPayload(failedMessage);
949
+ }
950
+
951
+ safeUpdateRunState(runId, {
952
+ pid: worker?.pid,
953
+ state: "queued",
954
+ last_message: "异步流水线已启动(detached)。"
955
+ });
956
+
957
+ return {
958
+ status: "ACCEPTED",
959
+ run_id: runId,
960
+ state: "queued",
961
+ poll_after_sec: getDefaultPollAfterSec(),
962
+ message: "异步流水线已启动(detached)。默认不自动轮询;如需进度请按需调用 get_recommend_pipeline_run。"
963
+ };
964
+ }
847
965
 
848
966
  function handleGetRunTool(args) {
849
967
  cleanupExpiredRuns();
@@ -876,7 +994,7 @@ function handleGetRunTool(args) {
876
994
  };
877
995
  }
878
996
 
879
- function handleCancelRunTool(args) {
997
+ function handleCancelRunTool(args) {
880
998
  const runId = normalizeText(args?.run_id);
881
999
  if (!runId) {
882
1000
  return {
@@ -888,8 +1006,8 @@ function handleCancelRunTool(args) {
888
1006
  }
889
1007
  };
890
1008
  }
891
- const snapshot = readRunState(runId);
892
- if (!snapshot) {
1009
+ const snapshot = readRunState(runId);
1010
+ if (!snapshot) {
893
1011
  return {
894
1012
  status: "FAILED",
895
1013
  error: {
@@ -898,93 +1016,43 @@ function handleCancelRunTool(args) {
898
1016
  retryable: false
899
1017
  }
900
1018
  };
901
- }
902
-
903
- if (TERMINAL_RUN_STATES.has(snapshot.state)) {
904
- return {
905
- status: "CANCEL_IGNORED",
906
- run: snapshot,
907
- message: "目标任务已结束,无需取消。"
908
- };
909
- }
910
-
911
- if (snapshot.state === RUN_STATE_PAUSED) {
912
- const canceledResult = {
913
- status: "FAILED",
914
- error: {
915
- code: "PIPELINE_CANCELED",
916
- message: "流水线已取消。",
917
- retryable: true
918
- },
919
- partial_result: snapshot.result?.partial_result || snapshot.result?.result || null
920
- };
921
- const canceledRun = safeUpdateRunState(runId, {
922
- state: RUN_STATE_CANCELED,
923
- stage: snapshot.stage || RUN_STAGE_PREFLIGHT,
924
- last_message: "流水线已取消。",
925
- control: {
926
- pause_requested: false,
927
- pause_requested_at: null,
928
- pause_requested_by: null,
929
- cancel_requested: false
930
- },
931
- error: canceledResult.error,
932
- result: canceledResult
933
- }) || readRunState(runId) || snapshot;
934
- return {
935
- status: "CANCEL_REQUESTED",
936
- run: canceledRun
937
- };
938
- }
939
-
940
- const activeRun = activeAsyncRuns.get(runId);
941
- if (!activeRun) {
942
- const canceledResult = {
943
- status: "FAILED",
944
- error: {
945
- code: "PIPELINE_CANCELED",
946
- message: "流水线已取消。",
947
- retryable: true
948
- },
949
- partial_result: snapshot.result?.partial_result || snapshot.result?.result || null
950
- };
951
- const canceledRun = safeUpdateRunState(runId, {
952
- state: RUN_STATE_CANCELED,
953
- stage: snapshot.stage || RUN_STAGE_PREFLIGHT,
954
- last_message: "流水线已取消。",
955
- control: {
956
- pause_requested: false,
957
- pause_requested_at: null,
958
- pause_requested_by: null,
959
- cancel_requested: false
960
- },
961
- error: canceledResult.error,
962
- result: canceledResult
963
- }) || readRunState(runId) || snapshot;
964
- return {
965
- status: "CANCEL_REQUESTED",
966
- run: canceledRun
967
- };
968
- }
969
- safeUpdateRunState(runId, {
970
- stage: snapshot.stage || RUN_STAGE_PREFLIGHT,
971
- last_message: "已收到取消请求,将在当前候选人处理完成后安全停止并落盘 CSV。",
972
- control: {
1019
+ }
1020
+ const reconciled = reconcileOrphanRunIfNeeded(runId, snapshot) || snapshot;
1021
+
1022
+ if (TERMINAL_RUN_STATES.has(reconciled.state)) {
1023
+ return {
1024
+ status: "CANCEL_IGNORED",
1025
+ run: reconciled,
1026
+ message: "目标任务已结束,无需取消。"
1027
+ };
1028
+ }
1029
+
1030
+ if (reconciled.state === RUN_STATE_PAUSED || !isProcessAlive(reconciled.pid)) {
1031
+ const canceledRun = finalizeCanceledRun(runId, reconciled);
1032
+ return {
1033
+ status: "CANCEL_REQUESTED",
1034
+ run: canceledRun
1035
+ };
1036
+ }
1037
+ safeUpdateRunState(runId, {
1038
+ stage: reconciled.stage || RUN_STAGE_PREFLIGHT,
1039
+ last_message: "已收到取消请求,将在当前候选人处理完成后安全停止并落盘 CSV。",
1040
+ control: {
973
1041
  pause_requested: true,
974
1042
  pause_requested_at: new Date().toISOString(),
975
1043
  pause_requested_by: TOOL_CANCEL_RUN,
976
1044
  cancel_requested: true
977
1045
  }
978
- });
979
-
980
- const latest = readRunState(runId) || snapshot;
981
- return {
982
- status: "CANCEL_REQUESTED",
1046
+ });
1047
+
1048
+ const latest = readRunState(runId) || reconciled;
1049
+ return {
1050
+ status: "CANCEL_REQUESTED",
983
1051
  run: latest
984
1052
  };
985
1053
  }
986
1054
 
987
- function handlePauseRunTool(args) {
1055
+ function handlePauseRunTool(args) {
988
1056
  const runId = normalizeText(args?.run_id);
989
1057
  if (!runId) {
990
1058
  return {
@@ -996,8 +1064,8 @@ function handlePauseRunTool(args) {
996
1064
  }
997
1065
  };
998
1066
  }
999
- const snapshot = readRunState(runId);
1000
- if (!snapshot) {
1067
+ const snapshot = readRunState(runId);
1068
+ if (!snapshot) {
1001
1069
  return {
1002
1070
  status: "FAILED",
1003
1071
  error: {
@@ -1006,22 +1074,23 @@ function handlePauseRunTool(args) {
1006
1074
  retryable: false
1007
1075
  }
1008
1076
  };
1009
- }
1010
-
1011
- if (TERMINAL_RUN_STATES.has(snapshot.state)) {
1012
- return {
1013
- status: "PAUSE_IGNORED",
1014
- run: snapshot,
1015
- message: "目标任务已结束,无需暂停。"
1016
- };
1017
- }
1018
- if (snapshot.state === RUN_STATE_PAUSED) {
1019
- return {
1020
- status: "PAUSE_IGNORED",
1021
- run: snapshot,
1022
- message: "目标任务已经处于 paused 状态。"
1023
- };
1024
- }
1077
+ }
1078
+ const reconciled = reconcileOrphanRunIfNeeded(runId, snapshot) || snapshot;
1079
+
1080
+ if (TERMINAL_RUN_STATES.has(reconciled.state)) {
1081
+ return {
1082
+ status: "PAUSE_IGNORED",
1083
+ run: reconciled,
1084
+ message: "目标任务已结束,无需暂停。"
1085
+ };
1086
+ }
1087
+ if (reconciled.state === RUN_STATE_PAUSED) {
1088
+ return {
1089
+ status: "PAUSE_IGNORED",
1090
+ run: reconciled,
1091
+ message: "目标任务已经处于 paused 状态。"
1092
+ };
1093
+ }
1025
1094
 
1026
1095
  const requestedRun = safeUpdateRunState(runId, {
1027
1096
  control: {
@@ -1031,15 +1100,15 @@ function handlePauseRunTool(args) {
1031
1100
  cancel_requested: false
1032
1101
  },
1033
1102
  last_message: "已收到暂停请求,将在当前候选人处理完成后暂停。"
1034
- }) || readRunState(runId) || snapshot;
1035
- return {
1103
+ }) || readRunState(runId) || reconciled;
1104
+ return {
1036
1105
  status: "PAUSE_REQUESTED",
1037
1106
  run: requestedRun,
1038
1107
  message: "暂停请求已接收,将在当前候选人处理完成后进入 paused。"
1039
1108
  };
1040
1109
  }
1041
1110
 
1042
- function handleResumeRunTool(args) {
1111
+ function handleResumeRunTool(args) {
1043
1112
  const runId = normalizeText(args?.run_id);
1044
1113
  if (!runId) {
1045
1114
  return {
@@ -1051,8 +1120,8 @@ function handleResumeRunTool(args) {
1051
1120
  }
1052
1121
  };
1053
1122
  }
1054
- const snapshot = readRunState(runId);
1055
- if (!snapshot) {
1123
+ const snapshot = readRunState(runId);
1124
+ if (!snapshot) {
1056
1125
  return {
1057
1126
  status: "FAILED",
1058
1127
  error: {
@@ -1061,38 +1130,32 @@ function handleResumeRunTool(args) {
1061
1130
  retryable: false
1062
1131
  }
1063
1132
  };
1064
- }
1065
- if (TERMINAL_RUN_STATES.has(snapshot.state)) {
1066
- return {
1067
- status: "FAILED",
1133
+ }
1134
+ const reconciled = reconcileOrphanRunIfNeeded(runId, snapshot) || snapshot;
1135
+ if (TERMINAL_RUN_STATES.has(reconciled.state)) {
1136
+ return {
1137
+ status: "FAILED",
1068
1138
  error: {
1069
1139
  code: "RUN_ALREADY_TERMINATED",
1070
1140
  message: "目标任务已结束,无法继续。",
1071
1141
  retryable: false
1072
1142
  }
1073
1143
  };
1074
- }
1075
- if (snapshot.state !== RUN_STATE_PAUSED) {
1076
- return {
1077
- status: "FAILED",
1144
+ }
1145
+ if (reconciled.state !== RUN_STATE_PAUSED) {
1146
+ return {
1147
+ status: "FAILED",
1078
1148
  error: {
1079
1149
  code: "RUN_NOT_PAUSED",
1080
1150
  message: "仅 paused 状态的 run 才能继续。",
1081
1151
  retryable: true
1082
1152
  },
1083
- run: snapshot
1084
- };
1085
- }
1086
- if (activeAsyncRuns.has(runId)) {
1087
- return {
1088
- status: "RESUME_IGNORED",
1089
- run: snapshot,
1090
- message: "该 run 当前已在执行,无需继续。"
1091
- };
1092
- }
1093
-
1094
- const executionContext = resolveRunContext(snapshot);
1095
- if (!executionContext) {
1153
+ run: reconciled
1154
+ };
1155
+ }
1156
+
1157
+ const executionContext = resolveRunContext(reconciled);
1158
+ if (!executionContext) {
1096
1159
  return {
1097
1160
  status: "FAILED",
1098
1161
  error: {
@@ -1119,23 +1182,43 @@ function handleResumeRunTool(args) {
1119
1182
  resume_count: Number.isInteger(current?.resume?.resume_count) ? current.resume.resume_count + 1 : 1,
1120
1183
  last_resumed_at: new Date().toISOString()
1121
1184
  }
1122
- })) || readRunState(runId) || snapshot;
1123
-
1124
- launchAsyncRun({
1125
- runId,
1126
- mode: RUN_MODE_ASYNC,
1127
- workspaceRoot: executionContext.workspaceRoot,
1128
- args: executionContext.args,
1129
- resumeRun: true
1130
- });
1131
-
1132
- return {
1133
- status: "RESUME_REQUESTED",
1134
- run: updated,
1135
- poll_after_sec: getDefaultPollAfterSec(),
1136
- message: "已恢复 Recommend 流水线。默认不自动轮询;如需进度请按需调用 get_recommend_pipeline_run。"
1137
- };
1138
- }
1185
+ })) || readRunState(runId) || reconciled;
1186
+
1187
+ let worker;
1188
+ try {
1189
+ worker = launchDetachedRunWorker({
1190
+ runId,
1191
+ resumeRun: true
1192
+ });
1193
+ } catch (error) {
1194
+ const failedMessage = `无法启动 detached 恢复进程:${error?.message || "unknown"}`;
1195
+ safeUpdateRunState(runId, {
1196
+ state: RUN_STATE_FAILED,
1197
+ stage: reconciled.stage || RUN_STAGE_PREFLIGHT,
1198
+ last_message: failedMessage,
1199
+ error: {
1200
+ code: "RUN_WORKER_LAUNCH_FAILED",
1201
+ message: failedMessage,
1202
+ retryable: true
1203
+ },
1204
+ result: buildWorkerLaunchFailedPayload(failedMessage)
1205
+ });
1206
+ return buildWorkerLaunchFailedPayload(failedMessage);
1207
+ }
1208
+
1209
+ const started = safeUpdateRunState(runId, {
1210
+ pid: worker?.pid,
1211
+ state: "queued",
1212
+ last_message: "已恢复 Recommend 流水线(detached)。"
1213
+ }) || readRunState(runId) || updated;
1214
+
1215
+ return {
1216
+ status: "RESUME_REQUESTED",
1217
+ run: started,
1218
+ poll_after_sec: getDefaultPollAfterSec(),
1219
+ message: "已恢复 Recommend 流水线(detached)。默认不自动轮询;如需进度请按需调用 get_recommend_pipeline_run。"
1220
+ };
1221
+ }
1139
1222
 
1140
1223
  async function handleRequest(message, workspaceRoot) {
1141
1224
  if (!message || message.jsonrpc !== "2.0") {
@@ -1329,15 +1412,34 @@ export function startServer() {
1329
1412
  });
1330
1413
  }
1331
1414
 
1332
- export const __testables = {
1333
- handleRequest,
1334
- activeAsyncRuns,
1335
- setRunPipelineImplForTests(nextImpl) {
1336
- runPipelineImpl = typeof nextImpl === "function" ? nextImpl : runRecommendPipeline;
1337
- }
1338
- };
1339
-
1340
- const thisFilePath = fileURLToPath(import.meta.url);
1341
- if (process.argv[1] && path.resolve(process.argv[1]) === thisFilePath) {
1342
- startServer();
1343
- }
1415
+ export const __testables = {
1416
+ handleRequest,
1417
+ runDetachedWorkerForTests(options = {}) {
1418
+ return runDetachedWorker(options);
1419
+ },
1420
+ setSpawnProcessImplForTests(nextImpl) {
1421
+ spawnProcessImpl = typeof nextImpl === "function" ? nextImpl : spawn;
1422
+ },
1423
+ setRunPipelineImplForTests(nextImpl) {
1424
+ runPipelineImpl = typeof nextImpl === "function" ? nextImpl : runRecommendPipeline;
1425
+ }
1426
+ };
1427
+
1428
+ const thisFilePath = fileURLToPath(import.meta.url);
1429
+ if (process.argv[1] && path.resolve(process.argv[1]) === thisFilePath) {
1430
+ const detachedWorkerOptions = parseDetachedWorkerOptions(process.argv.slice(2));
1431
+ if (detachedWorkerOptions) {
1432
+ runDetachedWorker({
1433
+ runId: detachedWorkerOptions.runId,
1434
+ resumeRun: detachedWorkerOptions.resumeRun
1435
+ }).then((result) => {
1436
+ if (!result?.ok) {
1437
+ process.exitCode = 1;
1438
+ }
1439
+ }).catch(() => {
1440
+ process.exitCode = 1;
1441
+ });
1442
+ } else {
1443
+ startServer();
1444
+ }
1445
+ }
@@ -6,7 +6,8 @@ import { __testables } from "./index.js";
6
6
 
7
7
  const {
8
8
  handleRequest,
9
- activeAsyncRuns,
9
+ runDetachedWorkerForTests,
10
+ setSpawnProcessImplForTests,
10
11
  setRunPipelineImplForTests
11
12
  } = __testables;
12
13
 
@@ -141,6 +142,35 @@ function setupPipelineMock() {
141
142
  });
142
143
  }
143
144
 
145
+ function parseDetachedSpawnArgs(argv = []) {
146
+ const normalized = Array.isArray(argv) ? argv.map((item) => String(item || "")) : [];
147
+ const runIdFlagIndex = normalized.indexOf("--run-id");
148
+ return {
149
+ runId: runIdFlagIndex >= 0 ? String(normalized[runIdFlagIndex + 1] || "").trim() : "",
150
+ resumeRun: normalized.includes("--resume")
151
+ };
152
+ }
153
+
154
+ function setupDetachedWorkerStub() {
155
+ setSpawnProcessImplForTests((command, argv = []) => {
156
+ assert.equal(typeof command, "string");
157
+ const { runId, resumeRun } = parseDetachedSpawnArgs(argv);
158
+ assert.equal(Boolean(runId), true, "detached worker spawn must include --run-id");
159
+ const pid = process.pid;
160
+ setTimeout(() => {
161
+ runDetachedWorkerForTests({
162
+ runId,
163
+ resumeRun,
164
+ workerPid: pid
165
+ }).catch(() => {});
166
+ }, 0);
167
+ return {
168
+ pid,
169
+ unref() {}
170
+ };
171
+ });
172
+ }
173
+
144
174
  async function testPauseAndResumeFlow() {
145
175
  const runId = await startAcceptedRun("run for pause and resume", 11);
146
176
  await waitForRunState(runId, ["running"]);
@@ -170,9 +200,6 @@ async function testResumeAfterProcessRestartSimulation() {
170
200
  assert.equal(pausePayload.status, "PAUSE_REQUESTED");
171
201
  await waitForRunState(runId, ["paused"]);
172
202
 
173
- // 模拟服务重启后内存态丢失:active map 为空,仅依赖 run-state 持久化恢复。
174
- activeAsyncRuns.clear();
175
-
176
203
  const resumePayload = await callTool(TOOL_RESUME_RUN, { run_id: runId }, 23);
177
204
  assert.equal(resumePayload.status, "RESUME_REQUESTED");
178
205
  assert.equal(resumePayload.run.run_id, runId);
@@ -214,6 +241,7 @@ async function main() {
214
241
  const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-index-async-"));
215
242
  process.env.BOSS_RECOMMEND_HOME = tempHome;
216
243
  setupPipelineMock();
244
+ setupDetachedWorkerStub();
217
245
 
218
246
  try {
219
247
  await testPauseAndResumeFlow();
@@ -223,7 +251,7 @@ async function main() {
223
251
  console.log("index async tests passed");
224
252
  } finally {
225
253
  setRunPipelineImplForTests(null);
226
- activeAsyncRuns.clear();
254
+ setSpawnProcessImplForTests(null);
227
255
  if (previousHome === undefined) {
228
256
  delete process.env.BOSS_RECOMMEND_HOME;
229
257
  } else {
@@ -13,11 +13,52 @@ const RESUME_CAPTURE_WAIT_MS = 60000;
13
13
  const RESUME_CAPTURE_MAX_ATTEMPTS = 4;
14
14
  const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
15
15
  const MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES = 10;
16
+ const DEFAULT_VISION_MAX_IMAGE_PIXELS = 36000000;
17
+ const DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS = 30000000;
18
+ let visionSharpFactory = null;
16
19
  const PAGE_SCOPE_TAB_STATUS = {
17
20
  recommend: "0",
18
21
  latest: "1",
19
22
  featured: "3"
20
23
  };
24
+ const BOTTOM_HINT_KEYWORDS = ["没有更多", "已显示全部", "已经到底", "暂无更多", "推荐完了", "没有更多人选"];
25
+ const LOAD_MORE_HINT_KEYWORDS = ["滚动加载更多", "下滑加载更多", "继续下滑", "继续滑动", "滑动加载", "正在加载", "加载中"];
26
+
27
+ function classifyFinishedWrapState(finishedWrapText, refreshButtonVisible = false) {
28
+ const normalizedText = normalizeText(finishedWrapText);
29
+ const matchedBottomKeyword = BOTTOM_HINT_KEYWORDS.find((keyword) => normalizedText.includes(keyword)) || null;
30
+ if (matchedBottomKeyword) {
31
+ return {
32
+ isBottom: true,
33
+ reason: matchedBottomKeyword,
34
+ matched_bottom_keyword: matchedBottomKeyword,
35
+ matched_load_more_keyword: null
36
+ };
37
+ }
38
+ const matchedLoadMoreKeyword = LOAD_MORE_HINT_KEYWORDS.find((keyword) => normalizedText.includes(keyword)) || null;
39
+ if (matchedLoadMoreKeyword) {
40
+ return {
41
+ isBottom: false,
42
+ reason: null,
43
+ matched_bottom_keyword: null,
44
+ matched_load_more_keyword: matchedLoadMoreKeyword
45
+ };
46
+ }
47
+ if (refreshButtonVisible) {
48
+ return {
49
+ isBottom: true,
50
+ reason: "refresh_button_visible",
51
+ matched_bottom_keyword: null,
52
+ matched_load_more_keyword: null
53
+ };
54
+ }
55
+ return {
56
+ isBottom: false,
57
+ reason: null,
58
+ matched_bottom_keyword: null,
59
+ matched_load_more_keyword: null
60
+ };
61
+ }
21
62
 
22
63
  function getCodexHome() {
23
64
  return process.env.CODEX_HOME
@@ -65,6 +106,35 @@ function parsePositiveInteger(raw) {
65
106
  return Number.isFinite(value) && value > 0 ? value : null;
66
107
  }
67
108
 
109
+ function resolveVisionPixelLimitFromEnv(envName, fallback) {
110
+ const parsed = parsePositiveInteger(process.env[envName]);
111
+ return parsed || fallback;
112
+ }
113
+
114
+ function resolveVisionRetryPixelLimit(primaryLimit) {
115
+ const safePrimary = parsePositiveInteger(primaryLimit) || DEFAULT_VISION_MAX_IMAGE_PIXELS;
116
+ const fallback = Math.max(1, Math.floor(safePrimary * 0.8));
117
+ const parsed = resolveVisionPixelLimitFromEnv("BOSS_RECOMMEND_VISION_RETRY_MAX_IMAGE_PIXELS", DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS);
118
+ const candidate = parsePositiveInteger(parsed) || fallback;
119
+ return Math.min(Math.max(1, candidate), Math.max(1, safePrimary - 1));
120
+ }
121
+
122
+ function loadVisionSharp() {
123
+ if (!visionSharpFactory) {
124
+ visionSharpFactory = require("sharp");
125
+ }
126
+ return visionSharpFactory;
127
+ }
128
+
129
+ function isVisionImageSizeLimitMessage(message) {
130
+ const text = normalizeText(message).toLowerCase();
131
+ if (!text) return false;
132
+ return (
133
+ /(像素|pixel|pixels|too large|image size|image dimension|too many pixels|max(?:imum)?[^a-z0-9]{0,8}(?:pixel|image)|超过|超出|上限)/i.test(text)
134
+ || (text.includes("image") && text.includes("limit"))
135
+ );
136
+ }
137
+
68
138
  function normalizePostAction(value) {
69
139
  const normalized = normalizeText(value).toLowerCase();
70
140
  if (!normalized) return null;
@@ -1041,7 +1111,8 @@ const jsDetectBottom = `(() => {
1041
1111
  const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
1042
1112
  const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
1043
1113
  .find((el) => isVisible(el)) || null;
1044
- const keywords = ['没有更多', '已显示全部', '已经到底', '暂无更多', '推荐完了', '没有更多人选'];
1114
+ const keywords = ${JSON.stringify(BOTTOM_HINT_KEYWORDS)};
1115
+ const loadMoreKeywords = ${JSON.stringify(LOAD_MORE_HINT_KEYWORDS)};
1045
1116
  const elements = Array.from(doc.querySelectorAll('div,span,p'));
1046
1117
  for (const el of elements) {
1047
1118
  if (el.offsetParent === null) continue;
@@ -1059,12 +1130,21 @@ const jsDetectBottom = `(() => {
1059
1130
  }
1060
1131
  }
1061
1132
  }
1133
+ const finishedWrapText = finishedWrap ? String(finishedWrap.textContent || '').replace(/\s+/g, ' ').trim() : '';
1134
+ const matchedBottomKeyword = keywords.find((keyword) => finishedWrapText.includes(keyword)) || null;
1135
+ const matchedLoadMoreKeyword = loadMoreKeywords.find((keyword) => finishedWrapText.includes(keyword)) || null;
1136
+ const inferredBottom = matchedBottomKeyword
1137
+ ? true
1138
+ : (Boolean(refreshButton) && !matchedLoadMoreKeyword);
1062
1139
  return {
1063
- isBottom: Boolean(finishedWrap),
1064
- reason: finishedWrap ? 'finished-wrap' : null,
1140
+ isBottom: inferredBottom,
1141
+ reason: matchedBottomKeyword || (inferredBottom ? 'refresh_button_visible' : null),
1065
1142
  finished_wrap_visible: Boolean(finishedWrap),
1143
+ finished_wrap_text: finishedWrapText || null,
1066
1144
  refresh_button_visible: Boolean(refreshButton),
1067
- refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
1145
+ refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null,
1146
+ matched_bottom_keyword: matchedBottomKeyword,
1147
+ matched_load_more_keyword: matchedLoadMoreKeyword
1068
1148
  };
1069
1149
  })()`;
1070
1150
  const jsWaitForDetail = `(() => {
@@ -2615,15 +2695,21 @@ class RecommendScreenCli {
2615
2695
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
2616
2696
  }
2617
2697
  const doc = frame.contentDocument;
2618
- const inner = Array.from(doc.querySelectorAll('.card-inner[data-geekid]'))
2698
+ const recommendInner = Array.from(doc.querySelectorAll('.card-inner[data-geekid]'))
2619
2699
  .find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
2620
- const featuredAnchor = inner
2700
+ const latestInner = recommendInner
2701
+ ? null
2702
+ : Array.from(doc.querySelectorAll('.candidate-card-wrap .card-inner[data-geek], .candidate-card-wrap [data-geek]'))
2703
+ .find((item) => (item.getAttribute('data-geek') || '') === String(candidateKey)) || null;
2704
+ const featuredAnchor = (recommendInner || latestInner)
2621
2705
  ? null
2622
2706
  : Array.from(doc.querySelectorAll('li.geek-info-card a[data-geekid], a[data-geekid]'))
2623
2707
  .find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
2624
- const card = inner
2625
- ? (inner.closest('li.card-item') || inner.closest('.card-item'))
2626
- : (featuredAnchor ? (featuredAnchor.closest('li.geek-info-card') || featuredAnchor.closest('.geek-info-card')) : null);
2708
+ const card = recommendInner
2709
+ ? (recommendInner.closest('li.card-item') || recommendInner.closest('.card-item'))
2710
+ : latestInner
2711
+ ? (latestInner.closest('.candidate-card-wrap') || latestInner.closest('li.card-item') || latestInner.closest('.card-item'))
2712
+ : (featuredAnchor ? (featuredAnchor.closest('li.geek-info-card') || featuredAnchor.closest('.geek-info-card')) : null);
2627
2713
  if (!card) {
2628
2714
  return { ok: false, error: 'CANDIDATE_CARD_NOT_FOUND' };
2629
2715
  }
@@ -2716,6 +2802,130 @@ class RecommendScreenCli {
2716
2802
  }
2717
2803
 
2718
2804
  async callVisionModel(imagePath) {
2805
+ const primaryLimit = resolveVisionPixelLimitFromEnv(
2806
+ "BOSS_RECOMMEND_VISION_MAX_IMAGE_PIXELS",
2807
+ DEFAULT_VISION_MAX_IMAGE_PIXELS
2808
+ );
2809
+ const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
2810
+ const preparedPrimary = await this.prepareVisionImageForModel(imagePath, primaryLimit, "primary");
2811
+ try {
2812
+ return await this.requestVisionModel(preparedPrimary.imagePath);
2813
+ } catch (error) {
2814
+ if (!isVisionImageSizeLimitMessage(error?.message || "")) {
2815
+ throw error;
2816
+ }
2817
+ log(
2818
+ `[VISION] 检测到图片尺寸超限,准备降采样重试: ` +
2819
+ `primary_limit=${primaryLimit} source=${preparedPrimary.source} ` +
2820
+ `source_pixels=${preparedPrimary.sourcePixels ?? "unknown"}`
2821
+ );
2822
+ }
2823
+ const preparedRetry = await this.prepareVisionImageForModel(imagePath, retryLimit, "retry");
2824
+ try {
2825
+ return await this.requestVisionModel(preparedRetry.imagePath);
2826
+ } catch (retryError) {
2827
+ if (!isVisionImageSizeLimitMessage(retryError?.message || "")) {
2828
+ throw retryError;
2829
+ }
2830
+ throw this.buildError(
2831
+ "VISION_IMAGE_SIZE_LIMIT_EXCEEDED",
2832
+ `Vision model still rejected image after retry downscale; ` +
2833
+ `primary_limit=${primaryLimit}; retry_limit=${retryLimit}; ` +
2834
+ `source_pixels=${preparedRetry.sourcePixels ?? "unknown"}; ` +
2835
+ `retry_pixels=${preparedRetry.currentPixels ?? "unknown"}; ` +
2836
+ `last_error=${normalizeText(retryError?.message || retryError)}`
2837
+ );
2838
+ }
2839
+ }
2840
+
2841
+ async prepareVisionImageForModel(imagePath, maxPixels, attemptTag = "primary") {
2842
+ const resolvedMaxPixels = parsePositiveInteger(maxPixels);
2843
+ if (!resolvedMaxPixels) {
2844
+ return {
2845
+ imagePath,
2846
+ source: "no_limit",
2847
+ sourcePixels: null,
2848
+ currentPixels: null
2849
+ };
2850
+ }
2851
+ let sharp;
2852
+ try {
2853
+ sharp = loadVisionSharp();
2854
+ } catch (error) {
2855
+ log(`[VISION] 加载 sharp 失败,跳过预缩放: ${error?.message || error}`);
2856
+ return {
2857
+ imagePath,
2858
+ source: "sharp_unavailable",
2859
+ sourcePixels: null,
2860
+ currentPixels: null
2861
+ };
2862
+ }
2863
+ let metadata;
2864
+ try {
2865
+ metadata = await sharp(imagePath).metadata();
2866
+ } catch (error) {
2867
+ log(`[VISION] 读取图片尺寸失败,跳过预缩放: ${error?.message || error}`);
2868
+ return {
2869
+ imagePath,
2870
+ source: "metadata_error",
2871
+ sourcePixels: null,
2872
+ currentPixels: null
2873
+ };
2874
+ }
2875
+ const width = Number(metadata?.width || 0);
2876
+ const height = Number(metadata?.height || 0);
2877
+ const sourcePixels = width > 0 && height > 0 ? width * height : null;
2878
+ if (!sourcePixels || sourcePixels <= resolvedMaxPixels) {
2879
+ return {
2880
+ imagePath,
2881
+ source: "within_limit",
2882
+ sourcePixels,
2883
+ currentPixels: sourcePixels
2884
+ };
2885
+ }
2886
+ const scale = Math.sqrt(resolvedMaxPixels / sourcePixels);
2887
+ const targetWidth = Math.max(1, Math.floor(width * scale));
2888
+ const targetHeight = Math.max(1, Math.floor(height * scale));
2889
+ const parsedPath = path.parse(imagePath);
2890
+ const resizedPath = path.join(
2891
+ parsedPath.dir,
2892
+ `${parsedPath.name}.${attemptTag}.max${resolvedMaxPixels}.png`
2893
+ );
2894
+ try {
2895
+ await sharp(imagePath)
2896
+ .resize({
2897
+ width: targetWidth,
2898
+ height: targetHeight,
2899
+ fit: "inside",
2900
+ withoutEnlargement: true
2901
+ })
2902
+ .png()
2903
+ .toFile(resizedPath);
2904
+ const resizedMeta = await sharp(resizedPath).metadata();
2905
+ const resizedPixels = Number(resizedMeta?.width || 0) * Number(resizedMeta?.height || 0);
2906
+ log(
2907
+ `[VISION] 图片预缩放完成: ${width}x${height}(${sourcePixels}) -> ` +
2908
+ `${resizedMeta?.width || "?"}x${resizedMeta?.height || "?"}(${resizedPixels || "?"}); ` +
2909
+ `limit=${resolvedMaxPixels}; attempt=${attemptTag}`
2910
+ );
2911
+ return {
2912
+ imagePath: resizedPath,
2913
+ source: "resized",
2914
+ sourcePixels,
2915
+ currentPixels: resizedPixels || null
2916
+ };
2917
+ } catch (error) {
2918
+ log(`[VISION] 预缩放失败,继续使用原图: ${error?.message || error}`);
2919
+ return {
2920
+ imagePath,
2921
+ source: "resize_failed",
2922
+ sourcePixels,
2923
+ currentPixels: sourcePixels
2924
+ };
2925
+ }
2926
+ }
2927
+
2928
+ async requestVisionModel(imagePath) {
2719
2929
  const imageBase64 = fs.readFileSync(imagePath, "base64");
2720
2930
  const rawBaseUrl = this.args.baseUrl;
2721
2931
  log(`[callVisionModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
@@ -3361,9 +3571,28 @@ class RecommendScreenCli {
3361
3571
  } else {
3362
3572
  this.resetResumeCaptureFailureStreak();
3363
3573
  }
3364
- if (["VISION_MODEL_FAILED", "TEXT_MODEL_FAILED"].includes(error.code)) {
3574
+ if (error.code === "TEXT_MODEL_FAILED") {
3365
3575
  throw error;
3366
3576
  }
3577
+ if (error.code === "VISION_MODEL_FAILED") {
3578
+ if (isVisionImageSizeLimitMessage(error?.message || "")) {
3579
+ log(
3580
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 触发视觉模型像素限制,` +
3581
+ "已在本轮跳过并继续处理下一位。"
3582
+ );
3583
+ } else {
3584
+ log(
3585
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 视觉模型调用失败,` +
3586
+ "已在本轮跳过并继续处理下一位。"
3587
+ );
3588
+ }
3589
+ }
3590
+ if (error.code === "VISION_IMAGE_SIZE_LIMIT_EXCEEDED") {
3591
+ log(
3592
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 触发视觉模型像素限制,` +
3593
+ "已完成预缩放和重试,仍失败,继续处理下一位。"
3594
+ );
3595
+ }
3367
3596
  } finally {
3368
3597
  const closed = await this.closeDetailPage();
3369
3598
  if (!closed) {
@@ -3485,7 +3714,8 @@ if (require.main === module) {
3485
3714
  parseFavoriteActionFromKnownRequest,
3486
3715
  parseFavoriteActionFromActionLog,
3487
3716
  parseFavoriteActionFromWsPayload,
3488
- isRecoverablePostActionError
3717
+ isRecoverablePostActionError,
3718
+ classifyFinishedWrapState
3489
3719
  }
3490
3720
  };
3491
3721
  }
@@ -382,6 +382,41 @@ async function testNetworkMissShouldFallbackToImageCapture() {
382
382
  assert.equal(result.result.resume_source, "image_fallback");
383
383
  }
384
384
 
385
+ async function testVisionModelFailureShouldSkipCandidateAndContinue() {
386
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-vision-failure-skip-"));
387
+ const first = { key: "vision-fail-1", geek_id: "vision-fail-1", name: "vision-fail-1" };
388
+ const second = { key: "vision-pass-2", geek_id: "vision-pass-2", name: "vision-pass-2" };
389
+ const cli = new FakeRecommendScreenCli(createArgs(tempDir), {
390
+ candidates: [first, second],
391
+ captureOutcomes: new Map([
392
+ ["vision-fail-1", { stitchedImage: path.join(tempDir, "vision-fail-1.png") }],
393
+ ["vision-pass-2", { stitchedImage: path.join(tempDir, "vision-pass-2.png") }]
394
+ ]),
395
+ screeningByKey: new Map([
396
+ ["vision-pass-2", { passed: true, reason: "ok", summary: "ok" }]
397
+ ])
398
+ });
399
+
400
+ cli.callVisionModel = async () => {
401
+ if (cli.lastCapturedCandidateKey === "vision-fail-1") {
402
+ const error = new Error("model backend timeout");
403
+ error.code = "VISION_MODEL_FAILED";
404
+ throw error;
405
+ }
406
+ return {
407
+ passed: true,
408
+ reason: "ok",
409
+ summary: "ok"
410
+ };
411
+ };
412
+
413
+ const result = await cli.run();
414
+ assert.equal(result.status, "COMPLETED");
415
+ assert.equal(result.result.processed_count, 2);
416
+ assert.equal(result.result.passed_count, 1);
417
+ assert.equal(result.result.skipped_count, 1);
418
+ }
419
+
385
420
  async function testFeaturedNetworkMissShouldSkipWithoutImageCapture() {
386
421
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-featured-network-only-"));
387
422
  const args = createArgs(tempDir);
@@ -551,6 +586,50 @@ function testFavoriteActionParserShouldOnlyTrustKnownRequestShapes() {
551
586
  assert.equal(userMark, "add");
552
587
  }
553
588
 
589
+ function testFinishedWrapClassifierShouldNotTreatLoadMoreAsBottom() {
590
+ const loadMore = __testables.classifyFinishedWrapState("滚动加载更多", false);
591
+ const loading = __testables.classifyFinishedWrapState("正在加载数据...", false);
592
+ const noMore = __testables.classifyFinishedWrapState("没有更多人选", false);
593
+ const refreshOnly = __testables.classifyFinishedWrapState("", true);
594
+
595
+ assert.equal(loadMore.isBottom, false);
596
+ assert.equal(loadMore.matched_load_more_keyword, "滚动加载更多");
597
+ assert.equal(loading.isBottom, false);
598
+ assert.equal(loading.matched_load_more_keyword, "正在加载");
599
+ assert.equal(noMore.isBottom, true);
600
+ assert.equal(noMore.matched_bottom_keyword, "没有更多");
601
+ assert.equal(refreshOnly.isBottom, true);
602
+ assert.equal(refreshOnly.reason, "refresh_button_visible");
603
+ }
604
+
605
+ async function testGetCenteredCandidateClickPointShouldSupportLatestSelector() {
606
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-latest-click-locator-"));
607
+ const args = createArgs(tempDir);
608
+ args.pageScope = "latest";
609
+ const cli = new RecommendScreenCli(args);
610
+
611
+ let expressionCaptured = "";
612
+ cli.evaluate = async (expression) => {
613
+ expressionCaptured = String(expression || "");
614
+ return {
615
+ ok: true,
616
+ x: 100,
617
+ y: 100,
618
+ width: 120,
619
+ height: 64
620
+ };
621
+ };
622
+
623
+ const result = await cli.getCenteredCandidateClickPoint({
624
+ key: "latest-test-key",
625
+ geek_id: "latest-test-key"
626
+ });
627
+
628
+ assert.equal(result.ok, true);
629
+ assert.equal(expressionCaptured.includes(".candidate-card-wrap .card-inner[data-geek]"), true);
630
+ assert.equal(expressionCaptured.includes("getAttribute('data-geek')"), true);
631
+ }
632
+
554
633
  async function testFeaturedPostActionFailureShouldStillRecordPassedCandidate() {
555
634
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-featured-action-failure-"));
556
635
  const args = createArgs(tempDir);
@@ -726,6 +805,7 @@ async function main() {
726
805
  await testFeaturedShouldUseNetworkResumeOnly();
727
806
  await testRecommendShouldKeepImageCaptureEvenWhenNetworkResumeExists();
728
807
  await testNetworkMissShouldFallbackToImageCapture();
808
+ await testVisionModelFailureShouldSkipCandidateAndContinue();
729
809
  await testFeaturedNetworkMissShouldSkipWithoutImageCapture();
730
810
  await testFeaturedFavoriteShouldNotUseDomFallback();
731
811
  await testFeaturedFavoriteShouldSkipClickWhenAlreadyInterested();
@@ -735,6 +815,8 @@ async function main() {
735
815
  testFavoriteActionParserShouldSupportFallbackRequestShape();
736
816
  testFavoriteActionParserShouldSupportWebSocketPayload();
737
817
  testFavoriteActionParserShouldOnlyTrustKnownRequestShapes();
818
+ testFinishedWrapClassifierShouldNotTreatLoadMoreAsBottom();
819
+ await testGetCenteredCandidateClickPointShouldSupportLatestSelector();
738
820
  await testFeaturedPostActionFailureShouldStillRecordPassedCandidate();
739
821
  await testStitchWithSharpShouldComposeExpectedImage();
740
822
  testStitchWithAvailablePythonShouldFallbackToPython();