🍎 launchd no macOS — plist + load + start
No macOS, launchd é o init system. Você descreve o serviço num plist XML, coloca em ~/Library/LaunchAgents/, carrega com launchctl load. Pronto: roda no boot, reinicia se cair, redireciona stdout pra arquivo. Sem nohup, sem screen, sem "deixa o terminal aberto".
📌 A regra do plist
Convenção: com.<empresa>.<servico>.plist. RunAtLoad=true + KeepAlive=true é o combo "sempre vivo". Logs em ~/Library/Logs/ pra Console.app indexar.
<?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>club.inema.agentos</string>
<key>ProgramArguments</key>
<array>
<string>/Users/marco/.venvs/agentos/bin/python</string>
<string>-m</string>
<string>agentos.main</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/marco/code/agentos</string>
<key>EnvironmentVariables</key>
<dict>
<key>AGENTOS_ENV</key>
<string>production</string>
<key>SECRET_KEY_FILE</key>
<string>/Users/marco/.agentos/master.key</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key>
<string>/Users/marco/Library/Logs/agentos.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/marco/Library/Logs/agentos.err.log</string>
<key>ThrottleInterval</key><integer>10</integer>
</dict>
</plist>
# Salvar como ~/Library/LaunchAgents/club.inema.agentos.plist
$ chmod 644 ~/Library/LaunchAgents/club.inema.agentos.plist
$ launchctl load ~/Library/LaunchAgents/club.inema.agentos.plist
$ launchctl start club.inema.agentos
# Ver status
$ launchctl list | grep agentos
12345 0 club.inema.agentos # PID, último exit code, label
# Recarregar após editar plist
$ launchctl unload ~/Library/LaunchAgents/club.inema.agentos.plist
$ launchctl load ~/Library/LaunchAgents/club.inema.agentos.plist
# Tail dos logs
$ tail -f ~/Library/Logs/agentos.err.log
✓ Fazer
- ✓
ThrottleInterval≥ 10s — evita loop de crash que consome CPU. - ✓Path absoluto pro Python do venv — launchd não herda
PATHdo shell. - ✓Logs separados
outeerr. - ✓
WorkingDirectoryexplícito — paths relativos quebram sem ele.
✗ Evitar
- ✗Usar
/usr/bin/python3— em update do macOS some. - ✗Esquecer
KeepAlive— caiu uma vez, ficou caído. - ✗Colocar segredo direto em
EnvironmentVariables— plist vai pro git. - ✗
chmod 755no plist — launchctl rejeita por segurança.
LaunchAgents = usuário. LaunchDaemons = root.
Sobe no login automático.
KeepAlive=true reinicia em ~ThrottleInterval.
Console.app mostra crash + stderr.
🐧 systemd no Linux — unit user + enable
No Linux, systemd é o padrão. Você descreve o serviço num .service INI, coloca em ~/.config/systemd/user/ (modo user — sem sudo), habilita com systemctl --user enable. journalctl dá log estruturado de graça.
# ~/.config/systemd/user/agentos.service
[Unit]
Description=Agentic OS — main loop
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/marco/code/agentos
ExecStart=/home/marco/.venvs/agentos/bin/python -m agentos.main
Environment=AGENTOS_ENV=production
Environment=SECRET_KEY_FILE=/home/marco/.agentos/master.key
EnvironmentFile=-/home/marco/.agentos/runtime.env
Restart=always
RestartSec=10
StartLimitInterval=120
StartLimitBurst=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=agentos
# Hardening básico — vale o esforço
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/home/marco/.agentos /home/marco/code/agentos/data
[Install]
WantedBy=default.target
# Habilitar e iniciar (sem sudo)
$ systemctl --user daemon-reload
$ systemctl --user enable --now agentos.service
# Sobreviver ao logout (caso contrário cai com a sessão)
$ sudo loginctl enable-linger marco
# Status, logs, restart
$ systemctl --user status agentos
$ journalctl --user -u agentos -f
$ journalctl --user -u agentos --since "2 hours ago" -p err
$ systemctl --user restart agentos
📊 macOS vs Linux — tabela comparativa
| Aspecto | launchd (macOS) | systemd (Linux) |
|---|---|---|
| Formato | plist XML | INI .service |
| Local (user) | ~/Library/LaunchAgents/ | ~/.config/systemd/user/ |
| Carregar | launchctl load | systemctl --user daemon-reload |
| Ligar | launchctl start <label> | systemctl --user start agentos |
| Auto-restart | KeepAlive=true | Restart=always |
| Throttle | ThrottleInterval=10 | RestartSec=10 |
| Logs | Arquivo em ~/Library/Logs/ | journalctl (estruturado) |
| Sobrevive logout | Sim (LaunchAgent) | Só com loginctl enable-linger |
| Hardening | Limitado (sandbox-exec) | Rico (NoNewPrivileges, ProtectSystem…) |
Default — processo não faz fork.
5 crashes em 120s = systemd desiste.
Sem ele, serviço user morre no logout.
Whitelist explícita de escrita.
🔐 Secrets com Fernet — AES + chave derivada
Tokens de API, chaves do bot, credenciais de banco — nada disso em .env versionado. Use cryptography.fernet: criptografia simétrica AES-128-CBC + HMAC-SHA256, chave única em arquivo fora do repo, descriptografia em memória só quando precisa.
🚨 Alerta — secrets em git são compromisso vitalício
Um git push com chave Anthropic exposta = chave queimada. Mesmo se você forçar --force-push, o GitHub escaneia, notifica o provider e a chave é revogada automaticamente em horas. Pior: bots scrapeiam commits públicos em segundos. Trate qualquer leak como rotação obrigatória, não como susto.
# secrets/box.py
import json
import os
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
class SecretBox:
"""Wrapper sobre Fernet com chave em arquivo + cache em memória."""
def __init__(self, key_path: Path, vault_path: Path):
self.key_path = key_path
self.vault_path = vault_path
self._cache: dict[str, str] | None = None
@classmethod
def init(cls, key_path: Path, vault_path: Path) -> "SecretBox":
"""Gera chave nova se não existir. chmod 0600 obrigatório."""
if not key_path.exists():
key_path.parent.mkdir(parents=True, exist_ok=True)
key_path.write_bytes(Fernet.generate_key())
os.chmod(key_path, 0o600)
if not vault_path.exists():
vault_path.write_bytes(b"")
os.chmod(vault_path, 0o600)
return cls(key_path, vault_path)
def _fernet(self) -> Fernet:
# Recusa abrir se permissões frouxas
mode = self.key_path.stat().st_mode & 0o777
if mode != 0o600:
raise PermissionError(
f"{self.key_path} tem modo {oct(mode)}; deveria ser 0o600"
)
return Fernet(self.key_path.read_bytes())
def _load(self) -> dict[str, str]:
if self._cache is not None:
return self._cache
raw = self.vault_path.read_bytes()
if not raw:
self._cache = {}
return self._cache
try:
plain = self._fernet().decrypt(raw)
except InvalidToken:
raise RuntimeError("Vault corrompido ou chave errada")
self._cache = json.loads(plain.decode("utf-8"))
return self._cache
def get(self, name: str) -> str:
data = self._load()
if name not in data:
raise KeyError(f"Secret '{name}' não está no vault")
return data[name]
def put(self, name: str, value: str) -> None:
data = self._load()
data[name] = value
token = self._fernet().encrypt(json.dumps(data).encode("utf-8"))
# Escrita atômica — tmp + rename
tmp = self.vault_path.with_suffix(".tmp")
tmp.write_bytes(token)
os.chmod(tmp, 0o600)
tmp.replace(self.vault_path)
self._cache = data
# Uso na boot do agente
from secrets.box import SecretBox
from pathlib import Path
box = SecretBox.init(
key_path=Path.home() / ".agentos" / "master.key",
vault_path=Path.home() / ".agentos" / "vault.fernet",
)
# Primeira vez — popula
box.put("ANTHROPIC_API_KEY", "sk-ant-...")
box.put("TELEGRAM_BOT_TOKEN", "1234:ABC...")
# No app
api_key = box.get("ANTHROPIC_API_KEY")
✓ chmod 0600 sempre
Só o dono lê e escreve. Sem isso, qualquer processo do user pode ler — incluindo malware que rodou com seus privilégios. SecretBox recusa abrir se modo está errado.
✗ World-readable é jogar fora
chmod 0644 deixa qualquer usuário do sistema ler. Em multi-tenant, nobody e www-data herdam acesso. É como deixar a senha do cofre num post-it no balcão.
Off-site. Sem ela, vault vira lixo.
MultiFernet aceita chave nova + velha.
tmp + rename — crash não corrompe.
>10 secrets ou multi-host? Vá pra Vault/SOPS.
📜 Logs estruturados — JSON lines + rotação
Log de string concatenada ("user 42 sent: hello") é hieróglifo pra máquina. JSON por linha com structlog + TimedRotatingFileHandler = busca, filtro, agregação em Grafana/Loki sem regex. Custa 5 linhas a mais no setup, paga em todo bug de produção.
💡 Dica — JSON line é o formato pra Grafana/Loki ler
Loki indexa labels (request_id, user_id, level), grep no resto. Datadog, Honeycomb, Elastic — todos consomem JSONL nativo. Texto plano vira string opaca e a busca custa 10x.
# observability/log.py
import logging
import logging.handlers
from pathlib import Path
import structlog
LOG_DIR = Path.home() / ".agentos" / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
def configure_logging(level: str = "INFO") -> None:
# Handler: 1 arquivo por dia, mantém 14
handler = logging.handlers.TimedRotatingFileHandler(
filename=LOG_DIR / "agentos.jsonl",
when="midnight",
interval=1,
backupCount=14,
encoding="utf-8",
utc=True,
)
handler.suffix = "%Y-%m-%d"
logging.basicConfig(
format="%(message)s", # structlog produz a linha já formatada
level=level,
handlers=[handler, logging.StreamHandler()],
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars, # request_id, user_id
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(), # <-- saída JSON
],
wrapper_class=structlog.make_filtering_bound_logger(
logging.getLevelName(level)
),
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
log = structlog.get_logger()
# Uso
from observability.log import log, configure_logging
from structlog.contextvars import bind_contextvars, clear_contextvars
configure_logging("INFO")
async def handle_message(msg):
clear_contextvars()
bind_contextvars(request_id=msg.id, user_id=msg.user_id, channel="telegram")
log.info("turn_start", text_preview=msg.text[:60])
try:
result = await run_agent(msg)
log.info("turn_end", tokens_in=result.tin, tokens_out=result.tout,
latency_ms=result.dt_ms)
except Exception:
log.exception("turn_failed") # stack trace serializado em JSON
raise
# Linha resultante:
# {"event":"turn_start","level":"info","timestamp":"2026-05-18T23:43:01Z",
# "request_id":"r-abc","user_id":"u-42","channel":"telegram",
# "text_preview":"oi, lembra do projeto X?"}
1 evento por linha. jq ama.
Disk não estoura, retenção previsível.
request_id viaja sem passar parâmetro.
Trunca payload — log não é warehouse.
⏰ Cron — dream cycle de 3h com lock
Agente bom dorme: a cada 3h, roda um "dream cycle" que consolida memória, gera resumos, sincroniza com fontes externas. Cron é a forma mais barata de agendar. Mas cron não sabe que a execução anterior ainda está rodando — sem lock, dois processos pisam um no outro.
# crontab -e (user crontab)
# Min Hora Dia Mês DiaSem Comando
0 */3 * * * /home/marco/.venvs/agentos/bin/python -m agentos.dream >> /home/marco/.agentos/logs/dream.log 2>&1
30 4 * * * /home/marco/.venvs/agentos/bin/python -m agentos.tasks.daily_digest
0 * * * * /home/marco/code/agentos/scripts/health_ping.sh
# agentos/dream.py
import asyncio
import fcntl
import os
import sys
from pathlib import Path
from contextlib import contextmanager
from observability.log import log, configure_logging
LOCK_PATH = Path.home() / ".agentos" / "dream.lock"
@contextmanager
def exclusive_lock(path: Path):
"""flock não-bloqueante. Se outro processo segura, levantamos."""
path.parent.mkdir(parents=True, exist_ok=True)
fp = path.open("w")
try:
fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
fp.close()
raise RuntimeError(f"dream já em execução (lock: {path})")
try:
fp.write(str(os.getpid()))
fp.flush()
yield
finally:
fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
fp.close()
async def dream_cycle():
log.info("dream_start")
await consolidate_memories()
await refresh_external_sources()
await summarize_recent_interactions()
log.info("dream_end")
def main():
configure_logging("INFO")
try:
with exclusive_lock(LOCK_PATH):
asyncio.run(dream_cycle())
except RuntimeError as e:
log.warning("dream_skipped", reason=str(e))
sys.exit(0) # exit 0 — cron não considera erro
if __name__ == "__main__":
main()
🧠 Por que fcntl.flock e não arquivo "lockfile" manual
- Kernel libera no crash: mesmo se o processo morrer com
kill -9, o lock cai junto. Lockfile manual ficaria órfão. - Atômico: dois processos chamando
flockao mesmo tempo — só um ganha. Sem race. - LOCK_NB: não-bloqueante. Se preso, falha rápido em vez de empilhar processos esperando.
- Cross-platform-ish: macOS e Linux compartilham; no Windows use
msvcrt.locking.
✓ Padrão dream cycle
- • A cada 3h consolida memória
- • Resume últimas 24h em 1 entrada
- • Reindex FTS5
- • Limpa interações >90 dias se houver fact extraído
✗ Erros típicos
- •
* * * * *sem lock — concorrência - • Job dura mais que o intervalo
- • Exit code ≠ 0 em "skip" → cron alerta
- • Sem log → "não rodou" vs "rodou e deu silêncio"
3h equilibra freshness e custo de LLM.
Vazio — use sempre paths absolutos.
2>&1 ou o cron te manda mail.
systemd timers (linux) / launchd StartCalendar (mac).
🚦 Healthcheck e kill switch — /status + /killall
Duas peças que o módulo 1.1 chamou de "termômetro de maturidade". /status responde "tô vivo, eis meu estado". /killall é o botão vermelho — uma flag no banco que o loop principal lê a cada turno e respeita. Sem isso, "desligar tudo" vira kill -9 torcendo.
-- schema.sql — flag central
CREATE TABLE IF NOT EXISTS system_flags (
name TEXT PRIMARY KEY,
value TEXT NOT NULL,
set_by TEXT NOT NULL,
set_at REAL NOT NULL,
reason TEXT
);
-- Estado inicial
INSERT OR IGNORE INTO system_flags(name, value, set_by, set_at, reason)
VALUES ('agent.enabled', '1', 'system', strftime('%s','now'), 'boot');
# ops/control.py
import time
import sqlite3
from observability.log import log
DB = "/home/marco/.agentos/data/agentos.db"
def is_enabled() -> bool:
with sqlite3.connect(DB) as cx:
row = cx.execute(
"SELECT value FROM system_flags WHERE name='agent.enabled'"
).fetchone()
return bool(row and row[0] == "1")
def killall(actor: str, reason: str) -> None:
with sqlite3.connect(DB) as cx:
cx.execute("""
INSERT INTO system_flags(name, value, set_by, set_at, reason)
VALUES ('agent.enabled', '0', ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
value='0', set_by=excluded.set_by,
set_at=excluded.set_at, reason=excluded.reason
""", (actor, time.time(), reason))
log.warning("kill_switch_engaged", actor=actor, reason=reason)
def resume(actor: str) -> None:
with sqlite3.connect(DB) as cx:
cx.execute("""
UPDATE system_flags SET value='1', set_by=?, set_at=?, reason='resume'
WHERE name='agent.enabled'
""", (actor, time.time()))
log.info("kill_switch_released", actor=actor)
# No loop principal do agente
from ops.control import is_enabled
async def handle(self, msg):
if not is_enabled():
await self.channel.send(OutgoingMessage(
user_id=msg.user_id,
text="🛑 Sistema em manutenção. Volto em breve."
))
return
# ... resto do turno
# Comandos administrativos expostos como tools (só admin)
class StatusTool(Tool):
name = "status"
description = "Diagnóstico: uptime, fila, último erro, flags."
parameters = {"type": "object", "properties": {}, "required": []}
async def execute(self, session, **kwargs):
uptime = time.time() - BOOT_TIME
return ToolResult(output=json.dumps({
"enabled": is_enabled(),
"uptime_s": round(uptime),
"pending_turns": queue.qsize(),
"last_error": LAST_ERROR_AT,
"version": __version__,
}, indent=2))
class KillallTool(Tool):
name = "killall"
description = "EMERGÊNCIA: para todos os turnos novos. Não mata em-andamento."
parameters = {
"type": "object",
"properties": {"reason": {"type": "string"}},
"required": ["reason"]
}
requires_approval = True # T3.4 — human-in-the-loop
async def execute(self, session, reason: str, **_):
if not session.user.is_admin:
return ToolResult(error="Apenas admins podem acionar kill switch")
killall(actor=session.user.id, reason=reason)
return ToolResult(output=f"🛑 Kill switch engajado. Motivo: {reason}")
💡 Healthcheck também vira endpoint externo
Exponha /health num HTTP server pequeno (aiohttp). UptimeRobot/Healthchecks.io faz ping a cada 1 min — se 2 falhas seguidas, te manda push. Custo: 5 linhas, 0 dólares.
📊 Conceitos-chave do kill switch
Diagnóstico em JSON. Admin lê do chat.
Flag no banco. Loop respeita.
Endpoint HTTP. Monitor externo.
Killall pede confirmação humana.
🏁 Trilha 2 concluída — resumo do módulo
Próxima trilha:
Trilha 3 — Multi-user OS. Tirar o agente de single-tenant e abrir pra equipe/clientes: identidade, isolamento, rate limit, audit log, billing por user.