Agendando tarefas no macOS com launchd (sem cron, sem gambiarra)

Neste post
No post anterior eu mostrei como os systemd timers substituem o cron em servidores Debian e Ubuntu com vantagens concretas: logging integrado, recuperação de execuções perdidas, dependências declarativas e controle de recursos. A lógica é convincente e a migração é direta — desde que você esteja num sistema que roda systemd. Mas se o seu dia a dia inclui um Mac, a história é outra.
O macOS tem seu próprio sistema de agendamento, anterior ao systemd e com uma filosofia diferente. Ele se chama launchd, existe desde o Mac OS X Tiger em 2005, e é responsável por praticamente tudo que roda em background no sistema — desde serviços internos da Apple até aquele updater do Spotify que você nunca pediu para instalar. Apesar de ser a forma oficial e recomendada de agendar tarefas no Mac, o launchd vive numa espécie de ponto cego: quem vem do Linux tende a procurar o cron por reflexo, e quem usa o Mac sem background em administração de sistemas nem sabe que a possibilidade existe.
Este post mostra como usar o launchd para agendar um comando que roda todos os dias às 7h da manhã no meu Mac — o fuqu telegram, que gera um briefing diário das minhas tarefas e envia para o Telegram. O caminho serve como modelo para qualquer script ou comando que você queira agendar de forma confiável sem depender de terminal aberto, sem cron e sem aplicativos de terceiros.
O cron funciona no Mac. Mas não é assim que se faz.#
O cron existe no macOS. Você pode abrir o Terminal, digitar crontab -e e agendar um job exatamente como faria num servidor Linux. O daemon está lá, funciona, e ninguém removeu. Então por que não usar?
O primeiro motivo é que a Apple considera o cron deprecado em favor do launchd desde 2005. O daemon continua presente por compatibilidade, mas não recebe melhorias, não se integra com os mecanismos modernos do sistema e não aparece em nenhuma documentação oficial como a forma recomendada de agendar tarefas. Na prática, ele funciona até o dia em que uma atualização do macOS muda alguma coisa e ele para de funcionar — e quando isso acontecer, não vai ter fix porque o cron não é prioridade da Apple há duas décadas.
O segundo motivo é mais imediato: o cron no macOS não sabe lidar com a forma como um Mac realmente opera. Macs dormem, fecham a tampa, ficam desligados de noite e ligam de manhã. Se o cron job estava agendado para as 3h e o Mac estava dormindo às 3h, a execução se perde sem aviso — o mesmo problema que o cron tem no Linux, mas agravado pelo fato de que um laptop pessoal fica desligado ou em sleep com muito mais frequência do que um servidor. O launchd tem mecanismo nativo para detectar execuções perdidas e dispará-las assim que o sistema acordar, o que por si só já justifica a troca para qualquer tarefa que precisa rodar diariamente de forma confiável.
O terceiro motivo é o ambiente de execução. O cron no macOS roda com um PATH mínimo e sem acesso ao ambiente do seu shell. Scripts que dependem de binários instalados pelo Homebrew em /opt/homebrew/bin, de ambientes virtuais Python ou de variáveis de ambiente configuradas no .zshrc simplesmente não encontram o que precisam quando executados via cron. A solução clássica — colocar o PATH completo na primeira linha do crontab ou encapsular tudo num wrapper que faz source do profile — funciona, mas é o tipo de gambiarra que você precisa lembrar que existe a cada novo script que adiciona. O launchd oferece uma forma declarativa de definir variáveis de ambiente por job, sem depender do shell do usuário.
Nada disso significa que o cron vai explodir se você usá-lo no Mac. Para um script simples que roda enquanto o laptop está aberto e acordado, ele faz o trabalho. Mas se você quer agendamento que sobrevive ao sleep, com logging previsível e integração com o sistema operacional, o launchd é a ferramenta certa — e é a que a Apple espera que você use.
launchd: o systemd do macOS#
LaunchDaemons vs. LaunchAgents#
O launchd é o processo de PID 1 do macOS — o primeiro processo que o kernel inicia e o pai de todos os outros. Nesse sentido, ele ocupa exatamente o mesmo papel que o systemd no Linux: gerencia serviços, controla dependências de inicialização e cuida do ciclo de vida de tudo que roda em background. A diferença é que o launchd faz isso desde 2005, quase uma década antes de o systemd se tornar padrão nas distribuições Linux.
As tarefas gerenciadas pelo launchd se dividem em duas categorias que é preciso entender antes de criar qualquer coisa: LaunchDaemons e LaunchAgents.
LaunchDaemons são serviços de sistema. Rodam como root, iniciam durante o boot antes de qualquer usuário fazer login e não têm acesso à sessão gráfica. Seus arquivos plist ficam em /Library/LaunchDaemons/ (para daemons de terceiros) ou em /System/Library/LaunchDaemons/ (para os da Apple, que você não deve tocar). São o equivalente dos serviços que no Linux você colocaria em /etc/systemd/system/ — backups de nível de sistema, serviços de rede, monitoramento de hardware.
LaunchAgents são tarefas de usuário. Rodam com as permissões do usuário logado, iniciam quando o usuário faz login e têm acesso ao ambiente gráfico da sessão. Existem em dois lugares: /Library/LaunchAgents/ para agents instalados por aplicativos de terceiros que se aplicam a todos os usuários, e ~/Library/LaunchAgents/ para agents pessoais que pertencem exclusivamente à sua conta. Esse último diretório é onde vai morar tudo que você criar para uso próprio — e é o equivalente mais próximo dos user timers do systemd em ~/.config/systemd/user/, com uma vantagem prática: no macOS, LaunchAgents pessoais funcionam automaticamente enquanto o usuário está logado, sem precisar de nenhum equivalente a loginctl enable-linger.
Para o caso de agendar um comando pessoal como o fuqu telegram, a escolha é direta: LaunchAgent em ~/Library/LaunchAgents/. Não precisa de root para criar, não precisa de root para carregar, e roda no contexto da sua sessão de usuário.
O formato plist e a lógica de agendamento#
Enquanto o systemd usa arquivos .ini com seções e diretivas, e o cron usa uma linha de texto com cinco campos, o launchd usa arquivos plist — property lists em formato XML. A reação inicial de quem vê um plist pela primeira vez costuma ser de estranhamento: é verboso, cheio de tags, e parece desproporcional para o que está fazendo. Mas o formato é o padrão que a Apple usa para configuração em todo o sistema operacional, e depois de escrever o primeiro arquivo o padrão fica previsível.
Um plist de LaunchAgent é um dicionário XML com chaves que descrevem o que executar, quando executar e como lidar com a saída. As chaves obrigatórias são apenas duas: Label, que é o identificador único do agent (por convenção, no formato de domínio reverso como com.janio.fuqu-telegram), e ProgramArguments, que é um array com o comando e seus argumentos. O mínimo necessário para definir uma tarefa cabe em poucas linhas — toda a cerimônia do XML é sintática, não conceitual.
O agendamento se configura com a chave StartCalendarInterval, que aceita um dicionário com campos para hora, minuto, dia do mês, mês e dia da semana. A lógica é similar ao cron: você especifica apenas os campos que importam e os demais assumem o valor “qualquer”. Um dicionário com apenas Hour e Minute definidos significa “todo dia nesse horário” — o equivalente direto de 0 7 * * * no cron ou *-*-* 07:00:00 no OnCalendar do systemd.
A diferença que importa na prática é o comportamento quando o Mac está dormindo ou desligado no horário agendado. O launchd detecta que a execução foi perdida e a dispara assim que o sistema acorda ou reinicia. Esse comportamento é o padrão — não precisa de nenhuma diretiva equivalente ao Persistent=true do systemd. Se o agent está carregado e o horário já passou, a execução acontece na próxima oportunidade. Para quem agenda tarefas num laptop que não fica ligado 24 horas, essa garantia sozinha vale a transição do cron para o launchd.
Outras chaves úteis que aparecerão no exemplo prático incluem StandardOutPath e StandardErrorPath para direcionar a saída para arquivos de log, EnvironmentVariables para definir variáveis de ambiente disponíveis durante a execução, e WorkingDirectory para definir o diretório de trabalho do processo. Tudo declarativo, tudo no mesmo arquivo, sem precisar de wrappers ou source de profiles.
Caso prático: fuqu telegram às 7h da manhã#
O que o comando faz#
O FUQU é um gerenciador de tarefas pessoal que roda no terminal, construído em Python com Textual e SQLite. Entre outras coisas, ele tem um subcomando telegram que gera um briefing diário — um resumo das tarefas pendentes, atrasadas e agendadas para o dia — e envia para um bot do Telegram. O briefing é gerado por um modelo de linguagem (LM Studio local com fallback para a API do Cerebras), formatado em Markdown e entregue como mensagem no chat do bot. O resultado é que todo dia às 7h da manhã, antes de eu abrir o laptop, o resumo do dia já está esperando no Telegram do meu celular.
No terminal, o comando que faz isso acontecer é:
cd ~/Dropbox/fuqu && .venv/bin/python -m fuqu telegram
O projeto vive em ~/Dropbox/fuqu, usa um virtualenv local em .venv e é executado como módulo Python. O cd para o diretório do projeto é necessário porque o FUQU resolve o caminho do banco de dados SQLite relativamente ao diretório de trabalho. Essa combinação de cd + caminho absoluto do interpretador Python dentro do venv é o tipo de coisa que funciona perfeitamente no shell interativo mas quebra quando executada fora do contexto do usuário — que é exatamente o que acontece num LaunchAgent.
O wrapper script#
O launchd não executa comandos através do shell. Ele chama o binário diretamente, sem passar por .zshrc, sem expandir aliases, sem herdar o PATH da sessão interativa. Por isso, a abordagem mais limpa é encapsular o comando num script de shell dedicado que cuida do ambiente de execução.
#!/bin/bash
cd ~/Dropbox/fuqu || exit 1
.venv/bin/python -m fuqu telegram
O script faz duas coisas: muda para o diretório do projeto (abortando se o diretório não existir) e executa o comando com o caminho absoluto do Python dentro do virtualenv. Não depende de alias, não depende de PATH, não depende de nada que exista apenas na sessão interativa do shell.
O arquivo pode ficar em qualquer lugar razoável. Eu mantenho scripts pessoais desse tipo em ~/bin/, que é um diretório que já existe no meu PATH interativo mas que aqui não importa — o plist vai referenciar o caminho absoluto de qualquer forma:
mkdir -p ~/bin
cat > ~/bin/fuqu-telegram.sh << 'EOF'
#!/bin/bash
cd ~/Dropbox/fuqu || exit 1
.venv/bin/python -m fuqu telegram
EOF
chmod +x ~/bin/fuqu-telegram.sh
Antes de envolver o launchd, vale testar o script isoladamente para garantir que ele funciona fora do contexto do shell interativo:
/usr/bin/env -i HOME="$HOME" ~/bin/fuqu-telegram.sh
O env -i limpa todas as variáveis de ambiente, simulando o ambiente mínimo que o launchd vai fornecer. Se o briefing chegar no Telegram, o script está pronto.
O arquivo plist#
O LaunchAgent é um arquivo XML em ~/Library/LaunchAgents/. Por convenção, o nome segue o formato de domínio reverso — no meu caso, com.janio.fuqu-telegram.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.janio.fuqu-telegram</string>
<key>ProgramArguments</key>
<array>
<string>/Users/janiosarmento/bin/fuqu-telegram.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/janiosarmento/.local/log/fuqu-telegram.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/janiosarmento/.local/log/fuqu-telegram.err.log</string>
<key>WorkingDirectory</key>
<string>/Users/janiosarmento/Dropbox/fuqu</string>
</dict>
</plist>
Algumas observações sobre cada chave:
O Label é o identificador único do agent no launchd. Precisa ser idêntico ao nome do arquivo sem a extensão .plist. É o que aparece na saída do launchctl list e o que você usa para interagir com o agent via linha de comando.
O ProgramArguments recebe um array com o caminho absoluto do script. Caminhos com ~ não são expandidos pelo launchd — sempre use o caminho completo a partir de /Users/. Se o comando precisasse de argumentos adicionais, cada um seria um <string> separado dentro do array.
O StartCalendarInterval com apenas Hour e Minute definidos dispara o agent todo dia às 7h00. Omitir os campos de dia, mês e dia da semana equivale a colocar * em cada um deles — o padrão é “qualquer valor”. Se o Mac estiver dormindo às 7h, o launchd executa o agent assim que o sistema acordar.
Os caminhos de StandardOutPath e StandardErrorPath direcionam a saída para arquivos de log. O diretório precisa existir antes da primeira execução — o launchd não cria diretórios automaticamente. Sem essas chaves, toda saída do script desaparece silenciosamente.
O WorkingDirectory é redundante com o cd no wrapper script, mas serve como uma garantia adicional. Se por algum motivo o cd dentro do script falhar de forma inesperada, o processo ainda estará no diretório correto.
Antes de carregar o agent, crie o diretório de logs:
mkdir -p ~/.local/log
Carregando e testando#
Com o plist no lugar e o diretório de logs criado, o agent precisa ser registrado no launchd. O macOS oferece dois conjuntos de comandos para isso: os antigos launchctl load e launchctl unload, e os mais recentes launchctl bootstrap e launchctl bootout. Os comandos antigos ainda funcionam e são mais simples, então é o que eu uso:
launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
Para verificar que o agent foi carregado:
launchctl list | grep fuqu
A saída mostra três colunas: o PID (um - se o agent não está rodando neste momento), o último código de saída (0 para sucesso) e o label. Ver o label na lista confirma que o launchd reconheceu o agent e está monitorando o agendamento.
Para testar sem esperar as 7h da manhã, você pode disparar o agent manualmente:
launchctl start com.janio.fuqu-telegram
O comando retorna imediatamente — a execução acontece em background. Para ver o resultado, verifique o log de saída:
cat ~/.local/log/fuqu-telegram.out.log
E o log de erro, que estará vazio se tudo correu bem:
cat ~/.local/log/fuqu-telegram.err.log
Se o briefing chegou no Telegram e os logs não mostram erros, o agent está funcionando. A partir de agora, todo dia às 7h — ou no momento em que você abrir o laptop se ele estava dormindo às 7h — o resumo das tarefas do dia vai aparecer no Telegram automaticamente, sem terminal aberto, sem intervenção manual.
Se precisar alterar o horário ou qualquer outra configuração, o ciclo é: descarregar o agent, editar o plist, recarregar:
launchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
# ... editar o arquivo ...
launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
Para remover o agent permanentemente, basta descarregá-lo e apagar o arquivo plist. Sem o arquivo em ~/Library/LaunchAgents/, ele não será carregado no próximo login.
O que o launchd resolve de graça#
StartCalendarInterval e execuções perdidas#
No post sobre systemd timers, a diretiva Persistent=true foi apresentada como uma vantagem concreta sobre o cron: se o sistema estava desligado no horário agendado, o timer dispara a execução assim que possível após o boot. No launchd, esse comportamento não precisa ser habilitado — ele é o padrão. Se o Mac estava dormindo ou desligado às 7h, o agent roda assim que o sistema acorda, sem nenhuma chave adicional no plist.
O mecanismo é simples: o launchd compara o StartCalendarInterval com o horário atual e com o timestamp da última execução. Se detecta que um ou mais intervalos foram perdidos, dispara o agent imediatamente. Diferente do systemd, que grava o timestamp da última execução em disco explicitamente quando Persistent=true está ativo, o launchd faz esse rastreamento internamente como parte do seu funcionamento normal.
Na prática, isso significa que o briefing das 7h da manhã chega no Telegram mesmo que eu só abra o laptop às 9h. O atraso é o tempo que o sistema leva para acordar e carregar os LaunchAgents — questão de segundos. Para tarefas diárias como essa, o comportamento é exatamente o que se espera: a execução acontece uma vez por dia, no horário definido ou na primeira oportunidade depois dele, sem duplicação e sem perda.
Existe uma sutileza que vale conhecer: se o Mac ficou desligado por vários dias, o launchd dispara o agent apenas uma vez ao acordar, não uma vez para cada dia perdido. Para um briefing diário isso é o comportamento correto — não faz sentido receber cinco briefings atrasados de uma vez. Mas para tarefas onde cada execução perdida importa individualmente, como rotações de log ou coletas de métricas, esse comportamento precisa ser levado em conta.
Logging com stdout e stderr#
O cron no macOS herda o mesmo problema de logging que tem no Linux: a saída dos jobs vai para o email local do usuário via o MTA do sistema, que na maioria dos Macs pessoais não está configurado. O resultado é que a saída simplesmente desaparece, e descobrir se um job rodou — e o que ele fez — vira um exercício de adivinhação.
As chaves StandardOutPath e StandardErrorPath no plist resolvem isso de forma direta. Toda a saída padrão do processo vai para um arquivo, toda a saída de erro vai para outro, ambos com append automático. Não precisa de redirecionamento no script, não precisa de logger, não precisa de nada além das duas linhas no plist.
A separação entre stdout e stderr é útil na prática. O arquivo de saída contém o resultado normal do comando — no caso do fuqu telegram, a confirmação de que o briefing foi gerado e enviado. O arquivo de erro captura exceções Python, stack traces, problemas de conexão com a API do LLM ou do Telegram, e qualquer outro diagnóstico que o comando escreva em stderr. Quando tudo funciona, o arquivo de erro fica vazio, e o fato de ele ter conteúdo já é por si só um sinal de que algo precisa de atenção.
Uma limitação em relação ao systemd é que o launchd não tem um equivalente ao journald. Os logs são arquivos de texto simples que crescem indefinidamente. Cabe a você gerenciar a rotação — seja com um segundo LaunchAgent que trunca ou rotaciona os arquivos periodicamente, seja com o newsyslog que o macOS inclui nativamente e que pode ser configurado para rotacionar qualquer arquivo de log. Para tarefas que rodam uma vez por dia e produzem poucas linhas de saída, o crescimento é negligível e a rotação pode ser algo que você resolve uma vez por ano apagando manualmente os arquivos antigos. Para tarefas mais verbosas ou frequentes, vale configurar rotação desde o início.
Variáveis de ambiente e PATH#
Quando você abre o Terminal no Mac e digita um comando, o shell carrega o .zshrc (ou .bash_profile, dependendo da configuração), define o PATH com os diretórios do Homebrew, exporta variáveis de ambiente personalizadas e configura tudo que o seu fluxo de trabalho precisa. Nenhuma dessas coisas existe no contexto de um LaunchAgent. O launchd executa o processo com um ambiente mínimo: HOME, USER, TMPDIR e pouco mais. O PATH padrão contém apenas /usr/bin:/bin:/usr/sbin:/sbin — o Homebrew em /opt/homebrew/bin, ferramentas instaladas via pip ou cargo em ~/.local/bin, e qualquer outro diretório personalizado simplesmente não existem.
O wrapper script contorna esse problema para o caso específico do FUQU porque usa o caminho absoluto do Python dentro do virtualenv, sem depender do PATH para nada. Mas para agents que chamam binários do Homebrew ou dependem de variáveis de ambiente específicas, o plist oferece a chave EnvironmentVariables:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
Cada variável é um par chave-valor dentro do dicionário. O PATH pode ser estendido para incluir qualquer diretório necessário, e variáveis como LANG garantem que a codificação de caracteres se comporte da mesma forma que no terminal interativo — algo que importa quando o comando produz saída com acentos ou caracteres especiais.
A abordagem declarativa tem uma vantagem sobre o padrão do cron de colocar PATH=... no topo do crontab: cada agent tem seu próprio conjunto de variáveis, isolado dos demais. Um agent que precisa de uma versão específica do Python pode apontar para um PATH diferente de outro que usa Node, sem conflito. No cron, o PATH definido no crontab vale para todos os jobs daquele usuário.
Troubleshooting: quando o agent não roda#
launchctl e os comandos que você vai usar#
O launchctl é a interface de linha de comando para o launchd, e na prática você vai usar um punhado de subcomandos repetidamente. Vale tê-los à mão porque a página de manual do launchctl é extensa e nem sempre amigável.
Para ver todos os agents carregados na sua sessão de usuário:
launchctl list
A saída tem três colunas: PID, último código de saída e label. Um PID com - significa que o agent não está rodando neste momento, o que é normal para agents baseados em StartCalendarInterval que executam e terminam. O código de saída 0 indica sucesso na última execução; qualquer outro número aponta para um problema.
Para verificar o status de um agent específico sem filtrar com grep:
launchctl print gui/$(id -u)/com.janio.fuqu-telegram
O subcomando print mostra informações detalhadas: o estado atual do agent, o caminho do plist, o último código de saída, os caminhos de log e as condições de agendamento. O prefixo gui/$(id -u) identifica o domínio do usuário logado — é o equivalente a dizer “o agent que pertence a mim, não ao sistema”.
Para disparar uma execução manual fora do horário agendado:
launchctl start com.janio.fuqu-telegram
Para descarregar um agent (remove do launchd mas não apaga o arquivo):
launchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
Para recarregar depois de editar o plist:
launchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
Não existe um launchctl reload. Descarregar e carregar de novo é o fluxo padrão, e precisa ser feito nessa ordem — tentar carregar um agent que já está carregado resulta em erro.
Erros comuns e onde procurar#
O erro mais frequente ao criar um LaunchAgent pela primeira vez é o agent simplesmente não fazer nada. Nenhuma mensagem, nenhum log, nenhum sinal de vida. O diagnóstico segue uma sequência previsível.
O primeiro passo é confirmar que o agent está carregado. Se launchctl list | grep fuqu não retorna nada, o launchd não sabe que o agent existe. As causas mais comuns são o arquivo plist fora do diretório correto (precisa estar em ~/Library/LaunchAgents/, não em ~/LaunchAgents/ nem em nenhuma outra variação), permissões incorretas no arquivo (deve pertencer ao seu usuário, com permissão 644), ou XML malformado. O plutil valida a sintaxe do plist sem carregá-lo:
plutil -lint ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
Se o plutil reportar erro, o problema é estrutural no XML — uma tag não fechada, um tipo errado, uma chave fora do dicionário principal. O plutil indica a linha e a natureza do erro, o que costuma ser suficiente para encontrar o problema.
O segundo passo, se o agent está carregado mas o código de saída não é zero, é olhar os logs. Se StandardOutPath e StandardErrorPath estão configurados no plist, o conteúdo do arquivo de erro geralmente revela o problema de imediato. Se os arquivos de log existem mas estão vazios, o processo pode estar terminando antes de produzir qualquer saída — o que aponta para um problema no próprio comando ou no wrapper script.
Se os arquivos de log nem sequer foram criados, a causa mais provável é que o diretório pai não existe. O launchd não cria diretórios intermediários — se ~/.local/log/ não existir, os logs simplesmente não são escritos, e o launchd não avisa que isso aconteceu. Criar o diretório e disparar o agent manualmente com launchctl start costuma resolver.
O terceiro padrão de falha é o comando funcionar no terminal mas falhar no LaunchAgent. Isso quase sempre é um problema de ambiente. O script depende de algo que existe na sessão interativa do shell mas não no contexto do launchd — um PATH que inclui o Homebrew, uma variável de ambiente exportada no .zshrc, um serviço que só roda quando o terminal está aberto. O teste com env -i descrito na seção anterior existe para pegar esse tipo de problema antes de envolver o launchd, mas se o agent já está criado e falhando, o diagnóstico é o mesmo: execute o wrapper script com env -i HOME="$HOME" e veja o que quebra.
Outros erros que aparecem com menos frequência incluem o Label no plist não corresponder ao nome do arquivo (o launchd é tolerante com isso na maioria dos casos, mas pode causar comportamentos inesperados), o ProgramArguments usando ~ em vez do caminho absoluto (o launchd não expande til), e o script wrapper sem permissão de execução (chmod +x resolve). Em todos esses casos, o launchctl print do agent costuma ter a informação necessária para chegar na causa — vale acostumar-se a usá-lo como primeira ferramenta de diagnóstico em vez de tentar adivinhar o que deu errado.
Comparando com systemd timers#
Quem leu o post anterior e chegou até aqui provavelmente já percebeu que o launchd e o systemd timers resolvem os mesmos problemas fundamentais do cron — logging, execuções perdidas, variáveis de ambiente, dependências — mas com abordagens diferentes que refletem as filosofias dos seus respectivos sistemas operacionais.
A separação de responsabilidades é o ponto onde a semelhança é mais direta. O systemd divide a tarefa em dois arquivos: um .service que define o que executar e um .timer que define quando. O launchd concentra tudo num único plist, com chaves para o comando e para o agendamento no mesmo arquivo. Na prática, a abordagem do systemd é mais flexível — o serviço pode ser testado, monitorado e controlado independentemente do timer, e o mesmo serviço pode ser disparado por múltiplos timers ou por outros eventos do sistema. O plist do launchd é mais autocontido e mais simples de mover entre máquinas, mas acopla a definição da tarefa ao seu agendamento.
O logging é onde o systemd leva vantagem clara. O journald centraliza toda a saída de todos os serviços num sistema de logging estruturado, com filtros por unit, por tempo, por prioridade e com persistência gerenciada automaticamente. O launchd escreve em arquivos de texto plano que você mesmo precisa rotacionar. Para um agent pessoal que roda uma vez por dia, a diferença é irrelevante. Para um servidor com dezenas de tarefas agendadas, a ausência de um journal centralizado no macOS se faz sentir.
A recuperação de execuções perdidas funciona nos dois sistemas, mas com mecanismos diferentes. No systemd, é opt-in via Persistent=true — se você não declarar a diretiva, execuções perdidas durante o downtime são ignoradas, exatamente como no cron. No launchd, a recuperação é o comportamento padrão e não pode ser desabilitada. A escolha da Apple faz mais sentido para laptops que dormem e acordam constantemente; a do systemd faz mais sentido para servidores onde o administrador pode ter boas razões para não querer que uma tarefa perdida rode fora do horário previsto.
O controle de recursos e sandboxing é território exclusivo do systemd. Diretivas como CPUQuota, MemoryMax, ProtectHome e PrivateTmp não têm equivalente direto no launchd para LaunchAgents de usuário. O macOS tem seus próprios mecanismos de sandboxing (App Sandbox, seatbelt profiles), mas eles são voltados para aplicativos distribuídos pela App Store, não para scripts pessoais em ~/Library/LaunchAgents/. Na prática, um LaunchAgent roda com as permissões completas do usuário, sem nenhuma restrição adicional — semelhante ao que o cron faz em ambos os sistemas.
A sintaxe de agendamento é mais expressiva no systemd. O formato OnCalendar aceita intervalos como Mon..Fri, repetições como *:0/15, múltiplas diretivas empilhadas no mesmo timer e a possibilidade de validar tudo previamente com systemd-analyze calendar. O StartCalendarInterval do launchd é funcional mas limitado: aceita valores fixos para cada campo, sem intervalos nem repetições no mesmo dicionário. Agendar uma tarefa para dias úteis às 18h exige cinco entradas separadas no plist (uma por dia), enquanto no systemd é uma única linha. Para agendamentos simples como “todo dia às 7h”, ambos resolvem com a mesma facilidade.
O ponto em que o launchd não tem rival é a integração com o ciclo de vida do macOS. Ele entende nativamente o sleep e o wake do sistema, sabe quando o usuário fez login e logout, e pode condicionar a execução de um agent a eventos como a montagem de um volume ou a conexão a uma rede específica. Systemd timers podem depender de targets e units do sistema, mas o vocabulário de eventos disponível no Linux é diferente — voltado para servidores que ficam ligados o tempo todo, não para laptops que abrem e fecham a tampa trinta vezes por dia.
No fim, a recomendação prática é a mesma para os dois sistemas: use a ferramenta nativa. Em servidores Linux, systemd timers. No Mac, launchd. O cron continua existindo em ambos, continua funcionando, e continua sendo a escolha mais rápida para um job descartável que você precisa rodar nas próximas duas horas. Mas para qualquer tarefa que vai fazer parte da rotina diária do sistema — como um briefing que precisa chegar no Telegram toda manhã, faça chuva, faça sol, o laptop esteja aberto ou não — o agendador nativo entrega confiabilidade que o cron simplesmente não consegue oferecer.
E se em vez de agendar por horário você quiser reagir a mudanças em arquivos — backup automático quando um banco SQLite é modificado, conversão de imagens quando aparecem numa pasta — escrevi um post sobre WatchPaths, o outro lado do launchd.
Sysadmin, 53 anos, brasileiro trabalhando de casa para o mundo todo. Cuida de servidores Linux, containers LXC, e de gatos que não saem de cima do teclado.