Dylan Matthews · SDLC Dispatcher

One bug report, followed through every gate

The dispatcher turns approved bug reports into code changes and checks each one independently. A fix only counts if its new test fails on the original code. Each fix is reviewed by a separate model, and the result is a draft pull request that a person has to accept. Every event, log and verdict below was exported from a real queue. None of it was written by hand.

Where the boundaries are

Generated code runs in a container that has no credentials to publish anything. The model can't publish, and the publisher never runs generated code.

SDLC Dispatcher architecture A trusted host holds intake, the SQLite queue, the worker controller, the review controller and the publisher. The controller launches disposable Docker containers for the coding worker and for offline checks. Only the coding container reaches a model API, through a provider-only proxy. Only the publisher talks to GitHub, and it opens a draft pull request that a human must accept. Trusted operator host Disposable Docker · untrusted code Linear webhookor JSON report SQLite queueapproval, audit Controllerpolicy, snapshots Orchestrator≤ 2 repair rounds Publisherrechecks, then writes Coding workerread-only root, non-root Offline checksbaseline · regression · fresh Model API Provider-onlyCONNECT proxy GitHubdraft PR Humanaccepts or not
The dashed loop is review sending blocking findings back for repair. Offline checks have no network at all.

The run

Two real runs, side by side at each stage.

Demo run

The README's credential-free quick start, run for this page on 02 Oct 2026, 15:51:35 UTC. It's fully reproducible, makes no model calls, and stops before review.

Live run · DYL-11

A real Deck Lab report from the pilot on 08 Sep 2026, 22:49:06 UTC. It went the whole way, to a draft PR that was later merged.

  1. A report arrives

    In
    A title and description from Linear or a JSON file.
    Out
    A durable job with a content revision digest. Editing the report later changes the digest and revokes approval.

    Demo run

    Greeting keeps accidental spaces
    Entering ' Ada ' should greet Ada without surrounding whitespace.

    Job 4373eb32 ingested as needs_review: generic projects wait for a person.

    revision e115fb2397bd8896…

    Live run · DYL-11

    [Bug] Choose/commanders/partner button styling is way off
    Report body, reporter and page path stay private.

    Job acc317d6 ingested as queued: automatic intake was on, and an allowlisted Linear user had moved the issue to the ready state.

    revision 2a286c0cca929c81… · 08 Sep 2026, 22:49:06 UTC

  2. Approval binds the job to a policy

    In
    The queued job and the project's registered TOML policy.
    Out
    An approval recorded against the policy fingerprint. Changing the policy later requires a new approval.

    Demo run

    approved at +1 ms, by the demo itself on the operator's behalf.

    policy 450a9a0d1471b387…

    Live run · DYL-11

    The approval was the intake transition itself. The worker claimed the job 2.9 min later (started).

    policy f2f385b1ed8c8493…

  3. Baseline checks run on the original code

    In
    A snapshot exported from Git at the base commit (committed files only), and the project's check commands.
    Out
    Proof that the starting point is healthy, so later failures belong to the change. If the baseline fails, no agent is launched.

    Demo run

    Base f3f95a108f, image 75da068f73b9, no network.

    baseline-0.log
    test_basic (test_greeting.GreetingTest.test_basic) ... ok
    
    ----------------------------------------------------------------------
    Ran 1 test in 0.000s
    
    OK

    Live run · DYL-11

    Base b36424793f. Four commands: ruff, black, mypy, pytest.

    baseline pytest summary
    1349 passed, 31 skipped, 53 warnings in 39.48s
  4. An isolated worker writes a fix and a new test

    In
    A disposable copy of the snapshot, the report text and the allowed paths.
    Out
    A candidate: changed files only, checked against allowed and protected paths and size limits.

    Demo run

    Engine: demo, a deterministic Python step that applies the fix and writes the test. It spends no model credits and proves nothing about model quality.

    Coding started at +686 ms and was done by +995 ms. Changed: greeting.py, test_regression.py.

    review.diff
    --- a/greeting.py
    +++ b/greeting.py
    @@ -1,2 +1,2 @@
     def greet(name):
    -    return "Hello, " + name
    +    return "Hello, " + name.strip()
    --- /dev/null
    +++ b/test_regression.py
    @@ -0,0 +1,5 @@
    +import unittest
    +from greeting import greet
    +class Regression(unittest.TestCase):
    +    def test_spaces(self):
    +        self.assertEqual(greet(" Ada "), "Hello, Ada")

    Live run · DYL-11

    Engine: cursor, model grok-4.6[effort=high,fast=false], in a non-root, read-only container that could only reach its provider through the proxy.

    Coding took 4.7 min. Changed: src/sabermetrics/ui/static/deck-lab.css, tests/test_layout.py.

    the new test (tests/test_layout.py)
    """Builder chrome layout: the commanders opener must fit the add-cards rail."""
    
    from __future__ import annotations
    
    import re
    from pathlib import Path
    
    ROOT = Path(__file__).resolve().parents[1]
    CSS_PATH = ROOT / "src" / "sabermetrics" / "ui" / "static" / "deck-lab.css"
    BUILDER_TEMPLATE = (
        ROOT / "src" / "sabermetrics" / "ui" / "templates" / "deck_lab" / "builder.html"
    )
    
    
    def _rule_body(css: str, selector: str) -> str:
        match = re.search(re.escape(selector) + r"\s*\{([^}]+)\}", css)
        assert match, f"missing CSS rule for {selector}"
        return match.group(1)
    
    
    def test_choose_commanders_button_is_inset_and_compact_in_add_panel() -> None:
        """The add-rail opener was full-bleed 16px text in a 220–250px column."""
        html = BUILDER_TEMPLATE.read_text()
        assert re.search(
            r'<aside class="dl-add-panel"[^>]*>[\s\S]*?'
            r'<button class="dl-button"[^>]*data-commanders-open[^>]*>'
            r"Choose commanders / partner</button>",
            html,
        )
        body = _rule_body(CSS_PATH.read_text(), ".dl-add-panel [data-commanders-open]")
        assert re.search(r"margin\s*:\s*0\s+14px\s+10px", body)
        font = re.search(r"font-size\s*:\s*(\d+(?:\.\d+)?)px", body)
        assert font is not None and float(font.group(1)) <= 12
        assert re.search(r"white-space\s*:\s*nowrap", body)
        assert re.search(r"flex\s*:\s*none", body)
        min_height = re.search(r"min-height\s*:\s*(\d+(?:\.\d+)?)px", body)
        assert min_height is not None and float(min_height.group(1)) <= 36
  5. The new test must fail on the original code

    In
    The candidate's new test files, copied onto the untouched base snapshot.
    Out
    A failing run. If the test passes on the old code, it doesn't demonstrate the bug, and the job is blocked.

    Demo run

    Exit code 1 on base, which is what the gate requires.

    regression-0.log
    test_basic (test_greeting.GreetingTest.test_basic) ... ok
    test_spaces (test_regression.Regression.test_spaces) ... FAIL
    
    ======================================================================
    FAIL: test_spaces (test_regression.Regression.test_spaces)
    ----------------------------------------------------------------------
    Traceback (most recent call last):
      File "/workspace/test_regression.py", line 5, in test_spaces
        self.assertEqual(greet(" Ada "), "Hello, Ada")
    AssertionError: 'Hello,  Ada ' != 'Hello, Ada'
    - Hello,  Ada 
    ?        -   -
    + Hello, Ada
    
    
    ----------------------------------------------------------------------
    Ran 2 tests in 0.001s
    
    FAILED (failures=1)

    Live run · DYL-11

    Exit code 1 on base. The CSS rule the test looks for doesn't exist yet.

    regression-0.log, failure section
    =================================== FAILURES ===================================
    _______ test_choose_commanders_button_is_inset_and_compact_in_add_panel ________
    
        def test_choose_commanders_button_is_inset_and_compact_in_add_panel() -> None:
            """The add-rail opener was full-bleed 16px text in a 220–250px column."""
            html = BUILDER_TEMPLATE.read_text()
            assert re.search(
                r'<aside class="dl-add-panel"[^>]*>[\s\S]*?'
                r'<button class="dl-button"[^>]*data-commanders-open[^>]*>'
                r"Choose commanders / partner</button>",
                html,
            )
    >       body = _rule_body(CSS_PATH.read_text(), ".dl-add-panel [data-commanders-open]")
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    
    tests/test_layout.py:30: 
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    
    css = ':root {\n  color-scheme: dark;\n  --ink: #0a0d16;\n  --ink-deep: #06080d;\n  --panel: #12172a;\n  --panel-2: #1b2238;...0px; height: auto; }\n.dl-commander-results button { display: block; widt …[truncated]
    selector = '.dl-add-panel [data-commanders-open]'
    
        def _rule_body(css: str, selector: str) -> str:
            match = re.search(re.escape(selector) + r"\s*\{([^}]+)\}", css)
    >       assert match, f"missing CSS rule for {selector}"
    E       AssertionError: missing CSS rule for .dl-add-panel [data-commanders-open]
    E       assert None
    
    tests/test_layout.py:17: AssertionError
  6. Fresh offline verification

    In
    The candidate applied to a new copy of the base, in a new container with no network.
    Out
    Every check passing on code the agent never touched. The agent's own claims of success aren't used.

    Demo run

    verification_started +996 ms, ready +1.6 s.

    check-0.log
    test_basic (test_greeting.GreetingTest.test_basic) ... ok
    test_spaces (test_regression.Regression.test_spaces) ... ok
    
    ----------------------------------------------------------------------
    Ran 2 tests in 0.001s
    
    OK

    Live run · DYL-11

    All four commands exit 0. pytest ran one more test than the baseline: the new one.

    candidate pytest summary
    1350 passed, 31 skipped, 53 warnings in 41.78s
  7. Independent model review

    The reviewer passed this fix and still said its test was weak. That finding went into the pull request.

    In
    A review packet: the diff, surrounding source, policy, check logs and browser evidence, each hashed.
    Out
    A verdict bound to the exact candidate digest. Blocking findings send it back for repair, at most twice.

    Demo run

    The demo stops before this stage: it makes no model calls. Its last pipeline stage is awaiting_approval, with result.json and review.diff ready for review.

    Live run · DYL-11

    pass gpt-6-astra (high effort), 79.4 s, 236,118 input tokens over 28 hashed files.

    The scoped CSS fixes the reported button styling in supplied desktop evidence, preserves the shared button component, and introduces no demonstrated blocking regression.

    low · non-blocking tests/test_layout.py:30

    The test inspects CSS strings rather than rendered geometry. A later overriding rule could restore the defect while this test still passes. The supplied base failure establishes only that the selector was absent.

    Requested: Add browser regression coverage for button containment, spacing, and label overflow in the desktop rail and open mobile panel.

    What the reviewer checked, and what it couldn't
    Coverage
    • Reviewed the packet, policy, complete diff, supplied templates, surrounding CSS, screenshots, browser measurements, and check logs.
    • Desktop screenshots with one and two commanders show the button inset consistently with nearby controls and its label on one line.
    • The change retains shared button styling, native button semantics, hover behavior, and the global focus outline; no new component or architecture boundary is introduced.
    • Single-commander containment and partner overflow measurements are identical before and after; nearby zone overlap is pre-existing.
    • Supplied checks report successful Ruff, Black, mypy, and pytest results: 1350 passed and 31 skipped. No tests were run by this reviewer.
    Limitations
    • Evidence uses synthetic fixtures without production data or external card artwork.
    • Browser evidence covers Chromium at 1440×900 and 390×844; mobile screenshots show the add panel closed and do not validate the changed button.
    • The 220px tablet rail, reported 1440×812 viewport, browser/text zoom, keyboard interaction, and other browsers lack direct evidence.
    • Referenced builder JavaScript is absent from the supplied source subset, so commander-dialog interaction could not be inspected.

    receipt: verdict pass · candidate ed2e0ad49f997943… · review 4e6866e5091fadf7…

  8. A draft pull request, for a person to accept

    In
    The accepted receipt. Before writing, the publisher rechecks issue eligibility, the base commit and the artifact digest.
    Out
    A draft PR. Merging is a human decision. Releasing is a separate, human-approved step.

    Demo run

    No PR: the demo has no GitHub credentials and cannot publish its artifact.

    Live run · DYL-11

    published at +12.4 min from intake. Fix feedback: deck-lab (acc317d6) (#32) opened as a draft at 08 Sep 2026, 23:01:28 UTC, +45 / −0 lines.

    1. 08 Sep 2026, 23:01:28 UTC: opened as a draft by the publisher
    2. 08 Sep 2026, 23:10:54 UTC: marked ready for review from the owner's account, 9.4 min later
    3. 08 Sep 2026, 23:11:02 UTC: merged by DylnMtthws

    The job's audit log has no merge or release action. The dispatcher opened the draft, and a person took it from there.

    Checks on the head commit
    • success container
    • success review
    • success test
    • success dispatcher/astra · Astra High passed this exact verified candidate

When things go wrong

These are the important cases. Each one shows real queue events from the live pilot where they exist, plus the repo tests that pin the behavior. The tests were run for this page: Ran 19 tests in 0.714s, OK, at dispatcher commit c8c4b942a7.

The sandbox can't start, so the agent never runs

This happened while the page was being built. The first demo run on this machine asked Docker for two CPUs, and the VM only has 1. The baseline container never started. The job was blocked before any coding step, and no artifact was produced beyond the failed log.

Queue events, demo attempt 1 (real)
Since firstAudit actionDetail
+0ingestedneeds_review
+1 msapproved
+121 msstarted
+482 msblockedBaseline checks failed; repair the environment or triage existing failures
baseline-0.log
docker: Error response from daemon: range of CPUs is from 0.01 to 1.00, as there are only 1 CPUs available

Run 'docker run --help' for more information

The block message says the baseline checks failed, when Docker actually refused to start the container. The outcome was correct, but the message pointed at the wrong cause. The page's data comes from a second run with that one flag lowered (see method).

Pinned by

Read test_baseline_failure_does_not_launch_agent
def test_baseline_failure_does_not_launch_agent(self):
        runner = self.run_worker("baseline")
        self.assertEqual(len(runner.calls), 1)
        self.assertEqual(self.store.get(self.job)["status"], "blocked")

A report or approval changes underneath a job

Approval belongs to one report revision under one policy. Editing the report, changing the policy, or withdrawing eligibility in Linear cancels queued or running work, and the job has to be approved again. On the release side, a status change is never taken as approval. The release controller needs a signed webhook delivery from the owner.

Real: DYL-15 reached Ready to Deploy without a verified owner webhook
Since firstAudit actionDetail
+0publishedhttps://github.com/DylnMtthws/deck-lab/pull/39
+10.7 minrelease_blockedReady to Deploy was observed without a verified owner webhook. Move the issue back to Ready for Your Review, then select Ready to Deploy again; check webhook delivery if this repeats.
+25.4 minlinear_release_approved{"head": "8e06dc47cb578c1cbf8582c034880f2b78a1f79f"}
+25.5 minrelease_progressvalidating
+25.9 minrelease_progressmerging
+26.5 minrelease_progressmain_ci
+31.9 minrelease_progressdispatching
+32.2 minrelease_progressdeploying
+36.9 minrelease_progressdeploying
+37.0 minrelease_progressdone

The controller refused to act on the status alone and said how to retry. Fifteen minutes later a verified approval arrived, and the release ran through merge, main CI and deploy.

Real: a job whose policy changed and whose eligibility was then withdrawn, before any work started
Since firstAudit actionDetail
+0ingestedqueued
+3.5 spolicy_changed_or_limit_reached
+10.0 sautomatic_approval
+14.1 spolicy_changed_or_limit_reached
+55.0 seligibility_withdrawn

Pinned by

Read test_report_update_cancels_run
def test_report_update_cancels_run(self):
        job = self.enqueue()
        self.store.approve(job, self.project)
        self.store.claim(self.project)
        self.store.ingest(
            self.project, "manual", "1", {"title": "Updated", "description": "Changed"}
        )
        self.assertTrue(self.store.cancelled(job))
        self.store.finish(job, "ready", artifact="unsafe")
        row = self.store.get(job)
        self.assertEqual(row["status"], "cancelled")
        self.assertIsNone(row["artifact"])

Commits move after the evidence was gathered

Evidence is only valid for the commits it was gathered on. The publisher compares the live base branch with the recorded base before any write. The release controller won't merge if the PR head differs from the reviewed head, and it won't deploy if main gained unrelated commits after the merge. A tampered artifact stops publication before any GitHub call.

None of these fired during the live pilot, so this card shows only the tests that pin the behavior.

Pinned by

Read test_moved_base_prevents_writes
def test_moved_base_prevents_writes(self):
        self.client.base_sha = "b" * 40
        with self.assertRaises(DispatchError):
            publish(self.store, self.project, self.job, client=self.client)
        self.assertTrue(all(method == "GET" for method, _, _ in self.client.calls))
        self.assertEqual(self.store.get(self.job)["status"], "blocked")
        self.store.approve(self.job, self.project)
        self.assertEqual(self.store.get(self.job)["status"], "queued")

A provider response is lost

GitHub and SQLite can't commit together. The dispatcher records its intent before each remote write. When the outcome is unclear, it inspects remote state and does not repeat the write.

Real: DYL-12 publication hit an ambiguous write, then reconciled into a single PR
Since firstAudit actionDetail
+0review_completedpass:77198b3a43fd25e8205e7db42a1f1bef2532fa1b5749c938e37314a330186b6f
+2 msready
+241 mspublication_started
+8.3 spublication_needs_attentionInspect publisher auth or reconcile an ambiguous write
+12.6 minpublication_started
+12.7 minpublishedhttps://github.com/DylnMtthws/deck-lab/pull/33

The first publication attempt ended in publication_needs_attention. Twelve minutes later, once the publisher's ten-minute lease had expired, publication ran again. A job left in publishing can only resume in reconcile mode, which looks for an existing branch and PR before creating anything. That job has exactly one pull request, #33.

Real: DYL-14 production deploy failed and was not re-dispatched blindly
Since firstAudit actionDetail
+0linear_release_approved{"head": "f6381bf0ec25ef25811c911676233f16675cd2f1"}
+16.6 srelease_progressvalidating
+40.0 srelease_progressmerging
+77.7 srelease_progressmain_ci
+6.8 minrelease_progressdispatching
+7.1 minrelease_progressdeploying
+8.2 minrelease_retryProduction workflow failed: https://github.com/DylnMtthws/deck-lab/actions/runs/34365278353. Inspect backup and deployment receipt before retrying; the old build may still be live.
+9.3 minrelease_retryProduction workflow failed: https://github.com/DylnMtthws/deck-lab/actions/runs/34365278353. Inspect backup and deployment receipt before retrying; the old build may still be live.
+10.3 minrelease_blockedProduction workflow failed: https://github.com/DylnMtthws/deck-lab/actions/runs/34365278353. Inspect backup and deployment receipt before retrying; the old build may still be live.
+40.1 minlinear_release_approved{"head": "f6381bf0ec25ef25811c911676233f16675cd2f1"}
+41.6 minrelease_progressmain_ci
+41.9 minrelease_progressdispatching
+42.2 minrelease_progressdeploying
+48.3 minrelease_progressdeploying
+48.3 minrelease_progressdone

After the production workflow failed, the controller recorded the failure, stopped, and told the operator to check the backup and deployment receipt. It deployed again only after a new verified owner approval.

Pinned by

Read test_lost_pr_response_reconciles_without_duplicate_creation
def test_lost_pr_response_reconciles_without_duplicate_creation(self):
        self.client.lose_response = True
        with self.assertRaises(DispatchError):
            publish(self.store, self.project, self.job, client=self.client)
        self.assertEqual(self.store.get(self.job)["status"], "publishing")
        with self.assertRaises(DispatchError):
            publish(self.store, self.project, self.job, reconcile=True, client=self.client)
        with self.store.transaction() as db:
            db.execute("UPDATE jobs SET deadline=? WHERE id=?", (time.time() - 1, self.job))
        publish(self.store, self.project, self.job, reconcile=True, client=self.client)
        self.assertEqual(
            sum(method == "POST" and path == "/pulls" for method, path, _ in self.client.calls),
            1,
        )

The reviewer keeps blocking

Blocking review findings go back to the coding worker for repair, at most twice. Every repair is verified fresh and reviewed again, and there is never a third round. In this run, review asked for a human decision twice. The second repair then ran past its deadline, so the job was blocked, not retried.

Before this job started, the queue logged 22 policy changes and 22 re-approvals while the operator was adjusting the registration. It didn't run under a policy that was still changing.

Real: two reviews, two repairs, then blocked with no third round
Since firstAudit actionDetail
+0started
+79.6 scoding_started
+7.5 minverifying
+7.5 minverification_started
+9.6 minreview_started
+9.9 minpreview_preparedfc5146fa8e1a0b6b4e88c311e6c8f3500cb95ae22940551fd2447afd9f19ffb1
+11.6 minreview_completedneeds_human_review:fc5146fa8e1a0b6b4e88c311e6c8f3500cb95ae22940551fd2447afd9f19ffb1
+11.6 minrepair_started1
+11.7 minrepair_started
+23.2 minverifying
+23.2 minverification_started
+25.3 minreview_started
+25.6 minpreview_preparedc6f49c10aabaf66849bb26c3d914f3c2a8d2b09570b12e3acd74d6118181e18d
+26.9 minreview_completedneeds_human_review:c6f49c10aabaf66849bb26c3d914f3c2a8d2b09570b12e3acd74d6118181e18d
+26.9 minrepair_started2
+26.9 minrepair_started
+30.0 minblockedWorker cancelled or deadline exceeded

Pinned by

Read test_two_repair_round_bound_is_enforced
def test_two_repair_round_bound_is_enforced(self):
        runner, reviews = self.exercise(
            [{"verdict": "changes_required", "findings": [self.finding(i)]} for i in range(3)]
        )
        self.assertEqual(len(reviews), 3)
        self.assertEqual(sum(c["phase"] == "agent" for c in runner.calls), 3)
        self.assertEqual(self.store.get(self.job)["status"], "blocked")

How this page was made

Commands, sources and limits.

Generate

docker build -f containers/test.Dockerfile -t sdlc-dispatcher-test:local .
sdlc-dispatcher --state .dispatcher/replay-demo demo

On this host the demo was started through tools/dispatcher-replay/run_demo.py, which changes one thing. The runner hardcodes --cpus=2, and this machine's Docker VM has 1 CPU, so the wrapper lowers that flag to what's available. The first, unmodified attempt is the blocked run shown above.

Export

tools/dispatcher-replay/export.py lives in the site repo, outside the published directory. It reads the demo queue and artifacts, the live job's queue rows and receipts, and the pull request from the GitHub API. It also runs the pinned tests. The page is rendered from run.json by render.py. Exported 02 Oct 2026, 16:01:28 UTC.

Redacted or left out

Local paths, hostnames, private preview links, Linear IDs, email addresses and anything shaped like a token are redacted. The live report's body, its reporter and the browser screenshots stay private. The export refuses to write if any of these patterns survive.

What this does not show

The demo's coding step is a fixed script, so it says nothing about model quality. The live run is one pilot job on a single host. A passing review and test suite are signals, not proof that the change is correct. The live reviewer said so itself.