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

24.2 Autonomous Factory Architecture

"I designed a five-stage pipeline for autonomous software development. Then I realized stages two through four kept delegating to each other in a cycle. I had invented bureaucracy."

A Software Architect Who Automated Too Much

Prerequisites

This section builds directly on Section 24.1, where you established what Software Engineering (SWE) agents can do and how the autonomy ladder classifies systems by their level of human involvement. The workflow graph patterns from Chapter 17 provide the structural foundation: the autonomous factory is, at its core, a multi-agent workflow with feedback loops. The observability infrastructure from Chapter 22 provides the monitoring layer that makes autonomous operation safe.

The Big Picture

An autonomous software factory is a persistent system that continuously monitors a repository for work, decides what to do, does it, checks the result, and learns from the outcome. Unlike the one-shot agent invocations in Section 24.1, the factory runs indefinitely as a background process. Its architecture has five stages arranged in a loop: sense (detect new issues and events), triage (classify by risk and route to human or agent), plan (decompose the task), execute (dispatch SWE agents), and verify (run tests, static analysis, and review). The verify stage feeds reward signals back to the triage stage, making the system self-improving. The stages, reward signals, and feedback loop (formalized as an online learning problem) follow below.

1. The Five-Stage Pipeline

In 2024, one widely cited anecdote describes a startup that left its autonomous code pipeline running over a long weekend and returned on Monday to find 31 pull requests merged, a dependency upgraded, and two stale feature flags cleaned up, all without a single human keystroke. That outcome was not magic; it was the product of a five-stage loop whose architecture this section dissects in full. The autonomous factory pipeline processes repository events through five sequential stages. Each stage has a well-defined input, output, and failure mode. The pipeline is not a one-way conveyor belt; the verify stage can route work back to earlier stages for retry, and the triage stage evolves its routing policy over time based on outcomes. Figure 24.2 illustrates the full pipeline topology, including the feedback path from verify back to triage. Figure 24.2.1 illustrates the five-stage autonomous factory pipeline with feedback loop.

Five-stage autonomous factory pipeline with feedback loop
Figure 24.2.1: The five-stage autonomous factory pipeline (sense, triage, plan, execute, verify) with feedback loop, showing how reward signals from verification flow back to the triage learner to progressively expand autonomous scope.
Sense Triage Plan Execute Verify Merge Escalate agent human pass retries exhausted retry reward signals
Figure 24.2: The five-stage autonomous factory pipeline. Events enter at Sense and flow through Triage, Plan, Execute, and Verify. Triage routes high-risk tasks directly to Escalate (human review). Verify routes passing changes to Merge, sends failing tasks back to Plan for retry (purple dashed line), and escalates when retries are exhausted. The orange dashed feedback path carries reward signals from Verify back to Triage, enabling the self-improving routing policy described in Section 3.

A pipeline here is a directed graph of processing stages. It transforms a raw event (such as a new GitHub issue) into a verified, mergeable code change; each stage acts as both a filter and an enrichment step. Without this structure, autonomous agents operate as isolated, stateless tools with no mechanism for self-correction or incremental trust expansion. Each stage reads a typed input record, performs its function (classification, decomposition, code generation, or verification), and attaches its output to that record. The stage then forwards the record onward; failure at any point triggers a retry loop or an escalation to a human. Use a staged pipeline when you need auditable, bounded autonomy with feedback. Use a one-shot agent when tasks are low-risk and self-contained, or a fully manual workflow when every change requires human judgment.

We model the pipeline as a state machine (where a state machine is a mathematical model that transitions between a finite set of states in response to events) \(M = (Q, \Sigma, \delta, q_0, F)\) where $Q = \{\text{sense}, \text{triage}, \text{plan}, \text{execute}, \text{verify}, \text{merge}, \text{escalate}, \text{retry}\}$ is the state set, $\Sigma$ is the set of events (new issue, test result, review decision), \(\delta\) is the transition function, \(q_0 = \text{sense}\) is the start state, and \(F = \{\text{merge}, \text{escalate}\}\) are terminal states. In short: an autonomous factory is a loop, not a line: sense, triage, plan, execute, verify, and let the outcomes teach the triage stage what to trust next time.

from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional, Any
import datetime

class PipelineStage(Enum):
    SENSE = auto()      # detect new issues, events, signals
    TRIAGE = auto()     # classify risk, route to agent or human
    PLAN = auto()       # decompose task into sub-steps
    EXECUTE = auto()    # dispatch SWE agents
    VERIFY = auto()     # run tests, static analysis, review
    MERGE = auto()      # auto-merge (terminal, success)
    ESCALATE = auto()   # route to human (terminal, needs help)
    RETRY = auto()      # loop back to plan or execute

@dataclass
class PipelineEvent:
    """An event flowing through the autonomous pipeline."""
    issue_id: str
    stage: PipelineStage
    timestamp: datetime.datetime = field(
        default_factory=datetime.datetime.now
    )
    payload: dict = field(default_factory=dict)
    attempt: int = 1
    max_attempts: int = 3

@dataclass
class PipelineState:
    """Full state of a task as it moves through the pipeline."""
    event: PipelineEvent
    triage_result: Optional["TriageDecision"] = None
    plan: Optional["TaskPlan"] = None
    execution_result: Optional["SWEAgentResult"] = None
    verification: Optional["VerificationReport"] = None
    reward_signals: dict = field(default_factory=dict)

    @property
    def should_retry(self) -> bool:
        """Retry if verification failed and attempts remain."""
        if self.verification is None:
            return False
        return (
            not self.verification.passed
            and self.event.attempt < self.event.max_attempts
        )

    @property
    def should_escalate(self) -> bool:
        """Escalate to human if retries exhausted or risk is high."""
        if self.triage_result and self.triage_result.route == "human":
            return True
        if self.verification and not self.verification.passed:
            return self.event.attempt >= self.event.max_attempts
        return False
The pipeline state machine as Python data classes, defining the eight stages (SENSE through RETRY) and the PipelineState record that accumulates triage, plan, execution, and verification results. The should_retry and should_escalate properties implement bounded retry logic, preventing infinite loops by capping attempts at max_attempts.

1.1 Stage 1: Sense

The sense stage monitors the repository for actionable events. The most common event sources are:

import aiohttp
import asyncio
from dataclasses import dataclass
from typing import AsyncIterator

@dataclass
class GitHubEventSource:
    """
    Polls GitHub for new issues and events.

    Uses the GitHub REST API with conditional requests
    (If-None-Match) to avoid redundant data transfer.
    """
    owner: str
    repo: str
    token: str
    poll_interval: int = 60  # seconds

    async def poll(self) -> AsyncIterator[PipelineEvent]:
        """Yield new events as they appear."""
        seen_issues: set[int] = set()
        etag: str | None = None
        url = (
            f"https://api.github.com/repos/{self.owner}/{self.repo}"
            f"/issues?state=open&sort=created&direction=desc"
        )
        headers = {
            "Authorization": f"Bearer {self.token}",
            "Accept": "application/vnd.github+json",
        }

        async with aiohttp.ClientSession() as session:
            while True:
                req_headers = {**headers}
                if etag:
                    req_headers["If-None-Match"] = etag

                async with session.get(url, headers=req_headers) as resp:
                    if resp.status == 304:
                        # No new data since last poll
                        await asyncio.sleep(self.poll_interval)
                        continue

                    etag = resp.headers.get("ETag")
                    issues = await resp.json()

                    for issue in issues:
                        if issue["number"] not in seen_issues:
                            seen_issues.add(issue["number"])
                            yield PipelineEvent(
                                issue_id=str(issue["number"]),
                                stage=PipelineStage.SENSE,
                                payload={
                                    "title": issue["title"],
                                    "body": issue.get("body", ""),
                                    "labels": [
                                        l["name"]
                                        for l in issue.get("labels", [])
                                    ],
                                    "author": issue["user"]["login"],
                                },
                            )

                await asyncio.sleep(self.poll_interval)
An async GitHub event source that polls for new issues using conditional requests. ETags (where an ETag is an HTTP header the server returns to identify a response version, letting the client skip re-downloading unchanged data) minimize API usage by skipping responses that have not changed. Each new issue becomes a PipelineEvent that enters the triage stage. Production deployments should use webhooks instead of polling for lower latency.

1.2 Stage 2: Triage

The triage stage is the most critical decision point in the pipeline. It classifies each incoming event by risk level and routes it to either autonomous execution or human review. A wrong decision here has asymmetric consequences: routing a safe task to a human wastes human time (low cost); routing a dangerous task to autonomous execution can cause production incidents (high cost). The triage policy should therefore be conservative by default, erring toward human review and expanding autonomous scope only as evidence accumulates.

Common Misconception

A frequent misconception is that "autonomous" means "no human involvement at all." In practice, most tasks in a well-designed autonomous factory still flow through the SUPERVISED route, where an agent does the work but a human reviews before merge. Full autonomy (the AUTONOMOUS route) is reserved for narrow, well-understood task categories that have accumulated a strong track record of success; it is the exception, not the default, and the system is specifically designed to keep humans in the loop for anything uncertain.

The triage decision combines two assessments: the delegability score from Section 24.1 (can the agent handle this task?) and a blast radius estimate, where blast radius is the worst-case scope of damage if the agent's change is incorrect (how many services, users, or data paths are affected). We implement triage as a classifier that takes issue metadata and repository context as input and produces a routing decision.

from dataclasses import dataclass
from enum import Enum

class TriageRoute(str, Enum):
    AUTONOMOUS = "autonomous"   # agent executes, auto-merge if verified
    SUPERVISED = "supervised"   # agent executes, human reviews before merge
    HUMAN = "human"             # route directly to human developer

@dataclass
class TriageDecision:
    """Output of the triage classifier."""
    route: TriageRoute
    confidence: float             # classifier confidence (0-1)
    risk_category: str            # "low", "medium", "high", "critical"
    estimated_complexity: float   # from TaskAssessment
    reasoning: str                # explanation for audit trail

class TriageClassifier:
    """
    Classifies issues for routing in the autonomous pipeline.

    Uses a combination of rule-based heuristics (for safety-
    critical patterns) and large language model (LLM)-based
    classification (for nuanced judgment). The rule layer
    runs first and can short-circuit to human review; the
    LLM layer handles everything else.
    """

    # Patterns that always route to human review
    CRITICAL_PATTERNS = [
        "security", "vulnerability", "CVE", "authentication",
        "authorization", "payment", "billing", "PII",
        "password", "encryption", "database migration",
        "breaking change", "API deprecation",
    ]

    # Labels that indicate low-risk tasks
    LOW_RISK_LABELS = [
        "documentation", "typo", "chore", "dependencies",
        "good first issue", "help wanted", "enhancement",
    ]

    def classify(self, event: PipelineEvent) -> TriageDecision:
        """Classify an issue and decide routing."""
        title = event.payload.get("title", "").lower()
        body = event.payload.get("body", "").lower()
        labels = event.payload.get("labels", [])
        combined_text = f"{title} {body}"

        # Rule layer: critical patterns always go to human
        for pattern in self.CRITICAL_PATTERNS:
            if pattern in combined_text:
                return TriageDecision(
                    route=TriageRoute.HUMAN,
                    confidence=0.95,
                    risk_category="critical",
                    estimated_complexity=0.8,
                    reasoning=(
                        f"Critical pattern '{pattern}' detected. "
                        f"Routing to human review."
                    ),
                )

        # Rule layer: known low-risk labels
        low_risk_labels = [
            l for l in labels if l.lower() in self.LOW_RISK_LABELS
        ]
        if low_risk_labels:
            return TriageDecision(
                route=TriageRoute.AUTONOMOUS,
                confidence=0.85,
                risk_category="low",
                estimated_complexity=0.2,
                reasoning=(
                    f"Low-risk labels detected: {low_risk_labels}. "
                    f"Routing to autonomous execution."
                ),
            )

        # Default: supervised (agent works, human reviews)
        return TriageDecision(
            route=TriageRoute.SUPERVISED,
            confidence=0.6,
            risk_category="medium",
            estimated_complexity=0.5,
            reasoning=(
                "No strong signal for autonomous or human routing. "
                "Defaulting to supervised mode."
            ),
        )
A two-layer triage classifier combining rule-based pattern matching (for safety-critical keywords like "security" and "payment") with label-based heuristics (for known low-risk categories like "typo" and "documentation"). The default route is SUPERVISED, ensuring no task reaches production without at least one verification step. An LLM-based classifier can replace the default case for more nuanced routing.

Step-Through: Triage Classification

Trace through the TriageClassifier.classify() method with three concrete issues:

Issue A: title = "Fix typo in README", labels = ["documentation", "typo"].
Step 1: combined_text = "fix typo in readme ". Scan CRITICAL_PATTERNS: "security" not in text, "vulnerability" not in text, ... none match. Step 2: Check labels: "documentation" is in LOW_RISK_LABELS, "typo" is in LOW_RISK_LABELS. Match found. Result: route = AUTONOMOUS, confidence = 0.85, risk = "low".

Issue B: title = "Update payment processing module", labels = ["enhancement"].
Step 1: combined_text = "update payment processing module ". Scan CRITICAL_PATTERNS: "security"? No. "vulnerability"? No. ... "payment"? Yes, match at index 5. Short-circuit. Result: route = HUMAN, confidence = 0.95, risk = "critical". The label "enhancement" is never checked because the rule layer fires first.

Issue C: title = "Refactor logging module", labels = ["refactor"].
Step 1: No critical pattern match. Step 2: "refactor" is not in LOW_RISK_LABELS. No match. Fall through to default. Result: route = SUPERVISED, confidence = 0.6, risk = "medium".

Key Insight: Asymmetric Error Costs Shape Triage Policy

The optimal triage policy is not the one that maximizes autonomous throughput. It is the one that minimizes expected regret, accounting for the asymmetric costs of false positives (routing a safe task to humans, wasting time) versus false negatives (routing a dangerous task to autonomous execution, risking incidents). If we denote the cost of unnecessary human review as \(c_{\text{fp}}\) and the cost of an autonomous failure as \(c_{\text{fn}}\), the optimal threshold \(\theta^*\) satisfies (derived from setting the expected costs of the two error types equal at the decision boundary): $$\theta^* = \frac{c_{\text{fp}}}{c_{\text{fp}} + c_{\text{fn}}}$$ Intuitively, the threshold is the fraction of total error cost attributable to false positives; when false negatives are far more expensive, that fraction shrinks, pushing the system toward caution. Since \(c_{\text{fn}} \gg c_{\text{fp}}\) in most production settings (an incident costs far more than a wasted code review), the threshold is low, meaning the system should be biased toward human involvement. Only as \(c_{\text{fn}}\) decreases (through better verification, sandboxing, and rollback mechanisms) should the threshold rise.

1.3 Stage 3: Plan

Tasks that pass triage enter the planning stage, where a planning agent decomposes the issue into concrete sub-tasks. The planner reads the issue description, examines the relevant code, and produces a task plan: an ordered list of steps the execution agent should follow. Good plans are specific enough to guide the agent but flexible enough to allow adaptation when the agent discovers something unexpected during execution.

from dataclasses import dataclass, field

@dataclass
class TaskStep:
    """A single step in a task plan."""
    description: str
    target_files: list[str]      # files this step will likely modify
    verification: str            # how to check this step succeeded
    estimated_complexity: float  # 0-1 scale

@dataclass
class TaskPlan:
    """A decomposed plan for resolving an issue."""
    issue_id: str
    summary: str                    # one-sentence summary of the approach
    steps: list[TaskStep]
    estimated_files_changed: int
    estimated_lines_changed: int
    test_strategy: str              # how to verify the full solution
    rollback_strategy: str          # how to undo if something goes wrong
    compute_budget: float = 2.0     # max USD to spend on this task

    @property
    def total_complexity(self) -> float:
        if not self.steps:
            return 0.0
        return sum(s.estimated_complexity for s in self.steps) / len(self.steps)

def create_plan_prompt(issue_title: str, issue_body: str, repo_summary: str) -> str:
    """
    Build the prompt for the planning agent.

    The planner receives the issue description and a summary
    of the repository structure (file tree, key modules, test
    locations) and produces a structured TaskPlan.
    """
    return f"""You are a software planning agent. Analyze this issue and
produce a step-by-step plan for resolving it.

ISSUE: {issue_title}
{issue_body}

REPOSITORY STRUCTURE:
{repo_summary}

OUTPUT FORMAT (JSON):
{{
  "summary": "One-sentence approach description",
  "steps": [
    {{
      "description": "What to do",
      "target_files": ["path/to/file.py"],
      "verification": "How to check it worked",
      "estimated_complexity": 0.3
    }}
  ],
  "estimated_files_changed": 3,
  "estimated_lines_changed": 50,
  "test_strategy": "Run pytest tests/test_module.py",
  "rollback_strategy": "git checkout -- src/module.py"
}}

PLANNING RULES:
1. Read the relevant code BEFORE planning.
2. Each step should be independently verifiable.
3. Order steps so later steps build on earlier ones.
4. Include a test step that verifies the complete fix.
5. Keep the plan minimal: prefer fewer, well-targeted changes."""
The TaskPlan data structure and create_plan_prompt template for the planning agent. Each TaskStep specifies target files, a verification method, and an estimated complexity score. The plan includes a compute budget (default \$2), a test strategy, and a rollback strategy to support safe autonomous execution.

1.4 Stage 4: Execute

The execution stage dispatches SWE agents to carry out the plan, using the ClaudeCodeSWEAgent from Section 24.1 with the plan steps as context. The key architectural choice: one agent follows the entire plan, or multiple specialized agents (one per step) work in parallel under orchestrator coordination. Single-agent execution suits most tasks because it preserves full context across steps and lets the agent adapt when a step reveals unexpected complexity. Multi-agent execution pays off only when the plan contains independent sub-tasks (fixing a bug in one module while updating documentation in another) that benefit from parallelism.

1.5 Stage 5: Verify

Verification is the stage that makes autonomous operation safe. It runs after execution and determines whether the agent's changes are correct, complete, and safe to merge. Verification combines multiple signals into a single pass/fail decision, with each signal contributing to a confidence score. Tests alone receive only 40% of the confidence weight; the remaining 60% comes from linting, type checks, security scans, and diff size, because passing tests can miss the subtle bugs that surface in production.

from dataclasses import dataclass, field
import subprocess
from pathlib import Path

@dataclass
class VerificationReport:
    """Comprehensive verification of an agent's changes."""
    passed: bool
Real-World Application: GitHub's Copilot Autofix
Real-World Application: GitHub's Copilot Autofix
test_results: dict # {"passed": int, "failed": int, "error": int} lint_score: float # 0-1, from static analysis type_check_passed: bool security_scan_clean: bool diff_size: dict # {"files": int, "additions": int, "deletions": int} review_comments: list[str] # from automated code review confidence: float # overall confidence (0-1) blocking_issues: list[str] # issues that prevent merge class VerificationPipeline: """ Multi-signal verification for autonomous changes. Runs tests, linting, type checking, and security scanning in sequence. Any blocking failure stops the pipeline. """ def __init__(self, repo_path: Path): self.repo_path = repo_path def verify(self) -> VerificationReport: """Run all verification checks and produce a report.""" blocking = [] # 1. Run the test suite test_results = self._run_tests() if test_results["failed"] > 0 or test_results["error"] > 0: blocking.append( f"{test_results['failed']} tests failed, " f"{test_results['error']} errors" ) # 2. Run linting (ruff) lint_score = self._run_linter() # 3. Run type checking (mypy/pyright) type_ok = self._run_type_check() if not type_ok: blocking.append("Type checking failed") # 4. Run security scan (bandit) security_ok = self._run_security_scan() if not security_ok: blocking.append("Security scan found issues") # 5. Measure diff size diff = self._measure_diff() # 6. Compute confidence from signals confidence = self._compute_confidence( test_results, lint_score, type_ok, security_ok, diff ) return VerificationReport( passed=len(blocking) == 0, test_results=test_results, lint_score=lint_score, type_check_passed=type_ok, security_scan_clean=security_ok, diff_size=diff, review_comments=[], confidence=confidence, blocking_issues=blocking, ) def _run_tests(self) -> dict: """Run pytest and parse results.""" result = subprocess.run( ["python", "-m", "pytest", "--tb=short", "-q"], capture_output=True, text=True, cwd=str(self.repo_path), timeout=300, ) # Parse pytest output for pass/fail counts output = result.stdout passed = failed = errors = 0 for line in output.split("\n"): if "passed" in line: import re m = re.search(r"(\d+) passed", line) if m: passed = int(m.group(1)) if "failed" in line: import re m = re.search(r"(\d+) failed", line) if m: failed = int(m.group(1)) if "error" in line: import re m = re.search(r"(\d+) error", line) if m: errors = int(m.group(1)) return {"passed": passed, "failed": failed, "error": errors} def _run_linter(self) -> float: """Run ruff and return a 0-1 quality score.""" result = subprocess.run( ["ruff", "check", "--statistics", "."], capture_output=True, text=True, cwd=str(self.repo_path), ) # Score: 1.0 if no issues, decreasing with issue count issue_count = len(result.stdout.strip().split("\n")) if not result.stdout.strip(): issue_count = 0 return max(0.0, 1.0 - issue_count * 0.05) def _run_type_check(self) -> bool: """Run pyright and return pass/fail.""" result = subprocess.run( ["pyright", "--outputjson"], capture_output=True, text=True, cwd=str(self.repo_path), ) return result.returncode == 0 def _run_security_scan(self) -> bool: """Run bandit and return pass/fail.""" result = subprocess.run( ["bandit", "-r", "src/", "-f", "json", "-q"], capture_output=True, text=True, cwd=str(self.repo_path), ) return result.returncode == 0 def _measure_diff(self) -> dict: """Measure the size of the agent's changes.""" result = subprocess.run( ["git", "diff", "--stat", "--numstat", "HEAD~1"], capture_output=True, text=True, cwd=str(self.repo_path), ) files, additions, deletions = 0, 0, 0 for line in result.stdout.strip().split("\n"): parts = line.split("\t") if len(parts) == 3: try: additions += int(parts[0]) deletions += int(parts[1]) files += 1 except ValueError: pass return { "files": files, "additions": additions, "deletions": deletions, } def _compute_confidence( self, tests, lint, type_ok, security_ok, diff ) -> float: """ Weighted confidence from verification signals. Tests are the strongest signal (weight 0.4). Smaller diffs get a bonus (less risk). """ score = 0.0 total_tests = tests["passed"] + tests["failed"] + tests["error"] if total_tests > 0: score += 0.4 * (tests["passed"] / total_tests) score += 0.2 * lint score += 0.15 * (1.0 if type_ok else 0.0) score += 0.15 * (1.0 if security_ok else 0.0) # Diff size penalty: larger changes are riskier total_lines = diff["additions"] + diff["deletions"] size_bonus = max(0.0, 0.1 * (1.0 - total_lines / 500)) score += size_bonus return min(1.0, score)
A multi-signal VerificationPipeline that runs pytest, ruff (linting), pyright (type checking), and bandit (security scanning) on the agent's changes. The _compute_confidence method weights test results most heavily (0.4), followed by lint score (0.2), type and security checks (0.15 each), and a small-diff bonus (up to 0.1). Any blocking failure prevents autonomous merge.
Practical Example: Why Multi-Signal Verification Matters

Consider an agent that fixes a "TypeError on line 42" issue by changing x.strip() to str(x).strip(). The test suite passes because the existing test only checks the happy path where x is already a string. But the linter flags a broader type inconsistency: the function is annotated as accepting str but is called with Optional[str]. The type checker confirms this, revealing that the agent's fix is a band-aid over a deeper design issue. Without multi-signal verification, this patch would reach production and eventually cause a None.strip() crash on a code path the tests do not cover. The verification pipeline catches it, routes the issue to SUPERVISED mode, and a human reviewer identifies the correct fix: adding a None check at the function boundary.

2. Reward Signals for Self-Improvement

With all five stages in place, the pipeline can process issues end to end, but it still behaves identically on its hundredth run as on its first unless it can learn from past outcomes.

The factory becomes self-improving when the verify stage feeds signals back to the triage stage. Over time, the triage classifier learns which types of issues the agent resolves successfully and which types it consistently fails on. This feedback loop is the key difference between a static L3 system and a progressing L4/L5 system (where L3, L4, and L5 refer to levels on the autonomy ladder from Section 24.1, ranging from supervised agent execution to fully autonomous operation).

We define four categories of reward signals, ordered by availability (how quickly you get the signal) and reliability (how accurately it predicts real-world success):

Signal Categories by Latency

Immediate signals (seconds). Test pass rate, lint score, type check result, diff size. Available immediately after execution. Reliable for catching obvious failures but blind to subtle correctness issues.

CI signals (minutes). Full CI pipeline results, integration test outcomes, build artifacts. Available after CI completes. More comprehensive than immediate signals because CI typically runs a broader test matrix (multiple Python versions, operating systems, dependency configurations).

Review signals (hours to days). Human reviewer comments, requested changes, approval/rejection. Available only for SUPERVISED tasks where a human reviews the PR. The richest signal because it captures correctness judgments that automated checks miss, but the slowest and most expensive to collect.

Production signals (days to weeks). User-reported bugs against the change, rollback events, performance metrics after deployment. The ground truth signal: did the change actually work in production? The most reliable but also the most delayed, making it suitable only for long-term calibration, not real-time routing decisions.

from dataclasses import dataclass, field
import datetime

@dataclass
class RewardSignal:
    """A single reward signal from the verification/monitoring pipeline."""
    source: str          # "tests", "ci", "review", "production"
    value: float         # normalized to [0, 1]
    confidence: float    # how reliable this signal is
    timestamp: datetime.datetime = field(
        default_factory=datetime.datetime.now
    )
    metadata: dict = field(default_factory=dict)

@dataclass
class OutcomeRecord:
    """
    Complete outcome record for a resolved issue.

    Stored in the feedback database and used to train
    the triage classifier over time.
    """
    issue_id: str
    triage_decision: TriageDecision
    plan_complexity: float
    execution_cost_usd: float
    execution_time_seconds: float
    signals: list[RewardSignal] = field(default_factory=list)

    @property
    def composite_reward(self) -> float:
        """
        Weighted average of all reward signals.

        Later signals (review, production) are weighted higher
        because they are more reliable, but earlier signals
        dominate until later ones arrive.
        """
        if not self.signals:
            return 0.0
        weights = {
            "tests": 0.2,
            "ci": 0.25,
            "review": 0.3,
            "production": 0.25,
        }
        weighted_sum = sum(
            s.value * weights.get(s.source, 0.1) * s.confidence
            for s in self.signals
        )
        weight_total = sum(
            weights.get(s.source, 0.1) * s.confidence
            for s in self.signals
        )
        return weighted_sum / weight_total if weight_total > 0 else 0.0

    @property
    def was_successful(self) -> bool:
        """Binary success: composite reward above threshold."""
        return self.composite_reward >= 0.7
Reward signal and outcome record structures for the feedback loop. The RewardSignal captures a single signal source (tests, CI, review, or production) with a normalized value and a confidence weight. The OutcomeRecord aggregates all signals for a resolved issue; its composite_reward property computes a weighted average that favors later, more reliable signals (review at 0.3, production at 0.25) over faster but shallower ones (tests at 0.2).

3. The Feedback Loop as Online Learning

The triage classifier improves over time by learning from outcome records. This is an online learning problem, where online learning means the model updates its parameters incrementally after each new observation rather than retraining on the full dataset: the classifier makes a routing decision (autonomous vs. supervised vs. human), observes the outcome, and updates its parameters. Two challenges complicate this loop. First, feedback arrives delayed: production signals take days to materialize. Second, feedback is partial: the system routes some tasks to humans and therefore never generates autonomous outcome data for them, so it never learns whether the agent could have handled those tasks.

We formalize this as a contextual bandit problem, where a contextual bandit is a sequential decision framework in which the learner observes context features, selects one of several actions, and receives a reward only for the chosen action (never learning the counterfactual reward of unchosen actions). At each time step \(t\), the triage classifier observes a context \(x_t\) (issue features: title, body, labels, repository statistics), selects an action $a_t \in \{\text{autonomous}, \text{supervised}, \text{human}\}$, and receives a reward $r_t$ (the composite reward from the outcome record). The goal is to learn a policy \(\pi(a | x)\) that maximizes cumulative reward while maintaining a safety constraint:

$$\max_\pi \sum_{t=1}^{T} \mathbb{E}_{a_t \sim \pi(\cdot | x_t)}[r_t] \quad \text{subject to} \quad P(\text{incident} | a_t = \text{autonomous}) \leq \epsilon$$

Mental Model

Think of the contextual bandit triage system like a hospital emergency department's intake nurse. Each patient (issue) arrives with visible symptoms (labels, title, description). The nurse must decide: send the patient home with over-the-counter advice (autonomous), schedule them for a doctor visit with follow-up (supervised), or admit them immediately to specialist care (human). The nurse does not know for certain which decision is correct at intake time, but after each case resolves, she learns whether the patient recovered, needed escalation, or had complications. Over hundreds of cases, she builds a mental model of which symptom patterns predict which outcomes, and her routing accuracy improves. The key parallel is that this learning only works because she tracks outcomes by category: "chest pain in patients under 30" might be safe for the doctor-visit track, while "chest pain in patients over 60" always goes to the specialist. The triage learner in our pipeline does exactly this, maintaining per-category success statistics and graduating categories to autonomous routing only when the evidence is strong enough.

The safety constraint \(\epsilon\) bounds the probability of an autonomous merge causing a production incident. In practice, \(\epsilon\) is set conservatively (e.g., 0.01) and relaxed as the system accumulates a track record.

from dataclasses import dataclass, field
import math

@dataclass
class TriageLearner:
    """
    Online learner for triage routing decisions.

    Uses Thompson sampling to balance exploration (trying
    autonomous on new task types) with exploitation (using
    the best known route). Thompson sampling draws a random
    sample from each action's posterior distribution (here a
    Beta distribution) and picks the action whose sample is
    highest, naturally balancing exploration and exploitation.
    The code below implements the conservative gating check
    (should_allow_autonomous); a full deployment would pair
    this gate with Thompson sampling for active exploration.
    """
    # Per-category success/failure counts (Beta distribution params)
    category_stats: dict[str, dict[str, list[float]]] = field(
        default_factory=lambda: {}
    )
    # Minimum observations before allowing autonomous routing
    min_observations: int = 10
    # Maximum autonomous failure rate
    max_failure_rate: float = 0.05

    def update(self, category: str, route: str, success: bool) -> None:
        """Update beliefs after observing an outcome."""
        if category not in self.category_stats:
            self.category_stats[category] = {}
        if route not in self.category_stats[category]:
            # Beta(1, 1) = uniform prior
            self.category_stats[category][route] = [1.0, 1.0]

        alpha_beta = self.category_stats[category][route]
        if success:
            alpha_beta[0] += 1  # increment successes
        else:
            alpha_beta[1] += 1  # increment failures

    def should_allow_autonomous(self, category: str) -> bool:
        """
        Decide if a task category has enough evidence
        for autonomous routing.

        Requires: (1) enough observations, (2) failure rate
        below threshold, (3) lower bound of Beta confidence
        interval above the safety threshold.
        """
        stats = self.category_stats.get(category, {})
        auto_stats = stats.get("autonomous", [1.0, 1.0])
        alpha, beta = auto_stats

        total_observations = alpha + beta - 2  # subtract prior
        if total_observations < self.min_observations:
            return False

        # Point estimate of success rate
        success_rate = alpha / (alpha + beta)
        failure_rate = 1.0 - success_rate

        if failure_rate > self.max_failure_rate:
            return False

        # 95% lower bound of Beta distribution
        # (A Beta distribution models the probability of
        # success given observed successes and failures;
        # its two parameters alpha and beta count successes
        # and failures plus prior pseudo-counts.)
        # Approximation using normal approximation to Beta
        std = math.sqrt(
            alpha * beta / ((alpha + beta) ** 2 * (alpha + beta + 1))
        )
        lower_bound = success_rate - 1.96 * std

        return lower_bound >= (1.0 - self.max_failure_rate)
An online triage learner using Beta-distributed success/failure counts per task category. The should_allow_autonomous method requires three conditions: at least min_observations trials, a point-estimate failure rate below max_failure_rate, and a 95% lower confidence bound that still exceeds the safety threshold. A category graduates to autonomous routing only when all three hold, implementing conservative expansion: start supervised, graduate to autonomous with evidence.

Exercise 24.2.1

The TriageLearner uses a Beta distribution prior of Beta(1, 1) (uniform) for each new task category. Suppose instead you initialized with Beta(1, 10), encoding a strong prior belief that agents will fail. How would this change the number of successful autonomous completions needed before a category graduates? Compute the minimum number of consecutive successes (with zero failures) required to pass the should_allow_autonomous check under each prior, assuming min_observations = 10 and max_failure_rate = 0.05.

Hint With Beta(alpha, beta), the point estimate of the success rate is alpha / (alpha + beta). After k consecutive successes starting from Beta(a0, b0), you have Beta(a0 + k, b0). Plug into the 95% lower bound formula and solve for the smallest k where the lower bound exceeds 0.95. For Beta(1, 1) with 10 successes you get Beta(11, 1), giving a success rate of 11/12 = 0.917 and a lower bound around 0.85, which does not yet pass. Keep increasing k until it does.

Research Frontier

SWE-bench Verified (Jimenez et al., 2024) established a rigorous benchmark for autonomous software engineering by curating 500 real GitHub issues from popular Python repositories, each with validated test patches that confirm whether an agent's fix is correct. As of early 2026, leading systems resolve 55-70% of these tasks autonomously (circa 2026), up from roughly 40% when the benchmark launched. More recent work pushes beyond single-issue resolution: SWE-bench Multimodal (2024) adds issues that require interpreting screenshots and UI mockups, while RepoBench (Liu et al., 2023) evaluates cross-file reasoning over entire repository contexts. The core open problem is closing the gap between benchmark performance and production reliability, because benchmark tasks have clean reproduction steps and isolated scope, whereas real repositories present ambiguous requirements, flaky tests, and undocumented side effects that current triage and verification pipelines struggle to handle. As of 2025, newer benchmarks such as SWE-bench Multi and LiveCodeBench complement the original suite by testing multi-turn agent interactions and continuously refreshing problem sets to avoid data contamination, reflecting the field's rapid maturation.

Real-World Application: GitHub's Copilot Autofix

GitHub's Copilot Autofix (launched 2024) implements a production version of the sense-triage-execute-verify pipeline described here. When a code scanning alert fires on a repository (the sense stage), the system triages the alert by severity and vulnerability type, generates a candidate fix using an LLM-based agent, and verifies the patch by re-running the CodeQL analysis that originally flagged the issue. Fixes that pass verification are proposed as one-click pull requests. GitHub reported that Autofix typically resolves over 60% of common vulnerability classes (cross-site scripting, SQL injection, path traversal) without human editing, but routes complex or ambiguous alerts to the developer for manual resolution, following the same conservative triage principle formalized in this section. As of 2025, Copilot Autofix has expanded beyond security fixes to cover general code quality issues, and comparable sense-triage-verify pipelines have appeared in competing products such as Amazon CodeGuru and Snyk Code.

4. Guardrails and Safety Architecture

A self-improving triage learner increases throughput, but higher throughput also increases the damage an unchecked failure can cause, which is why the factory pairs its learning loop with layered safety mechanisms.

Autonomous operation requires guardrails at every stage. The guardrail architecture implements a defense in depth strategy (layered security in which multiple independent safeguards each catch failures the others miss): each stage has its own safety checks, and a failure at any stage halts progression and escalates to a human.

Repository isolation. Agents never operate on the production repository directly. Every task gets a fresh clone (or a git worktree, where a worktree is a secondary working directory linked to the same repository, allowing parallel checkouts on separate branches without duplicating the full history) on a feature branch. The agent cannot access the main branch, the production database, or any external service beyond the repository and its test infrastructure.

File-level permissions. The autonomy configuration from Section 24.1 restricts which files the agent may edit. Configuration files, secrets, CI pipelines, database migrations, and infrastructure definitions are off-limits. These restrictions are enforced at the tool level (the edit tool checks the path against the allowlist before writing) rather than relying on the agent to self-police.

Diff size limits. An upper bound on the number of files changed and lines modified prevents runaway agents that rewrite large portions of the codebase. If the agent exceeds the limit, the execution stage halts and the task is escalated.

Checkpoint

So far, three guardrails keep an autonomous agent contained: it works in an isolated clone (not production), it can only edit files on an explicit allowlist, and a diff size cap stops it from rewriting the codebase; the next two guardrails address budget and incident recovery.

Cost caps. Each task has a compute budget (in USD) that limits how many LLM tokens the agent can consume. This prevents infinite retry loops where the agent keeps trying different approaches, burning through budget without making progress. The cost cap connects to the AgentOps infrastructure from Chapter 22.

Incident response. When an autonomously merged change causes a production issue (detected through monitoring), the system automatically reverts the PR, creates a post-mortem issue, and downgrades the task category from "autonomous" to "supervised" in the triage learner. This negative feedback ensures the system contracts its autonomous scope in response to failures.

Fun Note: The Infinite Refactor

A common failure mode in early autonomous systems was the "infinite refactor": the agent would be asked to fix a bug, notice that the surrounding code was poorly structured, refactor it, discover new issues in the refactored code, refactor those, and continue until it had rewritten half the repository. The fix was simple: count the number of files changed, and if it exceeds the limit, stop the agent and show the human what happened. Most "one-line bug fixes" do not require touching 47 files.

5. Integration with the Discovery Workbench

The autonomous factory extends the Discovery Workbench (introduced in Chapter 6) with a continuous development module. This module connects the factory's pipeline stages to the Workbench's existing components:

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

Build a working autonomous triage and verification pipeline on your laptop using only Python standard libraries and pytest. Step 1: Create a small Python project with three files: src/math_utils.py (a module with two functions, one containing an intentional bug), tests/test_math.py (pytest tests covering the correct function but not the buggy one), and issues.json (a list of three mock issues, each with a title, body, and labels array). Step 2: Implement the TriageClassifier from this section and run it against your three mock issues. Print the routing decision for each and verify that an issue mentioning "security" routes to HUMAN while one labeled "typo" routes to AUTONOMOUS. Step 3: Implement a simplified VerificationPipeline that runs pytest and counts pass/fail results. Run it against your project and confirm it detects the untested buggy function by adding a test that exercises the bug. Step 4: Wire the two together: read an issue from issues.json, triage it, and if the route is AUTONOMOUS, run the verification pipeline. Print a final report showing the triage decision, test results, and whether the pipeline would merge or escalate. Step 5: Add a TriageLearner that records outcomes from step 4 into a JSON file. Run the pipeline on all three issues, then reload the learner and print which categories (based on the first label of each issue) have enough data to qualify for autonomous routing. Observe that with only one observation per category, none qualify (the minimum is 10).

Lab: Triage Learner Convergence

Goal: Observe how the Beta-distribution triage learner graduates task categories from supervised to autonomous routing as evidence accumulates, and measure how the prior and safety threshold affect convergence speed.
Tools needed: Python 3.10+, only standard libraries (math, random, json).
Setup: Implement the TriageLearner class from this section. Create a simulator that generates synthetic outcome records for three task categories: "typo-fix" (95% agent success rate), "refactor" (75% success rate), and "new-feature" (50% success rate). For each category, sample 100 binary outcomes from its true success rate using random.random().
What to vary: (1) Change min_observations from 5 to 30 in steps of 5. (2) Change max_failure_rate from 0.01 to 0.10. (3) Swap the prior from Beta(1,1) to Beta(1,5) and Beta(5,1). For each configuration, record the observation count at which each category first passes should_allow_autonomous (or "never" if it does not pass within 100 observations).
What to observe: The "typo-fix" category should graduate quickly under all settings, "refactor" should graduate only with lenient thresholds, and "new-feature" should rarely graduate. Plot (or print) a table of graduation points across configurations. Notice how the pessimistic prior Beta(1,5) delays graduation by roughly the number of extra prior failures, while Beta(5,1) can dangerously accelerate it for unreliable categories.

Exercises

  1. Conceptual. The triage classifier uses keyword matching for critical patterns (e.g., "security", "payment"). Describe two scenarios where this approach produces false positives (routing safe tasks to humans unnecessarily) and two where it produces false negatives (missing genuinely dangerous tasks). Propose an improvement that addresses each failure mode.
  2. Coding. Implement a VerificationPipeline that runs three checks (pytest, ruff, and a custom "no print statements in production code" check) and produces a VerificationReport. Test it against a small repository with intentional failures in each category.
  3. Analysis. The composite reward formula weights review signals at 0.3 and production signals at 0.25. Argue for or against reversing these weights. Under what circumstances would production signals deserve higher weight? Consider the trade-off between signal reliability and signal latency.