@reconcrap/boss-recommend-mcp 2.1.22 → 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 +14 -8
  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 +291 -114
  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 +11 -2
  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 +112 -42
  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 -3
  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 +410 -329
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +1783 -592
  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 +124 -5
  64. package/src/parser.js +1296 -1296
  65. package/src/recommend-mcp.js +515 -80
  66. package/src/recommend-scheduler.js +66 -0
@@ -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";
@@ -90,6 +100,427 @@ import {
90
100
  } from "./actions.js";
91
101
  import { getRecommendRoots } from "./roots.js";
92
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
+ };
212
+ }
213
+
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;
222
+ }
223
+ return undefined;
224
+ }
225
+
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
241
+ };
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;
250
+ }
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;
259
+ }
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
+ );
277
+ return {
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
287
+ };
288
+ }
289
+
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);
321
+ return {
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
335
+ },
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) : []
344
+ };
345
+ }
346
+
347
+ function compactListReadStaleDiagnostic(error, {
348
+ attempt = 0,
349
+ exhausted = false
350
+ } = {}) {
351
+ const cdp = compactCdpFailureDiagnostic(error, {
352
+ fallbackCode: "RECOMMEND_LIST_READ_STALE_NODE",
353
+ fallbackPhase: "recommend:list-read"
354
+ });
355
+ return {
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),
359
+ phase: "recommend:list-read",
360
+ attempt,
361
+ exhausted: Boolean(exhausted),
362
+ at: new Date().toISOString()
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
+
93
524
  function normalizeLabels(labels = []) {
94
525
  return labels.map((label) => String(label || "").trim()).filter(Boolean);
95
526
  }
@@ -106,46 +537,46 @@ function isRefreshableListStall(reason = "") {
106
537
  function normalizeFilter(filter = {}) {
107
538
  const filterGroups = Array.isArray(filter.filterGroups)
108
539
  ? filter.filterGroups
109
- : Array.isArray(filter.groups)
110
- ? filter.groups
111
- : [];
112
- return {
113
- enabled: filter.enabled !== false,
114
- currentCityOnly: filter.currentCityOnly === true || filter.current_city_only === true,
115
- group: String(filter.group || ""),
116
- labels: normalizeLabels(filter.labels || filter.filterLabels || []),
117
- selectAllLabels: Boolean(filter.selectAllLabels),
118
- allowUnlimited: filter.allowUnlimited === true,
119
- verifySticky: filter.verifySticky === true,
120
- filterGroups: filterGroups.map((group) => ({
121
- group: String(group?.group || ""),
122
- labels: normalizeLabels(group?.labels || group?.filterLabels || []),
123
- selectAllLabels: group?.selectAllLabels !== false,
124
- allowUnlimited: group?.allowUnlimited === true,
125
- verifySticky: group?.verifySticky === true
126
- })).filter((group) => group.group || group.labels.length)
127
- };
128
- }
129
-
130
- export function compactFilterResult(filterResult) {
131
- if (!filterResult) return null;
132
- return {
133
- opened_panel: Boolean(filterResult.opened_panel),
134
- requested_groups: (filterResult.requested_groups || []).map((group) => ({
135
- group: group.group,
136
- labels: group.labels || [],
137
- select_all_labels: group.select_all_labels !== false,
138
- allow_unlimited: Boolean(group.allow_unlimited),
139
- verify_sticky: Boolean(group.verify_sticky)
140
- })),
141
- effective_groups: (filterResult.sticky_verification?.groups || []).map((group) => ({
142
- group: group.group,
143
- requested_labels: group.requested_labels || [],
144
- active_labels: group.active_labels || [],
145
- verified: Boolean(group.verified),
146
- unavailable: Boolean(group.unavailable),
147
- reason: group.reason || null
148
- })),
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
+ })),
149
580
  selected_option: filterResult.selected_option
150
581
  ? {
151
582
  group: filterResult.selected_option.group,
@@ -155,71 +586,71 @@ export function compactFilterResult(filterResult) {
155
586
  }
156
587
  : null,
157
588
  selected_options: (filterResult.selected_options || []).map((option) => ({
158
- group: option.group,
159
- label: option.label,
160
- was_active: Boolean(option.was_active),
161
- clicked: option.clicked !== false
162
- })),
163
- unavailable: Boolean(filterResult.unavailable),
164
- unavailable_groups: filterResult.unavailable_groups || [],
165
- confirmed: Boolean(filterResult.confirmed),
166
- sticky_verification: filterResult.sticky_verification || null,
167
- attempts: {
168
- initial_close: filterResult.initial_close_attempts || [],
169
- open: (filterResult.open_attempts || []).map((attempt) => ({
170
- selector: attempt.selector || null,
171
- node_id: attempt.node_id || null,
172
- click_target: attempt.click_target || null
173
- })),
174
- confirmation: (filterResult.confirm_attempts || []).map((attempt) => ({
175
- node_id: attempt.node_id || null,
176
- label: attempt.label || null,
177
- clicked: Boolean(attempt.clicked),
178
- errors: (attempt.errors || []).map((error) => ({
179
- node_id: error.node_id || null,
180
- message: error.message || String(error)
181
- }))
182
- }))
183
- },
184
- before_counts: filterResult.before_counts,
185
- after_confirm_counts: filterResult.after_confirm_counts
186
- };
187
- }
188
-
189
- function compactCurrentCityOnlyResult(result) {
190
- if (!result) return null;
191
- return {
192
- requested: Boolean(result.requested),
193
- effective: typeof result.effective === "boolean" ? result.effective : null,
194
- available: result.available !== false,
195
- unavailable: Boolean(result.unavailable),
196
- reason: result.reason || null,
197
- clicked: Boolean(result.clicked),
198
- current_city_label: result.current_city_label || null,
199
- before: result.before || null,
200
- after_toggle: result.after_toggle || null,
201
- confirmation: result.confirmation || null,
202
- sticky_verification: result.sticky_verification
203
- ? {
204
- verified: Boolean(result.sticky_verification.verified),
205
- expected: Boolean(result.sticky_verification.expected),
206
- actual: typeof result.sticky_verification.actual === "boolean"
207
- ? result.sticky_verification.actual
208
- : null,
209
- state_source: result.sticky_verification.state_source || null,
210
- close_confirmation: result.sticky_verification.close_confirmation || null
211
- }
212
- : null,
213
- attempts: Array.isArray(result.attempts) ? result.attempts : [],
214
- evidence: result.evidence || null
215
- };
216
- }
217
-
218
- function compactJobSelection(jobSelection) {
219
- if (!jobSelection) return null;
220
- return {
221
- requested: jobSelection.requested || "",
222
- 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),
223
654
  already_current: Boolean(jobSelection.already_current),
224
655
  reason: jobSelection.reason || null,
225
656
  selected_option: jobSelection.selected_option || null,
@@ -454,6 +885,7 @@ function compactRefreshAttempt(refreshAttempt) {
454
885
  method: refreshAttempt.method || "",
455
886
  reason: refreshAttempt.reason || null,
456
887
  error: refreshAttempt.error || null,
888
+ error_diagnostic: refreshAttempt.error_diagnostic || null,
457
889
  forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
458
890
  target_url: refreshAttempt.target_url || null,
459
891
  card_count: refreshAttempt.card_count || 0,
@@ -470,36 +902,40 @@ function compactRefreshAttempt(refreshAttempt) {
470
902
  ok: Boolean(attempt.ok),
471
903
  method: attempt.method || "",
472
904
  reason: attempt.reason || null,
473
- error: attempt.error || null,
474
- label: attempt.label || null,
475
- before_card_count: attempt.before_card_count || 0,
476
- after_card_count: attempt.after_card_count || 0,
477
- card_count: attempt.card_count || 0,
478
- elapsed_ms: attempt.elapsed_ms || 0,
479
- current_city_only: compactCurrentCityOnlyResult(attempt.current_city_only),
480
- current_city_only_attempts: (attempt.current_city_only_attempts || []).map((cityAttempt) => ({
481
- ok: Boolean(cityAttempt.ok),
482
- method: cityAttempt.method || "current_city_only_reapply",
483
- reason: cityAttempt.reason || null,
484
- error: cityAttempt.error || null,
485
- attempt: cityAttempt.attempt || 0,
486
- result: compactCurrentCityOnlyResult(cityAttempt.result)
487
- })),
488
- filter: compactFilterResult(attempt.filter)
489
- })),
490
- current_city_only_attempts: (refreshAttempt.current_city_only_attempts || []).map((attempt) => ({
491
- ok: Boolean(attempt.ok),
492
- method: attempt.method || "current_city_only_reapply",
493
- reason: attempt.reason || null,
494
- error: attempt.error || null,
495
- attempt: attempt.attempt || 0,
496
- result: compactCurrentCityOnlyResult(attempt.result)
497
- })),
498
- filter_reapply_attempts: (refreshAttempt.filter_reapply_attempts || []).map((attempt) => ({
499
- ok: Boolean(attempt.ok),
500
- method: attempt.method || "filter_reapply",
501
- reason: attempt.reason || null,
502
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,
503
939
  attempt: attempt.attempt || 0
504
940
  })),
505
941
  job_selection_attempts: (refreshAttempt.job_selection_attempts || []).map((attempt) => ({
@@ -507,19 +943,20 @@ function compactRefreshAttempt(refreshAttempt) {
507
943
  method: attempt.method || "job_select",
508
944
  reason: attempt.reason || null,
509
945
  error: attempt.error || null,
946
+ error_diagnostic: attempt.error_diagnostic || null,
510
947
  attempt: attempt.attempt || 0,
511
948
  iframe_document_node_id: attempt.iframe_document_node_id || 0,
512
- selected: Boolean(attempt.selected),
513
- selection_reason: attempt.selection_reason || null
514
- })),
515
- job_selection: compactJobSelection(refreshAttempt.job_selection),
516
- page_scope: compactPageScopeSelection(refreshAttempt.page_scope),
517
- current_city_only: compactCurrentCityOnlyResult(refreshAttempt.current_city_only),
518
- filter: compactFilterResult(refreshAttempt.filter)
519
- };
520
- }
521
-
522
- 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 = [], {
523
960
  greetCount = 0
524
961
  } = {}) {
525
962
  return {
@@ -573,12 +1010,16 @@ function compactCloseResult(closeResult) {
573
1010
  return result;
574
1011
  }
575
1012
 
576
- function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
577
- if (!error) return null;
578
- const result = {
579
- code: error.code || fallbackCode,
580
- message: error.message || String(error)
581
- };
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
+ };
582
1023
  if (error.close_result) {
583
1024
  result.close_result = compactCloseResult(error.close_result);
584
1025
  }
@@ -588,6 +1029,15 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
588
1029
  if (error.refresh_attempt) {
589
1030
  result.refresh_attempt = error.refresh_attempt;
590
1031
  }
1032
+ if (error.error_diagnostic) {
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;
1040
+ }
591
1041
  if (error.list_end_reason) {
592
1042
  result.list_end_reason = error.list_end_reason;
593
1043
  }
@@ -628,7 +1078,45 @@ function createRecommendBlockingPanelCloseFailureError(closeResult, phase = "")
628
1078
  return error;
629
1079
  }
630
1080
 
631
- 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, {
632
1120
  listEndReason = "",
633
1121
  targetCount = 0,
634
1122
  passedCount = 0
@@ -641,15 +1129,21 @@ function createRecommendRefreshFailureError(refreshAttempt, {
641
1129
  error.list_end_reason = listEndReason || null;
642
1130
  error.target_count = targetCount;
643
1131
  error.passed_count = passedCount;
644
- return error;
1132
+ return attachRecommendRefreshErrorDiagnostic(error, refreshAttempt);
645
1133
  }
646
1134
 
647
- export function isRecoverableImageCaptureError(error) {
648
- const code = String(error?.code || "");
649
- if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
650
- if (isStaleRecommendNodeError(error)) return true;
651
- return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
652
- }
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
+ }
653
1147
 
654
1148
  function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
655
1149
  const resolved = String(basePath || "").trim();
@@ -781,9 +1275,19 @@ export async function runRecommendWorkflow({
781
1275
  humanRestEnabled = false,
782
1276
  humanBehavior = null,
783
1277
  skipRecentColleagueContacted = true,
784
- colleagueContactWindowDays = 14
1278
+ colleagueContactWindowDays = 14,
1279
+ debugTestMode = false,
1280
+ debugForceListEndAfterProcessed = null,
1281
+ debugForceContextRecoveryAfterProcessed = null,
1282
+ debugForceCdpReconnectAfterProcessed = null
785
1283
  } = {}, runControl) {
786
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
+ });
787
1291
  const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
788
1292
  legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
789
1293
  });
@@ -837,25 +1341,164 @@ export async function runRecommendWorkflow({
837
1341
  const results = [];
838
1342
  const refreshAttempts = [];
839
1343
  let refreshRounds = 0;
840
- let contextRecoveryAttempts = 0;
1344
+ let contextRecoveryAttempts = 0;
841
1345
  let greetCount = 0;
842
1346
  const candidateRecoveryCounts = new Map();
843
- let jobSelection = null;
844
- let pageScopeSelection = null;
845
- let currentCityOnlyResult = null;
846
- let filterResult = null;
847
- let rootState = null;
848
- let cardNodeIds = [];
849
- let listEndReason = "";
850
- let lastHumanEvent = null;
851
- const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
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;
1360
+ let lastListReadStaleDiagnostic = null;
1361
+ let lastListReadRecoveryMode = null;
1362
+ const listReadStaleDiagnostics = [];
1363
+ let domStaleEventCount = 0;
1364
+ let currentDomOperation = "recommend:initialize";
1365
+ const domLifecycleTimeline = [];
1366
+ const domStaleForensics = [];
1367
+ const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
852
1368
  rootNodeId: rootState?.iframe?.documentNodeId,
853
1369
  containerSelectors: RECOMMEND_LIST_CONTAINER_SELECTORS,
854
1370
  itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
855
1371
  itemSelectors: [RECOMMEND_CARD_SELECTOR],
856
1372
  viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
857
- validateViewportPoint: true
858
- }));
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();
859
1502
 
860
1503
  function recordHumanEvent(event = null) {
861
1504
  if (!event) return lastHumanEvent;
@@ -892,6 +1535,7 @@ export async function runRecommendWorkflow({
892
1535
  target_count: targetPassCount,
893
1536
  target_count_semantics: "passed_candidates",
894
1537
  ...counts,
1538
+ transient_recovered: counts.transient_recovered + listReadStaleRecoveries,
895
1539
  screening_mode: normalizedScreeningMode,
896
1540
  unique_seen: listSnapshot.seen_count,
897
1541
  scroll_count: listSnapshot.scroll_count,
@@ -902,19 +1546,28 @@ export async function runRecommendWorkflow({
902
1546
  viewport_checks: viewportGuard.getStats().checks,
903
1547
  viewport_recoveries: viewportGuard.getStats().recoveries,
904
1548
  human_behavior_enabled: effectiveHumanBehavior.enabled,
905
- human_behavior_profile: effectiveHumanBehavior.profile,
906
- human_rest_level: effectiveHumanBehavior.restLevel,
907
- human_rest_enabled: effectiveHumanRestEnabled,
908
- skip_recent_colleague_contacted: shouldSkipRecentColleagueContacted,
909
- colleague_contact_window_days: normalizedColleagueContactWindowDays,
910
- current_city_only_requested: normalizedFilter.currentCityOnly,
911
- current_city_only_effective: currentCityOnlyResult?.effective ?? null,
912
- current_city_only_unavailable: Boolean(currentCityOnlyResult?.unavailable),
913
- human_rest_count: humanRestState.rest_count,
914
- human_rest_ms: humanRestState.total_rest_ms,
915
- last_human_event: lastHumanEvent,
916
- ...extra
917
- });
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
+ });
918
1571
  }
919
1572
 
920
1573
  function checkpointInProgressCandidate({
@@ -951,27 +1604,27 @@ export async function runRecommendWorkflow({
951
1604
  async function recoverAndReapplyRecommendContext(reason = "context_recovery", error = null, {
952
1605
  forceRecentNotView = true
953
1606
  } = {}) {
954
- await runControl.waitIfPaused();
955
- runControl.throwIfCanceled();
956
- const started = Date.now();
957
- runControl.setPhase("recommend:recover-context");
958
- contextRecoveryAttempts += 1;
959
- const effectiveForceRecentNotView = normalizedFilter.enabled && forceRecentNotView;
960
- const refreshResult = await refreshRecommendListAtEnd(client, {
961
- rootState,
962
- jobLabel,
963
- pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
964
- fallbackPageScope: normalizedFallbackPageScope,
965
- filter: normalizedFilter,
966
- preferEndRefreshButton: false,
967
- forceNavigate: true,
968
- targetUrl: targetUrl || RECOMMEND_TARGET_URL,
969
- forceRecentNotView: effectiveForceRecentNotView,
970
- cardTimeoutMs,
971
- buttonSettleMs: refreshButtonSettleMs,
972
- reloadSettleMs: refreshReloadSettleMs
973
- });
974
- 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;
975
1628
  if (refreshResult.ok) {
976
1629
  blockingPanelClose = await closeRecommendBlockingPanels(client, {
977
1630
  attemptsLimit: 2,
@@ -995,27 +1648,33 @@ export async function runRecommendWorkflow({
995
1648
  refresh: compactRefresh,
996
1649
  counters: countRecommendResultStatuses(results, { greetCount })
997
1650
  },
998
- candidate_list: compactInfiniteListState(listState)
999
- });
1000
- if (!refreshResult.ok) {
1001
- updateRecommendProgress({
1002
- refresh_method: refreshResult.method || null,
1003
- refresh_forced_recent_not_view: effectiveForceRecentNotView,
1004
- recovery_reason: reason
1005
- });
1006
- throw new Error(`Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`);
1007
- }
1008
- if (refreshResult.current_city_only) {
1009
- currentCityOnlyResult = refreshResult.current_city_only;
1010
- }
1011
- if (refreshResult.filter) {
1012
- filterResult = refreshResult.filter;
1013
- }
1014
- if (!blockingPanelClose?.closed) {
1015
- const panelError = createRecommendBlockingPanelCloseFailureError(blockingPanelClose, `recover:${reason}`);
1016
- panelError.refresh_attempt = compactRefresh;
1017
- throw panelError;
1018
- }
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
+ }
1019
1678
  rootState = refreshResult.root_state || await getRecommendRoots(client);
1020
1679
  rootState = await ensureRecommendViewport(rootState, "recover_after");
1021
1680
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
@@ -1023,25 +1682,25 @@ export async function runRecommendWorkflow({
1023
1682
  intervalMs: 300
1024
1683
  });
1025
1684
  resetInfiniteListForRefreshRound(listState, {
1026
- reason: `context_recovery:${reason}`,
1027
- round: contextRecoveryAttempts,
1028
- method: refreshResult.method,
1029
- metadata: {
1030
- card_count: cardNodeIds.length,
1031
- forced_recent_not_view: effectiveForceRecentNotView,
1032
- counters: countRecommendResultStatuses(results, { greetCount })
1033
- }
1034
- });
1035
- listEndReason = "";
1036
- updateRecommendProgress({
1037
- card_count: cardNodeIds.length,
1038
- refresh_method: refreshResult.method || null,
1039
- refresh_forced_recent_not_view: effectiveForceRecentNotView,
1040
- recovery_reason: reason
1041
- });
1042
- return refreshResult;
1043
- }
1044
-
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
+
1045
1704
  runControl.setPhase("recommend:cleanup");
1046
1705
  await closeRecommendDetail(client, { attemptsLimit: 2 });
1047
1706
  await closeRecommendAvatarPreview(client, { attemptsLimit: 2 });
@@ -1088,55 +1747,55 @@ export async function runRecommendWorkflow({
1088
1747
  throw new Error(`Recommend page scope was not selected: ${pageScopeSelection.reason || pageScopeSelection.effective_scope || requestedPageScope}`);
1089
1748
  }
1090
1749
  rootState = await getRecommendRoots(client);
1091
- rootState = await ensureRecommendViewport(rootState, "page_scope");
1092
- runControl.checkpoint({
1093
- page_scope: compactPageScopeSelection(pageScopeSelection)
1094
- });
1095
-
1096
- const initialFilterStages = await applyRecommendFilterEnvelopeStages(normalizedFilter, {
1097
- applyCurrentCityOnly: async () => {
1098
- await runControl.waitIfPaused();
1099
- runControl.throwIfCanceled();
1100
- runControl.setPhase("recommend:current-city-only");
1101
- const result = await ensureRecommendCurrentCityOnly(
1102
- client,
1103
- rootState.iframe.documentNodeId,
1104
- { enabled: normalizedFilter.currentCityOnly }
1105
- );
1106
- rootState = await getRecommendRoots(client);
1107
- rootState = await ensureRecommendViewport(rootState, "current_city_only");
1108
- runControl.checkpoint({
1109
- current_city_only: compactCurrentCityOnlyResult(result)
1110
- });
1111
- return result;
1112
- },
1113
- applyFilterPanel: async () => {
1114
- await runControl.waitIfPaused();
1115
- runControl.throwIfCanceled();
1116
- runControl.setPhase("recommend:filter");
1117
- const result = await selectAndConfirmFirstSafeFilter(
1118
- client,
1119
- rootState.iframe.documentNodeId,
1120
- buildRecommendFilterSelectionOptions(normalizedFilter)
1121
- );
1122
- if (!result.confirmed) {
1123
- throw new Error("Recommend run filter selection was not confirmed");
1124
- }
1125
- rootState = await getRecommendRoots(client);
1126
- rootState = await ensureRecommendViewport(rootState, "filter");
1127
- runControl.checkpoint({
1128
- filter: compactFilterResult(result)
1129
- });
1130
- return result;
1131
- }
1132
- });
1133
- currentCityOnlyResult = initialFilterStages.current_city_only;
1134
- filterResult = initialFilterStages.filter;
1135
-
1136
- await runControl.waitIfPaused();
1137
- runControl.throwIfCanceled();
1138
- runControl.setPhase("recommend:cards");
1139
- 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");
1140
1799
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
1141
1800
  timeoutMs: cardTimeoutMs,
1142
1801
  intervalMs: 300
@@ -1149,50 +1808,308 @@ export async function runRecommendWorkflow({
1149
1808
  list_end_reason: null
1150
1809
  });
1151
1810
 
1152
- while (countPassedResults(results) < targetPassCount) {
1153
- const candidateStarted = Date.now();
1154
- const timings = {};
1811
+ while (countPassedResults(results) < targetPassCount) {
1812
+ const candidateStarted = Date.now();
1813
+ const timings = {};
1155
1814
  await runControl.waitIfPaused();
1156
- runControl.throwIfCanceled();
1157
- runControl.setPhase("recommend:candidate");
1158
- rootState = await ensureRecommendViewport(rootState, "candidate_loop");
1159
-
1160
- const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
1161
- client,
1162
- state: listState,
1163
- maxScrolls: listMaxScrolls,
1164
- stableSignatureLimit: listStableSignatureLimit,
1165
- wheelDeltaY: listWheelDeltaY,
1166
- settleMs: listSettleMs,
1167
- listScrollJitterEnabled: effectiveHumanBehavior.listScrollJitter,
1168
- fallbackPoint: listFallbackResolver,
1169
- findNodeIds: async () => {
1170
- let currentRootState = await getRecommendRoots(client);
1171
- currentRootState = await ensureRecommendViewport(currentRootState, "candidate_find_nodes");
1172
- rootState = currentRootState;
1173
- const currentCardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
1174
- timeoutMs: Math.min(cardTimeoutMs, 5000),
1175
- intervalMs: 300
1815
+ runControl.throwIfCanceled();
1816
+ runControl.setPhase("recommend:list-read");
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
1176
1843
  });
1177
- cardNodeIds = currentCardNodeIds;
1178
- return currentCardNodeIds;
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,
1880
+ findNodeIds: async () => {
1881
+ currentDomOperation = "candidate:list-find-nodes";
1882
+ let currentRootState = await getRecommendRoots(client);
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
1929
+ }
1930
+ });
1931
+ },
1932
+ shouldRethrowReadError: isStaleRecommendNodeError,
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
+ }
1943
+ }));
1944
+ },
1945
+ onStale: async ({ error, diagnostic }) => {
1946
+ listReadStaleRecoveryAttempts += 1;
1947
+ const forensic = checkpointDomStaleForensic(error, {
1948
+ phase: "recommend:list-read",
1949
+ operation: currentDomOperation,
1950
+ candidateIndex: results.length
1951
+ });
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();
1179
1968
  },
1180
- readCandidate: async (nodeId, { visibleIndex }) => readRecommendCardCandidate(client, nodeId, {
1181
- targetUrl,
1182
- source: "recommend-run-card",
1183
- metadata: {
1184
- run_candidate_index: results.length,
1185
- visible_index: visibleIndex
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;
1993
+ return {
1994
+ card_count: freshCardNodeIds.length,
1995
+ iframe_document_node_id: freshRootState.iframe.documentNodeId,
1996
+ root_identity: compactRecommendDomRootIdentity(
1997
+ freshRootState,
1998
+ currentConnectionEpoch()
1999
+ )
2000
+ };
2001
+ },
2002
+ contextReapply: async ({ rootReacquireError }) => {
2003
+ await runControl.waitIfPaused();
2004
+ runControl.throwIfCanceled();
2005
+ const recovery = await recoverAndReapplyRecommendContext(
2006
+ "list_read_stale_node",
2007
+ rootReacquireError || error,
2008
+ { forceRecentNotView: true }
2009
+ );
2010
+ return {
2011
+ ok: Boolean(recovery?.ok),
2012
+ method: recovery?.method || null,
2013
+ card_count: recovery?.card_count || 0,
2014
+ root_identity: compactRecommendDomRootIdentity(
2015
+ rootState,
2016
+ currentConnectionEpoch()
2017
+ )
2018
+ };
2019
+ }
2020
+ });
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
+ });
2029
+ updateRecommendProgress({
2030
+ list_read_stale_recovery_failed: true
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;
1186
2041
  }
1187
- }),
1188
- detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
1189
- rootNodeId: rootState?.iframe?.documentNodeId,
1190
- markerSelectors: RECOMMEND_BOTTOM_MARKER_SELECTORS,
1191
- refreshSelectors: [RECOMMEND_END_REFRESH_SELECTOR],
1192
- textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
1193
- maxTextScanNodes: 500
1194
- })
1195
- }));
2042
+ },
2043
+ onRecoveryApplied: async ({ diagnostic, recoveryResult }) => {
2044
+ listReadStaleRecoveryApplied += 1;
2045
+ lastListReadStaleDiagnostic = diagnostic;
2046
+ lastListReadRecoveryMode = diagnostic.recovery_mode || null;
2047
+ const forensic = domStaleForensics.find((item) => (
2048
+ item.event_id === diagnostic.forensic_event_id
2049
+ ));
2050
+ checkpointDomStaleRecovery(forensic, {
2051
+ status: "recovery_applied",
2052
+ recoveryResult
2053
+ });
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
+ },
2063
+ onRecovered: async ({ diagnostic }) => {
2064
+ listReadStaleRecoveries += 1;
2065
+ lastListReadStaleDiagnostic = diagnostic;
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
2074
+ }
2075
+ });
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 }) => {
2086
+ listReadStaleRecoveryAttempts += 1;
2087
+ const forensic = checkpointDomStaleForensic(error, {
2088
+ phase: "recommend:list-read",
2089
+ operation: currentDomOperation,
2090
+ candidateIndex: results.length
2091
+ });
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;
1196
2113
  if (!nextCandidateResult.ok) {
1197
2114
  listEndReason = nextCandidateResult.reason || "list_exhausted";
1198
2115
  if (
@@ -1202,57 +2119,79 @@ export async function runRecommendWorkflow({
1202
2119
  && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
1203
2120
  ) {
1204
2121
  await runControl.waitIfPaused();
1205
- runControl.throwIfCanceled();
1206
- runControl.setPhase("recommend:refresh");
1207
- refreshRounds += 1;
1208
- const refreshResult = await refreshRecommendListAtEnd(client, {
1209
- rootState,
1210
- jobLabel,
1211
- pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
1212
- fallbackPageScope: normalizedFallbackPageScope,
1213
- filter: normalizedFilter,
1214
- forceRecentNotView: normalizedFilter.enabled,
1215
- cardTimeoutMs,
1216
- buttonSettleMs: refreshButtonSettleMs,
1217
- reloadSettleMs: refreshReloadSettleMs
1218
- });
1219
- 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);
1220
2149
  refreshAttempts.push(compactRefresh);
1221
2150
  runControl.checkpoint({
1222
2151
  refresh_round: refreshRounds,
1223
- refresh: compactRefresh
1224
- });
1225
- updateRecommendProgress({
1226
- card_count: refreshResult.card_count || cardNodeIds.length,
1227
- refresh_method: refreshResult.method || null,
1228
- refresh_forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view),
1229
- list_end_reason: listEndReason
1230
- });
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
+ });
1231
2160
  if (refreshResult.ok) {
1232
- if (refreshResult.current_city_only) {
1233
- currentCityOnlyResult = refreshResult.current_city_only;
1234
- }
1235
- if (refreshResult.filter) {
1236
- 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;
1237
2182
  }
1238
- rootState = refreshResult.root_state || await getRecommendRoots(client);
1239
- rootState = await ensureRecommendViewport(rootState, "refresh_after");
1240
- cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
1241
- timeoutMs: cardTimeoutMs,
1242
- intervalMs: 300
1243
- });
1244
2183
  resetInfiniteListForRefreshRound(listState, {
1245
- reason: listEndReason,
1246
- round: refreshRounds,
1247
- method: refreshResult.method,
1248
- metadata: {
1249
- card_count: cardNodeIds.length,
1250
- forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view)
1251
- }
1252
- });
1253
- listEndReason = "";
1254
- continue;
1255
- }
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
+ }
1256
2195
  throw createRecommendRefreshFailureError(compactRefresh, {
1257
2196
  listEndReason,
1258
2197
  targetCount: targetPassCount,
@@ -1262,6 +2201,7 @@ export async function runRecommendWorkflow({
1262
2201
  break;
1263
2202
  }
1264
2203
 
2204
+ runControl.setPhase("recommend:candidate");
1265
2205
  const index = results.length;
1266
2206
  let cardNodeId = nextCandidateResult.item.node_id;
1267
2207
  const candidateKey = nextCandidateResult.item.key;
@@ -1276,11 +2216,13 @@ export async function runRecommendWorkflow({
1276
2216
  if (index < effectiveDetailLimit) {
1277
2217
  try {
1278
2218
  await runControl.waitIfPaused();
1279
- runControl.throwIfCanceled();
1280
- runControl.setPhase("recommend:detail");
1281
- detailStep = "ensure_viewport";
1282
- rootState = await ensureRecommendViewport(rootState, "detail");
1283
- 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, {
1284
2226
  attemptsLimit: 2,
1285
2227
  rootState
1286
2228
  });
@@ -1298,10 +2240,12 @@ export async function runRecommendWorkflow({
1298
2240
  }
1299
2241
  if (blockingPanelClose.already_closed === false) {
1300
2242
  timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
1301
- }
1302
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1303
- detailStep = "open_detail";
1304
- 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();
1305
2249
  await maybeHumanActionCooldown("before_detail_open", timings);
1306
2250
  const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
1307
2251
  cardNodeId,
@@ -1317,8 +2261,10 @@ export async function runRecommendWorkflow({
1317
2261
  cardNodeId = openedDetail.card_node_id || cardNodeId;
1318
2262
  cardCandidate = openedDetail.card_candidate || cardCandidate;
1319
2263
  screeningCandidate = cardCandidate;
1320
- if (shouldSkipRecentColleagueContacted) {
1321
- detailStep = "check_colleague_contact";
2264
+ if (shouldSkipRecentColleagueContacted) {
2265
+ detailStep = "check_colleague_contact";
2266
+ currentDomOperation = "candidate:detail-colleague-contact";
2267
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1322
2268
  try {
1323
2269
  colleagueContact = await measureTiming(timings, "colleague_contact_check_ms", () => inspectRecentColleagueContact(
1324
2270
  client,
@@ -1356,9 +2302,11 @@ export async function runRecommendWorkflow({
1356
2302
  };
1357
2303
  }
1358
2304
  }
1359
- if (!skipRecentColleagueContact) {
1360
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
1361
- 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 });
1362
2310
  const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
1363
2311
  waitForRecommendDetailNetworkEvents,
1364
2312
  networkRecorder,
@@ -1371,8 +2319,10 @@ export async function runRecommendWorkflow({
1371
2319
  ));
1372
2320
  if (networkWait?.elapsed_ms != null) {
1373
2321
  timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
1374
- }
1375
- detailStep = "extract_detail";
2322
+ }
2323
+ detailStep = "extract_detail";
2324
+ currentDomOperation = "candidate:detail-extract";
2325
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1376
2326
  detailResult = await extractRecommendDetailCandidate(client, {
1377
2327
  cardCandidate,
1378
2328
  cardNodeId,
@@ -1396,8 +2346,10 @@ export async function runRecommendWorkflow({
1396
2346
  parsedNetworkProfileCount,
1397
2347
  waitResult: networkWait
1398
2348
  });
1399
- } else {
1400
- detailStep = "wait_capture_target";
2349
+ } else {
2350
+ detailStep = "wait_capture_target";
2351
+ currentDomOperation = "candidate:detail-wait-capture-target";
2352
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1401
2353
  captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
1402
2354
  domain: "recommend",
1403
2355
  timeoutMs: 6000,
@@ -1406,70 +2358,206 @@ export async function runRecommendWorkflow({
1406
2358
  captureTarget = captureTargetWait.target || null;
1407
2359
  const captureNodeId = captureTarget?.node_id || null;
1408
2360
  if (captureNodeId) {
1409
- const imageEvidencePath = imageEvidenceFilePath({
1410
- imageOutputDir,
1411
- domain: "recommend",
1412
- runId: runControl?.runId,
1413
- index,
1414
- extension: "jpg"
1415
- });
1416
- try {
1417
- detailStep = "capture_image";
1418
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
1419
- filePath: imageEvidencePath,
1420
- format: "jpeg",
1421
- quality: 72,
1422
- optimize: true,
1423
- resizeMaxWidth: 1100,
1424
- captureViewport: false,
1425
- padding: 0,
1426
- maxScreenshots: maxImagePages,
1427
- wheelDeltaY: imageWheelDeltaY,
1428
- settleMs: 350,
1429
- scrollMethod: "dom-anchor-fallback-input",
1430
- scrollDeltaJitterEnabled: effectiveHumanBehavior.listScrollJitter,
1431
- stepTimeoutMs: 45000,
1432
- totalTimeoutMs: 90000,
1433
- duplicateStopCount: 1,
1434
- skipDuplicateScreenshots: true,
1435
- composeForLlm: true,
1436
- llmPagesPerImage: 3,
1437
- llmResizeMaxWidth: 1100,
1438
- llmQuality: 72,
1439
- metadata: {
1440
- domain: "recommend",
1441
- capture_mode: "scroll_sequence",
1442
- acquisition_reason: "network_miss_image_fallback",
1443
- run_candidate_index: index,
1444
- candidate_key: candidateKey,
1445
- capture_target: captureTarget,
1446
- capture_target_wait: captureTargetWait
1447
- }
1448
- }));
1449
- source = "image";
1450
- } catch (error) {
1451
- if (!isRecoverableImageCaptureError(error)) throw error;
1452
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1453
- if (recoveryCount < 1) {
1454
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1455
- timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1456
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1457
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1458
- await closeRecommendAvatarPreview(client, { attemptsLimit: 2 }).catch(() => null);
1459
- await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1460
- await recoverAndReapplyRecommendContext(`image_capture:${detailStep}`, error, {
1461
- forceRecentNotView: true
1462
- });
1463
- continue;
1464
- }
1465
- imageEvidence = createRecoverableImageCaptureEvidence(error, {
1466
- elapsedMs: timings.screenshot_capture_ms,
1467
- filePath: imageEvidencePath,
1468
- extension: "jpg",
1469
- maxScreenshots: maxImagePages
1470
- });
1471
- source = "image_capture_failed";
1472
- }
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
+ }
1473
2561
  recordCvImageFallback(cvAcquisitionState, {
1474
2562
  reason: source === "image_capture_failed"
1475
2563
  ? "network_miss_image_capture_failed"
@@ -1478,9 +2566,18 @@ export async function runRecommendWorkflow({
1478
2566
  waitResult: networkWait,
1479
2567
  imageEvidence
1480
2568
  });
1481
- } else {
1482
- source = "missing_capture_node";
1483
- 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, {
1484
2581
  reason: "network_miss_no_capture_node",
1485
2582
  parsedNetworkProfileCount,
1486
2583
  waitResult: networkWait
@@ -1501,21 +2598,42 @@ export async function runRecommendWorkflow({
1501
2598
  };
1502
2599
  screeningCandidate = detailResult.candidate;
1503
2600
  }
1504
- } catch (error) {
1505
- if (!isRecoverableRecommendDetailError(error)) throw error;
1506
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1507
- 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) {
1508
2613
  candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1509
2614
  timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1510
2615
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1511
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1512
- await closeRecommendAvatarPreview(client, { attemptsLimit: 2 }).catch(() => null);
1513
- await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1514
- await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
1515
- forceRecentNotView: true
1516
- });
1517
- continue;
1518
- }
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" });
1519
2637
  recoverableDetailError = error;
1520
2638
  detailResult = null;
1521
2639
  timings.detail_recovered_error = compactRecoverableDetailError(error);
@@ -1526,11 +2644,12 @@ export async function runRecommendWorkflow({
1526
2644
  }
1527
2645
 
1528
2646
  await runControl.waitIfPaused();
1529
- runControl.throwIfCanceled();
1530
- runControl.setPhase("recommend:screening");
1531
- let llmResult = null;
1532
- if (useLlmScreening) {
1533
- 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) {
1534
2653
  llmResult = null;
1535
2654
  } else if (!llmConfig) {
1536
2655
  llmResult = createMissingLlmConfigResult();
@@ -1564,47 +2683,85 @@ export async function runRecommendWorkflow({
1564
2683
  ? createRecentColleagueContactSkipScreening(screeningCandidate, colleagueContact)
1565
2684
  : recoverableDetailError
1566
2685
  ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
1567
- : detailResult?.image_evidence?.ok === false
1568
- ? createImageCaptureFailureScreening(screeningCandidate, {
1569
- code: detailResult.image_evidence.error_code,
1570
- message: detailResult.image_evidence.error
1571
- })
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
+ })
1572
2691
  : useLlmScreening
1573
2692
  ? llmResultToScreening(llmResult, screeningCandidate)
1574
2693
  : screenCandidate(screeningCandidate, { criteria });
1575
2694
  let actionDiscovery = null;
1576
2695
  let postActionResult = null;
1577
- let closeFailureError = null;
1578
- let closeRecoveryFailure = null;
1579
- if (postActionEnabled && detailResult && !skipRecentColleagueContact) {
1580
- const postActionStarted = Date.now();
1581
- await runControl.waitIfPaused();
1582
- runControl.throwIfCanceled();
1583
- runControl.setPhase("recommend:post-action");
1584
- await maybeHumanActionCooldown("before_post_action", timings);
1585
- actionDiscovery = await waitForRecommendDetailActionControls(client, {
1586
- timeoutMs: actionTimeoutMs,
1587
- intervalMs: actionIntervalMs,
1588
- requireAny: true
1589
- });
1590
- postActionResult = await runRecommendPostAction({
1591
- client,
1592
- screening,
1593
- actionDiscovery,
1594
- postAction: normalizedPostAction,
1595
- greetCount,
1596
- maxGreetCount: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1597
- executePostAction,
1598
- afterClickDelayMs: actionAfterClickDelayMs
1599
- });
1600
- if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
1601
- greetCount += 1;
1602
- }
1603
- addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
1604
- }
1605
- if (detailResult && closeDetail && !detailResult.close_result?.closed) {
1606
- detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
1607
- await maybeHumanActionCooldown("after_detail_close", timings);
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
+ }
2741
+ }
2742
+ if (detailResult && closeDetail && !detailResult.close_result?.closed) {
2743
+ runControl.setPhase("recommend:close-detail");
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);
1608
2765
  if (!detailResult.close_result?.closed) {
1609
2766
  closeFailureError = createRecommendCloseFailureError(detailResult.close_result);
1610
2767
  try {
@@ -1623,17 +2780,17 @@ export async function runRecommendWorkflow({
1623
2780
  } catch (error) {
1624
2781
  closeRecoveryFailure = error;
1625
2782
  detailResult.cv_acquisition = {
1626
- ...(detailResult.cv_acquisition || {}),
1627
- close_recovery: {
1628
- ok: false,
1629
- reason: "context_recovery_failed",
1630
- error: error?.message || String(error),
1631
- forced_recent_not_view: Boolean(normalizedFilter.enabled)
1632
- }
1633
- };
1634
- }
1635
- }
1636
- }
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
+ }
1637
2794
  timings.total_ms = Date.now() - candidateStarted;
1638
2795
  const compactResult = {
1639
2796
  index,
@@ -1670,9 +2827,10 @@ export async function runRecommendWorkflow({
1670
2827
  last_candidate_key: candidateKey,
1671
2828
  last_score: screening.score
1672
2829
  });
1673
- const checkpointStarted = Date.now();
1674
- runControl.checkpoint({
1675
- results,
2830
+ const checkpointStarted = Date.now();
2831
+ runControl.checkpoint({
2832
+ in_progress_candidate: null,
2833
+ results,
1676
2834
  last_candidate: {
1677
2835
  id: screeningCandidate.id || null,
1678
2836
  key: candidateKey,
@@ -1729,17 +2887,17 @@ export async function runRecommendWorkflow({
1729
2887
  }
1730
2888
 
1731
2889
  runControl.setPhase("recommend:done");
1732
- return {
1733
- domain: "recommend",
1734
- target_url: targetUrl,
1735
- job_selection: compactJobSelection(jobSelection),
1736
- page_scope: compactPageScopeSelection(pageScopeSelection),
1737
- current_city_only: compactCurrentCityOnlyResult(currentCityOnlyResult),
1738
- filter: compactFilterResult(filterResult),
1739
- card_count: cardNodeIds.length,
1740
- candidate_list: compactInfiniteListState(listState),
1741
- viewport_health: {
1742
- 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(),
1743
2901
  events: viewportGuard.getEvents()
1744
2902
  },
1745
2903
  human_behavior: effectiveHumanBehavior,
@@ -1749,7 +2907,19 @@ export async function runRecommendWorkflow({
1749
2907
  refresh_rounds: refreshRounds,
1750
2908
  refresh_attempts: refreshAttempts,
1751
2909
  context_recoveries: contextRecoveryAttempts,
1752
- ...countRecommendResultStatuses(results, { greetCount }),
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,
2914
+ last_list_read_recovery_mode: lastListReadRecoveryMode,
2915
+ last_list_read_stale_diagnostic: lastListReadStaleDiagnostic,
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),
2920
+ ...countRecommendResultStatuses(results, { greetCount }),
2921
+ transient_recovered: countRecommendResultStatuses(results, { greetCount }).transient_recovered
2922
+ + listReadStaleRecoveries,
1753
2923
  results
1754
2924
  };
1755
2925
  }
@@ -1805,6 +2975,10 @@ export function createRecommendRunService({
1805
2975
  humanBehavior = null,
1806
2976
  skipRecentColleagueContacted = true,
1807
2977
  colleagueContactWindowDays = 14,
2978
+ debugTestMode = undefined,
2979
+ debugForceListEndAfterProcessed = undefined,
2980
+ debugForceContextRecoveryAfterProcessed = undefined,
2981
+ debugForceCdpReconnectAfterProcessed = undefined,
1808
2982
  name = "recommend-domain-run"
1809
2983
  } = {}) {
1810
2984
  if (!client) throw new Error("startRecommendRun requires a guarded CDP client");
@@ -1821,6 +2995,12 @@ export function createRecommendRunService({
1821
2995
  const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
1822
2996
  const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
1823
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
+ });
1824
3004
  return manager.startRun({
1825
3005
  runId,
1826
3006
  name,
@@ -1828,17 +3008,17 @@ export function createRecommendRunService({
1828
3008
  context: {
1829
3009
  domain: "recommend",
1830
3010
  target_url: targetUrl,
1831
- criteria_present: Boolean(criteria),
1832
- job_label: jobLabel || "",
1833
- requested_page_scope: requestedPageScope,
1834
- fallback_page_scope: normalizedFallbackPageScope,
1835
- filter: normalizedFilter,
1836
- current_city_only_requested: normalizedFilter.currentCityOnly,
1837
- max_candidates: maxCandidates,
1838
- max_candidates_semantics: "passed_candidates",
1839
- detail_limit: normalizedDetailLimit,
1840
- close_detail: closeDetail,
1841
- 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,
1842
3022
  max_image_pages: maxImagePages,
1843
3023
  image_wheel_delta_y: imageWheelDeltaY,
1844
3024
  list_max_scrolls: listMaxScrolls,
@@ -1866,7 +3046,10 @@ export function createRecommendRunService({
1866
3046
  human_behavior_profile: effectiveHumanBehavior.profile,
1867
3047
  human_behavior: effectiveHumanBehavior,
1868
3048
  human_rest_level: effectiveHumanBehavior.restLevel,
1869
- human_rest_enabled: effectiveHumanRestEnabled
3049
+ human_rest_enabled: effectiveHumanRestEnabled,
3050
+ debug_test_mode: debugBoundaryOptions.debugTestMode,
3051
+ debug_boundary_mode: debugBoundaryOptions.mode,
3052
+ debug_boundary_threshold: debugBoundaryOptions.threshold
1870
3053
  },
1871
3054
  progress: {
1872
3055
  card_count: 0,
@@ -1882,21 +3065,25 @@ export function createRecommendRunService({
1882
3065
  post_action_clicked: 0,
1883
3066
  image_capture_failed: 0,
1884
3067
  detail_open_failed: 0,
1885
- transient_recovered: 0,
1886
- colleague_contact_checked: 0,
1887
- recent_colleague_contact_skipped: 0,
1888
- colleague_contact_panel_missing: 0,
1889
- context_recoveries: 0,
1890
- current_city_only_requested: normalizedFilter.currentCityOnly,
1891
- current_city_only_effective: null,
1892
- current_city_only_unavailable: false,
1893
- human_behavior_enabled: effectiveHumanBehavior.enabled,
1894
- human_behavior_profile: effectiveHumanBehavior.profile,
1895
- human_rest_level: effectiveHumanBehavior.restLevel,
1896
- human_rest_enabled: effectiveHumanRestEnabled,
1897
- human_rest_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,
1898
3081
  human_rest_ms: 0,
1899
- last_human_event: null
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
1900
3087
  },
1901
3088
  checkpoint: {},
1902
3089
  task: (runControl) => workflow({
@@ -1939,7 +3126,11 @@ export function createRecommendRunService({
1939
3126
  humanRestEnabled: effectiveHumanRestEnabled,
1940
3127
  humanBehavior: effectiveHumanBehavior,
1941
3128
  skipRecentColleagueContacted: shouldSkipRecentColleagueContacted,
1942
- colleagueContactWindowDays: normalizedColleagueContactWindowDays
3129
+ colleagueContactWindowDays: normalizedColleagueContactWindowDays,
3130
+ debugTestMode: debugBoundaryOptions.debugTestMode,
3131
+ debugForceListEndAfterProcessed: debugBoundaryOptions.debugForceListEndAfterProcessed,
3132
+ debugForceContextRecoveryAfterProcessed: debugBoundaryOptions.debugForceContextRecoveryAfterProcessed,
3133
+ debugForceCdpReconnectAfterProcessed: debugBoundaryOptions.debugForceCdpReconnectAfterProcessed
1943
3134
  }, runControl)
1944
3135
  });
1945
3136
  }