Ga naar inhoud

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:

  1. CLAUDE_CODE_OAUTH_TOKEN uit het optionele Secret claude-oauth-token (sleutel token). Een claude 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, capabilities drop: ["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's tests/conftest.py reads, 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 closed in the log). It never falls back to a repo copy.
  • Ref unset: the target repo's own .claude/agents/<role>.md, with a WARNING in 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.sh print de landende branch als [dispatch] branch=<naam>.
  • Naast de code staat run-report.json op die branch. De diff_hash daarin 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 veld diff_hash_scope van run-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.