halo-agent 2.4.3 → 2.4.5

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/orchestrator.js CHANGED
@@ -550,6 +550,51 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
550
550
  // Wait for the human to fill + confirm. They resolve the blanks in
551
551
  // Chrome, then click Submit on the dashboard.
552
552
  await waitForSubmitConfirmation(config, queueId);
553
+
554
+ // SAFETY NET: re-check required-empties AFTER the wait returns. A stale
555
+ // submit-confirmed KV flag (from a previous run on the same queue id,
556
+ // or a multi-run debug loop) used to fast-forward the wait without the
557
+ // user actually filling anything. If Location is still empty after
558
+ // "confirmation," the form CANNOT submit successfully refuse the
559
+ // click and bounce back to REVIEWING. This is the Artie bug: REVIEWING
560
+ // returned in 10s with no user action, agent submitted an incomplete
561
+ // form, Firecrawl couldn't verify, Chrome closed.
562
+ const recheck = await detectRequiredEmpties(page, {
563
+ excludeLabels: (ctx.skippedFields || []).map((s) => s.label),
564
+ resumeUploaded: !!fillResult.resumeUploaded,
565
+ }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
566
+ if (recheck.hasEmptyRequired) {
567
+ const stillEmpty = recheck.emptyFields.map((f) => f.label).slice(0, 6).join(', ');
568
+ console.warn(`[orchestrator] Submit-confirmed signal received but required field(s) still empty: ${stillEmpty}. Re-parking at REVIEWING.`);
569
+ await reportStatus('REVIEWING', {
570
+ review_screenshot_r2_key: reviewKey || null,
571
+ step: 'REVIEWING',
572
+ step_detail: `Still empty after confirm: ${stillEmpty}. Fill in Chrome + click Submit.`.slice(0, 200),
573
+ needs_attention_reason: `These fields are still blank: ${stillEmpty}. The form won't accept the submission until they're filled. Use your Chrome window to fill them, then click Submit on the dashboard.`,
574
+ fields_filled: cumulativeFilled,
575
+ });
576
+ // Wait again. The stale-flag case shouldn't re-fire because
577
+ // submit-confirmed deletes the KV key when read.
578
+ await waitForSubmitConfirmation(config, queueId);
579
+ // After the second wait, re-check ONE more time. If still empty,
580
+ // give up and throw the human isn't going to fill it.
581
+ const finalCheck = await detectRequiredEmpties(page, {
582
+ excludeLabels: (ctx.skippedFields || []).map((s) => s.label),
583
+ resumeUploaded: !!fillResult.resumeUploaded,
584
+ }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
585
+ if (finalCheck.hasEmptyRequired) {
586
+ const blockers = finalCheck.emptyFields.map((f) => f.label).slice(0, 6).join(', ');
587
+ await reportStatus('NEEDS_ATTENTION', {
588
+ review_screenshot_r2_key: reviewKey || null,
589
+ needs_attention_reason: `Application can't submit required fields still blank after two review passes: ${blockers}. Open the Chrome window and fill them, then re-queue this job.`,
590
+ intervention_type: 'required_fields_empty',
591
+ step: 'NEEDS_ATTENTION',
592
+ step_detail: `Blocked on: ${blockers}`.slice(0, 200),
593
+ fields_filled: cumulativeFilled,
594
+ });
595
+ throw new Error(`Required fields still empty after two review passes: ${blockers}`);
596
+ }
597
+ }
553
598
  } else {
554
599
  await reportStatus('REVIEWING', {
555
600
  review_screenshot_r2_key: reviewKey || null,
@@ -841,27 +886,35 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
841
886
  let interventionType = 'generic';
842
887
  if (/timeout|timed.out|deadline/.test(msg)) interventionType = 'timeout';
843
888
  else if (/login|sign.?in|auth.?required|please.+log.?in/.test(msg)) interventionType = 'login_wall';
889
+ else if (/required fields|still empty|still blank|verification/.test(msg)) interventionType = 'required_fields_empty';
844
890
  await reportStatus('NEEDS_ATTENTION', {
845
891
  needs_attention_reason: err.message,
846
892
  intervention_type: interventionType,
847
893
  step: 'NEEDS_ATTENTION',
848
894
  step_detail: err.message?.slice(0, 120),
849
895
  }).catch(() => {});
896
+ // KEEP THE TAB OPEN on user-facing failures. The Artie bug had us close
897
+ // Chrome the instant Firecrawl said "verification failed," so the user
898
+ // couldn't see what state the form was in, couldn't manually finish, and
899
+ // had to re-queue from scratch. Whenever an intervention type is one the
900
+ // user can resolve in-browser, leave the page open so they can act.
901
+ if (interventionType !== 'generic' || /verification|empty|blank|location/.test(msg)) {
902
+ leaveTabOpen = true;
903
+ console.log('[orchestrator] Leaving tab open so you can finish manually see the cockpit Applications page for what needs attention.');
904
+ }
850
905
  } finally {
851
906
  // Clean up temp resume file
852
907
  if (tempResumeFile) {
853
908
  try { fs.unlinkSync(tempResumeFile); } catch {}
854
909
  }
855
- // Close the tab on failure/cleanup — but on a successful submit, LEAVE
856
- // it open so the user sees the confirmation page (visual loop-closure).
910
+ // Close the tab on failure/cleanup — but on a successful submit OR a
911
+ // user-actionable failure, LEAVE it open so the user can see / fix it.
857
912
  // The next job opens its own new tab, so leaving this one doesn't block
858
- // anything; sequential processing is enforced by the poller (one job at
859
- // a time). Stale success tabs accumulate, but that's the user's call to
860
- // close — they asked for the satisfaction of seeing it land.
913
+ // anything; sequential processing is enforced by the poller.
861
914
  if (page && !leaveTabOpen) {
862
915
  try { await page.close(); } catch {}
863
916
  } else if (page && leaveTabOpen) {
864
- console.log('[orchestrator] Leaving confirmation tab open for your review.');
917
+ console.log('[orchestrator] Leaving tab open for your review.');
865
918
  }
866
919
  }
867
920
  }
@@ -1274,6 +1327,22 @@ async function getUnmatchedFields(page, aep) {
1274
1327
 
1275
1328
  async function waitForSubmitConfirmation(config, queueId) {
1276
1329
  console.log(`[orchestrator] Waiting for user to confirm submission from dashboard...`);
1330
+ // Drain any stale flag FIRST. The submit-confirmed KV flag has a 5-min
1331
+ // TTL on the worker; a previous run on the SAME queue_id (or a debug
1332
+ // loop that re-queued the same job) could leave the flag set so this
1333
+ // wait would return immediately without the user clicking anything.
1334
+ // The Artie bug: wait returned in 10s, agent submitted incomplete form,
1335
+ // verification failed, Chrome closed silently.
1336
+ //
1337
+ // We do ONE drain read to clear any leftover flag, ignore its value,
1338
+ // then start the real polling loop. The drain read deletes the KV key
1339
+ // server-side, so a fresh "Submit" click is required from here on.
1340
+ try {
1341
+ await fetch(`${config.apiUrl}/apply-queue/submit-confirmed/${queueId}`, {
1342
+ headers: { Authorization: `Bearer ${config.token}` },
1343
+ });
1344
+ } catch {}
1345
+
1277
1346
  for (let i = 0; i < 180; i++) { // up to 15 minutes
1278
1347
  await new Promise(r => setTimeout(r, 5000));
1279
1348
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
package/smartFill.js CHANGED
@@ -123,7 +123,7 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
123
123
  // it as click_option.
124
124
  if (field.role === 'combobox' || field.role === 'listbox') {
125
125
  const result = await openAndPickOption(root, locator, item.value, {
126
- config: ctx.config, jobId: ctx.jobId, label: field.label,
126
+ config: ctx.config, jobId: ctx.jobId, label: field.label, field,
127
127
  });
128
128
  if (result.ok) return result;
129
129
  // Fall through to text-fill if no option matched at all
@@ -151,7 +151,7 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
151
151
 
152
152
  case 'click_option': {
153
153
  return await openAndPickOption(root, locator, item.value, {
154
- config: ctx.config, jobId: ctx.jobId, label: field.label,
154
+ config: ctx.config, jobId: ctx.jobId, label: field.label, field,
155
155
  });
156
156
  }
157
157
 
@@ -636,6 +636,67 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
636
636
  // need the Page. For in-frame fields, option menus render inside the
637
637
  // frame so root.locator finds them.
638
638
  const page = (root && typeof root.page === 'function') ? root.page() : root;
639
+
640
+ // ── Async-listbox typeahead (shape-agnostic): try FIRST. ─────────────
641
+ // Any combobox where options are NOT pre-rendered — Ashby Location,
642
+ // Greenhouse Google Places, Workday city pickers, school/company
643
+ // autocompletes — must be typed into BEFORE options can appear. The
644
+ // generic "click and snapshot" path below opens the menu, waits 350ms,
645
+ // and bails on empty — but Ashby's geo fetch alone takes ~700ms.
646
+ //
647
+ // We probe DOM signals on the located element AND on its descendants AND
648
+ // on its closest field-group, because the planner's mmid may resolve to
649
+ // the hidden input, the wrapper div, or the toggle button — and Ashby's
650
+ // wrapper has no role/aria attrs of its own.
651
+ //
652
+ // Triggers:
653
+ // - role=combobox + (aria-haspopup=listbox | aria-autocomplete=list)
654
+ // - aria-autocomplete=list (canonical ARIA for async listbox)
655
+ // - scanAccessibility-tagged field.isTypeahead === true
656
+ // - Ashby's emotion-hashed _input_ class inside a _fieldEntry_ group
657
+ try {
658
+ const fieldIsTypeahead = !!(llmCtx && llmCtx.field && llmCtx.field.isTypeahead);
659
+ const domIsTypeahead = await triggerLocator.evaluate((el) => {
660
+ const probe = (n) => {
661
+ if (!n || n.nodeType !== 1) return false;
662
+ const role = n.getAttribute('role');
663
+ const ac = n.getAttribute('aria-autocomplete');
664
+ const hp = n.getAttribute('aria-haspopup');
665
+ if (ac === 'list' || ac === 'both') return true;
666
+ if (role === 'combobox' && (hp === 'listbox' || hp === 'true')) return true;
667
+ return false;
668
+ };
669
+ // 1. element itself
670
+ if (probe(el)) return true;
671
+ // 2. descendant input/combobox (when locator is wrapper or toggle button)
672
+ const innerSelectors = 'input[role="combobox"], [aria-autocomplete="list"], [aria-autocomplete="both"], [role="combobox"][aria-haspopup="listbox"]';
673
+ const inner = el.querySelector?.(innerSelectors);
674
+ if (probe(inner)) return true;
675
+ // 3. closest field-group, then re-descend (handles ATS-specific wrappers)
676
+ const groupSel = '[class*="_fieldEntry_"], .ashby-application-form-field-entry, .application-question, [data-question-id], [class*="formField"], .field';
677
+ const group = el.closest?.(groupSel);
678
+ if (group) {
679
+ const groupInner = group.querySelector(innerSelectors);
680
+ if (probe(groupInner)) return true;
681
+ }
682
+ // 4. Ashby's emotion-hashed input class as a supplementary signal
683
+ const cls = String(el.className || '');
684
+ if (/_input_/.test(cls) && el.closest?.('[class*="_fieldEntry_"], .ashby-application-form-field-entry')) return true;
685
+ return false;
686
+ }).catch(() => false);
687
+
688
+ if (fieldIsTypeahead || domIsTypeahead) {
689
+ const why = [];
690
+ if (fieldIsTypeahead) why.push('field.isTypeahead');
691
+ if (domIsTypeahead) why.push('dom signals');
692
+ console.log(`[smartFill] openAndPickOption: detected typeahead (${why.join(', ')}) → typeAndPickSuggestion first`);
693
+ const r = await typeAndPickSuggestion(root, triggerLocator, value);
694
+ if (r && r.ok) return r;
695
+ // Fall through to specialized + generic paths on no-match.
696
+ console.log(`[smartFill] typeahead path did not land (${r && r.reason}); falling through to generic dispatch.`);
697
+ }
698
+ } catch {}
699
+
639
700
  try {
640
701
  const rs = await tryReactSelect(root, triggerLocator, value);
641
702
  if (rs !== null) {
@@ -652,29 +713,6 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
652
713
  if (itlResult !== null && itlResult.ok) return itlResult;
653
714
  } catch {}
654
715
 
655
- try {
656
- // Ashby async typeahead. Ashby's Location field is role="combobox" with
657
- // aria-haspopup="listbox" and an input class like `_input_v5ami_28`. It
658
- // renders NO options until you type, then fetches matches over the
659
- // network (~700ms) and shows them as [role="option"]. The generic
660
- // open-and-look path opened it, saw an empty menu, and bailed — that's
661
- // the Gen Digital "dropdown opened but no visible options found" failure.
662
- // Detect Ashby's widget and route to type-then-wait-for-async-options.
663
- const isAshbyCombo = await triggerLocator.evaluate((el) => {
664
- const role = el.getAttribute('role');
665
- const hasPopup = el.getAttribute('aria-haspopup');
666
- const cls = String(el.className || '');
667
- // Ashby's emotion-hashed input class, or the generic combobox+listbox shape.
668
- const ashbyClass = /_input_/.test(cls) && !!el.closest('[class*="_fieldEntry_"], .ashby-application-form-field-entry');
669
- return (role === 'combobox' && (hasPopup === 'listbox' || hasPopup === 'true')) || ashbyClass;
670
- }).catch(() => false);
671
- if (isAshbyCombo) {
672
- const r = await typeAndPickSuggestion(root, triggerLocator, value);
673
- if (r.ok) return r;
674
- // If typeahead didn't land, fall through to the generic path below.
675
- }
676
- } catch {}
677
-
678
716
  try {
679
717
  // Workday combobox / multi-select. Workday wraps every form widget in a
680
718
  // [data-automation-id^="formField-"] container and its dropdowns mount a
@@ -1075,9 +1113,34 @@ async function smartFillPage(page, aep, options) {
1075
1113
  // required-empty guard can EXCLUDE them. The planner has full
1076
1114
  // context (it saw "If yes…" conditional dependencies); the guard is
1077
1115
  // a blind DOM walk. A field the planner intentionally left blank
1078
- // (conditional-not-applicable, honeypot, optional) must not be
1079
- // mistaken for an unfilled required field.
1080
- if (ctx && Array.isArray(ctx.skippedFields) && f.label) {
1116
+ // (conditional-not-applicable, optional, already-filled) must not
1117
+ // be mistaken for an unfilled required field.
1118
+ //
1119
+ // BUT do NOT trust the planner's "honeypot" reasoning on a real
1120
+ // required fillable field. The Artie bug: planner saw an Ashby
1121
+ // combobox with placeholder "Start typing..." (no other visible
1122
+ // label since the actual <label> is a sibling React node) and called
1123
+ // it a honeypot. Real honeypots are inputs with anti-bot instructions
1124
+ // ("if you're a real human, leave blank") or display:none traps.
1125
+ // A visible required combobox is never a honeypot.
1126
+ //
1127
+ // Policy: when the field IS required and IS fillable (textbox /
1128
+ // combobox / select / textarea / file), only honor the skip if the
1129
+ // planner gave a LEGITIMATE skip reason. "honeypot" on a required
1130
+ // combobox falls through guard catches it as still-empty + routes
1131
+ // to REVIEWING with the human in the loop.
1132
+ const isFillable = ['textbox', 'combobox', 'listbox', 'searchbox', 'spinbutton'].includes(f.role)
1133
+ || ['textarea', 'select'].includes(f.tag)
1134
+ || (f.tag === 'input' && !['button', 'submit', 'hidden'].includes(f.inputType || ''));
1135
+ const reasonLower = (item.reasoning || result.reason || '').toLowerCase();
1136
+ const legitimateSkip = /already filled|conditional|not applicable|optional|n\/a|cover letter not provided|no cover_letter file/.test(reasonLower);
1137
+ const looksLikeBadHoneypotCall = /honeypot|hidden input|bot detection/.test(reasonLower);
1138
+
1139
+ if (f.required && isFillable && looksLikeBadHoneypotCall && !legitimateSkip) {
1140
+ console.warn(`[smartFill] OVERRIDE: planner called required ${f.role || f.tag} "${labelShort}" a honeypot not adding to skippedFields. The required-empty guard will catch it.`);
1141
+ // Do not push to ctx.skippedFields. The guard will see this field
1142
+ // as empty and route to REVIEWING.
1143
+ } else if (ctx && Array.isArray(ctx.skippedFields) && f.label) {
1081
1144
  ctx.skippedFields.push({ label: f.label, reason: (item.reasoning || result.reason || '').slice(0, 160) });
1082
1145
  }
1083
1146
  } else {