diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 10bd74ef..a9fee6f8 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -1653,28 +1653,14 @@ async function controlPageLivenessSnapshot(reason, timeoutMs = 1500) { })); } -async function ensureControlPageResponsiveForCommand(reason) { - const beforeUrl = currentPageUrl(); - const liveness = await controlPageLivenessSnapshot(reason + "-preflight", 3000); - if (liveness.ok) return { ok: true, recovered: false, reason, beforeUrl, afterUrl: currentPageUrl(), liveness, pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; - const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl); - await appendJsonl(files.control, eventRecord("control-page-unresponsive-before-command", { - reason, - beforeUrl, - target, - liveness, - pageRole: "control", - pageId, - pageEpoch: controlPageEpoch, - valuesRedacted: true - })); +async function recoverControlPageToTarget(reason, beforeUrl, target, liveness = null) { let navigation = null; let hydration = null; let afterLiveness = null; const attempts = []; let ok = false; for (let attempt = 1; attempt <= 2; attempt += 1) { - await recreateControlPageForNavigation(reason + "-control-page-unresponsive", attempt); + await recreateControlPageForNavigation(reason + "-control-page-recovery", attempt); try { navigation = await gotoTarget(target.targetPath); } catch (error) { @@ -1694,7 +1680,7 @@ async function ensureControlPageResponsiveForCommand(reason) { attempts.push({ attempt, ok, navigation, hydration, afterLiveness, pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }); if (ok) break; } - const recovery = { + return { ok, recovered: ok, reason, @@ -1711,8 +1697,26 @@ async function ensureControlPageResponsiveForCommand(reason) { pageEpoch: controlPageEpoch, valuesRedacted: true }; - await appendJsonl(files.control, eventRecord(ok ? "control-page-recovered-before-command" : "control-page-recovery-failed-before-command", recovery)); - if (!ok) { +} + +async function ensureControlPageResponsiveForCommand(reason) { + const beforeUrl = currentPageUrl(); + const liveness = await controlPageLivenessSnapshot(reason + "-preflight", 3000); + if (liveness.ok) return { ok: true, recovered: false, reason, beforeUrl, afterUrl: currentPageUrl(), liveness, pageRole: "control", pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; + const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl); + await appendJsonl(files.control, eventRecord("control-page-unresponsive-before-command", { + reason, + beforeUrl, + target, + liveness, + pageRole: "control", + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + })); + const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness); + await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-recovered-before-command" : "control-page-recovery-failed-before-command", recovery)); + if (!recovery.ok) { const error = new Error("control page recovery failed before " + reason); error.details = recovery; throw error; @@ -1720,17 +1724,56 @@ async function ensureControlPageResponsiveForCommand(reason) { return recovery; } +async function forceRecoverControlPageForCommand(reason) { + const beforeUrl = currentPageUrl(); + const liveness = await controlPageLivenessSnapshot(reason + "-snapshot", 3000); + const target = controlPageRecoveryTarget(liveness.snapshot, beforeUrl); + await appendJsonl(files.control, eventRecord("control-page-forced-recovery-before-command", { + reason, + beforeUrl, + target, + liveness, + pageRole: "control", + pageId, + pageEpoch: controlPageEpoch, + valuesRedacted: true + })); + const recovery = await recoverControlPageToTarget(reason, beforeUrl, target, liveness); + await appendJsonl(files.control, eventRecord(recovery.ok ? "control-page-forced-recovered-before-command" : "control-page-forced-recovery-failed-before-command", recovery)); + return recovery; +} + async function sendPrompt(text, options = {}) { if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text"); const responsePath = options.responsePath || "/v1/agent/chat"; const controlRecovery = await ensureControlPageResponsiveForCommand("sendPrompt"); const beforeUrl = currentPageUrl(); const beforeEvidence = await promptSideEffectSnapshot(); - const primaryEditor = page.locator("#command-input").last(); - const editor = await primaryEditor.isVisible().catch(() => false) - ? primaryEditor - : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); - await withHardTimeout(editor.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt composer editor did not become visible within 20s"); + let editor = null; + let composerRecovery = null; + let editorWaitError = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + const primaryEditor = page.locator("#command-input").last(); + const candidate = await primaryEditor.isVisible().catch(() => false) + ? primaryEditor + : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); + try { + await withHardTimeout(candidate.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt composer editor did not become visible within 20s"); + editor = candidate; + break; + } catch (error) { + editorWaitError = error; + if (attempt >= 2) break; + composerRecovery = await forceRecoverControlPageForCommand("sendPrompt-composer-editor-missing"); + if (composerRecovery.ok !== true) break; + } + } + if (!editor) { + const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-editor-missing-final", 3000); + const error = new Error("sendPrompt composer editor did not become visible"); + error.details = { beforeUrl, afterUrl: currentPageUrl(), controlRecovery, composerRecovery, snapshot, editorWaitError: errorSummary(editorWaitError), pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; + throw error; + } await fillComposerEditor(editor, text); const primarySubmitSelector = '#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]'; const primarySubmit = page.locator(primarySubmitSelector).last();