halo-agent 2.4.2 → 2.4.4

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.
@@ -429,12 +429,19 @@ async function detectRequiredEmptiesInRoot(page) {
429
429
  // control (not a shared group container with multiple inputs).
430
430
  const labelTextOf = (lblEl) => (lblEl && (lblEl.innerText || lblEl.textContent || '')).trim();
431
431
  const marks = (txt) => /\*\s*$/.test(txt) || /\(\s*required\s*\)/i.test(txt) || /\brequired\b\s*\*?$/i.test(txt);
432
+ // Class-marker required detection. Ashby renders the asterisk via a
433
+ // CSS ::after pseudo-element on a label whose className includes
434
+ // "_required_" / "required". The text-based `marks()` check above can't
435
+ // see pseudo-content, so we also accept a className match. Same idea
436
+ // covers many other React component-libraries (Workable, custom
437
+ // tailwind builds) that don't put the literal "*" in label text.
438
+ const labelHasRequiredClass = (lblEl) => !!(lblEl && lblEl.className && /(^|[\s_-])required($|[\s_-])/i.test(lblEl.className));
432
439
 
433
440
  if (el.id) {
434
441
  const lbl = document.querySelector(`label[for="${el.id}"]`);
435
- if (lbl) return marks(labelTextOf(lbl));
442
+ if (lbl) return marks(labelTextOf(lbl)) || labelHasRequiredClass(lbl);
436
443
  }
437
- if (el.labels && el.labels[0]) return marks(labelTextOf(el.labels[0]));
444
+ if (el.labels && el.labels[0]) return marks(labelTextOf(el.labels[0])) || labelHasRequiredClass(el.labels[0]);
438
445
 
439
446
  // Closest wrapping label/legend, but ONLY if that container holds a
440
447
  // single control (so we don't inherit a group asterisk meant for a
@@ -444,7 +451,7 @@ async function detectRequiredEmptiesInRoot(page) {
444
451
  const labelEl = p.querySelector(':scope > label, :scope > legend, :scope > [class*="label"]');
445
452
  if (labelEl) {
446
453
  const controlsInScope = p.querySelectorAll('input:not([type=hidden]), textarea, select, [role="combobox"], [contenteditable="true"]').length;
447
- if (controlsInScope <= 1) return marks(labelTextOf(labelEl));
454
+ if (controlsInScope <= 1) return marks(labelTextOf(labelEl)) || labelHasRequiredClass(labelEl);
448
455
  // Shared container with multiple controls — the asterisk isn't
449
456
  // necessarily ours. Stop; treat as not-required unless a
450
457
  // programmatic marker said otherwise (handled above).
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.2",
3
+ "version": "2.4.4",
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
@@ -1075,9 +1075,34 @@ async function smartFillPage(page, aep, options) {
1075
1075
  // required-empty guard can EXCLUDE them. The planner has full
1076
1076
  // context (it saw "If yes…" conditional dependencies); the guard is
1077
1077
  // 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) {
1078
+ // (conditional-not-applicable, optional, already-filled) must not
1079
+ // be mistaken for an unfilled required field.
1080
+ //
1081
+ // BUT do NOT trust the planner's "honeypot" reasoning on a real
1082
+ // required fillable field. The Artie bug: planner saw an Ashby
1083
+ // combobox with placeholder "Start typing..." (no other visible
1084
+ // label since the actual <label> is a sibling React node) and called
1085
+ // it a honeypot. Real honeypots are inputs with anti-bot instructions
1086
+ // ("if you're a real human, leave blank") or display:none traps.
1087
+ // A visible required combobox is never a honeypot.
1088
+ //
1089
+ // Policy: when the field IS required and IS fillable (textbox /
1090
+ // combobox / select / textarea / file), only honor the skip if the
1091
+ // planner gave a LEGITIMATE skip reason. "honeypot" on a required
1092
+ // combobox falls through guard catches it as still-empty + routes
1093
+ // to REVIEWING with the human in the loop.
1094
+ const isFillable = ['textbox', 'combobox', 'listbox', 'searchbox', 'spinbutton'].includes(f.role)
1095
+ || ['textarea', 'select'].includes(f.tag)
1096
+ || (f.tag === 'input' && !['button', 'submit', 'hidden'].includes(f.inputType || ''));
1097
+ const reasonLower = (item.reasoning || result.reason || '').toLowerCase();
1098
+ const legitimateSkip = /already filled|conditional|not applicable|optional|n\/a|cover letter not provided|no cover_letter file/.test(reasonLower);
1099
+ const looksLikeBadHoneypotCall = /honeypot|hidden input|bot detection/.test(reasonLower);
1100
+
1101
+ if (f.required && isFillable && looksLikeBadHoneypotCall && !legitimateSkip) {
1102
+ 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.`);
1103
+ // Do not push to ctx.skippedFields. The guard will see this field
1104
+ // as empty and route to REVIEWING.
1105
+ } else if (ctx && Array.isArray(ctx.skippedFields) && f.label) {
1081
1106
  ctx.skippedFields.push({ label: f.label, reason: (item.reasoning || result.reason || '').slice(0, 160) });
1082
1107
  }
1083
1108
  } else {