@reconcrap/boss-recommend-mcp 2.1.23 → 2.1.24

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.
Files changed (66) hide show
  1. package/README.md +5 -0
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/package.json +6 -1
  4. package/scripts/install-macos.sh +280 -280
  5. package/scripts/postinstall.cjs +44 -44
  6. package/skills/boss-chat/README.md +43 -42
  7. package/skills/boss-chat/SKILL.md +107 -106
  8. package/skills/boss-recommend-pipeline/README.md +13 -13
  9. package/skills/boss-recommend-pipeline/SKILL.md +47 -47
  10. package/skills/boss-recruit-pipeline/README.md +19 -19
  11. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  12. package/src/chat-mcp.js +301 -127
  13. package/src/core/boss-cards/index.js +199 -199
  14. package/src/core/browser/index.js +286 -136
  15. package/src/core/capture/index.js +2930 -1201
  16. package/src/core/cv-acquisition/index.js +512 -238
  17. package/src/core/cv-capture-target/index.js +513 -299
  18. package/src/core/greet-quota/index.js +71 -71
  19. package/src/core/infinite-list/index.js +31 -31
  20. package/src/core/reporting/legacy-csv.js +12 -12
  21. package/src/core/run/detached-launcher.js +305 -0
  22. package/src/core/run/index.js +105 -52
  23. package/src/core/run/timing.js +33 -33
  24. package/src/core/run/windows-detached-worker.ps1 +106 -0
  25. package/src/core/screening/index.js +2135 -2135
  26. package/src/core/self-heal/index.js +989 -973
  27. package/src/core/self-heal/viewport.js +1505 -564
  28. package/src/detached-worker.js +99 -99
  29. package/src/domains/chat/action-journal.js +443 -0
  30. package/src/domains/chat/cards.js +137 -137
  31. package/src/domains/chat/constants.js +9 -9
  32. package/src/domains/chat/detail.js +1684 -401
  33. package/src/domains/chat/index.js +8 -7
  34. package/src/domains/chat/jobs.js +620 -620
  35. package/src/domains/chat/page-guard.js +157 -122
  36. package/src/domains/chat/roots.js +56 -56
  37. package/src/domains/chat/run-service.js +1801 -760
  38. package/src/domains/common/account-rights-panel.js +314 -314
  39. package/src/domains/common/recovery-settle.js +159 -159
  40. package/src/domains/recommend/actions.js +515 -472
  41. package/src/domains/recommend/cards.js +243 -243
  42. package/src/domains/recommend/colleague-contact.js +333 -333
  43. package/src/domains/recommend/constants.js +92 -92
  44. package/src/domains/recommend/detail.js +12 -12
  45. package/src/domains/recommend/filters.js +611 -611
  46. package/src/domains/recommend/index.js +9 -9
  47. package/src/domains/recommend/jobs.js +542 -542
  48. package/src/domains/recommend/location.js +736 -736
  49. package/src/domains/recommend/refresh.js +422 -422
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +1791 -1205
  52. package/src/domains/recommend/scopes.js +246 -246
  53. package/src/domains/recruit/actions.js +277 -277
  54. package/src/domains/recruit/cards.js +74 -74
  55. package/src/domains/recruit/constants.js +236 -236
  56. package/src/domains/recruit/detail.js +588 -588
  57. package/src/domains/recruit/index.js +9 -9
  58. package/src/domains/recruit/instruction-parser.js +866 -866
  59. package/src/domains/recruit/refresh.js +45 -45
  60. package/src/domains/recruit/roots.js +68 -68
  61. package/src/domains/recruit/run-service.js +1817 -1620
  62. package/src/domains/recruit/search.js +3229 -3229
  63. package/src/index.js +16 -1
  64. package/src/parser.js +1296 -1296
  65. package/src/recommend-mcp.js +551 -362
  66. package/src/recommend-scheduler.js +75 -75
@@ -1,5 +1,5 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
3
  import { createRunLifecycleManager } from "../../core/run/index.js";
4
4
  import {
5
5
  addTiming,
@@ -16,16 +16,25 @@ import {
16
16
  sleep
17
17
  } from "../../core/browser/index.js";
18
18
  import { GREET_CREDITS_EXHAUSTED_CODE } from "../../core/greet-quota/index.js";
19
- import {
20
- compactCvAcquisitionState,
19
+ import {
20
+ attemptImageCaptureCheckpointResume,
21
+ compactCvAcquisitionState,
22
+ createImageCaptureWorkflowRetryTracker,
23
+ createRequiredImageEvidenceFailure,
24
+ imageCaptureResumeCheckpoint,
21
25
  countParsedNetworkProfiles,
22
26
  createCvAcquisitionState,
23
27
  DEFAULT_MAX_IMAGE_PAGES,
24
- getCvNetworkWaitPlan,
25
- recordCvImageFallback,
28
+ getCvNetworkWaitPlan,
29
+ isFailedClosedImageAcquisition,
30
+ isIncompleteImageEvidence,
31
+ isRecoverableImageCaptureWorkflowError,
32
+ recordCvImageFallback,
26
33
  recordCvNetworkHit,
27
- recordCvNetworkMiss,
28
- summarizeImageEvidence,
34
+ recordCvNetworkMiss,
35
+ reacquireImageCaptureResumeTarget,
36
+ requireCompleteImageEvidence,
37
+ summarizeImageEvidence,
29
38
  waitForCvNetworkEvents
30
39
  } from "../../core/cv-acquisition/index.js";
31
40
  import {
@@ -55,22 +64,23 @@ import {
55
64
  extractRecommendDetailCandidate,
56
65
  isRecommendDetailOpenMissError,
57
66
  isStaleRecommendNodeError,
58
- openRecommendCardDetailWithFreshRetry,
59
- waitForRecommendDetailNetworkEvents
67
+ openRecommendCardDetailWithFreshRetry,
68
+ waitForRecommendDetail,
69
+ waitForRecommendDetailNetworkEvents
60
70
  } from "./detail.js";
61
- import {
62
- readRecommendCardCandidate,
63
- waitForRecommendCardNodeIds
64
- } from "./cards.js";
65
- import { selectAndConfirmFirstSafeFilter } from "./filters.js";
66
- import { ensureRecommendCurrentCityOnly } from "./location.js";
67
- import {
68
- applyRecommendFilterEnvelopeStages,
69
- buildRecommendFilterSelectionOptions,
70
- refreshRecommendListAtEnd
71
- } from "./refresh.js";
72
- import { selectRecommendJob } from "./jobs.js";
73
- import {
71
+ import {
72
+ readRecommendCardCandidate,
73
+ waitForRecommendCardNodeIds
74
+ } from "./cards.js";
75
+ import { selectAndConfirmFirstSafeFilter } from "./filters.js";
76
+ import { ensureRecommendCurrentCityOnly } from "./location.js";
77
+ import {
78
+ applyRecommendFilterEnvelopeStages,
79
+ buildRecommendFilterSelectionOptions,
80
+ refreshRecommendListAtEnd
81
+ } from "./refresh.js";
82
+ import { selectRecommendJob } from "./jobs.js";
83
+ import {
74
84
  normalizeRecommendPageScope,
75
85
  selectRecommendPageScope
76
86
  } from "./scopes.js";
@@ -88,116 +98,249 @@ import {
88
98
  resolveRecommendPostAction,
89
99
  waitForRecommendDetailActionControls
90
100
  } from "./actions.js";
91
- import { getRecommendRoots } from "./roots.js";
92
-
93
- const RECOMMEND_DEBUG_BOUNDARY_MODES = Object.freeze({
94
- list_end: "debug_force_list_end_after_processed",
95
- context_recovery: "debug_force_context_recovery_after_processed",
96
- cdp_reconnect: "debug_force_cdp_reconnect_after_processed"
97
- });
98
-
99
- function hasOwn(source, key) {
100
- return Boolean(source && Object.prototype.hasOwnProperty.call(source, key));
101
- }
102
-
103
- function readDebugBoundaryValue(source, snakeKey, camelKey) {
104
- if (hasOwn(source, snakeKey)) return source[snakeKey];
105
- if (hasOwn(source, camelKey)) return source[camelKey];
106
- return null;
101
+ import { getRecommendRoots } from "./roots.js";
102
+
103
+ const RECOMMEND_DEBUG_BOUNDARY_MODES = Object.freeze({
104
+ list_end: "debug_force_list_end_after_processed",
105
+ context_recovery: "debug_force_context_recovery_after_processed",
106
+ cdp_reconnect: "debug_force_cdp_reconnect_after_processed"
107
+ });
108
+
109
+ function hasOwn(source, key) {
110
+ return Boolean(source && Object.prototype.hasOwnProperty.call(source, key));
111
+ }
112
+
113
+ function readDebugBoundaryValue(source, snakeKey, camelKey) {
114
+ if (hasOwn(source, snakeKey)) return source[snakeKey];
115
+ if (hasOwn(source, camelKey)) return source[camelKey];
116
+ return null;
117
+ }
118
+
119
+ function normalizeDebugBoundaryThreshold(raw, field) {
120
+ if (raw === undefined || raw === null || raw === "") return null;
121
+ const parsed = Number(raw);
122
+ if (!Number.isInteger(parsed) || parsed <= 0) {
123
+ const error = new Error(`${field} must be a positive integer`);
124
+ error.code = "INVALID_RECOMMEND_DEBUG_BOUNDARY";
125
+ throw error;
126
+ }
127
+ return parsed;
128
+ }
129
+
130
+ export function normalizeRecommendDebugBoundaryOptions(source = {}) {
131
+ const debugTestMode = source.debugTestMode === true || source.debug_test_mode === true;
132
+ const thresholds = {
133
+ list_end: normalizeDebugBoundaryThreshold(
134
+ readDebugBoundaryValue(
135
+ source,
136
+ RECOMMEND_DEBUG_BOUNDARY_MODES.list_end,
137
+ "debugForceListEndAfterProcessed"
138
+ ),
139
+ RECOMMEND_DEBUG_BOUNDARY_MODES.list_end
140
+ ),
141
+ context_recovery: normalizeDebugBoundaryThreshold(
142
+ readDebugBoundaryValue(
143
+ source,
144
+ RECOMMEND_DEBUG_BOUNDARY_MODES.context_recovery,
145
+ "debugForceContextRecoveryAfterProcessed"
146
+ ),
147
+ RECOMMEND_DEBUG_BOUNDARY_MODES.context_recovery
148
+ ),
149
+ cdp_reconnect: normalizeDebugBoundaryThreshold(
150
+ readDebugBoundaryValue(
151
+ source,
152
+ RECOMMEND_DEBUG_BOUNDARY_MODES.cdp_reconnect,
153
+ "debugForceCdpReconnectAfterProcessed"
154
+ ),
155
+ RECOMMEND_DEBUG_BOUNDARY_MODES.cdp_reconnect
156
+ )
157
+ };
158
+ const configured = Object.entries(thresholds).filter(([, threshold]) => threshold !== null);
159
+ if (configured.length > 1) {
160
+ const error = new Error(
161
+ `${configured.map(([mode]) => RECOMMEND_DEBUG_BOUNDARY_MODES[mode]).join(", ")} are mutually exclusive`
162
+ );
163
+ error.code = "RECOMMEND_DEBUG_BOUNDARIES_MUTUALLY_EXCLUSIVE";
164
+ throw error;
165
+ }
166
+ if (configured.length && !debugTestMode) {
167
+ const error = new Error(
168
+ `${RECOMMEND_DEBUG_BOUNDARY_MODES[configured[0][0]]} requires debug_test_mode=true`
169
+ );
170
+ error.code = "DEBUG_TEST_MODE_REQUIRED";
171
+ throw error;
172
+ }
173
+ const [configuredEntry = null] = configured;
174
+ return {
175
+ debugTestMode,
176
+ mode: configuredEntry?.[0] || null,
177
+ field: configuredEntry ? RECOMMEND_DEBUG_BOUNDARY_MODES[configuredEntry[0]] : null,
178
+ threshold: configuredEntry?.[1] ?? null,
179
+ debugForceListEndAfterProcessed: thresholds.list_end,
180
+ debugForceContextRecoveryAfterProcessed: thresholds.context_recovery,
181
+ debugForceCdpReconnectAfterProcessed: thresholds.cdp_reconnect
182
+ };
183
+ }
184
+
185
+ export function createRecommendDebugBoundaryController(source = {}) {
186
+ const config = normalizeRecommendDebugBoundaryOptions(source);
187
+ let triggered = false;
188
+ let triggerCount = 0;
189
+ return {
190
+ config,
191
+ take(processedCount) {
192
+ const processed = Math.max(0, Number(processedCount) || 0);
193
+ if (triggered || !config.mode || processed < config.threshold) return null;
194
+ triggered = true;
195
+ triggerCount += 1;
196
+ return {
197
+ mode: config.mode,
198
+ field: config.field,
199
+ threshold: config.threshold,
200
+ processed,
201
+ trigger_count: triggerCount
202
+ };
203
+ },
204
+ getState() {
205
+ return {
206
+ ...config,
207
+ triggered,
208
+ trigger_count: triggerCount
209
+ };
210
+ }
211
+ };
107
212
  }
108
213
 
109
- function normalizeDebugBoundaryThreshold(raw, field) {
110
- if (raw === undefined || raw === null || raw === "") return null;
111
- const parsed = Number(raw);
112
- if (!Number.isInteger(parsed) || parsed <= 0) {
113
- const error = new Error(`${field} must be a positive integer`);
114
- error.code = "INVALID_RECOMMEND_DEBUG_BOUNDARY";
115
- throw error;
214
+ function readErrorChainField(error, key) {
215
+ const seen = new Set();
216
+ let current = error;
217
+ for (let depth = 0; current && depth < 6; depth += 1) {
218
+ if ((typeof current === "object" || typeof current === "function") && seen.has(current)) break;
219
+ if (typeof current === "object" || typeof current === "function") seen.add(current);
220
+ if (current?.[key] !== undefined && current?.[key] !== null) return current[key];
221
+ current = current?.cause || null;
116
222
  }
117
- return parsed;
223
+ return undefined;
118
224
  }
119
225
 
120
- export function normalizeRecommendDebugBoundaryOptions(source = {}) {
121
- const debugTestMode = source.debugTestMode === true || source.debug_test_mode === true;
122
- const thresholds = {
123
- list_end: normalizeDebugBoundaryThreshold(
124
- readDebugBoundaryValue(
125
- source,
126
- RECOMMEND_DEBUG_BOUNDARY_MODES.list_end,
127
- "debugForceListEndAfterProcessed"
128
- ),
129
- RECOMMEND_DEBUG_BOUNDARY_MODES.list_end
130
- ),
131
- context_recovery: normalizeDebugBoundaryThreshold(
132
- readDebugBoundaryValue(
133
- source,
134
- RECOMMEND_DEBUG_BOUNDARY_MODES.context_recovery,
135
- "debugForceContextRecoveryAfterProcessed"
136
- ),
137
- RECOMMEND_DEBUG_BOUNDARY_MODES.context_recovery
138
- ),
139
- cdp_reconnect: normalizeDebugBoundaryThreshold(
140
- readDebugBoundaryValue(
141
- source,
142
- RECOMMEND_DEBUG_BOUNDARY_MODES.cdp_reconnect,
143
- "debugForceCdpReconnectAfterProcessed"
144
- ),
145
- RECOMMEND_DEBUG_BOUNDARY_MODES.cdp_reconnect
146
- )
226
+ function compactCdpFailureDiagnostic(error, {
227
+ fallbackCode = "RECOMMEND_DOM_STALE",
228
+ fallbackPhase = ""
229
+ } = {}) {
230
+ const message = String(readErrorChainField(error, "message") || error || "DOM node became stale");
231
+ const diagnostic = {
232
+ name: String(readErrorChainField(error, "name") || "Error").slice(0, 100),
233
+ code: String(readErrorChainField(error, "code") || fallbackCode).slice(0, 160),
234
+ message: message.slice(0, 500),
235
+ phase: String(readErrorChainField(error, "phase") || fallbackPhase || "").slice(0, 200) || null,
236
+ cdp_method: String(readErrorChainField(error, "cdp_method") || "").slice(0, 200) || null,
237
+ cdp_at: String(readErrorChainField(error, "cdp_at") || "").slice(0, 100) || null,
238
+ cdp_search_id: String(readErrorChainField(error, "cdp_search_id") || "").slice(0, 200) || null,
239
+ cdp_replay_policy: String(readErrorChainField(error, "cdp_replay_policy") || "").slice(0, 100) || null,
240
+ cdp_reconnect_error: String(readErrorChainField(error, "cdp_reconnect_error") || "").slice(0, 300) || null
147
241
  };
148
- const configured = Object.entries(thresholds).filter(([, threshold]) => threshold !== null);
149
- if (configured.length > 1) {
150
- const error = new Error(
151
- `${configured.map(([mode]) => RECOMMEND_DEBUG_BOUNDARY_MODES[mode]).join(", ")} are mutually exclusive`
152
- );
153
- error.code = "RECOMMEND_DEBUG_BOUNDARIES_MUTUALLY_EXCLUSIVE";
154
- throw error;
242
+ for (const key of [
243
+ "cdp_node_id",
244
+ "cdp_backend_node_id",
245
+ "cdp_connection_epoch",
246
+ "cdp_reconnected_epoch"
247
+ ]) {
248
+ const value = Number(readErrorChainField(error, key));
249
+ diagnostic[key] = Number.isInteger(value) && value >= 0 ? value : null;
155
250
  }
156
- if (configured.length && !debugTestMode) {
157
- const error = new Error(
158
- `${RECOMMEND_DEBUG_BOUNDARY_MODES[configured[0][0]]} requires debug_test_mode=true`
159
- );
160
- error.code = "DEBUG_TEST_MODE_REQUIRED";
161
- throw error;
251
+ for (const key of [
252
+ "cdp_reconnected",
253
+ "cdp_replayed_after_reconnect",
254
+ "cdp_replay_suppressed",
255
+ "cdp_outcome_unknown"
256
+ ]) {
257
+ const value = readErrorChainField(error, key);
258
+ diagnostic[key] = typeof value === "boolean" ? value : null;
162
259
  }
163
- const [configuredEntry = null] = configured;
260
+ const paramKeys = readErrorChainField(error, "cdp_param_keys");
261
+ diagnostic.cdp_param_keys = Array.isArray(paramKeys)
262
+ ? paramKeys
263
+ .map((key) => String(key || "").trim())
264
+ .filter((key) => /^[A-Za-z][A-Za-z0-9_]*$/.test(key))
265
+ .slice(0, 20)
266
+ : [];
267
+ return diagnostic;
268
+ }
269
+
270
+ export function compactRecommendDomRootIdentity(rootState = null, connectionEpoch = null) {
271
+ const epoch = Number(connectionEpoch);
272
+ const topNodeId = Number(rootState?.rootNodes?.top || rootState?.topRoot?.nodeId || 0);
273
+ const frameOwnerNodeId = Number(rootState?.rootNodes?.frameOwner || rootState?.iframe?.nodeId || 0);
274
+ const frameDocumentNodeId = Number(
275
+ rootState?.rootNodes?.frame || rootState?.iframe?.documentNodeId || 0
276
+ );
164
277
  return {
165
- debugTestMode,
166
- mode: configuredEntry?.[0] || null,
167
- field: configuredEntry ? RECOMMEND_DEBUG_BOUNDARY_MODES[configuredEntry[0]] : null,
168
- threshold: configuredEntry?.[1] ?? null,
169
- debugForceListEndAfterProcessed: thresholds.list_end,
170
- debugForceContextRecoveryAfterProcessed: thresholds.context_recovery,
171
- debugForceCdpReconnectAfterProcessed: thresholds.cdp_reconnect
278
+ connection_epoch: Number.isInteger(epoch) && epoch > 0 ? epoch : null,
279
+ top_document_node_id: Number.isInteger(topNodeId) && topNodeId > 0 ? topNodeId : null,
280
+ iframe_owner_node_id: Number.isInteger(frameOwnerNodeId) && frameOwnerNodeId > 0
281
+ ? frameOwnerNodeId
282
+ : null,
283
+ iframe_document_node_id: Number.isInteger(frameDocumentNodeId) && frameDocumentNodeId > 0
284
+ ? frameDocumentNodeId
285
+ : null,
286
+ iframe_selector: String(rootState?.iframe?.selector || "").slice(0, 300) || null
172
287
  };
173
288
  }
174
289
 
175
- export function createRecommendDebugBoundaryController(source = {}) {
176
- const config = normalizeRecommendDebugBoundaryOptions(source);
177
- let triggered = false;
178
- let triggerCount = 0;
290
+ function latestInfiniteListReadError(listState = null) {
291
+ const ledger = Array.isArray(listState?.ledger) ? listState.ledger : [];
292
+ for (let index = ledger.length - 1; index >= 0; index -= 1) {
293
+ const item = ledger[index];
294
+ if (item?.event !== "candidate_read_error") continue;
295
+ return {
296
+ at: item.at || null,
297
+ node_id: Number.isInteger(item.node_id) ? item.node_id : null,
298
+ visible_index: Number.isInteger(item.visible_index) ? item.visible_index : null,
299
+ error: String(item.error || "").slice(0, 500) || null
300
+ };
301
+ }
302
+ return null;
303
+ }
304
+
305
+ export function createRecommendDomStaleForensicEvent(error, {
306
+ eventId = "",
307
+ phase = "",
308
+ operation = "",
309
+ detailStep = "",
310
+ candidateIndex = null,
311
+ candidateKey = "",
312
+ cardNodeId = null,
313
+ rootState = null,
314
+ connectionEpoch = null,
315
+ listState = null,
316
+ counters = null,
317
+ timeline = []
318
+ } = {}) {
319
+ const at = new Date().toISOString();
320
+ const listReadError = latestInfiniteListReadError(listState);
179
321
  return {
180
- config,
181
- take(processedCount) {
182
- const processed = Math.max(0, Number(processedCount) || 0);
183
- if (triggered || !config.mode || processed < config.threshold) return null;
184
- triggered = true;
185
- triggerCount += 1;
186
- return {
187
- mode: config.mode,
188
- field: config.field,
189
- threshold: config.threshold,
190
- processed,
191
- trigger_count: triggerCount
192
- };
322
+ schema_version: 1,
323
+ event_id: String(eventId || `recommend_dom_stale_${Date.now()}`).slice(0, 160),
324
+ event_type: "dom_stale",
325
+ at,
326
+ phase: String(phase || "").slice(0, 200) || null,
327
+ operation: String(operation || "").slice(0, 200) || null,
328
+ detail_step: String(detailStep || "").slice(0, 200) || null,
329
+ candidate: {
330
+ index: Number.isInteger(candidateIndex) && candidateIndex >= 0 ? candidateIndex : null,
331
+ key: String(candidateKey || "").slice(0, 300) || null,
332
+ card_node_id: Number.isInteger(cardNodeId) && cardNodeId > 0 ? cardNodeId : null,
333
+ visible_index: listReadError?.visible_index ?? null,
334
+ failing_list_node_id: listReadError?.node_id ?? null
193
335
  },
194
- getState() {
195
- return {
196
- ...config,
197
- triggered,
198
- trigger_count: triggerCount
199
- };
200
- }
336
+ error: compactCdpFailureDiagnostic(error, {
337
+ fallbackCode: "RECOMMEND_DOM_STALE",
338
+ fallbackPhase: phase
339
+ }),
340
+ pre_recovery_roots: compactRecommendDomRootIdentity(rootState, connectionEpoch),
341
+ candidate_list: compactInfiniteListState(listState || {}),
342
+ counters: counters && typeof counters === "object" ? counters : null,
343
+ lifecycle_timeline: Array.isArray(timeline) ? timeline.slice(-20) : []
201
344
  };
202
345
  }
203
346
 
@@ -205,187 +348,180 @@ function compactListReadStaleDiagnostic(error, {
205
348
  attempt = 0,
206
349
  exhausted = false
207
350
  } = {}) {
351
+ const cdp = compactCdpFailureDiagnostic(error, {
352
+ fallbackCode: "RECOMMEND_LIST_READ_STALE_NODE",
353
+ fallbackPhase: "recommend:list-read"
354
+ });
208
355
  return {
209
- code: error?.code || "RECOMMEND_LIST_READ_STALE_NODE",
210
- message: String(error?.message || error || "Stale recommend list node").slice(0, 500),
356
+ ...cdp,
357
+ code: error?.code || cdp.code || "RECOMMEND_LIST_READ_STALE_NODE",
358
+ message: String(error?.message || cdp.message || error || "Stale recommend list node").slice(0, 500),
211
359
  phase: "recommend:list-read",
212
- cdp_method: error?.cdp_method || null,
213
- cdp_at: error?.cdp_at ? String(error.cdp_at).slice(0, 100) : null,
214
- cdp_node_id: Number.isInteger(error?.cdp_node_id) ? error.cdp_node_id : null,
215
- cdp_backend_node_id: Number.isInteger(error?.cdp_backend_node_id)
216
- ? error.cdp_backend_node_id
217
- : null,
218
- cdp_param_keys: Array.isArray(error?.cdp_param_keys)
219
- ? error.cdp_param_keys
220
- .map((key) => String(key || "").trim())
221
- .filter((key) => /^[A-Za-z][A-Za-z0-9_]*$/.test(key))
222
- .slice(0, 20)
223
- : [],
224
360
  attempt,
225
361
  exhausted: Boolean(exhausted),
226
362
  at: new Date().toISOString()
227
- };
228
- }
229
-
230
- function annotateListReadStaleFailure(error, diagnostics, {
231
- exhausted = false,
232
- recoveryFailed = false
233
- } = {}) {
234
- if (!error || typeof error !== "object") return error;
235
- error.phase = error.phase || "recommend:list-read";
236
- error.list_read_stale_recovery_attempts = diagnostics;
237
- if (exhausted) error.list_read_stale_recovery_exhausted = true;
238
- if (recoveryFailed) error.list_read_stale_recovery_failed = true;
239
- return error;
240
- }
241
-
242
- export async function acquireRecommendListReadWithStaleRecovery({
243
- acquire,
244
- recover,
245
- maxRetries = 2,
246
- onStale = null,
247
- onRecoveryApplied = null,
248
- onRecovered = null,
249
- onExhausted = null
250
- } = {}) {
251
- if (typeof acquire !== "function") {
252
- throw new Error("acquireRecommendListReadWithStaleRecovery requires acquire");
253
- }
254
- if (typeof recover !== "function") {
255
- throw new Error("acquireRecommendListReadWithStaleRecovery requires recover");
256
- }
257
- const retryLimit = Math.max(0, Number.isInteger(maxRetries) ? maxRetries : 2);
258
- const diagnostics = [];
259
- let acquireAttempt = 0;
260
- let pendingRecoveryDiagnostic = null;
261
- while (true) {
262
- acquireAttempt += 1;
263
- try {
264
- const result = await acquire({
265
- acquireAttempt,
266
- recoveryCount: diagnostics.filter((item) => item.recovered === true).length
267
- });
268
- if (pendingRecoveryDiagnostic) {
269
- pendingRecoveryDiagnostic.recovered = true;
270
- pendingRecoveryDiagnostic.recovered_at = new Date().toISOString();
271
- if (typeof onRecovered === "function") {
272
- await onRecovered({
273
- diagnostic: pendingRecoveryDiagnostic,
274
- diagnostics: diagnostics.slice()
275
- });
276
- }
277
- pendingRecoveryDiagnostic = null;
278
- }
279
- return {
280
- result,
281
- acquire_attempts: acquireAttempt,
282
- stale_diagnostics: diagnostics
283
- };
284
- } catch (error) {
285
- if (!isStaleRecommendNodeError(error)) throw error;
286
- pendingRecoveryDiagnostic = null;
287
- const staleAttempt = diagnostics.length + 1;
288
- const exhausted = staleAttempt > retryLimit;
289
- const diagnostic = compactListReadStaleDiagnostic(error, {
290
- attempt: staleAttempt,
291
- exhausted
292
- });
293
- diagnostics.push(diagnostic);
294
- if (exhausted) {
295
- if (typeof onExhausted === "function") {
296
- await onExhausted({ error, diagnostic, diagnostics: diagnostics.slice() });
297
- }
298
- throw annotateListReadStaleFailure(error, diagnostics, { exhausted: true });
299
- }
300
- if (typeof onStale === "function") {
301
- await onStale({ error, diagnostic, diagnostics: diagnostics.slice() });
302
- }
303
- let recoveryResult = null;
304
- try {
305
- recoveryResult = await recover({ error, diagnostic, diagnostics: diagnostics.slice() });
306
- } catch (recoveryError) {
307
- if (recoveryError && typeof recoveryError === "object" && !recoveryError.cause) {
308
- recoveryError.cause = error;
309
- }
310
- throw annotateListReadStaleFailure(recoveryError, diagnostics, {
311
- recoveryFailed: true
312
- });
313
- }
314
- diagnostic.recovery_applied = true;
315
- diagnostic.recovery_mode = recoveryResult?.recovery_mode || "unknown";
316
- diagnostic.recovery_applied_at = new Date().toISOString();
317
- pendingRecoveryDiagnostic = diagnostic;
318
- if (typeof onRecoveryApplied === "function") {
319
- await onRecoveryApplied({
320
- error,
321
- diagnostic,
322
- recoveryResult,
323
- diagnostics: diagnostics.slice()
324
- });
325
- }
326
- }
327
- }
328
- }
329
-
330
- export async function recoverRecommendListReadStaleContext({
331
- staleAttempt = 1,
332
- listState,
333
- rootReacquire,
334
- contextReapply
335
- } = {}) {
336
- if (typeof rootReacquire !== "function") {
337
- throw new Error("recoverRecommendListReadStaleContext requires rootReacquire");
338
- }
339
- if (typeof contextReapply !== "function") {
340
- throw new Error("recoverRecommendListReadStaleContext requires contextReapply");
341
- }
342
- const processedKeys = new Set(listState?.processed_keys || []);
343
- try {
344
- if (Number(staleAttempt) <= 1) {
345
- try {
346
- const rootResult = await rootReacquire();
347
- if (Number(rootResult?.card_count) <= 0) {
348
- const noCardsError = new Error("Recommend list root reacquire returned no candidate cards");
349
- noCardsError.code = "RECOMMEND_LIST_ROOT_REACQUIRE_NO_CARDS";
350
- throw noCardsError;
351
- }
352
- resetInfiniteListForRefreshRound(listState, {
353
- reason: "list_read_stale_node_root_reacquire",
354
- round: 1,
355
- method: "root_reacquire",
356
- metadata: {
357
- card_count: Number(rootResult?.card_count) || 0,
358
- processed_count: processedKeys.size
359
- }
360
- });
361
- return {
362
- recovery_mode: "root_reacquire",
363
- root_reacquire: rootResult || null
364
- };
365
- } catch (rootReacquireError) {
366
- const contextResult = await contextReapply({ rootReacquireError });
367
- return {
368
- recovery_mode: "context_reapply",
369
- escalated_from: "root_reacquire",
370
- root_reacquire_error: compactListReadStaleDiagnostic(rootReacquireError, {
371
- attempt: 1
372
- }),
373
- context_reapply: contextResult || null
374
- };
375
- }
376
- }
377
- const contextResult = await contextReapply({ rootReacquireError: null });
378
- return {
379
- recovery_mode: "context_reapply",
380
- escalated_from: "repeated_stale",
381
- context_reapply: contextResult || null
382
- };
383
- } finally {
384
- for (const key of processedKeys) listState?.processed_keys?.add(key);
385
- }
386
- }
387
-
388
- function normalizeLabels(labels = []) {
363
+ };
364
+ }
365
+
366
+ function annotateListReadStaleFailure(error, diagnostics, {
367
+ exhausted = false,
368
+ recoveryFailed = false
369
+ } = {}) {
370
+ if (!error || typeof error !== "object") return error;
371
+ error.phase = error.phase || "recommend:list-read";
372
+ error.list_read_stale_recovery_attempts = diagnostics;
373
+ if (exhausted) error.list_read_stale_recovery_exhausted = true;
374
+ if (recoveryFailed) error.list_read_stale_recovery_failed = true;
375
+ return error;
376
+ }
377
+
378
+ export async function acquireRecommendListReadWithStaleRecovery({
379
+ acquire,
380
+ recover,
381
+ maxRetries = 2,
382
+ onStale = null,
383
+ onRecoveryApplied = null,
384
+ onRecovered = null,
385
+ onExhausted = null
386
+ } = {}) {
387
+ if (typeof acquire !== "function") {
388
+ throw new Error("acquireRecommendListReadWithStaleRecovery requires acquire");
389
+ }
390
+ if (typeof recover !== "function") {
391
+ throw new Error("acquireRecommendListReadWithStaleRecovery requires recover");
392
+ }
393
+ const retryLimit = Math.max(0, Number.isInteger(maxRetries) ? maxRetries : 2);
394
+ const diagnostics = [];
395
+ let acquireAttempt = 0;
396
+ let pendingRecoveryDiagnostic = null;
397
+ while (true) {
398
+ acquireAttempt += 1;
399
+ try {
400
+ const result = await acquire({
401
+ acquireAttempt,
402
+ recoveryCount: diagnostics.filter((item) => item.recovered === true).length
403
+ });
404
+ if (pendingRecoveryDiagnostic) {
405
+ pendingRecoveryDiagnostic.recovered = true;
406
+ pendingRecoveryDiagnostic.recovered_at = new Date().toISOString();
407
+ if (typeof onRecovered === "function") {
408
+ await onRecovered({
409
+ diagnostic: pendingRecoveryDiagnostic,
410
+ diagnostics: diagnostics.slice()
411
+ });
412
+ }
413
+ pendingRecoveryDiagnostic = null;
414
+ }
415
+ return {
416
+ result,
417
+ acquire_attempts: acquireAttempt,
418
+ stale_diagnostics: diagnostics
419
+ };
420
+ } catch (error) {
421
+ if (!isStaleRecommendNodeError(error)) throw error;
422
+ pendingRecoveryDiagnostic = null;
423
+ const staleAttempt = diagnostics.length + 1;
424
+ const exhausted = staleAttempt > retryLimit;
425
+ const diagnostic = compactListReadStaleDiagnostic(error, {
426
+ attempt: staleAttempt,
427
+ exhausted
428
+ });
429
+ diagnostics.push(diagnostic);
430
+ if (exhausted) {
431
+ if (typeof onExhausted === "function") {
432
+ await onExhausted({ error, diagnostic, diagnostics: diagnostics.slice() });
433
+ }
434
+ throw annotateListReadStaleFailure(error, diagnostics, { exhausted: true });
435
+ }
436
+ if (typeof onStale === "function") {
437
+ await onStale({ error, diagnostic, diagnostics: diagnostics.slice() });
438
+ }
439
+ let recoveryResult = null;
440
+ try {
441
+ recoveryResult = await recover({ error, diagnostic, diagnostics: diagnostics.slice() });
442
+ } catch (recoveryError) {
443
+ if (recoveryError && typeof recoveryError === "object" && !recoveryError.cause) {
444
+ recoveryError.cause = error;
445
+ }
446
+ throw annotateListReadStaleFailure(recoveryError, diagnostics, {
447
+ recoveryFailed: true
448
+ });
449
+ }
450
+ diagnostic.recovery_applied = true;
451
+ diagnostic.recovery_mode = recoveryResult?.recovery_mode || "unknown";
452
+ diagnostic.recovery_applied_at = new Date().toISOString();
453
+ pendingRecoveryDiagnostic = diagnostic;
454
+ if (typeof onRecoveryApplied === "function") {
455
+ await onRecoveryApplied({
456
+ error,
457
+ diagnostic,
458
+ recoveryResult,
459
+ diagnostics: diagnostics.slice()
460
+ });
461
+ }
462
+ }
463
+ }
464
+ }
465
+
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
+ };
519
+ } finally {
520
+ for (const key of processedKeys) listState?.processed_keys?.add(key);
521
+ }
522
+ }
523
+
524
+ function normalizeLabels(labels = []) {
389
525
  return labels.map((label) => String(label || "").trim()).filter(Boolean);
390
526
  }
391
527
 
@@ -401,46 +537,46 @@ function isRefreshableListStall(reason = "") {
401
537
  function normalizeFilter(filter = {}) {
402
538
  const filterGroups = Array.isArray(filter.filterGroups)
403
539
  ? filter.filterGroups
404
- : Array.isArray(filter.groups)
405
- ? filter.groups
406
- : [];
407
- return {
408
- enabled: filter.enabled !== false,
409
- currentCityOnly: filter.currentCityOnly === true || filter.current_city_only === true,
410
- group: String(filter.group || ""),
411
- labels: normalizeLabels(filter.labels || filter.filterLabels || []),
412
- selectAllLabels: Boolean(filter.selectAllLabels),
413
- allowUnlimited: filter.allowUnlimited === true,
414
- verifySticky: filter.verifySticky === true,
415
- filterGroups: filterGroups.map((group) => ({
416
- group: String(group?.group || ""),
417
- labels: normalizeLabels(group?.labels || group?.filterLabels || []),
418
- selectAllLabels: group?.selectAllLabels !== false,
419
- allowUnlimited: group?.allowUnlimited === true,
420
- verifySticky: group?.verifySticky === true
421
- })).filter((group) => group.group || group.labels.length)
422
- };
423
- }
424
-
425
- export function compactFilterResult(filterResult) {
426
- if (!filterResult) return null;
427
- return {
428
- opened_panel: Boolean(filterResult.opened_panel),
429
- requested_groups: (filterResult.requested_groups || []).map((group) => ({
430
- group: group.group,
431
- labels: group.labels || [],
432
- select_all_labels: group.select_all_labels !== false,
433
- allow_unlimited: Boolean(group.allow_unlimited),
434
- verify_sticky: Boolean(group.verify_sticky)
435
- })),
436
- effective_groups: (filterResult.sticky_verification?.groups || []).map((group) => ({
437
- group: group.group,
438
- requested_labels: group.requested_labels || [],
439
- active_labels: group.active_labels || [],
440
- verified: Boolean(group.verified),
441
- unavailable: Boolean(group.unavailable),
442
- reason: group.reason || null
443
- })),
540
+ : Array.isArray(filter.groups)
541
+ ? filter.groups
542
+ : [];
543
+ return {
544
+ enabled: filter.enabled !== false,
545
+ currentCityOnly: filter.currentCityOnly === true || filter.current_city_only === true,
546
+ group: String(filter.group || ""),
547
+ labels: normalizeLabels(filter.labels || filter.filterLabels || []),
548
+ selectAllLabels: Boolean(filter.selectAllLabels),
549
+ allowUnlimited: filter.allowUnlimited === true,
550
+ verifySticky: filter.verifySticky === true,
551
+ filterGroups: filterGroups.map((group) => ({
552
+ group: String(group?.group || ""),
553
+ labels: normalizeLabels(group?.labels || group?.filterLabels || []),
554
+ selectAllLabels: group?.selectAllLabels !== false,
555
+ allowUnlimited: group?.allowUnlimited === true,
556
+ verifySticky: group?.verifySticky === true
557
+ })).filter((group) => group.group || group.labels.length)
558
+ };
559
+ }
560
+
561
+ export function compactFilterResult(filterResult) {
562
+ if (!filterResult) return null;
563
+ return {
564
+ opened_panel: Boolean(filterResult.opened_panel),
565
+ requested_groups: (filterResult.requested_groups || []).map((group) => ({
566
+ group: group.group,
567
+ labels: group.labels || [],
568
+ select_all_labels: group.select_all_labels !== false,
569
+ allow_unlimited: Boolean(group.allow_unlimited),
570
+ verify_sticky: Boolean(group.verify_sticky)
571
+ })),
572
+ effective_groups: (filterResult.sticky_verification?.groups || []).map((group) => ({
573
+ group: group.group,
574
+ requested_labels: group.requested_labels || [],
575
+ active_labels: group.active_labels || [],
576
+ verified: Boolean(group.verified),
577
+ unavailable: Boolean(group.unavailable),
578
+ reason: group.reason || null
579
+ })),
444
580
  selected_option: filterResult.selected_option
445
581
  ? {
446
582
  group: filterResult.selected_option.group,
@@ -450,71 +586,71 @@ export function compactFilterResult(filterResult) {
450
586
  }
451
587
  : null,
452
588
  selected_options: (filterResult.selected_options || []).map((option) => ({
453
- group: option.group,
454
- label: option.label,
455
- was_active: Boolean(option.was_active),
456
- clicked: option.clicked !== false
457
- })),
458
- unavailable: Boolean(filterResult.unavailable),
459
- unavailable_groups: filterResult.unavailable_groups || [],
460
- confirmed: Boolean(filterResult.confirmed),
461
- sticky_verification: filterResult.sticky_verification || null,
462
- attempts: {
463
- initial_close: filterResult.initial_close_attempts || [],
464
- open: (filterResult.open_attempts || []).map((attempt) => ({
465
- selector: attempt.selector || null,
466
- node_id: attempt.node_id || null,
467
- click_target: attempt.click_target || null
468
- })),
469
- confirmation: (filterResult.confirm_attempts || []).map((attempt) => ({
470
- node_id: attempt.node_id || null,
471
- label: attempt.label || null,
472
- clicked: Boolean(attempt.clicked),
473
- errors: (attempt.errors || []).map((error) => ({
474
- node_id: error.node_id || null,
475
- message: error.message || String(error)
476
- }))
477
- }))
478
- },
479
- before_counts: filterResult.before_counts,
480
- after_confirm_counts: filterResult.after_confirm_counts
481
- };
482
- }
483
-
484
- function compactCurrentCityOnlyResult(result) {
485
- if (!result) return null;
486
- return {
487
- requested: Boolean(result.requested),
488
- effective: typeof result.effective === "boolean" ? result.effective : null,
489
- available: result.available !== false,
490
- unavailable: Boolean(result.unavailable),
491
- reason: result.reason || null,
492
- clicked: Boolean(result.clicked),
493
- current_city_label: result.current_city_label || null,
494
- before: result.before || null,
495
- after_toggle: result.after_toggle || null,
496
- confirmation: result.confirmation || null,
497
- sticky_verification: result.sticky_verification
498
- ? {
499
- verified: Boolean(result.sticky_verification.verified),
500
- expected: Boolean(result.sticky_verification.expected),
501
- actual: typeof result.sticky_verification.actual === "boolean"
502
- ? result.sticky_verification.actual
503
- : null,
504
- state_source: result.sticky_verification.state_source || null,
505
- close_confirmation: result.sticky_verification.close_confirmation || null
506
- }
507
- : null,
508
- attempts: Array.isArray(result.attempts) ? result.attempts : [],
509
- evidence: result.evidence || null
510
- };
511
- }
512
-
513
- function compactJobSelection(jobSelection) {
514
- if (!jobSelection) return null;
515
- return {
516
- requested: jobSelection.requested || "",
517
- selected: Boolean(jobSelection.selected),
589
+ group: option.group,
590
+ label: option.label,
591
+ was_active: Boolean(option.was_active),
592
+ clicked: option.clicked !== false
593
+ })),
594
+ unavailable: Boolean(filterResult.unavailable),
595
+ unavailable_groups: filterResult.unavailable_groups || [],
596
+ confirmed: Boolean(filterResult.confirmed),
597
+ sticky_verification: filterResult.sticky_verification || null,
598
+ attempts: {
599
+ initial_close: filterResult.initial_close_attempts || [],
600
+ open: (filterResult.open_attempts || []).map((attempt) => ({
601
+ selector: attempt.selector || null,
602
+ node_id: attempt.node_id || null,
603
+ click_target: attempt.click_target || null
604
+ })),
605
+ confirmation: (filterResult.confirm_attempts || []).map((attempt) => ({
606
+ node_id: attempt.node_id || null,
607
+ label: attempt.label || null,
608
+ clicked: Boolean(attempt.clicked),
609
+ errors: (attempt.errors || []).map((error) => ({
610
+ node_id: error.node_id || null,
611
+ message: error.message || String(error)
612
+ }))
613
+ }))
614
+ },
615
+ before_counts: filterResult.before_counts,
616
+ after_confirm_counts: filterResult.after_confirm_counts
617
+ };
618
+ }
619
+
620
+ function compactCurrentCityOnlyResult(result) {
621
+ if (!result) return null;
622
+ return {
623
+ requested: Boolean(result.requested),
624
+ effective: typeof result.effective === "boolean" ? result.effective : null,
625
+ available: result.available !== false,
626
+ unavailable: Boolean(result.unavailable),
627
+ reason: result.reason || null,
628
+ clicked: Boolean(result.clicked),
629
+ current_city_label: result.current_city_label || null,
630
+ before: result.before || null,
631
+ after_toggle: result.after_toggle || null,
632
+ confirmation: result.confirmation || null,
633
+ sticky_verification: result.sticky_verification
634
+ ? {
635
+ verified: Boolean(result.sticky_verification.verified),
636
+ expected: Boolean(result.sticky_verification.expected),
637
+ actual: typeof result.sticky_verification.actual === "boolean"
638
+ ? result.sticky_verification.actual
639
+ : null,
640
+ state_source: result.sticky_verification.state_source || null,
641
+ close_confirmation: result.sticky_verification.close_confirmation || null
642
+ }
643
+ : null,
644
+ attempts: Array.isArray(result.attempts) ? result.attempts : [],
645
+ evidence: result.evidence || null
646
+ };
647
+ }
648
+
649
+ function compactJobSelection(jobSelection) {
650
+ if (!jobSelection) return null;
651
+ return {
652
+ requested: jobSelection.requested || "",
653
+ selected: Boolean(jobSelection.selected),
518
654
  already_current: Boolean(jobSelection.already_current),
519
655
  reason: jobSelection.reason || null,
520
656
  selected_option: jobSelection.selected_option || null,
@@ -746,10 +882,10 @@ function compactRefreshAttempt(refreshAttempt) {
746
882
  if (!refreshAttempt) return null;
747
883
  return {
748
884
  ok: Boolean(refreshAttempt.ok),
749
- method: refreshAttempt.method || "",
750
- reason: refreshAttempt.reason || null,
751
- error: refreshAttempt.error || null,
752
- error_diagnostic: refreshAttempt.error_diagnostic || null,
885
+ method: refreshAttempt.method || "",
886
+ reason: refreshAttempt.reason || null,
887
+ error: refreshAttempt.error || null,
888
+ error_diagnostic: refreshAttempt.error_diagnostic || null,
753
889
  forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
754
890
  target_url: refreshAttempt.target_url || null,
755
891
  card_count: refreshAttempt.card_count || 0,
@@ -764,63 +900,63 @@ function compactRefreshAttempt(refreshAttempt) {
764
900
  : null,
765
901
  attempts: (refreshAttempt.attempts || []).map((attempt) => ({
766
902
  ok: Boolean(attempt.ok),
767
- method: attempt.method || "",
768
- reason: attempt.reason || null,
769
- error: attempt.error || null,
770
- error_diagnostic: attempt.error_diagnostic || null,
771
- label: attempt.label || null,
772
- before_card_count: attempt.before_card_count || 0,
773
- after_card_count: attempt.after_card_count || 0,
774
- card_count: attempt.card_count || 0,
775
- elapsed_ms: attempt.elapsed_ms || 0,
776
- current_city_only: compactCurrentCityOnlyResult(attempt.current_city_only),
777
- current_city_only_attempts: (attempt.current_city_only_attempts || []).map((cityAttempt) => ({
778
- ok: Boolean(cityAttempt.ok),
779
- method: cityAttempt.method || "current_city_only_reapply",
780
- reason: cityAttempt.reason || null,
781
- error: cityAttempt.error || null,
782
- error_diagnostic: cityAttempt.error_diagnostic || null,
783
- attempt: cityAttempt.attempt || 0,
784
- result: compactCurrentCityOnlyResult(cityAttempt.result)
785
- })),
786
- filter: compactFilterResult(attempt.filter)
787
- })),
788
- current_city_only_attempts: (refreshAttempt.current_city_only_attempts || []).map((attempt) => ({
789
- ok: Boolean(attempt.ok),
790
- method: attempt.method || "current_city_only_reapply",
791
- reason: attempt.reason || null,
792
- error: attempt.error || null,
793
- error_diagnostic: attempt.error_diagnostic || null,
794
- attempt: attempt.attempt || 0,
795
- result: compactCurrentCityOnlyResult(attempt.result)
796
- })),
797
- filter_reapply_attempts: (refreshAttempt.filter_reapply_attempts || []).map((attempt) => ({
798
- ok: Boolean(attempt.ok),
799
- method: attempt.method || "filter_reapply",
800
- reason: attempt.reason || null,
801
- error: attempt.error || null,
802
- error_diagnostic: attempt.error_diagnostic || null,
903
+ method: attempt.method || "",
904
+ reason: attempt.reason || null,
905
+ error: attempt.error || null,
906
+ error_diagnostic: attempt.error_diagnostic || null,
907
+ 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,
911
+ elapsed_ms: attempt.elapsed_ms || 0,
912
+ current_city_only: compactCurrentCityOnlyResult(attempt.current_city_only),
913
+ current_city_only_attempts: (attempt.current_city_only_attempts || []).map((cityAttempt) => ({
914
+ ok: Boolean(cityAttempt.ok),
915
+ method: cityAttempt.method || "current_city_only_reapply",
916
+ reason: cityAttempt.reason || null,
917
+ error: cityAttempt.error || null,
918
+ error_diagnostic: cityAttempt.error_diagnostic || null,
919
+ attempt: cityAttempt.attempt || 0,
920
+ result: compactCurrentCityOnlyResult(cityAttempt.result)
921
+ })),
922
+ filter: compactFilterResult(attempt.filter)
923
+ })),
924
+ current_city_only_attempts: (refreshAttempt.current_city_only_attempts || []).map((attempt) => ({
925
+ ok: Boolean(attempt.ok),
926
+ method: attempt.method || "current_city_only_reapply",
927
+ reason: attempt.reason || null,
928
+ error: attempt.error || null,
929
+ error_diagnostic: attempt.error_diagnostic || null,
930
+ attempt: attempt.attempt || 0,
931
+ result: compactCurrentCityOnlyResult(attempt.result)
932
+ })),
933
+ filter_reapply_attempts: (refreshAttempt.filter_reapply_attempts || []).map((attempt) => ({
934
+ ok: Boolean(attempt.ok),
935
+ method: attempt.method || "filter_reapply",
936
+ reason: attempt.reason || null,
937
+ error: attempt.error || null,
938
+ error_diagnostic: attempt.error_diagnostic || null,
803
939
  attempt: attempt.attempt || 0
804
940
  })),
805
941
  job_selection_attempts: (refreshAttempt.job_selection_attempts || []).map((attempt) => ({
806
942
  ok: Boolean(attempt.ok),
807
- method: attempt.method || "job_select",
808
- reason: attempt.reason || null,
809
- error: attempt.error || null,
810
- error_diagnostic: attempt.error_diagnostic || null,
943
+ method: attempt.method || "job_select",
944
+ reason: attempt.reason || null,
945
+ error: attempt.error || null,
946
+ error_diagnostic: attempt.error_diagnostic || null,
811
947
  attempt: attempt.attempt || 0,
812
948
  iframe_document_node_id: attempt.iframe_document_node_id || 0,
813
- selected: Boolean(attempt.selected),
814
- selection_reason: attempt.selection_reason || null
815
- })),
816
- job_selection: compactJobSelection(refreshAttempt.job_selection),
817
- page_scope: compactPageScopeSelection(refreshAttempt.page_scope),
818
- current_city_only: compactCurrentCityOnlyResult(refreshAttempt.current_city_only),
819
- filter: compactFilterResult(refreshAttempt.filter)
820
- };
821
- }
822
-
823
- export function countRecommendResultStatuses(results = [], {
949
+ selected: Boolean(attempt.selected),
950
+ selection_reason: attempt.selection_reason || null
951
+ })),
952
+ job_selection: compactJobSelection(refreshAttempt.job_selection),
953
+ page_scope: compactPageScopeSelection(refreshAttempt.page_scope),
954
+ current_city_only: compactCurrentCityOnlyResult(refreshAttempt.current_city_only),
955
+ filter: compactFilterResult(refreshAttempt.filter)
956
+ };
957
+ }
958
+
959
+ export function countRecommendResultStatuses(results = [], {
824
960
  greetCount = 0
825
961
  } = {}) {
826
962
  return {
@@ -874,23 +1010,33 @@ function compactCloseResult(closeResult) {
874
1010
  return result;
875
1011
  }
876
1012
 
877
- function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
878
- if (!error) return null;
879
- const result = {
880
- code: error.code || fallbackCode,
881
- message: error.message || String(error)
882
- };
1013
+ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
1014
+ if (!error) return null;
1015
+ const cdpDiagnostic = compactCdpFailureDiagnostic(error, {
1016
+ fallbackCode,
1017
+ fallbackPhase: error?.phase || ""
1018
+ });
1019
+ const result = {
1020
+ code: error.code || fallbackCode,
1021
+ message: error.message || String(error)
1022
+ };
883
1023
  if (error.close_result) {
884
1024
  result.close_result = compactCloseResult(error.close_result);
885
1025
  }
886
1026
  if (error.phase) {
887
1027
  result.phase = error.phase;
888
1028
  }
889
- if (error.refresh_attempt) {
890
- result.refresh_attempt = error.refresh_attempt;
891
- }
1029
+ if (error.refresh_attempt) {
1030
+ result.refresh_attempt = error.refresh_attempt;
1031
+ }
892
1032
  if (error.error_diagnostic) {
893
1033
  result.error_diagnostic = error.error_diagnostic;
1034
+ } else if (
1035
+ cdpDiagnostic.cdp_method
1036
+ || cdpDiagnostic.cdp_connection_epoch !== null
1037
+ || cdpDiagnostic.cdp_node_id !== null
1038
+ ) {
1039
+ result.error_diagnostic = cdpDiagnostic;
894
1040
  }
895
1041
  if (error.list_end_reason) {
896
1042
  result.list_end_reason = error.list_end_reason;
@@ -932,45 +1078,45 @@ function createRecommendBlockingPanelCloseFailureError(closeResult, phase = "")
932
1078
  return error;
933
1079
  }
934
1080
 
935
- function findRecommendRefreshErrorDiagnostic(refreshAttempt) {
936
- if (!refreshAttempt || typeof refreshAttempt !== "object") return null;
937
- if (refreshAttempt.error_diagnostic) return refreshAttempt.error_diagnostic;
938
- const candidates = [
939
- ...(refreshAttempt.attempts || []),
940
- ...(refreshAttempt.current_city_only_attempts || []),
941
- ...(refreshAttempt.filter_reapply_attempts || []),
942
- ...(refreshAttempt.job_selection_attempts || [])
943
- ].reverse();
944
- for (const attempt of candidates) {
945
- if (attempt?.error_diagnostic) return attempt.error_diagnostic;
946
- const nestedCityAttempts = (attempt?.current_city_only_attempts || []).slice().reverse();
947
- for (const cityAttempt of nestedCityAttempts) {
948
- if (cityAttempt?.error_diagnostic) return cityAttempt.error_diagnostic;
949
- }
950
- }
951
- return null;
952
- }
953
-
954
- function attachRecommendRefreshErrorDiagnostic(error, refreshAttempt) {
955
- const diagnostic = findRecommendRefreshErrorDiagnostic(refreshAttempt);
956
- if (!error || !diagnostic) return error;
957
- error.error_diagnostic = diagnostic;
958
- for (const key of [
959
- "cdp_method",
960
- "cdp_at",
961
- "cdp_node_id",
962
- "cdp_backend_node_id",
963
- "cdp_search_id",
964
- "cdp_param_keys"
965
- ]) {
966
- if (diagnostic[key] !== undefined && error[key] === undefined) {
967
- error[key] = diagnostic[key];
968
- }
969
- }
970
- return error;
971
- }
972
-
973
- export function createRecommendRefreshFailureError(refreshAttempt, {
1081
+ function findRecommendRefreshErrorDiagnostic(refreshAttempt) {
1082
+ if (!refreshAttempt || typeof refreshAttempt !== "object") return null;
1083
+ if (refreshAttempt.error_diagnostic) return refreshAttempt.error_diagnostic;
1084
+ const candidates = [
1085
+ ...(refreshAttempt.attempts || []),
1086
+ ...(refreshAttempt.current_city_only_attempts || []),
1087
+ ...(refreshAttempt.filter_reapply_attempts || []),
1088
+ ...(refreshAttempt.job_selection_attempts || [])
1089
+ ].reverse();
1090
+ for (const attempt of candidates) {
1091
+ if (attempt?.error_diagnostic) return attempt.error_diagnostic;
1092
+ const nestedCityAttempts = (attempt?.current_city_only_attempts || []).slice().reverse();
1093
+ for (const cityAttempt of nestedCityAttempts) {
1094
+ if (cityAttempt?.error_diagnostic) return cityAttempt.error_diagnostic;
1095
+ }
1096
+ }
1097
+ return null;
1098
+ }
1099
+
1100
+ function attachRecommendRefreshErrorDiagnostic(error, refreshAttempt) {
1101
+ const diagnostic = findRecommendRefreshErrorDiagnostic(refreshAttempt);
1102
+ if (!error || !diagnostic) return error;
1103
+ error.error_diagnostic = diagnostic;
1104
+ for (const key of [
1105
+ "cdp_method",
1106
+ "cdp_at",
1107
+ "cdp_node_id",
1108
+ "cdp_backend_node_id",
1109
+ "cdp_search_id",
1110
+ "cdp_param_keys"
1111
+ ]) {
1112
+ if (diagnostic[key] !== undefined && error[key] === undefined) {
1113
+ error[key] = diagnostic[key];
1114
+ }
1115
+ }
1116
+ return error;
1117
+ }
1118
+
1119
+ export function createRecommendRefreshFailureError(refreshAttempt, {
974
1120
  listEndReason = "",
975
1121
  targetCount = 0,
976
1122
  passedCount = 0
@@ -981,18 +1127,24 @@ export function createRecommendRefreshFailureError(refreshAttempt, {
981
1127
  error.code = "RECOMMEND_END_REFRESH_FAILED";
982
1128
  error.refresh_attempt = refreshAttempt || null;
983
1129
  error.list_end_reason = listEndReason || null;
984
- error.target_count = targetCount;
985
- error.passed_count = passedCount;
986
- return attachRecommendRefreshErrorDiagnostic(error, refreshAttempt);
987
- }
988
-
989
- export function isRecoverableImageCaptureError(error) {
990
- const code = String(error?.code || "");
991
- if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
992
- if (isStaleRecommendNodeError(error)) return true;
993
- return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
1130
+ error.target_count = targetCount;
1131
+ error.passed_count = passedCount;
1132
+ return attachRecommendRefreshErrorDiagnostic(error, refreshAttempt);
994
1133
  }
995
1134
 
1135
+ export function isRecoverableImageCaptureError(error) {
1136
+ if (isRecoverableImageCaptureWorkflowError(error)) return true;
1137
+ if (isStaleRecommendNodeError(error)) return true;
1138
+ return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
1139
+ }
1140
+
1141
+ export function shouldFailClosedRecommendImageAcquisition(detailResult = null) {
1142
+ return isFailedClosedImageAcquisition({
1143
+ source: detailResult?.cv_acquisition?.source,
1144
+ imageEvidence: detailResult?.image_evidence
1145
+ });
1146
+ }
1147
+
996
1148
  function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
997
1149
  const resolved = String(basePath || "").trim();
998
1150
  if (!resolved) return [];
@@ -1120,22 +1272,22 @@ export async function runRecommendWorkflow({
1120
1272
  llmImageLimit = 8,
1121
1273
  llmImageDetail = "high",
1122
1274
  imageOutputDir = "",
1123
- humanRestEnabled = false,
1124
- humanBehavior = null,
1125
- skipRecentColleagueContacted = true,
1126
- colleagueContactWindowDays = 14,
1127
- debugTestMode = false,
1128
- debugForceListEndAfterProcessed = null,
1129
- debugForceContextRecoveryAfterProcessed = null,
1130
- debugForceCdpReconnectAfterProcessed = null
1131
- } = {}, runControl) {
1132
- if (!client) throw new Error("runRecommendWorkflow requires a guarded CDP client");
1133
- const debugBoundary = createRecommendDebugBoundaryController({
1134
- debugTestMode,
1135
- debugForceListEndAfterProcessed,
1136
- debugForceContextRecoveryAfterProcessed,
1137
- debugForceCdpReconnectAfterProcessed
1138
- });
1275
+ humanRestEnabled = false,
1276
+ humanBehavior = null,
1277
+ skipRecentColleagueContacted = true,
1278
+ colleagueContactWindowDays = 14,
1279
+ debugTestMode = false,
1280
+ debugForceListEndAfterProcessed = null,
1281
+ debugForceContextRecoveryAfterProcessed = null,
1282
+ debugForceCdpReconnectAfterProcessed = null
1283
+ } = {}, runControl) {
1284
+ if (!client) throw new Error("runRecommendWorkflow requires a guarded CDP client");
1285
+ const debugBoundary = createRecommendDebugBoundaryController({
1286
+ debugTestMode,
1287
+ debugForceListEndAfterProcessed,
1288
+ debugForceContextRecoveryAfterProcessed,
1289
+ debugForceCdpReconnectAfterProcessed
1290
+ });
1139
1291
  const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
1140
1292
  legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
1141
1293
  });
@@ -1189,69 +1341,201 @@ export async function runRecommendWorkflow({
1189
1341
  const results = [];
1190
1342
  const refreshAttempts = [];
1191
1343
  let refreshRounds = 0;
1192
- let contextRecoveryAttempts = 0;
1344
+ let contextRecoveryAttempts = 0;
1193
1345
  let greetCount = 0;
1194
1346
  const candidateRecoveryCounts = new Map();
1195
- let jobSelection = null;
1196
- let pageScopeSelection = null;
1197
- let currentCityOnlyResult = null;
1198
- let filterResult = null;
1199
- let rootState = null;
1200
- let cardNodeIds = [];
1201
- let listEndReason = "";
1202
- let lastHumanEvent = null;
1203
- let debugForceReconnectPending = null;
1204
- let listReadStaleRecoveryAttempts = 0;
1205
- let listReadStaleRecoveryApplied = 0;
1206
- let listReadStaleRecoveries = 0;
1347
+ const imageCaptureWorkflowRetries = createImageCaptureWorkflowRetryTracker();
1348
+ let jobSelection = null;
1349
+ let pageScopeSelection = null;
1350
+ let currentCityOnlyResult = null;
1351
+ let filterResult = null;
1352
+ let rootState = null;
1353
+ let cardNodeIds = [];
1354
+ let listEndReason = "";
1355
+ let lastHumanEvent = null;
1356
+ let debugForceReconnectPending = null;
1357
+ let listReadStaleRecoveryAttempts = 0;
1358
+ let listReadStaleRecoveryApplied = 0;
1359
+ let listReadStaleRecoveries = 0;
1207
1360
  let lastListReadStaleDiagnostic = null;
1208
1361
  let lastListReadRecoveryMode = null;
1209
1362
  const listReadStaleDiagnostics = [];
1210
- const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
1363
+ let domStaleEventCount = 0;
1364
+ let currentDomOperation = "recommend:initialize";
1365
+ const domLifecycleTimeline = [];
1366
+ const domStaleForensics = [];
1367
+ const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
1211
1368
  rootNodeId: rootState?.iframe?.documentNodeId,
1212
1369
  containerSelectors: RECOMMEND_LIST_CONTAINER_SELECTORS,
1213
1370
  itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
1214
1371
  itemSelectors: [RECOMMEND_CARD_SELECTOR],
1215
1372
  viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
1216
- validateViewportPoint: true
1217
- }));
1218
-
1219
- function recordHumanEvent(event = null) {
1220
- if (!event) return lastHumanEvent;
1221
- lastHumanEvent = {
1222
- at: new Date().toISOString(),
1223
- ...event
1224
- };
1225
- return lastHumanEvent;
1226
- }
1227
-
1228
- async function maybeHumanActionCooldown(phase, timings = {}) {
1229
- if (!effectiveHumanBehavior.actionCooldown) return null;
1230
- const pauseMs = humanDelay(280, 90, {
1231
- minMs: 80,
1232
- maxMs: 720
1233
- });
1234
- if (pauseMs > 0) {
1235
- await runControl.sleep(pauseMs);
1236
- addTiming(timings, `human_${phase}_pause_ms`, pauseMs);
1237
- }
1238
- return recordHumanEvent({
1239
- kind: "action_cooldown",
1240
- phase,
1241
- pause_ms: pauseMs
1242
- });
1243
- }
1244
-
1245
- function updateRecommendProgress(extra = {}) {
1246
- const counts = countRecommendResultStatuses(results, { greetCount });
1373
+ validateViewportPoint: true
1374
+ }));
1375
+
1376
+ function currentConnectionEpoch() {
1377
+ const epoch = Number(client?.__connectionEpoch);
1378
+ return Number.isInteger(epoch) && epoch > 0 ? epoch : null;
1379
+ }
1380
+
1381
+ function compactLifecycleUrl(value = "") {
1382
+ const text = String(value || "").trim();
1383
+ if (!text) return null;
1384
+ try {
1385
+ const parsed = new URL(text);
1386
+ return `${parsed.origin}${parsed.pathname}`.slice(0, 500);
1387
+ } catch {
1388
+ return text.split(/[?#]/, 1)[0].slice(0, 500) || null;
1389
+ }
1390
+ }
1391
+
1392
+ function recordDomLifecycleEvent(type, payload = {}) {
1393
+ const frame = payload?.frame || payload || {};
1394
+ const event = {
1395
+ at: new Date().toISOString(),
1396
+ type: String(type || "unknown").slice(0, 100),
1397
+ operation: currentDomOperation,
1398
+ connection_epoch: currentConnectionEpoch(),
1399
+ frame_id: String(frame?.id || payload?.frameId || "").slice(0, 200) || null,
1400
+ parent_frame_id: String(frame?.parentId || "").slice(0, 200) || null,
1401
+ loader_id: String(frame?.loaderId || "").slice(0, 200) || null,
1402
+ url: compactLifecycleUrl(frame?.url || "")
1403
+ };
1404
+ domLifecycleTimeline.push(event);
1405
+ if (domLifecycleTimeline.length > 40) domLifecycleTimeline.splice(0, domLifecycleTimeline.length - 40);
1406
+ return event;
1407
+ }
1408
+
1409
+ function subscribeDomLifecycleEvents() {
1410
+ const subscriptions = [
1411
+ [client?.DOM, "documentUpdated", "DOM.documentUpdated"],
1412
+ [client?.Page, "frameNavigated", "Page.frameNavigated"],
1413
+ [client?.Page, "frameDetached", "Page.frameDetached"],
1414
+ [client?.Page, "frameStartedLoading", "Page.frameStartedLoading"],
1415
+ [client?.Page, "frameStoppedLoading", "Page.frameStoppedLoading"]
1416
+ ];
1417
+ for (const [domain, eventName, label] of subscriptions) {
1418
+ if (typeof domain?.[eventName] !== "function") continue;
1419
+ try {
1420
+ domain[eventName]((payload = {}) => recordDomLifecycleEvent(label, payload));
1421
+ } catch {
1422
+ // Lifecycle observation is diagnostic-only and must not alter the run.
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ function checkpointDomStaleForensic(error, {
1428
+ phase = "",
1429
+ operation = currentDomOperation,
1430
+ detailStep = "",
1431
+ candidateIndex = null,
1432
+ candidateKey = "",
1433
+ cardNodeId = null
1434
+ } = {}) {
1435
+ if (!isStaleRecommendNodeError(error)) return null;
1436
+ domStaleEventCount += 1;
1437
+ const event = createRecommendDomStaleForensicEvent(error, {
1438
+ eventId: `recommend_dom_stale_${runControl?.runId || "run"}_${domStaleEventCount}`,
1439
+ phase,
1440
+ operation,
1441
+ detailStep,
1442
+ candidateIndex,
1443
+ candidateKey,
1444
+ cardNodeId,
1445
+ rootState,
1446
+ connectionEpoch: currentConnectionEpoch(),
1447
+ listState,
1448
+ counters: countRecommendResultStatuses(results, { greetCount }),
1449
+ timeline: domLifecycleTimeline
1450
+ });
1451
+ domStaleForensics.push(event);
1452
+ if (domStaleForensics.length > 12) domStaleForensics.splice(0, domStaleForensics.length - 12);
1453
+ runControl.checkpoint({
1454
+ dom_stale_forensic: event,
1455
+ dom_stale_forensics: domStaleForensics.slice(),
1456
+ candidate_list: compactInfiniteListState(listState)
1457
+ });
1458
+ updateRecommendProgress({
1459
+ dom_stale_events: domStaleEventCount,
1460
+ last_dom_stale_phase: phase || null,
1461
+ last_dom_stale_operation: operation || null
1462
+ });
1463
+ return event;
1464
+ }
1465
+
1466
+ function checkpointDomStaleRecovery(event, {
1467
+ status = "unknown",
1468
+ recoveryResult = null,
1469
+ recoveryError = null
1470
+ } = {}) {
1471
+ if (!event) return null;
1472
+ event.recovery = {
1473
+ status: String(status || "unknown").slice(0, 100),
1474
+ at: new Date().toISOString(),
1475
+ mode: String(recoveryResult?.recovery_mode || recoveryResult?.method || "").slice(0, 100) || null,
1476
+ escalated_from: String(recoveryResult?.escalated_from || "").slice(0, 100) || null,
1477
+ ok: recoveryResult?.ok === undefined ? null : Boolean(recoveryResult.ok),
1478
+ method: String(recoveryResult?.method || recoveryResult?.context_reapply?.method || "").slice(0, 100) || null,
1479
+ card_count: Number.isInteger(recoveryResult?.card_count)
1480
+ ? recoveryResult.card_count
1481
+ : Number.isInteger(recoveryResult?.root_reacquire?.card_count)
1482
+ ? recoveryResult.root_reacquire.card_count
1483
+ : Number.isInteger(recoveryResult?.context_reapply?.card_count)
1484
+ ? recoveryResult.context_reapply.card_count
1485
+ : null,
1486
+ error: recoveryError ? compactCdpFailureDiagnostic(recoveryError, {
1487
+ fallbackCode: "RECOMMEND_DOM_STALE_RECOVERY_FAILED",
1488
+ fallbackPhase: "recommend:recover-context"
1489
+ }) : null
1490
+ };
1491
+ event.post_recovery_roots = compactRecommendDomRootIdentity(rootState, currentConnectionEpoch());
1492
+ event.lifecycle_timeline = domLifecycleTimeline.slice(-20);
1493
+ runControl.checkpoint({
1494
+ dom_stale_forensic: event,
1495
+ dom_stale_forensics: domStaleForensics.slice(),
1496
+ candidate_list: compactInfiniteListState(listState)
1497
+ });
1498
+ return event;
1499
+ }
1500
+
1501
+ subscribeDomLifecycleEvents();
1502
+
1503
+ function recordHumanEvent(event = null) {
1504
+ if (!event) return lastHumanEvent;
1505
+ lastHumanEvent = {
1506
+ at: new Date().toISOString(),
1507
+ ...event
1508
+ };
1509
+ return lastHumanEvent;
1510
+ }
1511
+
1512
+ async function maybeHumanActionCooldown(phase, timings = {}) {
1513
+ if (!effectiveHumanBehavior.actionCooldown) return null;
1514
+ const pauseMs = humanDelay(280, 90, {
1515
+ minMs: 80,
1516
+ maxMs: 720
1517
+ });
1518
+ if (pauseMs > 0) {
1519
+ await runControl.sleep(pauseMs);
1520
+ addTiming(timings, `human_${phase}_pause_ms`, pauseMs);
1521
+ }
1522
+ return recordHumanEvent({
1523
+ kind: "action_cooldown",
1524
+ phase,
1525
+ pause_ms: pauseMs
1526
+ });
1527
+ }
1528
+
1529
+ function updateRecommendProgress(extra = {}) {
1530
+ const counts = countRecommendResultStatuses(results, { greetCount });
1247
1531
  const listSnapshot = compactInfiniteListState(listState);
1248
1532
  const humanRestState = humanRestController.getState();
1249
1533
  runControl.updateProgress({
1250
1534
  card_count: cardNodeIds.length,
1251
- target_count: targetPassCount,
1252
- target_count_semantics: "passed_candidates",
1253
- ...counts,
1254
- transient_recovered: counts.transient_recovered + listReadStaleRecoveries,
1535
+ target_count: targetPassCount,
1536
+ target_count_semantics: "passed_candidates",
1537
+ ...counts,
1538
+ transient_recovered: counts.transient_recovered + listReadStaleRecoveries,
1255
1539
  screening_mode: normalizedScreeningMode,
1256
1540
  unique_seen: listSnapshot.seen_count,
1257
1541
  scroll_count: listSnapshot.scroll_count,
@@ -1262,29 +1546,29 @@ export async function runRecommendWorkflow({
1262
1546
  viewport_checks: viewportGuard.getStats().checks,
1263
1547
  viewport_recoveries: viewportGuard.getStats().recoveries,
1264
1548
  human_behavior_enabled: effectiveHumanBehavior.enabled,
1265
- human_behavior_profile: effectiveHumanBehavior.profile,
1266
- human_rest_level: effectiveHumanBehavior.restLevel,
1267
- human_rest_enabled: effectiveHumanRestEnabled,
1268
- skip_recent_colleague_contacted: shouldSkipRecentColleagueContacted,
1269
- colleague_contact_window_days: normalizedColleagueContactWindowDays,
1270
- current_city_only_requested: normalizedFilter.currentCityOnly,
1271
- current_city_only_effective: currentCityOnlyResult?.effective ?? null,
1272
- current_city_only_unavailable: Boolean(currentCityOnlyResult?.unavailable),
1273
- human_rest_count: humanRestState.rest_count,
1274
- human_rest_ms: humanRestState.total_rest_ms,
1275
- last_human_event: lastHumanEvent,
1276
- debug_boundary_mode: debugBoundary.config.mode,
1277
- debug_boundary_threshold: debugBoundary.config.threshold,
1278
- debug_boundary_triggered: debugBoundary.getState().triggered,
1279
- debug_boundary_trigger_count: debugBoundary.getState().trigger_count,
1280
- list_read_stale_recovery_attempts: listReadStaleRecoveryAttempts,
1281
- list_read_stale_recovery_applied: listReadStaleRecoveryApplied,
1282
- list_read_stale_recoveries: listReadStaleRecoveries,
1283
- last_list_read_recovery_mode: lastListReadRecoveryMode,
1284
- last_list_read_stale_diagnostic: lastListReadStaleDiagnostic,
1285
- ...extra
1286
- });
1287
- }
1549
+ human_behavior_profile: effectiveHumanBehavior.profile,
1550
+ human_rest_level: effectiveHumanBehavior.restLevel,
1551
+ human_rest_enabled: effectiveHumanRestEnabled,
1552
+ skip_recent_colleague_contacted: shouldSkipRecentColleagueContacted,
1553
+ colleague_contact_window_days: normalizedColleagueContactWindowDays,
1554
+ current_city_only_requested: normalizedFilter.currentCityOnly,
1555
+ current_city_only_effective: currentCityOnlyResult?.effective ?? null,
1556
+ current_city_only_unavailable: Boolean(currentCityOnlyResult?.unavailable),
1557
+ human_rest_count: humanRestState.rest_count,
1558
+ human_rest_ms: humanRestState.total_rest_ms,
1559
+ last_human_event: lastHumanEvent,
1560
+ debug_boundary_mode: debugBoundary.config.mode,
1561
+ debug_boundary_threshold: debugBoundary.config.threshold,
1562
+ debug_boundary_triggered: debugBoundary.getState().triggered,
1563
+ debug_boundary_trigger_count: debugBoundary.getState().trigger_count,
1564
+ list_read_stale_recovery_attempts: listReadStaleRecoveryAttempts,
1565
+ list_read_stale_recovery_applied: listReadStaleRecoveryApplied,
1566
+ list_read_stale_recoveries: listReadStaleRecoveries,
1567
+ last_list_read_recovery_mode: lastListReadRecoveryMode,
1568
+ last_list_read_stale_diagnostic: lastListReadStaleDiagnostic,
1569
+ ...extra
1570
+ });
1571
+ }
1288
1572
 
1289
1573
  function checkpointInProgressCandidate({
1290
1574
  index = results.length,
@@ -1320,27 +1604,27 @@ export async function runRecommendWorkflow({
1320
1604
  async function recoverAndReapplyRecommendContext(reason = "context_recovery", error = null, {
1321
1605
  forceRecentNotView = true
1322
1606
  } = {}) {
1323
- await runControl.waitIfPaused();
1324
- runControl.throwIfCanceled();
1325
- const started = Date.now();
1326
- runControl.setPhase("recommend:recover-context");
1327
- contextRecoveryAttempts += 1;
1328
- const effectiveForceRecentNotView = normalizedFilter.enabled && forceRecentNotView;
1329
- const refreshResult = await refreshRecommendListAtEnd(client, {
1330
- rootState,
1331
- jobLabel,
1332
- pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
1333
- fallbackPageScope: normalizedFallbackPageScope,
1334
- filter: normalizedFilter,
1335
- preferEndRefreshButton: false,
1336
- forceNavigate: true,
1337
- targetUrl: targetUrl || RECOMMEND_TARGET_URL,
1338
- forceRecentNotView: effectiveForceRecentNotView,
1339
- cardTimeoutMs,
1340
- buttonSettleMs: refreshButtonSettleMs,
1341
- reloadSettleMs: refreshReloadSettleMs
1342
- });
1343
- let blockingPanelClose = null;
1607
+ await runControl.waitIfPaused();
1608
+ runControl.throwIfCanceled();
1609
+ const started = Date.now();
1610
+ runControl.setPhase("recommend:recover-context");
1611
+ contextRecoveryAttempts += 1;
1612
+ const effectiveForceRecentNotView = normalizedFilter.enabled && forceRecentNotView;
1613
+ const refreshResult = await refreshRecommendListAtEnd(client, {
1614
+ rootState,
1615
+ jobLabel,
1616
+ pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
1617
+ fallbackPageScope: normalizedFallbackPageScope,
1618
+ filter: normalizedFilter,
1619
+ preferEndRefreshButton: false,
1620
+ forceNavigate: true,
1621
+ targetUrl: targetUrl || RECOMMEND_TARGET_URL,
1622
+ forceRecentNotView: effectiveForceRecentNotView,
1623
+ cardTimeoutMs,
1624
+ buttonSettleMs: refreshButtonSettleMs,
1625
+ reloadSettleMs: refreshReloadSettleMs
1626
+ });
1627
+ let blockingPanelClose = null;
1344
1628
  if (refreshResult.ok) {
1345
1629
  blockingPanelClose = await closeRecommendBlockingPanels(client, {
1346
1630
  attemptsLimit: 2,
@@ -1364,33 +1648,33 @@ export async function runRecommendWorkflow({
1364
1648
  refresh: compactRefresh,
1365
1649
  counters: countRecommendResultStatuses(results, { greetCount })
1366
1650
  },
1367
- candidate_list: compactInfiniteListState(listState)
1368
- });
1369
- if (!refreshResult.ok) {
1370
- updateRecommendProgress({
1371
- refresh_method: refreshResult.method || null,
1372
- refresh_forced_recent_not_view: effectiveForceRecentNotView,
1373
- recovery_reason: reason
1374
- });
1375
- const recoveryError = new Error(
1376
- `Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`
1377
- );
1378
- recoveryError.code = "RECOMMEND_CONTEXT_RECOVERY_FAILED";
1379
- recoveryError.refresh_attempt = compactRefresh;
1380
- recoveryError.recovery_reason = reason;
1381
- throw attachRecommendRefreshErrorDiagnostic(recoveryError, compactRefresh);
1382
- }
1383
- if (refreshResult.current_city_only) {
1384
- currentCityOnlyResult = refreshResult.current_city_only;
1385
- }
1386
- if (refreshResult.filter) {
1387
- filterResult = refreshResult.filter;
1388
- }
1389
- if (!blockingPanelClose?.closed) {
1390
- const panelError = createRecommendBlockingPanelCloseFailureError(blockingPanelClose, `recover:${reason}`);
1391
- panelError.refresh_attempt = compactRefresh;
1392
- throw panelError;
1393
- }
1651
+ candidate_list: compactInfiniteListState(listState)
1652
+ });
1653
+ if (!refreshResult.ok) {
1654
+ updateRecommendProgress({
1655
+ refresh_method: refreshResult.method || null,
1656
+ refresh_forced_recent_not_view: effectiveForceRecentNotView,
1657
+ recovery_reason: reason
1658
+ });
1659
+ const recoveryError = new Error(
1660
+ `Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`
1661
+ );
1662
+ recoveryError.code = "RECOMMEND_CONTEXT_RECOVERY_FAILED";
1663
+ recoveryError.refresh_attempt = compactRefresh;
1664
+ 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) {
1674
+ const panelError = createRecommendBlockingPanelCloseFailureError(blockingPanelClose, `recover:${reason}`);
1675
+ panelError.refresh_attempt = compactRefresh;
1676
+ throw panelError;
1677
+ }
1394
1678
  rootState = refreshResult.root_state || await getRecommendRoots(client);
1395
1679
  rootState = await ensureRecommendViewport(rootState, "recover_after");
1396
1680
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
@@ -1398,25 +1682,25 @@ export async function runRecommendWorkflow({
1398
1682
  intervalMs: 300
1399
1683
  });
1400
1684
  resetInfiniteListForRefreshRound(listState, {
1401
- reason: `context_recovery:${reason}`,
1402
- round: contextRecoveryAttempts,
1403
- method: refreshResult.method,
1404
- metadata: {
1405
- card_count: cardNodeIds.length,
1406
- forced_recent_not_view: effectiveForceRecentNotView,
1407
- counters: countRecommendResultStatuses(results, { greetCount })
1408
- }
1409
- });
1410
- listEndReason = "";
1411
- updateRecommendProgress({
1412
- card_count: cardNodeIds.length,
1413
- refresh_method: refreshResult.method || null,
1414
- refresh_forced_recent_not_view: effectiveForceRecentNotView,
1415
- recovery_reason: reason
1416
- });
1417
- return refreshResult;
1418
- }
1419
-
1685
+ reason: `context_recovery:${reason}`,
1686
+ round: contextRecoveryAttempts,
1687
+ method: refreshResult.method,
1688
+ metadata: {
1689
+ card_count: cardNodeIds.length,
1690
+ forced_recent_not_view: effectiveForceRecentNotView,
1691
+ counters: countRecommendResultStatuses(results, { greetCount })
1692
+ }
1693
+ });
1694
+ listEndReason = "";
1695
+ updateRecommendProgress({
1696
+ card_count: cardNodeIds.length,
1697
+ refresh_method: refreshResult.method || null,
1698
+ refresh_forced_recent_not_view: effectiveForceRecentNotView,
1699
+ recovery_reason: reason
1700
+ });
1701
+ return refreshResult;
1702
+ }
1703
+
1420
1704
  runControl.setPhase("recommend:cleanup");
1421
1705
  await closeRecommendDetail(client, { attemptsLimit: 2 });
1422
1706
  await closeRecommendAvatarPreview(client, { attemptsLimit: 2 });
@@ -1463,55 +1747,55 @@ export async function runRecommendWorkflow({
1463
1747
  throw new Error(`Recommend page scope was not selected: ${pageScopeSelection.reason || pageScopeSelection.effective_scope || requestedPageScope}`);
1464
1748
  }
1465
1749
  rootState = await getRecommendRoots(client);
1466
- rootState = await ensureRecommendViewport(rootState, "page_scope");
1467
- runControl.checkpoint({
1468
- page_scope: compactPageScopeSelection(pageScopeSelection)
1469
- });
1470
-
1471
- const initialFilterStages = await applyRecommendFilterEnvelopeStages(normalizedFilter, {
1472
- applyCurrentCityOnly: async () => {
1473
- await runControl.waitIfPaused();
1474
- runControl.throwIfCanceled();
1475
- runControl.setPhase("recommend:current-city-only");
1476
- const result = await ensureRecommendCurrentCityOnly(
1477
- client,
1478
- rootState.iframe.documentNodeId,
1479
- { enabled: normalizedFilter.currentCityOnly }
1480
- );
1481
- rootState = await getRecommendRoots(client);
1482
- rootState = await ensureRecommendViewport(rootState, "current_city_only");
1483
- runControl.checkpoint({
1484
- current_city_only: compactCurrentCityOnlyResult(result)
1485
- });
1486
- return result;
1487
- },
1488
- applyFilterPanel: async () => {
1489
- await runControl.waitIfPaused();
1490
- runControl.throwIfCanceled();
1491
- runControl.setPhase("recommend:filter");
1492
- const result = await selectAndConfirmFirstSafeFilter(
1493
- client,
1494
- rootState.iframe.documentNodeId,
1495
- buildRecommendFilterSelectionOptions(normalizedFilter)
1496
- );
1497
- if (!result.confirmed) {
1498
- throw new Error("Recommend run filter selection was not confirmed");
1499
- }
1500
- rootState = await getRecommendRoots(client);
1501
- rootState = await ensureRecommendViewport(rootState, "filter");
1502
- runControl.checkpoint({
1503
- filter: compactFilterResult(result)
1504
- });
1505
- return result;
1506
- }
1507
- });
1508
- currentCityOnlyResult = initialFilterStages.current_city_only;
1509
- filterResult = initialFilterStages.filter;
1510
-
1511
- await runControl.waitIfPaused();
1512
- runControl.throwIfCanceled();
1513
- runControl.setPhase("recommend:cards");
1514
- rootState = await ensureRecommendViewport(rootState, "cards");
1750
+ rootState = await ensureRecommendViewport(rootState, "page_scope");
1751
+ runControl.checkpoint({
1752
+ page_scope: compactPageScopeSelection(pageScopeSelection)
1753
+ });
1754
+
1755
+ const initialFilterStages = await applyRecommendFilterEnvelopeStages(normalizedFilter, {
1756
+ applyCurrentCityOnly: async () => {
1757
+ await runControl.waitIfPaused();
1758
+ runControl.throwIfCanceled();
1759
+ runControl.setPhase("recommend:current-city-only");
1760
+ const result = await ensureRecommendCurrentCityOnly(
1761
+ client,
1762
+ rootState.iframe.documentNodeId,
1763
+ { enabled: normalizedFilter.currentCityOnly }
1764
+ );
1765
+ rootState = await getRecommendRoots(client);
1766
+ rootState = await ensureRecommendViewport(rootState, "current_city_only");
1767
+ runControl.checkpoint({
1768
+ current_city_only: compactCurrentCityOnlyResult(result)
1769
+ });
1770
+ return result;
1771
+ },
1772
+ applyFilterPanel: async () => {
1773
+ await runControl.waitIfPaused();
1774
+ runControl.throwIfCanceled();
1775
+ 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
+ }
1784
+ rootState = await getRecommendRoots(client);
1785
+ rootState = await ensureRecommendViewport(rootState, "filter");
1786
+ runControl.checkpoint({
1787
+ filter: compactFilterResult(result)
1788
+ });
1789
+ return result;
1790
+ }
1791
+ });
1792
+ currentCityOnlyResult = initialFilterStages.current_city_only;
1793
+ filterResult = initialFilterStages.filter;
1794
+
1795
+ await runControl.waitIfPaused();
1796
+ runControl.throwIfCanceled();
1797
+ runControl.setPhase("recommend:cards");
1798
+ rootState = await ensureRecommendViewport(rootState, "cards");
1515
1799
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
1516
1800
  timeoutMs: cardTimeoutMs,
1517
1801
  intervalMs: 300
@@ -1527,244 +1811,305 @@ export async function runRecommendWorkflow({
1527
1811
  while (countPassedResults(results) < targetPassCount) {
1528
1812
  const candidateStarted = Date.now();
1529
1813
  const timings = {};
1530
- await runControl.waitIfPaused();
1814
+ await runControl.waitIfPaused();
1531
1815
  runControl.throwIfCanceled();
1532
1816
  runControl.setPhase("recommend:list-read");
1533
- const debugBoundaryAction = debugBoundary.take(results.length);
1534
- let debugForcedListEnd = false;
1535
- if (debugBoundaryAction) {
1536
- const debugProgress = {
1537
- debug_boundary_mode: debugBoundaryAction.mode,
1538
- debug_boundary_threshold: debugBoundaryAction.threshold,
1539
- debug_boundary_triggered: true,
1540
- debug_boundary_trigger_count: debugBoundaryAction.trigger_count,
1541
- debug_boundary_processed: debugBoundaryAction.processed
1542
- };
1543
- updateRecommendProgress(debugProgress);
1544
- runControl.checkpoint({
1545
- debug_boundary: {
1546
- ...debugProgress,
1547
- field: debugBoundaryAction.field,
1548
- triggered_at: new Date().toISOString()
1549
- }
1550
- });
1551
- if (debugBoundaryAction.mode === "context_recovery") {
1552
- runControl.setPhase("recommend:debug-force-context-recovery");
1553
- await recoverAndReapplyRecommendContext("debug_force_context_recovery_after_processed", null, {
1554
- forceRecentNotView: true
1555
- });
1556
- updateRecommendProgress({
1557
- debug_force_context_recovery_count: debugBoundaryAction.trigger_count
1558
- });
1559
- runControl.setPhase("recommend:list-read");
1560
- } else if (debugBoundaryAction.mode === "cdp_reconnect") {
1561
- debugForceReconnectPending = debugBoundaryAction;
1562
- updateRecommendProgress({
1563
- debug_force_cdp_reconnect_pending: true
1564
- });
1565
- } else if (debugBoundaryAction.mode === "list_end") {
1566
- debugForcedListEnd = true;
1567
- updateRecommendProgress({
1568
- debug_force_list_end_count: debugBoundaryAction.trigger_count
1569
- });
1570
- }
1571
- }
1572
- const listReadAcquisition = await acquireRecommendListReadWithStaleRecovery({
1573
- maxRetries: 2,
1574
- acquire: async () => {
1575
- await runControl.waitIfPaused();
1576
- runControl.throwIfCanceled();
1577
- runControl.setPhase("recommend:list-read");
1578
- rootState = await ensureRecommendViewport(rootState, "candidate_loop");
1579
- if (debugForcedListEnd) {
1580
- return {
1581
- ok: false,
1582
- end_reached: true,
1583
- reason: "debug_forced_list_end"
1584
- };
1585
- }
1586
- return measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
1587
- client,
1588
- state: listState,
1589
- maxScrolls: listMaxScrolls,
1590
- stableSignatureLimit: listStableSignatureLimit,
1591
- wheelDeltaY: listWheelDeltaY,
1592
- settleMs: listSettleMs,
1593
- listScrollJitterEnabled: effectiveHumanBehavior.listScrollJitter,
1594
- fallbackPoint: listFallbackResolver,
1817
+ currentDomOperation = "candidate:list-read";
1818
+ const debugBoundaryAction = debugBoundary.take(results.length);
1819
+ let debugForcedListEnd = false;
1820
+ if (debugBoundaryAction) {
1821
+ const debugProgress = {
1822
+ debug_boundary_mode: debugBoundaryAction.mode,
1823
+ debug_boundary_threshold: debugBoundaryAction.threshold,
1824
+ debug_boundary_triggered: true,
1825
+ debug_boundary_trigger_count: debugBoundaryAction.trigger_count,
1826
+ debug_boundary_processed: debugBoundaryAction.processed
1827
+ };
1828
+ updateRecommendProgress(debugProgress);
1829
+ runControl.checkpoint({
1830
+ debug_boundary: {
1831
+ ...debugProgress,
1832
+ field: debugBoundaryAction.field,
1833
+ triggered_at: new Date().toISOString()
1834
+ }
1835
+ });
1836
+ if (debugBoundaryAction.mode === "context_recovery") {
1837
+ runControl.setPhase("recommend:debug-force-context-recovery");
1838
+ await recoverAndReapplyRecommendContext("debug_force_context_recovery_after_processed", null, {
1839
+ forceRecentNotView: true
1840
+ });
1841
+ updateRecommendProgress({
1842
+ debug_force_context_recovery_count: debugBoundaryAction.trigger_count
1843
+ });
1844
+ runControl.setPhase("recommend:list-read");
1845
+ } else if (debugBoundaryAction.mode === "cdp_reconnect") {
1846
+ debugForceReconnectPending = debugBoundaryAction;
1847
+ updateRecommendProgress({
1848
+ debug_force_cdp_reconnect_pending: true
1849
+ });
1850
+ } else if (debugBoundaryAction.mode === "list_end") {
1851
+ debugForcedListEnd = true;
1852
+ updateRecommendProgress({
1853
+ debug_force_list_end_count: debugBoundaryAction.trigger_count
1854
+ });
1855
+ }
1856
+ }
1857
+ const listReadAcquisition = await acquireRecommendListReadWithStaleRecovery({
1858
+ 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");
1864
+ if (debugForcedListEnd) {
1865
+ return {
1866
+ ok: false,
1867
+ end_reached: true,
1868
+ reason: "debug_forced_list_end"
1869
+ };
1870
+ }
1871
+ return measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
1872
+ client,
1873
+ state: listState,
1874
+ maxScrolls: listMaxScrolls,
1875
+ stableSignatureLimit: listStableSignatureLimit,
1876
+ wheelDeltaY: listWheelDeltaY,
1877
+ settleMs: listSettleMs,
1878
+ listScrollJitterEnabled: effectiveHumanBehavior.listScrollJitter,
1879
+ fallbackPoint: listFallbackResolver,
1595
1880
  findNodeIds: async () => {
1881
+ currentDomOperation = "candidate:list-find-nodes";
1596
1882
  let currentRootState = await getRecommendRoots(client);
1597
- currentRootState = await ensureRecommendViewport(currentRootState, "candidate_find_nodes");
1598
- rootState = currentRootState;
1599
- if (debugForceReconnectPending) {
1600
- runControl.setPhase("recommend:debug-force-cdp-reconnect");
1601
- const rawClient = client.__rawClient;
1602
- if (!rawClient || typeof rawClient.close !== "function") {
1603
- const error = new Error("Guarded CDP client does not expose a closable __rawClient");
1604
- error.code = "RECOMMEND_DEBUG_RAW_CDP_UNAVAILABLE";
1605
- throw error;
1606
- }
1607
- const forcedReconnectAction = debugForceReconnectPending;
1608
- debugForceReconnectPending = null;
1609
- await rawClient.close();
1610
- runControl.checkpoint({
1611
- debug_cdp_reconnect: {
1612
- raw_connection_closed: true,
1613
- processed: forcedReconnectAction.processed,
1614
- threshold: forcedReconnectAction.threshold,
1615
- closed_at: new Date().toISOString()
1616
- }
1617
- });
1618
- updateRecommendProgress({
1619
- debug_force_cdp_reconnect_pending: false,
1620
- debug_force_cdp_reconnect_count: forcedReconnectAction.trigger_count
1621
- });
1622
- runControl.setPhase("recommend:list-read");
1623
- }
1624
- const currentCardNodeIds = await waitForRecommendCardNodeIds(
1625
- client,
1626
- currentRootState.iframe.documentNodeId,
1627
- {
1628
- timeoutMs: Math.min(cardTimeoutMs, 5000),
1629
- intervalMs: 300
1883
+ currentRootState = await ensureRecommendViewport(currentRootState, "candidate_find_nodes");
1884
+ rootState = currentRootState;
1885
+ if (debugForceReconnectPending) {
1886
+ runControl.setPhase("recommend:debug-force-cdp-reconnect");
1887
+ const rawClient = client.__rawClient;
1888
+ if (!rawClient || typeof rawClient.close !== "function") {
1889
+ const error = new Error("Guarded CDP client does not expose a closable __rawClient");
1890
+ error.code = "RECOMMEND_DEBUG_RAW_CDP_UNAVAILABLE";
1891
+ throw error;
1892
+ }
1893
+ const forcedReconnectAction = debugForceReconnectPending;
1894
+ debugForceReconnectPending = null;
1895
+ await rawClient.close();
1896
+ runControl.checkpoint({
1897
+ debug_cdp_reconnect: {
1898
+ raw_connection_closed: true,
1899
+ processed: forcedReconnectAction.processed,
1900
+ threshold: forcedReconnectAction.threshold,
1901
+ closed_at: new Date().toISOString()
1902
+ }
1903
+ });
1904
+ updateRecommendProgress({
1905
+ debug_force_cdp_reconnect_pending: false,
1906
+ debug_force_cdp_reconnect_count: forcedReconnectAction.trigger_count
1907
+ });
1908
+ runControl.setPhase("recommend:list-read");
1909
+ }
1910
+ const currentCardNodeIds = await waitForRecommendCardNodeIds(
1911
+ client,
1912
+ currentRootState.iframe.documentNodeId,
1913
+ {
1914
+ timeoutMs: Math.min(cardTimeoutMs, 5000),
1915
+ intervalMs: 300
1916
+ }
1917
+ );
1918
+ cardNodeIds = currentCardNodeIds;
1919
+ return currentCardNodeIds;
1920
+ },
1921
+ readCandidate: async (nodeId, { visibleIndex }) => {
1922
+ currentDomOperation = "candidate:list-read-card";
1923
+ return readRecommendCardCandidate(client, nodeId, {
1924
+ targetUrl,
1925
+ source: "recommend-run-card",
1926
+ metadata: {
1927
+ run_candidate_index: results.length,
1928
+ visible_index: visibleIndex
1630
1929
  }
1631
- );
1632
- cardNodeIds = currentCardNodeIds;
1633
- return currentCardNodeIds;
1930
+ });
1634
1931
  },
1635
- readCandidate: async (nodeId, { visibleIndex }) => readRecommendCardCandidate(client, nodeId, {
1636
- targetUrl,
1637
- source: "recommend-run-card",
1638
- metadata: {
1639
- run_candidate_index: results.length,
1640
- visible_index: visibleIndex
1641
- }
1642
- }),
1643
1932
  shouldRethrowReadError: isStaleRecommendNodeError,
1644
- detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
1645
- rootNodeId: rootState?.iframe?.documentNodeId,
1646
- markerSelectors: RECOMMEND_BOTTOM_MARKER_SELECTORS,
1647
- refreshSelectors: [RECOMMEND_END_REFRESH_SELECTOR],
1648
- textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
1649
- maxTextScanNodes: 500
1650
- })
1933
+ detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => {
1934
+ currentDomOperation = "candidate:list-bottom-probe";
1935
+ return detectInfiniteListBottomMarker(client, {
1936
+ rootNodeId: rootState?.iframe?.documentNodeId,
1937
+ markerSelectors: RECOMMEND_BOTTOM_MARKER_SELECTORS,
1938
+ refreshSelectors: [RECOMMEND_END_REFRESH_SELECTOR],
1939
+ textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
1940
+ maxTextScanNodes: 500
1941
+ });
1942
+ }
1651
1943
  }));
1652
1944
  },
1653
- onStale: async ({ diagnostic }) => {
1945
+ onStale: async ({ error, diagnostic }) => {
1654
1946
  listReadStaleRecoveryAttempts += 1;
1655
- lastListReadStaleDiagnostic = diagnostic;
1656
- listReadStaleDiagnostics.push(diagnostic);
1657
- runControl.checkpoint({
1658
- list_read_stale_recovery: {
1659
- diagnostic,
1660
- recent_diagnostics: listReadStaleDiagnostics.slice(-12),
1661
- counters: countRecommendResultStatuses(results, { greetCount }),
1662
- candidate_list: compactInfiniteListState(listState)
1663
- }
1947
+ const forensic = checkpointDomStaleForensic(error, {
1948
+ phase: "recommend:list-read",
1949
+ operation: currentDomOperation,
1950
+ candidateIndex: results.length
1664
1951
  });
1665
- updateRecommendProgress();
1666
- },
1667
- recover: async ({ error, diagnostic }) => {
1668
- try {
1669
- return await recoverRecommendListReadStaleContext({
1670
- staleAttempt: diagnostic.attempt,
1671
- listState,
1672
- rootReacquire: async () => {
1673
- await runControl.waitIfPaused();
1674
- runControl.throwIfCanceled();
1675
- runControl.setPhase("recommend:list-read-root-reacquire");
1676
- let freshRootState = await getRecommendRoots(client);
1677
- freshRootState = await ensureRecommendViewport(
1678
- freshRootState,
1679
- "list_read_stale_root_reacquire"
1680
- );
1681
- const freshCardNodeIds = await waitForRecommendCardNodeIds(
1682
- client,
1683
- freshRootState.iframe.documentNodeId,
1684
- {
1685
- timeoutMs: Math.min(cardTimeoutMs, 10000),
1686
- intervalMs: 300
1687
- }
1688
- );
1689
- rootState = freshRootState;
1690
- cardNodeIds = freshCardNodeIds;
1952
+ if (forensic) {
1953
+ diagnostic.forensic_event_id = forensic.event_id;
1954
+ diagnostic.pre_recovery_roots = forensic.pre_recovery_roots;
1955
+ diagnostic.failing_candidate = forensic.candidate;
1956
+ }
1957
+ lastListReadStaleDiagnostic = diagnostic;
1958
+ listReadStaleDiagnostics.push(diagnostic);
1959
+ runControl.checkpoint({
1960
+ list_read_stale_recovery: {
1961
+ diagnostic,
1962
+ recent_diagnostics: listReadStaleDiagnostics.slice(-12),
1963
+ counters: countRecommendResultStatuses(results, { greetCount }),
1964
+ candidate_list: compactInfiniteListState(listState)
1965
+ }
1966
+ });
1967
+ updateRecommendProgress();
1968
+ },
1969
+ recover: async ({ error, diagnostic }) => {
1970
+ 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;
1691
1993
  return {
1692
1994
  card_count: freshCardNodeIds.length,
1693
- iframe_document_node_id: freshRootState.iframe.documentNodeId
1995
+ iframe_document_node_id: freshRootState.iframe.documentNodeId,
1996
+ root_identity: compactRecommendDomRootIdentity(
1997
+ freshRootState,
1998
+ currentConnectionEpoch()
1999
+ )
1694
2000
  };
1695
- },
1696
- contextReapply: async ({ rootReacquireError }) => {
1697
- await runControl.waitIfPaused();
1698
- runControl.throwIfCanceled();
1699
- const recovery = await recoverAndReapplyRecommendContext(
1700
- "list_read_stale_node",
1701
- rootReacquireError || error,
1702
- { forceRecentNotView: true }
1703
- );
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,
2008
+ { forceRecentNotView: true }
2009
+ );
1704
2010
  return {
1705
2011
  ok: Boolean(recovery?.ok),
1706
2012
  method: recovery?.method || null,
1707
- card_count: recovery?.card_count || 0
2013
+ card_count: recovery?.card_count || 0,
2014
+ root_identity: compactRecommendDomRootIdentity(
2015
+ rootState,
2016
+ currentConnectionEpoch()
2017
+ )
1708
2018
  };
1709
- }
1710
- });
2019
+ }
2020
+ });
1711
2021
  } catch (recoveryError) {
2022
+ const forensic = domStaleForensics.find((item) => (
2023
+ item.event_id === diagnostic.forensic_event_id
2024
+ ));
2025
+ checkpointDomStaleRecovery(forensic, {
2026
+ status: "recovery_failed",
2027
+ recoveryError
2028
+ });
1712
2029
  updateRecommendProgress({
1713
2030
  list_read_stale_recovery_failed: true
1714
- });
1715
- runControl.checkpoint({
1716
- list_read_stale_recovery_failed: {
1717
- trigger: lastListReadStaleDiagnostic,
1718
- recent_diagnostics: listReadStaleDiagnostics.slice(-12),
1719
- recovery_error: compactError(recoveryError, "RECOMMEND_LIST_READ_RECOVERY_FAILED"),
1720
- candidate_list: compactInfiniteListState(listState)
1721
- }
1722
- });
1723
- throw recoveryError;
1724
- }
1725
- },
1726
- onRecoveryApplied: async ({ diagnostic }) => {
2031
+ });
2032
+ runControl.checkpoint({
2033
+ list_read_stale_recovery_failed: {
2034
+ trigger: lastListReadStaleDiagnostic,
2035
+ recent_diagnostics: listReadStaleDiagnostics.slice(-12),
2036
+ recovery_error: compactError(recoveryError, "RECOMMEND_LIST_READ_RECOVERY_FAILED"),
2037
+ candidate_list: compactInfiniteListState(listState)
2038
+ }
2039
+ });
2040
+ throw recoveryError;
2041
+ }
2042
+ },
2043
+ onRecoveryApplied: async ({ diagnostic, recoveryResult }) => {
1727
2044
  listReadStaleRecoveryApplied += 1;
1728
2045
  lastListReadStaleDiagnostic = diagnostic;
1729
2046
  lastListReadRecoveryMode = diagnostic.recovery_mode || null;
1730
- updateRecommendProgress();
1731
- runControl.checkpoint({
1732
- list_read_stale_recovery_applied: {
1733
- diagnostic,
1734
- recovery_applied_count: listReadStaleRecoveryApplied,
1735
- candidate_list: compactInfiniteListState(listState)
1736
- }
2047
+ const forensic = domStaleForensics.find((item) => (
2048
+ item.event_id === diagnostic.forensic_event_id
2049
+ ));
2050
+ checkpointDomStaleRecovery(forensic, {
2051
+ status: "recovery_applied",
2052
+ recoveryResult
1737
2053
  });
1738
- },
2054
+ updateRecommendProgress();
2055
+ runControl.checkpoint({
2056
+ list_read_stale_recovery_applied: {
2057
+ diagnostic,
2058
+ recovery_applied_count: listReadStaleRecoveryApplied,
2059
+ candidate_list: compactInfiniteListState(listState)
2060
+ }
2061
+ });
2062
+ },
1739
2063
  onRecovered: async ({ diagnostic }) => {
1740
2064
  listReadStaleRecoveries += 1;
1741
2065
  lastListReadStaleDiagnostic = diagnostic;
1742
- updateRecommendProgress();
1743
- runControl.checkpoint({
1744
- list_read_stale_recovered: {
1745
- diagnostic,
1746
- recovery_count: listReadStaleRecoveries,
1747
- candidate_list: compactInfiniteListState(listState)
2066
+ const forensic = domStaleForensics.find((item) => (
2067
+ item.event_id === diagnostic.forensic_event_id
2068
+ ));
2069
+ checkpointDomStaleRecovery(forensic, {
2070
+ status: "recovered",
2071
+ recoveryResult: {
2072
+ recovery_mode: diagnostic.recovery_mode,
2073
+ ok: true
1748
2074
  }
1749
2075
  });
1750
- },
1751
- onExhausted: async ({ diagnostic }) => {
2076
+ updateRecommendProgress();
2077
+ runControl.checkpoint({
2078
+ list_read_stale_recovered: {
2079
+ diagnostic,
2080
+ recovery_count: listReadStaleRecoveries,
2081
+ candidate_list: compactInfiniteListState(listState)
2082
+ }
2083
+ });
2084
+ },
2085
+ onExhausted: async ({ error, diagnostic }) => {
1752
2086
  listReadStaleRecoveryAttempts += 1;
1753
- lastListReadStaleDiagnostic = diagnostic;
1754
- listReadStaleDiagnostics.push(diagnostic);
1755
- updateRecommendProgress({
1756
- list_read_stale_recovery_exhausted: true
2087
+ const forensic = checkpointDomStaleForensic(error, {
2088
+ phase: "recommend:list-read",
2089
+ operation: currentDomOperation,
2090
+ candidateIndex: results.length
1757
2091
  });
1758
- runControl.checkpoint({
1759
- list_read_stale_recovery_exhausted: {
1760
- diagnostic,
1761
- recent_diagnostics: listReadStaleDiagnostics.slice(-12),
1762
- candidate_list: compactInfiniteListState(listState)
1763
- }
1764
- });
1765
- }
1766
- });
1767
- const nextCandidateResult = listReadAcquisition.result;
2092
+ if (forensic) {
2093
+ diagnostic.forensic_event_id = forensic.event_id;
2094
+ diagnostic.pre_recovery_roots = forensic.pre_recovery_roots;
2095
+ diagnostic.failing_candidate = forensic.candidate;
2096
+ checkpointDomStaleRecovery(forensic, { status: "exhausted" });
2097
+ }
2098
+ lastListReadStaleDiagnostic = diagnostic;
2099
+ listReadStaleDiagnostics.push(diagnostic);
2100
+ updateRecommendProgress({
2101
+ list_read_stale_recovery_exhausted: true
2102
+ });
2103
+ runControl.checkpoint({
2104
+ list_read_stale_recovery_exhausted: {
2105
+ diagnostic,
2106
+ recent_diagnostics: listReadStaleDiagnostics.slice(-12),
2107
+ candidate_list: compactInfiniteListState(listState)
2108
+ }
2109
+ });
2110
+ }
2111
+ });
2112
+ const nextCandidateResult = listReadAcquisition.result;
1768
2113
  if (!nextCandidateResult.ok) {
1769
2114
  listEndReason = nextCandidateResult.reason || "list_exhausted";
1770
2115
  if (
@@ -1774,58 +2119,79 @@ export async function runRecommendWorkflow({
1774
2119
  && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
1775
2120
  ) {
1776
2121
  await runControl.waitIfPaused();
1777
- runControl.throwIfCanceled();
1778
- runControl.setPhase("recommend:refresh");
1779
- refreshRounds += 1;
1780
- const refreshResult = await refreshRecommendListAtEnd(client, {
1781
- rootState,
1782
- jobLabel,
1783
- pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
1784
- fallbackPageScope: normalizedFallbackPageScope,
1785
- filter: normalizedFilter,
1786
- preferEndRefreshButton: debugForcedListEnd ? false : undefined,
1787
- forceRecentNotView: normalizedFilter.enabled,
1788
- cardTimeoutMs,
1789
- buttonSettleMs: refreshButtonSettleMs,
1790
- reloadSettleMs: refreshReloadSettleMs
1791
- });
1792
- const compactRefresh = compactRefreshAttempt(refreshResult);
2122
+ runControl.throwIfCanceled();
2123
+ runControl.setPhase("recommend:refresh");
2124
+ currentDomOperation = "candidate:list-end-refresh";
2125
+ refreshRounds += 1;
2126
+ let refreshResult;
2127
+ try {
2128
+ refreshResult = await refreshRecommendListAtEnd(client, {
2129
+ rootState,
2130
+ jobLabel,
2131
+ pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
2132
+ fallbackPageScope: normalizedFallbackPageScope,
2133
+ filter: normalizedFilter,
2134
+ preferEndRefreshButton: debugForcedListEnd ? false : undefined,
2135
+ forceRecentNotView: normalizedFilter.enabled,
2136
+ cardTimeoutMs,
2137
+ buttonSettleMs: refreshButtonSettleMs,
2138
+ reloadSettleMs: refreshReloadSettleMs
2139
+ });
2140
+ } catch (error) {
2141
+ checkpointDomStaleForensic(error, {
2142
+ phase: "recommend:refresh",
2143
+ operation: currentDomOperation,
2144
+ candidateIndex: results.length
2145
+ });
2146
+ throw error;
2147
+ }
2148
+ const compactRefresh = compactRefreshAttempt(refreshResult);
1793
2149
  refreshAttempts.push(compactRefresh);
1794
2150
  runControl.checkpoint({
1795
2151
  refresh_round: refreshRounds,
1796
- refresh: compactRefresh
1797
- });
1798
- updateRecommendProgress({
1799
- card_count: refreshResult.card_count || cardNodeIds.length,
1800
- refresh_method: refreshResult.method || null,
1801
- refresh_forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view),
1802
- list_end_reason: listEndReason
1803
- });
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
+ });
1804
2160
  if (refreshResult.ok) {
1805
- if (refreshResult.current_city_only) {
1806
- currentCityOnlyResult = refreshResult.current_city_only;
1807
- }
1808
- if (refreshResult.filter) {
1809
- filterResult = refreshResult.filter;
2161
+ if (refreshResult.current_city_only) {
2162
+ currentCityOnlyResult = refreshResult.current_city_only;
2163
+ }
2164
+ if (refreshResult.filter) {
2165
+ filterResult = refreshResult.filter;
2166
+ }
2167
+ try {
2168
+ currentDomOperation = "candidate:list-end-refresh-reacquire";
2169
+ rootState = refreshResult.root_state || await getRecommendRoots(client);
2170
+ rootState = await ensureRecommendViewport(rootState, "refresh_after");
2171
+ cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
2172
+ timeoutMs: cardTimeoutMs,
2173
+ intervalMs: 300
2174
+ });
2175
+ } catch (error) {
2176
+ checkpointDomStaleForensic(error, {
2177
+ phase: "recommend:refresh",
2178
+ operation: currentDomOperation,
2179
+ candidateIndex: results.length
2180
+ });
2181
+ throw error;
1810
2182
  }
1811
- rootState = refreshResult.root_state || await getRecommendRoots(client);
1812
- rootState = await ensureRecommendViewport(rootState, "refresh_after");
1813
- cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
1814
- timeoutMs: cardTimeoutMs,
1815
- intervalMs: 300
1816
- });
1817
2183
  resetInfiniteListForRefreshRound(listState, {
1818
- reason: listEndReason,
1819
- round: refreshRounds,
1820
- method: refreshResult.method,
1821
- metadata: {
1822
- card_count: cardNodeIds.length,
1823
- forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view)
1824
- }
1825
- });
1826
- listEndReason = "";
1827
- continue;
1828
- }
2184
+ reason: listEndReason,
2185
+ round: refreshRounds,
2186
+ method: refreshResult.method,
2187
+ metadata: {
2188
+ card_count: cardNodeIds.length,
2189
+ forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view)
2190
+ }
2191
+ });
2192
+ listEndReason = "";
2193
+ continue;
2194
+ }
1829
2195
  throw createRecommendRefreshFailureError(compactRefresh, {
1830
2196
  listEndReason,
1831
2197
  targetCount: targetPassCount,
@@ -1835,8 +2201,8 @@ export async function runRecommendWorkflow({
1835
2201
  break;
1836
2202
  }
1837
2203
 
1838
- runControl.setPhase("recommend:candidate");
1839
- const index = results.length;
2204
+ runControl.setPhase("recommend:candidate");
2205
+ const index = results.length;
1840
2206
  let cardNodeId = nextCandidateResult.item.node_id;
1841
2207
  const candidateKey = nextCandidateResult.item.key;
1842
2208
  let cardCandidate = nextCandidateResult.item.candidate;
@@ -1850,11 +2216,13 @@ export async function runRecommendWorkflow({
1850
2216
  if (index < effectiveDetailLimit) {
1851
2217
  try {
1852
2218
  await runControl.waitIfPaused();
1853
- runControl.throwIfCanceled();
1854
- runControl.setPhase("recommend:detail");
1855
- detailStep = "ensure_viewport";
1856
- rootState = await ensureRecommendViewport(rootState, "detail");
1857
- const blockingPanelClose = await closeRecommendBlockingPanels(client, {
2219
+ runControl.throwIfCanceled();
2220
+ runControl.setPhase("recommend:detail");
2221
+ detailStep = "ensure_viewport";
2222
+ currentDomOperation = "candidate:detail-ensure-viewport";
2223
+ rootState = await ensureRecommendViewport(rootState, "detail");
2224
+ currentDomOperation = "candidate:detail-close-blocking-panels";
2225
+ const blockingPanelClose = await closeRecommendBlockingPanels(client, {
1858
2226
  attemptsLimit: 2,
1859
2227
  rootState
1860
2228
  });
@@ -1872,10 +2240,12 @@ export async function runRecommendWorkflow({
1872
2240
  }
1873
2241
  if (blockingPanelClose.already_closed === false) {
1874
2242
  timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
1875
- }
1876
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1877
- detailStep = "open_detail";
1878
- networkRecorder.clear();
2243
+ }
2244
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2245
+ detailStep = "open_detail";
2246
+ currentDomOperation = "candidate:detail-open";
2247
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2248
+ networkRecorder.clear();
1879
2249
  await maybeHumanActionCooldown("before_detail_open", timings);
1880
2250
  const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
1881
2251
  cardNodeId,
@@ -1891,8 +2261,10 @@ export async function runRecommendWorkflow({
1891
2261
  cardNodeId = openedDetail.card_node_id || cardNodeId;
1892
2262
  cardCandidate = openedDetail.card_candidate || cardCandidate;
1893
2263
  screeningCandidate = cardCandidate;
1894
- if (shouldSkipRecentColleagueContacted) {
1895
- detailStep = "check_colleague_contact";
2264
+ if (shouldSkipRecentColleagueContacted) {
2265
+ detailStep = "check_colleague_contact";
2266
+ currentDomOperation = "candidate:detail-colleague-contact";
2267
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1896
2268
  try {
1897
2269
  colleagueContact = await measureTiming(timings, "colleague_contact_check_ms", () => inspectRecentColleagueContact(
1898
2270
  client,
@@ -1930,9 +2302,11 @@ export async function runRecommendWorkflow({
1930
2302
  };
1931
2303
  }
1932
2304
  }
1933
- if (!skipRecentColleagueContact) {
1934
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
1935
- detailStep = "wait_network";
2305
+ if (!skipRecentColleagueContact) {
2306
+ const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
2307
+ detailStep = "wait_network";
2308
+ currentDomOperation = "candidate:detail-network-wait";
2309
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1936
2310
  const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
1937
2311
  waitForRecommendDetailNetworkEvents,
1938
2312
  networkRecorder,
@@ -1945,8 +2319,10 @@ export async function runRecommendWorkflow({
1945
2319
  ));
1946
2320
  if (networkWait?.elapsed_ms != null) {
1947
2321
  timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
1948
- }
1949
- detailStep = "extract_detail";
2322
+ }
2323
+ detailStep = "extract_detail";
2324
+ currentDomOperation = "candidate:detail-extract";
2325
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1950
2326
  detailResult = await extractRecommendDetailCandidate(client, {
1951
2327
  cardCandidate,
1952
2328
  cardNodeId,
@@ -1970,8 +2346,10 @@ export async function runRecommendWorkflow({
1970
2346
  parsedNetworkProfileCount,
1971
2347
  waitResult: networkWait
1972
2348
  });
1973
- } else {
1974
- detailStep = "wait_capture_target";
2349
+ } else {
2350
+ detailStep = "wait_capture_target";
2351
+ currentDomOperation = "candidate:detail-wait-capture-target";
2352
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1975
2353
  captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
1976
2354
  domain: "recommend",
1977
2355
  timeoutMs: 6000,
@@ -1980,70 +2358,206 @@ export async function runRecommendWorkflow({
1980
2358
  captureTarget = captureTargetWait.target || null;
1981
2359
  const captureNodeId = captureTarget?.node_id || null;
1982
2360
  if (captureNodeId) {
1983
- const imageEvidencePath = imageEvidenceFilePath({
1984
- imageOutputDir,
1985
- domain: "recommend",
1986
- runId: runControl?.runId,
1987
- index,
1988
- extension: "jpg"
1989
- });
1990
- try {
1991
- detailStep = "capture_image";
1992
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
1993
- filePath: imageEvidencePath,
1994
- format: "jpeg",
1995
- quality: 72,
1996
- optimize: true,
1997
- resizeMaxWidth: 1100,
1998
- captureViewport: false,
1999
- padding: 0,
2000
- maxScreenshots: maxImagePages,
2001
- wheelDeltaY: imageWheelDeltaY,
2002
- settleMs: 350,
2003
- scrollMethod: "dom-anchor-fallback-input",
2004
- scrollDeltaJitterEnabled: effectiveHumanBehavior.listScrollJitter,
2005
- stepTimeoutMs: 45000,
2006
- totalTimeoutMs: 90000,
2007
- duplicateStopCount: 1,
2008
- skipDuplicateScreenshots: true,
2009
- composeForLlm: true,
2010
- llmPagesPerImage: 3,
2011
- llmResizeMaxWidth: 1100,
2012
- llmQuality: 72,
2013
- metadata: {
2014
- domain: "recommend",
2015
- capture_mode: "scroll_sequence",
2016
- acquisition_reason: "network_miss_image_fallback",
2017
- run_candidate_index: index,
2018
- candidate_key: candidateKey,
2019
- capture_target: captureTarget,
2020
- capture_target_wait: captureTargetWait
2021
- }
2022
- }));
2023
- source = "image";
2024
- } catch (error) {
2025
- if (!isRecoverableImageCaptureError(error)) throw error;
2026
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
2027
- if (recoveryCount < 1) {
2028
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
2029
- timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
2030
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
2031
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
2032
- await closeRecommendAvatarPreview(client, { attemptsLimit: 2 }).catch(() => null);
2033
- await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
2034
- await recoverAndReapplyRecommendContext(`image_capture:${detailStep}`, error, {
2035
- forceRecentNotView: true
2036
- });
2037
- continue;
2038
- }
2039
- imageEvidence = createRecoverableImageCaptureEvidence(error, {
2040
- elapsedMs: timings.screenshot_capture_ms,
2041
- filePath: imageEvidencePath,
2042
- extension: "jpg",
2043
- maxScreenshots: maxImagePages
2044
- });
2045
- source = "image_capture_failed";
2046
- }
2361
+ const imageEvidencePath = imageEvidenceFilePath({
2362
+ imageOutputDir,
2363
+ domain: "recommend",
2364
+ runId: runControl?.runId,
2365
+ index,
2366
+ extension: "jpg"
2367
+ });
2368
+ const captureImageForTarget = (target, targetWait, resumeCheckpoint = null) => captureScrolledNodeScreenshots(
2369
+ client,
2370
+ target.node_id,
2371
+ {
2372
+ filePath: imageEvidencePath,
2373
+ format: "jpeg",
2374
+ quality: 72,
2375
+ optimize: true,
2376
+ resizeMaxWidth: 1100,
2377
+ captureViewport: false,
2378
+ iframeOwnerNodeId: target?.iframe_node_id || null,
2379
+ padding: 0,
2380
+ maxScreenshots: maxImagePages,
2381
+ wheelDeltaY: imageWheelDeltaY,
2382
+ settleMs: 350,
2383
+ scrollMethod: "dom-anchor-fallback-input",
2384
+ scrollDeltaJitterEnabled: effectiveHumanBehavior.listScrollJitter,
2385
+ stepTimeoutMs: 45000,
2386
+ totalTimeoutMs: 90000,
2387
+ duplicateStopCount: 2,
2388
+ skipDuplicateScreenshots: true,
2389
+ requireTerminalProof: true,
2390
+ composeForLlm: true,
2391
+ llmPagesPerImage: 3,
2392
+ llmResizeMaxWidth: 1100,
2393
+ llmQuality: 72,
2394
+ resumeCheckpoint,
2395
+ metadata: {
2396
+ domain: "recommend",
2397
+ capture_mode: "scroll_sequence",
2398
+ acquisition_reason: "network_miss_image_fallback",
2399
+ run_candidate_index: index,
2400
+ candidate_key: candidateKey,
2401
+ capture_target: target,
2402
+ capture_target_wait: targetWait,
2403
+ resumed_from_checkpoint: Boolean(resumeCheckpoint)
2404
+ }
2405
+ }
2406
+ );
2407
+ try {
2408
+ detailStep = "capture_image";
2409
+ currentDomOperation = "candidate:detail-capture-image";
2410
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2411
+ imageEvidence = await measureTiming(
2412
+ timings,
2413
+ "screenshot_capture_ms",
2414
+ () => captureImageForTarget(captureTarget, captureTargetWait)
2415
+ );
2416
+ imageEvidence = requireCompleteImageEvidence(imageEvidence, {
2417
+ code: "IMAGE_CAPTURE_EVIDENCE_MISSING",
2418
+ message: "Recommend CV capture returned no complete persisted evidence",
2419
+ metadata: { domain: "recommend", candidate_key: candidateKey }
2420
+ });
2421
+ source = isIncompleteImageEvidence(imageEvidence) ? "image_capture_failed" : "image";
2422
+ } catch (error) {
2423
+ if (!isRecoverableImageCaptureError(error)) throw error;
2424
+ const retryReservation = imageCaptureWorkflowRetries.consume(candidateKey);
2425
+ if (retryReservation.allowed) {
2426
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
2427
+ timings.image_capture_workflow_retry = retryReservation;
2428
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
2429
+ const resumeCheckpoint = imageCaptureResumeCheckpoint(error);
2430
+ if (resumeCheckpoint) {
2431
+ detailStep = "resume_image_capture_reacquire";
2432
+ const resumeAttempt = await attemptImageCaptureCheckpointResume({
2433
+ checkpoint: resumeCheckpoint,
2434
+ reacquire: () => reacquireImageCaptureResumeTarget({
2435
+ domain: "recommend",
2436
+ getRoots: () => getRecommendRoots(client),
2437
+ ensureViewport: (freshRoots) => ensureRecommendViewport(
2438
+ freshRoots,
2439
+ "image_capture_resume_reacquire"
2440
+ ),
2441
+ getDetailState: () => waitForRecommendDetail(client, {
2442
+ timeoutMs: 4000,
2443
+ intervalMs: 200
2444
+ }),
2445
+ isDetailAvailable: (state) => Boolean(state?.popup || state?.resumeIframe),
2446
+ waitForTarget: (detailState) => waitForCvCaptureTarget(client, detailState, {
2447
+ domain: "recommend",
2448
+ timeoutMs: 4000,
2449
+ intervalMs: 200
2450
+ })
2451
+ }),
2452
+ capture: (reacquired) => {
2453
+ rootState = reacquired.root_state;
2454
+ captureTarget = reacquired.target;
2455
+ captureTargetWait = reacquired.target_wait;
2456
+ detailStep = "resume_image_capture";
2457
+ return measureTiming(
2458
+ timings,
2459
+ "screenshot_capture_ms",
2460
+ () => captureImageForTarget(captureTarget, captureTargetWait, resumeCheckpoint)
2461
+ );
2462
+ }
2463
+ });
2464
+ if (resumeAttempt.outcome === "reacquire_failed") {
2465
+ const reacquireError = resumeAttempt.error;
2466
+ imageEvidence = {
2467
+ ...createRecoverableImageCaptureEvidence(reacquireError, {
2468
+ elapsedMs: timings.screenshot_capture_ms,
2469
+ filePath: imageEvidencePath,
2470
+ extension: "jpg",
2471
+ maxScreenshots: maxImagePages
2472
+ }),
2473
+ coverage_complete: false,
2474
+ resumed_from_checkpoint: true,
2475
+ resume_checkpoint_id: resumeCheckpoint.checkpoint_id || null,
2476
+ coverage_checkpoint: resumeCheckpoint
2477
+ };
2478
+ source = "image_capture_failed";
2479
+ timings.image_capture_resume = {
2480
+ attempted: true,
2481
+ ok: false,
2482
+ phase: "reacquire",
2483
+ checkpoint_id: resumeCheckpoint.checkpoint_id || null,
2484
+ error: compactError(reacquireError, "IMAGE_CAPTURE_RESUME_REACQUIRE_FAILED")
2485
+ };
2486
+ }
2487
+ if (resumeAttempt.outcome === "completed") {
2488
+ imageEvidence = requireCompleteImageEvidence(resumeAttempt.evidence, {
2489
+ code: "IMAGE_CAPTURE_RESUME_EVIDENCE_MISSING",
2490
+ message: "Recommend resumed CV capture returned no complete persisted evidence",
2491
+ metadata: { domain: "recommend", candidate_key: candidateKey }
2492
+ });
2493
+ source = isIncompleteImageEvidence(imageEvidence) ? "image_capture_failed" : "image";
2494
+ timings.image_capture_resume = {
2495
+ attempted: true,
2496
+ ok: !isIncompleteImageEvidence(imageEvidence),
2497
+ phase: "capture",
2498
+ checkpoint_id: resumeCheckpoint.checkpoint_id || null,
2499
+ confirmed_screenshot_count: resumeCheckpoint.unique_screenshot_count || 0
2500
+ };
2501
+ } else if (resumeAttempt.outcome === "capture_failed") {
2502
+ const resumeError = resumeAttempt.error;
2503
+ imageEvidence = {
2504
+ ...createRecoverableImageCaptureEvidence(resumeError, {
2505
+ elapsedMs: timings.screenshot_capture_ms,
2506
+ filePath: imageEvidencePath,
2507
+ extension: "jpg",
2508
+ maxScreenshots: maxImagePages
2509
+ }),
2510
+ coverage_complete: false,
2511
+ resumed_from_checkpoint: true,
2512
+ resume_checkpoint_id: resumeCheckpoint.checkpoint_id || null,
2513
+ coverage_checkpoint: resumeError.capture_checkpoint || resumeCheckpoint
2514
+ };
2515
+ source = "image_capture_failed";
2516
+ timings.image_capture_resume = {
2517
+ attempted: true,
2518
+ ok: false,
2519
+ phase: "capture",
2520
+ checkpoint_id: resumeCheckpoint.checkpoint_id || null,
2521
+ error: compactError(resumeError, "IMAGE_CAPTURE_RESUME_FAILED")
2522
+ };
2523
+ }
2524
+ } else {
2525
+ imageEvidence = {
2526
+ ...createRecoverableImageCaptureEvidence(error, {
2527
+ elapsedMs: timings.screenshot_capture_ms,
2528
+ filePath: imageEvidencePath,
2529
+ extension: "jpg",
2530
+ maxScreenshots: maxImagePages
2531
+ }),
2532
+ coverage_complete: false
2533
+ };
2534
+ source = "image_capture_failed";
2535
+ timings.image_capture_resume = {
2536
+ attempted: false,
2537
+ ok: false,
2538
+ phase: "checkpoint",
2539
+ error: {
2540
+ code: "IMAGE_CAPTURE_CHECKPOINT_UNAVAILABLE",
2541
+ message: "Capture failed without a resumable checkpoint"
2542
+ }
2543
+ };
2544
+ }
2545
+ } else {
2546
+ imageEvidence = {
2547
+ ...createRecoverableImageCaptureEvidence(error, {
2548
+ elapsedMs: timings.screenshot_capture_ms,
2549
+ filePath: imageEvidencePath,
2550
+ extension: "jpg",
2551
+ maxScreenshots: maxImagePages
2552
+ }),
2553
+ coverage_complete: false,
2554
+ metadata: {
2555
+ image_capture_workflow_retry: retryReservation
2556
+ }
2557
+ };
2558
+ source = "image_capture_failed";
2559
+ }
2560
+ }
2047
2561
  recordCvImageFallback(cvAcquisitionState, {
2048
2562
  reason: source === "image_capture_failed"
2049
2563
  ? "network_miss_image_capture_failed"
@@ -2052,9 +2566,18 @@ export async function runRecommendWorkflow({
2052
2566
  waitResult: networkWait,
2053
2567
  imageEvidence
2054
2568
  });
2055
- } else {
2056
- source = "missing_capture_node";
2057
- recordCvNetworkMiss(cvAcquisitionState, {
2569
+ } else {
2570
+ source = "missing_capture_node";
2571
+ imageEvidence = createRequiredImageEvidenceFailure({
2572
+ code: "IMAGE_CAPTURE_TARGET_UNAVAILABLE",
2573
+ message: "Recommend CV capture target was unavailable after the network fallback missed",
2574
+ metadata: {
2575
+ domain: "recommend",
2576
+ candidate_key: candidateKey,
2577
+ capture_target_wait: captureTargetWait || null
2578
+ }
2579
+ });
2580
+ recordCvNetworkMiss(cvAcquisitionState, {
2058
2581
  reason: "network_miss_no_capture_node",
2059
2582
  parsedNetworkProfileCount,
2060
2583
  waitResult: networkWait
@@ -2075,21 +2598,42 @@ export async function runRecommendWorkflow({
2075
2598
  };
2076
2599
  screeningCandidate = detailResult.candidate;
2077
2600
  }
2078
- } catch (error) {
2079
- if (!isRecoverableRecommendDetailError(error)) throw error;
2080
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
2081
- if (recoveryCount < 1) {
2601
+ } catch (error) {
2602
+ if (!isRecoverableRecommendDetailError(error)) throw error;
2603
+ const staleForensic = checkpointDomStaleForensic(error, {
2604
+ phase: "recommend:detail",
2605
+ operation: currentDomOperation,
2606
+ detailStep,
2607
+ candidateIndex: index,
2608
+ candidateKey,
2609
+ cardNodeId
2610
+ });
2611
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
2612
+ if (recoveryCount < 1) {
2082
2613
  candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
2083
2614
  timings.detail_recovery_trigger = compactRecoverableDetailError(error);
2084
2615
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
2085
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
2086
- await closeRecommendAvatarPreview(client, { attemptsLimit: 2 }).catch(() => null);
2087
- await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
2088
- await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
2089
- forceRecentNotView: true
2090
- });
2091
- continue;
2092
- }
2616
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
2617
+ await closeRecommendAvatarPreview(client, { attemptsLimit: 2 }).catch(() => null);
2618
+ 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
+ }
2636
+ checkpointDomStaleRecovery(staleForensic, { status: "candidate_failed_closed" });
2093
2637
  recoverableDetailError = error;
2094
2638
  detailResult = null;
2095
2639
  timings.detail_recovered_error = compactRecoverableDetailError(error);
@@ -2100,11 +2644,12 @@ export async function runRecommendWorkflow({
2100
2644
  }
2101
2645
 
2102
2646
  await runControl.waitIfPaused();
2103
- runControl.throwIfCanceled();
2104
- runControl.setPhase("recommend:screening");
2105
- let llmResult = null;
2106
- if (useLlmScreening) {
2107
- if (skipRecentColleagueContact || recoverableDetailError || detailResult?.image_evidence?.ok === false) {
2647
+ runControl.throwIfCanceled();
2648
+ runControl.setPhase("recommend:screening");
2649
+ let llmResult = null;
2650
+ const imageAcquisitionFailed = shouldFailClosedRecommendImageAcquisition(detailResult);
2651
+ if (useLlmScreening) {
2652
+ if (skipRecentColleagueContact || recoverableDetailError || imageAcquisitionFailed) {
2108
2653
  llmResult = null;
2109
2654
  } else if (!llmConfig) {
2110
2655
  llmResult = createMissingLlmConfigResult();
@@ -2138,48 +2683,85 @@ export async function runRecommendWorkflow({
2138
2683
  ? createRecentColleagueContactSkipScreening(screeningCandidate, colleagueContact)
2139
2684
  : recoverableDetailError
2140
2685
  ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
2141
- : detailResult?.image_evidence?.ok === false
2142
- ? createImageCaptureFailureScreening(screeningCandidate, {
2143
- code: detailResult.image_evidence.error_code,
2144
- message: detailResult.image_evidence.error
2145
- })
2686
+ : imageAcquisitionFailed
2687
+ ? createImageCaptureFailureScreening(screeningCandidate, {
2688
+ code: detailResult?.image_evidence?.error_code || "IMAGE_CAPTURE_EVIDENCE_MISSING",
2689
+ message: detailResult?.image_evidence?.error || "Required CV image evidence is unavailable"
2690
+ })
2146
2691
  : useLlmScreening
2147
2692
  ? llmResultToScreening(llmResult, screeningCandidate)
2148
2693
  : screenCandidate(screeningCandidate, { criteria });
2149
2694
  let actionDiscovery = null;
2150
2695
  let postActionResult = null;
2151
- let closeFailureError = null;
2152
- let closeRecoveryFailure = null;
2153
- if (postActionEnabled && detailResult && !skipRecentColleagueContact) {
2154
- const postActionStarted = Date.now();
2155
- await runControl.waitIfPaused();
2156
- runControl.throwIfCanceled();
2157
- runControl.setPhase("recommend:post-action");
2158
- await maybeHumanActionCooldown("before_post_action", timings);
2159
- actionDiscovery = await waitForRecommendDetailActionControls(client, {
2160
- timeoutMs: actionTimeoutMs,
2161
- intervalMs: actionIntervalMs,
2162
- requireAny: true
2163
- });
2164
- postActionResult = await runRecommendPostAction({
2165
- client,
2166
- screening,
2167
- actionDiscovery,
2168
- postAction: normalizedPostAction,
2169
- greetCount,
2170
- maxGreetCount: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
2171
- executePostAction,
2172
- afterClickDelayMs: actionAfterClickDelayMs
2173
- });
2174
- if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
2175
- greetCount += 1;
2176
- }
2177
- addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
2696
+ let closeFailureError = null;
2697
+ let closeRecoveryFailure = null;
2698
+ if (postActionEnabled && detailResult && !skipRecentColleagueContact) {
2699
+ try {
2700
+ const postActionStarted = Date.now();
2701
+ await runControl.waitIfPaused();
2702
+ runControl.throwIfCanceled();
2703
+ runControl.setPhase("recommend:post-action");
2704
+ detailStep = "post_action_discovery";
2705
+ currentDomOperation = "candidate:post-action-discovery";
2706
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2707
+ await maybeHumanActionCooldown("before_post_action", timings);
2708
+ actionDiscovery = await waitForRecommendDetailActionControls(client, {
2709
+ timeoutMs: actionTimeoutMs,
2710
+ intervalMs: actionIntervalMs,
2711
+ requireAny: true
2712
+ });
2713
+ detailStep = "post_action_execute";
2714
+ currentDomOperation = "candidate:post-action-execute";
2715
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2716
+ postActionResult = await runRecommendPostAction({
2717
+ client,
2718
+ screening,
2719
+ actionDiscovery,
2720
+ postAction: normalizedPostAction,
2721
+ greetCount,
2722
+ maxGreetCount: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
2723
+ executePostAction,
2724
+ afterClickDelayMs: actionAfterClickDelayMs
2725
+ });
2726
+ if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
2727
+ greetCount += 1;
2728
+ }
2729
+ addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
2730
+ } catch (error) {
2731
+ checkpointDomStaleForensic(error, {
2732
+ phase: "recommend:post-action",
2733
+ operation: currentDomOperation,
2734
+ detailStep,
2735
+ candidateIndex: index,
2736
+ candidateKey,
2737
+ cardNodeId
2738
+ });
2739
+ throw error;
2740
+ }
2178
2741
  }
2179
2742
  if (detailResult && closeDetail && !detailResult.close_result?.closed) {
2180
2743
  runControl.setPhase("recommend:close-detail");
2181
- detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
2182
- await maybeHumanActionCooldown("after_detail_close", timings);
2744
+ detailStep = "close_detail";
2745
+ currentDomOperation = "candidate:close-detail";
2746
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
2747
+ try {
2748
+ detailResult.close_result = await measureTiming(
2749
+ timings,
2750
+ "close_detail_ms",
2751
+ () => closeRecommendDetail(client)
2752
+ );
2753
+ } catch (error) {
2754
+ checkpointDomStaleForensic(error, {
2755
+ phase: "recommend:close-detail",
2756
+ operation: currentDomOperation,
2757
+ detailStep,
2758
+ candidateIndex: index,
2759
+ candidateKey,
2760
+ cardNodeId
2761
+ });
2762
+ throw error;
2763
+ }
2764
+ await maybeHumanActionCooldown("after_detail_close", timings);
2183
2765
  if (!detailResult.close_result?.closed) {
2184
2766
  closeFailureError = createRecommendCloseFailureError(detailResult.close_result);
2185
2767
  try {
@@ -2198,17 +2780,17 @@ export async function runRecommendWorkflow({
2198
2780
  } catch (error) {
2199
2781
  closeRecoveryFailure = error;
2200
2782
  detailResult.cv_acquisition = {
2201
- ...(detailResult.cv_acquisition || {}),
2202
- close_recovery: {
2203
- ok: false,
2204
- reason: "context_recovery_failed",
2205
- error: error?.message || String(error),
2206
- forced_recent_not_view: Boolean(normalizedFilter.enabled)
2207
- }
2208
- };
2209
- }
2210
- }
2211
- }
2783
+ ...(detailResult.cv_acquisition || {}),
2784
+ close_recovery: {
2785
+ ok: false,
2786
+ reason: "context_recovery_failed",
2787
+ error: error?.message || String(error),
2788
+ forced_recent_not_view: Boolean(normalizedFilter.enabled)
2789
+ }
2790
+ };
2791
+ }
2792
+ }
2793
+ }
2212
2794
  timings.total_ms = Date.now() - candidateStarted;
2213
2795
  const compactResult = {
2214
2796
  index,
@@ -2245,9 +2827,10 @@ export async function runRecommendWorkflow({
2245
2827
  last_candidate_key: candidateKey,
2246
2828
  last_score: screening.score
2247
2829
  });
2248
- const checkpointStarted = Date.now();
2249
- runControl.checkpoint({
2250
- results,
2830
+ const checkpointStarted = Date.now();
2831
+ runControl.checkpoint({
2832
+ in_progress_candidate: null,
2833
+ results,
2251
2834
  last_candidate: {
2252
2835
  id: screeningCandidate.id || null,
2253
2836
  key: candidateKey,
@@ -2304,37 +2887,40 @@ export async function runRecommendWorkflow({
2304
2887
  }
2305
2888
 
2306
2889
  runControl.setPhase("recommend:done");
2307
- return {
2308
- domain: "recommend",
2309
- target_url: targetUrl,
2310
- job_selection: compactJobSelection(jobSelection),
2311
- page_scope: compactPageScopeSelection(pageScopeSelection),
2312
- current_city_only: compactCurrentCityOnlyResult(currentCityOnlyResult),
2313
- filter: compactFilterResult(filterResult),
2314
- card_count: cardNodeIds.length,
2315
- candidate_list: compactInfiniteListState(listState),
2316
- viewport_health: {
2317
- stats: viewportGuard.getStats(),
2890
+ return {
2891
+ domain: "recommend",
2892
+ target_url: targetUrl,
2893
+ job_selection: compactJobSelection(jobSelection),
2894
+ page_scope: compactPageScopeSelection(pageScopeSelection),
2895
+ current_city_only: compactCurrentCityOnlyResult(currentCityOnlyResult),
2896
+ filter: compactFilterResult(filterResult),
2897
+ card_count: cardNodeIds.length,
2898
+ candidate_list: compactInfiniteListState(listState),
2899
+ viewport_health: {
2900
+ stats: viewportGuard.getStats(),
2318
2901
  events: viewportGuard.getEvents()
2319
2902
  },
2320
2903
  human_behavior: effectiveHumanBehavior,
2321
2904
  human_rest: humanRestController.getState(),
2322
2905
  last_human_event: lastHumanEvent,
2323
2906
  list_end_reason: listEndReason || null,
2324
- refresh_rounds: refreshRounds,
2325
- refresh_attempts: refreshAttempts,
2326
- context_recoveries: contextRecoveryAttempts,
2327
- debug_boundary: debugBoundary.getState(),
2328
- list_read_stale_recovery_attempts: listReadStaleRecoveryAttempts,
2329
- list_read_stale_recovery_applied: listReadStaleRecoveryApplied,
2330
- list_read_stale_recoveries: listReadStaleRecoveries,
2907
+ refresh_rounds: refreshRounds,
2908
+ refresh_attempts: refreshAttempts,
2909
+ context_recoveries: contextRecoveryAttempts,
2910
+ debug_boundary: debugBoundary.getState(),
2911
+ list_read_stale_recovery_attempts: listReadStaleRecoveryAttempts,
2912
+ list_read_stale_recovery_applied: listReadStaleRecoveryApplied,
2913
+ list_read_stale_recoveries: listReadStaleRecoveries,
2331
2914
  last_list_read_recovery_mode: lastListReadRecoveryMode,
2332
2915
  last_list_read_stale_diagnostic: lastListReadStaleDiagnostic,
2333
2916
  list_read_stale_diagnostics: listReadStaleDiagnostics.slice(-12),
2917
+ dom_stale_events: domStaleEventCount,
2918
+ dom_stale_forensics: domStaleForensics.slice(-12),
2919
+ dom_lifecycle_timeline: domLifecycleTimeline.slice(-20),
2334
2920
  ...countRecommendResultStatuses(results, { greetCount }),
2335
- transient_recovered: countRecommendResultStatuses(results, { greetCount }).transient_recovered
2336
- + listReadStaleRecoveries,
2337
- results
2921
+ transient_recovered: countRecommendResultStatuses(results, { greetCount }).transient_recovered
2922
+ + listReadStaleRecoveries,
2923
+ results
2338
2924
  };
2339
2925
  }
2340
2926
 
@@ -2386,15 +2972,15 @@ export function createRecommendRunService({
2386
2972
  llmImageDetail = "high",
2387
2973
  imageOutputDir = "",
2388
2974
  humanRestEnabled = false,
2389
- humanBehavior = null,
2390
- skipRecentColleagueContacted = true,
2391
- colleagueContactWindowDays = 14,
2392
- debugTestMode = undefined,
2393
- debugForceListEndAfterProcessed = undefined,
2394
- debugForceContextRecoveryAfterProcessed = undefined,
2395
- debugForceCdpReconnectAfterProcessed = undefined,
2396
- name = "recommend-domain-run"
2397
- } = {}) {
2975
+ humanBehavior = null,
2976
+ skipRecentColleagueContacted = true,
2977
+ colleagueContactWindowDays = 14,
2978
+ debugTestMode = undefined,
2979
+ debugForceListEndAfterProcessed = undefined,
2980
+ debugForceContextRecoveryAfterProcessed = undefined,
2981
+ debugForceCdpReconnectAfterProcessed = undefined,
2982
+ name = "recommend-domain-run"
2983
+ } = {}) {
2398
2984
  if (!client) throw new Error("startRecommendRun requires a guarded CDP client");
2399
2985
  const normalizedFilter = normalizeFilter(filter);
2400
2986
  const normalizedPostAction = normalizeRecommendPostAction(postAction) || "none";
@@ -2406,33 +2992,33 @@ export function createRecommendRunService({
2406
2992
  const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
2407
2993
  legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
2408
2994
  });
2409
- const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
2410
- const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
2411
- const normalizedDetailLimit = detailLimit == null ? null : Math.max(0, Number(detailLimit) || 0);
2412
- const debugBoundaryOptions = normalizeRecommendDebugBoundaryOptions({
2413
- debugTestMode: debugTestMode === true,
2414
- debugForceListEndAfterProcessed: debugForceListEndAfterProcessed ?? null,
2415
- debugForceContextRecoveryAfterProcessed: debugForceContextRecoveryAfterProcessed ?? null,
2416
- debugForceCdpReconnectAfterProcessed: debugForceCdpReconnectAfterProcessed ?? null
2417
- });
2418
- return manager.startRun({
2995
+ const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
2996
+ const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
2997
+ const normalizedDetailLimit = detailLimit == null ? null : Math.max(0, Number(detailLimit) || 0);
2998
+ const debugBoundaryOptions = normalizeRecommendDebugBoundaryOptions({
2999
+ debugTestMode: debugTestMode === true,
3000
+ debugForceListEndAfterProcessed: debugForceListEndAfterProcessed ?? null,
3001
+ debugForceContextRecoveryAfterProcessed: debugForceContextRecoveryAfterProcessed ?? null,
3002
+ debugForceCdpReconnectAfterProcessed: debugForceCdpReconnectAfterProcessed ?? null
3003
+ });
3004
+ return manager.startRun({
2419
3005
  runId,
2420
3006
  name,
2421
3007
  pid,
2422
3008
  context: {
2423
3009
  domain: "recommend",
2424
3010
  target_url: targetUrl,
2425
- criteria_present: Boolean(criteria),
2426
- job_label: jobLabel || "",
2427
- requested_page_scope: requestedPageScope,
2428
- fallback_page_scope: normalizedFallbackPageScope,
2429
- filter: normalizedFilter,
2430
- current_city_only_requested: normalizedFilter.currentCityOnly,
2431
- max_candidates: maxCandidates,
2432
- max_candidates_semantics: "passed_candidates",
2433
- detail_limit: normalizedDetailLimit,
2434
- close_detail: closeDetail,
2435
- cv_acquisition_mode: cvAcquisitionMode,
3011
+ criteria_present: Boolean(criteria),
3012
+ job_label: jobLabel || "",
3013
+ requested_page_scope: requestedPageScope,
3014
+ fallback_page_scope: normalizedFallbackPageScope,
3015
+ filter: normalizedFilter,
3016
+ current_city_only_requested: normalizedFilter.currentCityOnly,
3017
+ max_candidates: maxCandidates,
3018
+ max_candidates_semantics: "passed_candidates",
3019
+ detail_limit: normalizedDetailLimit,
3020
+ close_detail: closeDetail,
3021
+ cv_acquisition_mode: cvAcquisitionMode,
2436
3022
  max_image_pages: maxImagePages,
2437
3023
  image_wheel_delta_y: imageWheelDeltaY,
2438
3024
  list_max_scrolls: listMaxScrolls,
@@ -2458,12 +3044,12 @@ export function createRecommendRunService({
2458
3044
  colleague_contact_window_days: normalizedColleagueContactWindowDays,
2459
3045
  human_behavior_enabled: effectiveHumanBehavior.enabled,
2460
3046
  human_behavior_profile: effectiveHumanBehavior.profile,
2461
- human_behavior: effectiveHumanBehavior,
2462
- human_rest_level: effectiveHumanBehavior.restLevel,
2463
- human_rest_enabled: effectiveHumanRestEnabled,
2464
- debug_test_mode: debugBoundaryOptions.debugTestMode,
2465
- debug_boundary_mode: debugBoundaryOptions.mode,
2466
- debug_boundary_threshold: debugBoundaryOptions.threshold
3047
+ human_behavior: effectiveHumanBehavior,
3048
+ human_rest_level: effectiveHumanBehavior.restLevel,
3049
+ human_rest_enabled: effectiveHumanRestEnabled,
3050
+ debug_test_mode: debugBoundaryOptions.debugTestMode,
3051
+ debug_boundary_mode: debugBoundaryOptions.mode,
3052
+ debug_boundary_threshold: debugBoundaryOptions.threshold
2467
3053
  },
2468
3054
  progress: {
2469
3055
  card_count: 0,
@@ -2479,25 +3065,25 @@ export function createRecommendRunService({
2479
3065
  post_action_clicked: 0,
2480
3066
  image_capture_failed: 0,
2481
3067
  detail_open_failed: 0,
2482
- transient_recovered: 0,
2483
- colleague_contact_checked: 0,
2484
- recent_colleague_contact_skipped: 0,
2485
- colleague_contact_panel_missing: 0,
2486
- context_recoveries: 0,
2487
- current_city_only_requested: normalizedFilter.currentCityOnly,
2488
- current_city_only_effective: null,
2489
- current_city_only_unavailable: false,
2490
- human_behavior_enabled: effectiveHumanBehavior.enabled,
2491
- human_behavior_profile: effectiveHumanBehavior.profile,
2492
- human_rest_level: effectiveHumanBehavior.restLevel,
2493
- human_rest_enabled: effectiveHumanRestEnabled,
2494
- human_rest_count: 0,
2495
- human_rest_ms: 0,
2496
- last_human_event: null,
2497
- debug_boundary_mode: debugBoundaryOptions.mode,
2498
- debug_boundary_threshold: debugBoundaryOptions.threshold,
2499
- debug_boundary_triggered: false,
2500
- debug_boundary_trigger_count: 0
3068
+ transient_recovered: 0,
3069
+ colleague_contact_checked: 0,
3070
+ recent_colleague_contact_skipped: 0,
3071
+ colleague_contact_panel_missing: 0,
3072
+ context_recoveries: 0,
3073
+ current_city_only_requested: normalizedFilter.currentCityOnly,
3074
+ current_city_only_effective: null,
3075
+ current_city_only_unavailable: false,
3076
+ human_behavior_enabled: effectiveHumanBehavior.enabled,
3077
+ human_behavior_profile: effectiveHumanBehavior.profile,
3078
+ human_rest_level: effectiveHumanBehavior.restLevel,
3079
+ human_rest_enabled: effectiveHumanRestEnabled,
3080
+ human_rest_count: 0,
3081
+ human_rest_ms: 0,
3082
+ last_human_event: null,
3083
+ debug_boundary_mode: debugBoundaryOptions.mode,
3084
+ debug_boundary_threshold: debugBoundaryOptions.threshold,
3085
+ debug_boundary_triggered: false,
3086
+ debug_boundary_trigger_count: 0
2501
3087
  },
2502
3088
  checkpoint: {},
2503
3089
  task: (runControl) => workflow({
@@ -2538,14 +3124,14 @@ export function createRecommendRunService({
2538
3124
  llmImageDetail,
2539
3125
  imageOutputDir,
2540
3126
  humanRestEnabled: effectiveHumanRestEnabled,
2541
- humanBehavior: effectiveHumanBehavior,
2542
- skipRecentColleagueContacted: shouldSkipRecentColleagueContacted,
2543
- colleagueContactWindowDays: normalizedColleagueContactWindowDays,
2544
- debugTestMode: debugBoundaryOptions.debugTestMode,
2545
- debugForceListEndAfterProcessed: debugBoundaryOptions.debugForceListEndAfterProcessed,
2546
- debugForceContextRecoveryAfterProcessed: debugBoundaryOptions.debugForceContextRecoveryAfterProcessed,
2547
- debugForceCdpReconnectAfterProcessed: debugBoundaryOptions.debugForceCdpReconnectAfterProcessed
2548
- }, runControl)
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)
2549
3135
  });
2550
3136
  }
2551
3137