@reconcrap/boss-recommend-mcp 2.0.34 → 2.0.35

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": "2.0.34",
3
+ "version": "2.0.35",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -4,7 +4,8 @@ import {
4
4
  waitForRecommendCardNodeIds
5
5
  } from "./cards.js";
6
6
  import {
7
- RECOMMEND_RECENT_NOT_VIEW_LABEL
7
+ RECOMMEND_RECENT_NOT_VIEW_LABEL,
8
+ RECOMMEND_TARGET_URL
8
9
  } from "./constants.js";
9
10
  import { selectAndConfirmFirstSafeFilter } from "./filters.js";
10
11
  import { selectRecommendJob } from "./jobs.js";
@@ -94,6 +95,8 @@ export async function refreshRecommendListAtEnd(client, {
94
95
  fallbackPageScope = "recommend",
95
96
  filter = {},
96
97
  preferEndRefreshButton = true,
98
+ forceNavigate = false,
99
+ targetUrl = RECOMMEND_TARGET_URL,
97
100
  forceRecentNotView = true,
98
101
  cardTimeoutMs = 30000,
99
102
  buttonSettleMs = 8000,
@@ -156,8 +159,17 @@ export async function refreshRecommendListAtEnd(client, {
156
159
  }
157
160
  }
158
161
 
162
+ let fallbackMethod = "page_reload";
159
163
  try {
160
- await client.Page.reload({ ignoreCache: true });
164
+ let method = "page_reload";
165
+ if (forceNavigate && typeof client?.Page?.navigate === "function") {
166
+ await client.Page.navigate({ url: targetUrl || RECOMMEND_TARGET_URL });
167
+ method = "page_navigate";
168
+ fallbackMethod = method;
169
+ } else {
170
+ await client.Page.reload({ ignoreCache: true });
171
+ fallbackMethod = method;
172
+ }
161
173
  if (reloadSettleMs > 0) await sleep(reloadSettleMs);
162
174
  currentRootState = await waitForRecommendRoots(client, {
163
175
  timeoutMs: Math.max(30000, reloadSettleMs * 4),
@@ -202,8 +214,9 @@ export async function refreshRecommendListAtEnd(client, {
202
214
  });
203
215
  return {
204
216
  ok: cardNodeIds.length > 0,
205
- method: "page_reload",
217
+ method,
206
218
  attempts,
219
+ target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
207
220
  job_selection: jobSelection,
208
221
  page_scope: pageScopeResult,
209
222
  filter: filterResult,
@@ -214,10 +227,11 @@ export async function refreshRecommendListAtEnd(client, {
214
227
  } catch (error) {
215
228
  return {
216
229
  ok: false,
217
- method: "page_reload",
218
- reason: "page_reload_failed",
230
+ method: fallbackMethod,
231
+ reason: fallbackMethod === "page_navigate" ? "page_navigate_failed" : "page_reload_failed",
219
232
  error: error?.message || String(error),
220
233
  attempts,
234
+ target_url: fallbackMethod === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
221
235
  card_count: 0,
222
236
  root_state: currentRootState,
223
237
  forced_recent_not_view: forceRecentNotView
@@ -65,7 +65,8 @@ import {
65
65
  RECOMMEND_BOTTOM_MARKER_SELECTORS,
66
66
  RECOMMEND_CARD_SELECTOR,
67
67
  RECOMMEND_END_REFRESH_SELECTOR,
68
- RECOMMEND_LIST_CONTAINER_SELECTORS
68
+ RECOMMEND_LIST_CONTAINER_SELECTORS,
69
+ RECOMMEND_TARGET_URL
69
70
  } from "./constants.js";
70
71
  import {
71
72
  clickRecommendActionControl,
@@ -355,6 +356,7 @@ function compactRefreshAttempt(refreshAttempt) {
355
356
  ok: Boolean(refreshAttempt.ok),
356
357
  method: refreshAttempt.method || "",
357
358
  forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
359
+ target_url: refreshAttempt.target_url || null,
358
360
  card_count: refreshAttempt.card_count || 0,
359
361
  attempts: (refreshAttempt.attempts || []).map((attempt) => ({
360
362
  ok: Boolean(attempt.ok),
@@ -364,13 +366,39 @@ function compactRefreshAttempt(refreshAttempt) {
364
366
  before_card_count: attempt.before_card_count || 0,
365
367
  after_card_count: attempt.after_card_count || 0
366
368
  })),
369
+ job_selection: compactJobSelection(refreshAttempt.job_selection),
367
370
  page_scope: compactPageScopeSelection(refreshAttempt.page_scope),
368
371
  filter: compactFilterResult(refreshAttempt.filter)
369
372
  };
370
373
  }
371
374
 
375
+ export function countRecommendResultStatuses(results = [], {
376
+ greetCount = 0
377
+ } = {}) {
378
+ return {
379
+ processed: results.length,
380
+ screened: results.length,
381
+ detail_opened: results.filter((item) => item.detail).length,
382
+ passed: results.filter((item) => item.screening?.passed).length,
383
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
384
+ greet_count: greetCount,
385
+ post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
386
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
387
+ detail_open_failed: results.filter((item) => (
388
+ item.error?.code === "DETAIL_STALE_NODE"
389
+ || item.error?.code === "DETAIL_OPEN_FAILED"
390
+ )).length,
391
+ transient_recovered: results.filter((item) => (
392
+ item.error?.code === "DETAIL_STALE_NODE"
393
+ || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
394
+ || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
395
+ || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
396
+ )).length
397
+ };
398
+ }
399
+
372
400
  function countPassedResults(results = []) {
373
- return results.filter((item) => item?.screening?.passed).length;
401
+ return countRecommendResultStatuses(results).passed;
374
402
  }
375
403
 
376
404
  function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
@@ -381,6 +409,13 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
381
409
  };
382
410
  }
383
411
 
412
+ function createRecommendCloseFailureError(closeResult) {
413
+ const error = new Error(closeResult?.reason || "Recommend detail did not close before recovery");
414
+ error.code = "DETAIL_CLOSE_FAILED";
415
+ error.close_result = closeResult || null;
416
+ return error;
417
+ }
418
+
384
419
  export function isRecoverableImageCaptureError(error) {
385
420
  const code = String(error?.code || "");
386
421
  if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
@@ -535,7 +570,9 @@ export async function runRecommendWorkflow({
535
570
  const results = [];
536
571
  const refreshAttempts = [];
537
572
  let refreshRounds = 0;
573
+ let contextRecoveryAttempts = 0;
538
574
  let greetCount = 0;
575
+ const candidateRecoveryCounts = new Map();
539
576
  let jobSelection = null;
540
577
  let pageScopeSelection = null;
541
578
  let filterResult = null;
@@ -550,6 +587,121 @@ export async function runRecommendWorkflow({
550
587
  validateViewportPoint: true
551
588
  }));
552
589
 
590
+ function updateRecommendProgress(extra = {}) {
591
+ const counts = countRecommendResultStatuses(results, { greetCount });
592
+ const listSnapshot = compactInfiniteListState(listState);
593
+ runControl.updateProgress({
594
+ card_count: cardNodeIds.length,
595
+ target_count: targetPassCount,
596
+ target_count_semantics: "passed_candidates",
597
+ ...counts,
598
+ screening_mode: normalizedScreeningMode,
599
+ unique_seen: listSnapshot.seen_count,
600
+ scroll_count: listSnapshot.scroll_count,
601
+ refresh_rounds: refreshRounds,
602
+ refresh_attempts: refreshAttempts.length,
603
+ context_recoveries: contextRecoveryAttempts,
604
+ list_end_reason: listEndReason || null,
605
+ viewport_checks: viewportGuard.getStats().checks,
606
+ viewport_recoveries: viewportGuard.getStats().recoveries,
607
+ ...extra
608
+ });
609
+ }
610
+
611
+ function checkpointInProgressCandidate({
612
+ index = results.length,
613
+ candidateKey = "",
614
+ cardNodeId = null,
615
+ detailStep = "",
616
+ error = null
617
+ } = {}) {
618
+ runControl.checkpoint({
619
+ in_progress_candidate: {
620
+ index,
621
+ key: candidateKey,
622
+ card_node_id: cardNodeId,
623
+ detail_step: detailStep || null,
624
+ counters: countRecommendResultStatuses(results, { greetCount }),
625
+ error: compactError(error, "RECOMMEND_IN_PROGRESS_ERROR")
626
+ },
627
+ candidate_list: compactInfiniteListState(listState)
628
+ });
629
+ }
630
+
631
+ async function recoverAndReapplyRecommendContext(reason = "context_recovery", error = null, {
632
+ forceRecentNotView = true
633
+ } = {}) {
634
+ await runControl.waitIfPaused();
635
+ runControl.throwIfCanceled();
636
+ const started = Date.now();
637
+ runControl.setPhase("recommend:recover-context");
638
+ contextRecoveryAttempts += 1;
639
+ const refreshResult = await refreshRecommendListAtEnd(client, {
640
+ rootState,
641
+ jobLabel,
642
+ pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
643
+ fallbackPageScope: normalizedFallbackPageScope,
644
+ filter: normalizedFilter,
645
+ preferEndRefreshButton: false,
646
+ forceNavigate: true,
647
+ targetUrl: targetUrl || RECOMMEND_TARGET_URL,
648
+ forceRecentNotView,
649
+ cardTimeoutMs,
650
+ buttonSettleMs: refreshButtonSettleMs,
651
+ reloadSettleMs: refreshReloadSettleMs
652
+ });
653
+ const compactRefresh = {
654
+ ...compactRefreshAttempt(refreshResult),
655
+ context_recovery: true,
656
+ recovery_reason: reason,
657
+ trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
658
+ elapsed_ms: Date.now() - started
659
+ };
660
+ refreshAttempts.push(compactRefresh);
661
+ runControl.checkpoint({
662
+ context_recovery: {
663
+ attempt: contextRecoveryAttempts,
664
+ reason,
665
+ trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
666
+ refresh: compactRefresh,
667
+ counters: countRecommendResultStatuses(results, { greetCount })
668
+ },
669
+ candidate_list: compactInfiniteListState(listState)
670
+ });
671
+ if (!refreshResult.ok) {
672
+ updateRecommendProgress({
673
+ refresh_method: refreshResult.method || null,
674
+ refresh_forced_recent_not_view: forceRecentNotView,
675
+ recovery_reason: reason
676
+ });
677
+ throw new Error(`Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`);
678
+ }
679
+ rootState = refreshResult.root_state || await getRecommendRoots(client);
680
+ rootState = await ensureRecommendViewport(rootState, "recover_after");
681
+ cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
682
+ timeoutMs: cardTimeoutMs,
683
+ intervalMs: 300
684
+ });
685
+ resetInfiniteListForRefreshRound(listState, {
686
+ reason: `context_recovery:${reason}`,
687
+ round: contextRecoveryAttempts,
688
+ method: refreshResult.method,
689
+ metadata: {
690
+ card_count: cardNodeIds.length,
691
+ forced_recent_not_view: forceRecentNotView,
692
+ counters: countRecommendResultStatuses(results, { greetCount })
693
+ }
694
+ });
695
+ listEndReason = "";
696
+ updateRecommendProgress({
697
+ card_count: cardNodeIds.length,
698
+ refresh_method: refreshResult.method || null,
699
+ refresh_forced_recent_not_view: forceRecentNotView,
700
+ recovery_reason: reason
701
+ });
702
+ return refreshResult;
703
+ }
704
+
553
705
  runControl.setPhase("recommend:cleanup");
554
706
  await closeRecommendDetail(client, { attemptsLimit: 2 });
555
707
 
@@ -630,24 +782,8 @@ export async function runRecommendWorkflow({
630
782
  throw new Error("No recommend candidate cards found for run service");
631
783
  }
632
784
 
633
- runControl.updateProgress({
634
- card_count: cardNodeIds.length,
635
- target_count: targetPassCount,
636
- target_count_semantics: "passed_candidates",
637
- processed: 0,
638
- screened: 0,
639
- detail_opened: 0,
640
- passed: 0,
641
- greet_count: 0,
642
- post_action_clicked: 0,
643
- screening_mode: normalizedScreeningMode,
644
- llm_screened: 0,
645
- unique_seen: compactInfiniteListState(listState).seen_count,
646
- scroll_count: 0,
647
- refresh_rounds: 0,
648
- refresh_attempts: 0,
649
- viewport_checks: viewportGuard.getStats().checks,
650
- viewport_recoveries: viewportGuard.getStats().recoveries
785
+ updateRecommendProgress({
786
+ list_end_reason: null
651
787
  });
652
788
 
653
789
  while (countPassedResults(results) < targetPassCount) {
@@ -722,24 +858,11 @@ export async function runRecommendWorkflow({
722
858
  refresh_round: refreshRounds,
723
859
  refresh: compactRefresh
724
860
  });
725
- runControl.updateProgress({
861
+ updateRecommendProgress({
726
862
  card_count: refreshResult.card_count || cardNodeIds.length,
727
- target_count: targetPassCount,
728
- target_count_semantics: "passed_candidates",
729
- processed: results.length,
730
- screened: results.length,
731
- detail_opened: results.filter((item) => item.detail).length,
732
- passed: results.filter((item) => item.screening.passed).length,
733
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
734
- unique_seen: compactInfiniteListState(listState).seen_count,
735
- scroll_count: compactInfiniteListState(listState).scroll_count,
736
- refresh_rounds: refreshRounds,
737
- refresh_attempts: refreshAttempts.length,
738
863
  refresh_method: refreshResult.method || null,
739
864
  refresh_forced_recent_not_view: true,
740
- list_end_reason: listEndReason,
741
- viewport_checks: viewportGuard.getStats().checks,
742
- viewport_recoveries: viewportGuard.getStats().recoveries
865
+ list_end_reason: listEndReason
743
866
  });
744
867
  if (refreshResult.ok) {
745
868
  rootState = refreshResult.root_state || await getRecommendRoots(client);
@@ -772,12 +895,16 @@ export async function runRecommendWorkflow({
772
895
  let screeningCandidate = cardCandidate;
773
896
  let detailResult = null;
774
897
  let recoverableDetailError = null;
898
+ let detailStep = "not_started";
775
899
  if (index < effectiveDetailLimit) {
776
900
  try {
777
901
  await runControl.waitIfPaused();
778
902
  runControl.throwIfCanceled();
779
903
  runControl.setPhase("recommend:detail");
904
+ detailStep = "ensure_viewport";
780
905
  rootState = await ensureRecommendViewport(rootState, "detail");
906
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
907
+ detailStep = "open_detail";
781
908
  networkRecorder.clear();
782
909
  const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
783
910
  cardNodeId,
@@ -794,6 +921,7 @@ export async function runRecommendWorkflow({
794
921
  cardCandidate = openedDetail.card_candidate || cardCandidate;
795
922
  screeningCandidate = cardCandidate;
796
923
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
924
+ detailStep = "wait_network";
797
925
  const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
798
926
  waitForRecommendDetailNetworkEvents,
799
927
  networkRecorder,
@@ -807,6 +935,7 @@ export async function runRecommendWorkflow({
807
935
  if (networkWait?.elapsed_ms != null) {
808
936
  timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
809
937
  }
938
+ detailStep = "extract_detail";
810
939
  detailResult = await extractRecommendDetailCandidate(client, {
811
940
  cardCandidate,
812
941
  cardNodeId,
@@ -830,6 +959,7 @@ export async function runRecommendWorkflow({
830
959
  waitResult: networkWait
831
960
  });
832
961
  } else {
962
+ detailStep = "wait_capture_target";
833
963
  captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
834
964
  domain: "recommend",
835
965
  timeoutMs: 6000,
@@ -846,6 +976,7 @@ export async function runRecommendWorkflow({
846
976
  extension: "jpg"
847
977
  });
848
978
  try {
979
+ detailStep = "capture_image";
849
980
  imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
850
981
  filePath: imageEvidencePath,
851
982
  format: "jpeg",
@@ -879,6 +1010,17 @@ export async function runRecommendWorkflow({
879
1010
  source = "image";
880
1011
  } catch (error) {
881
1012
  if (!isRecoverableImageCaptureError(error)) throw error;
1013
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1014
+ if (recoveryCount < 1) {
1015
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1016
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1017
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1018
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1019
+ await recoverAndReapplyRecommendContext(`image_capture:${detailStep}`, error, {
1020
+ forceRecentNotView: true
1021
+ });
1022
+ continue;
1023
+ }
882
1024
  imageEvidence = createRecoverableImageCaptureEvidence(error, {
883
1025
  elapsedMs: timings.screenshot_capture_ms,
884
1026
  filePath: imageEvidencePath,
@@ -919,6 +1061,17 @@ export async function runRecommendWorkflow({
919
1061
  screeningCandidate = detailResult.candidate;
920
1062
  } catch (error) {
921
1063
  if (!isRecoverableRecommendDetailError(error)) throw error;
1064
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1065
+ if (recoveryCount < 1) {
1066
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1067
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1068
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1069
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1070
+ await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
1071
+ forceRecentNotView: true
1072
+ });
1073
+ continue;
1074
+ }
922
1075
  recoverableDetailError = error;
923
1076
  detailResult = null;
924
1077
  timings.detail_recovered_error = compactRecoverableDetailError(error);
@@ -994,6 +1147,21 @@ export async function runRecommendWorkflow({
994
1147
  }
995
1148
  if (detailResult && closeDetail) {
996
1149
  detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
1150
+ if (!detailResult.close_result?.closed) {
1151
+ const closeError = createRecommendCloseFailureError(detailResult.close_result);
1152
+ const recovery = await recoverAndReapplyRecommendContext("detail_close_failed", closeError, {
1153
+ forceRecentNotView: true
1154
+ });
1155
+ detailResult.cv_acquisition = {
1156
+ ...(detailResult.cv_acquisition || {}),
1157
+ close_recovery: {
1158
+ ok: Boolean(recovery.ok),
1159
+ method: recovery.method || "",
1160
+ forced_recent_not_view: Boolean(recovery.forced_recent_not_view),
1161
+ card_count: recovery.card_count || 0
1162
+ }
1163
+ };
1164
+ }
997
1165
  }
998
1166
  timings.total_ms = Date.now() - candidateStarted;
999
1167
  const compactResult = {
@@ -1024,27 +1192,7 @@ export async function runRecommendWorkflow({
1024
1192
  }
1025
1193
  });
1026
1194
 
1027
- runControl.updateProgress({
1028
- card_count: cardNodeIds.length,
1029
- target_count: targetPassCount,
1030
- target_count_semantics: "passed_candidates",
1031
- processed: results.length,
1032
- screened: results.length,
1033
- detail_opened: results.filter((item) => item.detail).length,
1034
- passed: results.filter((item) => item.screening.passed).length,
1035
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
1036
- greet_count: greetCount,
1037
- post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
1038
- image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
1039
- detail_open_failed: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "DETAIL_OPEN_FAILED").length,
1040
- transient_recovered: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_TIMEOUT" || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT").length,
1041
- unique_seen: compactInfiniteListState(listState).seen_count,
1042
- scroll_count: compactInfiniteListState(listState).scroll_count,
1043
- refresh_rounds: refreshRounds,
1044
- refresh_attempts: refreshAttempts.length,
1045
- list_end_reason: listEndReason || null,
1046
- viewport_checks: viewportGuard.getStats().checks,
1047
- viewport_recoveries: viewportGuard.getStats().recoveries,
1195
+ updateRecommendProgress({
1048
1196
  last_candidate_id: screeningCandidate.id || null,
1049
1197
  last_candidate_key: candidateKey,
1050
1198
  last_score: screening.score
@@ -1097,16 +1245,8 @@ export async function runRecommendWorkflow({
1097
1245
  list_end_reason: listEndReason || null,
1098
1246
  refresh_rounds: refreshRounds,
1099
1247
  refresh_attempts: refreshAttempts,
1100
- processed: results.length,
1101
- screened: results.length,
1102
- detail_opened: results.filter((item) => item.detail).length,
1103
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
1104
- detail_open_failed: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "DETAIL_OPEN_FAILED").length,
1105
- image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
1106
- transient_recovered: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_TIMEOUT" || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT").length,
1107
- passed: results.filter((item) => item.screening.passed).length,
1108
- greet_count: greetCount,
1109
- post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
1248
+ context_recoveries: contextRecoveryAttempts,
1249
+ ...countRecommendResultStatuses(results, { greetCount }),
1110
1250
  results
1111
1251
  };
1112
1252
  }
@@ -1213,7 +1353,11 @@ export function createRecommendRunService({
1213
1353
  llm_screened: 0,
1214
1354
  passed: 0,
1215
1355
  greet_count: 0,
1216
- post_action_clicked: 0
1356
+ post_action_clicked: 0,
1357
+ image_capture_failed: 0,
1358
+ detail_open_failed: 0,
1359
+ transient_recovered: 0,
1360
+ context_recoveries: 0
1217
1361
  },
1218
1362
  checkpoint: {},
1219
1363
  task: (runControl) => workflow({
@@ -3,10 +3,13 @@ import {
3
3
  normalizeRecruitSearchParams
4
4
  } from "./search.js";
5
5
 
6
- export function buildRecruitRefreshSearchParams(searchParams = {}) {
6
+ export function buildRecruitRefreshSearchParams(searchParams = {}, {
7
+ forceRecentViewed = true
8
+ } = {}) {
9
+ const normalizedSearchParams = normalizeRecruitSearchParams(searchParams);
7
10
  return {
8
- ...normalizeRecruitSearchParams(searchParams),
9
- filter_recent_viewed: true
11
+ ...normalizedSearchParams,
12
+ filter_recent_viewed: forceRecentViewed ? true : normalizedSearchParams.filter_recent_viewed
10
13
  };
11
14
  }
12
15
 
@@ -16,9 +19,10 @@ export async function refreshRecruitSearchAtEnd(client, {
16
19
  searchTimeoutMs = 90000,
17
20
  resetTimeoutMs = 180000,
18
21
  resetSettleMs = 5000,
19
- cityOptionTimeoutMs = 30000
22
+ cityOptionTimeoutMs = 30000,
23
+ forceRecentViewed = true
20
24
  } = {}) {
21
- const refreshSearchParams = buildRecruitRefreshSearchParams(searchParams);
25
+ const refreshSearchParams = buildRecruitRefreshSearchParams(searchParams, { forceRecentViewed });
22
26
  const application = await applyRecruitSearchParams(client, {
23
27
  searchParams: refreshSearchParams,
24
28
  requireCards,
@@ -32,7 +36,7 @@ export async function refreshRecruitSearchAtEnd(client, {
32
36
  return {
33
37
  ok: !requireCards || cardCount > 0,
34
38
  method: "page_reload_search",
35
- forced_recent_viewed: true,
39
+ forced_recent_viewed: Boolean(forceRecentViewed),
36
40
  search_params: refreshSearchParams,
37
41
  card_count: cardCount,
38
42
  application
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { createRunLifecycleManager } from "../../core/run/index.js";
2
4
  import {
3
5
  addTiming,
@@ -137,6 +139,129 @@ function compactRefreshAttempt(refreshAttempt) {
137
139
  };
138
140
  }
139
141
 
142
+ function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
143
+ if (!error) return null;
144
+ return {
145
+ code: error.code || fallbackCode,
146
+ message: error.message || String(error)
147
+ };
148
+ }
149
+
150
+ function createRecruitCloseFailureError(closeResult) {
151
+ const error = new Error(closeResult?.reason || "Recruit detail did not close before recovery");
152
+ error.code = "DETAIL_CLOSE_FAILED";
153
+ error.close_result = closeResult || null;
154
+ return error;
155
+ }
156
+
157
+ export function isStaleRecruitNodeError(error) {
158
+ const message = String(error?.message || error || "");
159
+ return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
160
+ }
161
+
162
+ export function isRecoverableRecruitImageCaptureError(error) {
163
+ const code = String(error?.code || "");
164
+ if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
165
+ if (isStaleRecruitNodeError(error)) return true;
166
+ return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
167
+ }
168
+
169
+ export function isRecoverableRecruitDetailError(error) {
170
+ return isStaleRecruitNodeError(error);
171
+ }
172
+
173
+ function compactRecoverableDetailError(error) {
174
+ return compactError(error, isStaleRecruitNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
175
+ }
176
+
177
+ function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
178
+ const resolved = String(basePath || "").trim();
179
+ if (!resolved) return [];
180
+ const parsed = path.parse(resolved);
181
+ const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
182
+ const files = [];
183
+ for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
184
+ const page = String(index + 1).padStart(2, "0");
185
+ const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
186
+ if (fs.existsSync(candidatePath)) files.push(candidatePath);
187
+ }
188
+ return files;
189
+ }
190
+
191
+ export function createRecoverableRecruitImageCaptureEvidence(error, {
192
+ elapsedMs = 0,
193
+ filePath = "",
194
+ extension = "jpg",
195
+ maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
196
+ } = {}) {
197
+ const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
198
+ return {
199
+ schema_version: 1,
200
+ ok: false,
201
+ source: "image-scroll-sequence",
202
+ elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
203
+ capture_count: filePaths.length,
204
+ screenshot_count: filePaths.length,
205
+ unique_screenshot_count: filePaths.length,
206
+ dropped_duplicate_count: 0,
207
+ total_byte_length: 0,
208
+ original_total_byte_length: 0,
209
+ llm_screenshot_count: 0,
210
+ llm_total_byte_length: 0,
211
+ llm_original_total_byte_length: 0,
212
+ llm_composition_error: null,
213
+ error_code: error?.code || (isStaleRecruitNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
214
+ error: error?.message || String(error || "Image capture failed"),
215
+ file_paths: filePaths,
216
+ llm_file_paths: []
217
+ };
218
+ }
219
+
220
+ function createImageCaptureFailureScreening(candidate, error) {
221
+ return {
222
+ status: "fail",
223
+ passed: false,
224
+ score: 0,
225
+ reasons: ["image_capture_failed"],
226
+ error: compactError(error, "IMAGE_CAPTURE_FAILED"),
227
+ candidate
228
+ };
229
+ }
230
+
231
+ function createRecoverableDetailFailureScreening(candidate, error) {
232
+ return {
233
+ status: "fail",
234
+ passed: false,
235
+ score: 0,
236
+ reasons: isStaleRecruitNodeError(error)
237
+ ? ["detail_open_failed", "stale_node"]
238
+ : ["detail_open_failed"],
239
+ error: compactRecoverableDetailError(error),
240
+ candidate
241
+ };
242
+ }
243
+
244
+ export function countRecruitResultStatuses(results = []) {
245
+ return {
246
+ processed: results.length,
247
+ screened: results.length,
248
+ detail_opened: results.filter((item) => item.detail).length,
249
+ passed: results.filter((item) => item.screening?.passed).length,
250
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
251
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
252
+ detail_open_failed: results.filter((item) => (
253
+ item.error?.code === "DETAIL_STALE_NODE"
254
+ || item.error?.code === "DETAIL_OPEN_FAILED"
255
+ )).length,
256
+ transient_recovered: results.filter((item) => (
257
+ item.error?.code === "DETAIL_STALE_NODE"
258
+ || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
259
+ || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
260
+ || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
261
+ )).length
262
+ };
263
+ }
264
+
140
265
  export async function runRecruitWorkflow({
141
266
  client,
142
267
  targetUrl = "",
@@ -197,6 +322,8 @@ export async function runRecruitWorkflow({
197
322
  const results = [];
198
323
  const refreshAttempts = [];
199
324
  let refreshRounds = 0;
325
+ let contextRecoveryAttempts = 0;
326
+ const candidateRecoveryCounts = new Map();
200
327
  let cardNodeIds = [];
201
328
  let listEndReason = "";
202
329
  const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
@@ -208,6 +335,115 @@ export async function runRecruitWorkflow({
208
335
  validateViewportPoint: true
209
336
  }));
210
337
 
338
+ function updateRecruitProgress(extra = {}) {
339
+ const counts = countRecruitResultStatuses(results);
340
+ const listSnapshot = compactInfiniteListState(listState);
341
+ runControl.updateProgress({
342
+ card_count: cardNodeIds.length,
343
+ target_count: limit,
344
+ ...counts,
345
+ screening_mode: normalizedScreeningMode,
346
+ unique_seen: listSnapshot.seen_count,
347
+ scroll_count: listSnapshot.scroll_count,
348
+ refresh_rounds: refreshRounds,
349
+ refresh_attempts: refreshAttempts.length,
350
+ context_recoveries: contextRecoveryAttempts,
351
+ list_end_reason: listEndReason || null,
352
+ viewport_checks: viewportGuard.getStats().checks,
353
+ viewport_recoveries: viewportGuard.getStats().recoveries,
354
+ ...extra
355
+ });
356
+ }
357
+
358
+ function checkpointInProgressCandidate({
359
+ index = results.length,
360
+ candidateKey = "",
361
+ cardNodeId = null,
362
+ detailStep = "",
363
+ error = null
364
+ } = {}) {
365
+ runControl.checkpoint({
366
+ in_progress_candidate: {
367
+ index,
368
+ key: candidateKey,
369
+ card_node_id: cardNodeId,
370
+ detail_step: detailStep || null,
371
+ counters: countRecruitResultStatuses(results),
372
+ error: compactError(error, "RECRUIT_IN_PROGRESS_ERROR")
373
+ },
374
+ candidate_list: compactInfiniteListState(listState)
375
+ });
376
+ }
377
+
378
+ async function recoverAndReapplyRecruitContext(reason = "context_recovery", error = null, {
379
+ forceRecentViewed = true
380
+ } = {}) {
381
+ await runControl.waitIfPaused();
382
+ runControl.throwIfCanceled();
383
+ const started = Date.now();
384
+ runControl.setPhase("recruit:recover-context");
385
+ contextRecoveryAttempts += 1;
386
+ const refreshResult = await refreshRecruitSearchAtEnd(client, {
387
+ searchParams: normalizedSearchParams,
388
+ requireCards: true,
389
+ searchTimeoutMs: cardTimeoutMs,
390
+ resetTimeoutMs,
391
+ resetSettleMs: refreshResetSettleMs,
392
+ cityOptionTimeoutMs,
393
+ forceRecentViewed
394
+ });
395
+ const compactRefresh = {
396
+ ...compactRefreshAttempt(refreshResult),
397
+ context_recovery: true,
398
+ recovery_reason: reason,
399
+ trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
400
+ elapsed_ms: Date.now() - started
401
+ };
402
+ refreshAttempts.push(compactRefresh);
403
+ runControl.checkpoint({
404
+ context_recovery: {
405
+ attempt: contextRecoveryAttempts,
406
+ reason,
407
+ trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
408
+ refresh: compactRefresh,
409
+ counters: countRecruitResultStatuses(results)
410
+ },
411
+ candidate_list: compactInfiniteListState(listState)
412
+ });
413
+ if (!refreshResult.ok) {
414
+ updateRecruitProgress({
415
+ refresh_method: refreshResult.method || null,
416
+ refresh_forced_recent_viewed: forceRecentViewed,
417
+ recovery_reason: reason
418
+ });
419
+ throw new Error(`Recruit context recovery failed after ${reason}: ${refreshResult.application?.reason || "refresh returned no cards"}`);
420
+ }
421
+ rootState = await getRecruitRoots(client);
422
+ rootState = await ensureRecruitViewport(rootState, "recover_after");
423
+ cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
424
+ timeoutMs: cardTimeoutMs,
425
+ intervalMs: 300
426
+ });
427
+ resetInfiniteListForRefreshRound(listState, {
428
+ reason: `context_recovery:${reason}`,
429
+ round: contextRecoveryAttempts,
430
+ method: refreshResult.method,
431
+ metadata: {
432
+ card_count: cardNodeIds.length,
433
+ forced_recent_viewed: forceRecentViewed,
434
+ counters: countRecruitResultStatuses(results)
435
+ }
436
+ });
437
+ listEndReason = "";
438
+ updateRecruitProgress({
439
+ card_count: cardNodeIds.length,
440
+ refresh_method: refreshResult.method || null,
441
+ refresh_forced_recent_viewed: forceRecentViewed,
442
+ recovery_reason: reason
443
+ });
444
+ return refreshResult;
445
+ }
446
+
211
447
  runControl.setPhase("recruit:cleanup");
212
448
  await closeRecruitDetail(client, { attemptsLimit: 2 });
213
449
 
@@ -264,21 +500,8 @@ export async function runRecruitWorkflow({
264
500
  throw new Error("No recruit/search candidate cards found for run service");
265
501
  }
266
502
 
267
- runControl.updateProgress({
268
- card_count: cardNodeIds.length,
269
- target_count: limit,
270
- processed: 0,
271
- screened: 0,
272
- detail_opened: 0,
273
- passed: 0,
274
- screening_mode: normalizedScreeningMode,
275
- llm_screened: 0,
276
- unique_seen: compactInfiniteListState(listState).seen_count,
277
- scroll_count: 0,
278
- refresh_rounds: 0,
279
- refresh_attempts: 0,
280
- viewport_checks: viewportGuard.getStats().checks,
281
- viewport_recoveries: viewportGuard.getStats().recoveries
503
+ updateRecruitProgress({
504
+ list_end_reason: null
282
505
  });
283
506
 
284
507
  while (results.length < limit) {
@@ -351,23 +574,11 @@ export async function runRecruitWorkflow({
351
574
  refresh_round: refreshRounds,
352
575
  refresh: compactRefresh
353
576
  });
354
- runControl.updateProgress({
577
+ updateRecruitProgress({
355
578
  card_count: refreshResult.card_count || cardNodeIds.length,
356
- target_count: limit,
357
- processed: results.length,
358
- screened: results.length,
359
- detail_opened: results.filter((item) => item.detail).length,
360
- passed: results.filter((item) => item.screening.passed).length,
361
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
362
- unique_seen: compactInfiniteListState(listState).seen_count,
363
- scroll_count: compactInfiniteListState(listState).scroll_count,
364
- refresh_rounds: refreshRounds,
365
- refresh_attempts: refreshAttempts.length,
366
579
  refresh_method: refreshResult.method || null,
367
580
  refresh_forced_recent_viewed: true,
368
- list_end_reason: listEndReason,
369
- viewport_checks: viewportGuard.getStats().checks,
370
- viewport_recoveries: viewportGuard.getStats().recoveries
581
+ list_end_reason: listEndReason
371
582
  });
372
583
  if (refreshResult.ok) {
373
584
  rootState = await getRecruitRoots(client);
@@ -399,128 +610,197 @@ export async function runRecruitWorkflow({
399
610
 
400
611
  let screeningCandidate = cardCandidate;
401
612
  let detailResult = null;
613
+ let recoverableDetailError = null;
614
+ let detailStep = "not_started";
402
615
  if (index < detailCountLimit) {
403
- await runControl.waitIfPaused();
404
- runControl.throwIfCanceled();
405
- runControl.setPhase("recruit:detail");
406
- rootState = await ensureRecruitViewport(rootState, "detail");
407
- networkRecorder.clear();
408
- const openedDetail = await openRecruitCardDetail(client, cardNodeId);
409
- addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
410
- addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
411
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
412
- const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
413
- waitForRecruitDetailNetworkEvents,
414
- networkRecorder,
415
- {
416
- waitPlan,
417
- minCount: 1,
418
- requireLoaded: true,
419
- intervalMs: 120
616
+ try {
617
+ await runControl.waitIfPaused();
618
+ runControl.throwIfCanceled();
619
+ runControl.setPhase("recruit:detail");
620
+ detailStep = "ensure_viewport";
621
+ rootState = await ensureRecruitViewport(rootState, "detail");
622
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
623
+ detailStep = "open_detail";
624
+ networkRecorder.clear();
625
+ const openedDetail = await openRecruitCardDetail(client, cardNodeId);
626
+ addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
627
+ addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
628
+ const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
629
+ detailStep = "wait_network";
630
+ const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
631
+ waitForRecruitDetailNetworkEvents,
632
+ networkRecorder,
633
+ {
634
+ waitPlan,
635
+ minCount: 1,
636
+ requireLoaded: true,
637
+ intervalMs: 120
638
+ }
639
+ ));
640
+ if (networkWait?.elapsed_ms != null) {
641
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
420
642
  }
421
- ));
422
- if (networkWait?.elapsed_ms != null) {
423
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
424
- }
425
- detailResult = await extractRecruitDetailCandidate(client, {
426
- cardCandidate,
427
- cardNodeId,
428
- detailState: openedDetail.detail_state,
429
- networkEvents: networkRecorder.events,
430
- targetUrl,
431
- closeDetail: false,
432
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
433
- networkParseIntervalMs: 250
434
- });
435
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
436
- const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
437
- let source = "network";
438
- let imageEvidence = null;
439
- let captureTarget = null;
440
- let captureTargetWait = null;
441
- if (parsedNetworkProfileCount > 0) {
442
- recordCvNetworkHit(cvAcquisitionState, {
443
- parsedNetworkProfileCount,
444
- waitResult: networkWait
643
+ detailStep = "extract_detail";
644
+ detailResult = await extractRecruitDetailCandidate(client, {
645
+ cardCandidate,
646
+ cardNodeId,
647
+ detailState: openedDetail.detail_state,
648
+ networkEvents: networkRecorder.events,
649
+ targetUrl,
650
+ closeDetail: false,
651
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
652
+ networkParseIntervalMs: 250
445
653
  });
446
- } else {
447
- captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
448
- domain: "recruit",
449
- timeoutMs: 6000,
450
- intervalMs: 250
451
- });
452
- captureTarget = captureTargetWait.target || null;
453
- const captureNodeId = captureTarget?.node_id || null;
454
- if (captureNodeId) {
455
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
456
- filePath: imageEvidenceFilePath({
654
+ addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
655
+ const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
656
+ let source = "network";
657
+ let imageEvidence = null;
658
+ let captureTarget = null;
659
+ let captureTargetWait = null;
660
+ if (parsedNetworkProfileCount > 0) {
661
+ recordCvNetworkHit(cvAcquisitionState, {
662
+ parsedNetworkProfileCount,
663
+ waitResult: networkWait
664
+ });
665
+ } else {
666
+ detailStep = "wait_capture_target";
667
+ captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
668
+ domain: "recruit",
669
+ timeoutMs: 6000,
670
+ intervalMs: 250
671
+ });
672
+ captureTarget = captureTargetWait.target || null;
673
+ const captureNodeId = captureTarget?.node_id || null;
674
+ if (captureNodeId) {
675
+ const imageEvidencePath = imageEvidenceFilePath({
457
676
  imageOutputDir,
458
677
  domain: "recruit",
459
678
  runId: runControl?.runId,
460
679
  index,
461
680
  extension: "jpg"
462
- }),
463
- format: "jpeg",
464
- quality: 72,
465
- optimize: true,
466
- resizeMaxWidth: 1100,
467
- captureViewport: false,
468
- padding: 0,
469
- maxScreenshots: maxImagePages,
470
- wheelDeltaY: imageWheelDeltaY,
471
- settleMs: 350,
472
- scrollMethod: "dom-anchor-fallback-input",
473
- stepTimeoutMs: 45000,
474
- totalTimeoutMs: 90000,
475
- duplicateStopCount: 1,
476
- skipDuplicateScreenshots: true,
477
- composeForLlm: true,
478
- llmPagesPerImage: 3,
479
- llmResizeMaxWidth: 1100,
480
- llmQuality: 72,
481
- metadata: {
482
- domain: "recruit",
483
- capture_mode: "scroll_sequence",
484
- acquisition_reason: "network_miss_image_fallback",
485
- run_candidate_index: index,
486
- candidate_key: candidateKey,
487
- capture_target: captureTarget,
488
- capture_target_wait: captureTargetWait
681
+ });
682
+ try {
683
+ detailStep = "capture_image";
684
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
685
+ filePath: imageEvidencePath,
686
+ format: "jpeg",
687
+ quality: 72,
688
+ optimize: true,
689
+ resizeMaxWidth: 1100,
690
+ captureViewport: false,
691
+ padding: 0,
692
+ maxScreenshots: maxImagePages,
693
+ wheelDeltaY: imageWheelDeltaY,
694
+ settleMs: 350,
695
+ scrollMethod: "dom-anchor-fallback-input",
696
+ stepTimeoutMs: 45000,
697
+ totalTimeoutMs: 90000,
698
+ duplicateStopCount: 1,
699
+ skipDuplicateScreenshots: true,
700
+ composeForLlm: true,
701
+ llmPagesPerImage: 3,
702
+ llmResizeMaxWidth: 1100,
703
+ llmQuality: 72,
704
+ metadata: {
705
+ domain: "recruit",
706
+ capture_mode: "scroll_sequence",
707
+ acquisition_reason: "network_miss_image_fallback",
708
+ run_candidate_index: index,
709
+ candidate_key: candidateKey,
710
+ capture_target: captureTarget,
711
+ capture_target_wait: captureTargetWait
712
+ }
713
+ }));
714
+ source = "image";
715
+ } catch (error) {
716
+ if (!isRecoverableRecruitImageCaptureError(error)) throw error;
717
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
718
+ if (recoveryCount < 1) {
719
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
720
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
721
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
722
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
723
+ await recoverAndReapplyRecruitContext(`image_capture:${detailStep}`, error, {
724
+ forceRecentViewed: true
725
+ });
726
+ continue;
727
+ }
728
+ imageEvidence = createRecoverableRecruitImageCaptureEvidence(error, {
729
+ elapsedMs: timings.screenshot_capture_ms,
730
+ filePath: imageEvidencePath,
731
+ extension: "jpg",
732
+ maxScreenshots: maxImagePages
733
+ });
734
+ source = "image_capture_failed";
489
735
  }
490
- }));
491
- source = "image";
492
- recordCvImageFallback(cvAcquisitionState, {
493
- parsedNetworkProfileCount,
494
- waitResult: networkWait,
495
- imageEvidence
496
- });
736
+ recordCvImageFallback(cvAcquisitionState, {
737
+ reason: source === "image_capture_failed"
738
+ ? "network_miss_image_capture_failed"
739
+ : "network_miss_image_fallback",
740
+ parsedNetworkProfileCount,
741
+ waitResult: networkWait,
742
+ imageEvidence
743
+ });
744
+ } else {
745
+ source = "missing_capture_node";
746
+ recordCvNetworkMiss(cvAcquisitionState, {
747
+ reason: "network_miss_no_capture_node",
748
+ parsedNetworkProfileCount,
749
+ waitResult: networkWait
750
+ });
751
+ }
752
+ }
753
+
754
+ detailResult.image_evidence = imageEvidence;
755
+ detailResult.cv_acquisition = {
756
+ source,
757
+ mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
758
+ wait_plan: waitPlan,
759
+ network_wait: networkWait,
760
+ parsed_network_profile_count: parsedNetworkProfileCount,
761
+ image_evidence: summarizeImageEvidence(imageEvidence),
762
+ capture_target: captureTarget || null,
763
+ capture_target_wait: captureTargetWait
764
+ };
765
+ screeningCandidate = detailResult.candidate;
766
+ if (closeDetail) {
767
+ detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
768
+ if (!detailResult.close_result?.closed) {
769
+ const closeError = createRecruitCloseFailureError(detailResult.close_result);
770
+ const recovery = await recoverAndReapplyRecruitContext("detail_close_failed", closeError, {
771
+ forceRecentViewed: true
772
+ });
773
+ detailResult.cv_acquisition = {
774
+ ...(detailResult.cv_acquisition || {}),
775
+ close_recovery: {
776
+ ok: Boolean(recovery.ok),
777
+ method: recovery.method || "",
778
+ forced_recent_viewed: Boolean(recovery.forced_recent_viewed),
779
+ card_count: recovery.card_count || 0
780
+ }
781
+ };
782
+ }
497
783
  } else {
498
- source = "missing_capture_node";
499
- recordCvNetworkMiss(cvAcquisitionState, {
500
- reason: "network_miss_no_capture_node",
501
- parsedNetworkProfileCount,
502
- waitResult: networkWait
784
+ detailResult.close_result = null;
785
+ }
786
+ } catch (error) {
787
+ if (!isRecoverableRecruitDetailError(error)) throw error;
788
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
789
+ if (recoveryCount < 1) {
790
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
791
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
792
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
793
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
794
+ await recoverAndReapplyRecruitContext(`detail:${detailStep}`, error, {
795
+ forceRecentViewed: true
503
796
  });
797
+ continue;
504
798
  }
799
+ recoverableDetailError = error;
800
+ detailResult = null;
801
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
802
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
505
803
  }
506
-
507
- let closeResult = null;
508
- if (closeDetail) {
509
- closeResult = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
510
- }
511
- detailResult.close_result = closeResult;
512
- detailResult.image_evidence = imageEvidence;
513
- detailResult.cv_acquisition = {
514
- source,
515
- mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
516
- wait_plan: waitPlan,
517
- network_wait: networkWait,
518
- parsed_network_profile_count: parsedNetworkProfileCount,
519
- image_evidence: summarizeImageEvidence(imageEvidence),
520
- capture_target: captureTarget || null,
521
- capture_target_wait: captureTargetWait
522
- };
523
- screeningCandidate = detailResult.candidate;
524
804
  }
525
805
 
526
806
  await runControl.waitIfPaused();
@@ -528,7 +808,9 @@ export async function runRecruitWorkflow({
528
808
  runControl.setPhase("recruit:screening");
529
809
  let llmResult = null;
530
810
  if (useLlmScreening) {
531
- if (!llmConfig) {
811
+ if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
812
+ llmResult = null;
813
+ } else if (!llmConfig) {
532
814
  llmResult = createMissingLlmConfigResult();
533
815
  } else {
534
816
  try {
@@ -550,9 +832,16 @@ export async function runRecruitWorkflow({
550
832
  }
551
833
  if (detailResult) detailResult.llm_result = llmResult;
552
834
  }
553
- const screening = useLlmScreening
554
- ? llmResultToScreening(llmResult, screeningCandidate)
555
- : screenCandidate(screeningCandidate, { criteria });
835
+ const screening = recoverableDetailError
836
+ ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
837
+ : detailResult?.image_evidence?.ok === false
838
+ ? createImageCaptureFailureScreening(screeningCandidate, {
839
+ code: detailResult.image_evidence.error_code,
840
+ message: detailResult.image_evidence.error
841
+ })
842
+ : useLlmScreening
843
+ ? llmResultToScreening(llmResult, screeningCandidate)
844
+ : screenCandidate(screeningCandidate, { criteria });
556
845
  timings.total_ms = Date.now() - candidateStarted;
557
846
  const compactResult = {
558
847
  index,
@@ -562,6 +851,14 @@ export async function runRecruitWorkflow({
562
851
  detail: compactDetail(detailResult),
563
852
  llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
564
853
  screening: compactScreening(screening),
854
+ error: recoverableDetailError
855
+ ? compactRecoverableDetailError(recoverableDetailError)
856
+ : detailResult?.image_evidence?.ok === false
857
+ ? compactError({
858
+ code: detailResult.image_evidence.error_code,
859
+ message: detailResult.image_evidence.error
860
+ }, "IMAGE_CAPTURE_FAILED")
861
+ : null,
565
862
  timings
566
863
  };
567
864
  results.push(compactResult);
@@ -572,21 +869,7 @@ export async function runRecruitWorkflow({
572
869
  }
573
870
  });
574
871
 
575
- runControl.updateProgress({
576
- card_count: cardNodeIds.length,
577
- target_count: limit,
578
- processed: results.length,
579
- screened: results.length,
580
- detail_opened: results.filter((item) => item.detail).length,
581
- passed: results.filter((item) => item.screening.passed).length,
582
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
583
- unique_seen: compactInfiniteListState(listState).seen_count,
584
- scroll_count: compactInfiniteListState(listState).scroll_count,
585
- refresh_rounds: refreshRounds,
586
- refresh_attempts: refreshAttempts.length,
587
- list_end_reason: listEndReason || null,
588
- viewport_checks: viewportGuard.getStats().checks,
589
- viewport_recoveries: viewportGuard.getStats().recoveries,
872
+ updateRecruitProgress({
590
873
  last_candidate_id: screeningCandidate.id || null,
591
874
  last_candidate_key: candidateKey,
592
875
  last_score: screening.score
@@ -603,7 +886,8 @@ export async function runRecruitWorkflow({
603
886
  passed: screening.passed,
604
887
  score: screening.score
605
888
  },
606
- llm_screening: compactScreeningLlmResult(llmResult)
889
+ llm_screening: compactScreeningLlmResult(llmResult),
890
+ error: compactResult.error
607
891
  }
608
892
  });
609
893
  addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
@@ -630,11 +914,8 @@ export async function runRecruitWorkflow({
630
914
  list_end_reason: listEndReason || null,
631
915
  refresh_rounds: refreshRounds,
632
916
  refresh_attempts: refreshAttempts,
633
- processed: results.length,
634
- screened: results.length,
635
- detail_opened: results.filter((item) => item.detail).length,
636
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
637
- passed: results.filter((item) => item.screening.passed).length,
917
+ context_recoveries: contextRecoveryAttempts,
918
+ ...countRecruitResultStatuses(results),
638
919
  results
639
920
  };
640
921
  }
@@ -722,7 +1003,11 @@ export function createRecruitRunService({
722
1003
  screened: 0,
723
1004
  detail_opened: 0,
724
1005
  llm_screened: 0,
725
- passed: 0
1006
+ passed: 0,
1007
+ image_capture_failed: 0,
1008
+ detail_open_failed: 0,
1009
+ transient_recovered: 0,
1010
+ context_recoveries: 0
726
1011
  },
727
1012
  checkpoint: {},
728
1013
  task: (runControl) => workflow({