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 +75 -6
- package/package.json +1 -1
- package/smartFill.js +91 -28
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
|
|
856
|
-
// it open so the user
|
|
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
|
|
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
|
|
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
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,
|
|
1079
|
-
// mistaken for an unfilled required field.
|
|
1080
|
-
|
|
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 {
|