halo-agent 2.4.3 → 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.
- package/orchestrator.js +75 -6
- package/package.json +1 -1
- package/smartFill.js +28 -3
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
|
@@ -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,
|
|
1079
|
-
// mistaken for an unfilled required field.
|
|
1080
|
-
|
|
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 {
|