@enricai/barnacle 1.6.1 → 1.6.2
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.
- package/README.md +13 -11
- package/dist/scraper/deep-query.d.ts +36 -0
- package/dist/scraper/deep-query.d.ts.map +1 -0
- package/dist/scraper/deep-query.js +87 -0
- package/dist/scraper/deep-query.js.map +1 -0
- package/dist/scraper/errors.d.ts +11 -2
- package/dist/scraper/errors.d.ts.map +1 -1
- package/dist/scraper/errors.js +10 -1
- package/dist/scraper/errors.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +55 -4
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +240 -50
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/phantom-click.d.ts +45 -0
- package/dist/scraper/phantom-click.d.ts.map +1 -0
- package/dist/scraper/phantom-click.js +37 -0
- package/dist/scraper/phantom-click.js.map +1 -0
- package/dist/scraper/session-browserbase.d.ts +48 -0
- package/dist/scraper/session-browserbase.d.ts.map +1 -1
- package/dist/scraper/session-browserbase.js +10 -5
- package/dist/scraper/session-browserbase.js.map +1 -1
- package/dist/scraper/session-shared.d.ts +6 -0
- package/dist/scraper/session-shared.d.ts.map +1 -1
- package/dist/scraper/session-shared.js.map +1 -1
- package/dist/scraper/submit-control.d.ts +51 -0
- package/dist/scraper/submit-control.d.ts.map +1 -0
- package/dist/scraper/submit-control.js +142 -0
- package/dist/scraper/submit-control.js.map +1 -0
- package/dist/scripts/recon-browser.d.ts +23 -18
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +51 -42
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +28 -7
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +50 -24
- package/dist/scripts/recon-generate.js.map +1 -1
- package/dist/scripts/recon-http.d.ts +36 -5
- package/dist/scripts/recon-http.d.ts.map +1 -1
- package/dist/scripts/recon-http.js +77 -47
- package/dist/scripts/recon-http.js.map +1 -1
- package/dist/scripts/recon-replay-jobs.d.ts +25 -1
- package/dist/scripts/recon-replay-jobs.d.ts.map +1 -1
- package/dist/scripts/recon-replay-jobs.js +33 -23
- package/dist/scripts/recon-replay-jobs.js.map +1 -1
- package/dist/scripts/recon-shared.d.ts +35 -0
- package/dist/scripts/recon-shared.d.ts.map +1 -1
- package/dist/scripts/recon-shared.js +92 -0
- package/dist/scripts/recon-shared.js.map +1 -1
- package/dist/scripts/recon-summarize.d.ts +5 -1
- package/dist/scripts/recon-summarize.d.ts.map +1 -1
- package/dist/scripts/recon-summarize.js +19 -7
- package/dist/scripts/recon-summarize.js.map +1 -1
- package/package.json +1 -1
|
@@ -60,6 +60,7 @@ exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
|
|
|
60
60
|
exports.buildRadioIdXPath = buildRadioIdXPath;
|
|
61
61
|
exports.selectRadioGroupOption = selectRadioGroupOption;
|
|
62
62
|
exports.narrowInvalidFormControl = narrowInvalidFormControl;
|
|
63
|
+
exports.formatStepPrefix = formatStepPrefix;
|
|
63
64
|
exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
|
|
64
65
|
exports.executeStepWithHealing = executeStepWithHealing;
|
|
65
66
|
exports.waitForSpaReady = waitForSpaReady;
|
|
@@ -81,7 +82,9 @@ const logging_1 = require("../lib/logging");
|
|
|
81
82
|
const call_capture_1 = require("../lib/telemetry/call-capture");
|
|
82
83
|
const call_types_1 = require("../lib/telemetry/call-types");
|
|
83
84
|
const errors_2 = require("../scraper/errors");
|
|
85
|
+
const phantom_click_1 = require("../scraper/phantom-click");
|
|
84
86
|
const stagehand_guard_1 = require("../scraper/stagehand-guard");
|
|
87
|
+
const submit_control_1 = require("../scraper/submit-control");
|
|
85
88
|
const recon_shared_1 = require("../scripts/recon-shared");
|
|
86
89
|
const logger = (0, logging_1.getLogger)({ name: "scraper/flow-runner" });
|
|
87
90
|
/** Cap on the rolling capture-filename window held in memory for failure dumps. */
|
|
@@ -730,7 +733,7 @@ function windowHasTransitionBody(params) {
|
|
|
730
733
|
const { preIdx, advanceTransitionBodyPattern } = params;
|
|
731
734
|
if (!advanceTransitionBodyPattern)
|
|
732
735
|
return false;
|
|
733
|
-
const capturesDir = params.capturesDir ?? recon_shared_1.
|
|
736
|
+
const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
|
|
734
737
|
let rx;
|
|
735
738
|
try {
|
|
736
739
|
rx = new RegExp(advanceTransitionBodyPattern);
|
|
@@ -772,7 +775,7 @@ function windowHasAdvanceTransition(params) {
|
|
|
772
775
|
const { preIdx, advanceTransitionBodyPattern } = params;
|
|
773
776
|
if (!advanceTransitionBodyPattern)
|
|
774
777
|
return false;
|
|
775
|
-
const capturesDir = params.capturesDir ?? recon_shared_1.
|
|
778
|
+
const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
|
|
776
779
|
let rx;
|
|
777
780
|
try {
|
|
778
781
|
rx = new RegExp(advanceTransitionBodyPattern);
|
|
@@ -921,7 +924,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
|
|
|
921
924
|
* keeps healing opportunistically.
|
|
922
925
|
*/
|
|
923
926
|
function shouldSkipTechnique(params) {
|
|
924
|
-
const { technique, priorAttempts, advanceUnmovedAfterAttempt1 } = params;
|
|
927
|
+
const { technique, priorAttempts, advanceUnmovedAfterAttempt1, phantomClickAfterAttempt1 } = params;
|
|
925
928
|
// Unmoved-advance short-circuit (measured: attempts 2-4 recovered a stuck
|
|
926
929
|
// advance 0 times in 289 steps). When attempt-1's act-string clicked the Next
|
|
927
930
|
// and the wizard did NOT move forward (non-advancing POST, or no effect),
|
|
@@ -942,6 +945,25 @@ function shouldSkipTechnique(params) {
|
|
|
942
945
|
reason: "advance step did not move the wizard on attempt 1; re-observe/re-click cannot advance it — skipping to rephrase/replan",
|
|
943
946
|
};
|
|
944
947
|
}
|
|
948
|
+
// Phantom-click short-circuit: attempt 1 clicked something Stagehand
|
|
949
|
+
// believes exists, but pre/post shows zero network, zero URL change, and
|
|
950
|
+
// no real DOM growth — the click almost certainly landed on nothing (the
|
|
951
|
+
// recon-submit-phantom-click bug report's light-DOM resolver can't see
|
|
952
|
+
// into a shadow root / web component). Repeating observe-act /
|
|
953
|
+
// structured-click / observe-act-exclude re-resolves the SAME
|
|
954
|
+
// light-DOM-only view of the page and would no-op identically, so skip
|
|
955
|
+
// straight to deep-submit-locator (attempt 2) instead. llm-rephrase
|
|
956
|
+
// (attempt 5) is never skipped — a differently-worded instruction is still
|
|
957
|
+
// a distinct attempt worth trying if the deep locator also fails.
|
|
958
|
+
if (phantomClickAfterAttempt1 === true &&
|
|
959
|
+
(technique === "observe-act" ||
|
|
960
|
+
technique === "structured-click" ||
|
|
961
|
+
technique === "observe-act-exclude")) {
|
|
962
|
+
return {
|
|
963
|
+
skip: true,
|
|
964
|
+
reason: "attempt 1 was a phantom click (reported success, zero observable effect); re-observe/re-click cannot reach a target the light-DOM resolver can't see — escalating to the deep submit-control locator",
|
|
965
|
+
};
|
|
966
|
+
}
|
|
945
967
|
if (technique === "structured-click") {
|
|
946
968
|
const anyXpathResolved = priorAttempts.some((a) => a.triedSelectors.length > 0);
|
|
947
969
|
if (!anyXpathResolved) {
|
|
@@ -1710,8 +1732,9 @@ async function extractInteractiveTargetsNearInvalid(page) {
|
|
|
1710
1732
|
/**
|
|
1711
1733
|
* Scan recent capture files for failed submit-endpoint requests and pull
|
|
1712
1734
|
* out structured field-level errors from the response body. The cascade
|
|
1713
|
-
* already saves every captured request to
|
|
1714
|
-
*
|
|
1735
|
+
* already saves every captured request to the run's graphql capture dir
|
|
1736
|
+
* (see {@link resolveReconRunDir}) with its parsed `responseBody`; this
|
|
1737
|
+
* helper reads those files back, filters to captures
|
|
1715
1738
|
* matching the configured submit pattern with status >= 400, and walks
|
|
1716
1739
|
* common error-shape conventions (`{ errors: [{ field, message }] }`,
|
|
1717
1740
|
* `{ validation/fieldErrors: { … } }`, `{ message }`).
|
|
@@ -1728,7 +1751,7 @@ function extractSubmitFailureEvidence(recentCaptureFilenames,
|
|
|
1728
1751
|
* deterministic hostname equality. Empty list / "any-4xx" mode disables
|
|
1729
1752
|
* the host filter and returns any 4xx in the window.
|
|
1730
1753
|
*/
|
|
1731
|
-
ownBackendHostnames, capturesDir = recon_shared_1.
|
|
1754
|
+
ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir, mode = "strict") {
|
|
1732
1755
|
if (recentCaptureFilenames.length === 0)
|
|
1733
1756
|
return "";
|
|
1734
1757
|
if (mode === "strict" && ownBackendHostnames.length === 0)
|
|
@@ -1819,7 +1842,7 @@ ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict")
|
|
|
1819
1842
|
* Returns a numbered evidence list. Empty string when no GA collect
|
|
1820
1843
|
* captures are present. Advisory — never load-bearing.
|
|
1821
1844
|
*/
|
|
1822
|
-
function extractGaEventEvidence(recentCaptureFilenames, capturesDir = recon_shared_1.
|
|
1845
|
+
function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir) {
|
|
1823
1846
|
if (recentCaptureFilenames.length === 0)
|
|
1824
1847
|
return "";
|
|
1825
1848
|
const records = [];
|
|
@@ -4284,8 +4307,23 @@ function narrowInvalidFormControl(entry) {
|
|
|
4284
4307
|
autoFilled: narrowedAutoFilled,
|
|
4285
4308
|
};
|
|
4286
4309
|
}
|
|
4310
|
+
/**
|
|
4311
|
+
* Single source of truth for step-line prefixes. Exists because the cascade
|
|
4312
|
+
* here has only ever received `stepIndex`, while the orchestrator loop in
|
|
4313
|
+
* recon-browser owns the plan array — so half the step lines in a run printed
|
|
4314
|
+
* a `N/total` denominator and half printed a bare `N`.
|
|
4315
|
+
*
|
|
4316
|
+
* Takes a getter rather than a number: a global replan splices new steps into
|
|
4317
|
+
* the live plan array mid-run, so the total must be read when the line is
|
|
4318
|
+
* emitted, not when the step started. Callers with no total omit it and get
|
|
4319
|
+
* the bare form.
|
|
4320
|
+
*/
|
|
4321
|
+
function formatStepPrefix(stepIndex, totalSteps) {
|
|
4322
|
+
const total = totalSteps?.();
|
|
4323
|
+
return total === undefined ? `step ${stepIndex + 1}` : `step ${stepIndex + 1}/${total}`;
|
|
4324
|
+
}
|
|
4287
4325
|
async function probeFormValidityBeforeSubmit(params) {
|
|
4288
|
-
const { page, stepIndex, logger } = params;
|
|
4326
|
+
const { page, stepIndex, totalSteps, logger } = params;
|
|
4289
4327
|
try {
|
|
4290
4328
|
const raw = await page.evaluate(FORM_VALIDITY_PROBE_EXPR);
|
|
4291
4329
|
if (!Array.isArray(raw))
|
|
@@ -4299,15 +4337,15 @@ async function probeFormValidityBeforeSubmit(params) {
|
|
|
4299
4337
|
}
|
|
4300
4338
|
const autoCount = out.filter((e) => e.autoFilled !== null).length;
|
|
4301
4339
|
if (out.length > 0) {
|
|
4302
|
-
logger.info(
|
|
4340
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe: ${out.length} ng-invalid form control(s) detected; empty=${out.filter((e) => e.emptyOrUnchecked).length}; auto-picked=${autoCount}`);
|
|
4303
4341
|
}
|
|
4304
4342
|
else {
|
|
4305
|
-
logger.info(
|
|
4343
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe: no ng-invalid form controls detected`);
|
|
4306
4344
|
}
|
|
4307
4345
|
return out;
|
|
4308
4346
|
}
|
|
4309
4347
|
catch (err) {
|
|
4310
|
-
logger.warn(
|
|
4348
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe threw: ${(0, errors_1.toErrorMessage)(err)} — proceeding without pre-flight evidence`);
|
|
4311
4349
|
return [];
|
|
4312
4350
|
}
|
|
4313
4351
|
}
|
|
@@ -4321,7 +4359,7 @@ async function probeFormValidityBeforeSubmit(params) {
|
|
|
4321
4359
|
* step is declared "absent". Exported for tests.
|
|
4322
4360
|
*/
|
|
4323
4361
|
async function probeStepBeforeAttempts(params) {
|
|
4324
|
-
const { stagehand, step, stepIndex, logger, captureFn } = params;
|
|
4362
|
+
const { stagehand, step, stepIndex, totalSteps, logger, captureFn } = params;
|
|
4325
4363
|
try {
|
|
4326
4364
|
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
|
|
4327
4365
|
if (candidates.length === 0) {
|
|
@@ -4338,24 +4376,24 @@ async function probeStepBeforeAttempts(params) {
|
|
|
4338
4376
|
// where the unfocused observe is also empty — stay "absent".
|
|
4339
4377
|
const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
|
|
4340
4378
|
if (unfocused.length > 0) {
|
|
4341
|
-
logger.info(
|
|
4379
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
|
|
4342
4380
|
return "present";
|
|
4343
4381
|
}
|
|
4344
|
-
logger.info(
|
|
4382
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found 0 candidates (focused and unfocused) — treating as absent (skip cascade, route to replan if required)`);
|
|
4345
4383
|
return "absent";
|
|
4346
4384
|
}
|
|
4347
|
-
logger.info(
|
|
4385
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found ${candidates.length} candidate(s)`);
|
|
4348
4386
|
return "present";
|
|
4349
4387
|
}
|
|
4350
4388
|
catch (err) {
|
|
4351
4389
|
// Bias toward the existing behavior on errors: don't trigger a spurious
|
|
4352
4390
|
// replan when the probe itself is the broken thing.
|
|
4353
|
-
logger.warn(
|
|
4391
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)}: probe threw ${(0, errors_1.toErrorMessage)(err)} — treating as present (cascade will run)`);
|
|
4354
4392
|
return "present";
|
|
4355
4393
|
}
|
|
4356
4394
|
}
|
|
4357
4395
|
async function executeStepWithHealing(params) {
|
|
4358
|
-
const { stagehand, page, step, optional, upload, submitStep, stepIndex, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, trajectory, onStepFailure, } = params;
|
|
4396
|
+
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;
|
|
4359
4397
|
// Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
|
|
4360
4398
|
// through executeStepWithHealing's signature so the cascade has it in
|
|
4361
4399
|
// scope when the invalid-fields judge migration (Task #43) lands. The
|
|
@@ -4395,7 +4433,7 @@ async function executeStepWithHealing(params) {
|
|
|
4395
4433
|
signalCounter,
|
|
4396
4434
|
recentCaptureMeta,
|
|
4397
4435
|
})) {
|
|
4398
|
-
logger.info(
|
|
4436
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
|
|
4399
4437
|
trajectory?.push({ stepIndex, verifiedBy: "network" });
|
|
4400
4438
|
return "completed";
|
|
4401
4439
|
}
|
|
@@ -4406,7 +4444,7 @@ async function executeStepWithHealing(params) {
|
|
|
4406
4444
|
// question unanswered. No-op (returns false → falls through) when the step
|
|
4407
4445
|
// isn't a single-dropdown select or no option matches.
|
|
4408
4446
|
if (await trySelectPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4409
|
-
logger.info(
|
|
4447
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
|
|
4410
4448
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4411
4449
|
return "completed";
|
|
4412
4450
|
}
|
|
@@ -4416,7 +4454,7 @@ async function executeStepWithHealing(params) {
|
|
|
4416
4454
|
// trySelectPrimitive (which handles <select> and no-ops on checkbox-only
|
|
4417
4455
|
// pages). No-op (falls through) when there's no checkbox group or no match.
|
|
4418
4456
|
if (await tryCheckboxPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4419
|
-
logger.info(
|
|
4457
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
|
|
4420
4458
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4421
4459
|
return "completed";
|
|
4422
4460
|
}
|
|
@@ -4427,7 +4465,7 @@ async function executeStepWithHealing(params) {
|
|
|
4427
4465
|
// React controlled state (the HCA Basic-Info Step-2 wall). No-op (falls
|
|
4428
4466
|
// through) when there's no radio group or no confident option match.
|
|
4429
4467
|
if (await tryRadioPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4430
|
-
logger.info(
|
|
4468
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
|
|
4431
4469
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4432
4470
|
return "completed";
|
|
4433
4471
|
}
|
|
@@ -4438,7 +4476,7 @@ async function executeStepWithHealing(params) {
|
|
|
4438
4476
|
// catch-all (parseSelectStep returns null there, so trySelectPrimitive above
|
|
4439
4477
|
// skipped it) and no-ops when the page has no required-empty select.
|
|
4440
4478
|
if (await tryFillRequiredSelectsPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4441
|
-
logger.info(
|
|
4479
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by required-select primitive`);
|
|
4442
4480
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4443
4481
|
return "completed";
|
|
4444
4482
|
}
|
|
@@ -4454,6 +4492,7 @@ async function executeStepWithHealing(params) {
|
|
|
4454
4492
|
stagehand,
|
|
4455
4493
|
step,
|
|
4456
4494
|
stepIndex,
|
|
4495
|
+
totalSteps,
|
|
4457
4496
|
logger,
|
|
4458
4497
|
captureFn,
|
|
4459
4498
|
});
|
|
@@ -4464,10 +4503,10 @@ async function executeStepWithHealing(params) {
|
|
|
4464
4503
|
// can't resolve the widget) — skipping would leave a required field empty
|
|
4465
4504
|
// and silently doom the later submit. Fall through to the cascade instead.
|
|
4466
4505
|
if (await hasUnfilledRequiredControlForStep(page, step)) {
|
|
4467
|
-
logger.info(
|
|
4506
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
|
|
4468
4507
|
}
|
|
4469
4508
|
else {
|
|
4470
|
-
logger.info(
|
|
4509
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, probe found no candidates)`);
|
|
4471
4510
|
return "skipped";
|
|
4472
4511
|
}
|
|
4473
4512
|
}
|
|
@@ -4482,7 +4521,7 @@ async function executeStepWithHealing(params) {
|
|
|
4482
4521
|
preMetaLength: stepStartMetaLength,
|
|
4483
4522
|
});
|
|
4484
4523
|
if (transitionUrl !== null) {
|
|
4485
|
-
logger.info(
|
|
4524
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (probe absent but recent transition detected: ${transitionUrl})`);
|
|
4486
4525
|
trajectory?.push({ stepIndex, verifiedBy: "url" });
|
|
4487
4526
|
return "completed";
|
|
4488
4527
|
}
|
|
@@ -4497,8 +4536,8 @@ async function executeStepWithHealing(params) {
|
|
|
4497
4536
|
ownBackendHostnames,
|
|
4498
4537
|
});
|
|
4499
4538
|
if (backendErrorUrl !== null) {
|
|
4500
|
-
logger.error(
|
|
4501
|
-
throw new errors_2.StepVerificationError(
|
|
4539
|
+
logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
|
|
4540
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
|
|
4502
4541
|
}
|
|
4503
4542
|
// Capture diagnostics + write a failure dump BEFORE throwing so the
|
|
4504
4543
|
// global replan path's `readFailureDumpEvidence` can populate the
|
|
@@ -4526,7 +4565,7 @@ async function executeStepWithHealing(params) {
|
|
|
4526
4565
|
bodyOuterHtml,
|
|
4527
4566
|
unfocusedObserve,
|
|
4528
4567
|
}) ?? null;
|
|
4529
|
-
throw new errors_2.StepVerificationError(
|
|
4568
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
|
|
4530
4569
|
}
|
|
4531
4570
|
// Pre-submit form-validity probe. Fires on the canonical submit step
|
|
4532
4571
|
// (either the final flow step OR a step explicitly flagged `submitStep:
|
|
@@ -4551,6 +4590,7 @@ async function executeStepWithHealing(params) {
|
|
|
4551
4590
|
const invalidControls = await probeFormValidityBeforeSubmit({
|
|
4552
4591
|
page,
|
|
4553
4592
|
stepIndex,
|
|
4593
|
+
totalSteps,
|
|
4554
4594
|
logger,
|
|
4555
4595
|
});
|
|
4556
4596
|
for (const c of invalidControls) {
|
|
@@ -4592,6 +4632,7 @@ async function executeStepWithHealing(params) {
|
|
|
4592
4632
|
resolvedMethod: null,
|
|
4593
4633
|
resolvedArguments: null,
|
|
4594
4634
|
verifiedBy: null,
|
|
4635
|
+
phantomClickVerdict: null,
|
|
4595
4636
|
});
|
|
4596
4637
|
}
|
|
4597
4638
|
// Brief settle window after auto-picks so Angular's change-detection
|
|
@@ -4607,6 +4648,11 @@ async function executeStepWithHealing(params) {
|
|
|
4607
4648
|
// NOT move the wizard forward. Read at the top of attempts 2-4 to skip the
|
|
4608
4649
|
// proven-dead re-observe/re-click techniques (see shouldSkipTechnique).
|
|
4609
4650
|
let advanceUnmovedAfterAttempt1 = false;
|
|
4651
|
+
// Set after attempt 1: Stagehand reported success but pre/post shows zero
|
|
4652
|
+
// observable effect (see classifyPhantomClick). Reroutes attempt 2 to
|
|
4653
|
+
// deep-submit-locator instead of observe-act, since re-resolving via the
|
|
4654
|
+
// same light-DOM view cannot reach a target the resolver can't see.
|
|
4655
|
+
let phantomClickAfterAttempt1 = false;
|
|
4610
4656
|
for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
|
|
4611
4657
|
// Telemetry-driven technique-skip: when a cascade technique's
|
|
4612
4658
|
// preconditions cannot be met by the prior attempts' state, running
|
|
@@ -4615,7 +4661,9 @@ async function executeStepWithHealing(params) {
|
|
|
4615
4661
|
// techniques faster.
|
|
4616
4662
|
if (attempt > 1) {
|
|
4617
4663
|
const wouldBeTechnique = attempt === 2
|
|
4618
|
-
?
|
|
4664
|
+
? phantomClickAfterAttempt1
|
|
4665
|
+
? "deep-submit-locator"
|
|
4666
|
+
: "observe-act"
|
|
4619
4667
|
: attempt === 3
|
|
4620
4668
|
? "structured-click"
|
|
4621
4669
|
: attempt === 4
|
|
@@ -4629,9 +4677,10 @@ async function executeStepWithHealing(params) {
|
|
|
4629
4677
|
errorMessage: a.errorMessage,
|
|
4630
4678
|
})),
|
|
4631
4679
|
advanceUnmovedAfterAttempt1,
|
|
4680
|
+
phantomClickAfterAttempt1,
|
|
4632
4681
|
});
|
|
4633
4682
|
if (decision.skip) {
|
|
4634
|
-
logger.info(
|
|
4683
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
|
|
4635
4684
|
failureReasons.push(`attempt ${attempt} skipped: ${decision.reason}`);
|
|
4636
4685
|
continue;
|
|
4637
4686
|
}
|
|
@@ -4662,6 +4711,7 @@ async function executeStepWithHealing(params) {
|
|
|
4662
4711
|
resolvedMethod: null,
|
|
4663
4712
|
resolvedArguments: null,
|
|
4664
4713
|
verifiedBy: null,
|
|
4714
|
+
phantomClickVerdict: null,
|
|
4665
4715
|
};
|
|
4666
4716
|
// First resolved action from Stagehand's `act` result — used to decide
|
|
4667
4717
|
// whether this attempt's signal should come from the network/URL pair or
|
|
@@ -4698,6 +4748,124 @@ async function executeStepWithHealing(params) {
|
|
|
4698
4748
|
}
|
|
4699
4749
|
}
|
|
4700
4750
|
}
|
|
4751
|
+
else if (attempt === 2 && phantomClickAfterAttempt1) {
|
|
4752
|
+
// Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
|
|
4753
|
+
// reported success but pre/post showed zero effect), so the target is
|
|
4754
|
+
// almost certainly unreachable via document.querySelectorAll — most
|
|
4755
|
+
// likely rendered inside an open shadow root by a web-component /
|
|
4756
|
+
// framework-native submit control (see recon-submit-phantom-click bug
|
|
4757
|
+
// report). Rank every submit-shaped candidate the deep traversal can
|
|
4758
|
+
// reach and click the top-ranked one; the ranking already excludes
|
|
4759
|
+
// Back/Cancel/Save-draft-shaped controls, so a false-positive submit
|
|
4760
|
+
// click cannot fire.
|
|
4761
|
+
//
|
|
4762
|
+
// Rank and click are two separate page.evaluate round trips over a
|
|
4763
|
+
// live Angular page, so any re-render between them shifts every
|
|
4764
|
+
// deepIndex — the click then lands on nothing (`{clicked: false}`),
|
|
4765
|
+
// not because the page is broken but because the snapshot the index
|
|
4766
|
+
// was computed from is already gone. That's a transient, self-clearing
|
|
4767
|
+
// condition, so re-rank against the CURRENT DOM and click once more
|
|
4768
|
+
// before giving up. Capped at one retry (two rank+click rounds total)
|
|
4769
|
+
// so a page re-rendering on every tick can't turn this into a loop.
|
|
4770
|
+
record.technique = "deep-submit-locator";
|
|
4771
|
+
for (let deepAttempt = 1; deepAttempt <= 2; deepAttempt++) {
|
|
4772
|
+
let ranked;
|
|
4773
|
+
try {
|
|
4774
|
+
ranked = (await page.evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)()));
|
|
4775
|
+
}
|
|
4776
|
+
catch (err) {
|
|
4777
|
+
// A thrown evaluate (page navigated away / frame detached) is not
|
|
4778
|
+
// a stale-index race — re-ranking a detached page will throw
|
|
4779
|
+
// again, so don't retry; record it and let the cascade move on.
|
|
4780
|
+
record.errorMessage = `deep-submit-locator: rank evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
|
|
4781
|
+
break;
|
|
4782
|
+
}
|
|
4783
|
+
if (ranked.length === 0) {
|
|
4784
|
+
record.errorMessage = "deep-submit-locator: no submit-shaped candidate found";
|
|
4785
|
+
break;
|
|
4786
|
+
}
|
|
4787
|
+
// biome-ignore lint/style/noNonNullAssertion: guarded by the length check above
|
|
4788
|
+
const top = ranked[0];
|
|
4789
|
+
record.instruction = `deep-submit-locator: ${top.tag} "${top.accessibleName}" (tier ${top.tier})`;
|
|
4790
|
+
record.triedSelectors = [`deep-index:${top.deepIndex}`];
|
|
4791
|
+
triedSelectors.push(`deep-index:${top.deepIndex}`);
|
|
4792
|
+
let clickResult;
|
|
4793
|
+
try {
|
|
4794
|
+
clickResult = (await page.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex)));
|
|
4795
|
+
}
|
|
4796
|
+
catch (err) {
|
|
4797
|
+
record.errorMessage = `deep-submit-locator: click evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
|
|
4798
|
+
break;
|
|
4799
|
+
}
|
|
4800
|
+
record.actResultSuccess = clickResult.clicked;
|
|
4801
|
+
record.actResultDescription = clickResult.clicked
|
|
4802
|
+
? `deep-submit-locator clicked ${top.tag} "${top.accessibleName}"`
|
|
4803
|
+
: "deep-submit-locator: candidate vanished before click (deepIndex stale)";
|
|
4804
|
+
if (clickResult.clicked) {
|
|
4805
|
+
// Synthesize a click action so downstream verification (network /
|
|
4806
|
+
// url / dom) treats this exactly like any other resolved click.
|
|
4807
|
+
resolvedAction = {
|
|
4808
|
+
selector: `deep-index:${top.deepIndex}`,
|
|
4809
|
+
description: record.actResultDescription,
|
|
4810
|
+
method: "click",
|
|
4811
|
+
};
|
|
4812
|
+
// Runner-up retry: the top pick can itself phantom-click (a
|
|
4813
|
+
// second web-component / shadow-root control that LOOKS
|
|
4814
|
+
// submit-shaped but doesn't wire a real handler — see
|
|
4815
|
+
// submit-control.ts's module docblock). Re-snapshot right here
|
|
4816
|
+
// and re-classify with the same classifyPhantomClick primitive
|
|
4817
|
+
// that escalated attempt 1, so a phantom top pick doesn't fall
|
|
4818
|
+
// through to attempt 3's structured-click, which can't reach a
|
|
4819
|
+
// shadow-root control either. Cap at ranked[1] only (not the
|
|
4820
|
+
// full list) so a page with many submit-shaped candidates can't
|
|
4821
|
+
// burn the step budget probing all of them.
|
|
4822
|
+
const runnerUp = ranked[1];
|
|
4823
|
+
if (runnerUp) {
|
|
4824
|
+
const midPost = await snapshotPage(page, signalCounter);
|
|
4825
|
+
const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
4826
|
+
actResultSuccess: true,
|
|
4827
|
+
pre,
|
|
4828
|
+
post: midPost,
|
|
4829
|
+
});
|
|
4830
|
+
if (topVerdict === "phantom") {
|
|
4831
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator top pick (${top.tag} "${top.accessibleName}") phantom-clicked; retrying runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier})`);
|
|
4832
|
+
record.instruction = `deep-submit-locator: ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier}), runner-up after top pick ${top.tag} "${top.accessibleName}" phantomed`;
|
|
4833
|
+
record.triedSelectors = [
|
|
4834
|
+
`deep-index:${top.deepIndex}`,
|
|
4835
|
+
`deep-index:${runnerUp.deepIndex}`,
|
|
4836
|
+
];
|
|
4837
|
+
triedSelectors.push(`deep-index:${runnerUp.deepIndex}`);
|
|
4838
|
+
const runnerUpClickResult = (await page
|
|
4839
|
+
.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))
|
|
4840
|
+
.catch(() => ({ clicked: false })));
|
|
4841
|
+
record.actResultSuccess = runnerUpClickResult.clicked;
|
|
4842
|
+
record.actResultDescription = runnerUpClickResult.clicked
|
|
4843
|
+
? `deep-submit-locator clicked runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" after top pick phantomed`
|
|
4844
|
+
: "deep-submit-locator: runner-up candidate vanished before click (deepIndex stale)";
|
|
4845
|
+
resolvedAction = runnerUpClickResult.clicked
|
|
4846
|
+
? {
|
|
4847
|
+
selector: `deep-index:${runnerUp.deepIndex}`,
|
|
4848
|
+
description: record.actResultDescription,
|
|
4849
|
+
method: "click",
|
|
4850
|
+
}
|
|
4851
|
+
: null;
|
|
4852
|
+
if (!runnerUpClickResult.clicked) {
|
|
4853
|
+
record.errorMessage = record.actResultDescription;
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
}
|
|
4857
|
+
// The top pick (or its runner-up) was reached and clicked. A
|
|
4858
|
+
// runner-up that itself vanished is not the stale-index race the
|
|
4859
|
+
// re-rank exists for — the rank was fresh enough to click the top
|
|
4860
|
+
// pick — so exit either way and let the cascade classify.
|
|
4861
|
+
break;
|
|
4862
|
+
}
|
|
4863
|
+
record.errorMessage = record.actResultDescription;
|
|
4864
|
+
if (deepAttempt === 1) {
|
|
4865
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator: deepIndex stale on first click, re-ranking once`);
|
|
4866
|
+
}
|
|
4867
|
+
}
|
|
4868
|
+
}
|
|
4701
4869
|
else if (attempt === 2 || attempt === 4) {
|
|
4702
4870
|
record.technique = attempt === 2 ? "observe-act" : "observe-act-exclude";
|
|
4703
4871
|
const observeOptions = attempt === 4 && triedSelectors.length > 0
|
|
@@ -4725,12 +4893,12 @@ async function executeStepWithHealing(params) {
|
|
|
4725
4893
|
// don't fast-skip — let the healing cascade continue so the
|
|
4726
4894
|
// required field gets answered instead of silently doomed.
|
|
4727
4895
|
if (await hasUnfilledRequiredControlForStep(page, step)) {
|
|
4728
|
-
logger.info(
|
|
4896
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
|
|
4729
4897
|
}
|
|
4730
4898
|
else {
|
|
4731
4899
|
record.verifiedBy = null;
|
|
4732
4900
|
attempts.push(record);
|
|
4733
|
-
logger.info(
|
|
4901
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, no candidates after act+observe)`);
|
|
4734
4902
|
return "skipped";
|
|
4735
4903
|
}
|
|
4736
4904
|
}
|
|
@@ -4748,7 +4916,7 @@ async function executeStepWithHealing(params) {
|
|
|
4748
4916
|
record.triedSelectors = [target.selector];
|
|
4749
4917
|
attempts.push(record);
|
|
4750
4918
|
failureReasons.push(record.errorMessage);
|
|
4751
|
-
logger.info(
|
|
4919
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
|
|
4752
4920
|
continue;
|
|
4753
4921
|
}
|
|
4754
4922
|
record.instruction = target.description;
|
|
@@ -4808,7 +4976,7 @@ async function executeStepWithHealing(params) {
|
|
|
4808
4976
|
record.actResultSuccess = false;
|
|
4809
4977
|
}
|
|
4810
4978
|
else if (readback.outcome === "differs") {
|
|
4811
|
-
logger.info(
|
|
4979
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
|
|
4812
4980
|
}
|
|
4813
4981
|
}
|
|
4814
4982
|
}
|
|
@@ -5080,7 +5248,7 @@ async function executeStepWithHealing(params) {
|
|
|
5080
5248
|
intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
|
|
5081
5249
|
});
|
|
5082
5250
|
if (advanceGateActive && !networkIsRealAdvance) {
|
|
5083
|
-
logger.info(
|
|
5251
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} network fired but no advance-transition (type=next) body matched within ${ADVANCE_TRANSITION_POLL_MS}ms poll (non-advancing POST); not treating as verified`);
|
|
5084
5252
|
}
|
|
5085
5253
|
// DOM-only-advance veto (opt-in). A rephrase can turn an advance/"Next" step
|
|
5086
5254
|
// into a field click (e.g. "click the Yes radio for '18?' then Next"), which
|
|
@@ -5106,7 +5274,7 @@ async function executeStepWithHealing(params) {
|
|
|
5106
5274
|
? domVerified
|
|
5107
5275
|
: false;
|
|
5108
5276
|
if (domVerified && !domVerifiedForStep) {
|
|
5109
|
-
logger.info(
|
|
5277
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} advance step succeeded only via DOM state change (field toggle / non-advancing POST), not a real transition; not treating as verified`);
|
|
5110
5278
|
}
|
|
5111
5279
|
let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep;
|
|
5112
5280
|
// Final-step submit-verification gate. Replaces the deterministic
|
|
@@ -5208,7 +5376,7 @@ async function executeStepWithHealing(params) {
|
|
|
5208
5376
|
// Any-4xx fallback. The judge said failed; surface any captured 4xx
|
|
5209
5377
|
// body's field-level error JSON for the rephrase prompt downstream
|
|
5210
5378
|
// — same role as before the migration.
|
|
5211
|
-
const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], recon_shared_1.
|
|
5379
|
+
const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], (0, recon_shared_1.resolveReconRunDir)().graphqlDir, "any-4xx");
|
|
5212
5380
|
if (fallbackEvidence.length > 0) {
|
|
5213
5381
|
failureReasons.push(`any-4xx fallback: ${fallbackEvidence.split("\n")[0]}`.slice(0, 240));
|
|
5214
5382
|
}
|
|
@@ -5348,7 +5516,7 @@ async function executeStepWithHealing(params) {
|
|
|
5348
5516
|
retryNetworkIsRealAdvance,
|
|
5349
5517
|
});
|
|
5350
5518
|
if (fallbackDomOnlyAdvance) {
|
|
5351
|
-
logger.info(
|
|
5519
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
|
|
5352
5520
|
}
|
|
5353
5521
|
let retryVerified = !clickBlockedByInvalid &&
|
|
5354
5522
|
!fallbackDomOnlyAdvance &&
|
|
@@ -5426,7 +5594,7 @@ async function executeStepWithHealing(params) {
|
|
|
5426
5594
|
failureReasons.push(`n+16 fallback: submit-judge-rejected: ${judgeVerdict.reason}`);
|
|
5427
5595
|
}
|
|
5428
5596
|
}
|
|
5429
|
-
logger.info(`n+16 probe: step=${stepIndex + 1} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} verified=${retryVerified}`);
|
|
5597
|
+
logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} verified=${retryVerified}`);
|
|
5430
5598
|
if (retryVerified) {
|
|
5431
5599
|
if (record.verifiedBy === null) {
|
|
5432
5600
|
record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
|
|
@@ -5434,24 +5602,24 @@ async function executeStepWithHealing(params) {
|
|
|
5434
5602
|
record.post = retryPost;
|
|
5435
5603
|
attempts.push(record);
|
|
5436
5604
|
if (attempt > 1) {
|
|
5437
|
-
logger.info(
|
|
5605
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} + el.click() fallback`);
|
|
5438
5606
|
}
|
|
5439
5607
|
else {
|
|
5440
|
-
logger.info(
|
|
5608
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} + el.click() fallback`);
|
|
5441
5609
|
}
|
|
5442
5610
|
trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
|
|
5443
5611
|
return "completed";
|
|
5444
5612
|
}
|
|
5445
5613
|
}
|
|
5446
5614
|
catch (probeErr) {
|
|
5447
|
-
logger.warn(`n+16 probe: step=${stepIndex + 1} attempt=${attempt} el.click() fallback threw: ${(0, errors_1.toErrorMessage)(probeErr)}`);
|
|
5615
|
+
logger.warn(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback threw: ${(0, errors_1.toErrorMessage)(probeErr)}`);
|
|
5448
5616
|
}
|
|
5449
5617
|
}
|
|
5450
5618
|
}
|
|
5451
5619
|
attempts.push(record);
|
|
5452
5620
|
if (verified) {
|
|
5453
5621
|
if (attempt > 1) {
|
|
5454
|
-
logger.info(
|
|
5622
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
|
|
5455
5623
|
}
|
|
5456
5624
|
else {
|
|
5457
5625
|
// Why log first-try wins explicitly: prior to this change, attempt-1
|
|
@@ -5461,19 +5629,29 @@ async function executeStepWithHealing(params) {
|
|
|
5461
5629
|
// collapse" (log showed 2 heals) but telemetry calls.ndjson showed
|
|
5462
5630
|
// 32 successful Stagehand acts. Surfacing attempt-1 wins lets the
|
|
5463
5631
|
// log match telemetry and prevents the same false alarm.
|
|
5464
|
-
logger.info(
|
|
5632
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
|
|
5465
5633
|
}
|
|
5466
5634
|
trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
|
|
5467
5635
|
return "completed";
|
|
5468
5636
|
}
|
|
5469
5637
|
const effectSignals = describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLength);
|
|
5638
|
+
// Phantom-click verdict, computed from the SAME pre/post pair
|
|
5639
|
+
// describeAttemptEffectSignals just rendered — not recomputed deltas.
|
|
5640
|
+
// Recorded on every unverified attempt (not just attempt 1) so the
|
|
5641
|
+
// failure dump's attempts[] always carries the classification; only
|
|
5642
|
+
// attempt 1's verdict drives the escalation flag below.
|
|
5643
|
+
record.phantomClickVerdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
5644
|
+
actResultSuccess: record.actResultSuccess,
|
|
5645
|
+
pre,
|
|
5646
|
+
post,
|
|
5647
|
+
});
|
|
5470
5648
|
const reason = record.errorMessage
|
|
5471
5649
|
? effectSignals
|
|
5472
5650
|
? `${record.errorMessage}; ${effectSignals}`
|
|
5473
5651
|
: record.errorMessage
|
|
5474
5652
|
: effectSignals || "no observable effect (no network, url, or dom change)";
|
|
5475
5653
|
failureReasons.push(reason);
|
|
5476
|
-
logger.warn(
|
|
5654
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${record.technique}) produced no observable effect — ${reason}`);
|
|
5477
5655
|
// One additive strategy among many: when a click on a final-step
|
|
5478
5656
|
// submit/continue button fails AND the page surfaces a visible
|
|
5479
5657
|
// <error> sibling next to a touched+dirty ng-invalid wrapper, surface
|
|
@@ -5498,7 +5676,7 @@ async function executeStepWithHealing(params) {
|
|
|
5498
5676
|
for (const p of pairs) {
|
|
5499
5677
|
const validationReason = formatValidationRejectedReason(p);
|
|
5500
5678
|
failureReasons.push(validationReason);
|
|
5501
|
-
logger.warn(
|
|
5679
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${validationReason}`);
|
|
5502
5680
|
}
|
|
5503
5681
|
}
|
|
5504
5682
|
// Telemetry-driven early-exit: when attempt 1 on a final-Submit click
|
|
@@ -5520,6 +5698,17 @@ async function executeStepWithHealing(params) {
|
|
|
5520
5698
|
isAdvanceStep(step) &&
|
|
5521
5699
|
!urlChanged &&
|
|
5522
5700
|
!networkIsRealAdvance;
|
|
5701
|
+
// Attempt 1 phantom-clicked: Stagehand reported success but pre/post
|
|
5702
|
+
// shows zero observable effect. The zero-effect delta is the primary
|
|
5703
|
+
// signal (classifyPhantomClick above); the live AISDK elementId
|
|
5704
|
+
// suppression counter is corroborating evidence only — logged, never
|
|
5705
|
+
// gating, since a nonzero count alone is too weak a signal on a run
|
|
5706
|
+
// that sees dozens of suppressions across hundreds of unrelated steps.
|
|
5707
|
+
phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
|
|
5708
|
+
if (phantomClickAfterAttempt1) {
|
|
5709
|
+
const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
|
|
5710
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} phantom click detected on attempt 1 (${record.technique}): reported success with no network/url/dom change${suppressedCount !== undefined ? `; ${suppressedCount} AISDK elementId errors suppressed this session (corroborating, not causal)` : ""} — escalating attempt 2 to deep-submit-locator`);
|
|
5711
|
+
}
|
|
5523
5712
|
const postAttemptInvalidCount = await countNgInvalidContainers(page);
|
|
5524
5713
|
const earlyExit = isSubmitRevealedInvalid({
|
|
5525
5714
|
// Treat the canonical submit click as "final" for this predicate
|
|
@@ -5535,7 +5724,7 @@ async function executeStepWithHealing(params) {
|
|
|
5535
5724
|
if (earlyExit) {
|
|
5536
5725
|
const exitReason = `submit-revealed-invalid: click surfaced ${postAttemptInvalidCount - preSubmitInvalidCount} new ng-invalid container(s) (was ${preSubmitInvalidCount}, now ${postAttemptInvalidCount}); attempts 2-${MAX_STEP_ATTEMPTS} cannot heal a form that needs answers — routing to replan`;
|
|
5537
5726
|
failureReasons.push(exitReason);
|
|
5538
|
-
logger.warn(
|
|
5727
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
|
|
5539
5728
|
break;
|
|
5540
5729
|
}
|
|
5541
5730
|
// Telemetry-driven early-exit for interior ADVANCE steps: if the Next
|
|
@@ -5555,7 +5744,7 @@ async function executeStepWithHealing(params) {
|
|
|
5555
5744
|
if (advanceStalled) {
|
|
5556
5745
|
const exitReason = `advance-stalled: the Next click fired network but no real transition (type=next) landed within the poll window; attempts 2-${MAX_STEP_ATTEMPTS} would only re-bounce the wizard — routing to replan`;
|
|
5557
5746
|
failureReasons.push(exitReason);
|
|
5558
|
-
logger.warn(
|
|
5747
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
|
|
5559
5748
|
break;
|
|
5560
5749
|
}
|
|
5561
5750
|
}
|
|
@@ -5583,9 +5772,9 @@ async function executeStepWithHealing(params) {
|
|
|
5583
5772
|
unfocusedObserve,
|
|
5584
5773
|
}) ?? null;
|
|
5585
5774
|
if (dumpPath !== null) {
|
|
5586
|
-
logger.error(
|
|
5775
|
+
logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
|
|
5587
5776
|
}
|
|
5588
|
-
throw new errors_2.StepVerificationError(
|
|
5777
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
|
|
5589
5778
|
}
|
|
5590
5779
|
/** SPA-readiness gate defaults — mirror the recon CLI's post-navigation wait. */
|
|
5591
5780
|
const SPA_READINESS_TIMEOUT_MS = 15_000;
|
|
@@ -5667,6 +5856,7 @@ async function runHealingFlow(deps) {
|
|
|
5667
5856
|
upload: s.upload,
|
|
5668
5857
|
submitStep: s.submitStep,
|
|
5669
5858
|
stepIndex: i,
|
|
5859
|
+
totalSteps: () => steps.length,
|
|
5670
5860
|
phase: "flow",
|
|
5671
5861
|
signalCounter,
|
|
5672
5862
|
recentCaptures,
|