@reconcrap/boss-recommend-mcp 2.1.24 → 2.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
3
5
  import { createRunLifecycleManager } from "../../core/run/index.js";
4
6
  import {
5
7
  addTiming,
@@ -46,7 +48,8 @@ import {
46
48
  resetInfiniteListForRefreshRound,
47
49
  resolveInfiniteListFallbackPoint
48
50
  } from "../../core/infinite-list/index.js";
49
- import { createViewportRunGuard } from "../../core/self-heal/index.js";
51
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
52
+ import { createChatActionJournal } from "../chat/action-journal.js";
50
53
  import {
51
54
  callScreeningLlm,
52
55
  compactScreeningLlmResult,
@@ -57,14 +60,20 @@ import {
57
60
  screenCandidate
58
61
  } from "../../core/screening/index.js";
59
62
  import {
60
- closeRecommendBlockingPanels,
61
- closeRecommendAvatarPreview,
62
- closeRecommendDetail,
63
- createRecommendDetailNetworkRecorder,
64
- extractRecommendDetailCandidate,
65
- isRecommendDetailOpenMissError,
66
- isStaleRecommendNodeError,
63
+ closeRecommendBlockingPanels,
64
+ closeRecommendAvatarPreview,
65
+ closeRecommendDetail,
66
+ compactRecommendDetailCandidateBinding,
67
+ createRecommendDetailCandidateBindingError,
68
+ createRecommendDetailNetworkRecorder,
69
+ extractRecommendDetailCandidate,
70
+ isRecommendDetailCandidateBindingError,
71
+ isRecommendDetailOpenMissError,
72
+ isRecommendPreClickStaleNoActionError,
73
+ isStaleRecommendNodeError,
67
74
  openRecommendCardDetailWithFreshRetry,
75
+ verifyExactCardClickToNewResumeRootCausality,
76
+ verifyRecommendDetailCandidateBinding,
68
77
  waitForRecommendDetail,
69
78
  waitForRecommendDetailNetworkEvents
70
79
  } from "./detail.js";
@@ -74,12 +83,15 @@ import {
74
83
  } from "./cards.js";
75
84
  import { selectAndConfirmFirstSafeFilter } from "./filters.js";
76
85
  import { ensureRecommendCurrentCityOnly } from "./location.js";
77
- import {
78
- applyRecommendFilterEnvelopeStages,
79
- buildRecommendFilterSelectionOptions,
80
- refreshRecommendListAtEnd
81
- } from "./refresh.js";
82
- import { selectRecommendJob } from "./jobs.js";
86
+ import {
87
+ applyRecommendFilterEnvelopeStages,
88
+ buildRecommendFilterSelectionOptions,
89
+ inspectRecommendFilteredEmptyState,
90
+ isVerifiedRecommendFilterApplication,
91
+ isVerifiedRecommendRefreshExhaustion,
92
+ refreshRecommendListAtEnd,
93
+ selectRecommendJobWithRootRefresh
94
+ } from "./refresh.js";
83
95
  import {
84
96
  normalizeRecommendPageScope,
85
97
  selectRecommendPageScope
@@ -93,12 +105,30 @@ import {
93
105
  RECOMMEND_TARGET_URL
94
106
  } from "./constants.js";
95
107
  import {
96
- clickRecommendActionControl,
97
- normalizeRecommendPostAction,
98
- resolveRecommendPostAction,
99
- waitForRecommendDetailActionControls
100
- } from "./actions.js";
101
- import { getRecommendRoots } from "./roots.js";
108
+ clickRecommendActionControl,
109
+ normalizeRecommendPostAction,
110
+ resolveRecommendPostAction,
111
+ verifyRecommendActionControlIdentity,
112
+ waitForRecommendDetailActionControls
113
+ } from "./actions.js";
114
+ import { getRecommendRoots } from "./roots.js";
115
+
116
+ const RECOMMEND_GREETING_ACTION_FINGERPRINT = "boss-recommend-greet-action-v1";
117
+
118
+ function defaultRecommendActionJournalDir() {
119
+ const configuredHome = String(process.env.BOSS_RECOMMEND_HOME || "").trim();
120
+ const root = configuredHome
121
+ ? path.resolve(configuredHome)
122
+ : path.join(os.homedir(), ".boss-recommend-mcp");
123
+ return path.join(root, "recommend-action-journal");
124
+ }
125
+
126
+ export function createRecommendGreetingActionJournal(options = {}) {
127
+ return createChatActionJournal({
128
+ baseDir: options.baseDir || defaultRecommendActionJournalDir(),
129
+ now: options.now
130
+ });
131
+ }
102
132
 
103
133
  const RECOMMEND_DEBUG_BOUNDARY_MODES = Object.freeze({
104
134
  list_end: "debug_force_list_end_after_processed",
@@ -463,59 +493,24 @@ export async function acquireRecommendListReadWithStaleRecovery({
463
493
  }
464
494
  }
465
495
 
466
- export async function recoverRecommendListReadStaleContext({
467
- staleAttempt = 1,
468
- listState,
469
- rootReacquire,
470
- contextReapply
471
- } = {}) {
472
- if (typeof rootReacquire !== "function") {
473
- throw new Error("recoverRecommendListReadStaleContext requires rootReacquire");
474
- }
475
- if (typeof contextReapply !== "function") {
476
- throw new Error("recoverRecommendListReadStaleContext requires contextReapply");
477
- }
478
- const processedKeys = new Set(listState?.processed_keys || []);
479
- try {
480
- if (Number(staleAttempt) <= 1) {
481
- try {
482
- const rootResult = await rootReacquire();
483
- if (Number(rootResult?.card_count) <= 0) {
484
- const noCardsError = new Error("Recommend list root reacquire returned no candidate cards");
485
- noCardsError.code = "RECOMMEND_LIST_ROOT_REACQUIRE_NO_CARDS";
486
- throw noCardsError;
487
- }
488
- resetInfiniteListForRefreshRound(listState, {
489
- reason: "list_read_stale_node_root_reacquire",
490
- round: 1,
491
- method: "root_reacquire",
492
- metadata: {
493
- card_count: Number(rootResult?.card_count) || 0,
494
- processed_count: processedKeys.size
495
- }
496
- });
497
- return {
498
- recovery_mode: "root_reacquire",
499
- root_reacquire: rootResult || null
500
- };
501
- } catch (rootReacquireError) {
502
- const contextResult = await contextReapply({ rootReacquireError });
503
- return {
504
- recovery_mode: "context_reapply",
505
- escalated_from: "root_reacquire",
506
- root_reacquire_error: compactListReadStaleDiagnostic(rootReacquireError, {
507
- attempt: 1
508
- }),
509
- context_reapply: contextResult || null
510
- };
511
- }
512
- }
513
- const contextResult = await contextReapply({ rootReacquireError: null });
514
- return {
515
- recovery_mode: "context_reapply",
516
- escalated_from: "repeated_stale",
517
- context_reapply: contextResult || null
518
- };
496
+ export async function recoverRecommendListReadStaleContext({
497
+ staleAttempt = 1,
498
+ listState,
499
+ contextReapply
500
+ } = {}) {
501
+ if (typeof contextReapply !== "function") {
502
+ throw new Error("recoverRecommendListReadStaleContext requires contextReapply");
503
+ }
504
+ const processedKeys = new Set(listState?.processed_keys || []);
505
+ try {
506
+ const contextResult = await contextReapply({ rootReacquireError: null });
507
+ return {
508
+ recovery_mode: "context_reapply",
509
+ escalated_from: Number(staleAttempt) <= 1
510
+ ? "root_only_recovery_disallowed"
511
+ : "repeated_stale",
512
+ context_reapply: contextResult || null
513
+ };
519
514
  } finally {
520
515
  for (const key of processedKeys) listState?.processed_keys?.add(key);
521
516
  }
@@ -547,13 +542,13 @@ function normalizeFilter(filter = {}) {
547
542
  labels: normalizeLabels(filter.labels || filter.filterLabels || []),
548
543
  selectAllLabels: Boolean(filter.selectAllLabels),
549
544
  allowUnlimited: filter.allowUnlimited === true,
550
- verifySticky: filter.verifySticky === true,
545
+ verifySticky: true,
551
546
  filterGroups: filterGroups.map((group) => ({
552
547
  group: String(group?.group || ""),
553
548
  labels: normalizeLabels(group?.labels || group?.filterLabels || []),
554
549
  selectAllLabels: group?.selectAllLabels !== false,
555
550
  allowUnlimited: group?.allowUnlimited === true,
556
- verifySticky: group?.verifySticky === true
551
+ verifySticky: true
557
552
  })).filter((group) => group.group || group.labels.length)
558
553
  };
559
554
  }
@@ -718,24 +713,67 @@ function compactScreening(screening) {
718
713
  };
719
714
  }
720
715
 
721
- function compactCandidate(candidate) {
716
+ function compactCandidate(candidate) {
722
717
  return {
723
718
  id: candidate?.id || null,
724
719
  identity: candidate?.identity || {},
725
720
  text_length: candidate?.text?.raw?.length || 0,
726
721
  tag_count: candidate?.tags?.length || 0
727
- };
728
- }
729
-
730
- function compactDetail(detailResult) {
722
+ };
723
+ }
724
+
725
+ function normalizeRecommendScreeningIdentityValue(value = "") {
726
+ return String(value || "").replace(/\s+/g, " ").trim().normalize("NFKC");
727
+ }
728
+
729
+ export function assertRecommendScreeningCandidateMatchesCard({
730
+ cardCandidate = null,
731
+ screeningCandidate = null,
732
+ stage = "before_screening"
733
+ } = {}) {
734
+ const expectedCandidateId = normalizeRecommendScreeningIdentityValue(cardCandidate?.id);
735
+ const actualCandidateId = normalizeRecommendScreeningIdentityValue(screeningCandidate?.id);
736
+ const expectedName = normalizeRecommendScreeningIdentityValue(cardCandidate?.identity?.name);
737
+ const actualName = normalizeRecommendScreeningIdentityValue(screeningCandidate?.identity?.name);
738
+ const diagnostic = {
739
+ schema_version: 1,
740
+ verified: Boolean(
741
+ expectedCandidateId
742
+ && actualCandidateId === expectedCandidateId
743
+ && expectedName
744
+ && actualName === expectedName
745
+ ),
746
+ stage,
747
+ expected_candidate_id: expectedCandidateId || null,
748
+ actual_candidate_id: actualCandidateId || null,
749
+ exact_candidate_id: Boolean(expectedCandidateId && actualCandidateId === expectedCandidateId),
750
+ expected_name: expectedName || null,
751
+ actual_name: actualName || null,
752
+ exact_name: Boolean(expectedName && actualName === expectedName)
753
+ };
754
+ if (diagnostic.verified) return diagnostic;
755
+
756
+ const error = new Error(
757
+ `RECOMMEND_SCREENING_CANDIDATE_IDENTITY_MISMATCH: stage=${stage}; expected=${expectedCandidateId || "missing"}/${expectedName || "missing"}; actual=${actualCandidateId || "missing"}/${actualName || "missing"}`
758
+ );
759
+ error.code = "RECOMMEND_SCREENING_CANDIDATE_IDENTITY_MISMATCH";
760
+ error.phase = "recommend:screening-candidate-identity";
761
+ error.screening_candidate_identity = diagnostic;
762
+ throw error;
763
+ }
764
+
765
+ function compactDetail(detailResult) {
731
766
  if (!detailResult) return null;
732
767
  return {
733
768
  popup_text_length: detailResult.detail?.popup_text?.length || 0,
734
769
  resume_text_length: detailResult.detail?.resume_text?.length || 0,
735
- network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
736
- parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
737
- cv_acquisition: detailResult.cv_acquisition || null,
738
- colleague_contact: detailResult.colleague_contact || null,
770
+ network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
771
+ parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
772
+ network_profile_binding: detailResult.network_profile_binding || null,
773
+ screening_candidate_identity: detailResult.screening_candidate_identity || null,
774
+ cv_acquisition: detailResult.cv_acquisition || null,
775
+ candidate_binding: compactRecommendDetailCandidateBinding(detailResult.candidate_binding),
776
+ colleague_contact: detailResult.colleague_contact || null,
739
777
  image_evidence: summarizeImageEvidence(detailResult.image_evidence),
740
778
  llm_screening: compactScreeningLlmResult(detailResult.llm_result),
741
779
  close_result: detailResult.close_result
@@ -753,26 +791,348 @@ function createMissingLlmConfigResult() {
753
791
  return createFailedLlmScreeningResult(new Error("LLM screening config is required for production recommend runs"));
754
792
  }
755
793
 
756
- function compactActionDiscovery(discovery) {
794
+ function compactActionDiscovery(discovery) {
757
795
  if (!discovery) return null;
758
796
  return {
759
797
  elapsed_ms: discovery.elapsed_ms,
760
798
  timed_out: Boolean(discovery.timed_out),
761
799
  detail_root_count: discovery.detail_root_count || 0,
762
800
  summary: discovery.summary || null
763
- };
764
- }
765
-
766
- async function runRecommendPostAction({
767
- client,
768
- screening,
769
- actionDiscovery,
770
- postAction = "none",
771
- greetCount = 0,
772
- maxGreetCount = null,
773
- executePostAction = true,
774
- afterClickDelayMs = 900
775
- } = {}) {
801
+ };
802
+ }
803
+
804
+ export async function selectAndVerifyInitialRecommendJob(client, rootState, {
805
+ jobLabel = "",
806
+ settleMs = 6000,
807
+ dropdownTimeoutMs = Math.max(8000, settleMs),
808
+ totalTimeoutMs = Math.max(30000, dropdownTimeoutMs + settleMs + 4000),
809
+ retryDelayMs = 1000,
810
+ selectWithRootRefresh = selectRecommendJobWithRootRefresh
811
+ } = {}) {
812
+ if (!jobLabel) {
813
+ throw new Error("selectAndVerifyInitialRecommendJob requires a requested job label");
814
+ }
815
+ if (typeof selectWithRootRefresh !== "function") {
816
+ throw new Error("selectAndVerifyInitialRecommendJob requires selectWithRootRefresh");
817
+ }
818
+ const result = await selectWithRootRefresh(client, rootState, {
819
+ jobLabel,
820
+ settleMs,
821
+ dropdownTimeoutMs,
822
+ totalTimeoutMs,
823
+ retryDelayMs
824
+ });
825
+ const selection = result?.job_selection || null;
826
+ const sticky = selection?.sticky_verification || null;
827
+ if (
828
+ selection?.selected !== true
829
+ || sticky?.verified !== true
830
+ || sticky?.menu_close?.ok === false
831
+ ) {
832
+ const error = new Error(
833
+ `Requested recommend job was not independently sticky-verified: requested=${jobLabel}; current=${sticky?.current_label_without_salary || sticky?.current_label || "unknown"}`
834
+ );
835
+ error.code = "RECOMMEND_INITIAL_JOB_STICKY_VERIFICATION_FAILED";
836
+ error.job_selection = compactJobSelection(selection);
837
+ throw error;
838
+ }
839
+ return result;
840
+ }
841
+
842
+ function compactRecommendActionJournalRecord(record = null, filePath = null) {
843
+ if (!record) return null;
844
+ const history = Array.isArray(record.history) ? record.history : [];
845
+ const last = history[history.length - 1] || null;
846
+ return {
847
+ domain: "recommend",
848
+ schema_version: record.schema_version || 1,
849
+ action_key: record.action_key || null,
850
+ candidate_id: record.candidate_id || null,
851
+ state: record.state || null,
852
+ revision: Number.isSafeInteger(Number(record.revision)) ? Number(record.revision) : history.length,
853
+ unknown_origin: record.state === "outcome_unknown" ? last?.from_state || null : null,
854
+ evidence: record.evidence || {},
855
+ created_at: record.created_at || null,
856
+ updated_at: record.updated_at || null,
857
+ first_run_id: record.first_run_id || null,
858
+ last_run_id: record.last_run_id || null,
859
+ file_path: filePath || null
860
+ };
861
+ }
862
+
863
+ function recommendGreetingUnknownOrigin(record = null) {
864
+ const history = Array.isArray(record?.history) ? record.history : [];
865
+ const last = history[history.length - 1];
866
+ return record?.state === "outcome_unknown" ? last?.from_state || null : null;
867
+ }
868
+
869
+ function latestRecommendGreetingEvidence(record = null) {
870
+ const history = Array.isArray(record?.history) ? record.history : [];
871
+ return history[history.length - 1]?.evidence || {};
872
+ }
873
+
874
+ export function isVerifiedRecommendActionJournalScope(scope = "") {
875
+ return /^boss-recommend-profile-v2:127\.0\.0\.1:profile-sha256:[0-9a-f]{64}$/u.test(
876
+ String(scope || "").trim()
877
+ );
878
+ }
879
+
880
+ function compactRecommendControlEvidence(control = null) {
881
+ const rect = control?.rect || {};
882
+ const center = control?.center || {};
883
+ return {
884
+ control_node_id: Number.isInteger(Number(control?.node_id)) ? Number(control.node_id) : null,
885
+ control_backend_node_id: Number.isInteger(Number(control?.backend_node_id))
886
+ ? Number(control.backend_node_id)
887
+ : null,
888
+ control_root: control?.root || null,
889
+ control_label: String(control?.label || "").trim() || null,
890
+ control_root_node_id: Number.isInteger(Number(control?.root_node_id))
891
+ ? Number(control.root_node_id)
892
+ : null,
893
+ control_root_backend_node_id: Number.isInteger(Number(control?.root_backend_node_id))
894
+ ? Number(control.root_backend_node_id)
895
+ : null,
896
+ control_center_x: Number.isFinite(Number(center.x)) ? Number(center.x) : null,
897
+ control_center_y: Number.isFinite(Number(center.y)) ? Number(center.y) : null,
898
+ control_rect_x: Number.isFinite(Number(rect.x)) ? Number(rect.x) : null,
899
+ control_rect_y: Number.isFinite(Number(rect.y)) ? Number(rect.y) : null,
900
+ control_rect_width: Number.isFinite(Number(rect.width)) ? Number(rect.width) : null,
901
+ control_rect_height: Number.isFinite(Number(rect.height)) ? Number(rect.height) : null
902
+ };
903
+ }
904
+
905
+ function compactRecommendPostActionError(error) {
906
+ if (!error) return null;
907
+ return {
908
+ code: error.code || "RECOMMEND_GREETING_CLICK_FAILED",
909
+ message: String(error.message || error).slice(0, 500),
910
+ phase: error.phase || null,
911
+ cdp_method: error.cdp_method || null,
912
+ cdp_at: error.cdp_at || null,
913
+ cdp_node_id: Number.isInteger(error.cdp_node_id) ? error.cdp_node_id : null,
914
+ cdp_outcome_unknown: error.cdp_outcome_unknown === true,
915
+ recommend_pre_input_aborted: error.recommend_pre_input_aborted === true,
916
+ recommend_input_dispatched: error.recommend_input_dispatched === true,
917
+ recommend_action_control_hit_test: error.recommend_action_control_hit_test || null
918
+ };
919
+ }
920
+
921
+ export function checkpointRecommendPostActionStopResult(runControl, checkpoint = {}, {
922
+ candidateResult = null,
923
+ candidateId = "",
924
+ actionState = null,
925
+ resultIndex = null
926
+ } = {}) {
927
+ try {
928
+ return runControl.checkpointCritical(checkpoint);
929
+ } catch (error) {
930
+ runControl.checkpoint({
931
+ ...checkpoint,
932
+ preserve_detail_on_terminal: true,
933
+ action_result_critical_persisted: false,
934
+ terminal_preservation: {
935
+ required: true,
936
+ reason: "post_action_candidate_result_critical_persistence_failed",
937
+ candidate_id: String(candidateId || "").trim() || null,
938
+ action_state: actionState || null,
939
+ result_index: Number.isInteger(resultIndex) ? resultIndex : null,
940
+ error: compactRecommendPostActionError(error)
941
+ }
942
+ });
943
+ error.code = error.code || "RECOMMEND_POST_ACTION_RESULT_PERSISTENCE_FAILED";
944
+ error.phase = error.phase || "recommend:post-action-result-persistence";
945
+ error.recommend_preserve_detail_on_terminal = true;
946
+ error.recommend_candidate_result = candidateResult;
947
+ throw error;
948
+ }
949
+ }
950
+
951
+ export function isVerifiedRecommendPostActionCandidateBinding(binding = null, candidateId = "") {
952
+ const expectedCandidateId = String(candidateId || "").trim();
953
+ const root = binding?.detail?.root;
954
+ const rootNodeId = Number(root?.node_id);
955
+ const rootBackendNodeId = Number(root?.backend_node_id);
956
+ const exactRoot = Boolean(
957
+ root?.stable === true
958
+ && root?.visible === true
959
+ && root?.canonical === true
960
+ && root?.action_root === true
961
+ && Number.isInteger(rootNodeId)
962
+ && rootNodeId > 0
963
+ && Number.isInteger(rootBackendNodeId)
964
+ && rootBackendNodeId > 0
965
+ );
966
+ const firstScopes = Array.isArray(binding?.detail?.first?.scopes)
967
+ ? binding.detail.first.scopes
968
+ : [];
969
+ const secondScopes = Array.isArray(binding?.detail?.second?.scopes)
970
+ ? binding.detail.second.scopes
971
+ : [];
972
+ const sampleMatchesRoot = (scopes) => {
973
+ const visiblePopups = scopes.filter((scope) => (
974
+ scope?.source === "popup" && scope?.visible === true
975
+ ));
976
+ const visibleIframes = scopes.filter((scope) => (
977
+ scope?.source === "resume_iframe" && scope?.visible === true
978
+ ));
979
+ if (visiblePopups.length > 1 || visibleIframes.length > 1) return false;
980
+ const canonicalScopes = root?.source === "popup" ? visiblePopups : visibleIframes;
981
+ if (
982
+ canonicalScopes.length !== 1
983
+ || Number(canonicalScopes[0]?.node_id) !== rootNodeId
984
+ || Number(canonicalScopes[0]?.backend_node_id) !== rootBackendNodeId
985
+ ) {
986
+ return false;
987
+ }
988
+ if (root?.source === "popup") {
989
+ const contained = root?.contained_iframe || null;
990
+ if (!contained) return visibleIframes.length === 0;
991
+ if (visibleIframes.length !== 1) return false;
992
+ const iframe = visibleIframes[0];
993
+ return Boolean(
994
+ contained?.stable === true
995
+ && contained?.contained === true
996
+ && iframe?.container_verified === true
997
+ && Number(iframe?.node_id) === Number(contained?.node_id)
998
+ && Number(iframe?.backend_node_id) === Number(contained?.backend_node_id)
999
+ && Number(iframe?.iframe_node_id) === Number(contained?.iframe_node_id)
1000
+ && Number(iframe?.iframe_backend_node_id) === Number(contained?.iframe_backend_node_id)
1001
+ && Number(iframe?.container_node_id) === rootNodeId
1002
+ && Number(iframe?.container_backend_node_id) === rootBackendNodeId
1003
+ && iframe?.selector === contained?.selector
1004
+ && JSON.stringify(iframe?.ancestry?.path || [])
1005
+ === JSON.stringify(contained?.ancestry_path || [])
1006
+ );
1007
+ }
1008
+ return visiblePopups.length === 0 && visibleIframes.length === 1;
1009
+ };
1010
+ const exactStableRootSamples = Boolean(
1011
+ exactRoot
1012
+ && sampleMatchesRoot(firstScopes)
1013
+ && sampleMatchesRoot(secondScopes)
1014
+ );
1015
+ const method = binding?.method;
1016
+ const compactBeforeCard = binding?.card?.before;
1017
+ const provenanceBeforeCard = binding?.card?.pre_click_provenance?.card;
1018
+ const causalBeforeCard = compactBeforeCard && provenanceBeforeCard
1019
+ ? {
1020
+ ...compactBeforeCard,
1021
+ candidate_id: compactBeforeCard.candidate_id ?? provenanceBeforeCard.candidate_id,
1022
+ name: compactBeforeCard.name ?? provenanceBeforeCard.name
1023
+ }
1024
+ : compactBeforeCard || provenanceBeforeCard;
1025
+ const causalProof = method === "exact_card_click_and_new_resume_root"
1026
+ ? verifyExactCardClickToNewResumeRootCausality({
1027
+ cardNodeId: causalBeforeCard?.node_id,
1028
+ expectedCandidateId: binding?.expected_candidate_id,
1029
+ expectedName: binding?.expected_name,
1030
+ beforeCard: causalBeforeCard,
1031
+ afterCard: binding?.card?.after,
1032
+ cardPreClickProvenance: binding?.card?.pre_click_provenance,
1033
+ cardClickEvidence: binding?.card?.click_evidence,
1034
+ clickAttempts: binding?.card?.click_attempts,
1035
+ detailRoot: binding?.detail?.root,
1036
+ rootsBeforeWereCaptured: binding?.detail?.roots_before_capture?.captured === true,
1037
+ rootsBeforeCaptureComplete: binding?.detail?.roots_before_capture?.complete === true,
1038
+ newlyMounted: binding?.detail?.newly_mounted === true,
1039
+ rootMatchesExpected: binding?.detail?.root_matches_expected === true,
1040
+ hasCandidateIdEvidence: binding?.detail?.candidate_id_evidence_present === true,
1041
+ candidateIdProbeComplete: binding?.detail?.candidate_id_probe_complete === true
1042
+ })
1043
+ : null;
1044
+ const exactDetailIdentity = method === "exact_candidate_id_and_name"
1045
+ ? binding?.detail?.candidate_id_probe_complete === true
1046
+ && binding?.detail?.exact_candidate_id === true
1047
+ && binding?.detail?.exact_name === true
1048
+ : method === "exact_name_and_secondary_identity"
1049
+ ? binding?.detail?.candidate_id_probe_complete === true
1050
+ && binding?.detail?.candidate_id_evidence_present === false
1051
+ && binding?.detail?.exact_name === true
1052
+ && binding?.detail?.exact_secondary === true
1053
+ : method === "exact_card_click_and_new_resume_root"
1054
+ ? binding?.detail?.candidate_id_probe_complete === true
1055
+ && binding?.detail?.candidate_id_evidence_present === false
1056
+ && binding?.detail?.exact_name === false
1057
+ && binding?.card?.causal_proof?.verified === true
1058
+ && causalProof?.verified === true
1059
+ : false;
1060
+ return Boolean(
1061
+ expectedCandidateId
1062
+ && binding?.verified === true
1063
+ && binding?.stable === true
1064
+ && binding?.expected_candidate_id === expectedCandidateId
1065
+ && binding?.card?.stable === true
1066
+ && binding?.card?.candidate_id === expectedCandidateId
1067
+ && exactStableRootSamples
1068
+ && exactDetailIdentity
1069
+ );
1070
+ }
1071
+
1072
+ export function assertRecommendControlMatchesCandidateDetailRoot(
1073
+ control,
1074
+ binding,
1075
+ stage = "post_action"
1076
+ ) {
1077
+ const boundRoot = binding?.detail?.root;
1078
+ const boundRootNodeId = Number(boundRoot?.node_id);
1079
+ const boundRootBackendNodeId = Number(boundRoot?.backend_node_id);
1080
+ const controlRootNodeId = Number(control?.root_node_id);
1081
+ const controlRootBackendNodeId = Number(control?.root_backend_node_id);
1082
+ const exact = Boolean(
1083
+ Number.isInteger(boundRootNodeId)
1084
+ && boundRootNodeId > 0
1085
+ && Number.isInteger(boundRootBackendNodeId)
1086
+ && boundRootBackendNodeId > 0
1087
+ && boundRoot?.stable === true
1088
+ && boundRoot?.visible === true
1089
+ && boundRoot?.canonical === true
1090
+ && boundRoot?.action_root === true
1091
+ && Number.isInteger(controlRootNodeId)
1092
+ && controlRootNodeId > 0
1093
+ && Number.isInteger(controlRootBackendNodeId)
1094
+ && controlRootBackendNodeId > 0
1095
+ // DOM.getDocument may remap a frontend node id while describeNode still
1096
+ // proves the same immutable backend node. The action path re-describes
1097
+ // this control root and proves control ancestry again before every Input.
1098
+ && controlRootBackendNodeId === boundRootBackendNodeId
1099
+ );
1100
+ if (!exact) {
1101
+ const error = new Error(
1102
+ `RECOMMEND_ACTION_DETAIL_ROOT_MISMATCH: action control is not bound to the exact candidate detail root at ${stage}`
1103
+ );
1104
+ error.code = "RECOMMEND_ACTION_DETAIL_ROOT_MISMATCH";
1105
+ error.phase = "recommend:post-action-binding";
1106
+ error.expected_root_node_id = boundRootNodeId || null;
1107
+ error.expected_root_backend_node_id = boundRootBackendNodeId || null;
1108
+ error.observed_root_node_id = Number.isInteger(controlRootNodeId) ? controlRootNodeId : null;
1109
+ error.observed_root_backend_node_id = Number.isInteger(controlRootBackendNodeId)
1110
+ ? controlRootBackendNodeId
1111
+ : null;
1112
+ error.retryable = false;
1113
+ throw error;
1114
+ }
1115
+ return true;
1116
+ }
1117
+
1118
+ export async function runRecommendPostAction({
1119
+ client,
1120
+ screening,
1121
+ actionDiscovery,
1122
+ postAction = "none",
1123
+ greetCount = 0,
1124
+ maxGreetCount = null,
1125
+ executePostAction = true,
1126
+ afterClickDelayMs = 900,
1127
+ candidateId = "",
1128
+ actionJournal = null,
1129
+ actionJournalScope = "boss-recommend:default",
1130
+ reverifyActionJournalScope = null,
1131
+ runId = "",
1132
+ candidateBinding = null,
1133
+ reverifyCandidateBinding = null,
1134
+ checkpointCritical = null
1135
+ } = {}) {
776
1136
  const plan = resolveRecommendPostAction({
777
1137
  postAction,
778
1138
  greetCount,
@@ -806,10 +1166,10 @@ async function runRecommendPostAction({
806
1166
  }
807
1167
  result.control = control;
808
1168
 
809
- if (plan.effective === "greet" && control.continue_chat) {
810
- result.reason = "already_connected_continue_chat";
811
- result.already_connected = true;
812
- return result;
1169
+ if (plan.effective === "greet" && control.continue_chat && !executePostAction) {
1170
+ result.reason = "already_connected_continue_chat";
1171
+ result.already_connected = true;
1172
+ return result;
813
1173
  }
814
1174
  if (plan.effective === "greet" && control.greet_quota?.exhausted) {
815
1175
  result.reason = "greet_credits_exhausted";
@@ -817,7 +1177,7 @@ async function runRecommendPostAction({
817
1177
  result.stop_run = true;
818
1178
  return result;
819
1179
  }
820
- if (plan.effective === "greet" && control.available === false) {
1180
+ if (plan.effective === "greet" && control.available === false && !control.continue_chat) {
821
1181
  result.reason = "greet_control_not_available";
822
1182
  return result;
823
1183
  }
@@ -825,70 +1185,583 @@ async function runRecommendPostAction({
825
1185
  result.reason = `${plan.effective}_control_disabled`;
826
1186
  return result;
827
1187
  }
828
- if (!executePostAction) {
829
- result.reason = "dry_run_post_action";
830
- result.would_click = true;
831
- return result;
832
- }
833
-
834
- result.action_attempted = true;
835
- result.control_before = control;
836
- let clickResult;
837
- try {
838
- clickResult = await clickRecommendActionControl(client, {
839
- ...control,
840
- kind: plan.effective
841
- });
842
- } catch (error) {
1188
+ if (!executePostAction) {
1189
+ result.reason = "dry_run_post_action";
1190
+ result.would_click = true;
1191
+ return result;
1192
+ }
1193
+
1194
+ const normalizedCandidateId = String(candidateId || "").trim();
1195
+ if (!normalizedCandidateId) {
1196
+ const error = new Error(
1197
+ "RECOMMEND_ACTION_CANDIDATE_ID_REQUIRED: refusing greeting without a stable Boss candidate ID"
1198
+ );
1199
+ error.code = "RECOMMEND_ACTION_CANDIDATE_ID_REQUIRED";
1200
+ error.retryable = false;
1201
+ throw error;
1202
+ }
1203
+ if (!isVerifiedRecommendPostActionCandidateBinding(candidateBinding, normalizedCandidateId)) {
1204
+ const error = createRecommendDetailCandidateBindingError(candidateBinding || {
1205
+ schema_version: 1,
1206
+ verified: false,
1207
+ reason: "post_action_candidate_binding_missing",
1208
+ expected_candidate_id: normalizedCandidateId
1209
+ });
1210
+ error.phase = "recommend:post-action-binding";
1211
+ throw error;
1212
+ }
1213
+ result.candidate_binding = compactRecommendDetailCandidateBinding(candidateBinding);
1214
+ assertRecommendControlMatchesCandidateDetailRoot(
1215
+ control,
1216
+ candidateBinding,
1217
+ "initial_control_binding"
1218
+ );
1219
+ if (!isVerifiedRecommendActionJournalScope(actionJournalScope)) {
1220
+ const error = new Error(
1221
+ "RECOMMEND_ACTION_SCOPE_UNVERIFIED: live greeting requires an exact canonical Chrome profile scope"
1222
+ );
1223
+ error.code = "RECOMMEND_ACTION_SCOPE_UNVERIFIED";
1224
+ error.retryable = false;
1225
+ throw error;
1226
+ }
1227
+ if (typeof reverifyCandidateBinding !== "function") {
1228
+ const error = new Error(
1229
+ "RECOMMEND_ACTION_CANDIDATE_REVERIFY_REQUIRED: live greeting requires fresh candidate binding"
1230
+ );
1231
+ error.code = "RECOMMEND_ACTION_CANDIDATE_REVERIFY_REQUIRED";
1232
+ error.retryable = false;
1233
+ throw error;
1234
+ }
1235
+ if (typeof reverifyActionJournalScope !== "function") {
1236
+ const error = new Error(
1237
+ "RECOMMEND_ACTION_SCOPE_REVERIFY_REQUIRED: live greeting requires fresh Chrome profile proof"
1238
+ );
1239
+ error.code = "RECOMMEND_ACTION_SCOPE_REVERIFY_REQUIRED";
1240
+ error.retryable = false;
1241
+ throw error;
1242
+ }
1243
+ if (!actionJournal || typeof actionJournal.read !== "function" || typeof actionJournal.transition !== "function") {
1244
+ const error = new Error(
1245
+ "RECOMMEND_ACTION_JOURNAL_REQUIRED: durable greeting action journal is unavailable"
1246
+ );
1247
+ error.code = "RECOMMEND_ACTION_JOURNAL_REQUIRED";
1248
+ error.retryable = false;
1249
+ throw error;
1250
+ }
1251
+ if (typeof checkpointCritical !== "function") {
1252
+ const error = new Error(
1253
+ "RECOMMEND_CRITICAL_CHECKPOINT_UNAVAILABLE: refusing greeting without required persistence"
1254
+ );
1255
+ error.code = "RECOMMEND_CRITICAL_CHECKPOINT_UNAVAILABLE";
1256
+ error.retryable = false;
1257
+ throw error;
1258
+ }
1259
+
1260
+ const requireFreshActionJournalScope = async (stage) => {
1261
+ const fresh = await reverifyActionJournalScope(stage);
1262
+ const freshScope = String(fresh?.scope || fresh || "").trim();
1263
+ if (!isVerifiedRecommendActionJournalScope(freshScope) || freshScope !== actionJournalScope) {
1264
+ const error = new Error(
1265
+ `RECOMMEND_ACTION_SCOPE_DRIFT: exact Chrome profile scope changed at ${stage}`
1266
+ );
1267
+ error.code = "RECOMMEND_ACTION_SCOPE_DRIFT";
1268
+ error.expected_scope = actionJournalScope;
1269
+ error.observed_scope = freshScope || null;
1270
+ error.retryable = false;
1271
+ throw error;
1272
+ }
1273
+ return freshScope;
1274
+ };
1275
+ const operationId = `${String(runId || "recommend-run").trim() || "recommend-run"}:${crypto.randomUUID()}`;
1276
+ const requireFreshCandidateBinding = async (stage, boundControl = control, {
1277
+ allowScroll = true,
1278
+ settleMs = 120
1279
+ } = {}) => {
1280
+ const normalizedSettleMs = allowScroll
1281
+ ? Math.max(0, Number(settleMs) || 0)
1282
+ : 0;
1283
+ const freshBinding = await reverifyCandidateBinding(stage, {
1284
+ allowScroll: allowScroll === true,
1285
+ settleMs: normalizedSettleMs
1286
+ });
1287
+ if (!isVerifiedRecommendPostActionCandidateBinding(freshBinding, normalizedCandidateId)) {
1288
+ const error = createRecommendDetailCandidateBindingError(freshBinding || {
1289
+ schema_version: 1,
1290
+ verified: false,
1291
+ reason: `post_action_candidate_binding_lost_${stage}`,
1292
+ expected_candidate_id: normalizedCandidateId
1293
+ });
1294
+ error.phase = "recommend:post-action-binding";
1295
+ throw error;
1296
+ }
1297
+ if (
1298
+ allowScroll === false
1299
+ && (
1300
+ freshBinding?.allow_scroll !== false
1301
+ || Number(freshBinding?.settle_ms) !== 0
1302
+ )
1303
+ ) {
1304
+ const error = createRecommendDetailCandidateBindingError({
1305
+ ...freshBinding,
1306
+ verified: false,
1307
+ reason: "non_scrolling_candidate_reproof_unavailable"
1308
+ });
1309
+ error.phase = "recommend:post-action-binding";
1310
+ throw error;
1311
+ }
1312
+ assertRecommendControlMatchesCandidateDetailRoot(boundControl, freshBinding, stage);
1313
+ result.candidate_binding = compactRecommendDetailCandidateBinding(freshBinding);
1314
+ return freshBinding;
1315
+ };
1316
+ await requireFreshActionJournalScope("before_journal_read");
1317
+ let journalRecord = actionJournal.read({
1318
+ scope: actionJournalScope,
1319
+ candidateId: normalizedCandidateId
1320
+ });
1321
+ let journalFilePath = typeof actionJournal.entryPath === "function"
1322
+ ? actionJournal.entryPath({ scope: actionJournalScope, candidateId: normalizedCandidateId })
1323
+ : null;
1324
+ const transitionAction = (state, evidence = {}, {
1325
+ requireChanged = false,
1326
+ recordIdempotent = false,
1327
+ expectedUpdatedAt = null,
1328
+ expectedRevision = null
1329
+ } = {}) => {
1330
+ const transitioned = actionJournal.transition({
1331
+ scope: actionJournalScope,
1332
+ candidateId: normalizedCandidateId,
1333
+ state,
1334
+ runId,
1335
+ greeting: RECOMMEND_GREETING_ACTION_FINGERPRINT,
1336
+ recordIdempotent,
1337
+ expectedUpdatedAt,
1338
+ expectedRevision,
1339
+ evidence: {
1340
+ action: "recommend_greet",
1341
+ active_candidate_id: normalizedCandidateId,
1342
+ operation_id: operationId,
1343
+ ...evidence
1344
+ }
1345
+ });
1346
+ if (requireChanged && transitioned?.changed !== true) {
1347
+ const error = new Error(
1348
+ "RECOMMEND_ACTION_IN_FLIGHT_NOT_OWNED: journal transition was idempotent or owned elsewhere"
1349
+ );
1350
+ error.code = "RECOMMEND_ACTION_IN_FLIGHT_NOT_OWNED";
1351
+ error.retryable = false;
1352
+ throw error;
1353
+ }
1354
+ journalRecord = transitioned.record;
1355
+ journalFilePath = transitioned.file_path || journalFilePath;
1356
+ const transaction = compactRecommendActionJournalRecord(journalRecord, journalFilePath);
1357
+ try {
1358
+ checkpointCritical({ action_transaction: transaction });
1359
+ } catch (error) {
1360
+ if (state === "greeting_send_in_flight" && transitioned?.changed === true) {
1361
+ error.recommend_pre_input_checkpoint_failed = true;
1362
+ error.recommend_owned_operation_id = operationId;
1363
+ throw error;
1364
+ }
1365
+ if (state === "greeting_confirmed" || state === "outcome_unknown") {
1366
+ result.action_transaction_checkpoint_error = compactRecommendPostActionError(error);
1367
+ result.preserve_detail_until_result_persisted = true;
1368
+ result.stop_run = true;
1369
+ } else {
1370
+ throw error;
1371
+ }
1372
+ }
1373
+ result.action_transaction = transaction;
1374
+ return journalRecord;
1375
+ };
1376
+ const preservePostInputJournalFailure = ({
1377
+ error,
1378
+ desiredState,
1379
+ reason,
1380
+ triggeringError = null,
1381
+ inputDispatched = true
1382
+ }) => {
1383
+ const journalError = compactRecommendPostActionError(error);
1384
+ const triggeringActionError = compactRecommendPostActionError(triggeringError);
1385
+ const lastDurableTransaction = compactRecommendActionJournalRecord(
1386
+ journalRecord,
1387
+ journalFilePath
1388
+ );
1389
+ const terminalPreservation = {
1390
+ required: true,
1391
+ reason: "post_input_action_journal_persistence_failed",
1392
+ candidate_id: normalizedCandidateId,
1393
+ operation_id: operationId,
1394
+ action: "recommend_greet",
1395
+ input_dispatched: inputDispatched === true,
1396
+ desired_action_state: desiredState || null,
1397
+ last_durable_action_state: lastDurableTransaction?.state || null,
1398
+ candidate_binding: result.candidate_binding
1399
+ || compactRecommendDetailCandidateBinding(candidateBinding),
1400
+ control: compactRecommendControlEvidence(result.control_confirmation || control),
1401
+ click_result: result.click_result || null,
1402
+ journal_error: journalError,
1403
+ triggering_error: triggeringActionError
1404
+ };
1405
+ result.reason = reason || "greet_post_input_journal_persistence_failed";
1406
+ result.counted_as_greet = false;
1407
+ result.outcome_unknown = true;
1408
+ result.stop_run = true;
1409
+ result.preserve_detail_on_terminal = true;
1410
+ result.preserve_detail_until_result_persisted = true;
1411
+ result.post_input_journal_persistence_failed = true;
1412
+ result.action_input_dispatched = inputDispatched === true;
1413
+ result.action_transaction = lastDurableTransaction;
1414
+ result.terminal_preservation = terminalPreservation;
1415
+ result.error = journalError;
1416
+ if (triggeringActionError) result.triggering_error = triggeringActionError;
1417
+ try {
1418
+ checkpointCritical({
1419
+ preserve_detail_on_terminal: true,
1420
+ action_result_critical_persisted: false,
1421
+ terminal_preservation: terminalPreservation,
1422
+ action_transaction: lastDurableTransaction
1423
+ });
1424
+ result.terminal_preservation_checkpointed = true;
1425
+ } catch (checkpointError) {
1426
+ result.terminal_preservation_checkpointed = false;
1427
+ result.terminal_preservation_checkpoint_error = compactRecommendPostActionError(
1428
+ checkpointError
1429
+ );
1430
+ }
1431
+ return result;
1432
+ };
1433
+
1434
+ if (control.continue_chat) {
1435
+ await requireFreshCandidateBinding("before_continue_chat_reconciliation", control);
1436
+ const reconciledControl = await verifyRecommendActionControlIdentity(client, control, {
1437
+ requireContinueChat: true,
1438
+ requireGeometry: true
1439
+ });
1440
+ await requireFreshCandidateBinding("after_continue_chat_control_verification", reconciledControl);
1441
+ result.control_reconciliation = reconciledControl;
1442
+ const unknownOrigin = recommendGreetingUnknownOrigin(journalRecord);
1443
+ if (
1444
+ journalRecord?.state === "greeting_send_in_flight"
1445
+ || (journalRecord?.state === "outcome_unknown" && unknownOrigin === "greeting_send_in_flight")
1446
+ ) {
1447
+ transitionAction("greeting_confirmed", {
1448
+ reason: "reconciled_from_exact_continue_chat_control"
1449
+ });
1450
+ }
1451
+ result.reason = journalRecord?.state === "greeting_confirmed"
1452
+ ? "greeting_confirmed_by_durable_journal"
1453
+ : "already_connected_continue_chat";
1454
+ result.already_connected = true;
1455
+ result.verified_after_click = true;
1456
+ return result;
1457
+ }
1458
+
1459
+ if (journalRecord?.state === "greeting_confirmed") {
1460
+ result.reason = "greeting_confirmed_by_durable_journal";
1461
+ result.already_connected = true;
1462
+ result.verified_after_click = true;
1463
+ result.action_transaction = compactRecommendActionJournalRecord(journalRecord, journalFilePath);
1464
+ return result;
1465
+ }
1466
+
1467
+ const unknownOrigin = recommendGreetingUnknownOrigin(journalRecord);
1468
+ const latestGreetingReason = latestRecommendGreetingEvidence(journalRecord)?.reason;
1469
+ const replayablePreInputAbort = Boolean(
1470
+ journalRecord?.state === "greeting_send_in_flight"
1471
+ && (
1472
+ latestGreetingReason === "pre_input_checkpoint_aborted"
1473
+ || latestGreetingReason === "pre_input_abort"
1474
+ )
1475
+ );
1476
+ if (
1477
+ (journalRecord?.state === "greeting_send_in_flight" && !replayablePreInputAbort)
1478
+ || (journalRecord?.state === "outcome_unknown" && unknownOrigin === "greeting_send_in_flight")
1479
+ ) {
1480
+ if (journalRecord.state !== "outcome_unknown") {
1481
+ transitionAction("outcome_unknown", {
1482
+ reason: "in_flight_greeting_not_passively_reconciled"
1483
+ });
1484
+ } else {
1485
+ result.action_transaction = compactRecommendActionJournalRecord(journalRecord, journalFilePath);
1486
+ checkpointCritical({ action_transaction: result.action_transaction });
1487
+ }
1488
+ result.reason = "greet_outcome_unknown_preserved_no_replay";
1489
+ result.outcome_unknown = true;
1490
+ result.skipped = true;
1491
+ return result;
1492
+ }
1493
+ if (journalRecord?.state === "outcome_unknown") {
1494
+ result.reason = "greet_outcome_unknown_preserved_no_replay";
1495
+ result.outcome_unknown = true;
1496
+ result.skipped = true;
1497
+ result.action_transaction = compactRecommendActionJournalRecord(journalRecord, journalFilePath);
1498
+ checkpointCritical({ action_transaction: result.action_transaction });
1499
+ return result;
1500
+ }
1501
+ if (!journalRecord) {
1502
+ transitionAction("pre_action", { reason: "greeting_eligible" });
1503
+ }
1504
+ if (journalRecord?.state !== "pre_action" && !replayablePreInputAbort) {
1505
+ const error = new Error(
1506
+ `RECOMMEND_ACTION_JOURNAL_STATE_UNSUPPORTED: ${journalRecord?.state || "unknown"}`
1507
+ );
1508
+ error.code = "RECOMMEND_ACTION_JOURNAL_STATE_UNSUPPORTED";
1509
+ error.retryable = false;
1510
+ throw error;
1511
+ }
1512
+
1513
+ result.action_attempted = true;
1514
+ result.control_before = control;
1515
+ let clickResult;
1516
+ let greetingInFlightPersisted = false;
1517
+ try {
1518
+ clickResult = await clickRecommendActionControl(client, {
1519
+ ...control,
1520
+ kind: plan.effective
1521
+ }, {
1522
+ beforeFinalRefresh: async () => {
1523
+ await requireFreshActionJournalScope("immediately_before_greeting_in_flight");
1524
+ await requireFreshCandidateBinding("immediately_before_greeting_control_refresh", control);
1525
+ },
1526
+ beforeClick: async (freshControl) => {
1527
+ const expectedUpdatedAt = journalRecord?.updated_at || null;
1528
+ const expectedRevision = Number(journalRecord?.revision);
1529
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
1530
+ const error = new Error(
1531
+ "RECOMMEND_ACTION_JOURNAL_REVISION_REQUIRED: exact journal ownership revision is missing"
1532
+ );
1533
+ error.code = "RECOMMEND_ACTION_JOURNAL_REVISION_REQUIRED";
1534
+ error.retryable = false;
1535
+ throw error;
1536
+ }
1537
+ transitionAction("greeting_send_in_flight", {
1538
+ send_method: "Input.dispatchMouseEvent",
1539
+ reason: "immediately_before_non_replayable_click",
1540
+ active_candidate_id: normalizedCandidateId,
1541
+ ...compactRecommendControlEvidence(freshControl)
1542
+ }, {
1543
+ requireChanged: true,
1544
+ recordIdempotent: replayablePreInputAbort,
1545
+ expectedUpdatedAt,
1546
+ expectedRevision
1547
+ });
1548
+ greetingInFlightPersisted = true;
1549
+ },
1550
+ beforeInput: async () => {
1551
+ await requireFreshActionJournalScope("before_final_greeting_control_verification");
1552
+ await requireFreshCandidateBinding("before_final_greeting_control_verification", control);
1553
+ },
1554
+ immediatelyBeforeInput: async (finalControl) => {
1555
+ await requireFreshActionJournalScope("immediately_before_greeting_input");
1556
+ await requireFreshCandidateBinding(
1557
+ "immediately_before_greeting_input",
1558
+ finalControl,
1559
+ { allowScroll: false, settleMs: 0 }
1560
+ );
1561
+ }
1562
+ });
1563
+ } catch (error) {
843
1564
  if (error?.code === GREET_CREDITS_EXHAUSTED_CODE) {
844
1565
  result.reason = "greet_credits_exhausted";
845
1566
  result.out_of_greet_credits = true;
846
1567
  result.stop_run = true;
847
- result.greet_quota = error.greet_quota || control.greet_quota || null;
848
- return result;
849
- }
850
- throw error;
851
- }
1568
+ result.greet_quota = error.greet_quota || control.greet_quota || null;
1569
+ return result;
1570
+ }
1571
+ if (
1572
+ error?.recommend_pre_input_checkpoint_failed === true
1573
+ || (
1574
+ error?.recommend_pre_input_aborted === true
1575
+ && journalRecord?.state === "greeting_send_in_flight"
1576
+ && error?.code !== "RECOMMEND_ACTION_IN_FLIGHT_NOT_OWNED"
1577
+ && error?.code !== "CHAT_ACTION_JOURNAL_CONCURRENT_UPDATE"
1578
+ )
1579
+ ) {
1580
+ const abortReason = error?.recommend_pre_input_checkpoint_failed === true
1581
+ ? "pre_input_checkpoint_aborted"
1582
+ : "pre_input_abort";
1583
+ const hitTestEvidence = error?.recommend_action_control_hit_test || null;
1584
+ const hitTestAttempts = Array.isArray(hitTestEvidence?.attempts)
1585
+ ? hitTestEvidence.attempts
1586
+ : [];
1587
+ const lastHitTestAttempt = hitTestAttempts.at(-1) || null;
1588
+ const aborted = actionJournal.transition({
1589
+ scope: actionJournalScope,
1590
+ candidateId: normalizedCandidateId,
1591
+ state: "greeting_send_in_flight",
1592
+ runId,
1593
+ greeting: RECOMMEND_GREETING_ACTION_FINGERPRINT,
1594
+ recordIdempotent: true,
1595
+ expectedUpdatedAt: journalRecord?.updated_at || null,
1596
+ expectedRevision: journalRecord?.revision ?? null,
1597
+ evidence: {
1598
+ action: "recommend_greet",
1599
+ active_candidate_id: normalizedCandidateId,
1600
+ operation_id: operationId,
1601
+ reason: abortReason,
1602
+ send_method: "Input.dispatchMouseEvent",
1603
+ pre_input_cdp_method: error?.cdp_method || null,
1604
+ action_hit_test_reason: lastHitTestAttempt?.reason || null,
1605
+ action_hit_test_attempt_count: hitTestAttempts.length,
1606
+ action_hit_test_last_hit_backend_node_id: Number.isInteger(
1607
+ Number(lastHitTestAttempt?.hit_backend_node_id)
1608
+ ) ? Number(lastHitTestAttempt.hit_backend_node_id) : null
1609
+ }
1610
+ });
1611
+ journalRecord = aborted.record;
1612
+ journalFilePath = aborted.file_path || journalFilePath;
1613
+ result.action_transaction = compactRecommendActionJournalRecord(journalRecord, journalFilePath);
1614
+ result.reason = abortReason === "pre_input_checkpoint_aborted"
1615
+ ? "greet_pre_input_checkpoint_aborted_replayable"
1616
+ : "greet_pre_input_aborted_replayable";
1617
+ result.pre_input_aborted = true;
1618
+ result.replayable = true;
1619
+ result.stop_run = true;
1620
+ result.error = compactRecommendPostActionError(error);
1621
+ return result;
1622
+ }
1623
+ if (
1624
+ error?.code === "RECOMMEND_ACTION_IN_FLIGHT_NOT_OWNED"
1625
+ || error?.code === "CHAT_ACTION_JOURNAL_CONCURRENT_UPDATE"
1626
+ ) {
1627
+ journalRecord = actionJournal.read({
1628
+ scope: actionJournalScope,
1629
+ candidateId: normalizedCandidateId
1630
+ }) || journalRecord;
1631
+ result.reason = "greet_in_flight_owned_by_another_operation";
1632
+ result.outcome_unknown = true;
1633
+ result.skipped = true;
1634
+ result.stop_run = true;
1635
+ result.action_transaction = compactRecommendActionJournalRecord(journalRecord, journalFilePath);
1636
+ return result;
1637
+ }
1638
+ if (greetingInFlightPersisted || journalRecord?.state === "greeting_send_in_flight") {
1639
+ try {
1640
+ transitionAction("outcome_unknown", {
1641
+ reason: "non_replayable_click_failed_or_disconnected",
1642
+ send_method: error?.cdp_method || "Input.dispatchMouseEvent"
1643
+ });
1644
+ } catch (journalError) {
1645
+ return preservePostInputJournalFailure({
1646
+ error: journalError,
1647
+ desiredState: "outcome_unknown",
1648
+ reason: "greet_post_input_outcome_unknown_persistence_failed",
1649
+ triggeringError: error,
1650
+ inputDispatched: true
1651
+ });
1652
+ }
1653
+ result.reason = "greet_outcome_unknown";
1654
+ result.outcome_unknown = true;
1655
+ result.stop_run = true;
1656
+ result.error = compactRecommendPostActionError(error);
1657
+ return result;
1658
+ }
1659
+ throw error;
1660
+ }
852
1661
  result.click_result = clickResult;
853
1662
  result.action_clicked = true;
854
1663
  result.counted_as_greet = plan.effective === "greet";
855
1664
  result.reason = "clicked";
856
1665
  if (afterClickDelayMs > 0) await sleep(afterClickDelayMs);
857
1666
  try {
858
- const afterDiscovery = await waitForRecommendDetailActionControls(client, {
859
- timeoutMs: 2500,
860
- intervalMs: 300,
861
- requireAny: false
862
- });
863
- const afterSummary = afterDiscovery?.summary || {};
864
- const afterControl = afterSummary.greet;
1667
+ const afterDiscovery = await waitForRecommendDetailActionControls(client, {
1668
+ timeoutMs: 2500,
1669
+ intervalMs: 300,
1670
+ requireAny: false,
1671
+ requireContinueChat: plan.effective === "greet"
1672
+ });
1673
+ const afterSummary = afterDiscovery?.summary || {};
1674
+ const afterControl = afterSummary.greet;
865
1675
  result.action_discovery_after = compactActionDiscovery(afterDiscovery);
866
- result.control_after = afterControl || null;
867
- if (plan.effective === "greet") {
868
- result.verified_after_click = Boolean(
869
- afterControl?.continue_chat
870
- || String(afterControl?.label || "").includes("继续沟通")
871
- );
1676
+ result.control_after = afterControl || null;
1677
+ if (plan.effective === "greet") {
1678
+ await requireFreshCandidateBinding("after_greeting_before_confirmation", afterControl);
1679
+ const confirmedControl = await verifyRecommendActionControlIdentity(client, afterControl, {
1680
+ requireContinueChat: true,
1681
+ requireGeometry: true
1682
+ });
1683
+ await requireFreshCandidateBinding("after_greeting_control_confirmation", confirmedControl);
1684
+ result.control_confirmation = confirmedControl;
1685
+ result.verified_after_click = confirmedControl.verified === true;
872
1686
  }
873
- } catch (error) {
1687
+ } catch (error) {
874
1688
  result.verify_error = {
875
- message: error?.message || String(error)
876
- };
877
- }
878
- return result;
879
- }
1689
+ message: error?.message || String(error)
1690
+ };
1691
+ }
1692
+ try {
1693
+ if (result.verified_after_click === true) {
1694
+ transitionAction("greeting_confirmed", {
1695
+ reason: "exact_continue_chat_control_after_click",
1696
+ send_method: "Input.dispatchMouseEvent"
1697
+ });
1698
+ result.counted_as_greet = plan.effective === "greet";
1699
+ result.reason = "greeting_confirmed";
1700
+ } else {
1701
+ transitionAction("outcome_unknown", {
1702
+ reason: "post_click_confirmation_not_observed",
1703
+ send_method: "Input.dispatchMouseEvent"
1704
+ });
1705
+ result.counted_as_greet = false;
1706
+ result.reason = "greet_outcome_unknown";
1707
+ result.outcome_unknown = true;
1708
+ result.stop_run = true;
1709
+ }
1710
+ } catch (journalError) {
1711
+ return preservePostInputJournalFailure({
1712
+ error: journalError,
1713
+ desiredState: result.verified_after_click === true
1714
+ ? "greeting_confirmed"
1715
+ : "outcome_unknown",
1716
+ reason: "greet_post_input_terminal_state_persistence_failed",
1717
+ inputDispatched: true
1718
+ });
1719
+ }
1720
+ return result;
1721
+ }
880
1722
 
881
- function compactRefreshAttempt(refreshAttempt) {
882
- if (!refreshAttempt) return null;
883
- return {
884
- ok: Boolean(refreshAttempt.ok),
885
- method: refreshAttempt.method || "",
1723
+ function compactRecommendFilteredEmptyState(emptyState) {
1724
+ if (!emptyState) return null;
1725
+ return {
1726
+ verified: Boolean(emptyState.verified),
1727
+ reason: emptyState.reason || null,
1728
+ text: emptyState.text || null,
1729
+ node_id: Number.isInteger(emptyState.node_id) ? emptyState.node_id : null,
1730
+ box: emptyState.box || null,
1731
+ accessibility: emptyState.accessibility || null,
1732
+ selector_counts: emptyState.selector_counts || null,
1733
+ checked_node_count: Number.isInteger(emptyState.checked_node_count)
1734
+ ? emptyState.checked_node_count
1735
+ : 0,
1736
+ candidate_node_count: Number.isInteger(emptyState.candidate_node_count)
1737
+ ? emptyState.candidate_node_count
1738
+ : 0,
1739
+ query_errors: Array.isArray(emptyState.query_errors) ? emptyState.query_errors.slice(0, 20) : []
1740
+ };
1741
+ }
1742
+
1743
+ export function isVerifiedRecommendRefreshCompletion(refreshResult) {
1744
+ return Boolean(
1745
+ refreshResult?.ok === true
1746
+ && refreshResult?.exhausted === true
1747
+ && refreshResult?.empty_state?.verified === true
1748
+ && Number(refreshResult?.card_count) === 0
1749
+ );
1750
+ }
1751
+
1752
+ function compactRefreshAttempt(refreshAttempt) {
1753
+ if (!refreshAttempt) return null;
1754
+ return {
1755
+ ok: Boolean(refreshAttempt.ok),
1756
+ exhausted: Boolean(refreshAttempt.exhausted),
1757
+ method: refreshAttempt.method || "",
886
1758
  reason: refreshAttempt.reason || null,
887
1759
  error: refreshAttempt.error || null,
888
1760
  error_diagnostic: refreshAttempt.error_diagnostic || null,
889
1761
  forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
890
1762
  target_url: refreshAttempt.target_url || null,
891
- card_count: refreshAttempt.card_count || 0,
1763
+ card_count: Number.isInteger(refreshAttempt.card_count) ? refreshAttempt.card_count : 0,
1764
+ empty_state: compactRecommendFilteredEmptyState(refreshAttempt.empty_state),
892
1765
  elapsed_ms: refreshAttempt.elapsed_ms || 0,
893
1766
  recovery_settle: refreshAttempt.recovery_settle
894
1767
  ? {
@@ -905,9 +1778,10 @@ function compactRefreshAttempt(refreshAttempt) {
905
1778
  error: attempt.error || null,
906
1779
  error_diagnostic: attempt.error_diagnostic || null,
907
1780
  label: attempt.label || null,
908
- before_card_count: attempt.before_card_count || 0,
909
- after_card_count: attempt.after_card_count || 0,
910
- card_count: attempt.card_count || 0,
1781
+ before_card_count: attempt.before_card_count || 0,
1782
+ after_card_count: attempt.after_card_count || 0,
1783
+ card_count: Number.isInteger(attempt.card_count) ? attempt.card_count : 0,
1784
+ empty_state: compactRecommendFilteredEmptyState(attempt.empty_state),
911
1785
  elapsed_ms: attempt.elapsed_ms || 0,
912
1786
  current_city_only: compactCurrentCityOnlyResult(attempt.current_city_only),
913
1787
  current_city_only_attempts: (attempt.current_city_only_attempts || []).map((cityAttempt) => ({
@@ -956,9 +1830,9 @@ function compactRefreshAttempt(refreshAttempt) {
956
1830
  };
957
1831
  }
958
1832
 
959
- export function countRecommendResultStatuses(results = [], {
960
- greetCount = 0
961
- } = {}) {
1833
+ export function countRecommendResultStatuses(results = [], {
1834
+ greetCount = 0
1835
+ } = {}) {
962
1836
  return {
963
1837
  processed: results.length,
964
1838
  screened: results.length,
@@ -969,17 +1843,18 @@ export function countRecommendResultStatuses(results = [], {
969
1843
  greet_count: greetCount,
970
1844
  post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
971
1845
  image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
972
- detail_open_failed: results.filter((item) => (
973
- item.error?.code === "DETAIL_STALE_NODE"
974
- || item.error?.code === "DETAIL_OPEN_FAILED"
975
- )).length,
976
- transient_recovered: results.filter((item) => (
977
- item.error?.code === "DETAIL_STALE_NODE"
978
- || item.error?.code === "DETAIL_OPEN_FAILED"
979
- || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
980
- || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
981
- || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
982
- )).length,
1846
+ detail_open_failed: results.filter((item) => (
1847
+ item.error?.code === "DETAIL_STALE_NODE"
1848
+ || item.error?.code === "DETAIL_OPEN_FAILED"
1849
+ || item.error?.code === "RECOMMEND_DETAIL_CANDIDATE_MISMATCH"
1850
+ )).length,
1851
+ transient_recovered: results.filter((item) => (
1852
+ (
1853
+ item.timings?.image_capture_resume?.attempted === true
1854
+ && item.timings?.image_capture_resume?.ok === true
1855
+ )
1856
+ || item.detail?.cv_acquisition?.close_recovery?.ok === true
1857
+ )).length,
983
1858
  colleague_contact_checked: results.filter((item) => item.detail?.colleague_contact?.checked).length,
984
1859
  recent_colleague_contact_skipped: results.filter((item) => (
985
1860
  item.screening?.status === "skip"
@@ -1041,15 +1916,37 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
1041
1916
  if (error.list_end_reason) {
1042
1917
  result.list_end_reason = error.list_end_reason;
1043
1918
  }
1044
- if (error.target_count != null) {
1045
- result.target_count = error.target_count;
1046
- }
1047
- if (error.passed_count != null) {
1919
+ if (error.target_count != null) {
1920
+ result.target_count = error.target_count;
1921
+ }
1922
+ if (error.screening_candidate_identity) {
1923
+ result.screening_candidate_identity = error.screening_candidate_identity;
1924
+ }
1925
+ if (error.passed_count != null) {
1048
1926
  result.passed_count = error.passed_count;
1049
1927
  }
1050
- if (Array.isArray(error.recommend_detail_open_attempts)) {
1051
- result.recommend_detail_open_attempts = error.recommend_detail_open_attempts;
1052
- }
1928
+ if (Array.isArray(error.recommend_detail_open_attempts)) {
1929
+ result.recommend_detail_open_attempts = error.recommend_detail_open_attempts;
1930
+ }
1931
+ if (error.recommend_pre_click_retry) {
1932
+ result.recommend_pre_click_retry = error.recommend_pre_click_retry;
1933
+ }
1934
+ if (error.recommend_pre_click_stale_no_action === true) {
1935
+ result.recommend_pre_click_stale_no_action = true;
1936
+ result.recommend_no_click_dispatched = error.recommend_no_click_dispatched === true;
1937
+ result.recommend_click_dispatched = error.recommend_click_dispatched === true;
1938
+ result.recommend_input_dispatched = error.recommend_input_dispatched === true;
1939
+ result.recommend_pre_click_stage = error.recommend_pre_click_stage || null;
1940
+ result.recommend_pre_click_retry_exhausted = error.recommend_pre_click_retry_exhausted === true;
1941
+ result.recommend_pre_click_reacquire_failed = error.recommend_pre_click_reacquire_failed === true;
1942
+ }
1943
+ if (error.recommend_input_dispatched === true) {
1944
+ result.recommend_click_dispatched = error.recommend_click_dispatched === true;
1945
+ result.recommend_input_dispatched = true;
1946
+ result.recommend_post_input_outcome_unknown = error.recommend_post_input_outcome_unknown === true;
1947
+ result.recommend_click_negative_outcome_observed = error.recommend_click_negative_outcome_observed === true;
1948
+ result.recommend_post_input_stage = error.recommend_post_input_stage || null;
1949
+ }
1053
1950
  if (Array.isArray(error.click_attempts)) {
1054
1951
  result.click_attempts = error.click_attempts;
1055
1952
  }
@@ -1135,6 +2032,7 @@ export function createRecommendRefreshFailureError(refreshAttempt, {
1135
2032
  export function isRecoverableImageCaptureError(error) {
1136
2033
  if (isRecoverableImageCaptureWorkflowError(error)) return true;
1137
2034
  if (isStaleRecommendNodeError(error)) return true;
2035
+ if (error?.code === "IMAGE_CAPTURE_TARGET_UNAVAILABLE") return true;
1138
2036
  return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
1139
2037
  }
1140
2038
 
@@ -1199,22 +2097,163 @@ function createImageCaptureFailureScreening(candidate, error) {
1199
2097
  };
1200
2098
  }
1201
2099
 
1202
- export function isRecoverableRecommendDetailError(error) {
1203
- return isStaleRecommendNodeError(error) || isRecommendDetailOpenMissError(error);
1204
- }
1205
-
1206
- function compactRecoverableDetailError(error) {
1207
- return compactError(error, isStaleRecommendNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
1208
- }
2100
+ export function isRecoverableRecommendDetailError(error) {
2101
+ if (isCandidateLocalRecommendPostClickBindingTimeout(error)) return true;
2102
+ if (
2103
+ error?.recommend_input_dispatched === true
2104
+ && error?.recommend_click_negative_outcome_observed !== true
2105
+ ) return false;
2106
+ return isRecommendPreClickStaleNoActionError(error)
2107
+ || isStaleRecommendNodeError(error)
2108
+ || isRecommendDetailOpenMissError(error)
2109
+ || isRecommendDetailCandidateBindingError(error);
2110
+ }
2111
+
2112
+ export function isCandidateLocalRecommendPostClickBindingTimeout(error) {
2113
+ const binding = error?.detail_candidate_binding || null;
2114
+ const clickAttempts = Array.isArray(error?.click_attempts) ? error.click_attempts : [];
2115
+ const dispatchedAttempts = clickAttempts.filter(
2116
+ (attempt) => attempt?.input_dispatched === true
2117
+ );
2118
+ return Boolean(
2119
+ isRecommendDetailCandidateBindingError(error)
2120
+ && error?.recommend_clean_pre_action_detail_binding_timeout === true
2121
+ && error?.recommend_click_dispatched === true
2122
+ && error?.recommend_input_dispatched === true
2123
+ && error?.recommend_post_input_outcome_unknown === false
2124
+ && error?.recommend_post_input_stage === "post_card_click_binding"
2125
+ && binding?.reason === "detail_binding_readiness_timeout"
2126
+ && binding?.readiness?.exhausted === true
2127
+ && binding?.readiness?.terminal === false
2128
+ && binding?.readiness?.last_error == null
2129
+ && dispatchedAttempts.length === 1
2130
+ && dispatchedAttempts[0]?.outcome === "detail"
2131
+ );
2132
+ }
2133
+
2134
+ export function getRecommendDetailFailureDisposition(error) {
2135
+ if (isCandidateLocalRecommendPostClickBindingTimeout(error)) {
2136
+ return {
2137
+ recoverable: true,
2138
+ candidate_local: true,
2139
+ context_recovery: false,
2140
+ allow_post_action: false,
2141
+ reason: "detail_binding_readiness_timeout_failed_closed"
2142
+ };
2143
+ }
2144
+ if (
2145
+ error?.recommend_input_dispatched === true
2146
+ && error?.recommend_click_negative_outcome_observed !== true
2147
+ ) {
2148
+ return {
2149
+ recoverable: false,
2150
+ candidate_local: false,
2151
+ context_recovery: false,
2152
+ allow_post_action: false,
2153
+ reason: "post_input_detail_outcome_unknown_terminal"
2154
+ };
2155
+ }
2156
+ if (isRecommendDetailCandidateBindingError(error)) {
2157
+ return {
2158
+ recoverable: true,
2159
+ candidate_local: true,
2160
+ context_recovery: false,
2161
+ allow_post_action: false,
2162
+ reason: "candidate_binding_failed_closed"
2163
+ };
2164
+ }
2165
+ if (
2166
+ isRecommendPreClickStaleNoActionError(error)
2167
+ && error?.recommend_pre_click_retry_exhausted === true
2168
+ && error?.recommend_pre_click_reacquire_failed !== true
2169
+ && error?.recommend_pre_click_retry?.candidate_local_exhaustion === true
2170
+ ) {
2171
+ return {
2172
+ recoverable: true,
2173
+ candidate_local: true,
2174
+ context_recovery: false,
2175
+ allow_post_action: false,
2176
+ reason: "pre_click_stale_no_action_failed_closed"
2177
+ };
2178
+ }
2179
+ if (isStaleRecommendNodeError(error) || isRecommendDetailOpenMissError(error)) {
2180
+ return {
2181
+ recoverable: true,
2182
+ candidate_local: false,
2183
+ context_recovery: true,
2184
+ allow_post_action: false,
2185
+ reason: "detail_context_recovery_required"
2186
+ };
2187
+ }
2188
+ return {
2189
+ recoverable: false,
2190
+ candidate_local: false,
2191
+ context_recovery: false,
2192
+ allow_post_action: false,
2193
+ reason: "fatal_detail_error"
2194
+ };
2195
+ }
2196
+
2197
+ function compactRecoverableDetailError(error) {
2198
+ const compact = compactError(
2199
+ error,
2200
+ isRecommendPreClickStaleNoActionError(error)
2201
+ && error?.recommend_pre_click_retry_exhausted === true
2202
+ ? "RECOMMEND_PRE_CLICK_STALE_NO_ACTION"
2203
+ : isStaleRecommendNodeError(error)
2204
+ ? "DETAIL_STALE_NODE"
2205
+ : isRecommendDetailCandidateBindingError(error)
2206
+ ? "RECOMMEND_DETAIL_CANDIDATE_MISMATCH"
2207
+ : "DETAIL_OPEN_FAILED"
2208
+ );
2209
+ if (error?.detail_candidate_binding) {
2210
+ compact.candidate_binding = compactRecommendDetailCandidateBinding(error.detail_candidate_binding);
2211
+ }
2212
+ return compact;
2213
+ }
2214
+
2215
+ export function preserveRecommendDetailCandidateBindingForRecovery(detailResult, binding = null) {
2216
+ const preservedBinding = binding || null;
2217
+ if (detailResult && typeof detailResult === "object") {
2218
+ detailResult.candidate_binding = preservedBinding;
2219
+ }
2220
+ return preservedBinding;
2221
+ }
2222
+
2223
+ export function reserveRecommendDetailRecovery(recoveryCounts, candidateKey, limit = 1) {
2224
+ const boundedLimit = Math.max(0, Math.floor(Number(limit) || 0));
2225
+ const currentCount = Number(recoveryCounts?.get?.(candidateKey) || 0);
2226
+ if (currentCount >= boundedLimit) {
2227
+ return {
2228
+ allowed: false,
2229
+ current_count: currentCount,
2230
+ next_count: currentCount,
2231
+ limit: boundedLimit
2232
+ };
2233
+ }
2234
+ recoveryCounts.set(candidateKey, currentCount + 1);
2235
+ return {
2236
+ allowed: true,
2237
+ current_count: currentCount,
2238
+ next_count: currentCount + 1,
2239
+ limit: boundedLimit
2240
+ };
2241
+ }
1209
2242
 
1210
- function createRecoverableDetailFailureScreening(candidate, error) {
2243
+ function createRecoverableDetailFailureScreening(candidate, error) {
1211
2244
  return {
1212
2245
  status: "fail",
1213
2246
  passed: false,
1214
2247
  score: 0,
1215
- reasons: isStaleRecommendNodeError(error)
1216
- ? ["detail_open_failed", "stale_node"]
1217
- : isRecommendDetailOpenMissError(error)
2248
+ reasons: isRecommendPreClickStaleNoActionError(error)
2249
+ && error?.recommend_pre_click_retry_exhausted === true
2250
+ && error?.recommend_pre_click_reacquire_failed !== true
2251
+ ? ["detail_open_failed", "pre_click_stale_no_action"]
2252
+ : isStaleRecommendNodeError(error)
2253
+ ? ["detail_open_failed", "stale_node"]
2254
+ : isRecommendDetailCandidateBindingError(error)
2255
+ ? ["detail_open_failed", "detail_candidate_mismatch"]
2256
+ : isRecommendDetailOpenMissError(error)
1218
2257
  ? ["detail_open_failed", "detail_open_miss"]
1219
2258
  : ["detail_open_failed"],
1220
2259
  error: compactRecoverableDetailError(error),
@@ -1222,7 +2261,7 @@ function createRecoverableDetailFailureScreening(candidate, error) {
1222
2261
  };
1223
2262
  }
1224
2263
 
1225
- function createRecentColleagueContactSkipScreening(candidate, colleagueContact) {
2264
+ function createRecentColleagueContactSkipScreening(candidate, colleagueContact) {
1226
2265
  const matched = colleagueContact?.matched_row || null;
1227
2266
  return {
1228
2267
  status: "skip",
@@ -1232,8 +2271,287 @@ function createRecentColleagueContactSkipScreening(candidate, colleagueContact)
1232
2271
  reason: matched?.text || "Candidate has recent colleague contact history",
1233
2272
  matched_colleague_contact: matched,
1234
2273
  candidate
1235
- };
1236
- }
2274
+ };
2275
+ }
2276
+
2277
+ export function isVerifiedColleagueContactInspection(colleagueContact) {
2278
+ const rows = Array.isArray(colleagueContact?.rows) ? colleagueContact.rows : [];
2279
+ const rowCount = Number(colleagueContact?.row_count);
2280
+ const sectionNodeId = Number(colleagueContact?.section_node_id);
2281
+ const sectionBackendNodeId = Number(colleagueContact?.section_backend_node_id);
2282
+ const reason = colleagueContact?.reason;
2283
+ if (reason === "panel_missing") {
2284
+ const absenceProbe = colleagueContact?.absence_probe;
2285
+ const backendNodeIds = Array.isArray(absenceProbe?.scope_backend_node_ids)
2286
+ ? absenceProbe.scope_backend_node_ids
2287
+ : [];
2288
+ return Boolean(
2289
+ colleagueContact?.checked === true
2290
+ && colleagueContact?.panel_found === false
2291
+ && colleagueContact?.recent === false
2292
+ && colleagueContact?.indeterminate === false
2293
+ && absenceProbe?.verified === true
2294
+ && absenceProbe?.selector === ".colleague-collaboration"
2295
+ && Number.isInteger(Number(absenceProbe?.scope_count))
2296
+ && Number(absenceProbe.scope_count) > 0
2297
+ && Number.isInteger(Number(absenceProbe?.stable_scope_count))
2298
+ && Number(absenceProbe.stable_scope_count) > 0
2299
+ && Number(absenceProbe.stable_scope_count) === backendNodeIds.length
2300
+ && backendNodeIds.every((backendNodeId) => (
2301
+ Number.isInteger(Number(backendNodeId)) && Number(backendNodeId) > 0
2302
+ ))
2303
+ && Number.isInteger(Number(absenceProbe?.poll_count))
2304
+ && Number(absenceProbe.poll_count) >= 2
2305
+ && Number(absenceProbe?.elapsed_ms) >= Number(absenceProbe?.timeout_ms)
2306
+ && absenceProbe?.full_window_elapsed === true
2307
+ && Number(absenceProbe?.query_error_count) === 0
2308
+ && absenceProbe?.scope_binding_lost === false
2309
+ && rows.length === 0
2310
+ );
2311
+ }
2312
+ const scrollProbe = colleagueContact?.scroll_probe;
2313
+ const scrollPositions = Array.isArray(scrollProbe?.positions) ? scrollProbe.positions : [];
2314
+ const requestedScrolls = Number(scrollProbe?.scrolls_requested);
2315
+ const completedScrolls = Number(scrollProbe?.scrolls_completed);
2316
+ const endProof = scrollProbe?.end_proof;
2317
+ const seenRowTexts = new Set();
2318
+ let priorRowSignature = null;
2319
+ let priorRowLayoutSignature = null;
2320
+ let recomputedStableSignatureCount = 0;
2321
+ let recomputedEffectiveScrollCount = 0;
2322
+ const scrollPositionsVerified = scrollPositions.every((position, index) => {
2323
+ const rowTexts = Array.isArray(position?.row_texts)
2324
+ ? position.row_texts.map((text) => String(text || "")).sort()
2325
+ : [];
2326
+ const rowIdentityKeys = Array.isArray(position?.row_identity_keys)
2327
+ ? position.row_identity_keys.map((key) => String(key || "")).sort()
2328
+ : [];
2329
+ const rowBackendNodeIds = Array.isArray(position?.row_backend_node_ids)
2330
+ ? position.row_backend_node_ids.map(Number)
2331
+ : [];
2332
+ const orderedRowLayout = Array.isArray(position?.ordered_row_layout)
2333
+ ? position.ordered_row_layout.map((row) => ({
2334
+ backend_node_id: Number(row?.backend_node_id),
2335
+ text: String(row?.text || ""),
2336
+ x: Number(row?.x),
2337
+ y: Number(row?.y),
2338
+ width: Number(row?.width),
2339
+ height: Number(row?.height)
2340
+ }))
2341
+ : [];
2342
+ const recomputedOrderedRowLayout = orderedRowLayout.slice().sort((left, right) => (
2343
+ left.y - right.y
2344
+ || left.x - right.x
2345
+ || left.backend_node_id - right.backend_node_id
2346
+ ));
2347
+ const orderedRowLayoutKeys = recomputedOrderedRowLayout.map((row) => (
2348
+ `${row.text}:${row.x}:${row.y}:${row.width}:${row.height}`
2349
+ ));
2350
+ const persistedOrderedRowLayoutKeys = Array.isArray(position?.ordered_row_layout_keys)
2351
+ ? position.ordered_row_layout_keys.map((key) => String(key || ""))
2352
+ : [];
2353
+ const expectedNewRowTexts = rowTexts.filter((text) => !seenRowTexts.has(text));
2354
+ const rowSignature = JSON.stringify(rowIdentityKeys);
2355
+ const rowLayoutSignature = JSON.stringify(orderedRowLayoutKeys);
2356
+ const expectedScrollEffectObserved = Boolean(
2357
+ index > 0
2358
+ && rowLayoutSignature !== priorRowLayoutSignature
2359
+ );
2360
+ if (expectedScrollEffectObserved) recomputedEffectiveScrollCount += 1;
2361
+ if (
2362
+ index > 0
2363
+ && rowSignature === priorRowSignature
2364
+ && expectedNewRowTexts.length === 0
2365
+ ) {
2366
+ recomputedStableSignatureCount += 1;
2367
+ } else {
2368
+ recomputedStableSignatureCount = 0;
2369
+ }
2370
+ const verified = Boolean(
2371
+ Number(position?.position_index) === index
2372
+ && Number(position?.sampled_after_scroll_count) === index
2373
+ && Number(position?.row_count) > 0
2374
+ && rowTexts.length === Number(position?.row_count)
2375
+ && new Set(rowTexts).size === rowTexts.length
2376
+ && rowIdentityKeys.length === Number(position?.row_count)
2377
+ && new Set(rowIdentityKeys).size === rowIdentityKeys.length
2378
+ && rowBackendNodeIds.length === Number(position?.row_count)
2379
+ && rowBackendNodeIds.every((backendNodeId) => Number.isInteger(backendNodeId) && backendNodeId > 0)
2380
+ && position?.row_signature === rowSignature
2381
+ && orderedRowLayout.length === Number(position?.row_count)
2382
+ && orderedRowLayout.every((row) => (
2383
+ Number.isInteger(row.backend_node_id)
2384
+ && row.backend_node_id > 0
2385
+ && rowBackendNodeIds.includes(row.backend_node_id)
2386
+ && row.text.length > 0
2387
+ && rowTexts.includes(row.text)
2388
+ && [row.x, row.y, row.width, row.height].every(Number.isFinite)
2389
+ && row.width > 0
2390
+ && row.height > 0
2391
+ ))
2392
+ && JSON.stringify(orderedRowLayout) === JSON.stringify(recomputedOrderedRowLayout)
2393
+ && orderedRowLayoutKeys.length === Number(position?.row_count)
2394
+ && new Set(orderedRowLayoutKeys).size === orderedRowLayoutKeys.length
2395
+ && JSON.stringify(persistedOrderedRowLayoutKeys) === JSON.stringify(orderedRowLayoutKeys)
2396
+ && position?.row_layout_signature === rowLayoutSignature
2397
+ && position?.scroll_effect_observed === expectedScrollEffectObserved
2398
+ && Number(position?.cumulative_effective_scroll_count) === recomputedEffectiveScrollCount
2399
+ && Number(position?.new_row_count) === expectedNewRowTexts.length
2400
+ && JSON.stringify(
2401
+ Array.isArray(position?.new_row_texts)
2402
+ ? position.new_row_texts.map((text) => String(text || "")).sort()
2403
+ : []
2404
+ ) === JSON.stringify([...expectedNewRowTexts].sort())
2405
+ && Number(position?.stable_signature_count) === recomputedStableSignatureCount
2406
+ && Number(position?.unreadable_row_count) === 0
2407
+ && position?.binding_before_verified === true
2408
+ && position?.binding_after_verified === true
2409
+ );
2410
+ for (const text of rowTexts) seenRowTexts.add(text);
2411
+ priorRowSignature = rowSignature;
2412
+ priorRowLayoutSignature = rowLayoutSignature;
2413
+ return verified;
2414
+ });
2415
+ const lastScrollPosition = scrollPositions[scrollPositions.length - 1] || null;
2416
+ const exactEndProofVerified = Boolean(
2417
+ endProof?.verified === true
2418
+ && endProof?.method === "effective_scroll_then_repeated_identical_rows"
2419
+ && Number(endProof?.stable_samples_required) === 2
2420
+ && Number(endProof?.stable_samples_observed) >= 2
2421
+ && Number(endProof?.additional_wheel_attempts_without_change) >= 2
2422
+ && endProof?.effective_scroll_observed === true
2423
+ && Number(endProof?.effective_scroll_count) === recomputedEffectiveScrollCount
2424
+ && recomputedEffectiveScrollCount > 0
2425
+ && Number(endProof?.end_position_index) === scrollPositions.length - 1
2426
+ && Number(endProof?.end_scroll_count) === completedScrolls
2427
+ && endProof?.row_signature === lastScrollPosition?.row_signature
2428
+ && Number(lastScrollPosition?.stable_signature_count) >= 2
2429
+ && scrollProbe?.cap_reached_without_end === false
2430
+ );
2431
+ const noRecentScanVerified = reason !== "no_recent_colleague_contact" || Boolean(
2432
+ scrollProbe?.completed === true
2433
+ && scrollProbe?.coverage_verified === true
2434
+ && Number.isInteger(requestedScrolls)
2435
+ && requestedScrolls >= 2
2436
+ && requestedScrolls <= 48
2437
+ && Number.isInteger(completedScrolls)
2438
+ && completedScrolls >= 2
2439
+ && completedScrolls <= requestedScrolls
2440
+ && Number(scrollProbe?.position_count) === completedScrolls + 1
2441
+ && scrollPositions.length === completedScrolls + 1
2442
+ && Number(scrollProbe?.step_delta_y) > 0
2443
+ && Number(scrollProbe?.overlap_ratio) > 0
2444
+ && Number(scrollProbe?.overlap_ratio) < 1
2445
+ && Number(scrollProbe?.effective_scroll_count) === recomputedEffectiveScrollCount
2446
+ && recomputedEffectiveScrollCount > 0
2447
+ && scrollPositionsVerified
2448
+ && exactEndProofVerified
2449
+ );
2450
+ const semanticResultMatchesRows = reason === "recent_colleague_contact_found"
2451
+ ? colleagueContact?.recent === true && rows.some((row) => row?.within_window === true)
2452
+ : reason === "no_recent_colleague_contact"
2453
+ ? colleagueContact?.recent === false && rows.every((row) => row?.within_window === false)
2454
+ : false;
2455
+ return Boolean(
2456
+ colleagueContact?.checked === true
2457
+ && colleagueContact?.panel_found === true
2458
+ && colleagueContact?.indeterminate !== true
2459
+ && typeof colleagueContact?.recent === "boolean"
2460
+ && semanticResultMatchesRows
2461
+ && noRecentScanVerified
2462
+ && String(colleagueContact?.selected_tab_text || "").replace(/\s+/g, "").trim() === "同事沟通进度"
2463
+ && colleagueContact?.selected_tab_count === 1
2464
+ && colleagueContact?.pane_binding_verified === true
2465
+ && colleagueContact?.binding?.verified === true
2466
+ && colleagueContact?.binding?.selection_reverified_after_rows === true
2467
+ && colleagueContact?.binding?.row_scope === "selected_section_descendants"
2468
+ && Number.isInteger(sectionNodeId)
2469
+ && sectionNodeId > 0
2470
+ && Number.isInteger(sectionBackendNodeId)
2471
+ && sectionBackendNodeId > 0
2472
+ && Number.isInteger(rowCount)
2473
+ && rowCount > 0
2474
+ && rows.length === rowCount
2475
+ && (
2476
+ reason !== "no_recent_colleague_contact"
2477
+ || (
2478
+ seenRowTexts.size === rowCount
2479
+ && rows.every((row) => seenRowTexts.has(String(row?.text || "")))
2480
+ )
2481
+ )
2482
+ && rows.every((row) => (
2483
+ Boolean(String(row?.text || "").trim())
2484
+ && Boolean(row?.parsed_date)
2485
+ && typeof row?.within_window === "boolean"
2486
+ && row?.visible === true
2487
+ && Number(row?.section_node_id) === sectionNodeId
2488
+ && Number(row?.section_backend_node_id) === sectionBackendNodeId
2489
+ && Number.isInteger(Number(row?.backend_node_id))
2490
+ && Number(row?.backend_node_id) > 0
2491
+ && (
2492
+ reason !== "no_recent_colleague_contact"
2493
+ || (
2494
+ Array.isArray(row?.observed_at_positions)
2495
+ && row.observed_at_positions.length > 0
2496
+ && row.observed_at_positions.every((position) => (
2497
+ Number.isInteger(Number(position))
2498
+ && Number(position) >= 0
2499
+ && Number(position) <= completedScrolls
2500
+ ))
2501
+ )
2502
+ )
2503
+ ))
2504
+ );
2505
+ }
2506
+
2507
+ export function getColleagueContactSkipReason(colleagueContact) {
2508
+ if (!isVerifiedColleagueContactInspection(colleagueContact)) {
2509
+ return "colleague_contact_unverified";
2510
+ }
2511
+ return colleagueContact?.recent === true ? "skipped_recent_colleague_contact" : "";
2512
+ }
2513
+
2514
+ export async function bindRecommendColleagueContactInspectionResult(
2515
+ colleagueContact,
2516
+ { reverifyCandidateBinding } = {}
2517
+ ) {
2518
+ if (typeof reverifyCandidateBinding !== "function") {
2519
+ const error = new Error("Recommend colleague-contact result requires fresh candidate binding verification");
2520
+ error.code = "RECOMMEND_DETAIL_CANDIDATE_BINDING_REQUIRED";
2521
+ throw error;
2522
+ }
2523
+ const candidateBinding = await reverifyCandidateBinding(
2524
+ "after_colleague_contact_before_result"
2525
+ );
2526
+ if (candidateBinding?.verified !== true) {
2527
+ throw createRecommendDetailCandidateBindingError(candidateBinding);
2528
+ }
2529
+ return {
2530
+ candidate_binding: candidateBinding,
2531
+ skip_reason: getColleagueContactSkipReason(colleagueContact)
2532
+ };
2533
+ }
2534
+
2535
+ export function resolveEffectiveRecommendDetailLimit({
2536
+ detailLimit = Number.POSITIVE_INFINITY,
2537
+ postActionEnabled = false,
2538
+ requireColleagueContactInspection = false
2539
+ } = {}) {
2540
+ if (postActionEnabled || requireColleagueContactInspection) return Number.POSITIVE_INFINITY;
2541
+ return detailLimit;
2542
+ }
2543
+
2544
+ function createUnverifiedColleagueContactSkipScreening(candidate, colleagueContact) {
2545
+ return {
2546
+ status: "skip",
2547
+ passed: false,
2548
+ score: 0,
2549
+ reasons: ["colleague_contact_unverified"],
2550
+ reason: "Candidate colleague-contact status could not be verified",
2551
+ colleague_contact: colleagueContact,
2552
+ candidate
2553
+ };
2554
+ }
1237
2555
 
1238
2556
  export async function runRecommendWorkflow({
1239
2557
  client,
@@ -1263,10 +2581,13 @@ export async function runRecommendWorkflow({
1263
2581
  postAction = "none",
1264
2582
  maxGreetCount = null,
1265
2583
  executePostAction = true,
1266
- actionTimeoutMs = 8000,
1267
- actionIntervalMs = 500,
1268
- actionAfterClickDelayMs = 900,
1269
- screeningMode = "llm",
2584
+ actionTimeoutMs = 8000,
2585
+ actionIntervalMs = 500,
2586
+ actionAfterClickDelayMs = 900,
2587
+ actionJournal = null,
2588
+ actionJournalScope = "boss-recommend:default",
2589
+ reverifyActionJournalScope = null,
2590
+ screeningMode = "llm",
1270
2591
  llmConfig = null,
1271
2592
  llmTimeoutMs = 120000,
1272
2593
  llmImageLimit = 8,
@@ -1310,14 +2631,25 @@ export async function runRecommendWorkflow({
1310
2631
  const requestedPageScope = normalizeRecommendPageScope(pageScope) || "recommend";
1311
2632
  const normalizedFallbackPageScope = normalizeRecommendPageScope(fallbackPageScope) || "recommend";
1312
2633
  const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
1313
- const useLlmScreening = normalizedScreeningMode !== "deterministic";
1314
- const postActionEnabled = normalizedPostAction !== "none";
2634
+ const useLlmScreening = normalizedScreeningMode !== "deterministic";
2635
+ const postActionEnabled = normalizedPostAction !== "none";
2636
+ const screeningOnlyBindingEnabled = Boolean(
2637
+ normalizedPostAction === "none"
2638
+ && executePostAction === false
2639
+ );
2640
+ const effectiveActionJournal = postActionEnabled && executePostAction
2641
+ ? actionJournal || createRecommendGreetingActionJournal()
2642
+ : actionJournal;
1315
2643
  const shouldSkipRecentColleagueContacted = skipRecentColleagueContacted !== false;
1316
2644
  const normalizedColleagueContactWindowDays = Math.max(1, Number(colleagueContactWindowDays) || 14);
1317
2645
  const colleagueContactReferenceDate = new Date();
1318
- const targetPassCount = Math.max(1, Number(maxCandidates) || 1);
1319
- const detailCountLimit = detailLimit == null ? Number.POSITIVE_INFINITY : Math.max(0, Number(detailLimit) || 0);
1320
- const effectiveDetailLimit = postActionEnabled ? Number.POSITIVE_INFINITY : detailCountLimit;
2646
+ const targetPassCount = Math.max(1, Number(maxCandidates) || 1);
2647
+ const detailCountLimit = detailLimit == null ? Number.POSITIVE_INFINITY : Math.max(0, Number(detailLimit) || 0);
2648
+ const effectiveDetailLimit = resolveEffectiveRecommendDetailLimit({
2649
+ detailLimit: detailCountLimit,
2650
+ postActionEnabled,
2651
+ requireColleagueContactInspection: shouldSkipRecentColleagueContacted
2652
+ });
1321
2653
  const networkRecorder = effectiveDetailLimit > 0
1322
2654
  ? createRecommendDetailNetworkRecorder(client)
1323
2655
  : null;
@@ -1362,6 +2694,7 @@ export async function runRecommendWorkflow({
1362
2694
  const listReadStaleDiagnostics = [];
1363
2695
  let domStaleEventCount = 0;
1364
2696
  let currentDomOperation = "recommend:initialize";
2697
+ let currentDetailCandidateBinding = null;
1365
2698
  const domLifecycleTimeline = [];
1366
2699
  const domStaleForensics = [];
1367
2700
  const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
@@ -1581,9 +2914,10 @@ export async function runRecommendWorkflow({
1581
2914
  in_progress_candidate: {
1582
2915
  index,
1583
2916
  key: candidateKey,
1584
- card_node_id: cardNodeId,
1585
- detail_step: detailStep || null,
1586
- counters: countRecommendResultStatuses(results, { greetCount }),
2917
+ card_node_id: cardNodeId,
2918
+ detail_step: detailStep || null,
2919
+ candidate_binding: compactRecommendDetailCandidateBinding(currentDetailCandidateBinding),
2920
+ counters: countRecommendResultStatuses(results, { greetCount }),
1587
2921
  error: compactError(error, "RECOMMEND_IN_PROGRESS_ERROR")
1588
2922
  },
1589
2923
  candidate_list: compactInfiniteListState(listState)
@@ -1624,11 +2958,12 @@ export async function runRecommendWorkflow({
1624
2958
  buttonSettleMs: refreshButtonSettleMs,
1625
2959
  reloadSettleMs: refreshReloadSettleMs
1626
2960
  });
1627
- let blockingPanelClose = null;
1628
- if (refreshResult.ok) {
1629
- blockingPanelClose = await closeRecommendBlockingPanels(client, {
1630
- attemptsLimit: 2,
1631
- rootState: refreshResult.root_state || rootState
2961
+ const verifiedExhaustion = isVerifiedRecommendRefreshCompletion(refreshResult);
2962
+ let blockingPanelClose = null;
2963
+ if (refreshResult.ok && !verifiedExhaustion) {
2964
+ blockingPanelClose = await closeRecommendBlockingPanels(client, {
2965
+ attemptsLimit: 2,
2966
+ rootState: refreshResult.root_state || rootState
1632
2967
  });
1633
2968
  }
1634
2969
  const compactRefresh = {
@@ -1650,7 +2985,7 @@ export async function runRecommendWorkflow({
1650
2985
  },
1651
2986
  candidate_list: compactInfiniteListState(listState)
1652
2987
  });
1653
- if (!refreshResult.ok) {
2988
+ if (!refreshResult.ok) {
1654
2989
  updateRecommendProgress({
1655
2990
  refresh_method: refreshResult.method || null,
1656
2991
  refresh_forced_recent_not_view: effectiveForceRecentNotView,
@@ -1662,15 +2997,56 @@ export async function runRecommendWorkflow({
1662
2997
  recoveryError.code = "RECOMMEND_CONTEXT_RECOVERY_FAILED";
1663
2998
  recoveryError.refresh_attempt = compactRefresh;
1664
2999
  recoveryError.recovery_reason = reason;
1665
- throw attachRecommendRefreshErrorDiagnostic(recoveryError, compactRefresh);
1666
- }
1667
- if (refreshResult.current_city_only) {
1668
- currentCityOnlyResult = refreshResult.current_city_only;
1669
- }
1670
- if (refreshResult.filter) {
1671
- filterResult = refreshResult.filter;
1672
- }
1673
- if (!blockingPanelClose?.closed) {
3000
+ throw attachRecommendRefreshErrorDiagnostic(recoveryError, compactRefresh);
3001
+ }
3002
+ if (refreshResult.exhausted && !verifiedExhaustion) {
3003
+ updateRecommendProgress({
3004
+ refresh_method: refreshResult.method || null,
3005
+ refresh_forced_recent_not_view: effectiveForceRecentNotView,
3006
+ recovery_reason: reason,
3007
+ refresh_exhausted_unverified: true
3008
+ });
3009
+ const recoveryError = new Error(
3010
+ `Recommend context recovery returned unverified exhaustion after ${reason}`
3011
+ );
3012
+ recoveryError.code = "RECOMMEND_CONTEXT_RECOVERY_FAILED";
3013
+ recoveryError.refresh_attempt = compactRefresh;
3014
+ recoveryError.recovery_reason = reason;
3015
+ throw attachRecommendRefreshErrorDiagnostic(recoveryError, compactRefresh);
3016
+ }
3017
+ if (refreshResult.current_city_only) {
3018
+ currentCityOnlyResult = refreshResult.current_city_only;
3019
+ }
3020
+ if (refreshResult.filter) {
3021
+ filterResult = refreshResult.filter;
3022
+ }
3023
+ if (verifiedExhaustion) {
3024
+ rootState = refreshResult.root_state || rootState;
3025
+ cardNodeIds = [];
3026
+ listEndReason = "filtered_list_exhausted";
3027
+ updateRecommendProgress({
3028
+ card_count: 0,
3029
+ refresh_method: refreshResult.method || null,
3030
+ refresh_forced_recent_not_view: effectiveForceRecentNotView,
3031
+ recovery_reason: reason,
3032
+ list_end_reason: listEndReason,
3033
+ refresh_exhausted: true,
3034
+ empty_state_verified: true
3035
+ });
3036
+ runControl.checkpoint({
3037
+ context_recovery: {
3038
+ attempt: contextRecoveryAttempts,
3039
+ reason,
3040
+ refresh: compactRefresh,
3041
+ refresh_exhausted: true,
3042
+ list_end_reason: listEndReason,
3043
+ counters: countRecommendResultStatuses(results, { greetCount })
3044
+ },
3045
+ candidate_list: compactInfiniteListState(listState)
3046
+ });
3047
+ return refreshResult;
3048
+ }
3049
+ if (!blockingPanelClose?.closed) {
1674
3050
  const panelError = createRecommendBlockingPanelCloseFailureError(blockingPanelClose, `recover:${reason}`);
1675
3051
  panelError.refresh_attempt = compactRefresh;
1676
3052
  throw panelError;
@@ -1716,19 +3092,20 @@ export async function runRecommendWorkflow({
1716
3092
  iframe_document_node_id: rootState.iframe.documentNodeId
1717
3093
  });
1718
3094
 
1719
- if (jobLabel) {
1720
- await runControl.waitIfPaused();
1721
- runControl.throwIfCanceled();
1722
- runControl.setPhase("recommend:job");
1723
- jobSelection = await selectRecommendJob(client, rootState.iframe.documentNodeId, {
1724
- jobLabel,
1725
- settleMs: cardTimeoutMs > 45000 ? 12000 : 6000
1726
- });
1727
- if (!jobSelection.selected) {
1728
- throw new Error(`Requested recommend job was not selected: ${jobSelection.reason}`);
1729
- }
1730
- rootState = await getRecommendRoots(client);
1731
- rootState = await ensureRecommendViewport(rootState, "job");
3095
+ if (jobLabel) {
3096
+ await runControl.waitIfPaused();
3097
+ runControl.throwIfCanceled();
3098
+ runControl.setPhase("recommend:job");
3099
+ const initialJobSelection = await selectAndVerifyInitialRecommendJob(client, rootState, {
3100
+ jobLabel,
3101
+ settleMs: cardTimeoutMs > 45000 ? 12000 : 6000,
3102
+ dropdownTimeoutMs: Math.max(8000, Math.min(cardTimeoutMs, 60000)),
3103
+ totalTimeoutMs: Math.max(30000, Math.min(cardTimeoutMs + 15000, 90000)),
3104
+ retryDelayMs: 1000
3105
+ });
3106
+ jobSelection = initialJobSelection.job_selection;
3107
+ rootState = initialJobSelection.root_state || await getRecommendRoots(client);
3108
+ rootState = await ensureRecommendViewport(rootState, "job");
1732
3109
  runControl.checkpoint({
1733
3110
  job_selection: compactJobSelection(jobSelection)
1734
3111
  });
@@ -1773,14 +3150,17 @@ export async function runRecommendWorkflow({
1773
3150
  await runControl.waitIfPaused();
1774
3151
  runControl.throwIfCanceled();
1775
3152
  runControl.setPhase("recommend:filter");
1776
- const result = await selectAndConfirmFirstSafeFilter(
1777
- client,
1778
- rootState.iframe.documentNodeId,
1779
- buildRecommendFilterSelectionOptions(normalizedFilter)
1780
- );
1781
- if (!result.confirmed) {
1782
- throw new Error("Recommend run filter selection was not confirmed");
1783
- }
3153
+ const result = await selectAndConfirmFirstSafeFilter(
3154
+ client,
3155
+ rootState.iframe.documentNodeId,
3156
+ buildRecommendFilterSelectionOptions(normalizedFilter)
3157
+ );
3158
+ if (!isVerifiedRecommendFilterApplication(
3159
+ result,
3160
+ buildRecommendFilterSelectionOptions(normalizedFilter)
3161
+ )) {
3162
+ throw new Error("Recommend run filter selection was not fully verified");
3163
+ }
1784
3164
  rootState = await getRecommendRoots(client);
1785
3165
  rootState = await ensureRecommendViewport(rootState, "filter");
1786
3166
  runControl.checkpoint({
@@ -1796,19 +3176,50 @@ export async function runRecommendWorkflow({
1796
3176
  runControl.throwIfCanceled();
1797
3177
  runControl.setPhase("recommend:cards");
1798
3178
  rootState = await ensureRecommendViewport(rootState, "cards");
1799
- cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
1800
- timeoutMs: cardTimeoutMs,
1801
- intervalMs: 300
1802
- });
1803
- if (!cardNodeIds.length) {
1804
- throw new Error("No recommend candidate cards found for run service");
1805
- }
1806
-
1807
- updateRecommendProgress({
1808
- list_end_reason: null
1809
- });
1810
-
1811
- while (countPassedResults(results) < targetPassCount) {
3179
+ cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
3180
+ timeoutMs: cardTimeoutMs,
3181
+ intervalMs: 300
3182
+ });
3183
+ let initialEmptyState = null;
3184
+ if (!cardNodeIds.length) {
3185
+ initialEmptyState = await inspectRecommendFilteredEmptyState(
3186
+ client,
3187
+ rootState.iframe.documentNodeId
3188
+ );
3189
+ const initialExhausted = isVerifiedRecommendRefreshExhaustion({
3190
+ cardCount: 0,
3191
+ filter: normalizedFilter,
3192
+ filterResult,
3193
+ pageScopeResult: pageScopeSelection,
3194
+ currentCityOnlyResult,
3195
+ emptyState: initialEmptyState
3196
+ });
3197
+ if (!initialExhausted) {
3198
+ const error = new Error("No recommend candidate cards found for run service");
3199
+ error.code = "RECOMMEND_INITIAL_LIST_EMPTY_UNVERIFIED";
3200
+ error.empty_state = compactRecommendFilteredEmptyState(initialEmptyState);
3201
+ throw error;
3202
+ }
3203
+ listEndReason = "filtered_list_exhausted";
3204
+ runControl.checkpoint({
3205
+ initial_list_exhausted: true,
3206
+ list_end_reason: listEndReason,
3207
+ empty_state: compactRecommendFilteredEmptyState(initialEmptyState),
3208
+ counters: countRecommendResultStatuses(results, { greetCount })
3209
+ });
3210
+ }
3211
+
3212
+ updateRecommendProgress({
3213
+ card_count: cardNodeIds.length,
3214
+ list_end_reason: listEndReason || null,
3215
+ initial_list_exhausted: listEndReason === "filtered_list_exhausted",
3216
+ empty_state_verified: initialEmptyState?.verified === true
3217
+ });
3218
+
3219
+ while (
3220
+ countPassedResults(results) < targetPassCount
3221
+ && listEndReason !== "filtered_list_exhausted"
3222
+ ) {
1812
3223
  const candidateStarted = Date.now();
1813
3224
  const timings = {};
1814
3225
  await runControl.waitIfPaused();
@@ -1856,11 +3267,18 @@ export async function runRecommendWorkflow({
1856
3267
  }
1857
3268
  const listReadAcquisition = await acquireRecommendListReadWithStaleRecovery({
1858
3269
  maxRetries: 2,
1859
- acquire: async () => {
1860
- await runControl.waitIfPaused();
1861
- runControl.throwIfCanceled();
1862
- runControl.setPhase("recommend:list-read");
1863
- rootState = await ensureRecommendViewport(rootState, "candidate_loop");
3270
+ acquire: async () => {
3271
+ await runControl.waitIfPaused();
3272
+ runControl.throwIfCanceled();
3273
+ runControl.setPhase("recommend:list-read");
3274
+ if (listEndReason === "filtered_list_exhausted") {
3275
+ return {
3276
+ ok: false,
3277
+ end_reached: true,
3278
+ reason: listEndReason
3279
+ };
3280
+ }
3281
+ rootState = await ensureRecommendViewport(rootState, "candidate_loop");
1864
3282
  if (debugForcedListEnd) {
1865
3283
  return {
1866
3284
  ok: false,
@@ -1968,43 +3386,15 @@ export async function runRecommendWorkflow({
1968
3386
  },
1969
3387
  recover: async ({ error, diagnostic }) => {
1970
3388
  try {
1971
- return await recoverRecommendListReadStaleContext({
1972
- staleAttempt: diagnostic.attempt,
1973
- listState,
1974
- rootReacquire: async () => {
1975
- await runControl.waitIfPaused();
1976
- runControl.throwIfCanceled();
1977
- runControl.setPhase("recommend:list-read-root-reacquire");
1978
- let freshRootState = await getRecommendRoots(client);
1979
- freshRootState = await ensureRecommendViewport(
1980
- freshRootState,
1981
- "list_read_stale_root_reacquire"
1982
- );
1983
- const freshCardNodeIds = await waitForRecommendCardNodeIds(
1984
- client,
1985
- freshRootState.iframe.documentNodeId,
1986
- {
1987
- timeoutMs: Math.min(cardTimeoutMs, 10000),
1988
- intervalMs: 300
1989
- }
1990
- );
1991
- rootState = freshRootState;
1992
- cardNodeIds = freshCardNodeIds;
1993
- return {
1994
- card_count: freshCardNodeIds.length,
1995
- iframe_document_node_id: freshRootState.iframe.documentNodeId,
1996
- root_identity: compactRecommendDomRootIdentity(
1997
- freshRootState,
1998
- currentConnectionEpoch()
1999
- )
2000
- };
2001
- },
2002
- contextReapply: async ({ rootReacquireError }) => {
2003
- await runControl.waitIfPaused();
2004
- runControl.throwIfCanceled();
2005
- const recovery = await recoverAndReapplyRecommendContext(
2006
- "list_read_stale_node",
2007
- rootReacquireError || error,
3389
+ return await recoverRecommendListReadStaleContext({
3390
+ staleAttempt: diagnostic.attempt,
3391
+ listState,
3392
+ contextReapply: async () => {
3393
+ await runControl.waitIfPaused();
3394
+ runControl.throwIfCanceled();
3395
+ const recovery = await recoverAndReapplyRecommendContext(
3396
+ "list_read_stale_node",
3397
+ error,
2008
3398
  { forceRecentNotView: true }
2009
3399
  );
2010
3400
  return {
@@ -2109,10 +3499,20 @@ export async function runRecommendWorkflow({
2109
3499
  });
2110
3500
  }
2111
3501
  });
2112
- const nextCandidateResult = listReadAcquisition.result;
2113
- if (!nextCandidateResult.ok) {
2114
- listEndReason = nextCandidateResult.reason || "list_exhausted";
2115
- if (
3502
+ const nextCandidateResult = listReadAcquisition.result;
3503
+ if (!nextCandidateResult.ok) {
3504
+ listEndReason = nextCandidateResult.reason || "list_exhausted";
3505
+ if (listEndReason === "filtered_list_exhausted") {
3506
+ cardNodeIds = [];
3507
+ updateRecommendProgress({
3508
+ card_count: 0,
3509
+ list_end_reason: listEndReason,
3510
+ refresh_exhausted: true,
3511
+ empty_state_verified: true
3512
+ });
3513
+ break;
3514
+ }
3515
+ if (
2116
3516
  (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
2117
3517
  && refreshOnEnd
2118
3518
  && countPassedResults(results) < targetPassCount
@@ -2147,16 +3547,49 @@ export async function runRecommendWorkflow({
2147
3547
  }
2148
3548
  const compactRefresh = compactRefreshAttempt(refreshResult);
2149
3549
  refreshAttempts.push(compactRefresh);
2150
- runControl.checkpoint({
2151
- refresh_round: refreshRounds,
2152
- refresh: compactRefresh
2153
- });
2154
- updateRecommendProgress({
2155
- card_count: refreshResult.card_count || cardNodeIds.length,
2156
- refresh_method: refreshResult.method || null,
2157
- refresh_forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view),
2158
- list_end_reason: listEndReason
2159
- });
3550
+ runControl.checkpoint({
3551
+ refresh_round: refreshRounds,
3552
+ refresh: compactRefresh
3553
+ });
3554
+ updateRecommendProgress({
3555
+ card_count: Number.isInteger(refreshResult.card_count)
3556
+ ? refreshResult.card_count
3557
+ : cardNodeIds.length,
3558
+ refresh_method: refreshResult.method || null,
3559
+ refresh_forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view),
3560
+ list_end_reason: listEndReason
3561
+ });
3562
+ if (refreshResult.exhausted && !isVerifiedRecommendRefreshCompletion(refreshResult)) {
3563
+ throw createRecommendRefreshFailureError(compactRefresh, {
3564
+ listEndReason,
3565
+ targetCount: targetPassCount,
3566
+ passedCount: countPassedResults(results)
3567
+ });
3568
+ }
3569
+ if (isVerifiedRecommendRefreshCompletion(refreshResult)) {
3570
+ if (refreshResult.current_city_only) {
3571
+ currentCityOnlyResult = refreshResult.current_city_only;
3572
+ }
3573
+ if (refreshResult.filter) {
3574
+ filterResult = refreshResult.filter;
3575
+ }
3576
+ cardNodeIds = [];
3577
+ listEndReason = "filtered_list_exhausted";
3578
+ updateRecommendProgress({
3579
+ card_count: 0,
3580
+ list_end_reason: listEndReason,
3581
+ refresh_exhausted: true,
3582
+ empty_state_verified: true
3583
+ });
3584
+ runControl.checkpoint({
3585
+ refresh_round: refreshRounds,
3586
+ refresh: compactRefresh,
3587
+ refresh_exhausted: true,
3588
+ list_end_reason: listEndReason,
3589
+ counters: countRecommendResultStatuses(results, { greetCount })
3590
+ });
3591
+ break;
3592
+ }
2160
3593
  if (refreshResult.ok) {
2161
3594
  if (refreshResult.current_city_only) {
2162
3595
  currentCityOnlyResult = refreshResult.current_city_only;
@@ -2208,12 +3641,241 @@ export async function runRecommendWorkflow({
2208
3641
  let cardCandidate = nextCandidateResult.item.candidate;
2209
3642
 
2210
3643
  let screeningCandidate = cardCandidate;
2211
- let detailResult = null;
2212
- let recoverableDetailError = null;
2213
- let colleagueContact = null;
2214
- let skipRecentColleagueContact = false;
2215
- let detailStep = "not_started";
2216
- if (index < effectiveDetailLimit) {
3644
+ let detailResult = null;
3645
+ let recoverableDetailError = null;
3646
+ let candidateLocalDetailFailurePending = false;
3647
+ let candidateLocalDetailTerminalError = null;
3648
+ let colleagueContact = null;
3649
+ let colleagueContactSkipReason = "";
3650
+ let detailStep = "not_started";
3651
+ let openedDetailState = null;
3652
+ currentDetailCandidateBinding = null;
3653
+
3654
+ async function requireCurrentDetailCandidateBinding(stage, {
3655
+ allowScroll = true,
3656
+ settleMs = 120
3657
+ } = {}) {
3658
+ const bindingContext = openedDetailState?.candidate_binding_context || {
3659
+ card_pre_click_provenance: currentDetailCandidateBinding?.card?.pre_click_provenance || null,
3660
+ detail_roots_before: currentDetailCandidateBinding?.detail?.roots_before_capture || null,
3661
+ expected_detail_root: currentDetailCandidateBinding?.detail?.root || null,
3662
+ allow_card_disappearance: true,
3663
+ card_click_evidence: currentDetailCandidateBinding?.card?.click_evidence || null,
3664
+ click_attempts: currentDetailCandidateBinding?.card?.click_attempts || []
3665
+ };
3666
+ const freshDetailState = await waitForRecommendDetail(client, {
3667
+ timeoutMs: 4000,
3668
+ intervalMs: 200
3669
+ });
3670
+ if (!freshDetailState?.popup && !freshDetailState?.resumeIframe) {
3671
+ throw createRecommendDetailCandidateBindingError({
3672
+ schema_version: 1,
3673
+ verified: false,
3674
+ reason: "detail_not_visible_during_reverification",
3675
+ method: null,
3676
+ expected_candidate_id: cardCandidate?.id || null,
3677
+ expected_name: cardCandidate?.identity?.name || null,
3678
+ card: currentDetailCandidateBinding?.card || null,
3679
+ detail: null
3680
+ });
3681
+ }
3682
+ openedDetailState = {
3683
+ ...freshDetailState,
3684
+ candidate_binding: currentDetailCandidateBinding,
3685
+ candidate_binding_context: bindingContext
3686
+ };
3687
+ const binding = await verifyRecommendDetailCandidateBinding(client, {
3688
+ cardNodeId,
3689
+ cardCandidate,
3690
+ detailState: freshDetailState,
3691
+ cardEvidenceBefore: bindingContext?.card_pre_click_provenance?.card || null,
3692
+ cardPreClickProvenance: bindingContext?.card_pre_click_provenance || null,
3693
+ detailRootsBefore: bindingContext?.detail_roots_before || null,
3694
+ // In zero-outbound screening mode Boss may replace the short-lived
3695
+ // loading dialog with the final resume popup. Re-prove a stable CV
3696
+ // target in the fresh popup instead of requiring the loading root's
3697
+ // backend identity to survive that legitimate replacement.
3698
+ expectedDetailRoot: screeningOnlyBindingEnabled
3699
+ ? null
3700
+ : bindingContext?.expected_detail_root || null,
3701
+ allowCardDisappearance: bindingContext?.allow_card_disappearance === true,
3702
+ cardClickEvidence: bindingContext?.card_click_evidence || null,
3703
+ clickAttempts: Array.isArray(bindingContext?.click_attempts)
3704
+ ? bindingContext.click_attempts
3705
+ : [],
3706
+ settleMs: allowScroll ? Math.max(0, Number(settleMs) || 0) : 0,
3707
+ allowScroll: allowScroll === true
3708
+ });
3709
+ currentDetailCandidateBinding = binding;
3710
+ if (openedDetailState) {
3711
+ openedDetailState = {
3712
+ ...openedDetailState,
3713
+ candidate_binding: binding,
3714
+ candidate_binding_context: bindingContext
3715
+ };
3716
+ }
3717
+ if (detailResult) detailResult.candidate_binding = binding;
3718
+ checkpointInProgressCandidate({
3719
+ index,
3720
+ candidateKey,
3721
+ cardNodeId,
3722
+ detailStep: stage || detailStep
3723
+ });
3724
+ const acceptedCandidateBinding = Boolean(
3725
+ binding.verified === true
3726
+ || (
3727
+ screeningOnlyBindingEnabled
3728
+ && binding.screening_verified === true
3729
+ )
3730
+ );
3731
+ if (!acceptedCandidateBinding) {
3732
+ throw createRecommendDetailCandidateBindingError(binding);
3733
+ }
3734
+ return binding;
3735
+ }
3736
+
3737
+ async function containCandidateLocalDetailBindingFailure(error, {
3738
+ phase = "recommend:detail-binding",
3739
+ operation = currentDomOperation,
3740
+ forensic = null
3741
+ } = {}) {
3742
+ if (candidateLocalDetailFailurePending) return;
3743
+ if (error?.detail_candidate_binding) {
3744
+ currentDetailCandidateBinding = error.detail_candidate_binding;
3745
+ }
3746
+ currentDetailCandidateBinding = preserveRecommendDetailCandidateBindingForRecovery(
3747
+ detailResult,
3748
+ currentDetailCandidateBinding
3749
+ );
3750
+ candidateLocalDetailFailurePending = true;
3751
+ const localForensic = forensic || checkpointDomStaleForensic(error, {
3752
+ phase,
3753
+ operation,
3754
+ detailStep,
3755
+ candidateIndex: index,
3756
+ candidateKey,
3757
+ cardNodeId
3758
+ });
3759
+ checkpointDomStaleRecovery(localForensic, {
3760
+ status: "candidate_failed_closed_no_context_recovery"
3761
+ });
3762
+ recoverableDetailError = error;
3763
+ detailResult = null;
3764
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
3765
+
3766
+ let detailCleanup = null;
3767
+ let detailCleanupError = null;
3768
+ try {
3769
+ detailCleanup = await closeRecommendDetail(client, { attemptsLimit: 2 });
3770
+ } catch (cleanupError) {
3771
+ detailCleanupError = cleanupError;
3772
+ }
3773
+ let avatarCleanup = null;
3774
+ let avatarCleanupError = null;
3775
+ try {
3776
+ avatarCleanup = await closeRecommendAvatarPreview(client, { attemptsLimit: 2 });
3777
+ } catch (cleanupError) {
3778
+ avatarCleanupError = cleanupError;
3779
+ }
3780
+ let cleanupRootState = null;
3781
+ let cleanupRootError = null;
3782
+ try {
3783
+ cleanupRootState = await getRecommendRoots(client);
3784
+ cleanupRootState = await ensureRecommendViewport(
3785
+ cleanupRootState,
3786
+ "detail_binding_candidate_local_cleanup"
3787
+ );
3788
+ } catch (cleanupError) {
3789
+ cleanupRootError = cleanupError;
3790
+ }
3791
+ let panelCleanup = null;
3792
+ let panelCleanupError = null;
3793
+ try {
3794
+ panelCleanup = await closeRecommendBlockingPanels(client, {
3795
+ attemptsLimit: 2,
3796
+ rootState: cleanupRootState || rootState
3797
+ });
3798
+ } catch (cleanupError) {
3799
+ panelCleanupError = cleanupError;
3800
+ }
3801
+ timings.detail_candidate_local_cleanup = {
3802
+ detail: compactCloseResult(detailCleanup),
3803
+ detail_error: detailCleanupError?.message || null,
3804
+ avatar_preview: compactCloseResult(avatarCleanup),
3805
+ avatar_preview_error: avatarCleanupError?.message || null,
3806
+ blocking_panels: compactCloseResult(panelCleanup),
3807
+ blocking_panels_error: panelCleanupError?.message || null,
3808
+ root_reacquired: Boolean(cleanupRootState?.iframe?.documentNodeId),
3809
+ root_error: cleanupRootError?.message || null,
3810
+ card_count: null
3811
+ };
3812
+ if (
3813
+ detailCleanup?.closed !== true
3814
+ || avatarCleanup?.closed !== true
3815
+ || panelCleanup?.closed !== true
3816
+ || detailCleanupError
3817
+ || avatarCleanupError
3818
+ || panelCleanupError
3819
+ || cleanupRootError
3820
+ || !cleanupRootState?.iframe?.documentNodeId
3821
+ ) {
3822
+ const cleanupError = new Error(
3823
+ "Recommend candidate-local detail binding cleanup did not restore a closed page state"
3824
+ );
3825
+ cleanupError.code = "RECOMMEND_DETAIL_LOCAL_CLEANUP_FAILED";
3826
+ cleanupError.phase = "recommend:detail-binding-cleanup";
3827
+ cleanupError.cleanup = timings.detail_candidate_local_cleanup;
3828
+ cleanupError.detail_candidate_binding = currentDetailCandidateBinding;
3829
+ candidateLocalDetailTerminalError = cleanupError;
3830
+ checkpointInProgressCandidate({
3831
+ index,
3832
+ candidateKey,
3833
+ cardNodeId,
3834
+ detailStep: "detail_binding_cleanup_failed",
3835
+ error: cleanupError
3836
+ });
3837
+ return;
3838
+ }
3839
+
3840
+ try {
3841
+ const freshCardNodeIds = await waitForRecommendCardNodeIds(
3842
+ client,
3843
+ cleanupRootState.iframe.documentNodeId,
3844
+ {
3845
+ timeoutMs: Math.min(cardTimeoutMs, 5000),
3846
+ intervalMs: 300
3847
+ }
3848
+ );
3849
+ if (!Array.isArray(freshCardNodeIds) || freshCardNodeIds.length === 0) {
3850
+ throw new Error("Recommend candidate-local cleanup reacquired no candidate cards");
3851
+ }
3852
+ rootState = cleanupRootState;
3853
+ cardNodeIds = freshCardNodeIds;
3854
+ timings.detail_candidate_local_cleanup.card_count = freshCardNodeIds.length;
3855
+ } catch (reacquireError) {
3856
+ const cleanupError = new Error(
3857
+ "Recommend candidate-local detail cleanup could not reacquire the candidate list"
3858
+ );
3859
+ cleanupError.code = "RECOMMEND_DETAIL_LOCAL_CLEANUP_FAILED";
3860
+ cleanupError.phase = "recommend:detail-binding-cleanup";
3861
+ cleanupError.cause = reacquireError;
3862
+ cleanupError.cleanup = {
3863
+ ...timings.detail_candidate_local_cleanup,
3864
+ reacquire_error: reacquireError?.message || String(reacquireError)
3865
+ };
3866
+ cleanupError.detail_candidate_binding = currentDetailCandidateBinding;
3867
+ candidateLocalDetailTerminalError = cleanupError;
3868
+ checkpointInProgressCandidate({
3869
+ index,
3870
+ candidateKey,
3871
+ cardNodeId,
3872
+ detailStep: "detail_binding_list_reacquire_failed",
3873
+ error: cleanupError
3874
+ });
3875
+ }
3876
+ }
3877
+
3878
+ if (index < effectiveDetailLimit) {
2217
3879
  try {
2218
3880
  await runControl.waitIfPaused();
2219
3881
  runControl.throwIfCanceled();
@@ -2247,65 +3909,93 @@ export async function runRecommendWorkflow({
2247
3909
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2248
3910
  networkRecorder.clear();
2249
3911
  await maybeHumanActionCooldown("before_detail_open", timings);
2250
- const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
3912
+ const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
2251
3913
  cardNodeId,
2252
3914
  candidateKey,
2253
3915
  cardCandidate,
2254
3916
  rootState,
2255
- targetUrl,
2256
- retryTimeoutMs: 8000,
2257
- maxAttempts: 3
2258
- });
3917
+ targetUrl,
3918
+ retryTimeoutMs: 8000,
3919
+ acceptScreeningBinding: screeningOnlyBindingEnabled,
3920
+ maxAttempts: 3
3921
+ });
2259
3922
  addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
2260
3923
  addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
2261
- cardNodeId = openedDetail.card_node_id || cardNodeId;
2262
- cardCandidate = openedDetail.card_candidate || cardCandidate;
2263
- screeningCandidate = cardCandidate;
3924
+ cardNodeId = openedDetail.card_node_id || cardNodeId;
3925
+ cardCandidate = openedDetail.card_candidate || cardCandidate;
3926
+ screeningCandidate = cardCandidate;
3927
+ openedDetailState = openedDetail.detail_state;
3928
+ currentDetailCandidateBinding = openedDetail.candidate_binding || openedDetail.detail_state?.candidate_binding || null;
3929
+ checkpointInProgressCandidate({
3930
+ index,
3931
+ candidateKey,
3932
+ cardNodeId,
3933
+ detailStep: "detail_binding_verified"
3934
+ });
2264
3935
  if (shouldSkipRecentColleagueContacted) {
2265
3936
  detailStep = "check_colleague_contact";
2266
3937
  currentDomOperation = "candidate:detail-colleague-contact";
3938
+ await requireCurrentDetailCandidateBinding("before_colleague_contact");
2267
3939
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2268
3940
  try {
2269
3941
  colleagueContact = await measureTiming(timings, "colleague_contact_check_ms", () => inspectRecentColleagueContact(
2270
3942
  client,
2271
- openedDetail.detail_state,
3943
+ openedDetailState,
2272
3944
  {
2273
3945
  referenceDate: colleagueContactReferenceDate,
2274
3946
  windowDays: normalizedColleagueContactWindowDays
2275
3947
  }
2276
3948
  ));
2277
- if (colleagueContact?.recent) {
2278
- skipRecentColleagueContact = true;
2279
- detailResult = {
2280
- candidate: screeningCandidate,
2281
- detail: {
2282
- popup_text: "",
2283
- resume_text: ""
2284
- },
2285
- colleague_contact: colleagueContact,
2286
- cv_acquisition: {
2287
- source: "skipped_recent_colleague_contact",
2288
- skipped: true,
2289
- reason: "skipped_recent_colleague_contact"
2290
- }
2291
- };
2292
- detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
2293
- await maybeHumanActionCooldown("after_detail_close", timings);
2294
- }
2295
- } catch (error) {
2296
- colleagueContact = {
2297
- checked: false,
2298
- recent: false,
2299
- reason: "inspection_failed",
2300
- error: error?.message || String(error),
2301
- window_days: normalizedColleagueContactWindowDays
2302
- };
2303
- }
2304
- }
2305
- if (!skipRecentColleagueContact) {
3949
+ } catch (error) {
3950
+ colleagueContact = {
3951
+ checked: false,
3952
+ panel_found: false,
3953
+ recent: null,
3954
+ indeterminate: true,
3955
+ reason: "inspection_failed",
3956
+ error: error?.message || String(error),
3957
+ window_days: normalizedColleagueContactWindowDays
3958
+ };
3959
+ }
3960
+ detailStep = "verify_after_colleague_contact";
3961
+ currentDomOperation = "candidate:detail-verify-after-colleague-contact";
3962
+ const boundColleagueResult = await bindRecommendColleagueContactInspectionResult(
3963
+ colleagueContact,
3964
+ { reverifyCandidateBinding: requireCurrentDetailCandidateBinding }
3965
+ );
3966
+ currentDetailCandidateBinding = boundColleagueResult.candidate_binding;
3967
+ colleagueContactSkipReason = boundColleagueResult.skip_reason;
3968
+ if (colleagueContactSkipReason) {
3969
+ detailStep = "colleague_contact_skip_result";
3970
+ currentDomOperation = "candidate:detail-colleague-contact-skip-result";
3971
+ await requireCurrentDetailCandidateBinding("before_colleague_contact_skip_result");
3972
+ detailResult = {
3973
+ candidate: screeningCandidate,
3974
+ candidate_binding: currentDetailCandidateBinding,
3975
+ detail: {
3976
+ popup_text: "",
3977
+ resume_text: ""
3978
+ },
3979
+ colleague_contact: colleagueContact,
3980
+ cv_acquisition: {
3981
+ source: colleagueContactSkipReason,
3982
+ skipped: true,
3983
+ reason: colleagueContactSkipReason
3984
+ }
3985
+ };
3986
+ detailStep = "colleague_contact_skip_close";
3987
+ currentDomOperation = "candidate:detail-colleague-contact-skip-close";
3988
+ await requireCurrentDetailCandidateBinding("before_colleague_contact_skip_close");
3989
+ detailResult.candidate_binding = currentDetailCandidateBinding;
3990
+ detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
3991
+ await maybeHumanActionCooldown("after_detail_close", timings);
3992
+ }
3993
+ }
3994
+ if (!colleagueContactSkipReason) {
2306
3995
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
2307
3996
  detailStep = "wait_network";
2308
3997
  currentDomOperation = "candidate:detail-network-wait";
3998
+ await requireCurrentDetailCandidateBinding("before_cv_network_wait");
2309
3999
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2310
4000
  const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
2311
4001
  waitForRecommendDetailNetworkEvents,
@@ -2322,11 +4012,12 @@ export async function runRecommendWorkflow({
2322
4012
  }
2323
4013
  detailStep = "extract_detail";
2324
4014
  currentDomOperation = "candidate:detail-extract";
4015
+ await requireCurrentDetailCandidateBinding("before_cv_extract");
2325
4016
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2326
- detailResult = await extractRecommendDetailCandidate(client, {
4017
+ detailResult = await extractRecommendDetailCandidate(client, {
2327
4018
  cardCandidate,
2328
4019
  cardNodeId,
2329
- detailState: openedDetail.detail_state,
4020
+ detailState: openedDetailState,
2330
4021
  networkEvents: networkRecorder.events,
2331
4022
  targetUrl,
2332
4023
  closeDetail: false,
@@ -2349,8 +4040,9 @@ export async function runRecommendWorkflow({
2349
4040
  } else {
2350
4041
  detailStep = "wait_capture_target";
2351
4042
  currentDomOperation = "candidate:detail-wait-capture-target";
4043
+ await requireCurrentDetailCandidateBinding("before_cv_capture_target");
2352
4044
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2353
- captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
4045
+ captureTargetWait = await waitForCvCaptureTarget(client, openedDetailState, {
2354
4046
  domain: "recommend",
2355
4047
  timeoutMs: 6000,
2356
4048
  intervalMs: 250
@@ -2404,10 +4096,38 @@ export async function runRecommendWorkflow({
2404
4096
  }
2405
4097
  }
2406
4098
  );
4099
+ const reacquireCaptureTargetAfterBinding = async ({
4100
+ timeoutMs = 4000,
4101
+ intervalMs = 200
4102
+ } = {}) => {
4103
+ const reboundWait = await waitForCvCaptureTarget(client, openedDetailState, {
4104
+ domain: "recommend",
4105
+ timeoutMs,
4106
+ intervalMs
4107
+ });
4108
+ const reboundTarget = reboundWait?.target || null;
4109
+ if (!reboundTarget?.node_id) {
4110
+ const error = new Error(
4111
+ "Recommend CV capture target was unavailable after binding re-verification"
4112
+ );
4113
+ error.code = "IMAGE_CAPTURE_TARGET_UNAVAILABLE";
4114
+ error.capture_target_wait = reboundWait || null;
4115
+ throw error;
4116
+ }
4117
+ captureTargetWait = reboundWait;
4118
+ captureTarget = reboundTarget;
4119
+ return reboundTarget;
4120
+ };
2407
4121
  try {
2408
4122
  detailStep = "capture_image";
2409
4123
  currentDomOperation = "candidate:detail-capture-image";
4124
+ await requireCurrentDetailCandidateBinding("before_cv_image_capture");
2410
4125
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
4126
+ // Binding re-verification obtains a fresh document tree and can
4127
+ // invalidate frontend node ids returned by the earlier target
4128
+ // discovery. Reacquire the contained CV node after that proof,
4129
+ // then capture it without any intervening DOM-root read.
4130
+ await reacquireCaptureTargetAfterBinding();
2411
4131
  imageEvidence = await measureTiming(
2412
4132
  timings,
2413
4133
  "screenshot_capture_ms",
@@ -2449,11 +4169,15 @@ export async function runRecommendWorkflow({
2449
4169
  intervalMs: 200
2450
4170
  })
2451
4171
  }),
2452
- capture: (reacquired) => {
4172
+ capture: async (reacquired) => {
2453
4173
  rootState = reacquired.root_state;
4174
+ openedDetailState = reacquired.detail_state;
2454
4175
  captureTarget = reacquired.target;
2455
4176
  captureTargetWait = reacquired.target_wait;
2456
4177
  detailStep = "resume_image_capture";
4178
+ currentDomOperation = "candidate:detail-resume-image-capture";
4179
+ await requireCurrentDetailCandidateBinding("before_resumed_cv_image_capture");
4180
+ await reacquireCaptureTargetAfterBinding();
2457
4181
  return measureTiming(
2458
4182
  timings,
2459
4183
  "screenshot_capture_ms",
@@ -2586,19 +4310,32 @@ export async function runRecommendWorkflow({
2586
4310
  }
2587
4311
 
2588
4312
  detailResult.image_evidence = imageEvidence;
2589
- detailResult.cv_acquisition = {
4313
+ detailResult.cv_acquisition = {
2590
4314
  source,
2591
4315
  mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
2592
4316
  wait_plan: waitPlan,
2593
- network_wait: networkWait,
2594
- parsed_network_profile_count: parsedNetworkProfileCount,
2595
- image_evidence: summarizeImageEvidence(imageEvidence),
4317
+ network_wait: networkWait,
4318
+ parsed_network_profile_count: parsedNetworkProfileCount,
4319
+ network_profile_binding: detailResult.network_profile_binding || null,
4320
+ image_evidence: summarizeImageEvidence(imageEvidence),
2596
4321
  capture_target: captureTarget || null,
2597
- capture_target_wait: captureTargetWait
2598
- };
2599
- screeningCandidate = detailResult.candidate;
2600
- }
4322
+ capture_target_wait: captureTargetWait
4323
+ };
4324
+ detailStep = "verify_before_screening";
4325
+ currentDomOperation = "candidate:detail-verify-before-screening";
4326
+ await requireCurrentDetailCandidateBinding("before_llm_or_local_screening");
4327
+ detailResult.candidate_binding = currentDetailCandidateBinding;
4328
+ screeningCandidate = detailResult.candidate;
4329
+ detailResult.screening_candidate_identity = assertRecommendScreeningCandidateMatchesCard({
4330
+ cardCandidate,
4331
+ screeningCandidate,
4332
+ stage: "before_screening_candidate_assignment"
4333
+ });
4334
+ }
2601
4335
  } catch (error) {
4336
+ if (error?.detail_candidate_binding) {
4337
+ currentDetailCandidateBinding = error.detail_candidate_binding;
4338
+ }
2602
4339
  if (!isRecoverableRecommendDetailError(error)) throw error;
2603
4340
  const staleForensic = checkpointDomStaleForensic(error, {
2604
4341
  phase: "recommend:detail",
@@ -2608,54 +4345,81 @@ export async function runRecommendWorkflow({
2608
4345
  candidateKey,
2609
4346
  cardNodeId
2610
4347
  });
2611
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
2612
- if (recoveryCount < 1) {
2613
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
2614
- timings.detail_recovery_trigger = compactRecoverableDetailError(error);
2615
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
4348
+ currentDetailCandidateBinding = preserveRecommendDetailCandidateBindingForRecovery(
4349
+ detailResult,
4350
+ currentDetailCandidateBinding
4351
+ );
4352
+ const failureDisposition = getRecommendDetailFailureDisposition(error);
4353
+ if (failureDisposition.candidate_local) {
4354
+ await containCandidateLocalDetailBindingFailure(error, {
4355
+ phase: "recommend:detail",
4356
+ operation: currentDomOperation,
4357
+ forensic: staleForensic
4358
+ });
4359
+ } else {
4360
+ const recoveryReservation = reserveRecommendDetailRecovery(
4361
+ candidateRecoveryCounts,
4362
+ candidateKey,
4363
+ 1
4364
+ );
4365
+ if (recoveryReservation.allowed) {
4366
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
4367
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
4368
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
4369
+ await closeRecommendAvatarPreview(client, { attemptsLimit: 2 }).catch(() => null);
4370
+ await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
4371
+ try {
4372
+ const recovery = await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
4373
+ forceRecentNotView: true
4374
+ });
4375
+ checkpointDomStaleRecovery(staleForensic, {
4376
+ status: "recovered",
4377
+ recoveryResult: recovery
4378
+ });
4379
+ } catch (recoveryError) {
4380
+ checkpointDomStaleRecovery(staleForensic, {
4381
+ status: "recovery_failed",
4382
+ recoveryError
4383
+ });
4384
+ throw recoveryError;
4385
+ }
4386
+ continue;
4387
+ }
4388
+ checkpointDomStaleRecovery(staleForensic, { status: "candidate_failed_closed" });
4389
+ recoverableDetailError = error;
4390
+ detailResult = null;
4391
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
2616
4392
  await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
2617
4393
  await closeRecommendAvatarPreview(client, { attemptsLimit: 2 }).catch(() => null);
2618
4394
  await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
2619
- try {
2620
- const recovery = await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
2621
- forceRecentNotView: true
2622
- });
2623
- checkpointDomStaleRecovery(staleForensic, {
2624
- status: "recovered",
2625
- recoveryResult: recovery
2626
- });
2627
- } catch (recoveryError) {
2628
- checkpointDomStaleRecovery(staleForensic, {
2629
- status: "recovery_failed",
2630
- recoveryError
2631
- });
2632
- throw recoveryError;
2633
- }
2634
- continue;
2635
4395
  }
2636
- checkpointDomStaleRecovery(staleForensic, { status: "candidate_failed_closed" });
2637
- recoverableDetailError = error;
2638
- detailResult = null;
2639
- timings.detail_recovered_error = compactRecoverableDetailError(error);
2640
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
2641
- await closeRecommendAvatarPreview(client, { attemptsLimit: 2 }).catch(() => null);
2642
- await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
2643
- }
4396
+ }
2644
4397
  }
2645
4398
 
2646
- await runControl.waitIfPaused();
2647
- runControl.throwIfCanceled();
4399
+ if (!candidateLocalDetailFailurePending) {
4400
+ await runControl.waitIfPaused();
4401
+ runControl.throwIfCanceled();
4402
+ }
2648
4403
  runControl.setPhase("recommend:screening");
2649
4404
  let llmResult = null;
2650
4405
  const imageAcquisitionFailed = shouldFailClosedRecommendImageAcquisition(detailResult);
2651
4406
  if (useLlmScreening) {
2652
- if (skipRecentColleagueContact || recoverableDetailError || imageAcquisitionFailed) {
4407
+ if (colleagueContactSkipReason || recoverableDetailError || imageAcquisitionFailed) {
2653
4408
  llmResult = null;
2654
- } else if (!llmConfig) {
2655
- llmResult = createMissingLlmConfigResult();
2656
- } else {
2657
- try {
2658
- const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
4409
+ } else if (!llmConfig) {
4410
+ llmResult = createMissingLlmConfigResult();
4411
+ } else {
4412
+ try {
4413
+ detailStep = "llm_screening_binding";
4414
+ currentDomOperation = "candidate:detail-verify-before-llm";
4415
+ await requireCurrentDetailCandidateBinding("immediately_before_llm_screening");
4416
+ detailResult.candidate_binding = currentDetailCandidateBinding;
4417
+ detailResult.screening_candidate_identity = assertRecommendScreeningCandidateMatchesCard({
4418
+ cardCandidate,
4419
+ screeningCandidate,
4420
+ stage: "immediately_before_llm_screening"
4421
+ });
4422
+ const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
2659
4423
  ? "vision_model_ms"
2660
4424
  : "text_model_ms";
2661
4425
  llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
@@ -2666,22 +4430,33 @@ export async function runRecommendWorkflow({
2666
4430
  imageEvidence: detailResult?.image_evidence || null,
2667
4431
  maxImages: llmImageLimit,
2668
4432
  imageDetail: llmImageDetail
2669
- }));
2670
- } catch (error) {
2671
- if (isFatalLlmProviderError(error)) {
2672
- throw createFatalLlmRunError(error, {
2673
- domain: "recommend",
2674
- candidate: screeningCandidate
2675
- });
2676
- }
2677
- llmResult = createFailedLlmScreeningResult(error);
2678
- }
4433
+ }));
4434
+ } catch (error) {
4435
+ if (error?.code === "RECOMMEND_SCREENING_CANDIDATE_IDENTITY_MISMATCH") {
4436
+ throw error;
4437
+ } else if (isRecommendDetailCandidateBindingError(error)) {
4438
+ await containCandidateLocalDetailBindingFailure(error, {
4439
+ phase: "recommend:llm-binding",
4440
+ operation: currentDomOperation
4441
+ });
4442
+ llmResult = null;
4443
+ } else if (isFatalLlmProviderError(error)) {
4444
+ throw createFatalLlmRunError(error, {
4445
+ domain: "recommend",
4446
+ candidate: screeningCandidate
4447
+ });
4448
+ } else {
4449
+ llmResult = createFailedLlmScreeningResult(error);
4450
+ }
4451
+ }
2679
4452
  }
2680
4453
  if (detailResult) detailResult.llm_result = llmResult;
2681
4454
  }
2682
- const screening = skipRecentColleagueContact
2683
- ? createRecentColleagueContactSkipScreening(screeningCandidate, colleagueContact)
2684
- : recoverableDetailError
4455
+ let screening = colleagueContactSkipReason === "skipped_recent_colleague_contact"
4456
+ ? createRecentColleagueContactSkipScreening(screeningCandidate, colleagueContact)
4457
+ : colleagueContactSkipReason === "colleague_contact_unverified"
4458
+ ? createUnverifiedColleagueContactSkipScreening(screeningCandidate, colleagueContact)
4459
+ : recoverableDetailError
2685
4460
  ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
2686
4461
  : imageAcquisitionFailed
2687
4462
  ? createImageCaptureFailureScreening(screeningCandidate, {
@@ -2695,7 +4470,12 @@ export async function runRecommendWorkflow({
2695
4470
  let postActionResult = null;
2696
4471
  let closeFailureError = null;
2697
4472
  let closeRecoveryFailure = null;
2698
- if (postActionEnabled && detailResult && !skipRecentColleagueContact) {
4473
+ if (
4474
+ postActionEnabled
4475
+ && screening?.passed === true
4476
+ && detailResult
4477
+ && !colleagueContactSkipReason
4478
+ ) {
2699
4479
  try {
2700
4480
  const postActionStarted = Date.now();
2701
4481
  await runControl.waitIfPaused();
@@ -2703,6 +4483,8 @@ export async function runRecommendWorkflow({
2703
4483
  runControl.setPhase("recommend:post-action");
2704
4484
  detailStep = "post_action_discovery";
2705
4485
  currentDomOperation = "candidate:post-action-discovery";
4486
+ await requireCurrentDetailCandidateBinding("before_post_action_discovery");
4487
+ detailResult.candidate_binding = currentDetailCandidateBinding;
2706
4488
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2707
4489
  await maybeHumanActionCooldown("before_post_action", timings);
2708
4490
  actionDiscovery = await waitForRecommendDetailActionControls(client, {
@@ -2712,6 +4494,8 @@ export async function runRecommendWorkflow({
2712
4494
  });
2713
4495
  detailStep = "post_action_execute";
2714
4496
  currentDomOperation = "candidate:post-action-execute";
4497
+ await requireCurrentDetailCandidateBinding("before_post_action_execute");
4498
+ detailResult.candidate_binding = currentDetailCandidateBinding;
2715
4499
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2716
4500
  postActionResult = await runRecommendPostAction({
2717
4501
  client,
@@ -2721,14 +4505,27 @@ export async function runRecommendWorkflow({
2721
4505
  greetCount,
2722
4506
  maxGreetCount: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
2723
4507
  executePostAction,
2724
- afterClickDelayMs: actionAfterClickDelayMs
4508
+ afterClickDelayMs: actionAfterClickDelayMs,
4509
+ candidateId: screeningCandidate.id || cardCandidate.id || "",
4510
+ candidateBinding: currentDetailCandidateBinding,
4511
+ reverifyCandidateBinding: async (stage, options = {}) => {
4512
+ await requireCurrentDetailCandidateBinding(stage, options);
4513
+ detailResult.candidate_binding = currentDetailCandidateBinding;
4514
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep: stage });
4515
+ return currentDetailCandidateBinding;
4516
+ },
4517
+ actionJournal: effectiveActionJournal,
4518
+ actionJournalScope,
4519
+ reverifyActionJournalScope,
4520
+ runId: runControl.runId,
4521
+ checkpointCritical: (patch) => runControl.checkpointCritical(patch)
2725
4522
  });
2726
4523
  if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
2727
4524
  greetCount += 1;
2728
4525
  }
2729
4526
  addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
2730
4527
  } catch (error) {
2731
- checkpointDomStaleForensic(error, {
4528
+ const postActionForensic = checkpointDomStaleForensic(error, {
2732
4529
  phase: "recommend:post-action",
2733
4530
  operation: currentDomOperation,
2734
4531
  detailStep,
@@ -2736,9 +4533,150 @@ export async function runRecommendWorkflow({
2736
4533
  candidateKey,
2737
4534
  cardNodeId
2738
4535
  });
2739
- throw error;
4536
+ if (isRecommendDetailCandidateBindingError(error)) {
4537
+ await containCandidateLocalDetailBindingFailure(error, {
4538
+ phase: "recommend:post-action-binding",
4539
+ operation: currentDomOperation,
4540
+ forensic: postActionForensic
4541
+ });
4542
+ screening = createRecoverableDetailFailureScreening(screeningCandidate, error);
4543
+ actionDiscovery = null;
4544
+ postActionResult = null;
4545
+ } else {
4546
+ throw error;
4547
+ }
2740
4548
  }
2741
4549
  }
4550
+ if (postActionResult?.stop_run || postActionResult?.outcome_unknown) {
4551
+ const preservePostInputDetail = postActionResult?.preserve_detail_on_terminal === true;
4552
+ timings.total_ms = Date.now() - candidateStarted;
4553
+ const stopResult = {
4554
+ index,
4555
+ candidate_key: candidateKey,
4556
+ card_node_id: cardNodeId,
4557
+ candidate_binding: compactRecommendDetailCandidateBinding(currentDetailCandidateBinding),
4558
+ candidate: compactCandidate(screeningCandidate),
4559
+ detail: compactDetail(detailResult),
4560
+ llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
4561
+ screening: compactScreening(screening),
4562
+ action_discovery: compactActionDiscovery(actionDiscovery),
4563
+ post_action: postActionResult,
4564
+ error: recoverableDetailError
4565
+ ? compactRecoverableDetailError(recoverableDetailError)
4566
+ : detailResult?.image_evidence?.ok === false
4567
+ ? compactError({
4568
+ code: detailResult.image_evidence.error_code,
4569
+ message: detailResult.image_evidence.error
4570
+ }, "IMAGE_CAPTURE_FAILED")
4571
+ : null,
4572
+ timings
4573
+ };
4574
+ results.push(stopResult);
4575
+ markInfiniteListCandidateProcessed(listState, candidateKey, {
4576
+ metadata: {
4577
+ result_index: index,
4578
+ candidate_id: screeningCandidate.id || null
4579
+ }
4580
+ });
4581
+ updateRecommendProgress({
4582
+ last_candidate_id: screeningCandidate.id || null,
4583
+ last_candidate_key: candidateKey,
4584
+ last_score: screening.score
4585
+ });
4586
+ const terminalCandidateCheckpoint = {
4587
+ in_progress_candidate: null,
4588
+ results,
4589
+ preserve_detail_on_terminal: preservePostInputDetail,
4590
+ action_result_critical_persisted: !preservePostInputDetail,
4591
+ terminal_preservation: preservePostInputDetail
4592
+ ? postActionResult.terminal_preservation || {
4593
+ required: true,
4594
+ reason: "post_input_action_journal_persistence_failed",
4595
+ candidate_id: screeningCandidate.id || null,
4596
+ action_state: postActionResult?.action_transaction?.state || null,
4597
+ result_index: index
4598
+ }
4599
+ : null,
4600
+ last_candidate: {
4601
+ id: screeningCandidate.id || null,
4602
+ key: candidateKey,
4603
+ identity: screeningCandidate.identity || {},
4604
+ candidate_binding: compactRecommendDetailCandidateBinding(currentDetailCandidateBinding),
4605
+ screening: {
4606
+ status: screening.status,
4607
+ passed: screening.passed,
4608
+ score: screening.score
4609
+ },
4610
+ llm_screening: compactScreeningLlmResult(llmResult),
4611
+ error: stopResult.error,
4612
+ post_action: postActionResult
4613
+ }
4614
+ };
4615
+ checkpointRecommendPostActionStopResult(runControl, terminalCandidateCheckpoint, {
4616
+ candidateResult: stopResult,
4617
+ candidateId: screeningCandidate.id || "",
4618
+ actionState: postActionResult?.action_transaction?.state || null,
4619
+ resultIndex: index
4620
+ });
4621
+ listEndReason = postActionResult.reason || "post_action_stop";
4622
+ break;
4623
+ }
4624
+ if (
4625
+ postActionResult?.action_clicked === true
4626
+ && postActionResult?.action_transaction?.state === "greeting_confirmed"
4627
+ ) {
4628
+ const durablePostActionResult = {
4629
+ index,
4630
+ candidate_key: candidateKey,
4631
+ card_node_id: cardNodeId,
4632
+ candidate_binding: compactRecommendDetailCandidateBinding(currentDetailCandidateBinding),
4633
+ candidate: compactCandidate(screeningCandidate),
4634
+ detail: compactDetail(detailResult),
4635
+ llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
4636
+ screening: compactScreening(screening),
4637
+ action_discovery: compactActionDiscovery(actionDiscovery),
4638
+ post_action: postActionResult,
4639
+ error: recoverableDetailError
4640
+ ? compactRecoverableDetailError(recoverableDetailError)
4641
+ : detailResult?.image_evidence?.ok === false
4642
+ ? compactError({
4643
+ code: detailResult.image_evidence.error_code,
4644
+ message: detailResult.image_evidence.error
4645
+ }, "IMAGE_CAPTURE_FAILED")
4646
+ : null,
4647
+ timings: {
4648
+ ...timings,
4649
+ total_ms: Date.now() - candidateStarted
4650
+ },
4651
+ provisional_before_detail_cleanup: true
4652
+ };
4653
+ checkpointRecommendPostActionStopResult(runControl, {
4654
+ in_progress_candidate: null,
4655
+ results: [...results, durablePostActionResult],
4656
+ preserve_detail_on_terminal: false,
4657
+ action_result_critical_persisted: true,
4658
+ post_action_candidate_result_persisted: true,
4659
+ last_candidate: {
4660
+ id: screeningCandidate.id || null,
4661
+ key: candidateKey,
4662
+ identity: screeningCandidate.identity || {},
4663
+ candidate_binding: compactRecommendDetailCandidateBinding(currentDetailCandidateBinding),
4664
+ screening: {
4665
+ status: screening.status,
4666
+ passed: screening.passed,
4667
+ score: screening.score
4668
+ },
4669
+ llm_screening: compactScreeningLlmResult(llmResult),
4670
+ error: durablePostActionResult.error,
4671
+ post_action: postActionResult
4672
+ }
4673
+ }, {
4674
+ candidateResult: durablePostActionResult,
4675
+ candidateId: screeningCandidate.id || "",
4676
+ actionState: "greeting_confirmed",
4677
+ resultIndex: index
4678
+ });
4679
+ }
2742
4680
  if (detailResult && closeDetail && !detailResult.close_result?.closed) {
2743
4681
  runControl.setPhase("recommend:close-detail");
2744
4682
  detailStep = "close_detail";
@@ -2794,9 +4732,10 @@ export async function runRecommendWorkflow({
2794
4732
  timings.total_ms = Date.now() - candidateStarted;
2795
4733
  const compactResult = {
2796
4734
  index,
2797
- candidate_key: candidateKey,
2798
- card_node_id: cardNodeId,
2799
- candidate: compactCandidate(screeningCandidate),
4735
+ candidate_key: candidateKey,
4736
+ card_node_id: cardNodeId,
4737
+ candidate_binding: compactRecommendDetailCandidateBinding(currentDetailCandidateBinding),
4738
+ candidate: compactCandidate(screeningCandidate),
2800
4739
  detail: compactDetail(detailResult),
2801
4740
  llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
2802
4741
  screening: compactScreening(screening),
@@ -2826,15 +4765,39 @@ export async function runRecommendWorkflow({
2826
4765
  last_candidate_id: screeningCandidate.id || null,
2827
4766
  last_candidate_key: candidateKey,
2828
4767
  last_score: screening.score
2829
- });
4768
+ });
2830
4769
  const checkpointStarted = Date.now();
2831
- runControl.checkpoint({
4770
+ const completedCandidateCheckpoint = {
2832
4771
  in_progress_candidate: null,
2833
4772
  results,
2834
- last_candidate: {
4773
+ candidate_list: compactInfiniteListState(listState),
4774
+ candidate_local_detail_failure: candidateLocalDetailFailurePending
4775
+ ? {
4776
+ required: true,
4777
+ terminal: Boolean(candidateLocalDetailTerminalError),
4778
+ candidate_id: screeningCandidate.id || null,
4779
+ candidate_key: candidateKey,
4780
+ result_index: index,
4781
+ binding: compactRecommendDetailCandidateBinding(currentDetailCandidateBinding),
4782
+ cleanup: timings.detail_candidate_local_cleanup || null
4783
+ }
4784
+ : null,
4785
+ candidate_local_detail_terminal: candidateLocalDetailTerminalError
4786
+ ? {
4787
+ required: true,
4788
+ code: candidateLocalDetailTerminalError.code || null,
4789
+ phase: candidateLocalDetailTerminalError.phase || null,
4790
+ candidate_id: screeningCandidate.id || null,
4791
+ candidate_key: candidateKey,
4792
+ result_index: index,
4793
+ cleanup: candidateLocalDetailTerminalError.cleanup || null
4794
+ }
4795
+ : null,
4796
+ last_candidate: {
2835
4797
  id: screeningCandidate.id || null,
2836
- key: candidateKey,
2837
- identity: screeningCandidate.identity || {},
4798
+ key: candidateKey,
4799
+ identity: screeningCandidate.identity || {},
4800
+ candidate_binding: compactRecommendDetailCandidateBinding(currentDetailCandidateBinding),
2838
4801
  screening: {
2839
4802
  status: screening.status,
2840
4803
  passed: screening.passed,
@@ -2842,12 +4805,28 @@ export async function runRecommendWorkflow({
2842
4805
  },
2843
4806
  llm_screening: compactScreeningLlmResult(llmResult),
2844
4807
  error: compactResult.error,
2845
- post_action: postActionResult
2846
- }
2847
- });
2848
- addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
2849
-
2850
- if (closeRecoveryFailure) {
4808
+ post_action: postActionResult
4809
+ }
4810
+ };
4811
+ if (candidateLocalDetailFailurePending) {
4812
+ runControl.checkpointCritical(completedCandidateCheckpoint);
4813
+ } else {
4814
+ runControl.checkpoint(completedCandidateCheckpoint);
4815
+ }
4816
+ addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
4817
+
4818
+ if (candidateLocalDetailTerminalError) {
4819
+ candidateLocalDetailTerminalError.candidate_result_persisted = true;
4820
+ candidateLocalDetailTerminalError.candidate_result_index = index;
4821
+ throw candidateLocalDetailTerminalError;
4822
+ }
4823
+
4824
+ if (candidateLocalDetailFailurePending) {
4825
+ await runControl.waitIfPaused();
4826
+ runControl.throwIfCanceled();
4827
+ }
4828
+
4829
+ if (closeRecoveryFailure) {
2851
4830
  throw closeRecoveryFailure;
2852
4831
  }
2853
4832
 
@@ -2921,10 +4900,33 @@ export async function runRecommendWorkflow({
2921
4900
  transient_recovered: countRecommendResultStatuses(results, { greetCount }).transient_recovered
2922
4901
  + listReadStaleRecoveries,
2923
4902
  results
2924
- };
2925
- }
2926
-
2927
- export function createRecommendRunService({
4903
+ };
4904
+ }
4905
+
4906
+ function createRecommendPostActionTerminalFailure(summary = null) {
4907
+ const results = Array.isArray(summary?.results) ? summary.results : [];
4908
+ const stoppedResult = [...results].reverse().find((result) => (
4909
+ result?.post_action?.stop_run === true
4910
+ )) || null;
4911
+ const postAction = stoppedResult?.post_action || null;
4912
+ if (!postAction) return null;
4913
+ if (
4914
+ postAction.out_of_greet_credits === true
4915
+ || postAction.reason === "greet_credits_exhausted"
4916
+ ) return null;
4917
+ const reason = String(postAction.reason || summary?.list_end_reason || "unknown").trim();
4918
+ const error = new Error(`Recommend post-action terminal failure: ${reason}`);
4919
+ error.code = postAction.outcome_unknown === true
4920
+ ? "RECOMMEND_GREET_OUTCOME_UNKNOWN"
4921
+ : postAction.pre_input_aborted === true
4922
+ ? "RECOMMEND_GREET_PRE_INPUT_ABORTED"
4923
+ : "RECOMMEND_POST_ACTION_TERMINAL_FAILURE";
4924
+ error.phase = "recommend:post-action-terminal";
4925
+ error.run_summary = summary;
4926
+ return error;
4927
+ }
4928
+
4929
+ export function createRecommendRunService({
2928
4930
  lifecycle,
2929
4931
  idPrefix = "recommend",
2930
4932
  workflow = runRecommendWorkflow,
@@ -2962,9 +4964,12 @@ export function createRecommendRunService({
2962
4964
  postAction = "none",
2963
4965
  maxGreetCount = null,
2964
4966
  executePostAction = true,
2965
- actionTimeoutMs = 8000,
2966
- actionIntervalMs = 500,
2967
- actionAfterClickDelayMs = 900,
4967
+ actionTimeoutMs = 8000,
4968
+ actionIntervalMs = 500,
4969
+ actionAfterClickDelayMs = 900,
4970
+ actionJournal = null,
4971
+ actionJournalScope = "boss-recommend:default",
4972
+ reverifyActionJournalScope = null,
2968
4973
  screeningMode = "llm",
2969
4974
  llmConfig = null,
2970
4975
  llmTimeoutMs = 120000,
@@ -3033,7 +5038,9 @@ export function createRecommendRunService({
3033
5038
  post_action: normalizedPostAction,
3034
5039
  max_greet_count: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
3035
5040
  execute_post_action: Boolean(executePostAction),
3036
- action_timeout_ms: actionTimeoutMs,
5041
+ action_timeout_ms: actionTimeoutMs,
5042
+ action_journal_enabled: Boolean(normalizedPostAction !== "none" && executePostAction),
5043
+ action_journal_scope: actionJournalScope,
3037
5044
  screening_mode: normalizedScreeningMode,
3038
5045
  llm_configured: Boolean(llmConfig),
3039
5046
  llm_timeout_ms: llmTimeoutMs,
@@ -3086,53 +5093,61 @@ export function createRecommendRunService({
3086
5093
  debug_boundary_trigger_count: 0
3087
5094
  },
3088
5095
  checkpoint: {},
3089
- task: (runControl) => workflow({
3090
- client,
3091
- targetUrl,
3092
- criteria,
3093
- jobLabel,
3094
- pageScope: requestedPageScope,
3095
- fallbackPageScope: normalizedFallbackPageScope,
3096
- filter: normalizedFilter,
3097
- maxCandidates,
3098
- detailLimit: normalizedDetailLimit,
3099
- closeDetail,
3100
- delayMs,
3101
- cardTimeoutMs,
3102
- maxImagePages,
3103
- imageWheelDeltaY,
3104
- cvAcquisitionMode,
3105
- listMaxScrolls,
3106
- listStableSignatureLimit,
3107
- listWheelDeltaY,
3108
- listSettleMs,
3109
- listFallbackPoint,
3110
- refreshOnEnd,
3111
- maxRefreshRounds,
3112
- refreshButtonSettleMs,
3113
- refreshReloadSettleMs,
3114
- postAction: normalizedPostAction,
3115
- maxGreetCount,
3116
- executePostAction,
3117
- actionTimeoutMs,
3118
- actionIntervalMs,
3119
- actionAfterClickDelayMs,
3120
- screeningMode: normalizedScreeningMode,
3121
- llmConfig,
3122
- llmTimeoutMs,
3123
- llmImageLimit,
3124
- llmImageDetail,
3125
- imageOutputDir,
3126
- humanRestEnabled: effectiveHumanRestEnabled,
3127
- humanBehavior: effectiveHumanBehavior,
3128
- skipRecentColleagueContacted: shouldSkipRecentColleagueContacted,
3129
- colleagueContactWindowDays: normalizedColleagueContactWindowDays,
3130
- debugTestMode: debugBoundaryOptions.debugTestMode,
3131
- debugForceListEndAfterProcessed: debugBoundaryOptions.debugForceListEndAfterProcessed,
3132
- debugForceContextRecoveryAfterProcessed: debugBoundaryOptions.debugForceContextRecoveryAfterProcessed,
3133
- debugForceCdpReconnectAfterProcessed: debugBoundaryOptions.debugForceCdpReconnectAfterProcessed
3134
- }, runControl)
3135
- });
5096
+ task: async (runControl) => {
5097
+ const summary = await workflow({
5098
+ client,
5099
+ targetUrl,
5100
+ criteria,
5101
+ jobLabel,
5102
+ pageScope: requestedPageScope,
5103
+ fallbackPageScope: normalizedFallbackPageScope,
5104
+ filter: normalizedFilter,
5105
+ maxCandidates,
5106
+ detailLimit: normalizedDetailLimit,
5107
+ closeDetail,
5108
+ delayMs,
5109
+ cardTimeoutMs,
5110
+ maxImagePages,
5111
+ imageWheelDeltaY,
5112
+ cvAcquisitionMode,
5113
+ listMaxScrolls,
5114
+ listStableSignatureLimit,
5115
+ listWheelDeltaY,
5116
+ listSettleMs,
5117
+ listFallbackPoint,
5118
+ refreshOnEnd,
5119
+ maxRefreshRounds,
5120
+ refreshButtonSettleMs,
5121
+ refreshReloadSettleMs,
5122
+ postAction: normalizedPostAction,
5123
+ maxGreetCount,
5124
+ executePostAction,
5125
+ actionTimeoutMs,
5126
+ actionIntervalMs,
5127
+ actionAfterClickDelayMs,
5128
+ actionJournal,
5129
+ actionJournalScope,
5130
+ reverifyActionJournalScope,
5131
+ screeningMode: normalizedScreeningMode,
5132
+ llmConfig,
5133
+ llmTimeoutMs,
5134
+ llmImageLimit,
5135
+ llmImageDetail,
5136
+ imageOutputDir,
5137
+ humanRestEnabled: effectiveHumanRestEnabled,
5138
+ humanBehavior: effectiveHumanBehavior,
5139
+ skipRecentColleagueContacted: shouldSkipRecentColleagueContacted,
5140
+ colleagueContactWindowDays: normalizedColleagueContactWindowDays,
5141
+ debugTestMode: debugBoundaryOptions.debugTestMode,
5142
+ debugForceListEndAfterProcessed: debugBoundaryOptions.debugForceListEndAfterProcessed,
5143
+ debugForceContextRecoveryAfterProcessed: debugBoundaryOptions.debugForceContextRecoveryAfterProcessed,
5144
+ debugForceCdpReconnectAfterProcessed: debugBoundaryOptions.debugForceCdpReconnectAfterProcessed
5145
+ }, runControl);
5146
+ const terminalFailure = createRecommendPostActionTerminalFailure(summary);
5147
+ if (terminalFailure) throw terminalFailure;
5148
+ return summary;
5149
+ }
5150
+ });
3136
5151
  }
3137
5152
 
3138
5153
  return {