From 57f5b3b201b4fb4e74299c6eacb0a05d0ff9eff1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 22:27:05 -0700 Subject: [PATCH] fix(daemon): harden launchd restart handoff (#71409) --- CHANGELOG.md | 1 + src/daemon/launchd-restart-handoff.test.ts | 5 ++++ src/daemon/launchd-restart-handoff.ts | 33 ++++++++++++++-------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf83d44dee2..f5c29a04be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. +- macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. - Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. diff --git a/src/daemon/launchd-restart-handoff.test.ts b/src/daemon/launchd-restart-handoff.test.ts index e0ed59ec4bc..6919b6ee7bf 100644 --- a/src/daemon/launchd-restart-handoff.test.ts +++ b/src/daemon/launchd-restart-handoff.test.ts @@ -63,6 +63,11 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => { const [, args] = spawnMock.mock.calls[0] as [string, string[]]; expect(args[7]).toBe("ai.openclaw.gateway"); + expect(args[1]).toContain('if launchctl print "$service_target" >/dev/null 2>&1; then'); + expect(args[1]).toContain("reason=launchd-auto-reload"); + expect(args[1]).toContain("print_retry_count=$((print_retry_count - 1))"); + expect(args[1]).toContain("sleep 0.2"); + expect(args[1]).toContain('if launchctl bootstrap "$domain" "$plist_path"; then'); expect(args[1]).toContain('if launchctl start "$label"; then'); expect(args[1]).not.toContain('basename "$service_target"'); }); diff --git a/src/daemon/launchd-restart-handoff.ts b/src/daemon/launchd-restart-handoff.ts index ed709a13f21..942120e6413 100644 --- a/src/daemon/launchd-restart-handoff.ts +++ b/src/daemon/launchd-restart-handoff.ts @@ -22,6 +22,9 @@ export type LaunchdRestartTarget = { serviceTarget: string; }; +const START_AFTER_EXIT_PRINT_RETRY_COUNT = 15; +const START_AFTER_EXIT_PRINT_RETRY_DELAY_SECONDS = 0.2; + function assertValidLaunchAgentLabel(label: string): string { const trimmed = label.trim(); if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) { @@ -116,28 +119,36 @@ exit "$status" `; } + const verifyLaunchdReload = `print_retry_count="${START_AFTER_EXIT_PRINT_RETRY_COUNT}" +while [ "$print_retry_count" -gt 0 ]; do + if launchctl print "$service_target" >/dev/null 2>&1; then + printf '[%s] openclaw restart done source=launchd-handoff mode=${mode} reason=launchd-auto-reload\\n' "$(date -u +%FT%TZ)" >&2 + exit 0 + fi + print_retry_count=$((print_retry_count - 1)) + sleep ${START_AFTER_EXIT_PRINT_RETRY_DELAY_SECONDS} +done +`; + // Restart is explicit operator intent; undo any previous `launchctl disable`. return `service_target="$1" domain="$2" plist_path="$3" ${waitForCallerPid} +${verifyLaunchdReload} status=0 launchctl enable "$service_target" -if launchctl start "$label"; then - status=0 -else - status=$? - if launchctl bootstrap "$domain" "$plist_path"; then - if launchctl start "$label"; then - status=0 - else - launchctl kickstart -k "$service_target" - status=$? - fi +if launchctl bootstrap "$domain" "$plist_path"; then + if launchctl start "$label"; then + status=0 else launchctl kickstart -k "$service_target" status=$? fi +else + status=$? + launchctl kickstart -k "$service_target" + status=$? fi if [ "$status" -eq 0 ]; then printf '[%s] openclaw restart done source=launchd-handoff mode=${mode}\\n' "$(date -u +%FT%TZ)" >&2