Skip to Content
InternalsAutonomous runs

Autonomous runs

apps/core/src/autonomous/ implements freecode night. For how to use it, see overnight runs. The design lives in docs/specs/2026-09-28-overnight-runs.md, and its §8 is the implementation record for Phases 0–5, all built on 2026-09-28. That spec replaces Phases 1–5 of 2026-08-10-autonomous-runs-design.md. Only that older spec’s Phase 0 (types.ts, budget.ts, run-store.ts) is still used.

Shape

cli/commands/night.ts yargs surface, duration/time parsing cli/commands/night-run.ts composition root: preflight, boot, wire deps, exit summary │ ▼ autonomous/orchestrator.ts runNight(deps, limits) — PURE over injected git/clock/iteration │ per iteration ▼ autonomous/iteration.ts one fresh session via runEffect, AgentLoopConfig.unattended autonomous/prompt.ts the iteration prompt (a USER message, never system content) tools/finish-iteration.ts how an iteration ends autonomous/envelope.ts answers permission asks without a human autonomous/sandbox.ts bubblewrap plan for unattended bash / --verify / shells │ ▼ autonomous/git.ts commit / reset / worktree / diffstat autonomous/night-store.ts notes.md, decisions.jsonl, iterations.jsonl (secret-scrubbed) autonomous/run-store.ts manifest.json (atomic), FREECODE_RUNS_HOME autonomous/quota-wait.ts planQuotaWait — pure wait policy autonomous/inhibit.ts one killable systemd-inhibit / caffeinate child autonomous/report.ts pure fold → report.md autonomous/night-ops.ts status / list / stop / crash reconciliation autonomous/supervisor.ts detached / scheduled worker launch

orchestrator.ts makes every decision and does no IO of its own. Git, the clock, sleeping, the iteration runner, --verify and the stop flag are all passed in, so orchestrator.test.ts can drive a whole night on a fake clock. night-run.ts contains no policy. It only wires the real implementations together.

The one seam in the agent loop

The loop knows about unattended runs through exactly one field: AgentLoopConfig.unattended (UnattendedContext). When it is set:

  • Permission asks go to decideUnattendedAsk instead of the bus.
  • Tools can see it via ToolContext.unattended. question answers itself and records the ask. bash wraps its command in the sandbox plan.
  • finish_iteration is added to the provider’s tool list by unattendedToolDefs. getToolDefs filters it out, so attended sessions never see it.
  • The run ends once unattended.finish is set, after the current tool batch.

The envelope’s deny rules are added to the session’s DENY tier via PermissionSettingsManager.addSessionDeny. Deny beats allow, so a user’s Bash(git:*) allow cannot widen them. deniedSegment / normalizeSegment split chained commands and strip env prefixes and program paths before matching. That fix lives in the envelope, not in permission/rules.ts, because docs/DECISIONS.md records that allow-rule prefix matching deliberately refuses compound commands, and the envelope is the only caller whose rules are all denials. The three bypasses this closes were found by evals/night.jsonl.

Failure evidence (Phase 0)

agent/recovery/manager.ts classifies failures:

  • QuotaExhaustedError carries scope (window / credits / auth / unknown) and resetAt.
  • quotaResetAt reads every common reset-header spelling and takes the soonest future one.
  • classifyLoopFailure fills LoopResult.failure at all five loop exits, including the three that report success: true.
  • model.error rollout events carry rateLimitHeaders (names and values only, no body).

A question with no frontend subscribed is rejected at once rather than waiting the 30-minute prompt timeout.

Quota waits

planQuotaWait is a pure function. The outcome for each input:

InputPlan
creditsabort (permanent_error)
unknown after 3 probesabort (permanent_error)
stated reset in the futurewait until reset + 60s
no usable resetprobe after 1, 2, 4, …, max 30 min
wait would exceed maxWaitMsabort (wait_budget)
wait would cross --untilabort (deadline)

A single wait is capped at 24h. Both budgets are checked against what the wait would cost, before it starts. A wait retries the same iteration number and does not count towards maxConsecutiveFailures. sleepUntil in night-run.ts re-checks the clock every 60s, for two reasons: a single setTimeout overflows past 2^31 ms, and a suspended machine resumes to a timer that never fired.

Stop reasons

objective_met, max_iterations, deadline, budget_usd, consecutive_failures, permanent_error, wait_budget, interrupted, cancelled. An iteration can fail with no_finish, no_op, turn_cap, stuck, timeout, commit_failed, verify_failed, provider, quota or auth. Each iteration is capped at MAX_TURNS_PER_ITERATION (60) and ITERATION_TIMEOUT_MS (30 min).

Storage and the report

~/.freecode/runs/<id>/ holds manifest.json, notes.md, iterations.jsonl, decisions.jsonl, report.md and, for detached runs, worker.log. scrubFields redacts each string field before it is serialised. An earlier version scrubbed the finished JSON line, which produced invalid JSON, so the reader skipped it and a redacted needs_human item disappeared from the report.

report.ts is a pure fold over the manifest and both logs. That is how night report can regenerate the report for a run that crashed. Crash detection is pidAlive() (kill(pid, 0), where EPERM counts as alive), reconciled when the manifest is read and then saved. It applies to running and pending manifests. A finished run is never re-judged. night stop sets cancelRequested in the manifest and never sends a signal, because killing a process mid-write is how manifests get corrupted.

Detached worker and sandbox (Phase 5)

launchDetachedNight writes a pending manifest before spawning, so a fast worker’s live manifest is never overwritten by the parent’s provisional one. It then re-runs the current invocation (currentInvocation() handles both script and single-file binaries) as a detached process, with stdout and stderr sent to worker.log. Three environment variables hand the run over: FREECODE_NIGHT_WORKER=1, FREECODE_NIGHT_RUN_ID, and FREECODE_NIGHT_SCHEDULED_FOR. A worker always runs in a dedicated worktree. A scheduled worker inhibits sleep while waiting for its start time and polls the manifest for cancelRequested every second.

sandboxPlan builds the bubblewrap command:

bwrap --die-with-parent --new-session --unshare-{pid,ipc,uts,cgroup-try} --ro-bind / / --tmpfs $HOME --tmpfs /tmp [--ro-bind <main .git> <main .git>] # worktree runs only --bind <tree> <tree> --setenv HOME /tmp --proc /proc --dev /dev --chdir <cwd> /bin/bash -c <command>

sandboxEnvironment drops variables whose names match KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH. bubblewrapAvailable checks by running a minimal sandbox, not bwrap --version: --version succeeds even when the kernel forbids the user namespace that a real sandbox needs. The network is shared on purpose.

A worktree’s .git is a file that points at <main>/.git/worktrees/<name>. That directory is usually under the hidden $HOME, so worktreeGitDir() resolves the repository’s common git dir and mounts it read-only. Without that mount, every git command inside a detached run failed with “not a git repository”.

The process directory must be the run tree. The agent loop gives every tool cwd: process.cwd() (agent/loop.ts). night-run.ts therefore calls process.chdir() into the worktree once it exists. Before this fix, bash and relative file paths resolved against the user’s own checkout. The sandbox refused all of those calls, but with --no-sandbox they would have run there. The first live detached run found both problems.

Frontends

IPC night.list | report | stop (server.ts) only read what a separate night process left on disk. They start none of the backend, so reading a finished run never spins up providers or MCP servers. night.list counts needsHuman in core, so that each frontend doesn’t decide for itself what “needs you” means. The TUI’s /night is apps/tui/src/components/night-panel.ts.

Tests

orchestrator.test.ts, quota-wait.test.ts, envelope.test.ts, git.test.ts (a real repo in a tmpdir), night-store.test.ts, report.test.ts, night-ops.test.ts, sandbox.test.ts, supervisor.test.ts, tools/finish-iteration.test.ts, cli/commands/night.test.ts, and the TUI’s night-panel.test.ts. The paid eval suite is evals/night.jsonl (5 cases, unattended: true). Its 2026-09-28 run on MiniMax-M3 scored 5/5, with finish_iteration called in 14/15 trials.

Known gaps are listed on the user guide.