FORM NOT VOID, MIND NO CORE

Chapter 12: Headless Autonomous Operation

2026.08.10

A system with a user present and a system with no user present are not two states of the same system — they are two different system entry points.


Pattern Layer

12.1 Differences Between User-Present and No-User Modes

In user-present mode, there is a bidirectional channel between the agent and the user:

User -> agent: instructions, clarifications, negations, supplementary information
agent -> user: progress, questions, confirmation requests, options

The most important characteristic of this channel is: the agent can ask follow-up questions. When something is uncertain, it can ask the user. The user can also proactively interrupt — "no, go a different direction" — and the agent adjusts the plan.

In no-user (headless) mode, this bidirectional channel disappears:

agent -> system: tool calls
system -> agent: tool results

There is no room for follow-up questions. When the agent encounters uncertainty, it must make the best decision within the available information. It cannot say "please confirm" — because there is no one to ask.

Three key differences:

  1. The disappearance of the interaction channel. In user-present mode, the agent can ask "should we keep this module's interface?" In no-user mode, the agent must know the interface strategy from the start, or derive it through rules like "if the module is a public API -> keep the interface." It cannot stop mid-way to ask
  2. The permission model switches. With a user present, it uses the Ask model — a popup, the user decides. Without a user, it uses PreAuthorized + AutoDeny — pre-authorized actions execute directly; unauthorized ones are directly denied. Denial does not block — the agent should find an alternative path after being denied
  3. The exit strategy changes. In user-present mode, exiting is simple: the user closes the terminal. The Session saves automatically. In no-user mode, exit must follow an explicit exit code contract — because the caller is a scheduling script or CI system, not a person

12.2 The Pre-Authorization Model

In no-user mode, it is impossible to pop up a permission dialog for the user. Therefore, all permission decisions must be made before launch.

Pre-authorization is configured via the codecoder.json file:

{
  "allowlist": {
    "run_command:git": "AlwaysThisSession",
    "run_command:cargo": "AlwaysThisSession",
    "write_file:src/**": "AlwaysThisProject",
    "write_file:tests/**": "AlwaysThisProject"
  },
  "bg_max_auto": 10,
  "bg_circuit_k": 2
}
  • key: Permission key, in the same format as interactive mode
  • value: Trust level. AlwaysThisProject persists to the project's allowlist; AlwaysThisSession is loaded into memory at session start
  • Unauthorized keys: Automatically denied, returning ToolFinished{is_error: true}

Agent behavior constraints on auto-denial: Denial is not a "dead end" — the agent should design workarounds. Cannot use run_command:git -> can use read_file to read local git logs. Cannot write files -> can output to stdout for the scheduler to capture. The error message from a denial includes the rejected key and reason, and the agent can adjust its strategy based on this information.

Circuit breaking: After being denied or stuck on a step k times (bg_circuit_k, default 2), the system proactively terminates the headless run. This prevents the agent from repeatedly spinning its wheels in the same dead end.

12.3 Graceful Exit and Crash Recovery

There are four possible exit scenarios in headless mode:

  1. Normal completion (exit code 0): All milestones done, task complete
  2. StuckNeedsFix (exit code 2): A milestone is stuck in needs_fix, retry budget exhausted
  3. Graph anomaly (exit code 3-5): Graph structure issues, initialization failure, empty graph
  4. Signal exit (exit code depends on signal): Received SIGINT / SIGTERM, graceful termination

SIGINT -> CancelToken chain:

When the headless runner receives SIGINT, the system does not directly kill the process. Instead, it flips a shared CancelToken. The full design of CancelToken (shared flip, dual cancellation path, grace period) is described in Chapter 5, Sections 5.2 and 5.6. Behavior after cancellation:

  1. Terminate the currently executing tool (e.g., kill a subprocess)
  2. Save the current state (completed milestones, current progress)
  3. Exit (exit code 0 — because "cancelled" is not considered an error)

Crash recovery:

Crash recovery relies on two mechanisms:

  • Stamp file: When the agent starts, it writes a timestamp file to the project root directory, and deletes it on normal exit. On the next startup, if the stamp file exists, it means the previous run terminated abnormally
  • Supervisor state: supervisor_state.json persistently saves each Persistent Capability's crash_count and gave_up status. For process supervision and crash recovery of Persistent Capabilities, see Chapter 8, Section 8.6. After restart, services that exceeded the crash limit are skipped and not re-spawned

Case Layer

12.4 BG_TASK vs BG_WORKGRAPH

CodeCoder supports two headless modes:

BG_TASK mode:

CODECODER_BG_TASK="Refactor module A's interface and extract it into an independent trait" ccd

A natural language task description is passed through an environment variable. The agent executes the task directly after starting, without a Work Graph. It exits when the task is complete.

BG_TASK is suitable for "one-shot tasks" — tasks that do not require milestone planning, step decomposition, or can be completed with a single prompt.

BG_WORKGRAPH mode:

CODECODER_BG_WORKGRAPH=1 ccd

An environment variable signals the agent to enter headless mode, but no task description is provided. The agent reads milestone definitions from an existing Work Graph file and advances through the drive_workgraph loop.

BG_WORKGRAPH is suitable for "planned multi-step tasks" — tasks that need milestone dependency relationships, acceptance gates, and the self-recovery loop.

Startup flow for both modes:

fn run_background_cfg(config: BackgroundConfig) -> Result<ExitCode> {
    // Set the no-user flag
    context.set_headless(true);

    // Load pre-authorizations
    context.load_allowlist(&config.allowlist)?;

    if let Some(task) = &config.bg_task {
        // BG_TASK mode: process the task directly
        context.process_message(task)?;
    } else {
        // BG_WORKGRAPH mode: load and advance the work graph
        let mut graph = context.load_workgraph()?;
        drive_workgraph(&mut graph, &context)?;
    }

    // Check exit conditions
    if context.has_stuck_milestones() {
        Ok(ExitCode::StuckNeedsFix)
    } else {
        Ok(ExitCode::Success)
    }
}

12.5 codecoder.json Pre-Authorization

The complete format of the pre-authorization file:

{
  "allowlist": {
    "run_command:git": "AlwaysThisSession",
    "run_command:cargo": "AlwaysThisSession",
    "run_command:docker": "AlwaysThisSession",
    "write_file:src/**": "AlwaysThisProject",
    "write_file:tests/**": "AlwaysThisProject",
    "write_file:docs/**": "AlwaysThisProject",
    "read_file:*": "AlwaysThisProject",
    "glob:*": "AlwaysThisProject",
    "grep:*": "AlwaysThisProject",
    "diff:*": "AlwaysThisProject",
    "web_search:*": "AlwaysThisSession",
    "web_fetch:*": "AlwaysThisSession"
  },
  "bg_max_auto": 10,
  "bg_circuit_k": 2,
  "bg_max_fix_attempts": 3
}

Design principles:

  • Read-only operations (read_file, glob, grep, diff) recommend full wildcard * at the project level — these operations do not modify system state
  • Write operations (write_file) recommend path restrictions at the project level — scoping writes to specific directories
  • Execution operations (run_command) recommend session-level pre-authorization — even when pre-authorized, they are only valid within the current session
  • Self-modifying operations (generate_*, run_capability) are not recommended for pre-authorization — these should always trigger a permission check

12.6 BgObserver: Observability

In headless mode, there is no terminal window — the user cannot see the agent's real-time output. BgObserver solves this problem.

During a headless run, BgObserver writes every event simultaneously to stderr and to a .ccd.bg.ndjson file in the project root:

# .ccd.bg.ndjson (one JSON object per line)
{"event":"NewToken","token":"analyzing","timestamp":"..."}
{"event":"NewToken","token":" structure","timestamp":"..."}
{"event":"ToolStarted","tool":"read_file","args":"src/mod.rs","timestamp":"..."}
{"event":"ToolFinished","tool":"read_file","result":"ok","timestamp":"..."}
{"event":"MilestoneDone","milestone":"analyze-structure","timestamp":"..."}

Users can tail -f .ccd.bg.ndjson to observe the agent's progress in real time. The file is append-only — one JSON object per line, with events ordered chronologically. The file is truncated at the start of a session and appended to event-by-event during the run.

.ccd.bg.ndjson has been added to .gitignore — it will not pollute the project repository.

12.7 Exit Code Contract

The headless runner's exit code contract:

enum ExitCode {
    Success = 0,          // Normal completion, all milestones done
    EmptyGraph = 5,       // Empty graph, no milestones
    StuckNeedsFix = 2,    // Milestone stuck in needs_fix, retry budget exhausted
    GraphError = 3,       // Graph structure anomaly
    InitError = 4,        // Initialization failure
}

The consumers of exit codes are upper-level schedulers (CI systems, cron, automation scripts):

  • Exit code 0: Normal, proceed
  • Exit code 2: Needs human intervention — a milestone is stuck and auto-recovery cannot resolve it
  • Exit code 3-5: System anomaly, not a task issue — configuration needs to be checked

12.8 SIGINT -> CancelToken Chain

The complete SIGINT handling chain:

fn handle_sigint() {
    // 1. Flip the shared CancelToken
    cancel_token.cancel();

    // 2. Send Cancel command (ensures the agent receives it even in a waiting state)
    cmd_tx.send(AgentCommand::Cancel);

    // 3. Wait for the current tool to finish (at most grace_period)
    let deadline = Instant::now() + Duration::from_secs(30);
    while !current_tool_finished() && Instant::now() < deadline {
        thread::sleep(Duration::from_millis(100));
    }

    // 4. Save state
    save_session();
    save_workgraph();

    // 5. Exit
    process::exit(0);
}

The grace period in step 3 is crucial. If the agent is in the middle of writing a file, a forced termination could corrupt the file. The 30-second wait gives the agent time to finish the current tool. After 30 seconds — even if the current tool has not completed — the state is saved and the process exits.

12.9 Crash Recovery Flow

The complete crash recovery flow:

fn recover_from_crash() -> Result<()> {
    // 1. Check the stamp file
    let stamp_path = project_root().join(".ccd.stamp");
    if stamp_path.exists() {
        // Previous run terminated abnormally
        let last_session = read_stamp(&stamp_path)?;
        log::warn!("Previous run terminated abnormally, recovering session {}", last_session);

        // Load supervisor state
        let supervisor = SupervisorState::load()?;
        for (name, state) in &supervisor.services {
            if state.crash_count > state.crash_budget {
                // Skip services that exceeded the crash limit
                log::warn!("Skipping {}: crash count {} exceeds budget {}", 
                    name, state.crash_count, state.crash_budget);
                continue;
            }
        }

        // Recover session
        resume_session(&last_session)?;
    }

    // Write a new stamp
    stamp_path.write(current_session_id())?;
    Ok(())
}

Key design points of crash recovery:

  • The stamp file is not a lock — it is just a marker. If the process crashes, the stamp file is not cleaned up, and the abnormal termination is detected on the next startup
  • supervisor_state persists crash_count — prevents Persistent Capabilities from being automatically restarted after every crash (infinite crash loop)
  • Continue after recovery — recovery does not rewind to the initial state; it continues from where the process was interrupted

ADR Deep Reading

The Design Evolution of the needs_fix Self-Recovery Loop

ADR 0039 documents the process of introducing the needs_fix self-recovery loop. The concrete implementation of the self-recovery loop (including retry count, failure reason injection, and fix prompt format) was fully expanded in Chapter 11, Section 11.7. This section only records the design evolution history.

Initial design (no self-recovery): After a milestone entered needs_fix, the system did nothing. It waited for the user to manually set it back to pending or in_progress. In headless mode, this meant "stuck" — with no user, the milestone could never recover.

First enhancement (introducing self-recovery): The self-recovery loop was introduced: needs_fix -> inject failure reason into fix prompt -> re-execute -> re-accept. The loop is bounded (default 3 attempts). Milestones still failing after exhausting the budget -> StuckNeedsFix.

Second enhancement (cumulative failure reasons): In the initial version, each retry prompt only contained "the reason for the last failure." The problem: the agent fixed the first issue, but acceptance revealed a second issue. The retry prompt for the second attempt only contained the reason for "the second issue," and the agent did not know the first issue had already been fixed.

The solution: the failure reason from each retry is appended to fix_reason. When attempting a fix, the agent can read the complete failure history — "First failure: compilation error. Second failure: tests failed." This helps the agent understand that "fixing the first issue may have introduced the second."


Next chapter: Context compaction and persistence management — what happens when a session exceeds the context window.