From 27412e4fa97ed041002b6dddd330724e08028dcd Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 5 Oct 2026 07:21:50 -0500 Subject: [PATCH 1/2] fix(agent): a consent question naming the phone or email is not that field (#414); 1.66.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drafter's deterministic phone and email rules matched any label that mentions a phone or an email. Prenuvo's "By selecting YES, I consent to receive recruiting SMS messages … at the phone number provided" was answered with the phone number, left unmapped by the dry run, and put back over a hand-set "Yes" on every rebuild. A label that asks for consent, opt-in or agreement, or names SMS, text messages, WhatsApp or subscribing, now goes to the model instead. Plain "Phone" and "Mobile phone number" still take the number. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXYffreKMhXLNgc2wdhsiw --- .env.production.example | 2 +- BACKLOG.md | 1 + .../AnswerDrafterTests.cs | 19 +++++++++++++++++++ api/ApplyTrack.Api/Agent/AnswerDrafter.cs | 10 ++++++++-- api/ApplyTrack.Api/ApplyTrack.Api.csproj | 2 +- pyproject.toml | 2 +- src/applytrack/__init__.py | 2 +- uv.lock | 2 +- 8 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.env.production.example b/.env.production.example index c616601..f807c91 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,7 +1,7 @@ # Production deployments intentionally have no working secret defaults. # Copy this file to .env.production, replace every CHANGE-ME value, keep the # result out of version control, and pin a released OSApplyTrack version. -OSAPPLYTRACK_VERSION=1.66.5 +OSAPPLYTRACK_VERSION=1.66.6 POSTGRES_USER=applytrack POSTGRES_PASSWORD=CHANGE-ME-use-a-long-random-password POSTGRES_DB=applytrack diff --git a/BACKLOG.md b/BACKLOG.md index 95de901..dedce78 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -165,6 +165,7 @@ review items and unsupported forms still need human attention. - [ ] [#408 — Agent: LinkedIn SDUI Easy Apply — the contact step's City typeahead is still left unmapped (SMX)](https://github.com/CryptoJones/OSApplyTrack/issues/408) (found re-running the Ready lane after #406: `CityLabel()` matches and the résumé location is typed, but the typeahead is never committed on the SDUI dialog; the run stops at the contact step with `City` the only unmapped field) - [ ] [#410 — Agent: a packet's whole answer draft rides one LLM completion under the API's 120 s timeout — Orbis's 29-question form timed out twice and every answer was voided](https://github.com/CryptoJones/OSApplyTrack/issues/410) (not the network or the endpoint — a 36 KB request from the agent's own netns answers in 1.5 s; the real 22-question prompt just takes poolside longer than two minutes. Mitigated on pluto with `Llm__TimeoutSeconds=600` on the agent; the fix is a worker-side ceiling, chunked drafting, or both) - [ ] [#409 — Agent: LinkedIn SDUI Easy Apply — Trility's dialog never reaches Submit within 12 steps](https://github.com/CryptoJones/OSApplyTrack/issues/409) (`mapped: 0, unmapped: []` — the driver presses Next twelve times on a step it cannot leave and reports nothing about it; needs the screenshot and the live dialog) +- [x] [#414 — Agent: a consent question that mentions the phone number gets answered with the phone number (Prenuvo SMS opt-in)](https://github.com/CryptoJones/OSApplyTrack/issues/414) (fixed in 1.66.6: the drafter's phone and email rules skip a label that asks for consent, opt-in or agreement, or names SMS, text messages, WhatsApp or subscribing; plain "Phone" / "Mobile phone number" still take the number.) - [x] [#412 — Agent: LinkedIn SDUI years-of-experience text boxes refuse prose answers ("5+ years")](https://github.com/CryptoJones/OSApplyTrack/issues/412) (fixed in 1.66.5: the SDUI dialog draws "How many years …?" as a plain 20-character text box, so the #332 whole-number coercion keyed on `type=number` never fired and the drafter's "10+ years — …" was refused after Next. A label that asks "How many years" now gets the whole number its answer states, and "Less than 1 year" reads 0, not 1. Found on the 2026-10-05 Ready sweep: seven LinkedIn rows rebuilt on Claude answered every question and still dry-ran 0 mapped.) - [x] [#406 — Agent: Ready rows stuck on failures the worker should have recovered from — DNS blip stamped non-transient, browser crash unflagged, LLM 429 hammered every tick](https://github.com/CryptoJones/OSApplyTrack/issues/406) (fixed in 1.66.4: "the posting's host could not be resolved" is a `TransientRunException`, retried once after two seconds and recorded as transient, so the reconciler retries it; a run the browser died under ("Target page, context or browser has been closed") is flagged `transient` by the submitter and read by those words from before; and a pass stops judging at the first `LlmUnavailableException` and holds the tenant off for 15 minutes instead of asking the next eleven leads every five minutes. The eight rows the fix could not reach — four stored `transient: false`, launchdarkly's crash, and three LinkedIn runs from before #403 — were queued again by hand.) - [x] [#403 — Agent: LinkedIn Easy Apply times out on the new SDUI dialog (no .jobs-easy-apply-modal)](https://github.com/CryptoJones/OSApplyTrack/issues/403) (fixed in 1.66.2: the driver now also recognizes LinkedIn's SDUI ``. It finds Next / Review / Submit application and Dismiss by their text or label, reads fields from `label[for]` / `aria-label` and radio options from the text beside each one, catches errors that appear only after a refused Next, unticks Follow by its aria-label, and answers the blank mobile-number box from the packet's standard phone. Checked against the live dialog on 2026-10-03.) diff --git a/api/ApplyTrack.Api.Tests/AnswerDrafterTests.cs b/api/ApplyTrack.Api.Tests/AnswerDrafterTests.cs index f55a3b7..de7642f 100644 --- a/api/ApplyTrack.Api.Tests/AnswerDrafterTests.cs +++ b/api/ApplyTrack.Api.Tests/AnswerDrafterTests.cs @@ -545,4 +545,23 @@ public void Fit_to_options_matches_case_insensitively_and_splits_a_multiselect() var free = new PacketQuestion("q", "Why", true, PacketQuestion.Textarea, [], PacketQuestion.Custom); Assert.Equal(("because", null), AnswerDrafter.FitToOptions(free, "because")); } + + [Theory] + [InlineData("By selecting YES, I consent to receive recruiting SMS messages from Prenuvo at the phone number provided on my job application.")] + [InlineData("Do you opt-in to receive WhatsApp messages at your mobile number?")] + [InlineData("I agree to receive updates by email")] + public void A_consent_question_naming_a_phone_or_email_is_not_that_field(string label) + { + var (answer, _) = AnswerDrafter.Deterministic( + new("question_1", label, true, PacketQuestion.Select, ["Yes", "No"], PacketQuestion.Custom), Ctx()); + Assert.NotEqual("555-0100", answer); + Assert.False(answer is { } a && a.Contains('@')); + } + + [Theory] + [InlineData("Phone")] + [InlineData("Mobile phone number")] + public void A_plain_phone_label_still_takes_the_number(string label) => + Assert.Equal(("555-0100", null), AnswerDrafter.Deterministic( + new("question_2", label, true, PacketQuestion.Text, [], PacketQuestion.Custom), Ctx())); } diff --git a/api/ApplyTrack.Api/Agent/AnswerDrafter.cs b/api/ApplyTrack.Api/Agent/AnswerDrafter.cs index b996a6a..753b9ec 100644 --- a/api/ApplyTrack.Api/Agent/AnswerDrafter.cs +++ b/api/ApplyTrack.Api/Agent/AnswerDrafter.cs @@ -55,6 +55,11 @@ public sealed partial class AnswerDrafter private static partial Regex Phone(); [GeneratedRegex(@"\be-?mail\b", RegexOptions.IgnoreCase)] private static partial Regex EmailRe(); + // A question that asks permission to use the number or address ("By selecting YES, I + // consent to receive recruiting SMS messages … at the phone number provided") is a + // yes/no, not the contact field it names. Prenuvo's got the phone number typed in (#414). + [GeneratedRegex(@"\bconsent|\bopt[- ]?in\b|\bagree\b|\bsubscribe|\bsms\b|text messages|whatsapp", RegexOptions.IgnoreCase)] + private static partial Regex Consent(); [GeneratedRegex(@"first name|given name", RegexOptions.IgnoreCase)] private static partial Regex FirstName(); [GeneratedRegex(@"last name|surname|family name", RegexOptions.IgnoreCase)] @@ -211,9 +216,10 @@ public static (string? Answer, string? Reason) Deterministic(PacketQuestion q, A } if (FullName().IsMatch(label)) return ctx.Resume.FullName.Length > 0 ? (ctx.Resume.FullName, null) : (null, "add your name in Résumé settings"); - if (id is "email" || EmailRe().IsMatch(label)) + var consent = Consent().IsMatch(label); + if (id is "email" || (EmailRe().IsMatch(label) && !consent)) return ctx.Email.Length > 0 ? (ctx.Email, null) : (null, "no email on the account"); - if (id is "phone" || Phone().IsMatch(label)) + if (id is "phone" || (Phone().IsMatch(label) && !consent)) return ctx.Settings.Phone.Length > 0 ? (ctx.Settings.Phone, null) : (null, "add a phone number in Settings · Agent"); if (id is "cover_letter") return ctx.CoverLetter.Length > 0 ? (ctx.CoverLetter, null) : (null, "no cover letter drafted"); diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index 4d79c13..621a599 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.66.5 + 1.66.6 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/pyproject.toml b/pyproject.toml index 846eba1..f46176c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.66.5" +version = "1.66.6" description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/src/applytrack/__init__.py b/src/applytrack/__init__.py index a344878..8f79b5d 100644 --- a/src/applytrack/__init__.py +++ b/src/applytrack/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """applytrack — a local, markdown-backed job-application tracker.""" -__version__ = "1.66.5" +__version__ = "1.66.6" diff --git a/uv.lock b/uv.lock index 5b108f8..c83ac9e 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.66.5" +version = "1.66.6" source = { editable = "." } dependencies = [ { name = "cryptography" }, From e8d8fb5bf5e2e4246d79151aff886e31c772f5f6 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 5 Oct 2026 07:32:04 -0500 Subject: [PATCH 2/2] fix(agent): two controls under one label each take their own answer (#416) OneStream's form has two "Country*" pickers, the phone number's and the address block's. The driver tries the exact label first and prefers the match that is still empty, but a react-select keeps its input empty after a choice, so both read empty and the phone's picker took the address answer every run. When a locator matches several controls, the one whose id or name is the question's own id now wins. A fixture with two "Country*" react-selects posts both values; it fails without the change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXYffreKMhXLNgc2wdhsiw --- BACKLOG.md | 1 + .../BrowserSubmitterTests.cs | 41 +++++++++++++++++++ .../Agent/Browser/BrowserSubmitter.cs | 25 +++++++++++ 3 files changed, 67 insertions(+) diff --git a/BACKLOG.md b/BACKLOG.md index dedce78..a099068 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -165,6 +165,7 @@ review items and unsupported forms still need human attention. - [ ] [#408 — Agent: LinkedIn SDUI Easy Apply — the contact step's City typeahead is still left unmapped (SMX)](https://github.com/CryptoJones/OSApplyTrack/issues/408) (found re-running the Ready lane after #406: `CityLabel()` matches and the résumé location is typed, but the typeahead is never committed on the SDUI dialog; the run stops at the contact step with `City` the only unmapped field) - [ ] [#410 — Agent: a packet's whole answer draft rides one LLM completion under the API's 120 s timeout — Orbis's 29-question form timed out twice and every answer was voided](https://github.com/CryptoJones/OSApplyTrack/issues/410) (not the network or the endpoint — a 36 KB request from the agent's own netns answers in 1.5 s; the real 22-question prompt just takes poolside longer than two minutes. Mitigated on pluto with `Llm__TimeoutSeconds=600` on the agent; the fix is a worker-side ceiling, chunked drafting, or both) - [ ] [#409 — Agent: LinkedIn SDUI Easy Apply — Trility's dialog never reaches Submit within 12 steps](https://github.com/CryptoJones/OSApplyTrack/issues/409) (`mapped: 0, unmapped: []` — the driver presses Next twelve times on a step it cannot leave and reports nothing about it; needs the screenshot and the live dialog) +- [x] [#416 — Agent: two controls under one label — OneStream's address Country lost to the phone's country picker](https://github.com/CryptoJones/OSApplyTrack/issues/416) (fixed in 1.66.6: when a locator matches several controls, the one whose id or name is the question's own id wins; a react-select reads empty after a choice, so "prefer the empty one" took the phone's picker every time.) - [x] [#414 — Agent: a consent question that mentions the phone number gets answered with the phone number (Prenuvo SMS opt-in)](https://github.com/CryptoJones/OSApplyTrack/issues/414) (fixed in 1.66.6: the drafter's phone and email rules skip a label that asks for consent, opt-in or agreement, or names SMS, text messages, WhatsApp or subscribing; plain "Phone" / "Mobile phone number" still take the number.) - [x] [#412 — Agent: LinkedIn SDUI years-of-experience text boxes refuse prose answers ("5+ years")](https://github.com/CryptoJones/OSApplyTrack/issues/412) (fixed in 1.66.5: the SDUI dialog draws "How many years …?" as a plain 20-character text box, so the #332 whole-number coercion keyed on `type=number` never fired and the drafter's "10+ years — …" was refused after Next. A label that asks "How many years" now gets the whole number its answer states, and "Less than 1 year" reads 0, not 1. Found on the 2026-10-05 Ready sweep: seven LinkedIn rows rebuilt on Claude answered every question and still dry-ran 0 mapped.) - [x] [#406 — Agent: Ready rows stuck on failures the worker should have recovered from — DNS blip stamped non-transient, browser crash unflagged, LLM 429 hammered every tick](https://github.com/CryptoJones/OSApplyTrack/issues/406) (fixed in 1.66.4: "the posting's host could not be resolved" is a `TransientRunException`, retried once after two seconds and recorded as transient, so the reconciler retries it; a run the browser died under ("Target page, context or browser has been closed") is flagged `transient` by the submitter and read by those words from before; and a pass stops judging at the first `LlmUnavailableException` and holds the tenant off for 15 minutes instead of asking the next eleven leads every five minutes. The eight rows the fix could not reach — four stored `transient: false`, launchdarkly's crash, and three LinkedIn runs from before #403 — were queued again by hand.) diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index 8230bd5..76c0447 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -470,6 +470,7 @@ function suggest(box) { // Greenhouse's current form: react-select comboboxes for country, city (an async // autocomplete) and the custom questions, each with a hidden required input. _fixture.MapGet("/jobs/react-select", () => Results.Content(ReactSelectHtml, "text/html")); + _fixture.MapGet("/jobs/react-select-two-countries", () => Results.Content(ReactSelectTwoCountriesHtml, "text/html")); // A form whose own validation blocks Submit on a field the DOM never marks required. _fixture.MapGet("/jobs/validating", () => Results.Content(ValidatingFormHtml, "text/html")); // A form that re-mounts itself on Submit instead of posting. @@ -1134,6 +1135,27 @@ Click the object that does not fit the column pattern """; + // OneStream's form (#416): the phone number's country picker and the address block's + // Country are both labelled "Country*", the address one further down the page. + private static readonly string ReactSelectTwoCountriesHtml = ReactSelectHtml.Replace( + "