@enricai/barnacle 1.6.9 → 1.6.11

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 (93) hide show
  1. package/README.md +71 -3
  2. package/dist/api/routes/submissions.d.ts +21 -0
  3. package/dist/api/routes/submissions.d.ts.map +1 -0
  4. package/dist/api/routes/submissions.js +48 -0
  5. package/dist/api/routes/submissions.js.map +1 -0
  6. package/dist/api/schemas/submissions.d.ts +110 -0
  7. package/dist/api/schemas/submissions.d.ts.map +1 -0
  8. package/dist/api/schemas/submissions.js +77 -0
  9. package/dist/api/schemas/submissions.js.map +1 -0
  10. package/dist/config.d.ts +34 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +5 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/lib/telemetry/beacon-capture.d.ts +37 -0
  15. package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -0
  16. package/dist/lib/telemetry/beacon-capture.js +47 -0
  17. package/dist/lib/telemetry/beacon-capture.js.map +1 -0
  18. package/dist/lib/telemetry/reconciliation-record.d.ts +105 -0
  19. package/dist/lib/telemetry/reconciliation-record.d.ts.map +1 -0
  20. package/dist/lib/telemetry/reconciliation-record.js +76 -0
  21. package/dist/lib/telemetry/reconciliation-record.js.map +1 -0
  22. package/dist/lib/telemetry/reconciliation-source.d.ts +33 -0
  23. package/dist/lib/telemetry/reconciliation-source.d.ts.map +1 -0
  24. package/dist/lib/telemetry/reconciliation-source.js +91 -0
  25. package/dist/lib/telemetry/reconciliation-source.js.map +1 -0
  26. package/dist/lib/telemetry/s3-sink.d.ts.map +1 -1
  27. package/dist/lib/telemetry/s3-sink.js +1 -2
  28. package/dist/lib/telemetry/s3-sink.js.map +1 -1
  29. package/dist/lib/telemetry/submission-capture.d.ts +30 -9
  30. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
  31. package/dist/lib/telemetry/submission-capture.js +32 -21
  32. package/dist/lib/telemetry/submission-capture.js.map +1 -1
  33. package/dist/lib/telemetry/submission-query.d.ts +32 -0
  34. package/dist/lib/telemetry/submission-query.d.ts.map +1 -0
  35. package/dist/lib/telemetry/submission-query.js +49 -0
  36. package/dist/lib/telemetry/submission-query.js.map +1 -0
  37. package/dist/lib/telemetry/submission-reader.d.ts +52 -0
  38. package/dist/lib/telemetry/submission-reader.d.ts.map +1 -0
  39. package/dist/lib/telemetry/submission-reader.js +111 -0
  40. package/dist/lib/telemetry/submission-reader.js.map +1 -0
  41. package/dist/lib/telemetry/submissions-s3-objects.d.ts +23 -0
  42. package/dist/lib/telemetry/submissions-s3-objects.d.ts.map +1 -0
  43. package/dist/lib/telemetry/submissions-s3-objects.js +132 -0
  44. package/dist/lib/telemetry/submissions-s3-objects.js.map +1 -0
  45. package/dist/lib/telemetry/submissions-s3-reader.d.ts +25 -0
  46. package/dist/lib/telemetry/submissions-s3-reader.d.ts.map +1 -0
  47. package/dist/lib/telemetry/submissions-s3-reader.js +63 -0
  48. package/dist/lib/telemetry/submissions-s3-reader.js.map +1 -0
  49. package/dist/lib/tracking-click.d.ts +13 -2
  50. package/dist/lib/tracking-click.d.ts.map +1 -1
  51. package/dist/lib/tracking-click.js +42 -4
  52. package/dist/lib/tracking-click.js.map +1 -1
  53. package/dist/plugins/loader.d.ts +17 -3
  54. package/dist/plugins/loader.d.ts.map +1 -1
  55. package/dist/plugins/loader.js +59 -6
  56. package/dist/plugins/loader.js.map +1 -1
  57. package/dist/scraper/cookie-jar.d.ts +10 -4
  58. package/dist/scraper/cookie-jar.d.ts.map +1 -1
  59. package/dist/scraper/cookie-jar.js +10 -5
  60. package/dist/scraper/cookie-jar.js.map +1 -1
  61. package/dist/scraper/deep-locator-candidates.d.ts +84 -0
  62. package/dist/scraper/deep-locator-candidates.d.ts.map +1 -0
  63. package/dist/scraper/deep-locator-candidates.js +206 -0
  64. package/dist/scraper/deep-locator-candidates.js.map +1 -0
  65. package/dist/scraper/deep-locator-fake.d.ts +116 -0
  66. package/dist/scraper/deep-locator-fake.d.ts.map +1 -0
  67. package/dist/scraper/deep-locator-fake.js +128 -0
  68. package/dist/scraper/deep-locator-fake.js.map +1 -0
  69. package/dist/scraper/flow-runner.d.ts +28 -1
  70. package/dist/scraper/flow-runner.d.ts.map +1 -1
  71. package/dist/scraper/flow-runner.js +204 -14
  72. package/dist/scraper/flow-runner.js.map +1 -1
  73. package/dist/scraper/frame-target.d.ts +60 -14
  74. package/dist/scraper/frame-target.d.ts.map +1 -1
  75. package/dist/scraper/frame-target.js +126 -38
  76. package/dist/scraper/frame-target.js.map +1 -1
  77. package/dist/scraper/stagehand-guard.d.ts +14 -0
  78. package/dist/scraper/stagehand-guard.d.ts.map +1 -1
  79. package/dist/scraper/stagehand-guard.js +28 -8
  80. package/dist/scraper/stagehand-guard.js.map +1 -1
  81. package/dist/scraper/watchdog.d.ts +38 -0
  82. package/dist/scraper/watchdog.d.ts.map +1 -0
  83. package/dist/scraper/watchdog.js +54 -0
  84. package/dist/scraper/watchdog.js.map +1 -0
  85. package/dist/scripts/recon-browser.d.ts.map +1 -1
  86. package/dist/scripts/recon-browser.js +14 -3
  87. package/dist/scripts/recon-browser.js.map +1 -1
  88. package/dist/server.d.ts.map +1 -1
  89. package/dist/server.js +2 -0
  90. package/dist/server.js.map +1 -1
  91. package/dist/site-plugin.d.ts +12 -0
  92. package/dist/site-plugin.d.ts.map +1 -1
  93. package/package.json +1 -1
@@ -87,11 +87,13 @@ const schemas_1 = require("../lib/llm/schemas");
87
87
  const logging_1 = require("../lib/logging");
88
88
  const call_capture_1 = require("../lib/telemetry/call-capture");
89
89
  const call_types_1 = require("../lib/telemetry/call-types");
90
+ const deep_locator_candidates_1 = require("../scraper/deep-locator-candidates");
90
91
  const errors_2 = require("../scraper/errors");
91
92
  const frame_target_1 = require("../scraper/frame-target");
92
93
  const phantom_click_1 = require("../scraper/phantom-click");
93
94
  const stagehand_guard_1 = require("../scraper/stagehand-guard");
94
95
  const submit_control_1 = require("../scraper/submit-control");
96
+ const watchdog_1 = require("../scraper/watchdog");
95
97
  const recon_shared_1 = require("../scripts/recon-shared");
96
98
  const logger = (0, logging_1.getLogger)({ name: "scraper/flow-runner" });
97
99
  /** Cap on the rolling capture-filename window held in memory for failure dumps. */
@@ -521,9 +523,12 @@ async function snapshotPage(target, signalCounter) {
521
523
  * body HTML and url instead of pairing a child frame's DOM with the top
522
524
  * document's url. `title()` intentionally still reads the top document for
523
525
  * a child frame (see `frame-target.ts`); `url()` is the frame discriminator.
526
+ * Routed through `mainFrameTarget(page)` (rather than a raw `page.title()`)
527
+ * when no `frameTarget` is resolved so this CDP read is watchdog-bounded the
528
+ * same way a resolved `FrameTarget`'s `title()` already is.
524
529
  */
525
530
  async function resolveDumpPageIdentity(page, frameTarget) {
526
- const pageTitle = await (frameTarget ?? page).title().catch(() => "");
531
+ const pageTitle = await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).title().catch(() => "");
527
532
  const pageUrl = await (frameTarget ? frameTarget.url() : Promise.resolve(page.url())).catch(() => page.url());
528
533
  return { pageTitle, pageUrl };
529
534
  }
@@ -4408,6 +4413,28 @@ async function probeFormValidityBeforeSubmit(params) {
4408
4413
  return [];
4409
4414
  }
4410
4415
  }
4416
+ /**
4417
+ * Adapts `resolveDeepLocatorCandidates` results into `Action`-shaped
4418
+ * evidence for `rephraseWithLLM`, which only knows about Stagehand's
4419
+ * `Action` type. Never throws: `resolveDeepLocatorCandidates` itself
4420
+ * degrades to `[]` on a resolver failure, so this only feeds the rephrase
4421
+ * prompt richer evidence when available — a frame-scoped step whose
4422
+ * observe AND deepLocator both come back empty just gets the same `[]`
4423
+ * evidence it would have gotten before this fix.
4424
+ *
4425
+ * `instruction` is forwarded to `resolveDeepLocatorCandidates` so the
4426
+ * evidence list is ranked by relevance to the step, same as the act path —
4427
+ * an unranked `[]`-then-DOM-order list would feed the rephrase LLM its
4428
+ * worst evidence first instead of its best.
4429
+ */
4430
+ async function deepLocatorCandidatesAsActions(page, frameSelector, instruction) {
4431
+ const candidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, "*", instruction);
4432
+ return candidates.map((c) => ({
4433
+ selector: c.selector,
4434
+ description: c.accessibleText || "(no accessible text)",
4435
+ method: "click",
4436
+ }));
4437
+ }
4411
4438
  /**
4412
4439
  * Cheap pre-cascade reachability gate. Runs before the 5-attempt healing cascade
4413
4440
  * (and any global replan) so a step aimed at the wrong page state fails fast
@@ -4419,10 +4446,16 @@ async function probeFormValidityBeforeSubmit(params) {
4419
4446
  *
4420
4447
  * `frameTarget` scopes both observe calls to a resolved cross-origin child
4421
4448
  * frame when the flow declared `frameSelector`; omitted (main frame) is
4422
- * byte-identical to today's unscoped calls.
4449
+ * byte-identical to today's unscoped calls. When `frameTarget.frame` is a
4450
+ * resolved child frame, `observe()` is blind to it (measured against a
4451
+ * cross-origin OOPIF — see `deep-locator-candidates.ts`'s module docblock),
4452
+ * so a 0-candidate focused+unfocused observe pair additionally falls back to
4453
+ * `resolveDeepLocatorCandidates` before declaring the step "absent" — a
4454
+ * frame-scoped step must not short-circuit to replan before the cascade
4455
+ * (which itself now routes through the same resolver) ever runs.
4423
4456
  */
4424
4457
  async function probeStepBeforeAttempts(params) {
4425
- const { stagehand, step, stepIndex, totalSteps, logger, captureFn, frameTarget } = params;
4458
+ const { stagehand, page, step, stepIndex, totalSteps, logger, captureFn, frameTarget, reresolveFrameTarget, } = params;
4426
4459
  try {
4427
4460
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
4428
4461
  if (candidates.length === 0) {
@@ -4442,6 +4475,16 @@ async function probeStepBeforeAttempts(params) {
4442
4475
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
4443
4476
  return "present";
4444
4477
  }
4478
+ const effectiveFrameTarget = reresolveFrameTarget
4479
+ ? await reresolveFrameTarget()
4480
+ : frameTarget;
4481
+ if (effectiveFrameTarget?.frame) {
4482
+ const deepLocatorCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, effectiveFrameTarget.frameSelector, "*");
4483
+ if (deepLocatorCandidates.length > 0) {
4484
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: observe found 0 candidates (focused and unfocused) but deepLocator found ${deepLocatorCandidates.length} — treating as present (let cascade resolve)`);
4485
+ return "present";
4486
+ }
4487
+ }
4445
4488
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found 0 candidates (focused and unfocused) — treating as absent (skip cascade, route to replan if required)`);
4446
4489
  return "absent";
4447
4490
  }
@@ -4456,7 +4499,45 @@ async function probeStepBeforeAttempts(params) {
4456
4499
  }
4457
4500
  }
4458
4501
  async function executeStepWithHealing(params) {
4459
- const { stagehand, page, frameTarget, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
4502
+ const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
4503
+ // Mutable (not the destructured const above) so a lost frame-attach race
4504
+ // can be upgraded in place once the OOPIF attaches later in the cascade —
4505
+ // see reresolveFrameTargetIfLost below. Every existing reference in this
4506
+ // function keeps reading `frameTarget` unchanged; only its declaration
4507
+ // moved so it can be reassigned.
4508
+ let frameTarget = params.frameTarget;
4509
+ let frameReresolveAttempted = false;
4510
+ /**
4511
+ * Re-resolves a frame that lost the attach race at step entry —
4512
+ * `resolveFrameTarget`'s per-step poll (called once in the runner's step
4513
+ * loop, before `executeStepWithHealing` even starts) gives up and pins
4514
+ * `frameTarget.frame` to `null` for the rest of the step, so every
4515
+ * deepLocator gate below tests a stale "unresolved" handle even after the
4516
+ * OOPIF actually attaches moments later (the run 5 vs. run 6 divergence in
4517
+ * the bug report). Called just before each gate; re-checks with
4518
+ * `timeoutMs: 0` — a single presence probe, no added poll delay — so a
4519
+ * frame that has since attached is picked up without making the deadline
4520
+ * that already elapsed matter again. Memoized (`frameReresolveAttempted`)
4521
+ * so the cascade's several gates share one attempt instead of each
4522
+ * re-checking; a target with no `declaredFrameSelector` (the frame was
4523
+ * never declared, or resolution already succeeded) returns immediately
4524
+ * without calling `resolveFrameTarget` at all, so main-frame-only flows do
4525
+ * zero extra work.
4526
+ */
4527
+ const reresolveFrameTargetIfLost = async () => {
4528
+ if (frameReresolveAttempted)
4529
+ return;
4530
+ frameReresolveAttempted = true;
4531
+ if (frameTarget?.frame || !frameTarget?.declaredFrameSelector)
4532
+ return;
4533
+ const reresolved = await (0, frame_target_1.resolveFrameTarget)(page, frameTarget.declaredFrameSelector, {
4534
+ timeoutMs: 0,
4535
+ });
4536
+ if (!reresolved.frame)
4537
+ return;
4538
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} frame ${frameTarget.declaredFrameSelector} attached after step entry — re-resolved before deepLocator gate`);
4539
+ frameTarget = reresolved;
4540
+ };
4460
4541
  // Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
4461
4542
  // through executeStepWithHealing's signature so the cascade has it in
4462
4543
  // scope when the invalid-fields judge migration (Task #43) lands. The
@@ -4587,12 +4668,17 @@ async function executeStepWithHealing(params) {
4587
4668
  // burning 4 attempts on a page that clearly isn't the right one.
4588
4669
  const probeResult = await probeStepBeforeAttempts({
4589
4670
  stagehand,
4671
+ page,
4590
4672
  step,
4591
4673
  stepIndex,
4592
4674
  totalSteps,
4593
4675
  logger,
4594
4676
  captureFn,
4595
4677
  frameTarget,
4678
+ reresolveFrameTarget: async () => {
4679
+ await reresolveFrameTargetIfLost();
4680
+ return frameTarget;
4681
+ },
4596
4682
  });
4597
4683
  if (probeResult === "absent") {
4598
4684
  if (optional) {
@@ -4650,7 +4736,16 @@ async function executeStepWithHealing(params) {
4650
4736
  .evaluate("document.body ? document.body.outerHTML : null")
4651
4737
  .catch(() => null);
4652
4738
  const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
4653
- const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
4739
+ const probeAbsentObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
4740
+ // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
4741
+ // result degrades to the deep-locator resolver for dump evidence — this
4742
+ // dump feeds replanRemainingFlow's diagnostic prompt, and an empty
4743
+ // candidate list there returns "repeat the failed step", burning the
4744
+ // replan budget on every frame-scoped probe-absent failure.
4745
+ await reresolveFrameTargetIfLost();
4746
+ const unfocusedObserve = probeAbsentObservedUnfocused.length === 0 && frameTarget?.frame
4747
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
4748
+ : probeAbsentObservedUnfocused;
4654
4749
  const dumpPath = onStepFailure?.({
4655
4750
  stepIndex,
4656
4751
  phase,
@@ -4973,7 +5068,59 @@ async function executeStepWithHealing(params) {
4973
5068
  ? { ignoreSelectors: [...triedSelectors], timeout: exports.STEP_WATCHDOG_MS }
4974
5069
  : { timeout: exports.STEP_WATCHDOG_MS };
4975
5070
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn, frameTarget);
4976
- if (candidates.length === 0) {
5071
+ // observe() is blind to a cross-origin OOPIF (see
5072
+ // deep-locator-candidates.ts's module docblock) — when the step is
5073
+ // frame-scoped and observe came back empty, fall back to the deep
5074
+ // locator resolver before declaring the attempt candidate-less.
5075
+ // Passing `step` ranks candidates by relevance to the instruction
5076
+ // (see resolveDeepLocatorCandidates) so the top pick below is the
5077
+ // element the step actually names, not just DOM order.
5078
+ // Attempt 4's ignoreSelectors has no deepLocator equivalent, so the
5079
+ // exclusion is applied here by filtering resolved (already-ranked)
5080
+ // candidates against triedSelectors instead — otherwise attempt 4
5081
+ // would re-pick the same failed element and burn the attempt.
5082
+ await reresolveFrameTargetIfLost();
5083
+ const deepLocatorCandidates = candidates.length === 0 && frameTarget?.frame
5084
+ ? (await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameTarget.frameSelector, "*", step)).filter((c) => !triedSelectors.includes(c.selector))
5085
+ : [];
5086
+ if (candidates.length === 0 && deepLocatorCandidates.length > 0) {
5087
+ const top = deepLocatorCandidates[0];
5088
+ if (top) {
5089
+ record.instruction = `deepLocator: ${top.accessibleText || "(no accessible text)"}`;
5090
+ // Deny-list guard: mirrors the observe branch's refusal below —
5091
+ // never act on a wizard-exit control regardless of which
5092
+ // candidate source (observe vs. deepLocator) surfaced it.
5093
+ if (isWizardExitAction(top.accessibleText, wizardExitButtonLabels)) {
5094
+ record.errorMessage = `refused wizard-exit control: "${top.accessibleText.slice(0, 60)}"`;
5095
+ triedSelectors.push(top.selector);
5096
+ record.triedSelectors = [top.selector];
5097
+ attempts.push(record);
5098
+ failureReasons.push(record.errorMessage);
5099
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
5100
+ continue;
5101
+ }
5102
+ triedSelectors.push(top.selector);
5103
+ record.triedSelectors = [top.selector];
5104
+ try {
5105
+ await (0, deep_locator_candidates_1.clickDeepLocatorCandidate)(page, frameTarget?.frameSelector, "*", top.index);
5106
+ record.actResultSuccess = true;
5107
+ record.actResultDescription = `deepLocator clicked "${top.accessibleText || top.selector}"`;
5108
+ // Synthesize a click action so downstream verification (network
5109
+ // / url / dom) treats this exactly like any other resolved
5110
+ // click — same idiom as deep-submit-locator/structured-click.
5111
+ resolvedAction = {
5112
+ selector: top.selector,
5113
+ description: record.actResultDescription,
5114
+ method: "click",
5115
+ };
5116
+ }
5117
+ catch (err) {
5118
+ record.actResultSuccess = false;
5119
+ record.errorMessage = `deepLocator: click threw ${(0, errors_1.toErrorMessage)(err)}`;
5120
+ }
5121
+ }
5122
+ }
5123
+ else if (candidates.length === 0) {
4977
5124
  record.errorMessage = "observe returned no candidates";
4978
5125
  // Optional-step short-circuit: when attempt 2 confirms no candidates
4979
5126
  // match AND the step was marked optional in the flow, skip cleanly.
@@ -5240,7 +5387,16 @@ async function executeStepWithHealing(params) {
5240
5387
  "anthropic billing exhausted (FATAL_BILLING already logged); skipping rephrase";
5241
5388
  }
5242
5389
  else {
5243
- const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5390
+ const observedCandidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5391
+ // observe() is blind to a cross-origin OOPIF, so a frame-scoped
5392
+ // empty result degrades to the deep-locator resolver for prompt
5393
+ // evidence rather than hard-failing — lower stakes than the
5394
+ // attempt-2/4 click path since this only feeds the rephrase
5395
+ // prompt, so a resolver error/empty result is fine to swallow.
5396
+ await reresolveFrameTargetIfLost();
5397
+ const candidates = observedCandidates.length === 0 && frameTarget?.frame
5398
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector, step)
5399
+ : observedCandidates;
5244
5400
  // Fetch live-page evidence so the rephrase prompt can reason about
5245
5401
  // form state, not just observe candidates. Mirrors the same
5246
5402
  // extraction the cascade-exhaust dump path already does.
@@ -5255,7 +5411,10 @@ async function executeStepWithHealing(params) {
5255
5411
  // frameTarget: for a frame-scoped flow the ambient UI that matters
5256
5412
  // (the wizard's own Save/Close controls) lives inside the iframe
5257
5413
  // alongside the failed step, not in the top document.
5258
- const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5414
+ const observedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5415
+ const unfocused = observedUnfocused.length === 0 && frameTarget?.frame
5416
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
5417
+ : observedUnfocused;
5259
5418
  const submitFailureList = extractSubmitFailureEvidence(recentCaptures, ownBackendHostnames);
5260
5419
  const gaEventList = extractGaEventEvidence(recentCaptures);
5261
5420
  const priorAttemptsForPrompt = attempts.map((a, i) => ({
@@ -5431,7 +5590,10 @@ async function executeStepWithHealing(params) {
5431
5590
  // structural ng-invalid containers is not fuzzy matching, just
5432
5591
  // observing existence).
5433
5592
  const invalidMarkerCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0);
5434
- const pageTitle = await page.title().catch(() => "");
5593
+ const pageTitle = await (0, watchdog_1.withWatchdog)(() => page.title(), {
5594
+ timeoutMs: config_1.config.scraper.frameEvaluateTimeoutMs,
5595
+ label: "flow-runner: submit-verification page title probe",
5596
+ }).catch(() => "");
5435
5597
  const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
5436
5598
  const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
5437
5599
  client: anthropic,
@@ -5664,7 +5826,10 @@ async function executeStepWithHealing(params) {
5664
5826
  }
5665
5827
  const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5666
5828
  const invalidMarkerCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0);
5667
- const pageTitle = await page.title().catch(() => "");
5829
+ const pageTitle = await (0, watchdog_1.withWatchdog)(() => page.title(), {
5830
+ timeoutMs: config_1.config.scraper.frameEvaluateTimeoutMs,
5831
+ label: "flow-runner: submit-verification retry page title probe",
5832
+ }).catch(() => "");
5668
5833
  const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
5669
5834
  const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
5670
5835
  client: anthropic,
@@ -5862,16 +6027,28 @@ async function executeStepWithHealing(params) {
5862
6027
  }
5863
6028
  }
5864
6029
  }
5865
- const finalObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
6030
+ const cascadeExhaustObservedFinal = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
6031
+ // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
6032
+ // result degrades to the deep-locator resolver for dump evidence — this
6033
+ // dump feeds replanRemainingFlow's diagnostic prompt, and an empty
6034
+ // candidate list there returns "repeat the failed step", burning the
6035
+ // replan budget on every frame-scoped cascade-exhaust failure.
6036
+ await reresolveFrameTargetIfLost();
6037
+ const finalObserve = cascadeExhaustObservedFinal.length === 0 && frameTarget?.frame
6038
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector, step)
6039
+ : cascadeExhaustObservedFinal;
5866
6040
  const { pageTitle, pageUrl } = await resolveDumpPageIdentity(page, frameTarget);
5867
6041
  // Discriminator data for "Stagehand sees nothing" failures: capture the raw
5868
6042
  // DOM and an unfocused observe so a triager can tell empty-page from
5869
6043
  // Stagehand-can't-see-it without reproducing the failure.
5870
- const bodyOuterHtmlRaw = await (frameTarget ?? page)
6044
+ const bodyOuterHtmlRaw = await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
5871
6045
  .evaluate("document.body ? document.body.outerHTML : null")
5872
6046
  .catch(() => null);
5873
6047
  const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
5874
- const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
6048
+ const cascadeExhaustObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
6049
+ const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
6050
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
6051
+ : cascadeExhaustObservedUnfocused;
5875
6052
  const dumpPath = onStepFailure?.({
5876
6053
  stepIndex,
5877
6054
  phase,
@@ -5902,6 +6079,16 @@ const SPA_MIN_BODY_LENGTH = 5_000;
5902
6079
  * the entire flow. The recon CLI has this gate inline; generated plugins call it
5903
6080
  * here so they inherit the same behavior. Polls `document.body.outerHTML.length`
5904
6081
  * up to a threshold, then proceeds regardless (best-effort, never throws).
6082
+ *
6083
+ * Each `document.body.outerHTML.length` read is itself bounded by a watchdog
6084
+ * (capped to one poll interval) and treated as "still 0 chars" on timeout —
6085
+ * same pattern as `waitForChildFrameReady`'s `isReady()` probe. Without this,
6086
+ * a single wedged CDP round-trip inside `readBodyLength` would pend forever
6087
+ * and the `while (Date.now() < deadline)` loop below would never be
6088
+ * re-entered, defeating the very deadline it exists to enforce.
6089
+ * `page.waitForTimeout(pollMs)` is left unguarded: it is a plain delay (no
6090
+ * DOM/network read whose response can be lost), so it does not carry the
6091
+ * "wedged read" failure mode this fix targets.
5905
6092
  */
5906
6093
  async function waitForSpaReady(page, logger, opts = {}) {
5907
6094
  const timeoutMs = opts.timeoutMs ?? SPA_READINESS_TIMEOUT_MS;
@@ -5909,7 +6096,10 @@ async function waitForSpaReady(page, logger, opts = {}) {
5909
6096
  const minBodyLength = opts.minBodyLength ?? SPA_MIN_BODY_LENGTH;
5910
6097
  const bodyLengthExpr = "document.body ? document.body.outerHTML.length : 0";
5911
6098
  const readBodyLength = async () => {
5912
- const raw = await page.evaluate(bodyLengthExpr).catch(() => 0);
6099
+ const raw = await (0, watchdog_1.withWatchdog)(() => page.evaluate(bodyLengthExpr), {
6100
+ timeoutMs: pollMs,
6101
+ label: "flow-runner: spa readiness body-length probe",
6102
+ }).catch(() => 0);
5913
6103
  return typeof raw === "number" ? raw : 0;
5914
6104
  };
5915
6105
  let bodyLength = await readBodyLength();