@enricai/barnacle 1.6.0 → 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 +66 -7
- package/dist/scraper/cookie-jar.d.ts +19 -0
- package/dist/scraper/cookie-jar.d.ts.map +1 -0
- package/dist/scraper/cookie-jar.js +43 -0
- package/dist/scraper/cookie-jar.js.map +1 -0
- 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 +259 -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 +34 -12
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +78 -33
- 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 +68 -0
- package/dist/scripts/recon-shared.d.ts.map +1 -1
- package/dist/scripts/recon-shared.js +94 -1
- 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 +9 -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. */
|
|
@@ -126,6 +129,12 @@ function wireSignalCapture(page, params) {
|
|
|
126
129
|
// into the capture once, in `onFinished` (the only place a Capture is built).
|
|
127
130
|
// See mergeResponseHeaders.
|
|
128
131
|
const extraResponseHeaders = new Map();
|
|
132
|
+
// Cookie (and other extra-info-only request headers) keyed by requestId.
|
|
133
|
+
// Mirrors extraResponseHeaders: Network.requestWillBeSent omits the outgoing
|
|
134
|
+
// Cookie header by design, and requestWillBeSentExtraInfo — which carries it —
|
|
135
|
+
// can race requestWillBeSent in either order. Buffered here and folded into
|
|
136
|
+
// the capture once, in `onFinished`. See mergeResponseHeaders.
|
|
137
|
+
const extraRequestHeaders = new Map();
|
|
129
138
|
// One-shot per-run warning state for the GA `ep.isExpired=true` beacon.
|
|
130
139
|
// Defensive instrumentation: across 5,542 captures surveyed 2026-06-15,
|
|
131
140
|
// every observation was `false` — but if a future job ever ships in the
|
|
@@ -158,6 +167,13 @@ function wireSignalCapture(page, params) {
|
|
|
158
167
|
const merged = mergeResponseHeaders(extraResponseHeaders.get(params.requestId) ?? {}, params.headers);
|
|
159
168
|
extraResponseHeaders.set(params.requestId, merged);
|
|
160
169
|
};
|
|
170
|
+
const onRequestExtraInfo = (params) => {
|
|
171
|
+
// Accumulate — can race requestWillBeSent in either order, and a redirect
|
|
172
|
+
// fires this more than once per requestId. The fold into the capture
|
|
173
|
+
// happens once, in onFinished, so order with requestWillBeSent never matters.
|
|
174
|
+
const merged = mergeResponseHeaders(extraRequestHeaders.get(params.requestId) ?? {}, params.headers);
|
|
175
|
+
extraRequestHeaders.set(params.requestId, merged);
|
|
176
|
+
};
|
|
161
177
|
const onFinished = async (params) => {
|
|
162
178
|
const req = inFlight.get(params.requestId);
|
|
163
179
|
if (!req)
|
|
@@ -168,6 +184,10 @@ function wireSignalCapture(page, params) {
|
|
|
168
184
|
// it can't leak per run.
|
|
169
185
|
req.responseHeaders = mergeResponseHeaders(req.responseHeaders, extraResponseHeaders.get(params.requestId));
|
|
170
186
|
extraResponseHeaders.delete(params.requestId);
|
|
187
|
+
// Same fold, request side: recovers the outgoing Cookie header that
|
|
188
|
+
// requestWillBeSent omits by design.
|
|
189
|
+
req.requestHeaders = mergeResponseHeaders(req.requestHeaders, extraRequestHeaders.get(params.requestId));
|
|
190
|
+
extraRequestHeaders.delete(params.requestId);
|
|
171
191
|
const phase = getCurrentPhase();
|
|
172
192
|
let responseBody = null;
|
|
173
193
|
try {
|
|
@@ -298,11 +318,13 @@ function wireSignalCapture(page, params) {
|
|
|
298
318
|
logger.info(`captured [${capture.status}] ${capture.method} ${req.url} → ${filename}`);
|
|
299
319
|
};
|
|
300
320
|
session.on("Network.requestWillBeSent", onRequest);
|
|
321
|
+
session.on("Network.requestWillBeSentExtraInfo", onRequestExtraInfo);
|
|
301
322
|
session.on("Network.responseReceived", onResponse);
|
|
302
323
|
session.on("Network.responseReceivedExtraInfo", onResponseExtraInfo);
|
|
303
324
|
session.on("Network.loadingFinished", onFinished);
|
|
304
325
|
return () => {
|
|
305
326
|
session.off("Network.requestWillBeSent", onRequest);
|
|
327
|
+
session.off("Network.requestWillBeSentExtraInfo", onRequestExtraInfo);
|
|
306
328
|
session.off("Network.responseReceived", onResponse);
|
|
307
329
|
session.off("Network.responseReceivedExtraInfo", onResponseExtraInfo);
|
|
308
330
|
session.off("Network.loadingFinished", onFinished);
|
|
@@ -711,7 +733,7 @@ function windowHasTransitionBody(params) {
|
|
|
711
733
|
const { preIdx, advanceTransitionBodyPattern } = params;
|
|
712
734
|
if (!advanceTransitionBodyPattern)
|
|
713
735
|
return false;
|
|
714
|
-
const capturesDir = params.capturesDir ?? recon_shared_1.
|
|
736
|
+
const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
|
|
715
737
|
let rx;
|
|
716
738
|
try {
|
|
717
739
|
rx = new RegExp(advanceTransitionBodyPattern);
|
|
@@ -753,7 +775,7 @@ function windowHasAdvanceTransition(params) {
|
|
|
753
775
|
const { preIdx, advanceTransitionBodyPattern } = params;
|
|
754
776
|
if (!advanceTransitionBodyPattern)
|
|
755
777
|
return false;
|
|
756
|
-
const capturesDir = params.capturesDir ?? recon_shared_1.
|
|
778
|
+
const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
|
|
757
779
|
let rx;
|
|
758
780
|
try {
|
|
759
781
|
rx = new RegExp(advanceTransitionBodyPattern);
|
|
@@ -902,7 +924,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
|
|
|
902
924
|
* keeps healing opportunistically.
|
|
903
925
|
*/
|
|
904
926
|
function shouldSkipTechnique(params) {
|
|
905
|
-
const { technique, priorAttempts, advanceUnmovedAfterAttempt1 } = params;
|
|
927
|
+
const { technique, priorAttempts, advanceUnmovedAfterAttempt1, phantomClickAfterAttempt1 } = params;
|
|
906
928
|
// Unmoved-advance short-circuit (measured: attempts 2-4 recovered a stuck
|
|
907
929
|
// advance 0 times in 289 steps). When attempt-1's act-string clicked the Next
|
|
908
930
|
// and the wizard did NOT move forward (non-advancing POST, or no effect),
|
|
@@ -923,6 +945,25 @@ function shouldSkipTechnique(params) {
|
|
|
923
945
|
reason: "advance step did not move the wizard on attempt 1; re-observe/re-click cannot advance it — skipping to rephrase/replan",
|
|
924
946
|
};
|
|
925
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
|
+
}
|
|
926
967
|
if (technique === "structured-click") {
|
|
927
968
|
const anyXpathResolved = priorAttempts.some((a) => a.triedSelectors.length > 0);
|
|
928
969
|
if (!anyXpathResolved) {
|
|
@@ -1691,8 +1732,9 @@ async function extractInteractiveTargetsNearInvalid(page) {
|
|
|
1691
1732
|
/**
|
|
1692
1733
|
* Scan recent capture files for failed submit-endpoint requests and pull
|
|
1693
1734
|
* out structured field-level errors from the response body. The cascade
|
|
1694
|
-
* already saves every captured request to
|
|
1695
|
-
*
|
|
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
|
|
1696
1738
|
* matching the configured submit pattern with status >= 400, and walks
|
|
1697
1739
|
* common error-shape conventions (`{ errors: [{ field, message }] }`,
|
|
1698
1740
|
* `{ validation/fieldErrors: { … } }`, `{ message }`).
|
|
@@ -1709,7 +1751,7 @@ function extractSubmitFailureEvidence(recentCaptureFilenames,
|
|
|
1709
1751
|
* deterministic hostname equality. Empty list / "any-4xx" mode disables
|
|
1710
1752
|
* the host filter and returns any 4xx in the window.
|
|
1711
1753
|
*/
|
|
1712
|
-
ownBackendHostnames, capturesDir = recon_shared_1.
|
|
1754
|
+
ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir, mode = "strict") {
|
|
1713
1755
|
if (recentCaptureFilenames.length === 0)
|
|
1714
1756
|
return "";
|
|
1715
1757
|
if (mode === "strict" && ownBackendHostnames.length === 0)
|
|
@@ -1800,7 +1842,7 @@ ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict")
|
|
|
1800
1842
|
* Returns a numbered evidence list. Empty string when no GA collect
|
|
1801
1843
|
* captures are present. Advisory — never load-bearing.
|
|
1802
1844
|
*/
|
|
1803
|
-
function extractGaEventEvidence(recentCaptureFilenames, capturesDir = recon_shared_1.
|
|
1845
|
+
function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir) {
|
|
1804
1846
|
if (recentCaptureFilenames.length === 0)
|
|
1805
1847
|
return "";
|
|
1806
1848
|
const records = [];
|
|
@@ -4265,8 +4307,23 @@ function narrowInvalidFormControl(entry) {
|
|
|
4265
4307
|
autoFilled: narrowedAutoFilled,
|
|
4266
4308
|
};
|
|
4267
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
|
+
}
|
|
4268
4325
|
async function probeFormValidityBeforeSubmit(params) {
|
|
4269
|
-
const { page, stepIndex, logger } = params;
|
|
4326
|
+
const { page, stepIndex, totalSteps, logger } = params;
|
|
4270
4327
|
try {
|
|
4271
4328
|
const raw = await page.evaluate(FORM_VALIDITY_PROBE_EXPR);
|
|
4272
4329
|
if (!Array.isArray(raw))
|
|
@@ -4280,15 +4337,15 @@ async function probeFormValidityBeforeSubmit(params) {
|
|
|
4280
4337
|
}
|
|
4281
4338
|
const autoCount = out.filter((e) => e.autoFilled !== null).length;
|
|
4282
4339
|
if (out.length > 0) {
|
|
4283
|
-
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}`);
|
|
4284
4341
|
}
|
|
4285
4342
|
else {
|
|
4286
|
-
logger.info(
|
|
4343
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe: no ng-invalid form controls detected`);
|
|
4287
4344
|
}
|
|
4288
4345
|
return out;
|
|
4289
4346
|
}
|
|
4290
4347
|
catch (err) {
|
|
4291
|
-
logger.warn(
|
|
4348
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe threw: ${(0, errors_1.toErrorMessage)(err)} — proceeding without pre-flight evidence`);
|
|
4292
4349
|
return [];
|
|
4293
4350
|
}
|
|
4294
4351
|
}
|
|
@@ -4302,7 +4359,7 @@ async function probeFormValidityBeforeSubmit(params) {
|
|
|
4302
4359
|
* step is declared "absent". Exported for tests.
|
|
4303
4360
|
*/
|
|
4304
4361
|
async function probeStepBeforeAttempts(params) {
|
|
4305
|
-
const { stagehand, step, stepIndex, logger, captureFn } = params;
|
|
4362
|
+
const { stagehand, step, stepIndex, totalSteps, logger, captureFn } = params;
|
|
4306
4363
|
try {
|
|
4307
4364
|
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
|
|
4308
4365
|
if (candidates.length === 0) {
|
|
@@ -4319,24 +4376,24 @@ async function probeStepBeforeAttempts(params) {
|
|
|
4319
4376
|
// where the unfocused observe is also empty — stay "absent".
|
|
4320
4377
|
const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
|
|
4321
4378
|
if (unfocused.length > 0) {
|
|
4322
|
-
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)`);
|
|
4323
4380
|
return "present";
|
|
4324
4381
|
}
|
|
4325
|
-
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)`);
|
|
4326
4383
|
return "absent";
|
|
4327
4384
|
}
|
|
4328
|
-
logger.info(
|
|
4385
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found ${candidates.length} candidate(s)`);
|
|
4329
4386
|
return "present";
|
|
4330
4387
|
}
|
|
4331
4388
|
catch (err) {
|
|
4332
4389
|
// Bias toward the existing behavior on errors: don't trigger a spurious
|
|
4333
4390
|
// replan when the probe itself is the broken thing.
|
|
4334
|
-
logger.warn(
|
|
4391
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)}: probe threw ${(0, errors_1.toErrorMessage)(err)} — treating as present (cascade will run)`);
|
|
4335
4392
|
return "present";
|
|
4336
4393
|
}
|
|
4337
4394
|
}
|
|
4338
4395
|
async function executeStepWithHealing(params) {
|
|
4339
|
-
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;
|
|
4340
4397
|
// Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
|
|
4341
4398
|
// through executeStepWithHealing's signature so the cascade has it in
|
|
4342
4399
|
// scope when the invalid-fields judge migration (Task #43) lands. The
|
|
@@ -4376,7 +4433,7 @@ async function executeStepWithHealing(params) {
|
|
|
4376
4433
|
signalCounter,
|
|
4377
4434
|
recentCaptureMeta,
|
|
4378
4435
|
})) {
|
|
4379
|
-
logger.info(
|
|
4436
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
|
|
4380
4437
|
trajectory?.push({ stepIndex, verifiedBy: "network" });
|
|
4381
4438
|
return "completed";
|
|
4382
4439
|
}
|
|
@@ -4387,7 +4444,7 @@ async function executeStepWithHealing(params) {
|
|
|
4387
4444
|
// question unanswered. No-op (returns false → falls through) when the step
|
|
4388
4445
|
// isn't a single-dropdown select or no option matches.
|
|
4389
4446
|
if (await trySelectPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4390
|
-
logger.info(
|
|
4447
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
|
|
4391
4448
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4392
4449
|
return "completed";
|
|
4393
4450
|
}
|
|
@@ -4397,7 +4454,7 @@ async function executeStepWithHealing(params) {
|
|
|
4397
4454
|
// trySelectPrimitive (which handles <select> and no-ops on checkbox-only
|
|
4398
4455
|
// pages). No-op (falls through) when there's no checkbox group or no match.
|
|
4399
4456
|
if (await tryCheckboxPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4400
|
-
logger.info(
|
|
4457
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
|
|
4401
4458
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4402
4459
|
return "completed";
|
|
4403
4460
|
}
|
|
@@ -4408,7 +4465,7 @@ async function executeStepWithHealing(params) {
|
|
|
4408
4465
|
// React controlled state (the HCA Basic-Info Step-2 wall). No-op (falls
|
|
4409
4466
|
// through) when there's no radio group or no confident option match.
|
|
4410
4467
|
if (await tryRadioPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4411
|
-
logger.info(
|
|
4468
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
|
|
4412
4469
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4413
4470
|
return "completed";
|
|
4414
4471
|
}
|
|
@@ -4419,7 +4476,7 @@ async function executeStepWithHealing(params) {
|
|
|
4419
4476
|
// catch-all (parseSelectStep returns null there, so trySelectPrimitive above
|
|
4420
4477
|
// skipped it) and no-ops when the page has no required-empty select.
|
|
4421
4478
|
if (await tryFillRequiredSelectsPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4422
|
-
logger.info(
|
|
4479
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by required-select primitive`);
|
|
4423
4480
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4424
4481
|
return "completed";
|
|
4425
4482
|
}
|
|
@@ -4435,6 +4492,7 @@ async function executeStepWithHealing(params) {
|
|
|
4435
4492
|
stagehand,
|
|
4436
4493
|
step,
|
|
4437
4494
|
stepIndex,
|
|
4495
|
+
totalSteps,
|
|
4438
4496
|
logger,
|
|
4439
4497
|
captureFn,
|
|
4440
4498
|
});
|
|
@@ -4445,10 +4503,10 @@ async function executeStepWithHealing(params) {
|
|
|
4445
4503
|
// can't resolve the widget) — skipping would leave a required field empty
|
|
4446
4504
|
// and silently doom the later submit. Fall through to the cascade instead.
|
|
4447
4505
|
if (await hasUnfilledRequiredControlForStep(page, step)) {
|
|
4448
|
-
logger.info(
|
|
4506
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
|
|
4449
4507
|
}
|
|
4450
4508
|
else {
|
|
4451
|
-
logger.info(
|
|
4509
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, probe found no candidates)`);
|
|
4452
4510
|
return "skipped";
|
|
4453
4511
|
}
|
|
4454
4512
|
}
|
|
@@ -4463,7 +4521,7 @@ async function executeStepWithHealing(params) {
|
|
|
4463
4521
|
preMetaLength: stepStartMetaLength,
|
|
4464
4522
|
});
|
|
4465
4523
|
if (transitionUrl !== null) {
|
|
4466
|
-
logger.info(
|
|
4524
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (probe absent but recent transition detected: ${transitionUrl})`);
|
|
4467
4525
|
trajectory?.push({ stepIndex, verifiedBy: "url" });
|
|
4468
4526
|
return "completed";
|
|
4469
4527
|
}
|
|
@@ -4478,8 +4536,8 @@ async function executeStepWithHealing(params) {
|
|
|
4478
4536
|
ownBackendHostnames,
|
|
4479
4537
|
});
|
|
4480
4538
|
if (backendErrorUrl !== null) {
|
|
4481
|
-
logger.error(
|
|
4482
|
-
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");
|
|
4483
4541
|
}
|
|
4484
4542
|
// Capture diagnostics + write a failure dump BEFORE throwing so the
|
|
4485
4543
|
// global replan path's `readFailureDumpEvidence` can populate the
|
|
@@ -4507,7 +4565,7 @@ async function executeStepWithHealing(params) {
|
|
|
4507
4565
|
bodyOuterHtml,
|
|
4508
4566
|
unfocusedObserve,
|
|
4509
4567
|
}) ?? null;
|
|
4510
|
-
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");
|
|
4511
4569
|
}
|
|
4512
4570
|
// Pre-submit form-validity probe. Fires on the canonical submit step
|
|
4513
4571
|
// (either the final flow step OR a step explicitly flagged `submitStep:
|
|
@@ -4532,6 +4590,7 @@ async function executeStepWithHealing(params) {
|
|
|
4532
4590
|
const invalidControls = await probeFormValidityBeforeSubmit({
|
|
4533
4591
|
page,
|
|
4534
4592
|
stepIndex,
|
|
4593
|
+
totalSteps,
|
|
4535
4594
|
logger,
|
|
4536
4595
|
});
|
|
4537
4596
|
for (const c of invalidControls) {
|
|
@@ -4573,6 +4632,7 @@ async function executeStepWithHealing(params) {
|
|
|
4573
4632
|
resolvedMethod: null,
|
|
4574
4633
|
resolvedArguments: null,
|
|
4575
4634
|
verifiedBy: null,
|
|
4635
|
+
phantomClickVerdict: null,
|
|
4576
4636
|
});
|
|
4577
4637
|
}
|
|
4578
4638
|
// Brief settle window after auto-picks so Angular's change-detection
|
|
@@ -4588,6 +4648,11 @@ async function executeStepWithHealing(params) {
|
|
|
4588
4648
|
// NOT move the wizard forward. Read at the top of attempts 2-4 to skip the
|
|
4589
4649
|
// proven-dead re-observe/re-click techniques (see shouldSkipTechnique).
|
|
4590
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;
|
|
4591
4656
|
for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
|
|
4592
4657
|
// Telemetry-driven technique-skip: when a cascade technique's
|
|
4593
4658
|
// preconditions cannot be met by the prior attempts' state, running
|
|
@@ -4596,7 +4661,9 @@ async function executeStepWithHealing(params) {
|
|
|
4596
4661
|
// techniques faster.
|
|
4597
4662
|
if (attempt > 1) {
|
|
4598
4663
|
const wouldBeTechnique = attempt === 2
|
|
4599
|
-
?
|
|
4664
|
+
? phantomClickAfterAttempt1
|
|
4665
|
+
? "deep-submit-locator"
|
|
4666
|
+
: "observe-act"
|
|
4600
4667
|
: attempt === 3
|
|
4601
4668
|
? "structured-click"
|
|
4602
4669
|
: attempt === 4
|
|
@@ -4610,9 +4677,10 @@ async function executeStepWithHealing(params) {
|
|
|
4610
4677
|
errorMessage: a.errorMessage,
|
|
4611
4678
|
})),
|
|
4612
4679
|
advanceUnmovedAfterAttempt1,
|
|
4680
|
+
phantomClickAfterAttempt1,
|
|
4613
4681
|
});
|
|
4614
4682
|
if (decision.skip) {
|
|
4615
|
-
logger.info(
|
|
4683
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
|
|
4616
4684
|
failureReasons.push(`attempt ${attempt} skipped: ${decision.reason}`);
|
|
4617
4685
|
continue;
|
|
4618
4686
|
}
|
|
@@ -4643,6 +4711,7 @@ async function executeStepWithHealing(params) {
|
|
|
4643
4711
|
resolvedMethod: null,
|
|
4644
4712
|
resolvedArguments: null,
|
|
4645
4713
|
verifiedBy: null,
|
|
4714
|
+
phantomClickVerdict: null,
|
|
4646
4715
|
};
|
|
4647
4716
|
// First resolved action from Stagehand's `act` result — used to decide
|
|
4648
4717
|
// whether this attempt's signal should come from the network/URL pair or
|
|
@@ -4679,6 +4748,124 @@ async function executeStepWithHealing(params) {
|
|
|
4679
4748
|
}
|
|
4680
4749
|
}
|
|
4681
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
|
+
}
|
|
4682
4869
|
else if (attempt === 2 || attempt === 4) {
|
|
4683
4870
|
record.technique = attempt === 2 ? "observe-act" : "observe-act-exclude";
|
|
4684
4871
|
const observeOptions = attempt === 4 && triedSelectors.length > 0
|
|
@@ -4706,12 +4893,12 @@ async function executeStepWithHealing(params) {
|
|
|
4706
4893
|
// don't fast-skip — let the healing cascade continue so the
|
|
4707
4894
|
// required field gets answered instead of silently doomed.
|
|
4708
4895
|
if (await hasUnfilledRequiredControlForStep(page, step)) {
|
|
4709
|
-
logger.info(
|
|
4896
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
|
|
4710
4897
|
}
|
|
4711
4898
|
else {
|
|
4712
4899
|
record.verifiedBy = null;
|
|
4713
4900
|
attempts.push(record);
|
|
4714
|
-
logger.info(
|
|
4901
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, no candidates after act+observe)`);
|
|
4715
4902
|
return "skipped";
|
|
4716
4903
|
}
|
|
4717
4904
|
}
|
|
@@ -4729,7 +4916,7 @@ async function executeStepWithHealing(params) {
|
|
|
4729
4916
|
record.triedSelectors = [target.selector];
|
|
4730
4917
|
attempts.push(record);
|
|
4731
4918
|
failureReasons.push(record.errorMessage);
|
|
4732
|
-
logger.info(
|
|
4919
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
|
|
4733
4920
|
continue;
|
|
4734
4921
|
}
|
|
4735
4922
|
record.instruction = target.description;
|
|
@@ -4789,7 +4976,7 @@ async function executeStepWithHealing(params) {
|
|
|
4789
4976
|
record.actResultSuccess = false;
|
|
4790
4977
|
}
|
|
4791
4978
|
else if (readback.outcome === "differs") {
|
|
4792
|
-
logger.info(
|
|
4979
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
|
|
4793
4980
|
}
|
|
4794
4981
|
}
|
|
4795
4982
|
}
|
|
@@ -5061,7 +5248,7 @@ async function executeStepWithHealing(params) {
|
|
|
5061
5248
|
intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
|
|
5062
5249
|
});
|
|
5063
5250
|
if (advanceGateActive && !networkIsRealAdvance) {
|
|
5064
|
-
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`);
|
|
5065
5252
|
}
|
|
5066
5253
|
// DOM-only-advance veto (opt-in). A rephrase can turn an advance/"Next" step
|
|
5067
5254
|
// into a field click (e.g. "click the Yes radio for '18?' then Next"), which
|
|
@@ -5087,7 +5274,7 @@ async function executeStepWithHealing(params) {
|
|
|
5087
5274
|
? domVerified
|
|
5088
5275
|
: false;
|
|
5089
5276
|
if (domVerified && !domVerifiedForStep) {
|
|
5090
|
-
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`);
|
|
5091
5278
|
}
|
|
5092
5279
|
let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep;
|
|
5093
5280
|
// Final-step submit-verification gate. Replaces the deterministic
|
|
@@ -5189,7 +5376,7 @@ async function executeStepWithHealing(params) {
|
|
|
5189
5376
|
// Any-4xx fallback. The judge said failed; surface any captured 4xx
|
|
5190
5377
|
// body's field-level error JSON for the rephrase prompt downstream
|
|
5191
5378
|
// — same role as before the migration.
|
|
5192
|
-
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");
|
|
5193
5380
|
if (fallbackEvidence.length > 0) {
|
|
5194
5381
|
failureReasons.push(`any-4xx fallback: ${fallbackEvidence.split("\n")[0]}`.slice(0, 240));
|
|
5195
5382
|
}
|
|
@@ -5329,7 +5516,7 @@ async function executeStepWithHealing(params) {
|
|
|
5329
5516
|
retryNetworkIsRealAdvance,
|
|
5330
5517
|
});
|
|
5331
5518
|
if (fallbackDomOnlyAdvance) {
|
|
5332
|
-
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`);
|
|
5333
5520
|
}
|
|
5334
5521
|
let retryVerified = !clickBlockedByInvalid &&
|
|
5335
5522
|
!fallbackDomOnlyAdvance &&
|
|
@@ -5407,7 +5594,7 @@ async function executeStepWithHealing(params) {
|
|
|
5407
5594
|
failureReasons.push(`n+16 fallback: submit-judge-rejected: ${judgeVerdict.reason}`);
|
|
5408
5595
|
}
|
|
5409
5596
|
}
|
|
5410
|
-
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}`);
|
|
5411
5598
|
if (retryVerified) {
|
|
5412
5599
|
if (record.verifiedBy === null) {
|
|
5413
5600
|
record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
|
|
@@ -5415,24 +5602,24 @@ async function executeStepWithHealing(params) {
|
|
|
5415
5602
|
record.post = retryPost;
|
|
5416
5603
|
attempts.push(record);
|
|
5417
5604
|
if (attempt > 1) {
|
|
5418
|
-
logger.info(
|
|
5605
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} + el.click() fallback`);
|
|
5419
5606
|
}
|
|
5420
5607
|
else {
|
|
5421
|
-
logger.info(
|
|
5608
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} + el.click() fallback`);
|
|
5422
5609
|
}
|
|
5423
5610
|
trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
|
|
5424
5611
|
return "completed";
|
|
5425
5612
|
}
|
|
5426
5613
|
}
|
|
5427
5614
|
catch (probeErr) {
|
|
5428
|
-
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)}`);
|
|
5429
5616
|
}
|
|
5430
5617
|
}
|
|
5431
5618
|
}
|
|
5432
5619
|
attempts.push(record);
|
|
5433
5620
|
if (verified) {
|
|
5434
5621
|
if (attempt > 1) {
|
|
5435
|
-
logger.info(
|
|
5622
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
|
|
5436
5623
|
}
|
|
5437
5624
|
else {
|
|
5438
5625
|
// Why log first-try wins explicitly: prior to this change, attempt-1
|
|
@@ -5442,19 +5629,29 @@ async function executeStepWithHealing(params) {
|
|
|
5442
5629
|
// collapse" (log showed 2 heals) but telemetry calls.ndjson showed
|
|
5443
5630
|
// 32 successful Stagehand acts. Surfacing attempt-1 wins lets the
|
|
5444
5631
|
// log match telemetry and prevents the same false alarm.
|
|
5445
|
-
logger.info(
|
|
5632
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
|
|
5446
5633
|
}
|
|
5447
5634
|
trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
|
|
5448
5635
|
return "completed";
|
|
5449
5636
|
}
|
|
5450
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
|
+
});
|
|
5451
5648
|
const reason = record.errorMessage
|
|
5452
5649
|
? effectSignals
|
|
5453
5650
|
? `${record.errorMessage}; ${effectSignals}`
|
|
5454
5651
|
: record.errorMessage
|
|
5455
5652
|
: effectSignals || "no observable effect (no network, url, or dom change)";
|
|
5456
5653
|
failureReasons.push(reason);
|
|
5457
|
-
logger.warn(
|
|
5654
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${record.technique}) produced no observable effect — ${reason}`);
|
|
5458
5655
|
// One additive strategy among many: when a click on a final-step
|
|
5459
5656
|
// submit/continue button fails AND the page surfaces a visible
|
|
5460
5657
|
// <error> sibling next to a touched+dirty ng-invalid wrapper, surface
|
|
@@ -5479,7 +5676,7 @@ async function executeStepWithHealing(params) {
|
|
|
5479
5676
|
for (const p of pairs) {
|
|
5480
5677
|
const validationReason = formatValidationRejectedReason(p);
|
|
5481
5678
|
failureReasons.push(validationReason);
|
|
5482
|
-
logger.warn(
|
|
5679
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${validationReason}`);
|
|
5483
5680
|
}
|
|
5484
5681
|
}
|
|
5485
5682
|
// Telemetry-driven early-exit: when attempt 1 on a final-Submit click
|
|
@@ -5501,6 +5698,17 @@ async function executeStepWithHealing(params) {
|
|
|
5501
5698
|
isAdvanceStep(step) &&
|
|
5502
5699
|
!urlChanged &&
|
|
5503
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
|
+
}
|
|
5504
5712
|
const postAttemptInvalidCount = await countNgInvalidContainers(page);
|
|
5505
5713
|
const earlyExit = isSubmitRevealedInvalid({
|
|
5506
5714
|
// Treat the canonical submit click as "final" for this predicate
|
|
@@ -5516,7 +5724,7 @@ async function executeStepWithHealing(params) {
|
|
|
5516
5724
|
if (earlyExit) {
|
|
5517
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`;
|
|
5518
5726
|
failureReasons.push(exitReason);
|
|
5519
|
-
logger.warn(
|
|
5727
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
|
|
5520
5728
|
break;
|
|
5521
5729
|
}
|
|
5522
5730
|
// Telemetry-driven early-exit for interior ADVANCE steps: if the Next
|
|
@@ -5536,7 +5744,7 @@ async function executeStepWithHealing(params) {
|
|
|
5536
5744
|
if (advanceStalled) {
|
|
5537
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`;
|
|
5538
5746
|
failureReasons.push(exitReason);
|
|
5539
|
-
logger.warn(
|
|
5747
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
|
|
5540
5748
|
break;
|
|
5541
5749
|
}
|
|
5542
5750
|
}
|
|
@@ -5564,9 +5772,9 @@ async function executeStepWithHealing(params) {
|
|
|
5564
5772
|
unfocusedObserve,
|
|
5565
5773
|
}) ?? null;
|
|
5566
5774
|
if (dumpPath !== null) {
|
|
5567
|
-
logger.error(
|
|
5775
|
+
logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
|
|
5568
5776
|
}
|
|
5569
|
-
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");
|
|
5570
5778
|
}
|
|
5571
5779
|
/** SPA-readiness gate defaults — mirror the recon CLI's post-navigation wait. */
|
|
5572
5780
|
const SPA_READINESS_TIMEOUT_MS = 15_000;
|
|
@@ -5648,6 +5856,7 @@ async function runHealingFlow(deps) {
|
|
|
5648
5856
|
upload: s.upload,
|
|
5649
5857
|
submitStep: s.submitStep,
|
|
5650
5858
|
stepIndex: i,
|
|
5859
|
+
totalSteps: () => steps.length,
|
|
5651
5860
|
phase: "flow",
|
|
5652
5861
|
signalCounter,
|
|
5653
5862
|
recentCaptures,
|