fix(daemon): harden launchd restart handoff (#71409)

This commit is contained in:
Vincent Koc
2026-04-24 22:27:05 -07:00
committed by GitHub
parent 44ad970e48
commit 57f5b3b201
3 changed files with 28 additions and 11 deletions

View File

@@ -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.

View File

@@ -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"');
});

View File

@@ -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