feat(stuck): stuck-state pipeline overlay (cad-atlas#11)
CD / Build & Import (push) Failing after 3s
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Skipped
CD / Deploy via GitOps (push) Skipped

Consumes mathias/cad#14's cad-stuck query directly — cad's app/domain
packages are public Go modules, so this reuses the exact tested
classification logic (import, not re-implementation) rather than
duplicating the taxonomy across two repos.

ApplyStuckOverlay (internal/atlas/stuck.go, fixture-tested) maps each
finding's category onto the existing 9-stage pipeline: armed_never_fired to
the human dispatch gate, claimed_never_ran/ceiling_hit/refire_loop/
near_attempt_cap to the one Execute stage (there is no separate Claim
stage), done_not_closed to the loop-back stage. Adds a StuckCount/
AwaitingCount badge per stage plus one Node per finding, linking out to the
underlying issue/comment — read-only, no controls, matching cad#14's own
constraint.

Two visual classes, kept deliberately distinct per the issue's own framing:
a coral 'N stuck' badge (needs intervention) vs a green 'N awaiting' badge
(a functioning control waiting on a human) — done_not_closed can never
render as a bottleneck.

Verified against the live estate: STAGE 05 shows 2 stuck (cobalt-dingo#30
ceiling_hit, cad-atlas#10 near_attempt_cap), STAGE 08 shows 5 awaiting
(cobalt-dingo #3/#7/#12/#16/#19 done_not_closed) — screenshotted via a
koala k3s Playwright job against a live-token server, matching cad#14's own
proven query output exactly.

go.mod: added git.d-ma.be/mathias/cad v0.10.0 (app/domain/adapters-gitearead
only — no mcp-chassis pulled in, that's only imported by cad's own cmd/cad).
Dockerfile: added the same GOPRIVATE/insteadOf fetch config mathias/cad's
own Dockerfile uses, now needed for this new private-module dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 13:27:02 +02:00
co-authored by Claude Sonnet 5
parent 4ab65a3b1b
commit 97d0f56383
9 changed files with 327 additions and 8 deletions
+3 -3
View File
@@ -29,12 +29,12 @@
{"t":"Admission controller","plain_t":"Tamper-proof seal","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
{"t":"⚖️ var-go Oath","plain_t":"Definition of done","plain":"A machine-checkable \"definition of done\" is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec.","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]}
]},
{"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint",
{"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint","generate":"stuck-badges",
"trans_label":"A human said go","trans":"The hard stop. Nothing crosses automatically — a person must review the plan and risk and explicitly release it, and the repo must be on the allow-list, before any agent starts. This is the single human checkpoint in the whole pipeline.","nodes":[
{"t":"Human triggers execution","plain_t":"The go button","plain":"A person confirms the plan and its risk level, then releases the work — nothing runs until they do.","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true},
{"t":"Session-Dispatch bridge","plain_t":"Hand-off to agents","plain":"The approval flips a switch that hands the signed work order over to the agents to start execution.","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]}
]},
{"no":"STAGE 05","short":"Build","cls":"exec","title":"Execute · agentsquad + dispatch","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail. Two execution paths run today: agentsquad for orchestrated multi-agent jobs, and dispatch for headless single-issue work.","path":"koala · agentsquad-serve + dispatch CronJob",
{"no":"STAGE 05","short":"Build","cls":"exec","title":"Execute · agentsquad + dispatch","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail. Two execution paths run today: agentsquad for orchestrated multi-agent jobs, and dispatch for headless single-issue work.","path":"koala · agentsquad-serve + dispatch CronJob","generate":"stuck-badges",
"trans_label":"Change proposed","trans":"Advances when the agents finish and open a proposed change (a PR) with its audit log attached. Until there's a concrete change to test, nothing moves.","nodes":[
{"t":"Task API","plain_t":"Start a job","plain":"A request kicks off a job and hands back an id you can poll for progress.","pill":"var(--coral)","d":"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).","tags":["single agentsquad.yaml"]},
{"t":"Executor + reviewer loop","plain_t":"Build + independent review","plain":"One agent does the work; a second, independent agent on a different model reviews it — so nothing marks its own homework.","cls":"win","pill":"var(--coral)","d":"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.","risk":true},
@@ -53,7 +53,7 @@
"trans_label":"It's live","trans":"Once the new version is actually running on the server, the deployed outcome becomes the input to scoring. Advancing means shipped and observable, not just merged.","nodes":[
{"t":"Deploy on green","plain_t":"Auto-deploy when green","plain":"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it).","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]}
]},
{"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)",
{"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)","generate":"stuck-badges",
"trans_label":"What did we learn?","trans":"The scored outcome flows back into the mission board so goals, problems, and priorities update — the loop that makes the pipeline a cycle rather than a line. Partly manual today; an explicit improvement target.","nodes":[
{"t":"Close the loop","plain_t":"Score & feed back","plain":"The outcome is scored against the goal that started it and written back to the mission board, so future planning learns from what actually shipped.","pill":"var(--violet)","d":"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)","tags":["continuous"]}
]}
+7 -1
View File
@@ -42,7 +42,13 @@ type Stage struct {
Generate string `json:"generate,omitempty"`
TransLabel string `json:"trans_label,omitempty"`
Trans string `json:"trans,omitempty"`
Nodes []Node `json:"nodes"`
// StuckCount/AwaitingCount are set by ApplyStuckOverlay (cad-atlas#11) on
// stages marked `"generate":"stuck-badges"` — the two visual classes the
// issue asks for kept as separate counts so a functioning control
// (awaiting human) can never be rendered as a bottleneck (stuck).
StuckCount int `json:"stuck_count,omitempty"`
AwaitingCount int `json:"awaiting_count,omitempty"`
Nodes []Node `json:"nodes"`
}
// Atlas is the full data model the frontend renders.
+100
View File
@@ -0,0 +1,100 @@
// stuck.go overlays cad-atlas#11's stuck-state badges onto the pipeline: a
// count badge per stage (Node entries + Stage.StuckCount/AwaitingCount),
// sourced from mathias/cad#14's `cad stuck` query rather than re-implemented
// here. cad's app/domain packages are public Go modules (not internal/) —
// this reuses the exact tested classification logic instead of duplicating
// the taxonomy, per ADR-0002 ("a small coupling surface, not a monolith").
package atlas
import (
"fmt"
caddomain "git.d-ma.be/mathias/cad/domain"
)
// stageForCategory maps a cad-stuck taxonomy row onto the existing 9-stage
// pipeline (atlas.json), following the grouping cad-atlas#11 itself proposes:
// Trigger→armed-but-never-fired sits at the human dispatch gate (the label
// that authorizes dispatch is on, nothing has claimed it yet); Claim/Execute
// collapse into the one Execute stage this pipeline already has (there is no
// separate Claim stage); Close maps to the loop-back stage, where a human
// closing the issue is the action that actually completes the loop.
//
// CategoryDoneNotClosed is deliberately absent from the "stuck" class — see
// ApplyStuckOverlay, which reads Class rather than Category to decide the
// visual treatment. It IS present here for stage placement.
var stageForCategory = map[caddomain.StuckCategory]string{
caddomain.CategoryArmedNeverFired: "STAGE 04",
caddomain.CategoryClaimedNeverRan: "STAGE 05",
caddomain.CategoryCeilingHit: "STAGE 05",
caddomain.CategoryReFireLoop: "STAGE 05",
caddomain.CategoryNearAttemptCap: "STAGE 05",
caddomain.CategoryDoneNotClosed: "STAGE 08",
}
// stuckNodeCls / awaitingNodeCls are the CSS classes the static shell colours
// (cad-atlas.html: .node.stuck, .node.awaiting-human) — the two visual
// classes cad-atlas#11 asks for, kept visually distinct so "awaiting human"
// (a functioning control) never reads as "stuck" (needs intervention).
const (
stuckNodeCls = "stuck"
awaitingNodeCls = "awaiting-human"
)
// ApplyStuckOverlay assigns each finding to the stage stageForCategory names,
// and for every stage in stages marked `"generate":"stuck-badges"`, sets its
// StuckCount/AwaitingCount and prepends one Node per finding assigned there
// (title, class, repo tag, and a URL to the finding's first locator — this
// overlay never adds a control, only a link out to Gitea). Findings whose
// category has no stage mapping are silently omitted from the pipeline view
// rather than misplaced — cad-atlas#11 is a read of the query, not a second
// copy of its taxonomy, so an unmapped category is a gap to notice in
// stageForCategory, not something to guess a stage for.
//
// stages is returned unmodified aside from the stuck-badges stages; this is a
// pure function, no I/O, so it is fixture-testable without a live query.
func ApplyStuckOverlay(stages []Stage, findings []caddomain.StuckFinding) []Stage {
byStage := make(map[string][]caddomain.StuckFinding)
for _, f := range findings {
stageNo, ok := stageForCategory[f.Category]
if !ok {
continue
}
byStage[stageNo] = append(byStage[stageNo], f)
}
out := make([]Stage, len(stages))
copy(out, stages)
for i := range out {
if out[i].Generate != "stuck-badges" {
continue
}
assigned := byStage[out[i].No]
var stuckN, awaitingN int
nodes := make([]Node, 0, len(assigned))
for _, f := range assigned {
cls := stuckNodeCls
if f.Class == caddomain.ClassAwaitingHuman {
cls = awaitingNodeCls
awaitingN++
} else {
stuckN++
}
var url string
if len(f.Locators) > 0 {
url = f.Locators[0]
}
nodes = append(nodes, Node{
Title: fmt.Sprintf("#%d %s", f.Issue, f.Category),
Desc: f.Detail,
Cls: cls,
Tags: []string{"live · cad-stuck", f.Repo},
URL: url,
})
}
out[i].StuckCount = stuckN
out[i].AwaitingCount = awaitingN
out[i].Nodes = append(nodes, out[i].Nodes...)
}
return out
}
+110
View File
@@ -0,0 +1,110 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
caddomain "git.d-ma.be/mathias/cad/domain"
)
func stage(no, generate string) atlas.Stage {
return atlas.Stage{No: no, Title: "example", Generate: generate}
}
func finding(repo string, issue int, cat caddomain.StuckCategory, class caddomain.StuckClass) caddomain.StuckFinding {
return caddomain.StuckFinding{
Repo: repo, Issue: issue, Title: "example", Category: cat, Class: class,
Detail: "detail",
Locators: []string{"https://git.d-ma.be/" + repo + "/issues/1"},
}
}
func TestApplyStuckOverlay_BadgesOnlyOnGeneratedStages(t *testing.T) {
stages := []atlas.Stage{
stage("STAGE 04", ""), // no generate marker — must be untouched
stage("STAGE 04b", "stuck-badges"), // marked but no findings map here
}
out := atlas.ApplyStuckOverlay(stages, nil)
if len(out[0].Nodes) != 0 || out[0].StuckCount != 0 {
t.Fatalf("un-marked stage was touched: %+v", out[0])
}
}
func TestApplyStuckOverlay_ArmedNeverFiredGoesToStage04(t *testing.T) {
stages := []atlas.Stage{stage("STAGE 04", "stuck-badges")}
findings := []caddomain.StuckFinding{
finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck),
}
out := atlas.ApplyStuckOverlay(stages, findings)
if out[0].StuckCount != 1 || out[0].AwaitingCount != 0 {
t.Fatalf("counts = stuck=%d awaiting=%d, want stuck=1 awaiting=0", out[0].StuckCount, out[0].AwaitingCount)
}
if len(out[0].Nodes) != 1 {
t.Fatalf("want 1 node, got %d", len(out[0].Nodes))
}
n := out[0].Nodes[0]
if n.Cls != "stuck" {
t.Errorf("Cls = %q, want %q", n.Cls, "stuck")
}
if n.URL == "" {
t.Error("node has no URL locator")
}
if n.Title != "#190 armed_never_fired" {
t.Errorf("Title = %q", n.Title)
}
}
func TestApplyStuckOverlay_DoneNotClosedIsAwaitingHumanAtStage08(t *testing.T) {
stages := []atlas.Stage{stage("STAGE 08", "stuck-badges")}
findings := []caddomain.StuckFinding{
finding("mathias/cobalt-dingo", 19, caddomain.CategoryDoneNotClosed, caddomain.ClassAwaitingHuman),
finding("mathias/cobalt-dingo", 16, caddomain.CategoryDoneNotClosed, caddomain.ClassAwaitingHuman),
}
out := atlas.ApplyStuckOverlay(stages, findings)
if out[0].StuckCount != 0 || out[0].AwaitingCount != 2 {
t.Fatalf("counts = stuck=%d awaiting=%d, want stuck=0 awaiting=2 — done_not_closed must never render as stuck", out[0].StuckCount, out[0].AwaitingCount)
}
for _, n := range out[0].Nodes {
if n.Cls != "awaiting-human" {
t.Errorf("node Cls = %q, want %q", n.Cls, "awaiting-human")
}
}
}
func TestApplyStuckOverlay_ExecutionCategoriesCollapseToStage05(t *testing.T) {
stages := []atlas.Stage{stage("STAGE 05", "stuck-badges")}
findings := []caddomain.StuckFinding{
finding("mathias/cobalt-dingo", 30, caddomain.CategoryCeilingHit, caddomain.ClassStuck),
finding("mathias/cad-atlas", 10, caddomain.CategoryNearAttemptCap, caddomain.ClassStuck),
finding("mathias/cobalt-dingo", 16, caddomain.CategoryReFireLoop, caddomain.ClassStuck),
finding("mathias/cobalt-dingo", 41, caddomain.CategoryClaimedNeverRan, caddomain.ClassStuck),
}
out := atlas.ApplyStuckOverlay(stages, findings)
if out[0].StuckCount != 4 {
t.Fatalf("StuckCount = %d, want 4", out[0].StuckCount)
}
}
func TestApplyStuckOverlay_NodesPrependedNotReplacingAuthoredContent(t *testing.T) {
s := stage("STAGE 04", "stuck-badges")
s.Nodes = []atlas.Node{{Title: "authored node"}}
out := atlas.ApplyStuckOverlay([]atlas.Stage{s}, []caddomain.StuckFinding{
finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck),
})
if len(out[0].Nodes) != 2 {
t.Fatalf("want 2 nodes (1 live + 1 authored), got %d", len(out[0].Nodes))
}
if out[0].Nodes[len(out[0].Nodes)-1].Title != "authored node" {
t.Errorf("authored node was not preserved: %+v", out[0].Nodes)
}
}
func TestApplyStuckOverlay_DoesNotMutateInput(t *testing.T) {
stages := []atlas.Stage{stage("STAGE 04", "stuck-badges")}
_ = atlas.ApplyStuckOverlay(stages, []caddomain.StuckFinding{
finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck),
})
if len(stages[0].Nodes) != 0 {
t.Fatalf("input slice was mutated: %+v", stages[0].Nodes)
}
}
+69
View File
@@ -1,9 +1,12 @@
package web
import (
"context"
_ "embed"
"encoding/json"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -12,6 +15,10 @@ import (
"git.d-ma.be/mathias/cad-atlas/internal/cluster"
"git.d-ma.be/mathias/cad-atlas/internal/gitea"
"git.d-ma.be/mathias/cad-atlas/internal/version"
"git.d-ma.be/mathias/cad/adapters/gitearead"
cadapp "git.d-ma.be/mathias/cad/app"
caddomain "git.d-ma.be/mathias/cad/domain"
)
// atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load
@@ -64,6 +71,10 @@ func NewHandler() http.Handler {
}
}
}
// cad-atlas#11: overlay mathias/cad#14's cad-stuck findings as
// per-stage badges. Read-only — ApplyStuckOverlay only adds Nodes
// with a link out; it never writes anything back to Gitea.
a.Stages = atlas.ApplyStuckOverlay(a.Stages, ld.stuck)
if ld.deploy != nil || ld.flux != nil {
var live []atlas.Node
if ld.deploy != nil {
@@ -93,6 +104,30 @@ type liveOverlayData struct {
flux *atlas.Flux
timeline []atlas.RunDot
issues []atlas.Node
stuck []caddomain.StuckFinding
}
// stuckRepos is the estate cad-stuck queries — mirrors mathias/dispatch's
// dispatch-repos.txt scope. Override with CAD_ATLAS_STUCK_REPOS (comma
// separated "owner/name" entries) so this doesn't need a redeploy every time
// the estate's repo list changes; cad-atlas has no filesystem access to the
// canonical dispatch-repos.txt at runtime.
var defaultStuckRepos = []string{
"mathias/dispatch-sandbox", "mathias/cobalt-dingo", "mathias/parallax",
"mathias/cad-atlas", "mathias/brain-gardener", "mathias/hyperguild", "mathias/brain",
}
func stuckRepos() []string {
if v := os.Getenv("CAD_ATLAS_STUCK_REPOS"); v != "" {
var repos []string
for _, r := range strings.Split(v, ",") {
if r = strings.TrimSpace(r); r != "" {
repos = append(repos, r)
}
}
return repos
}
return defaultStuckRepos
}
// live cache: query the cluster at most once per TTL; fall back to the authored
@@ -147,6 +182,40 @@ func liveOverlay() liveOverlayData {
d.flux = &f
}
}
d.stuck = fetchStuck()
liveCache = d
return d
}
// fetchStuck re-derives cad#14's stuck worklist fresh on every liveOverlay
// call (no cache beyond the shared liveTTL) — the same "re-derive, never
// recall" discipline cad-stuck and cad-babysit both hold to. Read-only:
// gitearead.Client has no write method in its package. Degrades to nil
// (no badges rendered) on any error or when GITEA_TOKEN is unset, exactly
// like every other liveOverlay source — a forge outage costs a missing
// overlay, never a broken page.
func fetchStuck() []caddomain.StuckFinding {
token := os.Getenv("GITEA_TOKEN")
if token == "" {
return nil
}
base := os.Getenv("GITEA_BASE")
if base == "" || strings.Contains(base, ".svc.cluster.local") {
// The in-cluster Gitea service (internal/gitea's default) has no
// public HTTPS endpoint for gitearead's client to call with a
// bearer token in the same way; cad-stuck's own default (the public
// git.d-ma.be) is the correct base here regardless of what
// GITEA_BASE is set to for the read-only in-cluster fetchers above.
base = "https://git.d-ma.be"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := gitearead.New(base, token)
cfg := cadapp.DefaultStuckConfig()
cfg.BaseURL = base
findings, _, err := cadapp.RunStuckQuery(ctx, client, stuckRepos(), cfg)
if err != nil {
return nil
}
return findings
}
+21 -1
View File
@@ -120,6 +120,17 @@
border:1px solid var(--line);color:var(--dim);margin:6px 6px 0 0}
.pill{width:8px;height:8px;border-radius:50%;flex:0 0 8px}
/* cad-atlas#11: stuck-state overlay. Two visual classes, deliberately
distinct — "stuck" needs intervention, "awaiting-human" is a control
working as designed and must never look like a problem. */
.node.stuck{border-color:rgba(255,122,92,.55);background:linear-gradient(180deg,rgba(255,122,92,.07),transparent)}
.node.awaiting-human{border-color:rgba(74,208,122,.5);background:linear-gradient(180deg,rgba(74,208,122,.06),transparent)}
.stagehead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
.stagehead .badges{display:flex;gap:6px}
.badge{font-size:10.5px;padding:2px 8px;border-radius:20px;font-weight:600;white-space:nowrap}
.badge.stuck{color:var(--coral);border:1px solid rgba(255,122,92,.5)}
.badge.awaiting{color:var(--green);border:1px solid rgba(74,208,122,.5)}
.stage.telos h2{color:var(--violet)}
.stage.gate h2{color:var(--amber)}
.stage.exec h2{color:var(--coral)}
@@ -234,7 +245,16 @@ function renderAtlas(){
? `<div class="no mono">${s.no}</div><h2>${s.plain_title||s.title}</h2>`+
`<div class="tech-sub">${s.title}</div>`+(s.plain?`<div class="plain-what">${s.plain}</div>`:'')
: `<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
let h = `<div class="stagehead">${head}</div>`;
// cad-atlas#11: a count badge per stage from cad-stuck (mathias/cad#14),
// read-only — no controls, just a count and (via the nodes below) a link
// out to the underlying issues. Two classes, kept visually separate: a
// "stuck" count needs intervention, an "awaiting" count is a control
// working as designed and must never read as a problem.
let badges='';
if(s.stuck_count)badges+=`<span class="badge stuck">${s.stuck_count} stuck</span>`;
if(s.awaiting_count)badges+=`<span class="badge awaiting">${s.awaiting_count} awaiting</span>`;
if(badges)badges=`<span class="badges">${badges}</span>`;
let h = `<div class="stagehead">${head}${badges}</div>`;
(s.nodes||[]).forEach(n=>{
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
const body = plain ? (n.plain||n.d||'') : (n.d||'');