Pular para o conteúdo principal
O Scheduler (codename Chronos) é a camada de automação durável do ChatCLI. Ele permite:
  • Agendar ações por tempo absoluto, relativo, cron ou interval.
  • Esperar condições (HTTP, K8s, Docker, TCP, file, shell, LLM) e disparar ações só quando satisfeitas.
  • Encadear jobs em DAG com DependsOn / Triggers.
  • Rodar em daemon que sobrevive ao fechar da CLI — perfeito para deploys longos, terraform apply, migrações de banco.
  • Dar aos agents uma ferramenta @scheduler para planejar os próprios follow-ups (“aguarde o deploy e me avise”).
Tudo persistente via WAL com CRC32, snapshots periódicos, circuit breakers por evaluator/action, rate limiter global e por-owner, audit log JSONL, métricas Prometheus e hooks de lifecycle.
Todos os três modos do ChatCLI (CLI interativa, servidor gRPC, operador K8s) podem usar o scheduler. O daemon é opcional — em uso casual o scheduler roda in-process e o WAL replaya os jobs na próxima vez que você abrir o CLI.

Visão geral do fluxo

Cada job pode disparar imediatamente, esperar uma condição, encadear outros jobs e propagar lifecycle hooks. O diagrama abaixo mostra um pipeline típico de deploy + verificação + notificação:

Por que você precisa disso

Antes do scheduler, ChatCLI era sempre síncrono. Você pedia algo, esperava, recebia resposta. Agora:
 /schedule deploy --when +0s --do "shell: terraform apply -auto-approve" --triggers verify
 /schedule verify --when manual --do "/run kubectl get pods" --wait "k8s:deployment/prod/api:Available" --timeout 10m

 exit    # pode fechar o CLI — jobs continuam se o daemon estiver rodando
O terraform apply roda, espera a deployment ficar Available, então executa o check final. Você volta horas depois e pergunta /jobs history para ver o que aconteceu.

Dois modos de execução

Sem setup. Abra o chatcli normal e use /schedule / /wait / /jobs. O scheduler roda dentro do próprio processo.
 chatcli
 /schedule backup --cron "0 2 * * *" --do "/run backup diário"
O status line no prompt mostra [jobs: 1⏳] enquanto há jobs ativos.
Se você sair do chatcli, os workers param. Os jobs pendentes ficam no WAL (~/.chatcli/scheduler/wal/) e serão replayados automaticamente na próxima vez que você abrir o CLI.

Comando /schedule — criar um job

/schedule <nome> --when <t> --do <ação> [flags]

Valores de --when

O DSL aceita múltiplos formatos:
FormatoExemploComportamento
Relative+5m, in 30s, after 2hUma vez, após a duração
Absoluteat 2026-04-25T14:00, at nowUma vez, em tempo exato
Cron 5-camposcron:0 2 * * *, 0 2 * * *Recorrente, padrão Vixie-cron
Cron shorthand@hourly, @daily, @weekly, @monthly, @yearlyPresets comuns
Intervalevery 30s, every 5m, every 1hRecorrente com intervalo fixo
Condition-gatedwhen-readySem tempo — dispara quando --wait for satisfeito
Manualmanual, triggeredSó dispara via --triggers de outro job

Valores de --do

Sete tipos de action:
TipoSintaxeDescrição
Slash command/run tests / /coder refactor XExecuta slash command como se o user tivesse digitado
Shellshell: docker compose up -dComando shell sob CoderMode safety
Agent taskagent: deploy e verifiqueBoota ReAct agent com a tarefa
LLM promptllm: resumir o relatório semanalSingle LLM call headless
WebhookPOST https://hooks.slack.com/... | helloHTTP request
Hookhook:PostToolUseDispara hook chatcli por event name
NoopnoopÚtil para pipelines só com Triggers

Flags completas

Exemplos

# Cron diário
/schedule backup --cron "0 2 * * *" --do "/run backup --full"

# Um-shot com wait
/schedule deploy --when +0s --do "shell: terraform apply -auto-approve" \
  --wait "k8s:deployment/prod/api:Available" --timeout 15m \
  --triggers smoke-tests

# Interval com tag
/schedule health-check --every 30s --do "/run healthcheck prod" --tag env=prod

# Manual (só dispara via triggers do pai)
/schedule notify --when manual --do "POST https://slack.webhook | deploy done"

Comando /wait — bloquear até condição

Açúcar sintático para “esperar que X aconteça e opcionalmente fazer Y”.
/wait --until <condição> [--then <ação>] [flags]

DSL de condições

SintaxeEvaluator internoExemplo
http://host/path==200http_statusAguarda HTTP 200
http://host~=/ok/http_status (regex)Aguarda regex bater no body
tcp://host:porttcp_reachableAguarda porta TCP aberta
k8s:<kind>/<ns>/<name>:<cond>k8s_resource_readyk8s:pod/prod/api:Ready
k8s:<kind>/<name>k8s_resource_readyNamespace default, condição Ready
docker:<name>:runningdocker_runningContainer rodando
docker:<name>:healthydocker_runningContainer com healthcheck OK
file:/pathfile_existsArquivo existe
file:/path>=100file_exists (min_size)Arquivo ≥ 100 bytes
shell: <cmd>shell_exitShell retorna 0
<cmd>~=/pattern/regex_matchOutput do cmd casa regex
llm: <pergunta>llm_checkLLM responde YES à pergunta
and(<expr>, <expr>, ...)all_ofTodos satisfeitos
or(<expr>, <expr>, ...)any_ofQualquer um satisfeito
not <expr>negateNega a expressão filha

Exemplos

# Esperar o endpoint ficar saudável e rodar smoke test
/wait --until http://localhost:8080/health==200 --then "/run smoke" --every 5s --timeout 10m

# Async: registra e volta imediato; você vê em /jobs list
/wait --until "k8s:pod/prod/api-*:Ready" --async --name api-ready

# Composta: porta aberta E arquivo de lock removido
/wait --until "and(tcp://db:5432, not file:/tmp/migrating)" --then "/run app restart"

Timeouts

  • --on-timeout fail (padrão) — marca como timed_out e encerra.
  • --on-timeout fire_anyway — roda a ação mesmo sem a condição satisfeita.
  • --on-timeout fallback — roda o action alternativo definido em WaitSpec.Fallback (via JSON spec) e depois falha.

Comando /jobs — gerenciar

/jobs list                    # ativos (pending, blocked, waiting, running, paused)
/jobs list --all              # inclui terminais
/jobs list --status running
/jobs list --owner me
/jobs list --tag env=prod --name backup

/jobs show <id>               # detalhe completo + histórico de execuções
/jobs tree                    # DAG ASCII (depends_on / triggers)
/jobs logs <id>               # histórico de execuções (falhas, outputs)
/jobs history                 # jobs terminais (alias de list --all filtrado)

/jobs cancel <id> [motivo...]
/jobs pause <id>
/jobs resume <id>

/jobs daemon                  # status do daemon ou in-process
/jobs gc                      # força snapshot + WAL garbage collection
O autocomplete (pressione Tab) sugere:
  • Subcomandos (list, show, cancel, …)
  • IDs reais dos jobs para show/cancel/pause/resume/logs
  • Valores para --status (pending, running, waiting, …) e --owner (me, user, agent, worker, system, hook)

Daemon mode

Ciclo de vida

chatcli daemon start [--detach] [--socket <path>]
chatcli daemon stop  [--socket <path>]
chatcli daemon status [--socket <path>]
chatcli daemon ping  [--socket <path>]
chatcli daemon install    # imprime template systemd/launchd
  • --detach faz re-exec com setsid (Unix) / CREATE_NEW_PROCESS_GROUP (Windows), libera o terminal. Log vai para <socket_dir>/daemon.log.
  • O CLI interativo auto-detecta um daemon na socket configurada e vira thin client/schedule, /wait, /jobs round-trip por IPC.
  • Stale sockets (processo morto) são limpos automaticamente antes de start.

Protocolo IPC

Socket UNIX com frames de 4-byte length-prefix + JSON payload. Kinds suportados:
  • ping, bye — health/close
  • enqueue, cancel, pause, resume, query, list, snapshot, stats — operações
  • subscribe — server-sent events para UI
Durabilidade é idêntica ao in-process: WAL fsync antes de admitir, snapshot periódico, replay on boot.

systemd / launchd

chatcli daemon install imprime um template pronto para colar em /etc/systemd/system/chatcli-scheduler.service ou ~/Library/LaunchAgents/.

@scheduler — tool para agents

Dentro do ReAct loop, o agent pode chamar @scheduler com 5 subcomandos. Isso permite agents plancjar pausas autônomas.
<tool_call name="@scheduler" args='{
  "cmd": "schedule",
  "args": {
    "name": "wait-deploy",
    "when": "+0s",
    "do": "/run kubectl get pods -n prod",
    "until": "k8s:deployment/prod/api:Available",
    "timeout": "10m"
  }
}' />
Subcomandos:
cmdArgs shapeRetorna
schedule{name, when, do, wait?, timeout?, depends_on?, triggers?, ...}{job_id, status, summary}
wait{until, every?, timeout?, async?, then?}Sync: {outcome, job} · Async: {job_id, status}
query{id}Job completo (status, history, transitions)
list{filter?: {owner, statuses, tag, name_substr, include_terminal}}{jobs: [...]}
cancel{id, reason?}{ok, job_id}
Owner do agent é preservado automaticamente — filter.owner == OwnerAgent por default no list, e agents só podem cancelar jobs que criaram (ou jobs de workers filhos).

Evaluators e actions — plug-in registry

Evaluators builtin

Cada um implementa ConditionEvaluator em cli/scheduler/condition/:

shell_exit

Executa comando, compara exit code com expected (default 0).

http_status

GET/POST para URL, matching exato ou por regex no body.

file_exists

Presença de arquivo, tamanho mínimo, mtime estável.

k8s_resource_ready

kubectl get + jsonpath; Pod, Deployment, StatefulSet, Service, etc.

docker_running

docker inspect; running + healthcheck.

tcp_reachable

Dial TCP com timeout.

regex_match

Shell cmd + regex contra stdout/stderr/combined.

llm_check

LLM headless responde YES/NO à sua pergunta.

custom

User script — args via env CHATCLI_SCHEDULER_SPEC.

all_of / any_of

Composite com curto-circuito e negação por filho.

Actions builtin

Em cli/scheduler/action/:
  • slash_cmd — invoca /foo args via command handler.
  • shell — comando shell sob CoderMode safety (allowlist/denylist de /config security).
  • agent_task — boota ReAct loop com a tarefa.
  • worker_dispatch — single-agent worker invocation.
  • llm_prompt — LLM call headless, opção de append na history.
  • webhook — HTTP POST/GET/PUT com JSON body, headers, expected status.
  • hook — fire hook chatcli por evento.
  • noop — útil para pipelines só com Triggers.
  • agent_resume — retoma um agent estacionado via @park. Carrega snapshot, reentra o ReAct loop com a history restaurada. Veja Agent Park & Resume.
  • park_poll — driver de polling do @park for_url / for_cmd. Roda a cada interval; ao casar success_when ou estourar deadline, dispara um agent_resume. Self-rescheduling crash-safe.

Durabilidade

WAL (Write-Ahead Log)

  • Um arquivo por job: ~/.chatcli/scheduler/wal/<jobid>.wal
  • Framing: magic[4] | length[4] | crc32[4] | payload | crc32[4] — duplo CRC detecta torn writes.
  • Atomic write via tmp+rename + dir fsync.
  • Arquivos corrompidos viram <jobid>.wal.corrupt para inspeção.

Snapshot

  • Escrito a cada SNAPSHOT_INTERVAL (padrão 5min) em snapshot.json.
  • Atomic replace via tmp-rename.
  • Boot preferencial: snapshot → overlay com qualquer .wal mais novo.

Replay on boot

  • Jobs Running ou Waiting no momento do crash voltam a Pending com Attempts preservado.
  • Missed fires respeitam MissPolicy:
    • fire_once (padrão) — coalesce de todos os ticks perdidos em um único fire.
    • fire_all — fire por cada tick perdido (opt-in, pode saturar).
    • skip — ignora a janela perdida, forward pra próxima.

Garbage collection

  • Jobs terminais ficam TTL em disco (padrão 24h) para /jobs history.
  • GC loop (WAL_GC_INTERVAL, padrão 1h) unlink .wal expirados.

Segurança

Action allowlist

CHATCLI_SCHEDULER_ACTION_ALLOWLIST controla quais tipos de action podem ser agendados. Default:
slash_cmd, llm_prompt, agent_task, worker_dispatch, hook, noop, webhook, shell
Cada action passa pela sua porta de segurança específica:
  • shellpreflight + re-check no fire contra CoderMode (ver próxima seção).
  • webhook → http.Client com timeout e max response size.
  • agent_task → reenter ReAct loop que mantém sua própria policy interativa.
  • slash_cmd → vai pelo CommandHandler do CLI (sujeito ao fluxo normal da sessão).

Preflight CoderMode para shell

O scheduler nunca prompta interativamente. Em modo daemon não existe usuário presente; em cron noturno o usuário pode estar offline. Então toda aprovação acontece na hora do /schedule, não no fire.
Todo comando shell embutido num job (na Action, no Wait.Condition ou nos filhos de composites all_of/any_of) passa pelo PolicyManager do CoderMode — o mesmo que /coder e /agent usam interativamente. Três resultados:
ClassificaçãoComportamento no scheduler
Allow (allowlist match ou read-only conhecido tipo kubectl get)✅ job admitido
Deny (denylist match)❌ rejeita no /schedule com ErrShellPolicyDeny. Denylist bate --i-know — não é override.
Ask (fora da allowlist, comando desconhecido)⚠️ rejeita com ErrShellPolicyAsk a menos que o job tenha DangerousConfirmed=true (via --i-know)
O preflight acontece antes do WAL write, então jobs perigosos nunca chegam a ficar persistidos. E no fire, o RunShell do bridge re-carrega a policy do disco e re-classifica — se o operator adicionou uma Deny rule entre o schedule e a execução, o job falha em vez de rodar.

Como editar a policy do CoderMode

/config security agora é hierárquico. A forma sem subcomando continua mostrando o panorama read-only; subcomandos novos mutam o PolicyManager ao vivo e persistem em ~/.chatcli/coder_policy.json:
/config security                              # dump read-only (como antes)
/config security rules                        # lista rules ativas agrupadas por action
/config security allow "@coder exec my-tool"  # adiciona ALLOW
/config security deny "@coder exec rm -rf /"  # adiciona DENY (confirma [y/N])
/config security forget "<pattern>"           # remove rule (confirma [y/N])
/config security reload                       # relê o JSON do disco
Fluxo típico depois do /schedule reclamar:
❯ /schedule backup --when +5m --do "shell: my-tool --backup"
  ❌ scheduler: shell command requires approval: my-tool --backup

❯ /config security allow "@coder exec my-tool"
  ✔ rule ALLOW adicionada: @coder exec my-tool
  persistido em ~/.chatcli/coder_policy.json

❯ /schedule backup --when +5m --do "shell: my-tool --backup"
  ✔ Job a1b2c3 criado (backup).
Confirmação destrutiva: deny e forget sempre perguntam [y/N]. allow pergunta apenas quando o pattern é “amplo” (ex: @coder exec sozinho ou um sufixo muito curto). Adicione --yes / -y para pular o prompt em scripts. Scope das mudanças: allow / deny / forget atualizam o JSON imediatamente; o CLI interativo (workerPolicyAdapter) recarrega a cada prompt Ask, e o scheduler recarrega a cada RunShell. Se você editou o JSON externamente, use /config security reload para forçar todos os caches a re-lerem. Os caminhos alternativos (mais antigos) continuam válidos:
  1. Pelo prompt interativo do /coder — escolher “Allow always” ou “Deny forever” num safety prompt também persiste a rule via PolicyManager.AddRule. Mesma infraestrutura do /config security allow/deny.
  2. Editar ~/.chatcli/coder_policy.json direto — útil para onboarding em lote (copiar um coder_policy.json pronto pro time), ou para regras por-projeto em <root>/coder_policy.json (merged com a global).
    {
      "rules": [
        {"pattern": "@coder exec my-proprietary-tool", "action": "allow"},
        {"pattern": "@coder exec rm -rf /",            "action": "deny"}
      ],
      "merge": true
    }
    
    A pattern usa prefix match sobre <toolName> <args> como o PolicyManager normaliza. Deny sempre bate allow.

--i-know e i_know (agents)

Quando você quer agendar um comando fora da allowlist sem adicioná-lo permanentemente:
/schedule custom-probe --when +30s \
  --do "shell: my-proprietary-tool --probe" \
  --i-know
Isso seta Job.DangerousConfirmed=true e o job passa pelo preflight mesmo com classificação Ask. Denylist continua bloqueando — --i-know não sobrepõe um deny explícito. Agents também têm a forma equivalente via tool call:
<tool_call name="@scheduler" args='{
  "cmd": "schedule",
  "args": {
    "name": "probe",
    "when": "+30s",
    "do": "shell: my-proprietary-tool --probe",
    "i_know": true
  }
}' />
A autorização aqui é implícita: você já autorizou o agent quando rodou /agent. Se quiser travar agents de usarem i_know, configure CHATCLI_SCHEDULER_ALLOW_AGENTS=false ou mantenha os comandos perigosos na denylist (agents nunca conseguem burlar denylist).

Bypass total (trusted automation)

Para automações internas em ambiente confiável, você pode desligar a checagem de policy inteiramente por-job:
  1. Operator permite: CHATCLI_SCHEDULER_SHELL_ALLOW_BYPASS=true
  2. Job cria com bypass_safety: true no spec JSON da action:
    {"type": "shell", "payload": {"command": "...", "bypass_safety": true}}
    
Evite — quase sempre o caminho correto é aprovar o comando uma vez via /coder (escolher “Allow always”) ou usar --i-know explicitamente no /schedule. Bypass é para CI/CD em containers efêmeros onde a sandbox é o isolamento.

Rate limiting

Token-bucket global + per-owner com tolerância de nanodelay:
CHATCLI_SCHEDULER_RATE_LIMIT_GLOBAL_RPS=5.0    # default
CHATCLI_SCHEDULER_RATE_LIMIT_GLOBAL_BURST=20
CHATCLI_SCHEDULER_RATE_LIMIT_OWNER_RPS=1.0
CHATCLI_SCHEDULER_RATE_LIMIT_OWNER_BURST=10
Um agent em ReAct loop runaway não consegue inundar a fila — o rate limiter rejeita com Retry-After hint.

Circuit breakers

Um breaker por evaluator type e um por action type, com closed → open → half_open classic:
CHATCLI_SCHEDULER_BREAKER_FAILURE_THRESHOLD=5
CHATCLI_SCHEDULER_BREAKER_WINDOW=60s
CHATCLI_SCHEDULER_BREAKER_COOLDOWN=30s
Se o k8s API cair, o breaker k8s_resource_ready abre e todos os jobs dependentes fail-fast com ErrBreakerOpen em vez de saturar o worker pool.

Audit log

Toda mutação (create, transition, cancel, fire) escreve uma linha JSON em ~/.chatcli/scheduler/audit.log. Rotação por lumberjack (padrão 10 MiB, 7 backups, 30 dias).

Autorização

  • OwnerUser e OwnerSystem podem cancelar qualquer job.
  • OwnerAgent só pode cancelar jobs próprios ou de workers filhos.
  • Cross-owner cancel retorna ErrNotAuthorized e fire hook PreJobCancel para auditoria.

Observabilidade

Métricas Prometheus

MétricaTipoLabels
chatcli_scheduler_jobs_created_totalCounterowner_kind, action_type
chatcli_scheduler_jobs_fired_totalCounteroutcome, action_type
chatcli_scheduler_wait_checks_totalCountercondition_type, satisfied
chatcli_scheduler_wait_duration_secondsHistogramcondition_type
chatcli_scheduler_action_duration_secondsHistogramaction_type, outcome
chatcli_scheduler_queue_depthGauge
chatcli_scheduler_active_jobsGauge
chatcli_scheduler_breaker_stateGaugekind, key (0=closed, 1=open, 2=half_open)
chatcli_scheduler_retries_totalCounterattempt (bucketed 1/2/3/4+)
chatcli_scheduler_enqueue_errors_totalCounterreason (rate_limited, full, invalid, …)
chatcli_scheduler_wal_segmentsGauge
chatcli_scheduler_audit_writes_totalCounter
chatcli_scheduler_daemon_connectionsGauge

Events

Scheduler publica no cli/bus e dispara hooks chatcli:
  • job.created, job.scheduled, job.fired
  • job.wait_started, job.wait_tick, job.wait_satisfied
  • job.running, job.completed, job.failed, job.timed_out, job.cancelled, job.skipped
  • job.retry_queued, job.paused, job.resumed, job.dependency_resolved
  • breaker.opened, breaker.half_open, breaker.closed
  • daemon.started, daemon.stopped
Hooks recebem Scheduler.<evento> como HookEvent.Type — você pode amarrar um Slack webhook em Scheduler.job.failed via ~/.chatcli/hooks.json.

Status line no prompt

Quando há jobs ativos, o prefix do prompt ganha [jobs: 2▶ 1⏳ 1✗]:
  • running
  • 👁 waiting (em polling)
  • pending
  • blocked (aguardando deps)
  • failed

Configuração completa

Veja Variáveis de Ambiente → Scheduler para as ~25 env vars.

/config scheduler

❯ /config scheduler

⏲ Scheduler (Chronos) — Agendamento & Wait-Until
  Núcleo
    CHATCLI_SCHEDULER_ENABLED:        enabled
    CHATCLI_SCHEDULER_DATA_DIR:       ~/.chatcli/scheduler
    CHATCLI_SCHEDULER_MAX_JOBS:       256
    CHATCLI_SCHEDULER_WORKER_COUNT:   4
    CHATCLI_SCHEDULER_ALLOW_AGENTS:   enabled
    CHATCLI_SCHEDULER_ACTION_ALLOWLIST: slash_cmd,shell,agent_task,...
  Budget Padrão
    CHATCLI_SCHEDULER_DEFAULT_ACTION_TIMEOUT:  5m
    CHATCLI_SCHEDULER_DEFAULT_POLL_INTERVAL:   5s
    CHATCLI_SCHEDULER_DEFAULT_WAIT_TIMEOUT:    30m
    ...
  Daemon
    CHATCLI_SCHEDULER_DAEMON_SOCKET:           /tmp/chatcli-scheduler.sock
    CHATCLI_SCHEDULER_DAEMON_AUTO_CONNECT:     enabled
  Active jobs: 3
  Queue depth: 2
  WAL segments: 11
  Daemon:      connected @ /tmp/chatcli-scheduler.sock

Arquitetura interna (resumida)

┌──────────────────────────────────────────────────────────────┐
│  ChatCLI process                                             │
│                                                              │
│  /schedule  ─┐                                               │
│  /wait      ─┼──▶ Scheduler ◀──▶ WAL + snapshot              │
│  /jobs      ─┘      │                                        │
│  @scheduler ────────┤                                        │
│                     │                                        │
│                     ├──▶ Condition evaluators (plug-in)      │
│                     ├──▶ Action executors (plug-in)          │
│                     ├──▶ Rate limiter (global + per-owner)   │
│                     ├──▶ Circuit breakers (cond + action)    │
│                     ├──▶ bus.EventBus + hook manager         │
│                     └──▶ audit log (JSONL) + Prometheus      │
└──────────────────────────────────────────────────────────────┘

             ▼ (opcional)
       UNIX socket ─▶ chatcli-daemon (mesma binary, --detach)
  • Schedule pump (1 goroutine) drena a priority queue por NextFireAt.
  • Worker pool (N goroutines = WORKER_COUNT) executa handleJob (wait → action → finalize).
  • Snapshot loop (1 goroutine) freeze periódico.
  • GC loop (1 goroutine) reap terminais expirados.

Próximos passos

Cookbook: Automatizações

Exemplos práticos: deploy com wait, cron de backup, pipeline com DAG.

Referência: Comandos

Tabela completa de flags e subcomandos.

Referência: Env vars

Todas as 25+ variáveis do scheduler.

Hooks System

Amarrar webhooks Slack/PagerDuty nos eventos do scheduler.