@reconcrap/boss-recommend-mcp 2.0.4 → 2.0.6

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.
@@ -22,6 +22,7 @@ import {
22
22
  getNextInfiniteListCandidate,
23
23
  markInfiniteListCandidateProcessed
24
24
  } from "../../core/infinite-list/index.js";
25
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
25
26
  import { createRunLifecycleManager } from "../../core/run/index.js";
26
27
  import {
27
28
  callScreeningLlm,
@@ -250,9 +251,13 @@ async function setupChatRunContext(client, {
250
251
  normalizedStartFrom,
251
252
  readyTimeoutMs,
252
253
  listSettleMs,
253
- runControl
254
+ runControl,
255
+ ensureViewport = null
254
256
  } = {}) {
255
- const rootState = await getChatRoots(client);
257
+ let rootState = await getChatRoots(client);
258
+ if (ensureViewport) {
259
+ rootState = await ensureViewport(rootState, "context_roots");
260
+ }
256
261
  runControl.checkpoint({
257
262
  top_document_node_id: rootState.rootNodes.top
258
263
  });
@@ -280,6 +285,10 @@ async function setupChatRunContext(client, {
280
285
  if (normalizeText(job) && !jobSelection.selected) {
281
286
  throw new Error(`Chat job selection failed: ${jobSelection.reason || "unknown"}`);
282
287
  }
288
+ rootState = await getChatRoots(client);
289
+ if (ensureViewport) {
290
+ rootState = await ensureViewport(rootState, "context_job");
291
+ }
283
292
  runControl.checkpoint({
284
293
  chat_context_step: "job_selection",
285
294
  primary_label: primaryLabel,
@@ -294,6 +303,10 @@ async function setupChatRunContext(client, {
294
303
  if (!startFilter.ok) {
295
304
  throw new Error(`Chat start filter selection failed: ${startFilter.error || "unknown"}`);
296
305
  }
306
+ rootState = await getChatRoots(client);
307
+ if (ensureViewport) {
308
+ rootState = await ensureViewport(rootState, "context_start_filter");
309
+ }
297
310
  runControl.checkpoint({
298
311
  chat_context_step: "start_filter",
299
312
  primary_label: primaryLabel,
@@ -362,6 +375,18 @@ export async function runChatWorkflow({
362
375
  domain: "chat",
363
376
  listName: "chat-candidates"
364
377
  });
378
+ const viewportGuard = createViewportRunGuard({
379
+ client,
380
+ domain: "chat",
381
+ root: "top",
382
+ frameOwnerRoot: "top",
383
+ runControl,
384
+ getRoots: getChatRoots
385
+ });
386
+ async function ensureChatViewport(rootState, phase) {
387
+ const result = await viewportGuard.ensure(rootState, { phase });
388
+ return result.rootState || rootState;
389
+ }
365
390
  const results = [];
366
391
  let cardNodeIds = [];
367
392
  let listEndReason = "";
@@ -398,7 +423,8 @@ export async function runChatWorkflow({
398
423
  normalizedStartFrom,
399
424
  readyTimeoutMs,
400
425
  listSettleMs,
401
- runControl
426
+ runControl,
427
+ ensureViewport: ensureChatViewport
402
428
  });
403
429
  let rootState = setup.rootState;
404
430
  contextSetup = {
@@ -431,7 +457,8 @@ export async function runChatWorkflow({
431
457
  normalizedStartFrom,
432
458
  readyTimeoutMs,
433
459
  listSettleMs,
434
- runControl
460
+ runControl,
461
+ ensureViewport: ensureChatViewport
435
462
  });
436
463
  rootState = recoveredSetup.rootState;
437
464
  contextSetup = {
@@ -449,7 +476,7 @@ export async function runChatWorkflow({
449
476
  await runControl.waitIfPaused();
450
477
  runControl.throwIfCanceled();
451
478
  runControl.setPhase("chat:cards");
452
- const cardRootState = await getChatRoots(client);
479
+ const cardRootState = await ensureChatViewport(await getChatRoots(client), "cards");
453
480
  const initialCards = await waitForChatCandidateNodeIds(client, cardRootState.rootNodes.top, {
454
481
  timeoutMs: cardTimeoutMs,
455
482
  intervalMs: 500
@@ -479,7 +506,9 @@ export async function runChatWorkflow({
479
506
  request_skipped: 0,
480
507
  unique_seen: compactInfiniteListState(listState).seen_count,
481
508
  scroll_count: compactInfiniteListState(listState).scroll_count,
482
- list_end_reason: listEndReason
509
+ list_end_reason: listEndReason,
510
+ viewport_checks: viewportGuard.getStats().checks,
511
+ viewport_recoveries: viewportGuard.getStats().recoveries
483
512
  });
484
513
  runControl.setPhase("chat:done");
485
514
  return {
@@ -493,6 +522,10 @@ export async function runChatWorkflow({
493
522
  requested_start_from: normalizedStartFrom
494
523
  },
495
524
  candidate_list: compactInfiniteListState(listState),
525
+ viewport_health: {
526
+ stats: viewportGuard.getStats(),
527
+ events: viewportGuard.getEvents()
528
+ },
496
529
  list_end_reason: listEndReason,
497
530
  target_pass_count: passTarget,
498
531
  process_until_list_end: Boolean(processUntilListEnd),
@@ -524,7 +557,9 @@ export async function runChatWorkflow({
524
557
  request_satisfied: 0,
525
558
  request_skipped: 0,
526
559
  unique_seen: compactInfiniteListState(listState).seen_count,
527
- scroll_count: 0
560
+ scroll_count: 0,
561
+ viewport_checks: viewportGuard.getStats().checks,
562
+ viewport_recoveries: viewportGuard.getStats().recoveries
528
563
  });
529
564
 
530
565
  while (
@@ -537,6 +572,7 @@ export async function runChatWorkflow({
537
572
  await runControl.waitIfPaused();
538
573
  runControl.throwIfCanceled();
539
574
  runControl.setPhase("chat:candidate");
575
+ rootState = await ensureChatViewport(rootState, "candidate_loop");
540
576
  const loopTopLevelState = await getChatTopLevelState(client);
541
577
  if (!loopTopLevelState.is_chat_shell) {
542
578
  await recoverAndReapplyChatContext("candidate_loop_non_chat_shell", {
@@ -554,7 +590,8 @@ export async function runChatWorkflow({
554
590
  settleMs: listSettleMs,
555
591
  fallbackPoint: listFallbackPoint,
556
592
  findNodeIds: async () => {
557
- const currentRootState = await getChatRoots(client);
593
+ const currentRootState = await ensureChatViewport(await getChatRoots(client), "candidate_find_nodes");
594
+ rootState = currentRootState;
558
595
  const currentCards = await waitForChatCandidateNodeIds(client, currentRootState.rootNodes.top, {
559
596
  timeoutMs: Math.min(cardTimeoutMs, 8000),
560
597
  intervalMs: 500
@@ -609,6 +646,7 @@ export async function runChatWorkflow({
609
646
  await runControl.waitIfPaused();
610
647
  runControl.throwIfCanceled();
611
648
  runControl.setPhase("chat:detail");
649
+ rootState = await ensureChatViewport(rootState, "detail");
612
650
 
613
651
  detailStep = "select_candidate";
614
652
  networkRecorder.clear();
@@ -921,6 +959,8 @@ export async function runChatWorkflow({
921
959
  unique_seen: compactInfiniteListState(listState).seen_count,
922
960
  scroll_count: compactInfiniteListState(listState).scroll_count,
923
961
  list_end_reason: listEndReason || null,
962
+ viewport_checks: viewportGuard.getStats().checks,
963
+ viewport_recoveries: viewportGuard.getStats().recoveries,
924
964
  last_candidate_id: screeningCandidate.id || null,
925
965
  last_candidate_key: candidateKey,
926
966
  last_score: screening.score
@@ -950,6 +990,10 @@ export async function runChatWorkflow({
950
990
  card_count: cardNodeIds.length,
951
991
  context_setup: contextSetup,
952
992
  candidate_list: compactInfiniteListState(listState),
993
+ viewport_health: {
994
+ stats: viewportGuard.getStats(),
995
+ events: viewportGuard.getEvents()
996
+ },
953
997
  list_end_reason: listEndReason || null,
954
998
  target_pass_count: passTarget,
955
999
  process_until_list_end: Boolean(processUntilListEnd),
@@ -8,6 +8,7 @@ import {
8
8
  querySelectorAll,
9
9
  sleep
10
10
  } from "../../core/browser/index.js";
11
+ import { candidateKeyFromProfile } from "../../core/infinite-list/index.js";
11
12
  import {
12
13
  buildScreeningCandidateFromDetail,
13
14
  htmlToText
@@ -22,6 +23,10 @@ import {
22
23
  getRecommendRoots,
23
24
  queryFirstAcrossRoots
24
25
  } from "./roots.js";
26
+ import {
27
+ findRecommendCardNodeIds,
28
+ readRecommendCardCandidate
29
+ } from "./cards.js";
25
30
 
26
31
  export function matchesRecommendDetailNetwork(url) {
27
32
  return DETAIL_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
@@ -146,14 +151,36 @@ export async function readRecommendDetailHtml(client, detailState) {
146
151
  let popupHTML = "";
147
152
  let resumeHTML = "";
148
153
  let resumeIframeDocumentNodeId = null;
154
+ const errors = [];
149
155
 
150
156
  if (detailState?.popup?.node_id) {
151
- popupHTML = await getOuterHTML(client, detailState.popup.node_id);
157
+ try {
158
+ popupHTML = await getOuterHTML(client, detailState.popup.node_id);
159
+ } catch (error) {
160
+ errors.push({
161
+ source: "popup",
162
+ node_id: detailState.popup.node_id,
163
+ stale_node: isStaleRecommendNodeError(error),
164
+ error: error?.message || String(error)
165
+ });
166
+ }
152
167
  }
153
168
 
154
169
  if (detailState?.resumeIframe?.node_id) {
155
- resumeIframeDocumentNodeId = await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id);
156
- resumeHTML = await getOuterHTML(client, resumeIframeDocumentNodeId);
170
+ try {
171
+ resumeIframeDocumentNodeId = await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id);
172
+ resumeHTML = await getOuterHTML(client, resumeIframeDocumentNodeId);
173
+ } catch (error) {
174
+ errors.push({
175
+ source: "resume_iframe",
176
+ node_id: detailState.resumeIframe.node_id,
177
+ document_node_id: resumeIframeDocumentNodeId,
178
+ stale_node: isStaleRecommendNodeError(error),
179
+ error: error?.message || String(error)
180
+ });
181
+ resumeIframeDocumentNodeId = null;
182
+ resumeHTML = "";
183
+ }
157
184
  }
158
185
 
159
186
  return {
@@ -161,7 +188,90 @@ export async function readRecommendDetailHtml(client, detailState) {
161
188
  resumeHTML,
162
189
  resumeIframeDocumentNodeId,
163
190
  popupText: htmlToText(popupHTML),
164
- resumeText: htmlToText(resumeHTML)
191
+ resumeText: htmlToText(resumeHTML),
192
+ errors
193
+ };
194
+ }
195
+
196
+ export function isStaleRecommendNodeError(error) {
197
+ const message = String(error?.message || error || "");
198
+ return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
199
+ }
200
+
201
+ export async function findRecommendCardNodeForCandidateKey(client, {
202
+ candidateKey = "",
203
+ rootState = null,
204
+ targetUrl = "",
205
+ source = "recommend-run-card-retry",
206
+ timeoutMs = 5000,
207
+ intervalMs = 250
208
+ } = {}) {
209
+ if (!candidateKey) {
210
+ return {
211
+ ok: false,
212
+ reason: "candidate_key_required"
213
+ };
214
+ }
215
+
216
+ const started = Date.now();
217
+ let lastError = null;
218
+ let lastCardCount = 0;
219
+ while (Date.now() - started <= timeoutMs) {
220
+ const currentRootState = rootState?.iframe?.documentNodeId
221
+ ? rootState
222
+ : await getRecommendRoots(client);
223
+ const frameNodeId = currentRootState?.iframe?.documentNodeId;
224
+ if (!frameNodeId) {
225
+ return {
226
+ ok: false,
227
+ reason: "recommend_frame_not_found"
228
+ };
229
+ }
230
+
231
+ const nodeIds = await findRecommendCardNodeIds(client, frameNodeId);
232
+ lastCardCount = nodeIds.length;
233
+ for (let visibleIndex = 0; visibleIndex < nodeIds.length; visibleIndex += 1) {
234
+ const nodeId = nodeIds[visibleIndex];
235
+ try {
236
+ const candidate = await readRecommendCardCandidate(client, nodeId, {
237
+ targetUrl,
238
+ source,
239
+ metadata: {
240
+ visible_index: visibleIndex,
241
+ retry_reason: "stale_detail_node"
242
+ }
243
+ });
244
+ const key = candidateKeyFromProfile(candidate, {
245
+ nodeId,
246
+ visibleIndex,
247
+ attributes: candidate?.attributes || candidate?.metadata?.attributes || {}
248
+ });
249
+ if (key === candidateKey) {
250
+ return {
251
+ ok: true,
252
+ node_id: nodeId,
253
+ visible_index: visibleIndex,
254
+ candidate,
255
+ key,
256
+ root_state: currentRootState,
257
+ card_count: nodeIds.length
258
+ };
259
+ }
260
+ } catch (error) {
261
+ lastError = error;
262
+ }
263
+ }
264
+
265
+ if (intervalMs > 0) await sleep(intervalMs);
266
+ rootState = null;
267
+ }
268
+
269
+ return {
270
+ ok: false,
271
+ reason: "candidate_key_not_mounted",
272
+ candidate_key: candidateKey,
273
+ last_card_count: lastCardCount,
274
+ error: lastError?.message || null
165
275
  };
166
276
  }
167
277
 
@@ -181,6 +291,77 @@ export async function openRecommendCardDetail(client, cardNodeId, {
181
291
  };
182
292
  }
183
293
 
294
+ export async function openRecommendCardDetailWithFreshRetry(client, {
295
+ cardNodeId,
296
+ candidateKey = "",
297
+ cardCandidate = null,
298
+ rootState = null,
299
+ targetUrl = "",
300
+ timeoutMs = 12000,
301
+ scrollIntoView = true,
302
+ retryTimeoutMs = 5000,
303
+ retryIntervalMs = 250,
304
+ maxAttempts = 2
305
+ } = {}) {
306
+ let currentNodeId = cardNodeId;
307
+ let currentCandidate = cardCandidate;
308
+ let currentRootState = rootState;
309
+ const attempts = [];
310
+ const limit = Math.max(1, Number(maxAttempts) || 1);
311
+
312
+ for (let attemptIndex = 0; attemptIndex < limit; attemptIndex += 1) {
313
+ try {
314
+ const opened = await openRecommendCardDetail(client, currentNodeId, {
315
+ timeoutMs,
316
+ scrollIntoView
317
+ });
318
+ return {
319
+ ...opened,
320
+ card_node_id: currentNodeId,
321
+ card_candidate: currentCandidate,
322
+ retry_attempts: attempts
323
+ };
324
+ } catch (error) {
325
+ const stale = isStaleRecommendNodeError(error);
326
+ attempts.push({
327
+ attempt: attemptIndex + 1,
328
+ node_id: currentNodeId,
329
+ stale_node: stale,
330
+ error: error?.message || String(error)
331
+ });
332
+ if (!stale || attemptIndex >= limit - 1 || !candidateKey) {
333
+ error.recommend_detail_open_attempts = attempts;
334
+ throw error;
335
+ }
336
+
337
+ const resolved = await findRecommendCardNodeForCandidateKey(client, {
338
+ candidateKey,
339
+ rootState: currentRootState,
340
+ targetUrl,
341
+ timeoutMs: retryTimeoutMs,
342
+ intervalMs: retryIntervalMs
343
+ });
344
+ attempts[attempts.length - 1].refresh_lookup = {
345
+ ok: Boolean(resolved.ok),
346
+ node_id: resolved.node_id || null,
347
+ visible_index: resolved.visible_index ?? null,
348
+ card_count: resolved.card_count || resolved.last_card_count || 0,
349
+ reason: resolved.reason || null,
350
+ error: resolved.error || null
351
+ };
352
+ if (!resolved.ok || !resolved.node_id) {
353
+ error.recommend_detail_open_attempts = attempts;
354
+ throw error;
355
+ }
356
+ currentNodeId = resolved.node_id;
357
+ currentCandidate = resolved.candidate || currentCandidate;
358
+ currentRootState = resolved.root_state || null;
359
+ }
360
+ }
361
+
362
+ throw new Error("Recommend detail retry exhausted");
363
+ }
364
+
184
365
  export async function closeRecommendDetail(client, {
185
366
  attemptsLimit = 3
186
367
  } = {}) {
@@ -317,7 +498,8 @@ export async function extractRecommendDetailCandidate(client, {
317
498
  detail_popup_root: detailState?.popup?.root || null,
318
499
  resume_iframe_selector: detailState?.resumeIframe?.selector || null,
319
500
  resume_iframe_root: detailState?.resumeIframe?.root || null,
320
- resume_iframe_document_node_id: detailHtml.resumeIframeDocumentNodeId
501
+ resume_iframe_document_node_id: detailHtml.resumeIframeDocumentNodeId,
502
+ detail_html_errors: detailHtml.errors || []
321
503
  }
322
504
  });
323
505
 
@@ -334,7 +516,8 @@ export async function extractRecommendDetailCandidate(client, {
334
516
  popup_text: detailHtml.popupText,
335
517
  resume_text: detailHtml.resumeText,
336
518
  popup_html_length: detailHtml.popupHTML.length,
337
- resume_html_length: detailHtml.resumeHTML.length
519
+ resume_html_length: detailHtml.resumeHTML.length,
520
+ html_errors: detailHtml.errors || []
338
521
  },
339
522
  close_result: closeResult
340
523
  };
@@ -25,7 +25,8 @@ export async function getRecommendRoots(client, {
25
25
  ].filter(Boolean),
26
26
  rootNodes: {
27
27
  top: topRoot.nodeId,
28
- frame: iframe?.documentNodeId || 0
28
+ frame: iframe?.documentNodeId || 0,
29
+ frameOwner: iframe?.nodeId || 0
29
30
  }
30
31
  };
31
32
  }
@@ -49,7 +50,8 @@ export async function waitForRecommendRoots(client, {
49
50
  roots: [],
50
51
  rootNodes: {
51
52
  top: 0,
52
- frame: 0
53
+ frame: 0,
54
+ frameOwner: 0
53
55
  }
54
56
  };
55
57
  }
@@ -20,12 +20,13 @@ import {
20
20
  markInfiniteListCandidateProcessed,
21
21
  resetInfiniteListForRefreshRound
22
22
  } from "../../core/infinite-list/index.js";
23
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
23
24
  import { screenCandidate } from "../../core/screening/index.js";
24
25
  import {
25
26
  closeRecommendDetail,
26
27
  createRecommendDetailNetworkRecorder,
27
28
  extractRecommendDetailCandidate,
28
- openRecommendCardDetail,
29
+ openRecommendCardDetailWithFreshRetry,
29
30
  waitForRecommendDetailNetworkEvents
30
31
  } from "./detail.js";
31
32
  import {
@@ -372,6 +373,18 @@ export async function runRecommendWorkflow({
372
373
  domain: "recommend",
373
374
  listName: "recommend-candidates"
374
375
  });
376
+ const viewportGuard = createViewportRunGuard({
377
+ client,
378
+ domain: "recommend",
379
+ root: "frame",
380
+ frameOwnerRoot: "frameOwner",
381
+ runControl,
382
+ getRoots: getRecommendRoots
383
+ });
384
+ async function ensureRecommendViewport(rootState, phase) {
385
+ const result = await viewportGuard.ensure(rootState, { phase });
386
+ return result.rootState || rootState;
387
+ }
375
388
  const results = [];
376
389
  const refreshAttempts = [];
377
390
  let refreshRounds = 0;
@@ -389,6 +402,7 @@ export async function runRecommendWorkflow({
389
402
  runControl.throwIfCanceled();
390
403
  runControl.setPhase("recommend:roots");
391
404
  let rootState = await getRecommendRoots(client);
405
+ rootState = await ensureRecommendViewport(rootState, "roots");
392
406
  runControl.checkpoint({
393
407
  iframe_selector: rootState.iframe.selector,
394
408
  iframe_document_node_id: rootState.iframe.documentNodeId
@@ -406,6 +420,7 @@ export async function runRecommendWorkflow({
406
420
  throw new Error(`Requested recommend job was not selected: ${jobSelection.reason}`);
407
421
  }
408
422
  rootState = await getRecommendRoots(client);
423
+ rootState = await ensureRecommendViewport(rootState, "job");
409
424
  runControl.checkpoint({
410
425
  job_selection: compactJobSelection(jobSelection)
411
426
  });
@@ -424,6 +439,7 @@ export async function runRecommendWorkflow({
424
439
  throw new Error(`Recommend page scope was not selected: ${pageScopeSelection.reason || pageScopeSelection.effective_scope || requestedPageScope}`);
425
440
  }
426
441
  rootState = await getRecommendRoots(client);
442
+ rootState = await ensureRecommendViewport(rootState, "page_scope");
427
443
  runControl.checkpoint({
428
444
  page_scope: compactPageScopeSelection(pageScopeSelection)
429
445
  });
@@ -440,6 +456,8 @@ export async function runRecommendWorkflow({
440
456
  if (!filterResult.confirmed) {
441
457
  throw new Error("Recommend run filter selection was not confirmed");
442
458
  }
459
+ rootState = await getRecommendRoots(client);
460
+ rootState = await ensureRecommendViewport(rootState, "filter");
443
461
  runControl.checkpoint({
444
462
  filter: compactFilterResult(filterResult)
445
463
  });
@@ -448,6 +466,7 @@ export async function runRecommendWorkflow({
448
466
  await runControl.waitIfPaused();
449
467
  runControl.throwIfCanceled();
450
468
  runControl.setPhase("recommend:cards");
469
+ rootState = await ensureRecommendViewport(rootState, "cards");
451
470
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
452
471
  timeoutMs: cardTimeoutMs,
453
472
  intervalMs: 300
@@ -468,13 +487,16 @@ export async function runRecommendWorkflow({
468
487
  unique_seen: compactInfiniteListState(listState).seen_count,
469
488
  scroll_count: 0,
470
489
  refresh_rounds: 0,
471
- refresh_attempts: 0
490
+ refresh_attempts: 0,
491
+ viewport_checks: viewportGuard.getStats().checks,
492
+ viewport_recoveries: viewportGuard.getStats().recoveries
472
493
  });
473
494
 
474
495
  while (results.length < limit) {
475
496
  await runControl.waitIfPaused();
476
497
  runControl.throwIfCanceled();
477
498
  runControl.setPhase("recommend:candidate");
499
+ rootState = await ensureRecommendViewport(rootState, "candidate_loop");
478
500
 
479
501
  const nextCandidateResult = await getNextInfiniteListCandidate({
480
502
  client,
@@ -485,7 +507,9 @@ export async function runRecommendWorkflow({
485
507
  settleMs: listSettleMs,
486
508
  fallbackPoint: listFallbackPoint,
487
509
  findNodeIds: async () => {
488
- const currentRootState = await getRecommendRoots(client);
510
+ let currentRootState = await getRecommendRoots(client);
511
+ currentRootState = await ensureRecommendViewport(currentRootState, "candidate_find_nodes");
512
+ rootState = currentRootState;
489
513
  const currentCardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
490
514
  timeoutMs: Math.min(cardTimeoutMs, 5000),
491
515
  intervalMs: 300
@@ -544,10 +568,13 @@ export async function runRecommendWorkflow({
544
568
  refresh_attempts: refreshAttempts.length,
545
569
  refresh_method: refreshResult.method || null,
546
570
  refresh_forced_recent_not_view: true,
547
- list_end_reason: listEndReason
571
+ list_end_reason: listEndReason,
572
+ viewport_checks: viewportGuard.getStats().checks,
573
+ viewport_recoveries: viewportGuard.getStats().recoveries
548
574
  });
549
575
  if (refreshResult.ok) {
550
576
  rootState = refreshResult.root_state || await getRecommendRoots(client);
577
+ rootState = await ensureRecommendViewport(rootState, "refresh_after");
551
578
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
552
579
  timeoutMs: cardTimeoutMs,
553
580
  intervalMs: 300
@@ -569,9 +596,9 @@ export async function runRecommendWorkflow({
569
596
  }
570
597
 
571
598
  const index = results.length;
572
- const cardNodeId = nextCandidateResult.item.node_id;
599
+ let cardNodeId = nextCandidateResult.item.node_id;
573
600
  const candidateKey = nextCandidateResult.item.key;
574
- const cardCandidate = nextCandidateResult.item.candidate;
601
+ let cardCandidate = nextCandidateResult.item.candidate;
575
602
 
576
603
  let screeningCandidate = cardCandidate;
577
604
  let detailResult = null;
@@ -579,8 +606,19 @@ export async function runRecommendWorkflow({
579
606
  await runControl.waitIfPaused();
580
607
  runControl.throwIfCanceled();
581
608
  runControl.setPhase("recommend:detail");
609
+ rootState = await ensureRecommendViewport(rootState, "detail");
582
610
  networkRecorder.clear();
583
- const openedDetail = await openRecommendCardDetail(client, cardNodeId);
611
+ const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
612
+ cardNodeId,
613
+ candidateKey,
614
+ cardCandidate,
615
+ rootState,
616
+ targetUrl,
617
+ maxAttempts: 2
618
+ });
619
+ cardNodeId = openedDetail.card_node_id || cardNodeId;
620
+ cardCandidate = openedDetail.card_candidate || cardCandidate;
621
+ screeningCandidate = cardCandidate;
584
622
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
585
623
  const networkWait = await waitForCvNetworkEvents(
586
624
  waitForRecommendDetailNetworkEvents,
@@ -719,6 +757,8 @@ export async function runRecommendWorkflow({
719
757
  refresh_rounds: refreshRounds,
720
758
  refresh_attempts: refreshAttempts.length,
721
759
  list_end_reason: listEndReason || null,
760
+ viewport_checks: viewportGuard.getStats().checks,
761
+ viewport_recoveries: viewportGuard.getStats().recoveries,
722
762
  last_candidate_id: screeningCandidate.id || null,
723
763
  last_candidate_key: candidateKey,
724
764
  last_score: screening.score
@@ -756,6 +796,10 @@ export async function runRecommendWorkflow({
756
796
  filter: compactFilterResult(filterResult),
757
797
  card_count: cardNodeIds.length,
758
798
  candidate_list: compactInfiniteListState(listState),
799
+ viewport_health: {
800
+ stats: viewportGuard.getStats(),
801
+ events: viewportGuard.getEvents()
802
+ },
759
803
  list_end_reason: listEndReason || null,
760
804
  refresh_rounds: refreshRounds,
761
805
  refresh_attempts: refreshAttempts,
@@ -25,7 +25,8 @@ export async function getRecruitRoots(client, {
25
25
  ].filter(Boolean),
26
26
  rootNodes: {
27
27
  top: topRoot.nodeId,
28
- frame: iframe?.documentNodeId || 0
28
+ frame: iframe?.documentNodeId || 0,
29
+ frameOwner: iframe?.nodeId || 0
29
30
  }
30
31
  };
31
32
  }