6.6 KiB
title, created, updated, type, project, tags, sources, related
| title | created | updated | type | project | tags | sources | related | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Builder Contract | 2026-06-23 | 2026-06-23 | concept | damascus-orchestrator |
|
|
|
Code Builder — Contract
What the code builder (Loop B) takes in, what it must produce, and how the reviewer resumes from its output. This is the contract the design doc §4 Loop B and §6 promise; the E2E suite tests adherence to it.
Input (what the agent receives)
The builder reads the row from work_items where phase='build' (atomic claim via SELECT ... FOR UPDATE SKIP LOCKED).
| Field | Source | Required? |
|---|---|---|
id |
work_items.id |
yes |
project |
work_items.project |
yes |
story_id |
work_items.story_id |
yes |
file_scope |
work_items.file_scope (from spec-refiner) |
yes |
spec_path |
work_items.spec_path |
yes |
base_commit |
work_items.base_commit (the main tip) |
yes — set by claim |
attempts |
work_items.attempts |
yes |
budget_cycles |
work_items.budget_cycles |
yes |
| Spec file contents | loaded from spec_path |
yes |
## TDD Plan |
parsed from spec | yes |
## Test Command |
parsed from spec | yes |
What the builder must do (sequentially, no skipping)
- Worktree isolation (design doc §6 Layer 1): create a git worktree off
base_commitat/workspace/worktrees/<story_id>, branchfeat/<story_id>. This is physical isolation — concurrent builders cannot overwrite each other. - Hand the spec to Claude Code via LiteLLM, model
minimax-m3:Critical:ANTHROPIC_BASE_URL=http://host.docker.internal:4000 ANTHROPIC_API_KEY=sk-dummy claude --print --model minimax-m3 --max-turns 50+ <full spec as prompt>--max-turns 50+is the realistic starting point for non-trivial component work. 12 turns is too few; the model burns turns reading files. - Constrain Claude's filesystem access to the worktree and the
file_scopepaths. Files outside the declared scope → reject (defense in depth; the spec refiner should not have allowed them, but verify). - Run the spec's
## Test Commandin the worktree. The TDD plan says tests must fail before code exists; after Claude writes, they must pass. - Rebase onto the project's main branch before opening a PR. Branch detection: try
main,master,developin order. The current default for wh40k-pc ismaster. Rebase conflict → verdictrebase_conflict, route back to builder with the conflict as context. - Commit + push the branch, then open a PR via Gitea API:
POST /api/v1/repos/<owner>/<repo>/pulls { head: feat/<story_id>, base: master|main, title: <story title>, body: <spec link> } - Persist the
pr_urlon the row:UPDATE work_items SET phase = 'review', branch = 'feat/<story_id>', pr_url = '<url returned by Gitea>', base_commit = '<new main tip after rebase>', updated_at = NOW() WHERE id = '<row id>' - Emit
events_outboxevents:build.committed(aftergit commit)build.pushed(aftergit push)build.pr_opened(after Gitea API returns 201)phase.transitioned(build → review)
Side effects on the row
On success:
phase = 'review'branch = 'feat/<story_id>'pr_url = <real Gitea PR URL>← MUST be non-null before transitionbase_commit = <sha after rebase>last_verdictnot set yet (the reviewer will set it)attemptsdoes NOT increment on success
On tests_failed (Claude wrote code, tests don't pass):
phase = 'build'(stays — retry)attempts++last_verdict = 'tests_failed'last_feedback = { "test_output": "...", "files_changed": [...] }- The worktree persists so the next attempt can read the partial work
On rebase_conflict:
phase = 'build'attempts++last_verdict = 'rebase_conflict'last_feedback = { "conflicting_files": [...], "their_sha": "..." }
On no_pr (defensive verdict — Claude claimed success but no PR exists):
phase = 'build'attempts++last_verdict = 'no_pr'- This verdict was added after observing the bug where the build phase returned success-without-opening-a-PR. The reviewer must never merge a row with
pr_url IS NULL.
On attempts >= budget_cycles:
phase = 'blocked'events_outboxemitsblocked.exhausted- The row is parked; surfaced to the human
Files-touched audit (the E2E test point)
The builder MUST record every file it modified, in last_feedback.files_changed. The reviewer and the metrics analyzer both consume this. This is the data the scope-disjoint check (§6 Layer 2) runs against.
Acceptance criteria (the contract)
- ✅ Builder claims a row in
buildphase viaSELECT ... FOR UPDATE SKIP LOCKED - ✅ Builder creates a worktree on a unique branch per story
- ✅ Builder invokes Claude Code with the spec as prompt, model
minimax-m3, max-turns ≥ 50 - ✅ Builder runs the spec's
## Test Commandand only succeeds on exit 0 - ✅ Builder rebases onto the project's main branch (
main→master→developin order) - ✅ Builder opens a real Gitea PR via the Gitea API (not just local commit)
- ✅ Builder persists
pr_urlon the row BEFORE transitioning toreview - ✅ Builder records
files_changedinlast_feedbackfor every file it touched - ✅ Builder NEVER sets
phase='review'withpr_url IS NULL(this was the bug) - ✅ On test failure:
attemptsincrements,last_verdict='tests_failed', row stays inbuild - ✅ On budget exhaustion:
phase='blocked', no further attempts - ✅ Concurrent builders on different stories cannot overwrite each other (worktree isolation)
How the next phase resumes
The reviewer, when it claims a row in review:
- Reads
work_items.pr_url→ fetches the PR diff from Gitea API - Reads
work_items.branch→ knows which branch to validate - Reads
work_items.base_commit→ knows whatmaster|maintip the rebase was tested against - Re-runs
## Test Commandin the worktree to confirm - Looks up the spec from
work_items.spec_pathto know what was promised
If pr_url IS NULL → reviewer MUST return verdict no_pr and NOT merge. This is the defensive guard against the bug.
Cross-references
- damascus-orchestrator-overview
- spec-refiner-contract — what feeds the builder
- reviewer-contract — what the builder hands off
- state-resume-protocol — general resume rules
- builder-concurrency-model — Layer 1 (worktree isolation) + Layer 2 (scope-disjoint dispatch)