Skip to content

feat: split mode across a VM boundary — a host ledger service over a socket - #173

Merged
geuben merged 88 commits into
mainfrom
worktree-issue-169-ledger-service
Oct 14, 2026
Merged

geuben merged 88 commits into
mainfrom
worktree-issue-169-ledger-service

Conversation

@geuben-dispatch

@geuben-dispatch geuben-dispatch Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Split mode across a VM boundary: a runner inside a disposable guest keeps no ledger. It reads and writes the host's through a unix socket served by a new ledger service (tdd ledger serve), which owns the SQLite files and is their only writer. Implements tasks/issue-169-ledger-service.md (46 cycles, all delivered).

  • One query layer (cycles 1–5). Every ledger access is a named Ledger method. cli, machine, advance, fleet and render no longer speak SQL or import sqlite3, and tests/test_ledger_surface.py guards that. Claims return None on conflict instead of raising IntegrityError, because no sqlite exception can cross a socket. The final close now settles blocked/complete before it ends the run, instead of ending it complete and flipping it to blocked.
  • Schema v13: sources (cycles 6–9). run and plan_contract gain a source column. The baseline/advance claims and the baseline cache are rebuilt keyed by source, so two guests at the same path neither block each other nor share a cached baseline. Existing rows migrate as local. tdd metrics reports each run's source.
  • The service and the remote ledger (cycles 10–28). There is one socket per source plus an admin socket. A connection's source is the listener it arrived on; the protocol has no source field. The wire is newline-delimited JSON carrying {method, args, kwargs}, never SQL. The service enforces:
    • foreign_run: another source's run, cycle or contract, or one that does not exist.
    • run_closed: writes to a complete/abandoned run (add_note is exempt; blocked runs stay writable).
    • method_not_allowed: anything outside the guest registry.
    • bad_repo: a repository path that is not absolute.
    • An executor bound by the operator is stamped on run start and abandonment as operator; an unbound guest's operator claim becomes claimed.
    • An unreachable socket fails the verb with ledger_unreachable. Nothing falls back to a local ledger.
    • Claim liveness is judged in the guest.
    • doctor names the source it reaches.
    • runner import is refused on a socket runner.
    • ledger_socket and ledger_home together are refused.
  • Operator verbs (cycles 29–36): tdd ledger serve|sources|add-source|remove-source|bind. They are always answered locally and never forwarded to a runner. Sources persist in <home>/sources.json. The admin socket is mode 600, guest sockets 660.
  • One worker budget per host (cycles 37–40). A socket-mode runner takes its worker lease from the service. A lease ends when its connection closes, and stale leases are not counted. A guest's fleet reports the host's live leases.
  • One history per host (cycles 41–45): tdd ledger metrics|fleet|log cover every source, and tdd ledger import <file> --source <name> brings an existing ledger in as one source and refuses one the host already holds.
  • Docs (cycle 46): docs/split-runner.md "Across a VM boundary", a README split-mode bullet, and a CHANGELOG entry.

tdd ledger metrics over two sources

Two guest runs in one repository, as vm-1 and vm-2, read on the host through the admin socket:

{
  "ok": true,
  "envelope_version": 1,
  "run": null,
  "result": {
    "repos": {
      "<repo slug>": {
        "runs": [
          {
            "run": 1,
            "plan_contract": 1,
            "source": "vm-1",
            "executor": "pytest-executor",
            "executor_source": "claimed",
            "outcome": null,
            "cycles_declared": 1,
            "cycles_closed": 0,
            "cycles_skipped": 0,
            "red_first_violation_rate": 0.0,
            "impl_attempts_total": 0,
            "impl_attempts_max": 0,
            "cycles_by_project": {
              "backend": 1
            },
            "blockers": {},
            "human_interventions": 0,
            "time": {
              "wall_clock_s": 1.0,
              "suite_s": 0,
              "suite_share": 0.0,
              "by_phase": {}
            },
            "integrity_events": {}
          },
          {
            "run": 2,
            "plan_contract": 2,
            "source": "vm-2",
            "executor": "pytest-executor",
            "executor_source": "claimed",
            "outcome": null,
            "cycles_declared": 1,
            "cycles_closed": 0,
            "cycles_skipped": 0,
            "red_first_violation_rate": 0.0,
            "impl_attempts_total": 0,
            "impl_attempts_max": 0,
            "cycles_by_project": {
              "backend": 1
            },
            "blockers": {},
            "human_interventions": 0,
            "time": {
              "wall_clock_s": 0.0,
              "suite_s": 0,
              "suite_share": 0.0,
              "by_phase": {}
            },
            "integrity_events": {}
          }
        ],
        "note": "Cross-plan aggregates are not comparable: cycle difficulty varies too much. Compare runs of the same contract only (R11.1)."
      }
    }
  },
  "next_action": {
    "verb": "complete",
    "detail": "Metrics computed over every source.",
    "terminal": true,
    "verb_set_version": 2
  }
}

Scope cut: the socket forwarding itself

The real forwarding (an ssh -R of a unix socket into a VM, vsock, or a container bind mount) cannot run in CI: there is no VM or ssh pair to forward through. It is a named scope cut in the plan. The service's side of the boundary is built and tested: a socket per source, its mode, and identity by listener. The tests run the service in-process, and ledger serve as a real subprocess. docs/split-runner.md documents the forwarding setups.

Commits

88 commits (red/green/refactor per cycle, then the friction log and one post-run test commit)
ec923a9 refactor: cli reaches the ledger through named domain methods
c4f648b refactor: the state machine reaches the ledger through named domain methods
ece0dce refactor: the final close settles blocked-or-complete before ending the run
9c55c1b refactor: fleet reads through a read-only Ledger
3e5d56b test: only the ledger module speaks SQL
0c3817e refactor: render reaches the ledger through named domain methods
7c191e2 test: a v12 ledger migrates with its rows tagged local
656bfb8 feat: ledger schema v13 records each run's source
6ace277 test: a source reads only its own runs and contracts
d8accbd feat: source-rooted ledger reads filter by source
ee6d5b5 test: claims and baseline cache are per source
257f69f feat: claims and the baseline cache are keyed by source
fb5c15a test: metrics names each run's source
c1c4e11 feat: metrics names each run's source
c224656 test: a source socket answers ping
b9173ef feat: ledger service listens on a socket per source
cfd2f5b test: every ledger method is classified for guests
3e94cae feat: classify the ledger's methods for guests
fd9465f test: a guest run lands in the host ledger
baf996a feat: a runner with ledger_socket keeps its ledger on the host
1a13225 test: pin a guest renders its own friction log through the socket
871ed14 test: pin a blocked guest run can still be unblocked
78c276d test: an unreachable ledger socket refuses the verb
99ea8d5 feat: fail closed when the ledger service is unreachable
98a7eb9 test: a guest cannot reach another source's run
5796174 feat: the service refuses run ids of other sources
dcbd8d7 test: a guest cannot reach another source's cycle
1309371 feat: ownership checks cover cycle and contract ids
a04d449 test: guests cannot call host-only ledger methods
54f7725 feat: the service dispatches only guest-classified methods
d1ba5a0 test: malformed repo paths are refused
db58c51 feat: the service validates the guest's repo path
1ca5140 test: a completed run is closed to guest writes
ff8368b feat: the service refuses writes to complete or abandoned runs
55fc021 test: notes on a completed run are still accepted
058d55b feat: appending a note is exempt from the closed-run rule
a62d3c1 test: a bound source records the operator's executor
0e851b6 feat: the service stamps a bound source's executor on run start
826d5b1 test: a guest cannot claim operator identity
8ec2def feat: an unbound source records guest identity as claimed
e341028 test: abandonment records the bound executor
574cd5e feat: the service stamps the executor on abandonment
9aa53c0 refactor: tidy abandonment stamping
f8b2982 test: claim staleness is judged where the pid lives
52f1874 feat: the remote ledger judges claim liveness locally
82c3ecc test: runner import is refused when the ledger is remote
e1ecf87 feat: runner import refuses a socket-mode runner
a1567f3 test: ledger_socket and ledger_home are exclusive
14f26f8 feat: refuse a runner config naming both ledger locations
41adac5 refactor: tidy socket-mode doctor
023b577 test: ledger verbs stay local and need a service config
e27e189 feat: tdd ledger verb group, answered locally
77743fd test: add-source opens a guest socket
f9f818c feat: tdd ledger add-source
49d0bfb test: ledger sources lists sources
f8ad925 feat: tdd ledger sources
3d628b7 test: bind changes a source's executor
0294733 feat: tdd ledger bind
5ffde93 test: remove-source closes the guest socket
07e2920 feat: tdd ledger remove-source
314cb3d test: sources survive a service restart
24b050e feat: the service persists its sources
356c568 test: admin and guest socket modes
38838b2 feat: the service sets its sockets' modes
1485bf0 test: ledger serve runs until SIGTERM
345a13f feat: tdd ledger serve
b0e86aa refactor: tidy serve
f52f674 test: worker leases are counted across guests
aa57378 feat: a socket-mode runner takes its worker lease from the service
51e91ae test: a closed lease connection frees its share
4d9cbdc feat: the service forgets a lease when its connection closes
023c90b test: stale leases do not throttle the host
1049762 feat: the service ignores leases past the stale limit
a85a76a test: guest fleet shows host-wide suites
47f6dcd feat: a socket-mode lease snapshot comes from the service
c7cf65c test: host metrics cover every source
0ebcb9a feat: tdd ledger metrics
392dea2 test: host fleet covers every source
859f111 feat: tdd ledger fleet
8936fe7 test: host log renders any source's run
24ee03b feat: tdd ledger log
18a99e9 test: ledger import retags an existing ledger
5f0386a feat: tdd ledger import
83dd475 test: ledger import never overwrites
9d2036c feat: ledger import refuses an existing host ledger
dd6a611 docs: split mode across a VM boundary
59c7e11 docs: friction log for issue-169-ledger-service
0f3023a test: cover the service's error replies, closed-run writes by cycle, and dropped connections

Test coverage note

All decision-point operators in new code are covered by at least one test (pseudo-mutation analysis passed).

The pre-PR pass found reachable branches the run's tests did not catch. Commit 0f3023a adds tests for them:

  • the service's bad-argument and method-raised error replies;
  • a write that names only a cycle_id of a closed run;
  • a session that never opened a ledger closing cleanly;
  • a non-local source reading back its own cached baseline;
  • wire.Row's missing-key IndexError;
  • a dropped connection, both at EOF and as a broken pipe.

Each of those nine mutations was applied and confirmed caught. The pass also found a real bug: Connection.close() after a dropped write re-raised BrokenPipeError from its flush, masking ledger_unreachable. That is fixed in the same commit. Two unreachable branches in _hung_up and the unused Row.__eq__ were removed rather than tested.

Closes #169

Gates run

  • uv run pytest -q (Python 3.14): 715 passed
  • UV_PROJECT_ENVIRONMENT=/tmp/tdd169-py311 uv run --python 3.11 pytest -q: 715 passed
  • uv run ruff check src tests: all checks passed
  • uv run ruff format --check src tests: 107 files already formatted
  • uv build + wheel smoke test (tdd ledger --help from a fresh venv): OK
  • zizmor not run locally: no workflow file changed. macOS legs run in CI only.

Interventions

none. The friction log records 0 human interventions, no blockers, no resume --unblock, no --accept-failures, no skipped cycles and no amended baseline. Deviations from the plan, each recorded as a tdd note in tasks/friction-logs/issue-169-ledger-service-friction.md:

  • Cycle 28 pre-passed. The plan's assertion ("vm-1" in the ledger reachable detail) was already true, because the socket file is named vm-1.sock. I ran the sensitivity check on the test as written (it bit), then in the refactor phase tightened it to "source vm-1" and implemented the planned doctor change. This shows in the friction log as a red_first_violation.
  • Cycle 14 used --kind plan_defect, because the plan's --kind environment is not a blocker kind.
  • Undeclared files:
    • Cycle 18 touched ledger.py: active_claim/active_advance_claim stayed guest reads until cycle 25 moved them to host-only, as cycle 25's expected failure assumes.
    • Cycle 29 touched tests/test_split_client.py: its local-verb guard pinned {docs, init}, and ledger joins it by design. The test is renamed test_only_docs_init_and_ledger_stay_local.
    • Cycle 45 touched cli.py, so an admin refusal (ledger_exists) is reported as the reason.

TDD-Run: 1
TDD-Cycle: 1
TDD-Phase: refactor
…ethods

TDD-Run: 1
TDD-Cycle: 2
TDD-Phase: refactor
…he run

TDD-Run: 1
TDD-Cycle: 3
TDD-Phase: refactor
TDD-Run: 1
TDD-Cycle: 4
TDD-Phase: refactor
TDD-Run: 1
TDD-Cycle: 5
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 6
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 6
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 7
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 7
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 8
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 8
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 9
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 9
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 10
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 10
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 11
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 11
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 12
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 12
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 14
TDD-Phase: pin
TDD-Run: 1
TDD-Cycle: 15
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 15
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 16
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 16
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 17
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 17
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 18
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 34
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 34
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 35
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 35
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 36
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 36
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 36
TDD-Phase: refactor
TDD-Run: 1
TDD-Cycle: 37
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 38
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 38
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 39
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 39
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 40
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 40
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 41
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 41
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 42
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 42
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 43
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 43
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 44
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 44
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 45
TDD-Phase: red
TDD-Run: 1
TDD-Cycle: 45
TDD-Phase: green
TDD-Run: 1
TDD-Cycle: 46
TDD-Phase: refactor
…and dropped connections

Found by the pre-PR mutation pass: a Connection closed after a dropped write raised BrokenPipeError from its flush, masking ledger_unreachable. Removes two unreachable branches in _hung_up and the unused Row.__eq__.
@geuben-dispatch
geuben-dispatch Bot force-pushed the worktree-issue-169-ledger-service branch from 73e2648 to fea8a91 Compare October 14, 2026 19:58
@geuben
geuben merged commit 28b904c into main Oct 14, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Split mode across a VM boundary: the runner writes to a host ledger service over a socket

1 participant