Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 24: Autonomous Software Organizations

24.3 Building a Supervised Autonomous Loop

"The autonomous loop has been running for six hours. It resolved three issues, opened two pull requests, and filed a bug against itself. I think it is becoming self-aware. Or at least self-critical."

A DevOps Engineer Watching the Dashboard at 2 AM

Prerequisites

This section brings together everything from the chapter. You need the SWE agent architecture and capability envelopes from Section 24.1, and the five-stage pipeline, reward signals, and triage learner from Section 24.2. The recipe uses LangGraph, a Python library for building stateful multi-step agent workflows as directed graphs, for workflow orchestration (introduced in Chapter 17), the Claude Code software development kit (SDK) for agent execution, and the GitHub application programming interface (API) for repository integration. Familiarity with async Python (asyncio) and basic git operations is assumed.

The Big Picture

This section is a recipe. You will build a complete supervised autonomous loop that runs as a persistent service, polls a GitHub repository for new issues, classifies each issue by risk, dispatches SWE agents to resolve low-risk issues, verifies the results through a multi-signal pipeline, opens pull requests with full provenance, and feeds outcomes back to improve triage over time. The system operates at L3 (supervised), where the agent acts but a human approves before changes take effect, by default, with a mechanism to graduate individual task categories to L4 (autonomous), where the agent may act and merge without waiting for human approval. As evidence accumulates, the system widens the boundary between these two levels. By the end, you will have a working system that you can point at a real repository and watch it resolve issues.

1. System Architecture

Imagine pointing a service at your GitHub repository on Friday evening and returning Monday to find three issues resolved, two pull requests merged, and a classifier that learned which categories of work it can handle without asking. That service is the supervised autonomous loop. Building one requires six components, each implemented as a node in a LangGraph state machine: (1) a GitHub poller that detects new issues, (2) a triage classifier that routes issues by risk, (3) a planning agent (a separate LLM call that decomposes the issue into concrete steps before any code is written) that decomposes tasks, (4) an execution engine that dispatches SWE agents, (5) a verification pipeline that validates changes, and (6) a feedback recorder that stores outcomes and updates the triage model. Figure 24.3 shows how these components connect through conditional edges that implement retry and escalation logic. These components connect through a typed state object that accumulates information as the task progresses through the pipeline.

Triage Plan Execute Verify Open PR Record Outcome pass retry escalate escalate
Figure 24.3: The supervised autonomous loop as a LangGraph state machine. Solid arrows show the main pipeline flow (triage, plan, execute, verify, open PR, record outcome). The dashed orange arrow is the retry path (verification failure loops back to planning). Dashed red arrows are escalation paths (triage or verification routes a task to a human).

Without a supervised loop, repositories accumulate a backlog of small, well-understood tasks that no one prioritizes: documentation fixes sit for weeks, dependency bumps expire into security vulnerabilities, and type hint gaps silently erode tooling quality. The cost of inaction on each individual issue is low, but the compounding cost across hundreds of issues is substantial.

A supervised autonomous loop is a long-running service that detects work items (such as GitHub issues), resolves them using AI agents, and routes results through verification and optional human review before merging. It converts idle repository time into continuous forward progress on low-risk tasks. Human engineers focus on design decisions and complex debugging instead. The mechanism is a state machine: each work item flows through a fixed sequence of nodes (triage, plan, execute, verify, report). Conditional edges retry on failure or escalate to a human when confidence is low. Use a supervised loop when your repository has a steady stream of well-defined, low-risk issues (documentation fixes, dependency bumps, type hint additions). For tasks requiring cross-system architectural judgment or security-sensitive changes, direct human ownership remains the better choice.

With the loop's purpose and boundaries defined, the next design question is which coordination pattern best enforces those boundaries at runtime.

The architecture follows the orchestrator pattern from Chapter 17: a central state machine coordinates specialized agents rather than letting agents communicate directly. This design makes the system easier to monitor, debug, and modify. Each node's behavior is determined entirely by the state it receives, and each node's output is a state update, making the pipeline deterministic (given the same LLM outputs) and replayable. In short: the state object is the system's memory, so every node can be stateless, restartable, and auditable.

from dataclasses import dataclass, field
from typing import Optional, Literal
import datetime

@dataclass
class LoopState:
    """
    Typed state object for the supervised autonomous loop.

    Flows through every node in the LangGraph pipeline.
    Each node reads what it needs and writes its results
    back into the state.
    """
    # Issue metadata (set by poller)
    issue_id: str = ""
    issue_title: str = ""
    issue_body: str = ""
    issue_labels: list[str] = field(default_factory=list)
    issue_author: str = ""

    # Triage results (set by triage node)
    triage_route: str = ""          # "autonomous", "supervised", "human"
    triage_confidence: float = 0.0
    risk_category: str = ""
    triage_reasoning: str = ""

    # Plan (set by planning node)
    plan_summary: str = ""
    plan_steps: list[dict] = field(default_factory=list)
    compute_budget_usd: float = 2.0

    # Execution results (set by execution node)
    branch_name: str = ""
    files_changed: list[str] = field(default_factory=list)
    lines_added: int = 0
    lines_removed: int = 0
    execution_cost_usd: float = 0.0
    execution_time_seconds: float = 0.0
    agent_output: str = ""

    # Verification results (set by verify node)
    tests_passed: int = 0
    tests_failed: int = 0
    lint_score: float = 0.0
    type_check_ok: bool = False
    security_ok: bool = False
    verification_passed: bool = False
    verification_confidence: float = 0.0
    blocking_issues: list[str] = field(default_factory=list)

    # Loop control
    attempt: int = 1
    max_attempts: int = 3
    final_action: str = ""  # "merge", "review", "escalate", "retry"
    pr_url: str = ""
    timestamp: str = field(
        default_factory=lambda: datetime.datetime.now().isoformat()
    )
The LoopState dataclass: the typed state object that flows through every node in the autonomous loop. Each node reads and writes specific fields, creating a clear data flow that is easy to inspect and debug. The final_action field determines the terminal transition: merge, request review, escalate to human, or retry.

2. Implementing the LangGraph Pipeline

LangGraph models the pipeline as a directed graph where nodes are Python functions that transform the state and edges are conditional transitions that route the state to the next node. The graph defines six nodes corresponding to the six components, plus conditional edges that implement the retry and escalation logic from Section 24.2. Figure 24.3.1 illustrates LangGraph supervised autonomous loop state machine.

LangGraph supervised autonomous loop state machine
Figure 24.3.1: The supervised autonomous loop as a LangGraph state machine, showing the six pipeline nodes, conditional routing after triage and verification, and the bounded retry loop that returns failed tasks to the planning stage.
from langgraph.graph import StateGraph, END
from langgraph.graph.state import CompiledStateGraph

def build_autonomous_loop() -> CompiledStateGraph:
    """
    Build the LangGraph pipeline for the supervised autonomous loop.

    The graph has six nodes connected by conditional edges:
    sense -> triage -> plan -> execute -> verify -> route
    where route branches to merge, review, escalate, or retry.
    """
    graph = StateGraph(LoopState)

    # Add nodes
    graph.add_node("triage", triage_node)
    graph.add_node("plan", plan_node)
    graph.add_node("execute", execute_node)
    graph.add_node("verify", verify_node)
    graph.add_node("open_pr", open_pr_node)
    graph.add_node("record_outcome", record_outcome_node)

    # Entry point: triage is the first node
    graph.set_entry_point("triage")

    # Triage routes to plan (agent handles) or END (human handles)
    graph.add_conditional_edges(
        "triage",
        route_after_triage,
        {
            "plan": "plan",
            "escalate": "record_outcome",
        },
    )

    # Plan always goes to execute
    graph.add_edge("plan", "execute")

    # Execute always goes to verify
    graph.add_edge("execute", "verify")

    # Verify routes to open_pr, retry, or escalate
    graph.add_conditional_edges(
        "verify",
        route_after_verify,
        {
            "open_pr": "open_pr",
            "retry": "plan",
            "escalate": "record_outcome",
        },
    )

    # Open PR goes to record outcome
    graph.add_edge("open_pr", "record_outcome")

    # Record outcome is terminal
    graph.add_edge("record_outcome", END)

    return graph.compile()


def route_after_triage(state: LoopState) -> str:
    """Conditional edge: route based on triage decision."""
    if state.triage_route == "human":
        return "escalate"
    return "plan"


def route_after_verify(state: LoopState) -> str:
    """Conditional edge: route based on verification results."""
    if state.verification_passed:
        return "open_pr"
    if state.attempt < state.max_attempts:
        return "retry"
    return "escalate"
Building the LangGraph StateGraph with six nodes and conditional edges. The route_after_triage function directs human-only tasks to escalation. The route_after_verify function implements bounded retry: if verification fails and attempts remain, loop back to planning; otherwise escalate to a human.

Mental Model

Think of the LangGraph pipeline as an airport baggage system. Each suitcase (issue) enters on the conveyor belt (poller), passes through an X-ray scanner (triage) that flags suspicious items for manual inspection and lets routine bags continue, then routes to a sorting machine (planner) that assigns it a destination gate (plan steps). A loader (execution engine) places it on the correct cart, and a final weight check (verification) confirms nothing went wrong. If the weight is off, the bag loops back to re-sorting for another attempt; after three failures, a human baggage handler collects it. The key insight the analogy preserves: the bag itself carries a tag (the typed state object) that every station reads and updates, so no station needs to remember what happened at previous stations.

3. Implementing Each Node

Each node is a Python function that takes the current state, performs its work, and returns a state update (a dictionary of fields to modify). The implementations below follow pipeline order.

3.1 Triage Node

The triage node uses the two-layer classifier from Section 24.2 (rule-based critical pattern matching plus label-based heuristics) and adds a large language model (LLM)-based classification layer for tasks that do not match any rule.

import subprocess
import json

def triage_node(state: LoopState) -> dict:
    """
    Classify the issue by risk and decide routing.

    Layer 1: Rule-based pattern matching for critical keywords.
    Layer 2: Label-based heuristics for known categories.
    Layer 3: LLM-based classification for ambiguous cases.
    """
    title = state.issue_title.lower()
    body = state.issue_body.lower()
    labels = [l.lower() for l in state.issue_labels]
    combined = f"{title} {body}"

    # Layer 1: critical patterns always go to human
    critical_patterns = [
        "security", "vulnerability", "CVE", "auth",
        "payment", "billing", "migration", "breaking",
        "password", "encryption", "deploy", "infra",
    ]
    for pattern in critical_patterns:
        if pattern in combined:
            return {
                "triage_route": "human",
                "triage_confidence": 0.95,
                "risk_category": "critical",
                "triage_reasoning": (
                    f"Critical pattern '{pattern}' detected."
                ),
            }

    # Layer 1.5: check if the triage learner has graduated this
    # category to autonomous (feedback from record_outcome_node)
    for label in labels:
        if _triage_learner.should_allow_autonomous(label):
            return {
                "triage_route": "autonomous",
                "triage_confidence": 0.90,
                "risk_category": "graduated",
                "triage_reasoning": (
                    f"Category '{label}' graduated to autonomous "
                    f"based on accumulated success evidence."
                ),
            }

    # Layer 2: low-risk labels
    low_risk = {"documentation", "typo", "chore", "dependencies",
                "good first issue", "help wanted"}
    if any(l in low_risk for l in labels):
        return {
            "triage_route": "autonomous",
            "triage_confidence": 0.85,
            "risk_category": "low",
            "triage_reasoning": "Low-risk label detected.",
        }

    # Layer 3: LLM classification for ambiguous cases
    classification = _llm_classify_issue(
        state.issue_title, state.issue_body
    )

    return {
        "triage_route": classification["route"],
        "triage_confidence": classification["confidence"],
        "risk_category": classification["risk"],
        "triage_reasoning": classification["reasoning"],
    }


def _llm_classify_issue(title: str, body: str) -> dict:
    """
    Use an LLM to classify issue risk when rules are ambiguous.

    Returns a structured classification with route, confidence,
    risk level, and reasoning.
    """
    prompt = f"""Classify this GitHub issue for autonomous handling.

TITLE: {title}
BODY: {body}

Respond with JSON:
{{
  "route": "autonomous" | "supervised" | "human",
  "confidence": 0.0-1.0,
  "risk": "low" | "medium" | "high" | "critical",
  "reasoning": "one sentence explanation"
}}

CLASSIFICATION RULES:
- "autonomous": simple bugs, documentation, type hints, test additions
- "supervised": feature additions, refactoring, moderate complexity
- "human": security, data handling, breaking changes, architecture"""

    result = subprocess.run(
        ["claude", "--print", "--model", "claude-haiku-4-20250414",
         "--output-format", "json", "--max-turns", "1"],
        input=prompt,
        capture_output=True, text=True,
        timeout=30,
    )

    try:
        output = json.loads(result.stdout)
        # Extract the classification from the agent's response
        text = output.get("result", "{}")
        # Parse the JSON from the model's response
        import re
        json_match = re.search(r'\{[^}]+\}', text)
        if json_match:
            return json.loads(json_match.group())
    except (json.JSONDecodeError, KeyError):
        pass

    # Default to supervised if LLM classification fails
    return {
        "route": "supervised",
        "confidence": 0.5,
        "risk": "medium",
        "reasoning": "LLM classification unavailable; defaulting to supervised.",
    }
Four-layer triage classifier: critical keyword scan, graduation check (consuming feedback from the record_outcome node, closing the gap between the graduation signal recorded downstream and the routing decision made here), label heuristics, and LLM fallback. The LLM layer uses Claude Haiku for fast, cheap classification of ambiguous issues. If the LLM call fails (timeout, parsing error), the node defaults to supervised mode, maintaining the conservative routing principle.

Exercise 24.3.1

The triage node's Layer 1 checks for the keyword "auth" in the combined title and body text. A user files an issue titled "Add author field to book metadata." What route does the triage node assign, and is that routing correct? If not, propose a concrete fix to the critical-pattern matching logic that avoids this false positive while still catching genuine authentication issues.

Hint

The substring "auth" appears inside the word "author." Consider using word-boundary matching (for example, re.search(r'\bauth\b', combined)) or replacing the short pattern with more specific terms like "authentication," "authorization," and "oauth."

Common Misconception

Readers often assume that a task routed as "autonomous" bypasses human oversight entirely, but that is not the case. Even autonomous-route tasks still pass through the full verification pipeline (tests, linting, diff size checks), and auto-merge only triggers when verification confidence exceeds 0.85; below that threshold the PR is labeled for human review just like any supervised task. "Autonomous" in this architecture means "the system may merge without waiting for a human approval step," not "no human can see or intervene."

Step-Through: Triage Classification of a Sample Issue

Trace through the triage node with a concrete issue: title = "Update README installation instructions," body = "The pip install command is outdated," labels = ["documentation", "good first issue"].

Layer 1 (critical patterns): combined text = "update readme installation instructions the pip install command is outdated." Check each critical keyword: "security" not found, "vulnerability" not found, "CVE" not found, "auth" not found, "payment" not found, "billing" not found, "migration" not found, "breaking" not found, "password" not found, "encryption" not found, "deploy" not found, "infra" not found. No match; proceed to Layer 2.

Layer 2 (label heuristics): labels = ["documentation", "good first issue"]. The set low_risk = {"documentation", "typo", "chore", "dependencies", "good first issue", "help wanted"}. Check: "documentation" is in low_risk. Match found. Return immediately: triage_route = "autonomous", triage_confidence = 0.85, risk_category = "low". Layer 3 (LLM) is never called, saving one API round-trip and roughly \$0.001.

3.2 Plan Node

import subprocess
import json

def plan_node(state: LoopState) -> dict:
    """
    Decompose the issue into a step-by-step plan.

    Uses a planning agent (Claude Sonnet) that reads the issue
    and produces a structured plan with target files, verification
    criteria, and a compute budget.
    """
    prompt = f"""You are a software planning agent. Create a plan to resolve
this GitHub issue.

ISSUE #{state.issue_id}: {state.issue_title}
{state.issue_body}

ATTEMPT: {state.attempt} of {state.max_attempts}
{"PREVIOUS ATTEMPT FAILED. Adjust your approach." if state.attempt > 1 else ""}
{f"Previous blocking issues: {state.blocking_issues}" if state.blocking_issues else ""}

Produce a JSON plan:
{{
  "summary": "one-sentence approach",
  "steps": [
    {{
      "description": "what to do",
      "target_files": ["path/to/file.py"],
      "verification": "how to check"
    }}
  ],
  "test_strategy": "command to run tests"
}}

Keep the plan minimal. Prefer focused changes over broad refactoring."""

    result = subprocess.run(
        ["claude", "--print", "--model", "claude-sonnet-4-20250514",
         "--output-format", "json", "--max-turns", "3"],
        input=prompt,
        capture_output=True, text=True,
        timeout=60,
    )

    try:
        output = json.loads(result.stdout)
        text = output.get("result", "{}")
        import re
        json_match = re.search(r'\{.*\}', text, re.DOTALL)
        if json_match:
            plan = json.loads(json_match.group())
            return {
                "plan_summary": plan.get("summary", ""),
                "plan_steps": plan.get("steps", []),
                "attempt": state.attempt + (1 if state.attempt > 1 else 0),
            }
    except (json.JSONDecodeError, KeyError):
        pass

    return {
        "plan_summary": "Direct fix attempt",
        "plan_steps": [{"description": "Read code and fix the issue",
                        "target_files": [], "verification": "run tests"}],
    }
The planning node decomposes an issue into concrete steps using Claude Sonnet. On retry attempts (when a previous execution failed verification), the planner receives the previous blocking issues and is instructed to adjust its approach, implementing the adaptive retry loop.

3.3 Execute Node

The execution node is the core of the system: it spawns a software engineering (SWE) agent (via the Claude Code SDK) in an isolated branch, passes it the plan, and captures the results. The agent operates in a fresh git branch created from the repository's default branch, ensuring complete isolation from other in-progress work.

import subprocess
import json
import time
from pathlib import Path

def execute_node(state: LoopState) -> dict:
    """
    Dispatch an SWE agent to implement the plan.

    Creates an isolated branch, runs Claude Code with the
    plan as context, and captures execution metrics.
    """
    repo_path = Path.cwd()  # assumes running in the repo root
    branch_name = f"auto/issue-{state.issue_id}-attempt-{state.attempt}"

    # Create a fresh branch from the default branch
    subprocess.run(
        ["git", "checkout", "-b", branch_name, "origin/main"],
        cwd=str(repo_path),
        capture_output=True,
    )

    # Build the execution prompt with plan context
    steps_text = "\n".join(
        f"  {i+1}. {s['description']} (files: {s.get('target_files', [])})"
        for i, s in enumerate(state.plan_steps)
    )

    execution_prompt = f"""You are an SWE agent resolving GitHub issue #{state.issue_id}.

ISSUE: {state.issue_title}
{state.issue_body}

PLAN:
{state.plan_summary}

STEPS:
{steps_text}

INSTRUCTIONS:
1. Follow the plan steps in order.
2. After implementing changes, run the test suite.
3. If tests fail, fix the issues before finishing.
4. Keep total changes under 500 lines.
5. Do not modify CI, deployment, or configuration files.

When finished, summarize all changes made."""

    # Execute via Claude Code SDK
    start_time = time.time()

    result = subprocess.run(
        [
            "claude", "--print",
            "--model", "claude-sonnet-4-20250514",
            "--max-turns", "25",
            "--output-format", "json",
            "--allowedTools", "Read,Edit,Write,Bash,Glob,Grep",
        ],
        input=execution_prompt,
        capture_output=True,
        text=True,
        cwd=str(repo_path),
        timeout=600,  # 10-minute timeout
    )

    elapsed = time.time() - start_time

    # Collect diff statistics
    diff_result = subprocess.run(
        ["git", "diff", "--numstat", "origin/main"],
        capture_output=True, text=True,
        cwd=str(repo_path),
    )

    files_changed = []
    total_added = total_removed = 0
    for line in diff_result.stdout.strip().split("\n"):
        if not line:
            continue
        parts = line.split("\t")
        if len(parts) == 3:
            try:
                total_added += int(parts[0])
                total_removed += int(parts[1])
            except ValueError:
                pass
Real-World Application: GitHub Copilot Workspace
Real-World Application: GitHub Copilot Workspace
files_changed.append(parts[2]) # Parse agent output agent_output = "" try: output = json.loads(result.stdout) agent_output = str(output.get("result", "")) except json.JSONDecodeError: agent_output = result.stdout[:2000] return { "branch_name": branch_name, "files_changed": files_changed, "lines_added": total_added, "lines_removed": total_removed, "execution_time_seconds": elapsed, "agent_output": agent_output, }
SWE agent dispatch via claude --print in an isolated git branch. The agent receives the plan steps as structured context, operates under tool restrictions (no network, no configuration edits), and is capped at 25 turns and 10 minutes. Diff statistics are collected after execution for the verification pipeline.

3.4 Verify Node

import subprocess
import re
from pathlib import Path

def verify_node(state: LoopState) -> dict:
    """
    Run multi-signal verification on the agent's changes.

    Checks: test suite, linter, type checker, diff size limits.
    Produces a pass/fail decision with confidence score.
    """
    repo_path = Path.cwd()
    blocking = []

    # 1. Run tests
    test_result = subprocess.run(
        ["python", "-m", "pytest", "--tb=short", "-q"],
        capture_output=True, text=True,
        cwd=str(repo_path),
        timeout=300,
    )
    passed = failed = 0
    for line in test_result.stdout.split("\n"):
        m = re.search(r"(\d+) passed", line)
        if m:
            passed = int(m.group(1))
        m = re.search(r"(\d+) failed", line)
        if m:
            failed = int(m.group(1))

    if failed > 0:
        blocking.append(f"{failed} test(s) failed")

    # 2. Run linter
    lint_result = subprocess.run(
        ["ruff", "check", "--quiet", "."],
        capture_output=True, text=True,
        cwd=str(repo_path),
    )
    lint_issues = len([
        l for l in lint_result.stdout.strip().split("\n") if l.strip()
    ]) if lint_result.stdout.strip() else 0
    lint_score = max(0.0, 1.0 - lint_issues * 0.05)

    # 3. Check diff size limits
    max_files = 10
    max_lines = 500
    total_lines = state.lines_added + state.lines_removed
    if len(state.files_changed) > max_files:
        blocking.append(
            f"Too many files changed: {len(state.files_changed)} > {max_files}"
        )
    if total_lines > max_lines:
        blocking.append(
            f"Too many lines changed: {total_lines} > {max_lines}"
        )

    # 4. Compute confidence
    # Weights: tests matter most (0.5), lint next (0.3), a base
    # floor of 0.2 ensures verified-but-untested changes still get
    # a nonzero score, and a small size bonus rewards minimal diffs.
    total_tests = passed + failed
    test_score = passed / total_tests if total_tests > 0 else 0.0
    size_bonus = max(0.0, 0.1 * (1.0 - total_lines / max_lines))
    confidence = 0.5 * test_score + 0.3 * lint_score + 0.2 + size_bonus
    confidence = min(1.0, confidence)

    return {
        "tests_passed": passed,
        "tests_failed": failed,
        "lint_score": lint_score,
        "verification_passed": len(blocking) == 0,
        "verification_confidence": confidence,
        "blocking_issues": blocking,
    }
Multi-signal verification: pytest, ruff linting, and diff size guards. The confidence score weights test results most heavily (0.5), reflecting the practical observation that passing tests is typically the strongest available evidence of correctness in automated pipelines. Diff size limits enforce the guardrail that prevents runaway agents from rewriting the codebase.

3.5 Open PR Node

import subprocess
import json

def open_pr_node(state: LoopState) -> dict:
    """
    Open a pull request with the agent's changes.

    The PR body includes full provenance: the original issue,
    triage decision, plan summary, execution metrics, and
    verification results. This audit trail is essential for
    building trust in autonomous operation.
    """
    # Push the branch
    subprocess.run(
        ["git", "push", "origin", state.branch_name],
        capture_output=True, text=True,
    )

    # Determine PR labels based on triage route
    labels = ["autonomous-agent"]
    if state.triage_route == "autonomous":
        labels.append("auto-merge-candidate")
    elif state.triage_route == "supervised":
        labels.append("needs-human-review")

    # Build the PR body with full provenance
    pr_body = f"""## Autonomous Resolution of #{state.issue_id}

**Issue:** {state.issue_title}

### Triage
- **Route:** {state.triage_route} (confidence: {state.triage_confidence:.2f})
- **Risk:** {state.risk_category}
- **Reasoning:** {state.triage_reasoning}

### Plan
{state.plan_summary}

### Execution
- **Attempt:** {state.attempt} of {state.max_attempts}
- **Files changed:** {len(state.files_changed)}
- **Lines:** +{state.lines_added} / -{state.lines_removed}
- **Time:** {state.execution_time_seconds:.0f}s
- **Cost:** ${state.execution_cost_usd:.2f}

### Verification
- **Tests:** {state.tests_passed} passed, {state.tests_failed} failed
- **Lint score:** {state.lint_score:.2f}
- **Confidence:** {state.verification_confidence:.2f}

### Files Changed
{chr(10).join(f"- `{f}`" for f in state.files_changed)}

---
*This PR was created by the supervised autonomous loop.
[Issue #{state.issue_id}] | Triage: {state.triage_route} | Confidence: {state.verification_confidence:.2f}*
"""

    # Create the PR using gh CLI
    pr_result = subprocess.run(
        [
            "gh", "pr", "create",
            "--title", f"[Auto] Fix #{state.issue_id}: {state.issue_title}",
            "--body", pr_body,
            "--label", ",".join(labels),
            "--head", state.branch_name,
        ],
        capture_output=True, text=True,
    )

    pr_url = pr_result.stdout.strip()

    # If autonomous route with high confidence, request auto-merge
    if (state.triage_route == "autonomous"
            and state.verification_confidence >= 0.85):
        subprocess.run(
            ["gh", "pr", "merge", pr_url, "--auto", "--squash"],
            capture_output=True, text=True,
        )
        return {
            "final_action": "merge",
            "pr_url": pr_url,
        }

    return {
        "final_action": "review",
        "pr_url": pr_url,
    }
PR creation with full provenance audit trail via the gh CLI. The body includes triage decision, plan, execution metrics, and verification results. For autonomous-route tasks with verification confidence at or above 0.85, the node enables GitHub's auto-merge feature; otherwise it labels the PR for human review.

3.6 Record Outcome Node

Once a pull request is opened (or an issue is escalated), the final node closes the feedback loop by recording what happened so the triage classifier can improve over time.

import json
import datetime
from pathlib import Path

# Module-level learner instance (persists across loop iterations)
_triage_learner = TriageLearner()

def record_outcome_node(state: LoopState) -> dict:
    """
    Record the outcome for the feedback loop.

    Stores the full pipeline trace in a JSON Lines (JSONL) log and
    updates the triage learner with the result.
    """
    outcome = {
        "issue_id": state.issue_id,
        "timestamp": datetime.datetime.now().isoformat(),
        "triage_route": state.triage_route,
        "risk_category": state.risk_category,
        "final_action": state.final_action,
        "attempts": state.attempt,
        "files_changed": len(state.files_changed),
        "lines_changed": state.lines_added + state.lines_removed,
        "tests_passed": state.tests_passed,
        "tests_failed": state.tests_failed,
        "verification_passed": state.verification_passed,
        "verification_confidence": state.verification_confidence,
        "execution_time_seconds": state.execution_time_seconds,
        "execution_cost_usd": state.execution_cost_usd,
        "pr_url": state.pr_url,
    }

    # Append to JSONL log
    log_path = Path(".autonomous-loop/outcomes.jsonl")
    log_path.parent.mkdir(exist_ok=True)
    with open(log_path, "a") as f:
        f.write(json.dumps(outcome) + "\n")

    # Update the triage learner
    success = state.verification_passed and state.final_action in (
        "merge", "review"
    )
    _triage_learner.update(
        category=state.risk_category,
        route=state.triage_route,
        success=success,
    )

    # Check if any categories can graduate to autonomous
    if _triage_learner.should_allow_autonomous(state.risk_category):
        outcome["graduation_note"] = (
            f"Category '{state.risk_category}' now eligible "
            f"for autonomous routing."
        )

    return {"final_action": state.final_action}
Outcome recording and triage learner update. The node persists the full pipeline trace to a JSONL file (where each line is a self-contained JSON object, enabling append-only writes and line-by-line streaming reads) and updates the TriageLearner, the Beta-distribution classifier from Section 24.2 that tracks per-category success rates. When a task category accumulates enough successful outcomes, the learner signals that the category can graduate to autonomous routing.
Key Insight: Provenance Is the Product

The pull request (PR) body generated by the autonomous loop is not just documentation; it is the product. In a supervised system, the human reviewer's job is to decide whether to merge. The quality of that decision depends entirely on the information available. A PR that says "fixed the bug" gives the reviewer nothing to work with. A PR that shows the triage reasoning, the plan, the execution metrics, and the verification results lets the reviewer make an informed decision in seconds rather than minutes. The same principle applies to the experiment provenance records from Chapter 47: every autonomous action must carry its own audit trail.

4. Running the Loop

With all nodes implemented, the main loop is straightforward: poll for new issues, create a state object for each, and run it through the LangGraph pipeline.

import asyncio
import aiohttp
from typing import AsyncIterator

async def poll_github_issues(
    owner: str, repo: str, token: str, interval: int = 60
) -> AsyncIterator[dict]:
    """Yield new GitHub issues as they appear."""
    seen: set[int] = set()
    url = f"https://api.github.com/repos/{owner}/{repo}/issues"
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/vnd.github+json",
    }

    async with aiohttp.ClientSession() as session:
        while True:
            async with session.get(
                url, headers=headers,
                params={"state": "open", "sort": "created", "direction": "desc"},
            ) as resp:
                if resp.status == 200:
                    issues = await resp.json()
                    for issue in issues:
                        if (issue["number"] not in seen
                                and "pull_request" not in issue):
                            seen.add(issue["number"])
                            yield issue
            await asyncio.sleep(interval)


async def run_autonomous_loop(
    owner: str,
    repo: str,
    token: str,
    poll_interval: int = 60,
) -> None:
    """
    Main entry point for the supervised autonomous loop.

    Polls GitHub for new issues and processes each through
    the LangGraph pipeline. Runs indefinitely until stopped.
    """
    pipeline = build_autonomous_loop()

    print(f"Autonomous loop started for {owner}/{repo}")
    print(f"Polling every {poll_interval}s...")

    async for issue in poll_github_issues(owner, repo, token, poll_interval):
        print(f"\nNew issue #{issue['number']}: {issue['title']}")

        # Build initial state from the issue
        initial_state = LoopState(
            issue_id=str(issue["number"]),
            issue_title=issue["title"],
            issue_body=issue.get("body", ""),
            issue_labels=[l["name"] for l in issue.get("labels", [])],
            issue_author=issue["user"]["login"],
        )

        # Run through the pipeline
        try:
            final_state = pipeline.invoke(initial_state)
            print(f"  Result: {final_state['final_action']}")
            if final_state.get("pr_url"):
                print(f"  PR: {final_state['pr_url']}")
        except Exception as e:
            print(f"  Pipeline error: {e}")
            # Log the error and continue to next issue
            continue


# Entry point
if __name__ == "__main__":
    import os
    asyncio.run(run_autonomous_loop(
        owner="your-org",
        repo="your-repo",
        token=os.environ["GITHUB_TOKEN"],
        poll_interval=60,
    ))
The async main loop: GitHub polling, LoopState construction, and pipeline invocation. It polls GitHub for new issues, constructs a state object for each, and runs it through the compiled LangGraph pipeline. Errors are caught per-issue so a single failure does not stop the loop. In production, this would run as a long-lived process supervised by systemd, Docker, or a cloud scheduler.
Library Shortcut: LangGraph Cloud for Managed Execution

The from-scratch loop above handles polling, state management, and error recovery manually. LangGraph Cloud provides managed execution with built-in persistence, resumability, and a web dashboard for monitoring pipeline runs. The same graph definition works unchanged; you deploy it with langgraph deploy and trigger runs via a REST API or webhook:

# Deploy the same graph to LangGraph Cloud (3 lines)
from langgraph_sdk import get_client

client = get_client(url="https://your-langgraph-cloud.com")
run = await client.runs.create(
    assistant_id="autonomous-loop",
    input={"issue_id": "42", "issue_title": "Fix pagination bug", ...},
)
print(f"Run ID: {run['run_id']}, Status: {run['status']}")
Deploying the autonomous loop graph to LangGraph Cloud via the langgraph_sdk client, replacing the manual polling loop with a managed REST-triggered run.

LangGraph Cloud replaces the manual polling loop, the JSONL log file, and the in-memory triage learner with managed infrastructure: a persistent state store (PostgreSQL), a queue for pending tasks, and a dashboard that shows every pipeline run with full state history. The trade-off is vendor dependency and cost versus operational simplicity.

5. Production Deployment Considerations

Moving from a local prototype to a production deployment introduces several concerns that do not arise during development.

Rate limiting. The GitHub API enforces rate limits (5,000 requests per hour for authenticated requests). The autonomous loop must respect these limits, especially when processing bursts of issues. Use conditional requests (ETags, where the server returns a token representing a response version and the client sends it back on the next request so the server can reply "not modified" without transferring data) to avoid consuming quota on unchanged data, and implement exponential backoff when limits are approached. The LLM API has its own rate limits and costs; the compute budget per task (compute_budget_usd in the plan) prevents runaway spending.

Concurrency. Multiple issues may arrive simultaneously. The simplest approach is serial processing (one issue at a time), which avoids branch conflicts and simplifies state management. For higher throughput, process issues in parallel using separate git worktrees (independent working directories that share the same repository history but each check out a different branch), one per issue, ensuring that each agent operates in complete isolation. LangGraph supports concurrent runs natively.

Checkpoint

So far, the production deployment discussion has introduced two operational concerns: rate limiting (with ETags and exponential backoff) and concurrency (serial processing versus parallel git worktrees). The next two topics, incident response and cost control, address what happens when things go wrong or when spending needs a ceiling.

Resilience and Cost Guardrails

Incident response. When an autonomously merged change causes a production issue, the system must respond automatically: revert the PR, create a post-mortem issue, and downgrade the task category in the triage learner. This requires integration with the monitoring infrastructure from Chapter 22 and a webhook that triggers the revert workflow.

Cost control. Autonomous operation generates LLM costs continuously. Implement three cost control mechanisms: (1) per-task budgets that cap spending on individual issues, (2) daily/weekly budget ceilings that pause the loop when cumulative costs exceed thresholds, and (3) cost dashboards that let operators see spending trends and adjust budgets. The cost tracking integrates with the AgentOps infrastructure from Chapter 22.

from dataclasses import dataclass, field
import datetime

@dataclass
class CostTracker:
    """
    Track and enforce cost budgets for the autonomous loop.

    Implements per-task, daily, and monthly cost ceilings
    with automatic pause when limits are reached.
    """
    daily_budget_usd: float = 50.0
    monthly_budget_usd: float = 500.0
    per_task_budget_usd: float = 5.0
    _daily_spend: float = 0.0
    _monthly_spend: float = 0.0
    _last_reset_day: int = field(
        default_factory=lambda: datetime.datetime.now().day
    )
    _last_reset_month: int = field(
        default_factory=lambda: datetime.datetime.now().month
    )

    def can_start_task(self) -> bool:
        """Check if budget allows starting a new task."""
        self._maybe_reset()
        return (
            self._daily_spend + self.per_task_budget_usd <= self.daily_budget_usd
            and self._monthly_spend + self.per_task_budget_usd <= self.monthly_budget_usd
        )

    def record_spend(self, amount_usd: float) -> None:
        """Record spending from a completed task."""
        self._maybe_reset()
        self._daily_spend += amount_usd
        self._monthly_spend += amount_usd

    def _maybe_reset(self) -> None:
        """Reset counters on day/month boundaries."""
        now = datetime.datetime.now()
        if now.day != self._last_reset_day:
            self._daily_spend = 0.0
            self._last_reset_day = now.day
        if now.month != self._last_reset_month:
            self._monthly_spend = 0.0
            self._last_reset_month = now.month

    @property
    def budget_summary(self) -> str:
        return (
            f"Daily: ${self._daily_spend:.2f}/${self.daily_budget_usd:.2f} | "
            f"Monthly: ${self._monthly_spend:.2f}/${self.monthly_budget_usd:.2f}"
        )
The CostTracker dataclass with three budget tiers: per-task, daily, and monthly. The can_start_task method gates the loop, preventing new task dispatch when budgets are exhausted. Counters reset automatically on day and month boundaries.
Practical Example: A Week of Autonomous Operation

Consider a representative scenario: deploying the supervised autonomous loop to a medium-sized open-source Python project (50k lines, 200 open issues, continuous integration (CI)). The numbers below are illustrative estimates based on typical token costs and task complexity, not measurements from a specific deployment. Over one week of operation with a \$50 daily budget:

6. Connecting to the Discovery Workbench

The supervised autonomous loop becomes a module in the Discovery Workbench (first introduced in Chapter 6) by exposing its pipeline state, outcome records, and triage model as Workbench components. This integration enables three capabilities:

Provenance tracking. Every autonomous code change is recorded in the Workbench's experiment registry (Chapter 47), linking the change to the issue that triggered it, the plan that guided it, and the verification results that validated it. When a scientific pipeline later produces unexpected results, the provenance chain can identify whether a recent autonomous code change is the cause.

Cross-system coordination. The GitHub poller can respond to issues filed by other Workbench components. For example, the anomaly detection system from Chapter 30 might detect a data quality problem and file a GitHub issue automatically. The loop resolves it, and the Workbench records the full chain: anomaly detected, issue filed, fix implemented, tests passed, change merged.

Self-improving infrastructure. The feedback loop that improves triage is itself a discovery process. The triage learner accumulates evidence about which task categories the agent handles well, discovers patterns in failure modes, and expands the autonomous frontier. This is the software engineering analogue of the self-improving scientific workflows in Chapter 53, where the "experiment" is a code change and the "result" is whether it worked.

Real-World Application: GitHub Copilot Workspace

GitHub's Copilot Workspace (announced at GitHub Universe in November 2023 and entered technical preview in April 2024) implements a supervised autonomous loop structurally similar to the one in this section. When a developer selects an issue, Copilot Workspace generates a plan, proposes file edits, runs the repository's test suite for verification, and presents the result as a draft pull request for human approval. The system enforces the same L3 supervision constraint: no change merges without explicit human sign-off, and the PR body includes the plan and verification results as provenance. As of 2025, GitHub has evolved this concept further with the Copilot coding agent, which can autonomously pick up assigned issues, create branches, and open PRs without the developer initiating the workflow manually.

The Bot That Filed a Bug Against Itself

According to reports from Meta's engineering blog, a team at Meta deployed an internal autonomous loop for resolving lint violations across their monorepo. Within its first week, the bot submitted a fix that introduced a new lint violation of a different category. The verification pipeline caught the regression, the retry planner tried a different approach, and that second attempt introduced yet another violation. After exhausting its three retries, the system escalated the issue to a human, who discovered that two lint rules contradicted each other. The bot had inadvertently performed a form of adversarial testing: by trying every compliant path and failing, it proved that no single-file fix could satisfy both rules simultaneously, exposing a configuration bug that human engineers had reportedly overlooked for months.

Research Frontier: From Autonomous Software to Autonomous Science

The supervised autonomous loop architecture generalizes beyond software engineering. Replace "GitHub issue" with "experimental hypothesis," replace "SWE agent" with "lab automation agent," replace "test suite" with "experimental validation protocol," and you have the architecture of a self-driving laboratory (Chapter 55). The triage classifier becomes a hypothesis prioritizer, the planning agent becomes an experiment designer, the execution engine becomes a robot controller, and the verification pipeline becomes a statistical analysis module. In 2025, Anthropic released Claude Code with native sub-agent orchestration (the Claude Code SDK), and researchers at Princeton and the University of Chicago demonstrated SWE-agent (Yang et al., 2024, "SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering"), which resolved 12.47% of real GitHub issues from the SWE-bench benchmark end-to-end (circa 2024) using a sense-plan-execute-verify loop closely mirroring the architecture in this section. More recently, OpenHands CodeAct (Wang et al., 2024) pushed that resolve rate above 50% on SWE-bench Verified (circa 2024) by combining code actions with a browsing agent and multi-turn self-debugging, demonstrating that bounded retry with adaptive re-planning (exactly the pattern our route_after_verify implements) is a key driver of autonomous resolution performance. As of 2025, top-performing systems on SWE-bench Verified exceed 60% resolve rates, and several commercial coding agents (including Claude Code and Cursor) have integrated similar sense-plan-execute-verify loops into production-grade developer workflows. The structural parallel between autonomous software and autonomous science is not coincidental; both are instances of the search framework from Chapter 1, searching through a space of possible actions for trajectories that achieve a goal.

Try It: Build a Local Triage-and-Verify Pipeline

You can build a minimal version of the supervised autonomous loop on your laptop using only Python standard libraries and a local git repository. No cloud APIs required.

  1. Create a toy repository. Initialize a new git repo with a single Python file containing a deliberate bug (for example, an off-by-one error in a list indexing function) and a test_main.py file with a failing test that exposes the bug.
  2. Implement the triage node. Write a function that reads the commit message or a simulated issue title, checks it against a list of critical keywords (security, migration, deploy), and returns a route: "autonomous" if no critical keywords match, "human" otherwise. Test it with five sample issue titles.
  3. Implement the verify node. Write a function that runs subprocess.run(["python", "-m", "pytest", "-q"]), parses the output for pass/fail counts, and returns a dictionary with verification_passed (True if zero failures) and tests_passed/tests_failed counts.
  4. Wire them together. Create a run_pipeline(issue_title, fix_function) that calls triage, then (if routed to autonomous) applies the fix function to the buggy file, then calls verify. Print the full state dictionary at each step.
  5. Test the retry loop. Run the pipeline with a fix function that intentionally introduces a second bug. Confirm that verification fails, then run it again with a correct fix and confirm verification passes. You now have a working triage, execute, verify, retry skeleton in under 100 lines of code.

Lab: Triage Classifier Accuracy on Real GitHub Issues

Goal: Measure how accurately the rule-based triage layers (Layers 1 and 2) classify real GitHub issues, and identify where an LLM layer adds value.

Tools needed: Python 3.10+, the requests library, and a GitHub personal access token. Optionally, the scikit-learn library for computing precision/recall.

Procedure (20 minutes): Use the GitHub API to fetch the 50 most recently closed issues from a well-labeled open-source repository (good candidates: pallets/flask, psf/requests, or tiangolo/fastapi). For each issue, run the Layer 1 and Layer 2 classification logic from this section. Manually label each issue as "autonomous-safe" or "human-required" based on the issue content and the actual fix (visible in the linked PR). Compute the false-positive rate (issues routed to autonomous that should have gone to a human) and the false-negative rate (safe issues unnecessarily escalated).

What to vary: Try adding or removing keywords from the critical_patterns list and adding or removing labels from the low_risk set. Observe how each change shifts the false-positive/false-negative trade-off.

What to observe: Which issue categories fall into the "ambiguous" zone that neither Layer 1 nor Layer 2 handles confidently? These are exactly the cases where the LLM-based Layer 3 earns its cost.

Exercises

  1. Conceptual. The autonomous loop uses a 10-minute timeout for agent execution. Analyze the trade-offs of increasing this to 30 minutes. Consider: resolve rate improvement (from the scaling laws discussed in Section 24.1), cost increase, concurrency impact (if the loop processes issues serially), and the risk of the agent "going in circles" on an unsolvable task.
  2. Coding. Implement a WebhookEventSource that replaces the polling loop with a GitHub webhook receiver (using Flask or FastAPI). The webhook should listen for issues.opened events and feed them into the same pipeline. Compare the latency of webhook-based versus polling-based event detection.
  3. Analysis. The cost tracker uses fixed daily and monthly budgets. Design an adaptive budget system that increases the daily budget when the autonomous loop is performing well (high verification pass rates) and decreases it when performance degrades. Formalize this as a control problem and specify the feedback signal, the control variable, and the stability constraints.