junco
contents

Operations

The worker is one daemon plus a CLI around it. This page covers running and recovering it; the pipeline itself is in How it works.

Daemon lifecycle

Bare junco opens the guided setup on a first run (no config yet); otherwise it is start. junco start runs the daemon in the foreground and polls forever; --once processes one task then exits. It acquires a single-instance lock, worker.lock, next to config.json — a second instance exits 0 rather than erroring, so a supervisor never enters a restart loop after a double start. junco run-once processes one available task and exits, taking no lock — safe for cron and dev use alongside a running daemon.

junco start
junco start --once
junco run-once

Stopping a foreground daemon: the first Ctrl-C finishes in-flight tickets, then exits — a graceful stop drains work. A second Ctrl-C force-stops: the running session aborts, and commits already made are salvaged. worker.maxConcurrent (default 1) runs that many tickets in parallel; tickets targeting the same repo always serialize.

Running as a service

junco service renders a platform-native service file to stdout — --platform launchd (macOS default) or systemd. Pipe it to the right location and load it (flags). Rendered units size their stop timeout to the maximum ticket timeout, so a draining worker is not SIGKILLed mid-ticket.

junco service --platform launchd \
  > ~/Library/LaunchAgents/com.junco.worker.plist
launchctl load ~/Library/LaunchAgents/com.junco.worker.plist
junco service --platform systemd \
  > ~/.config/systemd/user/junco.service
systemctl --user daemon-reload
systemctl --user enable --now junco

The daemon reads config and code once at startup. To pick up changes, use junco restart — not SIGTERM: with launchd's SuccessfulExit=false keep-alive, a graceful exit stays down. restart finds the launchd plist or systemd user unit running junco (units rendered before 0.10 name a --config path; those are still discovered), validates the config first (refusing to bounce the daemon onto a config it can't parse), kicks the unit with the platform-correct verb, and confirms the pid changed before reporting success.

Watching it

junco status is the one-glance view: daemon pid and uptime, endpoint readiness, in-flight tickets, processed counts, queue sizes. junco list [inbox|processing|done|failed] lists tickets newest-first per queue box. junco logs -f follows the worker log (flags) — structured JSON on stdout, human-readable on a TTY, also written to ~/.junco/logs/worker.log (<dataDir>/worker.log on a not-yet-migrated flat tree) and rotated at 10 MB; observability.logLevel: "debug" for verbose output.

junco status
junco logs -f
junco list failed
09:31:05 INFO  claimed {"src":"inbox/gh-acme-reef-api-52.md","dst":"processing/gh-acme-reef-api-52.md"}
09:42:58 INFO  [gh-acme-reef-api-52] critic: pass
09:43:56 INFO  [gh-acme-reef-api-52] opened PR https://github.com/acme/reef-api/pull/57

Every agent session appends its event stream — turns, tool calls, results, no token deltas — to <dataDir>/transcripts/<ticket-id>.jsonl: the debugging record for failed runs. Disable with observability.transcripts = false. All state resolves under one dataDir root; configs still carrying legacy path keys unify with junco data migrate.

The health server

With observability.healthEnabled = true, the daemon serves HTTP on healthHost:healthPort, default 127.0.0.1:8787. GET /live answers liveness (status, pid, uptime). GET /ready answers readiness — a latched or backed-off provider gate forces a 503 with the gate's reason, regardless of the endpoint probe. GET /health returns full metrics: uptime, poll count, in-flight tickets, live per-ticket progress, task counts by status, token and duration totals, plus the gate and spend fields.

curl http://127.0.0.1:8787/health | jq .
warn — the health server has no authentication. A non-loopback observability.healthHost exposes ticket ids, live progress, and metrics to the network unauthenticated. Keep it on 127.0.0.1 unless the interface is trusted — see Security.

The provider gate

junco classifies inference-endpoint failures and, for the ones an operator has to fix, pauses ticket claiming instead of burning tickets against a provider that will keep saying no. /health carries gate as {state, reason, since, until}.

StateKindClears on
okdefault
auth_error, quota_exhausted, misconfiglatcha successful session, a config hot-reload apply, or a daemon restart — never on its own
rate_limitedbackoffauto-expires at until; the delay doubles on each further rate-limit report, capped at 900 s
outage_backoffbackoffauto-expires after one non-doubling worker.retryBackoffSeconds interval
budget_exhaustedhybridlocal midnight, or an operator raising the cap via config hot-reload; a successful session does not clear it

A latch is never downgraded by a later rate-limit or outage report. Tickets that trip the gate return to the inbox with a fresh not_before and their retry budget untouched — provider faults never burn a ticket's retries. The dashboard's daemon panel shows the gate as a colored dot (red for a latch, yellow for a backoff) with a reason line.

Spend

worker.dailyBudgetUsd (default 0, disabled) caps USD spend per local calendar day; at the cap the gate enters budget_exhausted. Every completed session's actual resolved cost — Q&A, assess, analyze, and PR-flow's main run, critic pass, and corrective re-dispatch — is recorded in the per-day ledger (<dataDir>/spend.json) whether or not a cap is set. /health carries spend as {todayUsd, dailyBudgetUsd}, and the dashboard's daemon panel prints a matching line. Cap details: Configuration.

note — metrics.totalCostUsd and spend.todayUsd legitimately diverge: totalCostUsd accumulates only when a ticket reaches a terminal state and resets on daemon restart; the ledger records every session's cost immediately — once per attempt for a requeuing ticket — persists across restarts, and resets only at local midnight.

Updates

updateCheck (default true) runs a best-effort daily check against the npm registry, surfaced in the dashboard header, junco status, and junco doctor. It is CLI/TUI-side only — the daemon never phones home. junco update installs the latest release and drain-restarts the supervised daemon. Opt out with "updateCheck": false.

Recovery playbook

Stuck in processing/. A daemon crash mid-run (power loss, OOM) strands a ticket; the next startup detects orphaned claims and recovers them automatically. To force it, move the file back — result frontmatter written by the worker is stripped, the original frontmatter preserved:

mv <dataDir>/queue/processing/<ticket.md> <dataDir>/queue/inbox/

Failed tickets. junco retry <name…|--all> moves them back to the inbox for a fresh run — claim stamp, appended result blocks, and retry bookkeeping stripped. GitHub backlog. When GitHub is unreachable, labels, comments, and PR pushes queue in a durable outbox — FIFO replay, idempotent, dead-lettered after 3 attempts. junco outbox lists it (operation type, target, age, attempts, dead-letters); flush pushes now instead of waiting for the next sweep.

junco retry --all
junco outbox flush

Transient failures requeue automatically with backoff, up to maxTransientRetries; deferred tickets show their not-before time in the queue view. Provider-gate faults never count against a ticket's retry budget.