Dispatch-contract
Feiten over dispatch/dispatch.sh en het gerenderde Job-manifest
(dispatch/job-template.yaml). Eén rol-run = één K8s-Job in namespace agents.
De uitkomst komt uit Job.status.conditions, niet uit de pod-exit-code.
Aanroep
dispatch.sh <rol> <change> <repo> [run-id]
| Argument | Verplicht | Betekenis |
|---|---|---|
<rol> |
ja | architect | builder | reviewer | security |
<change> |
ja | change-naam (map onder openspec/changes/) |
<repo> |
ja | doelrepo (owner/repo of URL) |
[run-id] |
nee | default <rol>-<change-geslugd>-<YYYYmmdd-HHMMSS> |
De worker dwingt per rol een settings-allowlist (worker/settings/<rol>.json,
deny-by-default via --permission-mode dontAsk) en een output-JSON-schema
(worker/schemas/<rol>.json) af; zie roles.md.
Environment-variabelen
| Variabele | Default | Rol |
|---|---|---|
WORKER_IMAGE |
— (verplicht) | image per commit-SHA, bv. ghcr.io/mwest2020/habitat-worker:<sha>; nooit latest |
KUBECTL |
kubectl |
kubectl-binary/-wrapper; mag een remote wrapper zijn (KUBECTL="ssh <host> kubectl") — zie Remote kubectl |
HABITAT_BASE_BRANCH |
leeg | basisbranch voor de run |
HABITAT_MAX_BUDGET_USD |
5.00 |
kostendrempel per change |
ACTIVE_DEADLINE_SECONDS |
1800 |
activeDeadlineSeconds op de Job |
PAT_SECRET |
pat-node-01 |
secret met GIT_PAT (git-auth over HTTPS) |
HABITAT_LOGDIR |
./run-logs |
doelmap voor gearchiveerde logs |
CLAUDE_CREDS_FILE |
leeg | alleen voor het oude pad 2 hieronder: bron voor de claude-credentials-sync per dispatch (subscription-token verloopt ~8u). Leeg = geen sync; de worker gebruikt dan claude-oauth-token. Het bestand gaat via stdin naar kubectl, dus het werkt ook met een remote KUBECTL (tot 2026-09-26 las kubectl het pad op de remote host en brak de dispatch af) |
Worker-auth
De entrypoint kiest, in deze volgorde:
CLAUDE_CODE_OAUTH_TOKENuit het optionele Secretclaude-oauth-token(sleuteltoken). Eenclaude setup-token-token: subscription-auth die een jaar geldig is, dus geen sync per dispatch en geen verlopen token halverwege een keten. Aanmaken op een host met browser-toegang:
claude setup-token
kubectl -n agents create secret generic claude-oauth-token --from-literal=token=<token>
Dat is sinds 2026-09-26 de standaard: CLAUDE_CREDS_FILE staat leeg en het
Secret claude-credentials mag weg; beide mounts zijn optional.
2. Gemounte sessie-credentials (claude-credentials) — het oude
sub-first-pad, alleen vers als je dispatch.sh met
CLAUDE_CREDS_FILE=~/.claude/.credentials.json draait.
3. ANTHROPIC_API_KEY — Console-key, verloopt niet, maar rekent per token
af buiten het abonnement om.
Workers refreshen nooit zelf: een refresh in een wegwerp-Job kan de refresh-token roteren zonder te persisteren, wat bij parallelle runs racet. Vandaar dat optie 1 de voorkeur heeft boven een steeds opnieuw te synchroniseren sessie-token.
Het script exporteert daarnaast JOB_NAME, HABITAT_ROLE, HABITAT_CHANGE,
HABITAT_REPO en HABITAT_RUN_ID naar het manifest (via envsubst).
Job-conventies
- Naam:
habitat-<rol>-<change-geslugd>-<epoch>. - Labels:
app.kubernetes.io/part-of: habitat,habitat/component: worker(selecteert de worker-egress-CiliumNetworkPolicy),habitat/role: <rol>,habitat/run-id: <run-id>. - Isolatie:
serviceAccountName: role-<rol>,automountServiceAccountToken: false,backoffLimit: 0,restartPolicy: Never,ttlSecondsAfterFinished: 3600, capabilitiesdrop: ["ALL"],allowPrivilegeEscalation: false.
Test database (worker-test-database)
Every worker Job has a native sidecar testdb: postgres:16-alpine (pinned
by digest, the image wordsworth's CI uses) on an emptyDir, listening on
127.0.0.1 only. Its pg_isready startup probe holds the worker until the
database answers, and it stops with the worker. It runs as uid 70 with every
capability dropped.
The worker gets the URL twice, with the same value:
HABITAT_TEST_DATABASE_URL, the generic name;WORDSWORTH_TEST_DATABASE_URL, the name wordsworth'stests/conftest.pyreads, so its DB tests run instead of skipping.
Why: on 2026-10-24 the wordsworth change pilot-ingest-manifest went through
the chain three times with every DB test skipped in the builder. One build
failed only in CI, on a DB assertion no role could have run. The next was
failed by its reviewer: "written but never run". Live proof on the cluster:
a probe Job from this template queried PostgreSQL 16.15 through the
sidecar and completed.
Deadline per role (builder-deadline)
Without an explicit ACTIVE_DEADLINE_SECONDS, a builder Job gets 3600 s and
the other roles 1800 s; dispatch waits the deadline plus 600 s for an outcome.
Since the worker has a test database the builder runs a repository's DB
tests, and on 2026-10-24 a wordsworth builder hit 1800 s
(DeadlineExceeded, wordsworth#221).
The structured answer is final (verdict-is-final)
Roles run under --json-schema, so their first StructuredOutput call is the
answer and ends the run. The role prompt says so: give it once, at the end,
never as a trial. On 2026-10-24 an architect tried out the schema with a call
whose summary read "Test call to isolate schema issue…", and the chain
stopped on that (wordsworth#221).
Who pays: HABITAT_AUTH (worker-api-auth)
HABITAT_AUTH=subscription (the default) runs the worker on the setup-token
Secret claude-oauth-token, with claude-credentials as the optional mount.
HABITAT_AUTH=api runs it on Mark's API credits: ANTHROPIC_API_KEY from
Secret habitat-api-key (key key), and the other two point at an absent
Secret, so the worker cannot fall back to the subscription. ratatoskr sets
api mode for the repositories in its api_repos (convergence). Proven with a
probe Job: apiKeySource: ANTHROPIC_API_KEY, no setup-token in the env.
The builder runs Node projects too (builder-runs-npm)
Besides npm ci, npm test * and bash verify.sh, the builder may run
npm install (to write a lockfile and add dependencies), npm test and
node --test. Without npm install a new package has no lockfile, npm ci
fails, and on 2026-10-24 the convergence builder read that as a sandbox that
refuses all code and handed in tests that never ran.
The builder's own budget (builder-budget)
HABITAT_BUILDER_BUDGET_USD, when set, is the builder's --max-budget-usd;
the other roles keep HABITAT_MAX_BUDGET_USD. convergence builders hit the
shared $5 at 127-138 turns while no other role passed $1.03 (2026-10-24).
One retry on a failed structured answer (retry-structured-output)
A role whose run ends on error_max_structured_output_retries (the model
could not fit its answer into the role's schema) is dispatched once more by
chain.sh; only if that fails too does the chain stop. On 2026-10-24 three
chains stopped on it, one of them after architect, builder and reviewer had
passed.
Roles come from the handbook (roles-from-handbook)
Mark, 2026-10-25: "the cleanest is all looks at the handbook and there is where
we have or index our agents and skills, consumed by habitat for whatever
project". A run's role file is the handbook's
docs/agents/seeds/<role>.md at a pinned commit:
| Env | Meaning |
|---|---|
HABITAT_HANDBOOK_REF |
the handbook commit (7-40 hex). Set by the caller (ratatoskr's factory.yml); dispatch.sh passes it into the Job. |
HABITAT_HANDBOOK_BASE |
optional, default https://raw.githubusercontent.com/MWest2020/handbook; tests use a file:// tree. |
The worker fetches <base>/<ref>/docs/agents/seeds/<role>.md
(worker/role-seed.sh, python3 urllib, the image has no curl) to
/work/role/<role>.md, outside the work tree, so the entrypoint's
git add -A never commits it. Model and skills come from its front matter as
before; its body is inlined into the role's prompt.
- Ref set: a ref that is not a SHA, a role name that is not a slug, a 404,
or a file without front matter stops the run (exit 1,
fail closedin the log). It never falls back to a repo copy. - Ref unset: the target repo's own
.claude/agents/<role>.md, with aWARNINGin the log. A migration fallback only; once every caller sets the ref, repo copies go. - A role the handbook has no seed for cannot run with the ref set. On 2026-10-25 the handbook seeds are builder, reviewer and security; architect has none yet, so a chain with an architect needs that seed first.
Tests: bash worker/test-role-seed.sh (CI step role-seed-tests).
Branch- en run-report-conventie
- De worker pusht een run-unieke branch
habitat/<rol>/<change>-<run_id>in de doelrepo (nooit force, nooit destructief — een retry overschrijft de vorige run niet).dispatch.shprint de landende branch als[dispatch] branch=<naam>. - Naast de code staat
run-report.jsonop die branch. Dediff_hashdaarin dekt uitsluitend de agent-codewijziging; exact de door habitat gegenereerde artefacten van de run vallen erbuiten — op vaste naam plus de run-id, zónder wildcards (.habitat/audit.jsonl,.habitat/run-report.json,.habitat/run-report-<run-id>.html,.habitat/run-output-<run-id>.md). Niet de hele.habitat/-map en geen naam-wildcard, zodat élk ander bestand dat de agent onder.habitat/schrijft wél meetelt (geen smokkelkanaal). Het exacte reproductie-commando (mét de concrete run-id) staat in het velddiff_hash_scopevanrun-report.json.
Die lijst noemt precies de paden die habitat zelf schrijft, en geen pad
meer, en dat is in beide richtingen load-bearing. Ontbreekt er één — zoals
.habitat/run-report.json tot 2026-09-13 — dan wijkt de hash die het rapport
meldt af van de hash die je vanaf de branch narekent, en meldt de verificatie
manipulatie terwijl er niets aan de hand is. Staat er één te veel — zoals
run-report.json in de repo-wortel, waar habitat sinds
move-run-artifacts-out-of-git niets meer neerzet — dan is dat vrije
ongehashte ruimte: de agent maakt dat bestand aan, het wordt gecommit, en het
valt buiten diff_hash.
- De agent-eind-uitvoer (result) staat als .habitat/run-output-<run-id>.md
op de branch. Habitat schrijft dit bestand altijd — ook zonder leesbaar
result (dan een placeholder) — ná de diff-hash, zodat het artefact
deterministisch habitat-eigendom is en een agent er geen eigen versie voor kan
smokkelen. Een onleesbare .habitat/audit.jsonl-regel is fail-closed: het
rapport crasht niet en toont zo'n regel als expliciet gebroken rij.
- Reviewer/security bouwen op de builder-branch via
HABITAT_BASE_BRANCH. Gebruik chain.sh (zie hieronder) zodat die branch
automatisch wordt doorgegeven; handmatig kan ook met de naam uit de
builder-dispatch-output.
- Merges naar main doet altijd Mark; dispatch merget nooit.
Volle keten: chain.sh
dispatch/chain.sh <change> <repo> draait architect → builder → reviewer →
security en geeft de run-unieke builder-branch automatisch door aan reviewer en
security (HABITAT_BASE_BRANCH). Zelfde env als dispatch.sh (WORKER_IMAGE
verplicht).
Uitkomst
dispatch.sh wacht op een terminale Job.status.conditions (Complete
of Failed) en vertaalt die naar een exit-code. De wachttimeout is
ACTIVE_DEADLINE_SECONDS + 600s — ruim genoeg voor scheduling en een cold
image-pull, zodat een trage start niet als "onbekend" eindigt.
| Conditie | Melding | Exit |
|---|---|---|
Complete |
AFGEROND — lees run-report.json op branch habitat/<rol>/<change>-<run_id> |
0 |
Failed (DeadlineExceeded) |
TIME-OUT — branch mogelijk deels/niet gepusht |
1 |
Failed (overig) |
MISLUKT (<reason>) |
1 |
| geen conditie binnen de timeout | GEEN uitkomst binnen <n>s |
2 |
De logs worden gestreamd (kubectl logs -f zodra de pod draait) en
gearchiveerd naar $HABITAT_LOGDIR/<job-naam>.log.
Run-artefacten: lokaal, niet in git
De Job schrijft run-report-<id>.html, run-output-<id>.md, run-report.json
en audit.jsonl naar /work/artifacts — buiten de working tree. De gepushte
branch bevat dus alleen de wijziging van de agent.
De artefacten bereiken de operator via het Job-log: de worker print ze als
<naam> <base64> tussen ===habitat-artifact-begin=== en
===habitat-artifact-end===, en dispatch.sh knipt ze eruit naar
$HABITAT_LOGDIR/artifacts/<repo>/<run_id>/. Via het log en niet met
kubectl cp, omdat de pod na de Job-TTL weg is en cp breekt met een remote
KUBECTL (zie Remote kubectl).
Retentie: 14 dagen. dispatch.sh ruimt bij elke run artefactmappen en
*.log op die ouder zijn. Wil je iets bewaren, haal het dan binnen die termijn
weg — er is geen tweede kopie, en in git komt het niet.
Een oudere worker-image zonder markers levert geen artefacten op; dispatch merkt dat en gaat door zonder fout.
Remote kubectl
Draai je vanaf een host zonder cluster-toegang, dan is KUBECTL een wrapper:
KUBECTL="ssh -o BatchMode=yes <host> kubectl"
Let op: zo'n wrapper geeft het commando als één string door, waarna de shell op
de doelhost het opnieuw parseert. Argumenten met spaties, haakjes of
aanhalingstekens overleven dat niet. Daarom leest dispatch.sh de
Job-condities via -o json + een lokale python3-filter (job_conditions())
in plaats van een jsonpath met [?(@.status=="True")]: die template gaf op de
doelhost syntax error near unexpected token '(', 2>/dev/null slikte de fout
op, en elke run — ook een geslaagde — eindigde als GEEN uitkomst met exit
2 (waargenomen 2026-09-05 op een reviewer-run die PASS was).
Bouw je hier iets bij, houd kubectl-argumenten dan vrij van spaties en
aanhalingstekens, of haal -o json op en filter lokaal.
Runbook: cluster-livetests (add-role-architecture 3.1–3.3)
Draai dit op een host met kubectl-toegang tot het agents-cluster en een
verse Claude-sessie (voor de credentials-sync). Plak blok voor blok.
0. Pre-flight — image-tag en variabelen
De worker-image is per commit-SHA getagd (worker-image.yml → github.sha).
Pak de SHA van de laatste geslaagde build i.p.v. te gokken:
# vanuit een clone van MWest2020/habitat, op main
WORKER_SHA=$(gh run list --workflow worker-image.yml --branch main \
--status success --limit 1 --json headSha -q '.[0].headSha')
export WORKER_IMAGE="ghcr.io/mwest2020/habitat-worker:${WORKER_SHA}"
export CHANGE="livetest-$(date +%Y%m%d)" # naam van de test-change op de testrepo
export REPO="MWest2020/habitat-testrepo"
echo "image=$WORKER_IMAGE change=$CHANGE repo=$REPO"
# sanity: de worker-code-inhoud onder test zit in commit 2f2be16 (laatste worker/**).
kubectl -n agents get sa,role,rolebinding | grep -i architect # role-architect RBAC aanwezig?
3.1 + 3.3 — volle keten via chain.sh
dispatch/chain.sh "$CHANGE" "$REPO"
Draait architect → builder → reviewer → security en threadt de run-unieke
builder-branch automatisch naar reviewer en security. Verwacht: architect →
AFGEROND, schone boom (diff_hash = leeg-hash); builder → AFGEROND,
GREETING.md, Stop-hook-verify geslaagd; reviewer/security → AFGEROND met
diff_hash = leeg-hash (nul writes → read-only afgedwongen). Verdict-propagatie:
een rol-FAIL zet VERDICT=failed → Job Failed.
Losse rollen kan ook met dispatch.sh <rol> <change> <repo>; pak dan de
builder-branch uit [dispatch] branch=… en geef die als HABITAT_BASE_BRANCH
aan reviewer/security.
Extra check — Stop-hook blokkeert een builder met falende verify: draai de
builder met HABITAT_BASE_BRANCH=test/failing-verify (een branch waarvan
scripts/verify.sh faalt) en verwacht Job Failed.
3.2 — idempotentie-smoke
dispatch/dispatch.sh builder "$CHANGE" "$REPO" # run-a
dispatch/dispatch.sh builder "$CHANGE" "$REPO" # run-b (andere run-unieke branch)
# vergelijk het diff_hash-veld in run-report.json op beide branches → gelijk
Na groene 3.1–3.3: vink ze af in
openspec/changes/add-role-architecture/tasks.md, draai
openspec validate add-role-architecture en archiveer.
Optionele taak-referentie (amend-worker-task-ref)
HABITAT_TASK_REF (optioneel) = een pad relatief aan de doelrepo-root naar een
taak/context-bestand. De worker leest het ná de checkout uit de gecloonde repo en
voegt de inhoud als context aan de rol-prompt toe — dit is git, geen nieuw
invoerkanaal. Pad-begrenzing binnen de repo; een absoluut pad, .., of een
niet-bestaand bestand is fail-closed (exit, geen run). Gebruikt door de
boomhuis-brug (add-habitat-bridge): de orchestrator commit het taakbestand in de
doelrepo en geeft het pad mee.