Process spine: generate assets per milestone, and two trogdor profiles (quality / speed) #35

Open
opened 2026-07-27 21:13:11 -04:00 by cmoriarty · 6 comments
Owner

Two changes with one motive: iteration on trog is currently slower than the bugs are, so every mistake costs a full drain to find. Filed together because they compound — per-milestone generation shortens the feedback loop, the speed profile shortens every step inside it.

Part 2 landed (2026-07-27): trogdor-quality / trogdor-speed, the resident brain, trog profile to switch. Part 1 below was rewritten after it, because Part 2 changed what Part 1 should be.


Part 1 — assets are a tool, not a phase

What happens now

One assets phase writes a spec for the ENTIRE game, submits it as one batch, and drain generates all of it before the first line of code exists. Observed on the first real run (#11): 27 assets, ~2 hours of GPU, all of it spent before the build walk started — and then the run was abandoned because the art was unusable (#34).

Why it's wrong here

  1. It contradicts the process the graph encodes. The vertical slice is meant to hit final quality on ONE slice; full production then proceeds milestone by milestone. Generating milestone 4's assets alongside the slice's, before anything is playable, is the opposite of that.
  2. It front-loads the entire GPU cost before any code exists, for milestones that may still be cut.
  3. It severs the feedback loop. Assets are judged by a critic in isolation and never by "does this work in the game". A build that discovers the bobber reads badly at 32px has no path back to regeneration.
  4. It hid #34 for two hours. Three assets and one build would have shown dark rectangles immediately.

The shape (revised)

The original filing proposed per-milestone batching: assets(M1) -> drain -> build(M1). That is the same spec-then-drain machinery with a smaller batch, and it would be built only to be removed. The essence of a playful production process is not "batch later", it is make the thing when you find out you need it.

So: generation becomes a tool the brain and the code agent call, and the asset phase goes away. The milestone still declares what it expects to need — that is what keeps the walk reviewable rather than improvised — but declaring is planning, not generating.

The plumbing for this already landed (2026-07-28, part of #35 since it is the resident brain that makes it possible):

  • generate_asset(kind, prompt, …) returns a handle in ~0.5s; await_asset(job_id) collects it. Measured live: a 64px sprite is ~7s warm, ~25s cold, an animation ~82s — all of it time the caller can spend working.
  • During a build session the asset is written into that session's workspace, at its repo-relative path, and rides out on the session's own single commit. Verified end to end: the file is 404 in the repo while the session runs, and byte-identical in the commit afterwards.
  • Generating mid-build is refused where seats.brain_yields() is True, because there it would stop the brain the agent is talking to.

What remains is the graph.

What the graph should become

  • No assets phase. The vertical slice generates what the slice needs, when it needs it, and is judged by playing it.
  • Milestones declare, they do not batch. A milestone names what it expects to need; the walk generates on demand and the declaration is what a human reviews.
  • A playtest failure can send work back to regeneration — the loop the current shape severs.
  • Reuse is free: the queue is commit-keyed and the critics already skip unchanged files.

Constraints that are not negotiable

These come from things already learned the hard way; a redesign that ignores them re-earns the lesson.

  1. Verdicts stay with critics.py + the ledger. The brain decides when to generate; the critic decides whether it passed. That is where commit+target keying, temp 0.2 (0.7 flip-flopped, live-caught), never-rejudge-unchanged, and the bounded regen cap live. A brain looping "generate, look, try again" in its own context has none of them.
  2. Termination. The milestone walk is uncapped by design and only repair is capped (BUILD_FIX_RETRIES) — that cap is the only thing guaranteeing an unattended run ends. An on-demand generation tool is a new unbounded GPU spend and needs its own budget, or that guarantee is gone.
  3. Resume. Every step checkpoints, so a resumed node re-runs its tool calls. The ledger already answers "does this exist at this commit"; the tool must consult it or a resume regenerates the whole slice.
  4. The record. Today the spec document is the manifest of what the game has and why. If assets appear by tool call, something still has to write that record, or the game ends up with files and no account of them.
  5. drain.py's batch lanes retire; seats.py does not. Under trogdor-speed image and animate still share GPU3, so seat lifecycle, runnable(), and keep-warm stay load-bearing — the queue is what makes that contention invisible to the caller.

Done when

  • A run generates only what the milestone in front of it needs.
  • A playtest failure can send work back to regeneration.
  • An unattended run still terminates, with the asset budget as explicit as the repair cap.
  • trog test asset --mock still covers the whole path.

Related: #27 (hardware profiles), #34 (asset pipeline has no alpha), #11 (where this was found), #36 (switching profiles from the studio).

Two changes with one motive: iteration on trog is currently slower than the bugs are, so every mistake costs a full drain to find. Filed together because they compound — per-milestone generation shortens the feedback loop, the speed profile shortens every step inside it. **Part 2 landed** (2026-07-27): `trogdor-quality` / `trogdor-speed`, the resident brain, `trog profile` to switch. Part 1 below was rewritten after it, because Part 2 changed what Part 1 should be. --- ## Part 1 — assets are a tool, not a phase ### What happens now One `assets` phase writes a spec for the ENTIRE game, submits it as one batch, and `drain` generates all of it before the first line of code exists. Observed on the first real run (#11): 27 assets, ~2 hours of GPU, all of it spent before the build walk started — and then the run was abandoned because the art was unusable (#34). ### Why it's wrong here 1. **It contradicts the process the graph encodes.** The vertical slice is meant to hit final quality on ONE slice; full production then proceeds milestone by milestone. Generating milestone 4's assets alongside the slice's, before anything is playable, is the opposite of that. 2. **It front-loads the entire GPU cost** before any code exists, for milestones that may still be cut. 3. **It severs the feedback loop.** Assets are judged by a critic in isolation and never by "does this work in the game". A build that discovers the bobber reads badly at 32px has no path back to regeneration. 4. **It hid #34 for two hours.** Three assets and one build would have shown dark rectangles immediately. ### The shape (revised) The original filing proposed per-milestone batching: `assets(M1) -> drain -> build(M1)`. That is the same spec-then-drain machinery with a smaller batch, and it would be built only to be removed. The essence of a playful production process is not "batch later", it is **make the thing when you find out you need it**. So: generation becomes a tool the brain and the code agent call, and the asset *phase* goes away. The milestone still declares what it expects to need — that is what keeps the walk reviewable rather than improvised — but declaring is planning, not generating. **The plumbing for this already landed** (2026-07-28, part of #35 since it is the resident brain that makes it possible): - `generate_asset(kind, prompt, …)` returns a handle in ~0.5s; `await_asset(job_id)` collects it. Measured live: a 64px sprite is ~7s warm, ~25s cold, an animation ~82s — all of it time the caller can spend working. - During a build session the asset is written into that session's **workspace**, at its repo-relative path, and rides out on the session's own single commit. Verified end to end: the file is 404 in the repo while the session runs, and byte-identical in the commit afterwards. - Generating mid-build is refused where `seats.brain_yields()` is True, because there it would stop the brain the agent is talking to. What remains is the graph. ### What the graph should become - **No `assets` phase.** The vertical slice generates what the slice needs, when it needs it, and is judged by playing it. - **Milestones declare, they do not batch.** A milestone names what it expects to need; the walk generates on demand and the declaration is what a human reviews. - **A playtest failure can send work back to regeneration** — the loop the current shape severs. - Reuse is free: the queue is commit-keyed and the critics already skip unchanged files. ### Constraints that are not negotiable These come from things already learned the hard way; a redesign that ignores them re-earns the lesson. 1. **Verdicts stay with `critics.py` + the ledger.** The brain decides *when* to generate; the critic decides *whether* it passed. That is where commit+target keying, temp 0.2 (0.7 flip-flopped, live-caught), never-rejudge-unchanged, and the bounded regen cap live. A brain looping "generate, look, try again" in its own context has none of them. 2. **Termination.** The milestone walk is uncapped by design and only *repair* is capped (`BUILD_FIX_RETRIES`) — that cap is the only thing guaranteeing an unattended run ends. An on-demand generation tool is a new unbounded GPU spend and needs its own budget, or that guarantee is gone. 3. **Resume.** Every step checkpoints, so a resumed node re-runs its tool calls. The ledger already answers "does this exist at this commit"; the tool must consult it or a resume regenerates the whole slice. 4. **The record.** Today the spec document is the manifest of what the game has and why. If assets appear by tool call, something still has to write that record, or the game ends up with files and no account of them. 5. **`drain.py`'s batch lanes retire; `seats.py` does not.** Under `trogdor-speed` image and animate still share GPU3, so seat lifecycle, `runnable()`, and keep-warm stay load-bearing — the queue is what makes that contention invisible to the caller. ### Done when - A run generates only what the milestone in front of it needs. - A playtest failure can send work back to regeneration. - An unattended run still terminates, with the asset budget as explicit as the repair cap. - `trog test asset --mock` still covers the whole path. Related: #27 (hardware profiles), #34 (asset pipeline has no alpha), #11 (where this was found), #36 (switching profiles from the studio).
Author
Owner

Implementation context (written for a session starting cold)

Everything below was learned live on trogdor while building #11. It is the stuff that isn't in the repo and isn't derivable from reading it.

State as of filing

  • main is at 5f8f351, deployed to the trog stack, 220 tests green, lint clean.
  • The #11 walking skeleton is built and deployed but #11 is not done — no run has reached a playable URL. #34 (no alpha in generated sprites) blocks a meaningful re-run.
  • The last real run's repo is trog-games/a-cozy-fishing-game-2607272154 — useful as a fixture: real docs, a real 4-milestone plan, a real 27-asset spec, and 27 real (broken) assets.

Deploying — the part that is easy to get wrong

The Portainer webhook does not pull images. make redeploy fires the webhook, so after make build push it will redeploy the stack with the OLD images and everything will look mysteriously unchanged. Redeploy through the API instead:

PT=$(security find-generic-password -s portainer-api -w)
# stack 92 = trog, endpoint 39 = trogdor
ENV=$(curl -fsSk -H "X-API-Key: $PT" https://192.168.1.119:9443/api/stacks/92 \
      | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['Env']))")
BODY=$(python3 -c "import json,sys; print(json.dumps({'env': json.loads(sys.argv[1]), 'prune': False, 'pullImage': True}))" "$ENV")
curl -fsSk -X PUT -H "X-API-Key: $PT" -H 'Content-Type: application/json' \
  'https://192.168.1.119:9443/api/stacks/92/git/redeploy?endpointId=39' -d "$BODY"

Pass Env back explicitly. Omitting it clears the stack environment, which is where POSTGRES_PASSWORD, FORGEJO_TOKEN, FORGEJO_URL, REGISTRY, COMFY_IMAGE and WEBSEARCH_MCP_URL live. Verify env kept: 6 in the response.

The stack deploys from Forgejo main, not from the working tree — commits must be pushed before a redeploy means anything. A redeploy restarts the orchestrator, which kills any in-flight drain or build (assetq.fail_orphans() cleans up the rows on boot).

Brain bracketing: the invariants Part 2 must not break

Making resident: true skip bracketing touches the one thing the orchestrator is careful about. The relevant code is trog_lib/seats.py:

  • take_brain() — waits up to 300s for brain_idle() (llama.cpp /slots), then stops the brain container, sets _brain_taken.
  • restore_brain() — starts it; on failure it deliberately leaves _brain_taken set so a later release path retries. Clearing it there once left the brain down until the next boot (#26).
  • release_idle(ttl, restore=True) and release() are the paths that bring the brain back; _post_drain and the worker's idle sweep both rely on them.
  • reconcile() at boot removes orphan seats and starts the brain if nothing holds the GPUs.

For a resident brain the cleanest shape is for take_brain/restore_brain to become no-ops and _brain_taken to stay false — but check reconcile() too: with resident: true it must NOT treat a deliberately-stopped brain as an orphan-recovery case, and it must not start a brain whose cards a seat is currently using if the profile ever overlaps them. A profile whose brain.gpus intersect any seats.*.gpus while resident: true is a configuration error and should fail fast at load, not at render time — profiles.py already refuses unknown seat names, so validation belongs there.

Testing without a rig

  • make test — 220 unit tests, no services needed.
  • trog test asset --mock — the whole drain path on stub seats, zero GPUs. Point TROG_URL=http://trogdor:8200.
  • SEAT_MODE=mock / CODESEAT_MODE=mock — the code agent seat writes a placeholder Phaser page instead of raising a container.
  • The drain lane is unit-testable: tests/test_drain.py::_lane_harness drives _lane over a fake job with fake seats, which is how the progress and early-audio tests work. Reuse it.

Inspect the artifacts, not the verdicts

The single most useful lesson from #11: I reported "4 pass / 10 fail" for some time without opening an image, and the defect (#34) was one header read away. Concretely:

TOK=$(printf 'protocol=https\nhost=forgejo.underthere.xyz\n\n' | git credential fill | sed -n 's/^password=//p')
REPO=trog-games/a-cozy-fishing-game-2607272154
curl -sS -u "cmoriarty:$TOK" "https://forgejo.underthere.xyz/api/v1/repos/$REPO/raw/assets/sprite/bird.png" -o bird.png
python3 -c "import struct; d=open('bird.png','rb').read(); w,h,bd,ct=struct.unpack('>IIBB', d[16:26]); print(w,h,{0:'gray',2:'RGB (no alpha)',3:'palette',4:'gray+A',6:'RGBA'}[ct])"

The ledger carries the critic's full reasoning, which is often more informative than the verdict:

docker exec trog-postgres psql -U aegra -d aegra -tAc \
  "select target, verdict, left(detail->>'note',400) from trog_ledger \
   where game='<repo>' and tool='critique_image' order by id desc limit 5;"

(Note: trog_ledger has no created_at column — order by id.)

Traps already paid for

  • seats._wait_healthy accepts any non-5xx. With an authenticated seat a 401 reads as "up", and the failure surfaces much later as something unrelated. codeseat._wait_healthy is a separate implementation for exactly this reason; anything else that gains auth needs the same treatment.
  • PIL's Image.getcolors() returns (count, value), not (value, count). Getting it backwards makes a half-transparent sprite read as 0% transparent. Cost me a test cycle in critics.transparency.
  • Aegra cancels a run at BG_JOB_TIMEOUT_SECS (was 3600, now 86400 in compose). The symptom is execution_seconds=3600.01 Worker job cancelled status=interrupted in the aegra logs and a run that stops mid-phase for no visible reason. trog run now resumes from the checkpoint.
  • A stream that ends is not a run that ended. Aegra's /runs/stream closes cleanly during a long node with nothing to emit.
  • Status must be set in the endpoint, not the task. POST /drain and POST /build mark themselves running synchronously; otherwise a caller polling a second later reads the PREVIOUS run's terminal status. A build node skipping an hour of work because it saw a stale done is a silent failure.
  • nvidia-smi topo -m over ssh is the fastest way to confirm the NVLink/NUMA layout; don't infer it from GPU indices.

Measuring whether the speed profile was worth it

Run the same brief through both profiles and compare two things, not one:

  1. Wall clock per phase. trog_asset_jobs.timings now carries {boot, generate, deliver, warm, kind} per job (the drain records them as of d030fce), so the corpus is queryable rather than anecdotal.
  2. Verdict distribution from the critics. If speed mode's pass rate collapses, smaller models are the wrong lever and the profile should cut steps rather than parameters.

Baseline from the failed run, for comparison: 14 images, 14 sounds, 3 animations, one full regen pass, roughly 2 hours wall clock, and 4/14 images passing on the first pass — though that pass rate is not a fair baseline while #34 stands, since the critic was judging assets that were unusable for a reason it could not see.

## Implementation context (written for a session starting cold) Everything below was learned live on trogdor while building #11. It is the stuff that isn't in the repo and isn't derivable from reading it. ### State as of filing - `main` is at `5f8f351`, deployed to the trog stack, 220 tests green, lint clean. - The #11 walking skeleton is built and deployed but **#11 is not done** — no run has reached a playable URL. #34 (no alpha in generated sprites) blocks a meaningful re-run. - The last real run's repo is `trog-games/a-cozy-fishing-game-2607272154` — useful as a fixture: real docs, a real 4-milestone plan, a real 27-asset spec, and 27 real (broken) assets. ### Deploying — the part that is easy to get wrong The Portainer **webhook does not pull images**. `make redeploy` fires the webhook, so after `make build push` it will redeploy the stack with the OLD images and everything will look mysteriously unchanged. Redeploy through the API instead: ```bash PT=$(security find-generic-password -s portainer-api -w) # stack 92 = trog, endpoint 39 = trogdor ENV=$(curl -fsSk -H "X-API-Key: $PT" https://192.168.1.119:9443/api/stacks/92 \ | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['Env']))") BODY=$(python3 -c "import json,sys; print(json.dumps({'env': json.loads(sys.argv[1]), 'prune': False, 'pullImage': True}))" "$ENV") curl -fsSk -X PUT -H "X-API-Key: $PT" -H 'Content-Type: application/json' \ 'https://192.168.1.119:9443/api/stacks/92/git/redeploy?endpointId=39' -d "$BODY" ``` **Pass `Env` back explicitly.** Omitting it clears the stack environment, which is where `POSTGRES_PASSWORD`, `FORGEJO_TOKEN`, `FORGEJO_URL`, `REGISTRY`, `COMFY_IMAGE` and `WEBSEARCH_MCP_URL` live. Verify `env kept: 6` in the response. The stack deploys from **Forgejo main**, not from the working tree — commits must be pushed before a redeploy means anything. A redeploy restarts the orchestrator, which kills any in-flight drain or build (`assetq.fail_orphans()` cleans up the rows on boot). ### Brain bracketing: the invariants Part 2 must not break Making `resident: true` skip bracketing touches the one thing the orchestrator is careful about. The relevant code is `trog_lib/seats.py`: - `take_brain()` — waits up to 300s for `brain_idle()` (llama.cpp `/slots`), then stops the brain container, sets `_brain_taken`. - `restore_brain()` — starts it; on failure it deliberately **leaves `_brain_taken` set** so a later release path retries. Clearing it there once left the brain down until the next boot (#26). - `release_idle(ttl, restore=True)` and `release()` are the paths that bring the brain back; `_post_drain` and the worker's idle sweep both rely on them. - `reconcile()` at boot removes orphan seats and starts the brain if nothing holds the GPUs. For a resident brain the cleanest shape is for `take_brain`/`restore_brain` to become no-ops and `_brain_taken` to stay false — but check `reconcile()` too: with `resident: true` it must NOT treat a deliberately-stopped brain as an orphan-recovery case, and it must not start a brain whose cards a seat is currently using if the profile ever overlaps them. A profile whose `brain.gpus` intersect any `seats.*.gpus` while `resident: true` is a configuration error and should fail fast at load, not at render time — `profiles.py` already refuses unknown seat names, so validation belongs there. ### Testing without a rig - `make test` — 220 unit tests, no services needed. - `trog test asset --mock` — the whole drain path on stub seats, zero GPUs. Point `TROG_URL=http://trogdor:8200`. - `SEAT_MODE=mock` / `CODESEAT_MODE=mock` — the code agent seat writes a placeholder Phaser page instead of raising a container. - The drain lane is unit-testable: `tests/test_drain.py::_lane_harness` drives `_lane` over a fake job with fake seats, which is how the progress and early-audio tests work. Reuse it. ### Inspect the artifacts, not the verdicts The single most useful lesson from #11: I reported "4 pass / 10 fail" for some time without opening an image, and the defect (#34) was one header read away. Concretely: ```bash TOK=$(printf 'protocol=https\nhost=forgejo.underthere.xyz\n\n' | git credential fill | sed -n 's/^password=//p') REPO=trog-games/a-cozy-fishing-game-2607272154 curl -sS -u "cmoriarty:$TOK" "https://forgejo.underthere.xyz/api/v1/repos/$REPO/raw/assets/sprite/bird.png" -o bird.png python3 -c "import struct; d=open('bird.png','rb').read(); w,h,bd,ct=struct.unpack('>IIBB', d[16:26]); print(w,h,{0:'gray',2:'RGB (no alpha)',3:'palette',4:'gray+A',6:'RGBA'}[ct])" ``` The ledger carries the critic's full reasoning, which is often more informative than the verdict: ```bash docker exec trog-postgres psql -U aegra -d aegra -tAc \ "select target, verdict, left(detail->>'note',400) from trog_ledger \ where game='<repo>' and tool='critique_image' order by id desc limit 5;" ``` (Note: `trog_ledger` has no `created_at` column — order by `id`.) ### Traps already paid for - **`seats._wait_healthy` accepts any non-5xx.** With an authenticated seat a 401 reads as "up", and the failure surfaces much later as something unrelated. `codeseat._wait_healthy` is a separate implementation for exactly this reason; anything else that gains auth needs the same treatment. - **PIL's `Image.getcolors()` returns `(count, value)`, not `(value, count)`.** Getting it backwards makes a half-transparent sprite read as 0% transparent. Cost me a test cycle in `critics.transparency`. - **Aegra cancels a run at `BG_JOB_TIMEOUT_SECS`** (was 3600, now 86400 in compose). The symptom is `execution_seconds=3600.01 Worker job cancelled status=interrupted` in the aegra logs and a run that stops mid-phase for no visible reason. `trog run` now resumes from the checkpoint. - **A stream that ends is not a run that ended.** Aegra's `/runs/stream` closes cleanly during a long node with nothing to emit. - **Status must be set in the endpoint, not the task.** `POST /drain` and `POST /build` mark themselves running synchronously; otherwise a caller polling a second later reads the PREVIOUS run's terminal status. A build node skipping an hour of work because it saw a stale `done` is a silent failure. - **`nvidia-smi topo -m` over ssh** is the fastest way to confirm the NVLink/NUMA layout; don't infer it from GPU indices. ### Measuring whether the speed profile was worth it Run the same brief through both profiles and compare two things, not one: 1. **Wall clock** per phase. `trog_asset_jobs.timings` now carries `{boot, generate, deliver, warm, kind}` per job (the drain records them as of `d030fce`), so the corpus is queryable rather than anecdotal. 2. **Verdict distribution** from the critics. If speed mode's pass rate collapses, smaller models are the wrong lever and the profile should cut steps rather than parameters. Baseline from the failed run, for comparison: 14 images, 14 sounds, 3 animations, one full regen pass, roughly 2 hours wall clock, and 4/14 images passing on the first pass — though that pass rate is not a fair baseline while #34 stands, since the critic was judging assets that were unusable for a reason it could not see.
Author
Owner

Progress: Part 2 done, Part 1's tool half done, first graph step landed

Part 2 (2026-07-27)trogdor-quality / trogdor-speed, brain: {gpus, resident, cpuset}, trog profile to switch. Verified on the rig: brain resident on GPU0+1 across a whole session, brain and a generator seat resident simultaneously for the first time, audio ∥ animate preserved, image↔animate serialising on GPU3 as designed.

Three things the first live run corrected, all of them readouts rather than the mechanism:

  • Swapping brain for image stack printed on every job under a resident brain — the one line that would have made the win invisible.
  • ETAs were estimated from the other profile's history (FLUX-plus-a-swap predicting a 9s klein render). Timings now carry their rig.
  • klein's i2i lane silently returned its input at the global 0.55 denoise (0.75 lands, 0.85 is clean). Floored at 0.8.

The 5B animate lane was not broken, it was mis-sized. At the 14B graph's 512² the subject dissolves by frame 2 and clips degenerate into streak noise; at 704² with 30 steps the background stays clean and the subject holds across all 8 frames, reproduced across seeds, ~82s a clip. Identity is still softer than the 14B's — pix3lwalk was trained for exactly this and no such LoRA exists for the 5B. Bench sheets in /oneoffs/bench/.

Part 1, tool half (2026-07-28). generate_asset returns a handle in ~0.5s; await_asset collects it. Verified against a live build session: the asset landed in the session's workspace, was 404 in the repo the whole time the session ran, and came out byte-identical in the session's own single commit beside index.html.

Its four constraints are implemented, not just written down:

  • Criticrun_job had never called critics, so everything the tool made was committed unjudged. critics.judge_fresh judges the bytes (the file isn't in the repo during a session), records nothing to the ledger for prejudge_audio's reason, and the verdict travels back through await_asset. This immediately caught #34 — first judged asset came back "no alpha channel", and every sprite that session measured RGB/0.0% transparent. Fixed in 2c1a9d1; same prompt now passes.
  • TerminationASSET_BUDGET (24) per build session. A human at the CLI spends none.
  • Resume/assets/find first, workspace before repo, existing path returned rather than regenerated.
  • The record — a .trog.json sidecar per asset (prompt, verdict, critique, timestamp) riding the same delivery, so one commit carries the asset and its account.

Also: the OpenCode seat got an MCP block for the first time, handed over only where seats.brain_yields() is False — still no credential in that container, because the asset lands in its workspace rather than being committed by the tool.

Graph, first step. slice_build sits between vertical_slice and gate_bar: the slice is now built before the bar gate judges it, with its art made on demand where the brain is resident. The brief adapts to the rig — generate-as-you-go on a resident brain, flat rectangles plus an ## Assets needed list where the brain yields (a tool that refuses every call produces worse code than an honest instruction to draw rectangles).

Remaining

  • The milestone walk still batches: production -> assets -> drain -> build. Moving it onto per-item generation is the rest of this ticket.
  • No live end-to-end trog run yet — slice_build is covered by graph tests and deployed, but nothing has played a slice it built.
  • The asset budget is per build session; a whole-run budget may turn out to be the one that matters.

Related: #34 (fixed, found by closing the critic gap here), #36 (switching profiles from the studio).

## Progress: Part 2 done, Part 1's tool half done, first graph step landed **Part 2 (2026-07-27)** — `trogdor-quality` / `trogdor-speed`, `brain: {gpus, resident, cpuset}`, `trog profile` to switch. Verified on the rig: brain resident on GPU0+1 across a whole session, brain and a generator seat resident **simultaneously** for the first time, audio ∥ animate preserved, image↔animate serialising on GPU3 as designed. Three things the first live run corrected, all of them readouts rather than the mechanism: - `Swapping brain for image stack` printed on every job under a resident brain — the one line that would have made the win invisible. - ETAs were estimated from the *other* profile's history (FLUX-plus-a-swap predicting a 9s klein render). Timings now carry their rig. - klein's i2i lane silently returned its input at the global 0.55 denoise (0.75 lands, 0.85 is clean). Floored at 0.8. **The 5B animate lane was not broken, it was mis-sized.** At the 14B graph's 512² the subject dissolves by frame 2 and clips degenerate into streak noise; at **704² with 30 steps** the background stays clean and the subject holds across all 8 frames, reproduced across seeds, ~82s a clip. Identity is still softer than the 14B's — pix3lwalk was trained for exactly this and no such LoRA exists for the 5B. Bench sheets in `/oneoffs/bench/`. **Part 1, tool half (2026-07-28).** `generate_asset` returns a handle in ~0.5s; `await_asset` collects it. Verified against a live build session: the asset landed in the session's workspace, was 404 in the repo the whole time the session ran, and came out **byte-identical** in the session's own single commit beside `index.html`. Its four constraints are implemented, not just written down: - **Critic** — `run_job` had never called `critics`, so everything the tool made was committed unjudged. `critics.judge_fresh` judges the bytes (the file isn't in the repo during a session), records nothing to the ledger for `prejudge_audio`'s reason, and the verdict travels back through `await_asset`. **This immediately caught #34** — first judged asset came back "no alpha channel", and every sprite that session measured RGB/0.0% transparent. Fixed in `2c1a9d1`; same prompt now passes. - **Termination** — `ASSET_BUDGET` (24) per build session. A human at the CLI spends none. - **Resume** — `/assets/find` first, workspace before repo, existing path returned rather than regenerated. - **The record** — a `.trog.json` sidecar per asset (prompt, verdict, critique, timestamp) riding the same delivery, so one commit carries the asset and its account. Also: the OpenCode seat got an MCP block for the first time, handed over **only** where `seats.brain_yields()` is False — still no credential in that container, because the asset lands in its workspace rather than being committed by the tool. **Graph, first step.** `slice_build` sits between `vertical_slice` and `gate_bar`: the slice is now built before the bar gate judges it, with its art made on demand where the brain is resident. The brief adapts to the rig — generate-as-you-go on a resident brain, flat rectangles plus an `## Assets needed` list where the brain yields (a tool that refuses every call produces worse code than an honest instruction to draw rectangles). ## Remaining - The milestone walk still batches: `production -> assets -> drain -> build`. Moving it onto per-item generation is the rest of this ticket. - No live end-to-end `trog run` yet — `slice_build` is covered by graph tests and deployed, but nothing has played a slice it built. - The asset budget is per build session; a whole-run budget may turn out to be the one that matters. Related: #34 (fixed, found by closing the critic gap here), #36 (switching profiles from the studio).
Author
Owner

Handoff: seven headless runs, ten fixes, no working game yet

Session of 2026-07-28. Everything below is pushed, CI green, deployed. Stack is idle on trogdor-speed. Read the "what to do next" section first — the current blocker is small and specific.

What I was doing

Running the full production graph headless (trog run), monitoring, interrupting on bugs, fixing, redeploying, repeating. Seven runs. Each one got further than the last; none produced a playable game.

Landed (oldest first)

commit what
0fc0414, 4a6df1f CI was red on a stale cached .venv. Its key only changed when requirements-dev.txt did, so a bad venv stayed bad forever. Proved the code innocent first (clean linux/py3.14 container: ruff clean, 259 passed), then bumped the key. Green since.
2e99307 Three things one run found: MoE context 65536 → 131072 (measured: 25.6/32 GB at 128k, ~6 GB spare) after the agent webfetched a minified Phaser bundle and blew the window; briefs warn off large fetches; stall watchdog (BUILD_STALL_SECS, 600) aborts a session that goes silent instead of burning the full BUILD_TIMEOUT hour. Also: the fun gate never passed — 4/4 FAIL across two runs, every critique fair and none decisive, because the prompt said "judge harshly" and never said what PASS means. Now states the bar. Passes first try on every run since.
fa311b2 A milestone replaced the game. The plan opened with "Tech Spike: The Glow", the developer built the spike faithfully, and it overwrote index.html. Planning now requires every milestone to end playable; both build briefs say index.html IS the game, never replace it.
5fa81af Phaser 4 bootstrap guessed wrong twice (+esm has no default export; undefined.add). House rules now pin the exact script tag and name both failures. No bootstrap errors since.
b50bc4e gate_slice — three runs committed a slice that never drew a frame, because gate_bar judges the spec document. The slice is now played before it is judged, and failures go back to their author with the console attached, bounded by SLICE_FIX_RETRIES.
cbc69a7 A bare 422 was killing repair sessions with no reason given. The error now carries Forgejo's body — it found the next bug within one run.
1df6e7c Every image was failing its critic and the agent had started shipping procedural placeholders. Not the generator: eye-checked probes (a clean fish, a proper bucket) both FAILED, and the bucket's critique praised it before failing it. Measured, same image: judged against "a small orange fish, side view"PASS; against that + CRAFT_SPRITE_SUFFIXFAIL. build_spec was handing the craft-suffixed prompt to the critic as the brief, turning generation guidance into an acceptance test — and overriding the style-block craft opt-in. Spec now carries brief beside prompt.
188be3a Failures then changed to "Style Violation": the agent was writing briefs like "clean 2D vector style, 32x32 pixels" against a pixel-art pipeline. generate_asset now says outright that it makes pixel art.
a4b6705 The 422, named at last: repository file already exists [path: game.js]. commit_files checked existence on the branch, and Forgejo serves a stale contents read for a window after a fresh commit — the same window read_binary documents. So a just-created file read as absent, we chose create, batch rejected. It threw away two repair sessions that had already fixed the game. Existence is now pinned to head_commit.

275 tests, all with regression cover for the above.

Where run 7 died — the current blocker

gates: fun:pass, slice_plays:fail x3
reason: playtest could not run: 422: no index.html found under /

Commits succeeded (the 422 fix held), repairs ran and landed — but the slice build never produced index.html at the repo root, so the playtest had nothing to open. It then burned all three gate_slice attempts on the same report and advanced on its retry cap.

This is much shallower than earlier failures and smells like the same class: the rule exists (AGENTS.md says the headless browser opens index.html at the root) but nothing checks it, and the feedback is expensive and generic.

What to do next

  1. Make the missing entry point cheap to fix. Two candidates, probably both:
    • slice_build / build_phase verify index.html exists at the repo root before the node returns, and re-prompt immediately if not.
    • When the playtest reports "no index.html found", the repair brief should lead with that ("there is no index.html at the repo root; create one") instead of a generic playtest dump.
  2. Then run trog run "<brief>" again and watch gate_slice. That gate is the fast signal now — if the slice plays, the milestone walk is the next unknown.

Practical notes for whoever picks this up

  • Brief used throughout: a one-screen arcade game where you catch falling fish in a bucket, score goes up, speed increases. Deliberately tight — one screen, few assets, obvious win/lose.
  • trog run needs TROG_URL=http://trogdor:8200 TROG_AEGRA_URL=http://trogdor:2026 TROG_FILES_URL=http://trogdor:3923.
  • Useful live probes: GET /orch/build (session events, the agent's own narration), GET /orch/health, thread state at /threads/{id}/state (phase, next, gates, fixes), and trog playtest trog-games/<slug> — CPU-only, safe to run during a drain. Read the whole playtest output; the verdict line is at the top and a tail will hide it.
  • Asset verdicts live on trog_asset_items.verdict / verdict_note.
  • Look at the artifacts. Twice this session the verdicts said one thing and the images said another; both times the images were right.
  • Runs 1–7 left junk repos under trog-games/a-one-screen-arcade-* and create-a-cozy-fishing-*. Safe to delete; I don't do hard deletes.
  • trog profile switches rigs. trogdor-speed (resident 35B-A3B MoE, klein-4b art) is what all of this ran on; trogdor-quality is the FLUX rig with a bracketed brain, where slice_build falls back to placeholder art because the brain can't generate mid-session.

Still open from this ticket

The milestone walk still batches (production -> assets -> drain -> build). Only the vertical slice generates on demand. Moving the walk over is the rest of Part 1 and should probably wait until a slice reliably plays.

## Handoff: seven headless runs, ten fixes, no working game yet Session of 2026-07-28. Everything below is pushed, CI green, deployed. Stack is idle on `trogdor-speed`. **Read the "what to do next" section first — the current blocker is small and specific.** ### What I was doing Running the full production graph headless (`trog run`), monitoring, interrupting on bugs, fixing, redeploying, repeating. Seven runs. Each one got further than the last; none produced a playable game. ### Landed (oldest first) | commit | what | |---|---| | `0fc0414`, `4a6df1f` | **CI was red on a stale cached `.venv`.** Its key only changed when `requirements-dev.txt` did, so a bad venv stayed bad forever. Proved the code innocent first (clean linux/py3.14 container: ruff clean, 259 passed), then bumped the key. Green since. | | `2e99307` | Three things one run found: MoE context **65536 → 131072** (measured: 25.6/32 GB at 128k, ~6 GB spare) after the agent webfetched a minified Phaser bundle and blew the window; briefs warn off large fetches; **stall watchdog** (`BUILD_STALL_SECS`, 600) aborts a session that goes silent instead of burning the full `BUILD_TIMEOUT` hour. Also: **the fun gate never passed** — 4/4 FAIL across two runs, every critique fair and none decisive, because the prompt said "judge harshly" and never said what PASS means. Now states the bar. Passes first try on every run since. | | `fa311b2` | **A milestone replaced the game.** The plan opened with "Tech Spike: The Glow", the developer built the spike faithfully, and it overwrote `index.html`. Planning now requires every milestone to end playable; both build briefs say `index.html` IS the game, never replace it. | | `5fa81af` | **Phaser 4 bootstrap guessed wrong twice** (`+esm` has no default export; `undefined.add`). House rules now pin the exact script tag and name both failures. No bootstrap errors since. | | `b50bc4e` | **`gate_slice`** — three runs committed a slice that never drew a frame, because `gate_bar` judges the *spec document*. The slice is now played before it is judged, and failures go back to their author with the console attached, bounded by `SLICE_FIX_RETRIES`. | | `cbc69a7` | A bare `422` was killing repair sessions with no reason given. The error now carries Forgejo's body — **it found the next bug within one run.** | | `1df6e7c` | **Every image was failing its critic** and the agent had started shipping procedural placeholders. Not the generator: eye-checked probes (a clean fish, a proper bucket) both FAILED, and the bucket's critique praised it before failing it. Measured, same image: judged against `"a small orange fish, side view"` → **PASS**; against that + `CRAFT_SPRITE_SUFFIX` → **FAIL**. `build_spec` was handing the craft-suffixed prompt to the critic as the brief, turning generation guidance into an acceptance test — and overriding the style-block craft opt-in. Spec now carries `brief` beside `prompt`. | | `188be3a` | Failures then changed to "Style Violation": the agent was writing briefs like *"clean 2D vector style, 32x32 pixels"* against a pixel-art pipeline. `generate_asset` now says outright that it makes pixel art. | | `a4b6705` | The 422, named at last: `repository file already exists [path: game.js]`. `commit_files` checked existence **on the branch**, and Forgejo serves a stale contents read for a window after a fresh commit — the same window `read_binary` documents. So a just-created file read as absent, we chose `create`, batch rejected. It threw away two repair sessions that had *already fixed the game*. Existence is now pinned to `head_commit`. | 275 tests, all with regression cover for the above. ### Where run 7 died — the current blocker ``` gates: fun:pass, slice_plays:fail x3 reason: playtest could not run: 422: no index.html found under / ``` Commits succeeded (the 422 fix held), repairs ran and landed — but the slice build **never produced `index.html` at the repo root**, so the playtest had nothing to open. It then burned all three `gate_slice` attempts on the same report and advanced on its retry cap. This is much shallower than earlier failures and smells like the same class: the rule exists (AGENTS.md says the headless browser opens `index.html` at the root) but nothing *checks* it, and the feedback is expensive and generic. ### What to do next 1. **Make the missing entry point cheap to fix.** Two candidates, probably both: - `slice_build` / `build_phase` verify `index.html` exists at the repo root before the node returns, and re-prompt immediately if not. - When the playtest reports "no index.html found", the repair brief should lead with *that* ("there is no index.html at the repo root; create one") instead of a generic playtest dump. 2. Then run `trog run "<brief>"` again and watch `gate_slice`. That gate is the fast signal now — if the slice plays, the milestone walk is the next unknown. ### Practical notes for whoever picks this up - Brief used throughout: `a one-screen arcade game where you catch falling fish in a bucket, score goes up, speed increases`. Deliberately tight — one screen, few assets, obvious win/lose. - `trog run` needs `TROG_URL=http://trogdor:8200 TROG_AEGRA_URL=http://trogdor:2026 TROG_FILES_URL=http://trogdor:3923`. - Useful live probes: `GET /orch/build` (session events, the agent's own narration), `GET /orch/health`, thread state at `/threads/{id}/state` (phase, next, gates, fixes), and `trog playtest trog-games/<slug>` — CPU-only, safe to run during a drain. **Read the whole playtest output; the verdict line is at the top and a `tail` will hide it.** - Asset verdicts live on `trog_asset_items.verdict` / `verdict_note`. - **Look at the artifacts.** Twice this session the verdicts said one thing and the images said another; both times the images were right. - Runs 1–7 left junk repos under `trog-games/a-one-screen-arcade-*` and `create-a-cozy-fishing-*`. Safe to delete; I don't do hard deletes. - `trog profile` switches rigs. `trogdor-speed` (resident 35B-A3B MoE, klein-4b art) is what all of this ran on; `trogdor-quality` is the FLUX rig with a bracketed brain, where `slice_build` falls back to placeholder art because the brain can't generate mid-session. ### Still open from this ticket The milestone walk still batches (`production -> assets -> drain -> build`). Only the vertical slice generates on demand. Moving the walk over is the rest of Part 1 and should probably wait until a slice reliably plays.
Author
Owner

The blocker was not the blocker: the gate was never playing the game

Session of 2026-07-28, picking up from the handoff above. Four fixes, all
pushed and CI-green; three deployed, one waiting on the current run.

The "missing index.html" was a symptom

Comment 430 named 422: no index.html found under / as the blocker and
guessed the slice build was failing to write an entry point. It wasn't.

a-one-screen-arcade-2607281339 walked ideation to ship without committing
one line of code. Its builds list is seventeen identical entries:

{'milestone': 0, 'repair': False, 'ok': False, 'commit': '',
 'error': '409: {"detail":"a build is already running"}'}

That run had been started while ...1313 still held the build seat. The
orchestrator serves one build at a time and refuses the rest with 409, and
_hire read the refusal as a result — no commit, ledger fail, node
returns. The gates then did exactly what they are designed to do with a
build that does not run: repair, repair, advance on the cap, for four
milestones plus alpha and beta, against an agent that had never been hired.
Every gate reported "no index.html" because there genuinely was no index.html,
because nothing had ever been built.

e56411b — a 409 waits for the seat now. Two concurrent runs serialize,
which is what a single-seat orchestrator means. Any other non-200 still
fails fast: a 500 is an answer, not a queue.

The real one: a gate that never presses a key cannot tell a game from a poster

...1313 did build a game, and its gates passed it. Milestone 1:
PASS (59.5 fps; 1 canvas; 793 colors; motion 5.37). Milestone 2:
PASS (59.5 fps; 1 canvas; 793 colors; motion 5.37). The same number to two
decimal places, twice. Milestone 3 as well.

The screenshots say why. Every one of them is this:

Bucket ListClick to start

Nobody had clicked it. playtest.run was passive: load the page, screenshot
every two seconds, judge. motion 5.37 was the idle tween on the title text.
Three milestone gates in a row photographed a title card and called it
playable.

Driving that same commit by hand, one run found:

Phaser.Input.Keyboard.JustPressed is not a function

In a keyboard handler — so it could only ever fire when something pressed a
key, and nothing ever did. A fourth invented API (this.pauseMenuLine.linePath)
turned up the same way, after g.strokeArc and Phaser.Filter.DisplacementMap.

Before changing the gate I wanted to know whether the input path worked at
all, or whether a click through the runner was a no-op everywhere. So:
trog-games/input-probe — a minimal Phaser 4 page whose only job is to
paint a green box on pointerdown and an orange one on keydown. Through
the playtest service it comes back POINTERDOWN OK | KEYDOWN ArrowLeft.
The harness was fine. The game's start handler was broken, and three gates
had no way to notice. Worth keeping — it answers "is it me or the game?" in
sixty seconds.

b8e5642playtest.run and run_zip drive by default: wait out the
CDN boot, click the middle of the canvas to get past a start screen, run the
arrows, then try space. Space comes last and after the measured screenshot,
because it is a start button in some games and a pause in others, and pausing
the game you are photographing is how you get another honest-looking PASS.
script=[] still means "watch it, do not touch it"; fixture_baseline asks
for exactly that, so its two verdicts stay a fixed contract.

I looked at gating on motion and decided against it: the title-card tween
scored 5.37 while the known-good fixture scores 1.34, so any threshold that
catches a dead game fails a live one. Motion stays reported, not gated —
page errors under real input are the honest hard gate, and those now fire.

The agent could not see its own bug either

Each repair session fixed the call it was shown and confidently wrote the
next one. What ran out was never the bugs; it was the retry cap. The agent
wrote code, its session ended, and only then did a browser open — so a made-up
API came back a whole session later, if at all.

1abeb83playtest_build, an MCP tool that zips the LIVE workspace,
posts it to the playtest service's existing /playtest/upload, and hands the
agent back its own console errors, failed requests and fps. Fifteen seconds,
no GPU, no commit, and it drives the game exactly like the gate. AGENTS.md
and every build brief now tell it to run that before finishing and after every
fix, and to believe the report over its reading of its own code.

Nothing is recorded. A ledger verdict is keyed to a commit and is the verdict
for that commit; a workspace has no commit, and writing one against HEAD would
let a build the agent has since changed read as already judged — the trap
critics.prejudge_audio documents.

The MCP belt now rides on every rig. It was withheld wherever the brain
yields its cards so the agent would not be handed generate_asset where it
must refuse — but that also withheld the one CPU-only tool that tells it
whether its code runs, on the exact rig whose slice falls back to placeholder
art. generate_asset refuses itself there, with the reason, and the brief
says so plainly.

One more, found by hitting it

f7351d6 — an interrupted run has to give the build seat back. A
superseded run resumed after the redeploy, hired a session, and took the seat;
the graph side had been cancelled, so nothing would ever read its commit, but
the session ran on and the only way to recover the seat was stopping the
agent's container over SSH by hand. Survivable only while a busy seat failed
the next run outright — now that a 409 waits, an orphaned session stalls the
next run for the full lock timeout. POST /build/cancel cancels the task;
codeseat.build already tears its container down in a finally, so that is
the whole job. Pushed and CI-green but NOT deployed — deploying would have
interrupted the run in flight.

State

292 tests, lint clean, playtest fixture baseline still passes both directions
(good PASS / broken FAIL). playtest_build is live on the MCP surface;
the deployed gate completes its ten input steps.

Run 9 is in flight: trog-games/a-one-screen-arcade-2607281432, thread
125acbd6-0f9e-4af9-be61-1d36468d75d8. It is the first run where the gate
actually plays the game and the agent can see its own build. gate_slice is
the signal to watch.

Notes for whoever picks this up

  • Redeploy after the run to ship f7351d6: make build push, then the
    Portainer API PUT /api/stacks/92/git/redeploy?endpointId=39 with
    pullImage: true and the existing Env — the webhook alone does not pull.
  • trog run needs TROG_URL=http://trogdor:8200 TROG_AEGRA_URL=http://trogdor:2026 TROG_FILES_URL=http://trogdor:3923.
  • To judge a build without touching the ledger:
    POST /orch/playtest {"game": ..., "record": false} — and look at the
    screenshots
    , not the verdict line. Twice in the last two sessions the
    verdict said one thing and the picture said another; both times the picture
    was right. That is the whole reason this session found anything.
  • Junk repos from runs 1-9 under trog-games/a-one-screen-arcade-* and
    create-a-cozy-fishing-* are safe to delete. I don't do hard deletes.
## The blocker was not the blocker: the gate was never playing the game Session of 2026-07-28, picking up from the handoff above. Four fixes, all pushed and CI-green; three deployed, one waiting on the current run. ### The "missing index.html" was a symptom Comment 430 named `422: no index.html found under /` as the blocker and guessed the slice build was failing to write an entry point. It wasn't. `a-one-screen-arcade-2607281339` walked ideation to ship without committing one line of code. Its `builds` list is seventeen identical entries: ``` {'milestone': 0, 'repair': False, 'ok': False, 'commit': '', 'error': '409: {"detail":"a build is already running"}'} ``` That run had been started while `...1313` still held the build seat. The orchestrator serves one build at a time and refuses the rest with 409, and `_hire` read the refusal as a *result* — no commit, ledger fail, node returns. The gates then did exactly what they are designed to do with a build that does not run: repair, repair, advance on the cap, for four milestones plus alpha and beta, against an agent that had never been hired. Every gate reported "no index.html" because there genuinely was no index.html, because nothing had ever been built. **`e56411b`** — a 409 waits for the seat now. Two concurrent runs serialize, which is what a single-seat orchestrator means. Any other non-200 still fails fast: a 500 is an answer, not a queue. ### The real one: a gate that never presses a key cannot tell a game from a poster `...1313` did build a game, and its gates passed it. Milestone 1: `PASS (59.5 fps; 1 canvas; 793 colors; motion 5.37)`. Milestone 2: `PASS (59.5 fps; 1 canvas; 793 colors; motion 5.37)`. The same number to two decimal places, twice. Milestone 3 as well. The screenshots say why. Every one of them is this: > **Bucket List** — *Click to start* Nobody had clicked it. `playtest.run` was passive: load the page, screenshot every two seconds, judge. `motion 5.37` was the idle tween on the title text. Three milestone gates in a row photographed a title card and called it playable. Driving that same commit by hand, one run found: ``` Phaser.Input.Keyboard.JustPressed is not a function ``` In a keyboard handler — so it could only ever fire when something pressed a key, and nothing ever did. A fourth invented API (`this.pauseMenuLine.linePath`) turned up the same way, after `g.strokeArc` and `Phaser.Filter.DisplacementMap`. Before changing the gate I wanted to know whether the input path worked at all, or whether a click through the runner was a no-op everywhere. So: **`trog-games/input-probe`** — a minimal Phaser 4 page whose only job is to paint a green box on `pointerdown` and an orange one on `keydown`. Through the playtest service it comes back `POINTERDOWN OK | KEYDOWN ArrowLeft`. The harness was fine. The game's start handler was broken, and three gates had no way to notice. Worth keeping — it answers "is it me or the game?" in sixty seconds. **`b8e5642`** — `playtest.run` and `run_zip` drive by default: wait out the CDN boot, click the middle of the canvas to get past a start screen, run the arrows, then try space. Space comes last and *after* the measured screenshot, because it is a start button in some games and a pause in others, and pausing the game you are photographing is how you get another honest-looking PASS. `script=[]` still means "watch it, do not touch it"; `fixture_baseline` asks for exactly that, so its two verdicts stay a fixed contract. I looked at gating on `motion` and decided against it: the title-card tween scored 5.37 while the known-good fixture scores 1.34, so any threshold that catches a dead game fails a live one. Motion stays reported, not gated — page errors *under real input* are the honest hard gate, and those now fire. ### The agent could not see its own bug either Each repair session fixed the call it was shown and confidently wrote the next one. What ran out was never the bugs; it was the retry cap. The agent wrote code, its session ended, and only then did a browser open — so a made-up API came back a whole session later, if at all. **`1abeb83`** — `playtest_build`, an MCP tool that zips the LIVE workspace, posts it to the playtest service's existing `/playtest/upload`, and hands the agent back its own console errors, failed requests and fps. Fifteen seconds, no GPU, no commit, and it **drives** the game exactly like the gate. AGENTS.md and every build brief now tell it to run that before finishing and after every fix, and to believe the report over its reading of its own code. Nothing is recorded. A ledger verdict is keyed to a commit and *is* the verdict for that commit; a workspace has no commit, and writing one against HEAD would let a build the agent has since changed read as already judged — the trap `critics.prejudge_audio` documents. The MCP belt now rides on **every** rig. It was withheld wherever the brain yields its cards so the agent would not be handed `generate_asset` where it must refuse — but that also withheld the one CPU-only tool that tells it whether its code runs, on the exact rig whose slice falls back to placeholder art. `generate_asset` refuses itself there, with the reason, and the brief says so plainly. ### One more, found by hitting it **`f7351d6`** — an interrupted run has to give the build seat back. A superseded run resumed after the redeploy, hired a session, and took the seat; the graph side had been cancelled, so nothing would ever read its commit, but the session ran on and the only way to recover the seat was stopping the agent's container over SSH by hand. Survivable only while a busy seat failed the next run outright — now that a 409 *waits*, an orphaned session stalls the next run for the full lock timeout. `POST /build/cancel` cancels the task; `codeseat.build` already tears its container down in a `finally`, so that is the whole job. **Pushed and CI-green but NOT deployed** — deploying would have interrupted the run in flight. ### State 292 tests, lint clean, playtest fixture baseline still passes both directions (`good` PASS / `broken` FAIL). `playtest_build` is live on the MCP surface; the deployed gate completes its ten input steps. Run 9 is in flight: `trog-games/a-one-screen-arcade-2607281432`, thread `125acbd6-0f9e-4af9-be61-1d36468d75d8`. It is the first run where the gate actually plays the game and the agent can see its own build. `gate_slice` is the signal to watch. ### Notes for whoever picks this up - Redeploy after the run to ship `f7351d6`: `make build push`, then the Portainer API `PUT /api/stacks/92/git/redeploy?endpointId=39` with `pullImage: true` and the existing Env — the webhook alone does not pull. - `trog run` needs `TROG_URL=http://trogdor:8200 TROG_AEGRA_URL=http://trogdor:2026 TROG_FILES_URL=http://trogdor:3923`. - To judge a build without touching the ledger: `POST /orch/playtest {"game": ..., "record": false}` — and **look at the screenshots**, not the verdict line. Twice in the last two sessions the verdict said one thing and the picture said another; both times the picture was right. That is the whole reason this session found anything. - Junk repos from runs 1-9 under `trog-games/a-one-screen-arcade-*` and `create-a-cozy-fishing-*` are safe to delete. I don't do hard deletes.
Author
Owner

A playable vertical slice, first try, zero repairs

Run 9 (trog-games/a-one-screen-arcade-2607281432, thread
125acbd6-0f9e-4af9-be61-1d36468d75d8) cleared gate_fun, gate_slice and
gate_bar with fixes {} — nothing sent back to anybody. The slice is a
real game.

Playtest run 2026-07-28/1785250572-4f9dd4, driven, three frames:

  • boot — "SALMON DOWN / Catch the falling fish! / Tap or press any key to start"
  • playing — HUD (Score: 0, Speed: 1x, Misses: 0/3), three generated
    pixel-art salmon falling, a wooden bucket, ambient bubble particles
  • lateScore: 1, Misses: 2/3, the bucket moved left and tilted on a
    juice tween, a +1 popup floating over it

The score going 0 → 1 is the part that matters: the drive script's arrow keys
moved the bucket and it caught a fish. Misses incremented, so the fail
state works too. "Catch falling fish in a bucket, score goes up, speed
increases" is implemented, running, and reachable by input.

gate_slice checks: game_entry index.html @ 1229f9d, assets_resolve
7 references all resolve, playtest_passed pass.

What the self-playtest actually did

Nine playtest_build calls in one 15-minute session, and the narration is a
clean debugging arc rather than a loop:

  1. "generateCanvas doesn't exist in Phaser 4. Let me fix the background and
    other API issues." — a fifth invented API, caught seconds after being
    written instead of a graph repair later
  2. "The game runs and renders but has 404s from trying to load missing assets.
    I need to skip loading entirely and use placeholders." — this is the
    missing-asset black screen that CLAUDE.md calls the most common way an
    autonomous build "works", caught before the commit for the first time
  3. "Game passes playtest. Let me check on the generated assets and integrate
    them." — placeholders swapped for the real sprites once they finished
    rendering
  4. "Vertical slice complete. Final playtest: PASS — all checks green."

Meanwhile /orch/health showed working_seats ['audio','image'] with the
brain up and the code agent writing — the on-demand generation #35 is about,
doing exactly what it says.

We never wired Phaser's own agent skills, and that is why it invents APIs

Raised by @cmoriarty, and correct. docs/toolbelt-audit.md surveyed this in
the original toolbelt work and marked it keep for ticket 10:

Phaser 4 in-repo agent skills (phaserjs/phaser skills/) — ~28 skill
files + v3→v4 migration skill, maintained by the framework itself — beats
anything we'd write. Zero MCP token cost: the code agent reads them as
workspace files. Wire: setup_repo (or agent workspace config) points
OpenCode at the skills dir.

Ticket 10 shipped the code seat. The skills were never wired — no mention
of them in server/, opencode/, or either compose file, ever.

Every API this pipeline has invented has a skill covering it:

invented skill
Phaser.Input.Keyboard.JustPressed input-keyboard-mouse-touch — documents JustDown, the real call
Phaser.Filter.DisplacementMap filters-and-postfx
g.strokeArc, line.linePath graphics-and-shapes
generateCanvas render-textures
all of them v3-to-v4-migration

The audit even called the shot — it deferred a library-docs MCP because
"Phaser skills likely cover v1's API-hallucination risk for free. Adopt only
if ticket 10 shows the brain hallucinating APIs the skills don't catch." We
skipped the skills, hit the risk, and have been paying for a mitigation that
was designed and never installed.

6bd70e1 bakes all 28 SKILL.md files (644K) into the code-seat image at
/opt/phaser-skills, pinned to 4.2.1 — the version the games load from the
CDN, because a skills copy that drifts from the runtime documents calls the
game does not have. Outside /workspace deliberately: the session commits its
workspace diff, so skills unpacked in there would land in every game repo.
~2k words each is far too much to inject and exactly the right size to read
one of on demand, so AGENTS.md and all three build briefs now say to
ls $PHASER_SKILLS and read the topic before writing an unseen call.
check-updates.py reports drift against the upstream tag like every other pin.

Worth being clear about the shape of the mistake: I spent the session building
detection (playtest_build, driving gates) for a problem the design had
already decided to prevent. Both earn their keep, but the audit should have
been read first.

Not candidates

  • Phaser Game Agent MCP (announced 2026-07) is hosted SaaS — Phaser account,
    builds in their private cloud, billed from $0.01/min. Fails the self-hosted /
    no-phone-home bar the audit used to reject SaaS asset generators.
  • phaserjs/editor-mcp-server drives Phaser Editor v5. Wrong shape.

Pushed, not yet deployed

f7351d6 (interrupted run hands the build seat back) and 6bd70e1 (Phaser
skills) are on main and CI-green, held back because redeploying would interrupt
run 9. Ship both after it finishes:
make build push, then PUT /api/stacks/92/git/redeploy?endpointId=39 with
pullImage: true and the existing Env.

Then verify the skills actually get read: watch GET /orch/build for the agent
listing $PHASER_SKILLS / reading a SKILL.md before it writes Phaser calls.
The success signal is fewer invented-API errors than run 9's nine self-playtest
iterations needed.

Still open on this ticket

The milestone walk still batches (production → assets → drain → build). Only
the vertical slice generates on demand. Moving the walk over is the rest of
Part 1 — and now that a slice reliably plays, that precondition is met.

## A playable vertical slice, first try, zero repairs Run 9 (`trog-games/a-one-screen-arcade-2607281432`, thread `125acbd6-0f9e-4af9-be61-1d36468d75d8`) cleared `gate_fun`, `gate_slice` and `gate_bar` with `fixes {}` — nothing sent back to anybody. The slice is a real game. Playtest run `2026-07-28/1785250572-4f9dd4`, driven, three frames: - **boot** — "SALMON DOWN / Catch the falling fish! / Tap or press any key to start" - **playing** — HUD (`Score: 0`, `Speed: 1x`, `Misses: 0/3`), three generated pixel-art salmon falling, a wooden bucket, ambient bubble particles - **late** — `Score: 1`, `Misses: 2/3`, the bucket moved left and tilted on a juice tween, a `+1` popup floating over it The score going 0 → 1 is the part that matters: the drive script's arrow keys moved the bucket and it **caught a fish**. Misses incremented, so the fail state works too. "Catch falling fish in a bucket, score goes up, speed increases" is implemented, running, and reachable by input. `gate_slice` checks: `game_entry` index.html @ `1229f9d`, `assets_resolve` 7 references all resolve, `playtest_passed` pass. ### What the self-playtest actually did Nine `playtest_build` calls in one 15-minute session, and the narration is a clean debugging arc rather than a loop: 1. "`generateCanvas` doesn't exist in Phaser 4. Let me fix the background and other API issues." — a **fifth** invented API, caught seconds after being written instead of a graph repair later 2. "The game runs and renders but has 404s from trying to load missing assets. I need to skip loading entirely and use placeholders." — this is the missing-asset black screen that CLAUDE.md calls the most common way an autonomous build "works", caught **before** the commit for the first time 3. "Game passes playtest. Let me check on the generated assets and integrate them." — placeholders swapped for the real sprites once they finished rendering 4. "Vertical slice complete. Final playtest: **PASS** — all checks green." Meanwhile `/orch/health` showed `working_seats ['audio','image']` with the brain up and the code agent writing — the on-demand generation #35 is about, doing exactly what it says. ## We never wired Phaser's own agent skills, and that is why it invents APIs Raised by @cmoriarty, and correct. `docs/toolbelt-audit.md` surveyed this in the original toolbelt work and marked it **keep** for ticket 10: > **Phaser 4 in-repo agent skills** (`phaserjs/phaser` `skills/`) — ~28 skill > files + v3→v4 migration skill, maintained by the framework itself — beats > anything we'd write. Zero MCP token cost: the code agent reads them as > workspace files. Wire: `setup_repo` (or agent workspace config) points > OpenCode at the skills dir. Ticket 10 shipped the code seat. **The skills were never wired** — no mention of them in `server/`, `opencode/`, or either compose file, ever. Every API this pipeline has invented has a skill covering it: | invented | skill | |---|---| | `Phaser.Input.Keyboard.JustPressed` | `input-keyboard-mouse-touch` — documents `JustDown`, the real call | | `Phaser.Filter.DisplacementMap` | `filters-and-postfx` | | `g.strokeArc`, `line.linePath` | `graphics-and-shapes` | | `generateCanvas` | `render-textures` | | all of them | `v3-to-v4-migration` | The audit even called the shot — it deferred a library-docs MCP because "Phaser skills likely cover v1's API-hallucination risk for free. Adopt only if ticket 10 shows the brain hallucinating APIs the skills don't catch." We skipped the skills, hit the risk, and have been paying for a mitigation that was designed and never installed. **`6bd70e1`** bakes all 28 `SKILL.md` files (644K) into the code-seat image at `/opt/phaser-skills`, pinned to **4.2.1** — the version the games load from the CDN, because a skills copy that drifts from the runtime documents calls the game does not have. Outside `/workspace` deliberately: the session commits its workspace diff, so skills unpacked in there would land in every game repo. ~2k words each is far too much to inject and exactly the right size to read *one* of on demand, so AGENTS.md and all three build briefs now say to `ls $PHASER_SKILLS` and read the topic before writing an unseen call. `check-updates.py` reports drift against the upstream tag like every other pin. Worth being clear about the shape of the mistake: I spent the session building **detection** (`playtest_build`, driving gates) for a problem the design had already decided to **prevent**. Both earn their keep, but the audit should have been read first. ### Not candidates - **Phaser Game Agent MCP** (announced 2026-07) is hosted SaaS — Phaser account, builds in their private cloud, billed from $0.01/min. Fails the self-hosted / no-phone-home bar the audit used to reject SaaS asset generators. - **`phaserjs/editor-mcp-server`** drives Phaser Editor v5. Wrong shape. ## Pushed, not yet deployed `f7351d6` (interrupted run hands the build seat back) and `6bd70e1` (Phaser skills) are on main and CI-green, held back because redeploying would interrupt run 9. Ship both after it finishes: `make build push`, then `PUT /api/stacks/92/git/redeploy?endpointId=39` with `pullImage: true` and the existing Env. Then verify the skills actually get read: watch `GET /orch/build` for the agent listing `$PHASER_SKILLS` / reading a `SKILL.md` before it writes Phaser calls. The success signal is fewer invented-API errors than run 9's nine self-playtest iterations needed. ## Still open on this ticket The milestone walk still batches (`production → assets → drain → build`). Only the vertical slice generates on demand. Moving the walk over is the rest of Part 1 — and now that a slice reliably plays, that precondition is met.
Author
Owner

Run 9 shipped — a complete walk, and a playable game

builds: 7/7 sessions produced a commit
gates:  1 of 7 verdicts were FAIL (sent back, then passed)
play it: http://trogdor:3923/games/a-one-screen-arcade-2607281432/

For contrast, a-one-screen-arcade-2607281339 — the run the last handoff was
written from — was 0 of 17.

Verified at HEAD with a driven playtest, not from the summary line: all six
checks green, 58.7 fps, motion 24.68, zero page errors, zero failed requests.
The screenshots show Score: 2, Misses: 2/3 with the hearts depleting, a
+2 popup, and the bucket moved left by the gate's arrow keys. It is a game.

Ideation → preproduction → slice → 3 milestones → alpha → beta → ship, with
one repair in the whole run (M2's setLoop on a null sound, handed back with
the console and fixed on the first retry).

Three more bugs, all found by watching rather than by tests

84b7a16 — the stall watchdog had never worked. A milestone session went
quiet; the watchdog fired on schedule, logged "STALLED — aborting the session",
and nothing happened. It posted to /session/abort with no id, which OpenCode
answers 200 — it reads the path as a session named "abort" and dutifully
aborts that. So it got a success for a no-op and returned satisfied, while the
wedged session held the seat toward the full BUILD_TIMEOUT hour the watchdog
exists to prevent. Proved on the live container: both URLs return 200, only
/session/{id}/abort released it, and the session then committed its work.
The test was complicit — it asserted "abort" in url, which the broken URL
satisfies.

b42f17aplaytest_build 404'd on a file sitting in git. Mine, from
the same session. The agent's playtest reported the music missing; it was in
the repo at 11.2MB and served fine by the gate, which clones from git. The zip
did not have it: build_playtest snapshotted with MAX_FILE_BYTES, which
answers "is this committable" — the right question for a commit and the wrong
one for a browser, which only asks whether you can serve it the bytes. The tool
built to stop the agent chasing phantoms invented one, and it cost a repair
session. Playtest zips now carry 96MB. The other half of that confusion was
already there and silent — a file too big to commit was skipped with a log line
nobody reads — so the build now names it on the feed.

f7351d6 — an interrupted run has to give the build seat back. A superseded
run resumed after a redeploy, took the seat, and the only recovery was stopping
the agent's container by hand.

Also worth recording: both wedges happened immediately after todowrite.
Two for two. If it recurs it is worth a ticket of its own.

The Phaser skills are live and being used

Verified end to end, not just in-image. Every build session since the wiring
has used them — six for six:

bash: ls $PHASER_SKILLS
"Let me check the Phaser 4 skills for the API calls I'll be using"
read: particles/SKILL.md   cameras/SKILL.md   tweens/SKILL.md
read: audio-and-sound/SKILL.md   physics-arcade/SKILL.md
read: sprites-and-images/SKILL.md   input-keyboard-mouse-touch/SKILL.md
"Good. The API calls in the current code are all verified against Phaser 4 skills."

Used both before writing and as a self-audit afterwards. The alpha session read
v3-to-v4-migration/SKILL.md — the one the toolbelt audit singled out. The
committed code contains zero invented API, and it chose .isDown over
JustDown for held-key movement, which is applying the documentation rather
than parroting it. M3 (Polish) is where the previous run died twice on
DisplacementMap and linePath; this one read particles,
graphics-and-shapes and tweens first and passed.

Deployed via an image swap with no stack redeploy — opencode/ is build-only,
so a rebuilt trog-opencode:latest takes effect on the next build session. The
updated AGENTS.md went into the live game repo the same way.

What is wrong with the shipped game

It runs. It is not good, and both defects are invisible to every gate.

1. The background is an illustration of the game.
assets/background/ocean-bg.png is a large salmon, a purple trout, a wooden
bucket and a crate painted into a seabed. Full-screen behind play, the player
sees big fish and a bucket that are not interactive, beside the tiny sprites
that are. The critic passed it correctly — it matches the brief. The brief is
the bug: the agent asked for "an underwater ocean background" and the generator
drew the subject. Same shape as the sfx-as-songs bug — the generator does what
it is asked, so fix the asking. A standing background suffix ("empty scenery
only, nothing the player could mistake for a gameplay object") is the obvious
move, mirroring SFX_SUFFIX.

2. The placeholder outlived the asset.

133:  this.load.image('bucket', 'assets/sprite/bucket.png');   // real art
311:  this.bucket = this.physics.add.sprite(W/2, H-50, 'bucket_placeholder');

The real bucket loads and is never used; the player is still the flat rectangle
the slice drew while waiting. The guard at line 322 only avoids regenerating
the placeholder — nothing ever prefers the real texture. This is a direct
consequence of the #35 on-demand flow telling the agent to draw placeholders
and swap them later, and the swap silently not happening. Candidate fixes: a
house rule that a placeholder may only be created behind
if (!this.textures.exists(realKey)), and/or a check_phase rule that an
asset committed under assets/ and loaded but never referenced by an
add.sprite/add.image is a failed check.

Neither is a regression — they are the next layer, visible only now that games
survive long enough to be looked at.

State

Nine commits, all pushed, CI-green and deployed. 295 tests. The playtest
fixture baseline still passes both directions.

Still open on this ticket: the milestone walk continues to batch
(production → assets → drain → build); only the vertical slice generates on
demand. That is the rest of Part 1, and its stated precondition — "wait until a
slice reliably plays" — is now met.

## Run 9 shipped — a complete walk, and a playable game ``` builds: 7/7 sessions produced a commit gates: 1 of 7 verdicts were FAIL (sent back, then passed) play it: http://trogdor:3923/games/a-one-screen-arcade-2607281432/ ``` For contrast, `a-one-screen-arcade-2607281339` — the run the last handoff was written from — was **0 of 17**. Verified at HEAD with a driven playtest, not from the summary line: all six checks green, 58.7 fps, motion 24.68, zero page errors, zero failed requests. The screenshots show `Score: 2`, `Misses: 2/3` with the hearts depleting, a `+2` popup, and the bucket moved left by the gate's arrow keys. It is a game. Ideation → preproduction → slice → 3 milestones → alpha → beta → ship, with one repair in the whole run (M2's `setLoop` on a null sound, handed back with the console and fixed on the first retry). ## Three more bugs, all found by watching rather than by tests **`84b7a16` — the stall watchdog had never worked.** A milestone session went quiet; the watchdog fired on schedule, logged "STALLED — aborting the session", and nothing happened. It posted to `/session/abort` with no id, which OpenCode answers **200** — it reads the path as a session *named* "abort" and dutifully aborts that. So it got a success for a no-op and returned satisfied, while the wedged session held the seat toward the full `BUILD_TIMEOUT` hour the watchdog exists to prevent. Proved on the live container: both URLs return 200, only `/session/{id}/abort` released it, and the session then committed its work. The test was complicit — it asserted `"abort" in url`, which the broken URL satisfies. **`b42f17a` — `playtest_build` 404'd on a file sitting in git.** Mine, from the same session. The agent's playtest reported the music missing; it was in the repo at 11.2MB and served fine by the gate, which clones from git. The zip did not have it: `build_playtest` snapshotted with `MAX_FILE_BYTES`, which answers "is this committable" — the right question for a commit and the wrong one for a browser, which only asks whether you can serve it the bytes. The tool built to stop the agent chasing phantoms invented one, and it cost a repair session. Playtest zips now carry 96MB. The other half of that confusion was already there and silent — a file too big to commit was skipped with a log line nobody reads — so the build now names it on the feed. **`f7351d6` — an interrupted run has to give the build seat back.** A superseded run resumed after a redeploy, took the seat, and the only recovery was stopping the agent's container by hand. Also worth recording: **both wedges happened immediately after `todowrite`**. Two for two. If it recurs it is worth a ticket of its own. ## The Phaser skills are live and being used Verified end to end, not just in-image. Every build session since the wiring has used them — six for six: ``` bash: ls $PHASER_SKILLS "Let me check the Phaser 4 skills for the API calls I'll be using" read: particles/SKILL.md cameras/SKILL.md tweens/SKILL.md read: audio-and-sound/SKILL.md physics-arcade/SKILL.md read: sprites-and-images/SKILL.md input-keyboard-mouse-touch/SKILL.md "Good. The API calls in the current code are all verified against Phaser 4 skills." ``` Used both before writing and as a self-audit afterwards. The alpha session read `v3-to-v4-migration/SKILL.md` — the one the toolbelt audit singled out. The committed code contains **zero** invented API, and it chose `.isDown` over `JustDown` for held-key movement, which is applying the documentation rather than parroting it. M3 (Polish) is where the previous run died twice on `DisplacementMap` and `linePath`; this one read `particles`, `graphics-and-shapes` and `tweens` first and passed. Deployed via an image swap with no stack redeploy — `opencode/` is build-only, so a rebuilt `trog-opencode:latest` takes effect on the next build session. The updated AGENTS.md went into the live game repo the same way. ## What is wrong with the shipped game It runs. It is not good, and both defects are invisible to every gate. **1. The background is an illustration of the game.** `assets/background/ocean-bg.png` is a large salmon, a purple trout, a wooden bucket and a crate painted into a seabed. Full-screen behind play, the player sees big fish and a bucket that are not interactive, beside the tiny sprites that are. The critic passed it *correctly* — it matches the brief. The brief is the bug: the agent asked for "an underwater ocean background" and the generator drew the subject. Same shape as the sfx-as-songs bug — the generator does what it is asked, so fix the asking. A standing background suffix ("empty scenery only, nothing the player could mistake for a gameplay object") is the obvious move, mirroring `SFX_SUFFIX`. **2. The placeholder outlived the asset.** ```js 133: this.load.image('bucket', 'assets/sprite/bucket.png'); // real art 311: this.bucket = this.physics.add.sprite(W/2, H-50, 'bucket_placeholder'); ``` The real bucket loads and is never used; the player is still the flat rectangle the slice drew while waiting. The guard at line 322 only avoids *regenerating* the placeholder — nothing ever prefers the real texture. This is a direct consequence of the #35 on-demand flow telling the agent to draw placeholders and swap them later, and the swap silently not happening. Candidate fixes: a house rule that a placeholder may only be created behind `if (!this.textures.exists(realKey))`, and/or a `check_phase` rule that an asset committed under `assets/` and loaded but never referenced by an `add.sprite`/`add.image` is a failed check. Neither is a regression — they are the next layer, visible only now that games survive long enough to be looked at. ## State Nine commits, all pushed, CI-green and deployed. 295 tests. The playtest fixture baseline still passes both directions. Still open on this ticket: the milestone walk continues to batch (`production → assets → drain → build`); only the vertical slice generates on demand. That is the rest of Part 1, and its stated precondition — "wait until a slice reliably plays" — is now met.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cmoriarty/trog#35
No description provided.