@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
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 {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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) ||
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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) ||
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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 (
|
|
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:
|
|
1084
|
-
};
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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) ||
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
})
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
+
}
|
package/src/test-index-async.js
CHANGED
|
@@ -6,7 +6,8 @@ import { __testables } from "./index.js";
|
|
|
6
6
|
|
|
7
7
|
const {
|
|
8
8
|
handleRequest,
|
|
9
|
-
|
|
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
|
-
|
|
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:
|
|
1064
|
-
reason:
|
|
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
|
|
2698
|
+
const recommendInner = Array.from(doc.querySelectorAll('.card-inner[data-geekid]'))
|
|
2619
2699
|
.find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
|
|
2620
|
-
const
|
|
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 =
|
|
2625
|
-
? (
|
|
2626
|
-
:
|
|
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 (
|
|
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();
|