Skip to content

Commit 079e563

Browse files
fix(bridge-daemon): retry osascript wake delivery on transient frontmost-loss (#12459) (#12460)
Wakes were silently dropped when another app held frontmost during the multi-second osascript activate->paste->restore sequence: a strict single-shot frontmost assert + zero retry aborted the whole delivery on -2700. Two additive-robustness changes (load-bearing activate/keystroke forms unchanged): 1. JS retry (4x, wait() backoff) on -2700/'lost frontmost', phase-aware: a frontmost-loss during a 'user input restore' phase means the wake already submitted (Enter precedes it) -> treat as delivered, do not retry (no double-send). 2. AppleScript poll-re-activate loop (repeat 12x, exit on frontmost) replacing the single delay+assert after activation. Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
1 parent 319f6d9 commit 079e563

1 file changed

Lines changed: 68 additions & 4 deletions

File tree

ai/daemons/bridge/daemon.mjs

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,63 @@ function spawnAsync(command, args) {
598598
});
599599
}
600600

601+
/**
602+
* @summary Delivers a wake digest via osascript, retrying transient frontmost-loss races.
603+
*
604+
* macOS focus-stealing prevention makes a background daemon's `activate` / `set frontmost`
605+
* best-effort: when another app holds frontmost during the multi-second activate → paste →
606+
* restore sequence, `assertTargetFrontmost` aborts the osascript with a `-2700`
607+
* "lost frontmost status" error. Focus contention is transient, so we re-attempt the whole
608+
* delivery a few times before giving up.
609+
*
610+
* Phase-aware idempotency guard: the wake payload is submitted (`key code 36` / Enter) BEFORE
611+
* the "user input restore" phases. A frontmost-loss reported for a restore phase therefore means
612+
* the wake already landed — only the user's draft-restore failed (cosmetic). We must NOT retry
613+
* that case (it would double-submit the wake). Non-race errors (syntax/permissions) re-throw
614+
* immediately.
615+
* @param {String[]} osascriptArgs The fully-built `osascript -e …` argument list.
616+
* @param {String} subscriptionId For log attribution.
617+
* @param {String} appName For log attribution.
618+
* @returns {Promise<void>}
619+
*/
620+
async function deliverViaOsascriptWithRetry(osascriptArgs, subscriptionId, appName) {
621+
const maxAttempts = 4,
622+
backoffMs = 800;
623+
624+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
625+
try {
626+
await spawnAsync('osascript', osascriptArgs);
627+
writeLog('INFO',
628+
`[Bridge Daemon] Delivered ${subscriptionId} via osascript to ${appName}` +
629+
(attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : ''));
630+
return
631+
} catch (err) {
632+
const message = err.message || '',
633+
isFrontmostRace = /lost frontmost status|-2700/.test(message),
634+
afterSubmit = /user input restore/.test(message);
635+
636+
// Wake already submitted; only the draft-restore lost frontmost → delivered, do not retry.
637+
if (isFrontmostRace && afterSubmit) {
638+
writeLog('WARN',
639+
`[Bridge Daemon] ${subscriptionId} wake landed but draft-restore lost frontmost; ` +
640+
`not retrying (avoids double-send). ${message}`);
641+
return
642+
}
643+
644+
// Lost frontmost before submit → transient focus contention → retry the whole delivery.
645+
if (attempt < maxAttempts && isFrontmostRace) {
646+
writeLog('WARN',
647+
`[Bridge Daemon] Wake delivery ${subscriptionId} attempt ${attempt}/${maxAttempts} ` +
648+
`lost frontmost before submit (focus contention); retrying in ${backoffMs}ms. ${message}`);
649+
await wait(backoffMs);
650+
continue
651+
}
652+
653+
throw err
654+
}
655+
}
656+
}
657+
601658
/**
602659
* @summary Escapes values interpolated into AppleScript string literals.
603660
* @param {String} value
@@ -774,9 +831,17 @@ async function deliverDigest(subscription, digest) {
774831
'-e', ' set savedClipboard to ""',
775832
'-e', ' end try',
776833
'-e', ' try',
834+
'-e', ' set targetRaised to false',
835+
'-e', ' repeat 12 times',
777836
'-e', activateLine,
778-
'-e', ' delay 0.5',
779-
'-e', ' my assertTargetFrontmost(targetAppName, targetBundleId, targetProcessId, "after activation")',
837+
'-e', ' delay 0.25',
838+
'-e', ' try',
839+
'-e', ' my assertTargetFrontmost(targetAppName, targetBundleId, targetProcessId, "after activation")',
840+
'-e', ' set targetRaised to true',
841+
'-e', ' exit repeat',
842+
'-e', ' end try',
843+
'-e', ' end repeat',
844+
'-e', ' if not targetRaised then my assertTargetFrontmost(targetAppName, targetBundleId, targetProcessId, "after activation")',
780845
'-e', ' tell application "System Events"',
781846
'-e', ' set frontmostProcess to first application process whose frontmost is true',
782847
'-e', ' tell frontmostProcess'
@@ -863,8 +928,7 @@ async function deliverDigest(subscription, digest) {
863928
digest
864929
);
865930

866-
await spawnAsync('osascript', osascriptArgs);
867-
writeLog('INFO', `[Bridge Daemon] Delivered ${subscription.id} via osascript to ${appName}`);
931+
await deliverViaOsascriptWithRetry(osascriptArgs, subscription.id, appName);
868932
} else if (adapter === 'test') {
869933
writeLog('INFO', `[Bridge Daemon Test Adapter] Delivered ${subscription.id}: ${digest}`);
870934
} else {

0 commit comments

Comments
 (0)