@proagentstore/cli 0.4.88 → 0.4.89
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.
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* The trace carries classes, domains, decisions and artifact handles — never a typed value.
|
|
26
26
|
*/
|
|
27
27
|
import { classifyApplyClick } from "./contract.js";
|
|
28
|
+
import { CONFIRMATION_INTERVAL_SECONDS, CONFIRMATION_LOOKS, CONFIRMATION_TEXT_RE, confirmationMarker, observe, } from "./confirmation.js";
|
|
28
29
|
import { ELEMENT_PROBE_FN } from "../commit-guard.js";
|
|
29
30
|
import { normalize } from "../local-artifact/engine.js";
|
|
30
31
|
import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
|
|
@@ -58,8 +59,15 @@ const CONSENT_NAME = /\b(agree|consent|terms|privacy|acknowledge|certify|accept|
|
|
|
58
59
|
const CREDENTIAL_NAME = /password|passcode|one[- ]?time|\botp\b|verification code|security code|2fa|two[- ]factor/i;
|
|
59
60
|
/** Keys that move between fields without entering or activating anything. */
|
|
60
61
|
const SAFE_KEYS = /^(Tab|Shift\+Tab|Arrow(Up|Down|Left|Right)|Escape|Home|End|PageUp|PageDown)$/;
|
|
61
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Runs inside the page, through the browser's own evaluate tool — never offered to the CLI.
|
|
64
|
+
*
|
|
65
|
+
* The success vocabulary is INTERPOLATED from `confirmation.ts` rather than written out here
|
|
66
|
+
* (#994): a second copy of that regex is how the "sent" family — the exact word SEEK uses — was
|
|
67
|
+
* missing from the detector while being present in nobody's tests, through three live runs.
|
|
68
|
+
*/
|
|
62
69
|
const INSPECT_PAGE = `() => {
|
|
70
|
+
const CONFIRMATION_TEXT_SOURCE = ${JSON.stringify(CONFIRMATION_TEXT_RE.source)};
|
|
63
71
|
const text = (document.body ? document.body.innerText : "").slice(0, 20000).toLowerCase();
|
|
64
72
|
const has = (s) => !!document.querySelector(s);
|
|
65
73
|
return {
|
|
@@ -70,7 +78,7 @@ const INSPECT_PAGE = `() => {
|
|
|
70
78
|
login: has('input[type=password]'),
|
|
71
79
|
duplicate: /you('ve| have) already applied|already applied (for|to) this|application (already exists|has already been (submitted|received))|duplicate application/.test(text),
|
|
72
80
|
antiBot: /unusual traffic|access (is )?denied|request (has been )?blocked|bot detected|checking (if the site connection is secure|your browser)/.test(text),
|
|
73
|
-
confirmed:
|
|
81
|
+
confirmed: new RegExp(CONFIRMATION_TEXT_SOURCE).test(text),
|
|
74
82
|
unavailable: /(?:this |the )?(?:job|position|posting|role|opportunity).{0,120}(?:has )?(?:expired|closed|been filled|is no longer available|is no longer accepting applications)|(?:expired|closed|no longer available|no longer accepting applications).{0,120}(?:job|position|posting|role|opportunity)/.test(text)
|
|
75
83
|
? (/expired/.test(text) ? "expired" : "unavailable")
|
|
76
84
|
: null,
|
|
@@ -120,6 +128,8 @@ export class ApplyBridge {
|
|
|
120
128
|
/** Present only after the CLI explicitly reports a notice that the runner independently saw. */
|
|
121
129
|
unavailable = null;
|
|
122
130
|
submitAttempted = false;
|
|
131
|
+
/** What was seen after the submit click (#994) — read by the runtime to build the diagnostic. */
|
|
132
|
+
confirmation = null;
|
|
123
133
|
submitted = null;
|
|
124
134
|
/** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
|
|
125
135
|
blocked = null;
|
|
@@ -609,17 +619,44 @@ export class ApplyBridge {
|
|
|
609
619
|
this.submitAttempted = true;
|
|
610
620
|
this.host.emit({ type: "submit.attempted", url: before.url, domain, detail: { class: "submit", gateId: this.host.gateId } });
|
|
611
621
|
const res = await this.browser.callTool("browser_click", forward);
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
622
|
+
// Look REPEATEDLY, not once (#994). The old path waited two seconds and read the page a
|
|
623
|
+
// single time, so a receipt that arrives on a navigation or a client-side re-render a moment
|
|
624
|
+
// later was simply not there yet when the only look happened. `submitAttempted` is already
|
|
625
|
+
// true above, so waiting longer cannot make a pressed button look unpressed.
|
|
626
|
+
const { after, looks } = res.isError ? { after: null, looks: 0 } : await this.awaitConfirmation(before);
|
|
627
|
+
const seen = observe(before, after, looks);
|
|
628
|
+
this.confirmation = seen;
|
|
629
|
+
const evidence = { looks: seen.looks, urlChanged: seen.urlChanged, titleChanged: seen.titleChanged };
|
|
630
|
+
if (seen.marker) {
|
|
631
|
+
const at = after;
|
|
632
|
+
this.submitted = { url: at.url, at: new Date(this.host.now()).toISOString(), gateId: this.host.gateId };
|
|
633
|
+
this.host.emit({ type: "submit.confirmed", url: at.url, domain: hostOf(at.url) ?? domain, detail: { gateId: this.host.gateId, marker: seen.marker, ...evidence } });
|
|
617
634
|
return text("Submitted, and the site confirmed it. Stop now.");
|
|
618
635
|
}
|
|
619
636
|
this.blocked = { reason: "submit_unconfirmed", questions: ["The application was submitted but the site did not confirm it. Check the employer's site or your email before anything is retried."] };
|
|
620
|
-
this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
|
|
637
|
+
this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId, ...evidence } });
|
|
621
638
|
return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
|
|
622
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* Read the page until it confirms, or until the looks run out (#994).
|
|
642
|
+
*
|
|
643
|
+
* It returns the LAST page it read, not the first: when nothing confirms, the owner's
|
|
644
|
+
* diagnostic should describe where the browser actually ended up. Bounded by
|
|
645
|
+
* `CONFIRMATION_LOOKS` — an application run holds the owner's only browser, so "look again"
|
|
646
|
+
* must never become "sit here".
|
|
647
|
+
*/
|
|
648
|
+
async awaitConfirmation(before) {
|
|
649
|
+
let after = null;
|
|
650
|
+
for (let i = 0; i < CONFIRMATION_LOOKS; i++) {
|
|
651
|
+
await this.browser.callTool("browser_wait_for", { time: CONFIRMATION_INTERVAL_SECONDS }).catch(() => undefined);
|
|
652
|
+
const page = await this.inspect();
|
|
653
|
+
if (page)
|
|
654
|
+
after = page;
|
|
655
|
+
if (confirmationMarker(before, page))
|
|
656
|
+
return { after: page, looks: i + 1 };
|
|
657
|
+
}
|
|
658
|
+
return { after, looks: CONFIRMATION_LOOKS };
|
|
659
|
+
}
|
|
623
660
|
/** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
|
|
624
661
|
requireSupervisor(tool, actionClass) {
|
|
625
662
|
if (this.supervisorApproved)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did the employer's site confirm the submission? (#994)
|
|
3
|
+
*
|
|
4
|
+
* ── The live failure this module exists for
|
|
5
|
+
*
|
|
6
|
+
* Three distinct approved auto-submit runs on `au.seek.com` reached `submit.attempted` and all
|
|
7
|
+
* three ended terminal `blocked / submit_unconfirmed` with `submittedAt=null`: applications
|
|
8
|
+
* `435d31c8…` (run `a4bf5c0f…`), `71765edd…` (run `76416747…`) and `b6244557…` (run `2f2e88a5…`).
|
|
9
|
+
* The honest safety state was right — nothing was retried, and the owner was told to check the
|
|
10
|
+
* employer's site — but a 3/3 rate says the DETECTOR was wrong, not the sites.
|
|
11
|
+
*
|
|
12
|
+
* Three facts about the old detector, each read off the code rather than guessed at:
|
|
13
|
+
*
|
|
14
|
+
* 1. **The vocabulary had no "sent".** It matched `thank you for (your) appl`,
|
|
15
|
+
* `application (has been|was) (received|submitted)`, `we('ve| have) received your application`
|
|
16
|
+
* and `successfully (applied|submitted)` — and nothing else. SEEK's own post-apply wording is
|
|
17
|
+
* the "sent" family ("Application sent", "Your application has been sent", "You've applied"),
|
|
18
|
+
* so the one word the page actually uses was the one word the regex could not see.
|
|
19
|
+
* 2. **It looked once, 2 seconds after the click** (`browser_wait_for {time: 2}` then one
|
|
20
|
+
* `inspect()`), so a success state that arrives on a navigation or a client-side re-render a
|
|
21
|
+
* moment later was simply not there yet when the only look happened.
|
|
22
|
+
* 3. **Text was the only evidence considered.** The URL the click navigates to, the title, and
|
|
23
|
+
* the site's own "you have already applied" notice were all collected by `INSPECT_PAGE` and
|
|
24
|
+
* none of them counted.
|
|
25
|
+
*
|
|
26
|
+
* What remains UNVERIFIED, and is stated here rather than implied: no live SEEK page was captured
|
|
27
|
+
* while writing this, so which of the three was decisive for those three runs is not established.
|
|
28
|
+
* The tests below use realistic fixtures of the wording SEEK and the big ATSs publish, which the
|
|
29
|
+
* issue explicitly allows; a live capture would still be worth taking.
|
|
30
|
+
*
|
|
31
|
+
* ── What counts as confirmation
|
|
32
|
+
*
|
|
33
|
+
* A named marker, never a feeling. Every marker is an id this platform defines, and only the ID
|
|
34
|
+
* crosses the contract — never the text that matched it. That is the same rule `signalsOf` follows
|
|
35
|
+
* in `runtime.ts` for engine output, and for the same reason: an application page holds the
|
|
36
|
+
* owner's typed answers, and no redaction reliably separates those from the rest of the prose.
|
|
37
|
+
*
|
|
38
|
+
* The URL and duplicate markers are deliberately comparative — they need the page as it was BEFORE
|
|
39
|
+
* the click, which the submit path already has. A `/success` URL that was already there is not
|
|
40
|
+
* evidence of this click; "you have already applied" that was already there is somebody's earlier
|
|
41
|
+
* application, not this one.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* The success vocabulary, as ONE source used in two places: this module (tested in Node) and the
|
|
45
|
+
* in-page script (built from `.source`, executed in the browser). Two copies of a regex is how the
|
|
46
|
+
* "sent" family went missing from one of them for three live runs.
|
|
47
|
+
*
|
|
48
|
+
* Lower-cased text is matched, so the patterns carry no case class.
|
|
49
|
+
*/
|
|
50
|
+
export const CONFIRMATION_TEXT_RE = /thank you for (your )?appl|application (has been |was )?(received|submitted|sent)|we('ve| have) received your application|successfully (applied|submitted|sent)|your application (is|has been) (on its way|complete)|you(r application)?('ve| have)? (now )?applied|applied (on|to this job)|application (sent|complete|submitted) *[.!]?$/;
|
|
51
|
+
/**
|
|
52
|
+
* A URL that is itself the receipt. Matched against the path and query of the page the click
|
|
53
|
+
* landed on, and only when the click CHANGED it.
|
|
54
|
+
*
|
|
55
|
+
* Kept narrow on purpose: `/apply` alone is the form, not the confirmation, and treating it as one
|
|
56
|
+
* would turn "the click did nothing" into "submitted".
|
|
57
|
+
*/
|
|
58
|
+
export const CONFIRMATION_URL_RE = /(^|\/)(apply|application|applications|job)?[^/]*\/(success|succeeded|complete|completed|confirmation|confirmed|submitted|sent|thank-?you|receipt)(\/|$|\?)|[?&](applied|submitted|success)=(1|true|yes)/;
|
|
59
|
+
/** Ordered by how strong the evidence is: the first match is the one recorded. */
|
|
60
|
+
export function confirmationMarker(before, after) {
|
|
61
|
+
if (!after)
|
|
62
|
+
return null;
|
|
63
|
+
if (after.confirmed)
|
|
64
|
+
return "page_text";
|
|
65
|
+
const url = urlPathAndQuery(after.url).toLowerCase();
|
|
66
|
+
if (CONFIRMATION_URL_RE.test(url) && !!before && urlPathAndQuery(before.url).toLowerCase() !== url)
|
|
67
|
+
return "url_receipt";
|
|
68
|
+
// The site's own duplicate notice, but ONLY if it appeared after this click: "you have already
|
|
69
|
+
// applied" that was on the page beforehand is an earlier application of the owner's, and
|
|
70
|
+
// reading it as this run's success would record a submission that never happened.
|
|
71
|
+
if (after.duplicate && before && !before.duplicate)
|
|
72
|
+
return "already_applied_notice";
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** Path + query only: a host or a fragment is not evidence, and the host is recorded separately. */
|
|
76
|
+
function urlPathAndQuery(raw) {
|
|
77
|
+
try {
|
|
78
|
+
const u = new URL(raw);
|
|
79
|
+
return `${u.pathname}${u.search}`;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return raw;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* How long to keep looking, and how often.
|
|
87
|
+
*
|
|
88
|
+
* The old path waited 2s once. These are bounded for the reason every wait in this runner is: an
|
|
89
|
+
* application run holds the owner's only browser, so "look again" must not become "sit here". Six
|
|
90
|
+
* looks over ~9s covers a navigation and a client-side re-render, and stops long before the
|
|
91
|
+
* engine's own minute budget notices.
|
|
92
|
+
*/
|
|
93
|
+
export const CONFIRMATION_LOOKS = 6;
|
|
94
|
+
export const CONFIRMATION_INTERVAL_SECONDS = 1.5;
|
|
95
|
+
export function observe(before, after, looks) {
|
|
96
|
+
return {
|
|
97
|
+
marker: confirmationMarker(before, after),
|
|
98
|
+
looks,
|
|
99
|
+
urlChanged: !!after && !!before && after.url !== before.url,
|
|
100
|
+
titleChanged: !!after && !!before && after.title !== before.title,
|
|
101
|
+
unreadable: !after,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The closed-vocabulary signals that go on the run's diagnostic when a submit was pressed and not
|
|
106
|
+
* confirmed, so the owner's record says what was seen rather than only what was not.
|
|
107
|
+
*
|
|
108
|
+
* Each is an id, so nothing here can carry page text; together they distinguish the three cases a
|
|
109
|
+
* person would act on differently: the page never changed (the click may not have landed), the page
|
|
110
|
+
* moved but said nothing this detector knows (a wording to add), and the page could not be read.
|
|
111
|
+
*/
|
|
112
|
+
export function confirmationSignals(o) {
|
|
113
|
+
const out = [];
|
|
114
|
+
if (o.unreadable)
|
|
115
|
+
out.push("confirmation_page_unreadable");
|
|
116
|
+
else if (!o.marker)
|
|
117
|
+
out.push("confirmation_no_marker");
|
|
118
|
+
if (o.urlChanged)
|
|
119
|
+
out.push("confirmation_url_changed");
|
|
120
|
+
else if (!o.unreadable)
|
|
121
|
+
out.push("confirmation_url_unchanged");
|
|
122
|
+
if (o.titleChanged)
|
|
123
|
+
out.push("confirmation_title_changed");
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -82,31 +82,38 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
82
82
|
* dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
|
|
83
83
|
* `runnerContractProblem`).
|
|
84
84
|
*
|
|
85
|
-
* ── Why it moved to 0.4.
|
|
85
|
+
* ── Why it moved to 0.4.89 (#994)
|
|
86
86
|
*
|
|
87
|
-
* #
|
|
88
|
-
*
|
|
89
|
-
* `packages/browser-runner`, which ships INSIDE the published
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* `FALLBACK_COMMIT_RE`. The next live run therefore reproduced the defect the fix had already
|
|
93
|
-
* removed from `main` — `browser_click → class: submit → refused: fill_and_review`, no `rule`
|
|
94
|
-
* field, three seconds after the cloud's own `continue` — and the only durable evidence that the
|
|
95
|
-
* code was old was the absence of a field nobody looks for.
|
|
87
|
+
* #994 makes a post-click confirmation observable and recognises SEEK's "application sent"
|
|
88
|
+
* receipt wording. It also carries only closed-vocabulary evidence when a pressed submit remains
|
|
89
|
+
* unconfirmed. Those changes live in `packages/browser-runner`, which ships INSIDE the published
|
|
90
|
+
* CLI. A machine below this floor cannot provide that evidence, so it must not silently look
|
|
91
|
+
* equivalent to a current runner.
|
|
96
92
|
*
|
|
97
93
|
* A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
|
|
98
|
-
* So this floor moves with it: a machine that cannot
|
|
99
|
-
*
|
|
100
|
-
* nothing.
|
|
94
|
+
* So this floor moves with it: a machine that cannot observe a post-submit receipt is refused
|
|
95
|
+
* with a sentence that says so, rather than reporting an unhelpful `submit_unconfirmed` state.
|
|
101
96
|
*
|
|
102
97
|
* Bump this when the runner's half of the contract changes again, together with the CLI version
|
|
103
98
|
* that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
|
|
104
99
|
* been published. `cliAtLeast` treats an unreported version as capable, which is the convention
|
|
105
100
|
* every MIN_CLI gate here follows.
|
|
106
101
|
*/
|
|
107
|
-
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.
|
|
108
|
-
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
|
|
109
|
-
export const
|
|
102
|
+
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.89";
|
|
103
|
+
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
|
|
104
|
+
export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
|
|
105
|
+
export const LOCAL_APPLY_SIGNALS = [
|
|
106
|
+
"approval_policy_blocked",
|
|
107
|
+
"bridge_tools_missing",
|
|
108
|
+
"auth_prompt",
|
|
109
|
+
"no_output",
|
|
110
|
+
"engine_refused_task",
|
|
111
|
+
"confirmation_no_marker",
|
|
112
|
+
"confirmation_page_unreadable",
|
|
113
|
+
"confirmation_url_changed",
|
|
114
|
+
"confirmation_url_unchanged",
|
|
115
|
+
"confirmation_title_changed",
|
|
116
|
+
];
|
|
110
117
|
export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
|
|
111
118
|
export const LOCAL_APPLY_EVENT_TYPES = [
|
|
112
119
|
"engine.auth_checked",
|
|
@@ -140,7 +147,11 @@ const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
|
|
|
140
147
|
// submit since #985, and this whitelist silently dropped it — so the one fact that tells "it
|
|
141
148
|
// refused the control that opens the form" from "it refused the real submit" never reached the
|
|
142
149
|
// cloud trace, which is where the live investigation needed it.
|
|
143
|
-
|
|
150
|
+
// `marker`, `looks`, `urlChanged` and `titleChanged` are #994's post-click evidence: a marker ID
|
|
151
|
+
// from `LOCAL_APPLY_CONFIRMATION_MARKERS`, how many times the page was read, and whether it moved.
|
|
152
|
+
// Each is an id, a count or a boolean — the same whitelist rule as everything else here, so no page
|
|
153
|
+
// text and no typed value can ride along on a `submit.confirmed` / `submit.unconfirmed` event.
|
|
154
|
+
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "rule", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded", "marker", "looks", "urlChanged", "titleChanged"]);
|
|
144
155
|
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
145
156
|
export function parseLocalApplyEvent(raw) {
|
|
146
157
|
if (!raw || typeof raw !== "object")
|
|
@@ -167,6 +178,15 @@ export function parseLocalApplyEvent(raw) {
|
|
|
167
178
|
for (const [k, v] of Object.entries(o.detail)) {
|
|
168
179
|
if (!DETAIL_KEYS.has(k))
|
|
169
180
|
continue;
|
|
181
|
+
// #994: `marker` is a runner-defined confirmation ID, never the success sentence the
|
|
182
|
+
// page displayed. Keep the closed vocabulary closed even if a compromised/outdated
|
|
183
|
+
// runner tries to use this otherwise-string field as a prose channel.
|
|
184
|
+
if (k === "marker") {
|
|
185
|
+
const marker = oneOf(LOCAL_APPLY_CONFIRMATION_MARKERS, v);
|
|
186
|
+
if (marker)
|
|
187
|
+
detail[k] = marker;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
170
190
|
if (typeof v === "string")
|
|
171
191
|
detail[k] = v.slice(0, 300);
|
|
172
192
|
else if (typeof v === "number" || typeof v === "boolean")
|
|
@@ -33,6 +33,7 @@ import { redactText, secretEnvValues } from "../local-browser/contract.js";
|
|
|
33
33
|
import { finalText, missingLogin, signInHelp } from "../local-browser/engine.js";
|
|
34
34
|
import { resolveWorkspacePath } from "../local-browser/runtime.js";
|
|
35
35
|
import { ApplyBridge } from "./bridge.js";
|
|
36
|
+
import { confirmationSignals } from "./confirmation.js";
|
|
36
37
|
import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, } from "./contract.js";
|
|
37
38
|
import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
|
|
38
39
|
const MAX_EVENTS = 1000;
|
|
@@ -372,8 +373,20 @@ export class LocalApplyRuntime {
|
|
|
372
373
|
return { outcome: "submitted", summary, submitted: b.submitted };
|
|
373
374
|
if (b?.unavailable)
|
|
374
375
|
return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
|
|
375
|
-
|
|
376
|
-
|
|
376
|
+
// #994: a pressed submit nobody confirmed now SAYS what was seen. The outcome is unchanged —
|
|
377
|
+
// terminal, no retry, the owner told to check the employer's site — because that safety is
|
|
378
|
+
// correct; what was missing was any way to tell "the click never moved the page" from "the
|
|
379
|
+
// page moved and said something this platform does not recognise yet".
|
|
380
|
+
if (b?.submitAttempted) {
|
|
381
|
+
const d = this.diagnosticOf(run, code, "submit_unconfirmed");
|
|
382
|
+
return {
|
|
383
|
+
outcome: "blocked",
|
|
384
|
+
summary,
|
|
385
|
+
blockReason: "submit_unconfirmed",
|
|
386
|
+
questions: b.blocked?.questions ?? [],
|
|
387
|
+
diagnostic: { ...d, signals: [...d.signals, ...(b.confirmation ? confirmationSignals(b.confirmation) : [])] },
|
|
388
|
+
};
|
|
389
|
+
}
|
|
377
390
|
if (b?.reviewReady)
|
|
378
391
|
return { outcome: "awaiting_review", summary };
|
|
379
392
|
if (!b?.filled && missingLogin(e.engine, run.output.join("\n")))
|