@reconcrap/boss-recommend-mcp 2.0.33 → 2.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { createRunLifecycleManager } from "../../core/run/index.js";
2
4
  import {
3
5
  addTiming,
@@ -137,6 +139,129 @@ function compactRefreshAttempt(refreshAttempt) {
137
139
  };
138
140
  }
139
141
 
142
+ function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
143
+ if (!error) return null;
144
+ return {
145
+ code: error.code || fallbackCode,
146
+ message: error.message || String(error)
147
+ };
148
+ }
149
+
150
+ function createRecruitCloseFailureError(closeResult) {
151
+ const error = new Error(closeResult?.reason || "Recruit detail did not close before recovery");
152
+ error.code = "DETAIL_CLOSE_FAILED";
153
+ error.close_result = closeResult || null;
154
+ return error;
155
+ }
156
+
157
+ export function isStaleRecruitNodeError(error) {
158
+ const message = String(error?.message || error || "");
159
+ return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
160
+ }
161
+
162
+ export function isRecoverableRecruitImageCaptureError(error) {
163
+ const code = String(error?.code || "");
164
+ if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
165
+ if (isStaleRecruitNodeError(error)) return true;
166
+ return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
167
+ }
168
+
169
+ export function isRecoverableRecruitDetailError(error) {
170
+ return isStaleRecruitNodeError(error);
171
+ }
172
+
173
+ function compactRecoverableDetailError(error) {
174
+ return compactError(error, isStaleRecruitNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
175
+ }
176
+
177
+ function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
178
+ const resolved = String(basePath || "").trim();
179
+ if (!resolved) return [];
180
+ const parsed = path.parse(resolved);
181
+ const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
182
+ const files = [];
183
+ for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
184
+ const page = String(index + 1).padStart(2, "0");
185
+ const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
186
+ if (fs.existsSync(candidatePath)) files.push(candidatePath);
187
+ }
188
+ return files;
189
+ }
190
+
191
+ export function createRecoverableRecruitImageCaptureEvidence(error, {
192
+ elapsedMs = 0,
193
+ filePath = "",
194
+ extension = "jpg",
195
+ maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
196
+ } = {}) {
197
+ const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
198
+ return {
199
+ schema_version: 1,
200
+ ok: false,
201
+ source: "image-scroll-sequence",
202
+ elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
203
+ capture_count: filePaths.length,
204
+ screenshot_count: filePaths.length,
205
+ unique_screenshot_count: filePaths.length,
206
+ dropped_duplicate_count: 0,
207
+ total_byte_length: 0,
208
+ original_total_byte_length: 0,
209
+ llm_screenshot_count: 0,
210
+ llm_total_byte_length: 0,
211
+ llm_original_total_byte_length: 0,
212
+ llm_composition_error: null,
213
+ error_code: error?.code || (isStaleRecruitNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
214
+ error: error?.message || String(error || "Image capture failed"),
215
+ file_paths: filePaths,
216
+ llm_file_paths: []
217
+ };
218
+ }
219
+
220
+ function createImageCaptureFailureScreening(candidate, error) {
221
+ return {
222
+ status: "fail",
223
+ passed: false,
224
+ score: 0,
225
+ reasons: ["image_capture_failed"],
226
+ error: compactError(error, "IMAGE_CAPTURE_FAILED"),
227
+ candidate
228
+ };
229
+ }
230
+
231
+ function createRecoverableDetailFailureScreening(candidate, error) {
232
+ return {
233
+ status: "fail",
234
+ passed: false,
235
+ score: 0,
236
+ reasons: isStaleRecruitNodeError(error)
237
+ ? ["detail_open_failed", "stale_node"]
238
+ : ["detail_open_failed"],
239
+ error: compactRecoverableDetailError(error),
240
+ candidate
241
+ };
242
+ }
243
+
244
+ export function countRecruitResultStatuses(results = []) {
245
+ return {
246
+ processed: results.length,
247
+ screened: results.length,
248
+ detail_opened: results.filter((item) => item.detail).length,
249
+ passed: results.filter((item) => item.screening?.passed).length,
250
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
251
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
252
+ detail_open_failed: results.filter((item) => (
253
+ item.error?.code === "DETAIL_STALE_NODE"
254
+ || item.error?.code === "DETAIL_OPEN_FAILED"
255
+ )).length,
256
+ transient_recovered: results.filter((item) => (
257
+ item.error?.code === "DETAIL_STALE_NODE"
258
+ || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
259
+ || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
260
+ || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
261
+ )).length
262
+ };
263
+ }
264
+
140
265
  export async function runRecruitWorkflow({
141
266
  client,
142
267
  targetUrl = "",
@@ -197,6 +322,8 @@ export async function runRecruitWorkflow({
197
322
  const results = [];
198
323
  const refreshAttempts = [];
199
324
  let refreshRounds = 0;
325
+ let contextRecoveryAttempts = 0;
326
+ const candidateRecoveryCounts = new Map();
200
327
  let cardNodeIds = [];
201
328
  let listEndReason = "";
202
329
  const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
@@ -208,6 +335,115 @@ export async function runRecruitWorkflow({
208
335
  validateViewportPoint: true
209
336
  }));
210
337
 
338
+ function updateRecruitProgress(extra = {}) {
339
+ const counts = countRecruitResultStatuses(results);
340
+ const listSnapshot = compactInfiniteListState(listState);
341
+ runControl.updateProgress({
342
+ card_count: cardNodeIds.length,
343
+ target_count: limit,
344
+ ...counts,
345
+ screening_mode: normalizedScreeningMode,
346
+ unique_seen: listSnapshot.seen_count,
347
+ scroll_count: listSnapshot.scroll_count,
348
+ refresh_rounds: refreshRounds,
349
+ refresh_attempts: refreshAttempts.length,
350
+ context_recoveries: contextRecoveryAttempts,
351
+ list_end_reason: listEndReason || null,
352
+ viewport_checks: viewportGuard.getStats().checks,
353
+ viewport_recoveries: viewportGuard.getStats().recoveries,
354
+ ...extra
355
+ });
356
+ }
357
+
358
+ function checkpointInProgressCandidate({
359
+ index = results.length,
360
+ candidateKey = "",
361
+ cardNodeId = null,
362
+ detailStep = "",
363
+ error = null
364
+ } = {}) {
365
+ runControl.checkpoint({
366
+ in_progress_candidate: {
367
+ index,
368
+ key: candidateKey,
369
+ card_node_id: cardNodeId,
370
+ detail_step: detailStep || null,
371
+ counters: countRecruitResultStatuses(results),
372
+ error: compactError(error, "RECRUIT_IN_PROGRESS_ERROR")
373
+ },
374
+ candidate_list: compactInfiniteListState(listState)
375
+ });
376
+ }
377
+
378
+ async function recoverAndReapplyRecruitContext(reason = "context_recovery", error = null, {
379
+ forceRecentViewed = true
380
+ } = {}) {
381
+ await runControl.waitIfPaused();
382
+ runControl.throwIfCanceled();
383
+ const started = Date.now();
384
+ runControl.setPhase("recruit:recover-context");
385
+ contextRecoveryAttempts += 1;
386
+ const refreshResult = await refreshRecruitSearchAtEnd(client, {
387
+ searchParams: normalizedSearchParams,
388
+ requireCards: true,
389
+ searchTimeoutMs: cardTimeoutMs,
390
+ resetTimeoutMs,
391
+ resetSettleMs: refreshResetSettleMs,
392
+ cityOptionTimeoutMs,
393
+ forceRecentViewed
394
+ });
395
+ const compactRefresh = {
396
+ ...compactRefreshAttempt(refreshResult),
397
+ context_recovery: true,
398
+ recovery_reason: reason,
399
+ trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
400
+ elapsed_ms: Date.now() - started
401
+ };
402
+ refreshAttempts.push(compactRefresh);
403
+ runControl.checkpoint({
404
+ context_recovery: {
405
+ attempt: contextRecoveryAttempts,
406
+ reason,
407
+ trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
408
+ refresh: compactRefresh,
409
+ counters: countRecruitResultStatuses(results)
410
+ },
411
+ candidate_list: compactInfiniteListState(listState)
412
+ });
413
+ if (!refreshResult.ok) {
414
+ updateRecruitProgress({
415
+ refresh_method: refreshResult.method || null,
416
+ refresh_forced_recent_viewed: forceRecentViewed,
417
+ recovery_reason: reason
418
+ });
419
+ throw new Error(`Recruit context recovery failed after ${reason}: ${refreshResult.application?.reason || "refresh returned no cards"}`);
420
+ }
421
+ rootState = await getRecruitRoots(client);
422
+ rootState = await ensureRecruitViewport(rootState, "recover_after");
423
+ cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
424
+ timeoutMs: cardTimeoutMs,
425
+ intervalMs: 300
426
+ });
427
+ resetInfiniteListForRefreshRound(listState, {
428
+ reason: `context_recovery:${reason}`,
429
+ round: contextRecoveryAttempts,
430
+ method: refreshResult.method,
431
+ metadata: {
432
+ card_count: cardNodeIds.length,
433
+ forced_recent_viewed: forceRecentViewed,
434
+ counters: countRecruitResultStatuses(results)
435
+ }
436
+ });
437
+ listEndReason = "";
438
+ updateRecruitProgress({
439
+ card_count: cardNodeIds.length,
440
+ refresh_method: refreshResult.method || null,
441
+ refresh_forced_recent_viewed: forceRecentViewed,
442
+ recovery_reason: reason
443
+ });
444
+ return refreshResult;
445
+ }
446
+
211
447
  runControl.setPhase("recruit:cleanup");
212
448
  await closeRecruitDetail(client, { attemptsLimit: 2 });
213
449
 
@@ -264,21 +500,8 @@ export async function runRecruitWorkflow({
264
500
  throw new Error("No recruit/search candidate cards found for run service");
265
501
  }
266
502
 
267
- runControl.updateProgress({
268
- card_count: cardNodeIds.length,
269
- target_count: limit,
270
- processed: 0,
271
- screened: 0,
272
- detail_opened: 0,
273
- passed: 0,
274
- screening_mode: normalizedScreeningMode,
275
- llm_screened: 0,
276
- unique_seen: compactInfiniteListState(listState).seen_count,
277
- scroll_count: 0,
278
- refresh_rounds: 0,
279
- refresh_attempts: 0,
280
- viewport_checks: viewportGuard.getStats().checks,
281
- viewport_recoveries: viewportGuard.getStats().recoveries
503
+ updateRecruitProgress({
504
+ list_end_reason: null
282
505
  });
283
506
 
284
507
  while (results.length < limit) {
@@ -351,23 +574,11 @@ export async function runRecruitWorkflow({
351
574
  refresh_round: refreshRounds,
352
575
  refresh: compactRefresh
353
576
  });
354
- runControl.updateProgress({
577
+ updateRecruitProgress({
355
578
  card_count: refreshResult.card_count || cardNodeIds.length,
356
- target_count: limit,
357
- processed: results.length,
358
- screened: results.length,
359
- detail_opened: results.filter((item) => item.detail).length,
360
- passed: results.filter((item) => item.screening.passed).length,
361
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
362
- unique_seen: compactInfiniteListState(listState).seen_count,
363
- scroll_count: compactInfiniteListState(listState).scroll_count,
364
- refresh_rounds: refreshRounds,
365
- refresh_attempts: refreshAttempts.length,
366
579
  refresh_method: refreshResult.method || null,
367
580
  refresh_forced_recent_viewed: true,
368
- list_end_reason: listEndReason,
369
- viewport_checks: viewportGuard.getStats().checks,
370
- viewport_recoveries: viewportGuard.getStats().recoveries
581
+ list_end_reason: listEndReason
371
582
  });
372
583
  if (refreshResult.ok) {
373
584
  rootState = await getRecruitRoots(client);
@@ -399,128 +610,197 @@ export async function runRecruitWorkflow({
399
610
 
400
611
  let screeningCandidate = cardCandidate;
401
612
  let detailResult = null;
613
+ let recoverableDetailError = null;
614
+ let detailStep = "not_started";
402
615
  if (index < detailCountLimit) {
403
- await runControl.waitIfPaused();
404
- runControl.throwIfCanceled();
405
- runControl.setPhase("recruit:detail");
406
- rootState = await ensureRecruitViewport(rootState, "detail");
407
- networkRecorder.clear();
408
- const openedDetail = await openRecruitCardDetail(client, cardNodeId);
409
- addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
410
- addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
411
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
412
- const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
413
- waitForRecruitDetailNetworkEvents,
414
- networkRecorder,
415
- {
416
- waitPlan,
417
- minCount: 1,
418
- requireLoaded: true,
419
- intervalMs: 120
616
+ try {
617
+ await runControl.waitIfPaused();
618
+ runControl.throwIfCanceled();
619
+ runControl.setPhase("recruit:detail");
620
+ detailStep = "ensure_viewport";
621
+ rootState = await ensureRecruitViewport(rootState, "detail");
622
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
623
+ detailStep = "open_detail";
624
+ networkRecorder.clear();
625
+ const openedDetail = await openRecruitCardDetail(client, cardNodeId);
626
+ addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
627
+ addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
628
+ const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
629
+ detailStep = "wait_network";
630
+ const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
631
+ waitForRecruitDetailNetworkEvents,
632
+ networkRecorder,
633
+ {
634
+ waitPlan,
635
+ minCount: 1,
636
+ requireLoaded: true,
637
+ intervalMs: 120
638
+ }
639
+ ));
640
+ if (networkWait?.elapsed_ms != null) {
641
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
420
642
  }
421
- ));
422
- if (networkWait?.elapsed_ms != null) {
423
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
424
- }
425
- detailResult = await extractRecruitDetailCandidate(client, {
426
- cardCandidate,
427
- cardNodeId,
428
- detailState: openedDetail.detail_state,
429
- networkEvents: networkRecorder.events,
430
- targetUrl,
431
- closeDetail: false,
432
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
433
- networkParseIntervalMs: 250
434
- });
435
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
436
- const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
437
- let source = "network";
438
- let imageEvidence = null;
439
- let captureTarget = null;
440
- let captureTargetWait = null;
441
- if (parsedNetworkProfileCount > 0) {
442
- recordCvNetworkHit(cvAcquisitionState, {
443
- parsedNetworkProfileCount,
444
- waitResult: networkWait
643
+ detailStep = "extract_detail";
644
+ detailResult = await extractRecruitDetailCandidate(client, {
645
+ cardCandidate,
646
+ cardNodeId,
647
+ detailState: openedDetail.detail_state,
648
+ networkEvents: networkRecorder.events,
649
+ targetUrl,
650
+ closeDetail: false,
651
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
652
+ networkParseIntervalMs: 250
445
653
  });
446
- } else {
447
- captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
448
- domain: "recruit",
449
- timeoutMs: 6000,
450
- intervalMs: 250
451
- });
452
- captureTarget = captureTargetWait.target || null;
453
- const captureNodeId = captureTarget?.node_id || null;
454
- if (captureNodeId) {
455
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
456
- filePath: imageEvidenceFilePath({
654
+ addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
655
+ const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
656
+ let source = "network";
657
+ let imageEvidence = null;
658
+ let captureTarget = null;
659
+ let captureTargetWait = null;
660
+ if (parsedNetworkProfileCount > 0) {
661
+ recordCvNetworkHit(cvAcquisitionState, {
662
+ parsedNetworkProfileCount,
663
+ waitResult: networkWait
664
+ });
665
+ } else {
666
+ detailStep = "wait_capture_target";
667
+ captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
668
+ domain: "recruit",
669
+ timeoutMs: 6000,
670
+ intervalMs: 250
671
+ });
672
+ captureTarget = captureTargetWait.target || null;
673
+ const captureNodeId = captureTarget?.node_id || null;
674
+ if (captureNodeId) {
675
+ const imageEvidencePath = imageEvidenceFilePath({
457
676
  imageOutputDir,
458
677
  domain: "recruit",
459
678
  runId: runControl?.runId,
460
679
  index,
461
680
  extension: "jpg"
462
- }),
463
- format: "jpeg",
464
- quality: 72,
465
- optimize: true,
466
- resizeMaxWidth: 1100,
467
- captureViewport: false,
468
- padding: 0,
469
- maxScreenshots: maxImagePages,
470
- wheelDeltaY: imageWheelDeltaY,
471
- settleMs: 350,
472
- scrollMethod: "dom-anchor-fallback-input",
473
- stepTimeoutMs: 45000,
474
- totalTimeoutMs: 90000,
475
- duplicateStopCount: 1,
476
- skipDuplicateScreenshots: true,
477
- composeForLlm: true,
478
- llmPagesPerImage: 3,
479
- llmResizeMaxWidth: 1100,
480
- llmQuality: 72,
481
- metadata: {
482
- domain: "recruit",
483
- capture_mode: "scroll_sequence",
484
- acquisition_reason: "network_miss_image_fallback",
485
- run_candidate_index: index,
486
- candidate_key: candidateKey,
487
- capture_target: captureTarget,
488
- capture_target_wait: captureTargetWait
681
+ });
682
+ try {
683
+ detailStep = "capture_image";
684
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
685
+ filePath: imageEvidencePath,
686
+ format: "jpeg",
687
+ quality: 72,
688
+ optimize: true,
689
+ resizeMaxWidth: 1100,
690
+ captureViewport: false,
691
+ padding: 0,
692
+ maxScreenshots: maxImagePages,
693
+ wheelDeltaY: imageWheelDeltaY,
694
+ settleMs: 350,
695
+ scrollMethod: "dom-anchor-fallback-input",
696
+ stepTimeoutMs: 45000,
697
+ totalTimeoutMs: 90000,
698
+ duplicateStopCount: 1,
699
+ skipDuplicateScreenshots: true,
700
+ composeForLlm: true,
701
+ llmPagesPerImage: 3,
702
+ llmResizeMaxWidth: 1100,
703
+ llmQuality: 72,
704
+ metadata: {
705
+ domain: "recruit",
706
+ capture_mode: "scroll_sequence",
707
+ acquisition_reason: "network_miss_image_fallback",
708
+ run_candidate_index: index,
709
+ candidate_key: candidateKey,
710
+ capture_target: captureTarget,
711
+ capture_target_wait: captureTargetWait
712
+ }
713
+ }));
714
+ source = "image";
715
+ } catch (error) {
716
+ if (!isRecoverableRecruitImageCaptureError(error)) throw error;
717
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
718
+ if (recoveryCount < 1) {
719
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
720
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
721
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
722
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
723
+ await recoverAndReapplyRecruitContext(`image_capture:${detailStep}`, error, {
724
+ forceRecentViewed: true
725
+ });
726
+ continue;
727
+ }
728
+ imageEvidence = createRecoverableRecruitImageCaptureEvidence(error, {
729
+ elapsedMs: timings.screenshot_capture_ms,
730
+ filePath: imageEvidencePath,
731
+ extension: "jpg",
732
+ maxScreenshots: maxImagePages
733
+ });
734
+ source = "image_capture_failed";
489
735
  }
490
- }));
491
- source = "image";
492
- recordCvImageFallback(cvAcquisitionState, {
493
- parsedNetworkProfileCount,
494
- waitResult: networkWait,
495
- imageEvidence
496
- });
736
+ recordCvImageFallback(cvAcquisitionState, {
737
+ reason: source === "image_capture_failed"
738
+ ? "network_miss_image_capture_failed"
739
+ : "network_miss_image_fallback",
740
+ parsedNetworkProfileCount,
741
+ waitResult: networkWait,
742
+ imageEvidence
743
+ });
744
+ } else {
745
+ source = "missing_capture_node";
746
+ recordCvNetworkMiss(cvAcquisitionState, {
747
+ reason: "network_miss_no_capture_node",
748
+ parsedNetworkProfileCount,
749
+ waitResult: networkWait
750
+ });
751
+ }
752
+ }
753
+
754
+ detailResult.image_evidence = imageEvidence;
755
+ detailResult.cv_acquisition = {
756
+ source,
757
+ mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
758
+ wait_plan: waitPlan,
759
+ network_wait: networkWait,
760
+ parsed_network_profile_count: parsedNetworkProfileCount,
761
+ image_evidence: summarizeImageEvidence(imageEvidence),
762
+ capture_target: captureTarget || null,
763
+ capture_target_wait: captureTargetWait
764
+ };
765
+ screeningCandidate = detailResult.candidate;
766
+ if (closeDetail) {
767
+ detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
768
+ if (!detailResult.close_result?.closed) {
769
+ const closeError = createRecruitCloseFailureError(detailResult.close_result);
770
+ const recovery = await recoverAndReapplyRecruitContext("detail_close_failed", closeError, {
771
+ forceRecentViewed: true
772
+ });
773
+ detailResult.cv_acquisition = {
774
+ ...(detailResult.cv_acquisition || {}),
775
+ close_recovery: {
776
+ ok: Boolean(recovery.ok),
777
+ method: recovery.method || "",
778
+ forced_recent_viewed: Boolean(recovery.forced_recent_viewed),
779
+ card_count: recovery.card_count || 0
780
+ }
781
+ };
782
+ }
497
783
  } else {
498
- source = "missing_capture_node";
499
- recordCvNetworkMiss(cvAcquisitionState, {
500
- reason: "network_miss_no_capture_node",
501
- parsedNetworkProfileCount,
502
- waitResult: networkWait
784
+ detailResult.close_result = null;
785
+ }
786
+ } catch (error) {
787
+ if (!isRecoverableRecruitDetailError(error)) throw error;
788
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
789
+ if (recoveryCount < 1) {
790
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
791
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
792
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
793
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
794
+ await recoverAndReapplyRecruitContext(`detail:${detailStep}`, error, {
795
+ forceRecentViewed: true
503
796
  });
797
+ continue;
504
798
  }
799
+ recoverableDetailError = error;
800
+ detailResult = null;
801
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
802
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
505
803
  }
506
-
507
- let closeResult = null;
508
- if (closeDetail) {
509
- closeResult = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
510
- }
511
- detailResult.close_result = closeResult;
512
- detailResult.image_evidence = imageEvidence;
513
- detailResult.cv_acquisition = {
514
- source,
515
- mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
516
- wait_plan: waitPlan,
517
- network_wait: networkWait,
518
- parsed_network_profile_count: parsedNetworkProfileCount,
519
- image_evidence: summarizeImageEvidence(imageEvidence),
520
- capture_target: captureTarget || null,
521
- capture_target_wait: captureTargetWait
522
- };
523
- screeningCandidate = detailResult.candidate;
524
804
  }
525
805
 
526
806
  await runControl.waitIfPaused();
@@ -528,7 +808,9 @@ export async function runRecruitWorkflow({
528
808
  runControl.setPhase("recruit:screening");
529
809
  let llmResult = null;
530
810
  if (useLlmScreening) {
531
- if (!llmConfig) {
811
+ if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
812
+ llmResult = null;
813
+ } else if (!llmConfig) {
532
814
  llmResult = createMissingLlmConfigResult();
533
815
  } else {
534
816
  try {
@@ -550,9 +832,16 @@ export async function runRecruitWorkflow({
550
832
  }
551
833
  if (detailResult) detailResult.llm_result = llmResult;
552
834
  }
553
- const screening = useLlmScreening
554
- ? llmResultToScreening(llmResult, screeningCandidate)
555
- : screenCandidate(screeningCandidate, { criteria });
835
+ const screening = recoverableDetailError
836
+ ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
837
+ : detailResult?.image_evidence?.ok === false
838
+ ? createImageCaptureFailureScreening(screeningCandidate, {
839
+ code: detailResult.image_evidence.error_code,
840
+ message: detailResult.image_evidence.error
841
+ })
842
+ : useLlmScreening
843
+ ? llmResultToScreening(llmResult, screeningCandidate)
844
+ : screenCandidate(screeningCandidate, { criteria });
556
845
  timings.total_ms = Date.now() - candidateStarted;
557
846
  const compactResult = {
558
847
  index,
@@ -562,6 +851,14 @@ export async function runRecruitWorkflow({
562
851
  detail: compactDetail(detailResult),
563
852
  llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
564
853
  screening: compactScreening(screening),
854
+ error: recoverableDetailError
855
+ ? compactRecoverableDetailError(recoverableDetailError)
856
+ : detailResult?.image_evidence?.ok === false
857
+ ? compactError({
858
+ code: detailResult.image_evidence.error_code,
859
+ message: detailResult.image_evidence.error
860
+ }, "IMAGE_CAPTURE_FAILED")
861
+ : null,
565
862
  timings
566
863
  };
567
864
  results.push(compactResult);
@@ -572,21 +869,7 @@ export async function runRecruitWorkflow({
572
869
  }
573
870
  });
574
871
 
575
- runControl.updateProgress({
576
- card_count: cardNodeIds.length,
577
- target_count: limit,
578
- processed: results.length,
579
- screened: results.length,
580
- detail_opened: results.filter((item) => item.detail).length,
581
- passed: results.filter((item) => item.screening.passed).length,
582
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
583
- unique_seen: compactInfiniteListState(listState).seen_count,
584
- scroll_count: compactInfiniteListState(listState).scroll_count,
585
- refresh_rounds: refreshRounds,
586
- refresh_attempts: refreshAttempts.length,
587
- list_end_reason: listEndReason || null,
588
- viewport_checks: viewportGuard.getStats().checks,
589
- viewport_recoveries: viewportGuard.getStats().recoveries,
872
+ updateRecruitProgress({
590
873
  last_candidate_id: screeningCandidate.id || null,
591
874
  last_candidate_key: candidateKey,
592
875
  last_score: screening.score
@@ -603,7 +886,8 @@ export async function runRecruitWorkflow({
603
886
  passed: screening.passed,
604
887
  score: screening.score
605
888
  },
606
- llm_screening: compactScreeningLlmResult(llmResult)
889
+ llm_screening: compactScreeningLlmResult(llmResult),
890
+ error: compactResult.error
607
891
  }
608
892
  });
609
893
  addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
@@ -630,11 +914,8 @@ export async function runRecruitWorkflow({
630
914
  list_end_reason: listEndReason || null,
631
915
  refresh_rounds: refreshRounds,
632
916
  refresh_attempts: refreshAttempts,
633
- processed: results.length,
634
- screened: results.length,
635
- detail_opened: results.filter((item) => item.detail).length,
636
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
637
- passed: results.filter((item) => item.screening.passed).length,
917
+ context_recoveries: contextRecoveryAttempts,
918
+ ...countRecruitResultStatuses(results),
638
919
  results
639
920
  };
640
921
  }
@@ -722,7 +1003,11 @@ export function createRecruitRunService({
722
1003
  screened: 0,
723
1004
  detail_opened: 0,
724
1005
  llm_screened: 0,
725
- passed: 0
1006
+ passed: 0,
1007
+ image_capture_failed: 0,
1008
+ detail_open_failed: 0,
1009
+ transient_recovered: 0,
1010
+ context_recoveries: 0
726
1011
  },
727
1012
  checkpoint: {},
728
1013
  task: (runControl) => workflow({