Skip to content

feat(software-factory): environment images built when first needed, verdicts run by image ID - #852

Merged
blove merged 29 commits into
mainfrom
blove/images-on-demand-pr1
Sep 25, 2026
Merged

blove merged 29 commits into
mainfrom
blove/images-on-demand-pr1

Conversation

@blove

@blove blove commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Framework-gaps item 4, PR 1 of 2 (plan: docs/superpowers/plans/2026-09-25-images-built-on-demand.md, Tasks 1-17). A target's image is now built the first time a work order needs it. Nobody has to run target:prepare per pin anymore.

What changes

  • Host image registry. <FACTORY_STATE_DIR>/images.sqlite is keyed by a recipe digest over the target, pin, platform, pinned base, Dockerfile hash, image context, lockfile path, resolve assertions and command cwd. Builds are single flight per key and bounded by FACTORY_MAX_IMAGE_BUILDS (default 1). FACTORY_IMAGE_BUILD_TIMEOUT_MS (default 30 min) plus a queue bound caps each wait. Each build is cancelled when its last waiter leaves, and its log is stored as evidence.
  • target.json carries no images. Each target pins baseImage by digest instead. target:prepare only warms the registry.
  • Built on first need. Intake builds at the fit step with the active budget paused. Dispatch builds before anything is spent: the row waits in received, the approved digest is re-checked after the wait, and an operator cancel abandons the build.
  • The binding is authoritative. The work order journals image_bound. The verifier, the oracle proof and approve's re-verification then run that image by ID, with policy and environment identity computed from it. A binding that is missing, unreadable, moved to another target or pin, or gone from the daemon is refused on the record (image_unbound, image_changed). It never falls back to the registry or a tag.
  • CLI. factory dispatch keeps following a build past its request timeout, using the journalled wait bound. It ends on a refusal, a restart, or that bound.
  • CI. The two explicit target:prepare lines are gone. The sandbox global setup builds cli-flags and devkit into a per-run registry. Both workflow-audit fixtures move in the same commit.

The builder still runs the recipe tag, which is now key-scoped. PR 2 (Tasks 18-21) moves the builder to the bound ID and checks the image's build labels. Examples only, so there is no changeset.

Review

Every task had a spec review and a quality review, and the branch then had a final trust-focused review. The fixes that came out of review are recorded in the plan's "As landed" notes:

  • approve's binding read is guarded
  • a bound image beats the registry, with a test that binds
  • a target or pin moved since the binding is refused
  • a concurrent dispatch that lost the key journals no refusal
  • reconcile writes no false restart marker while a dispatch is live
  • the moved-tag proof always restores the tag

Verification (local, Node 24)

  • Controller unit suite: 849 passed. Server: 21 passed. Drafter: 17 passed.
  • test:sandbox, the Docker lane: 11 files passed and 1 skipped (target-cli, opt-in); 30 tests passed and 4 skipped. This includes the new proofs:
    • an unprepared devkit pin is built at intake with the prepare script's identity
    • a moved tag moves no verdict
    • the verifier runs by ID and refuses a missing ID
  • pnpm lint, node scripts/check-docs.mjs, workflow-contracts.test.mjs and pnpm test:release-integrity pass, as do the factory packages' typechecks.
  • The identity proofs ran against a warm build cache; no cold build of the cli target was measured.

🤖 Generated with Claude Code

blove and others added 29 commits September 25, 2026 12:14
…needed

Spec item 4 of the framework-gaps design: a host-local image registry
keyed by the recipe digest, builds at intake's fit step and at dispatch
with single flight, a global limit, a timeout and refcounted cancel,
target.json without images (base pinned by digest), work-order image
binding, and (PR 2) builder and verifier running the bound image by id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Eight Important issues and the minors from the independent review:
loadTaskRecipe for the CLI and the review's pin diff; --iidfile and
recipe-key-scoped tags with an id tag; the verifier runs the bound image
by id in PR 1 with policy from the binding; dispatch honours the route's
cancel and re-checks the approved digest after the image step; a
per-run lane registry; build labels checked by the builder in PR 2; a
bounded wait journalled for the CLI; FACTORY_SKIP_BASE_PULL kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ads without an image

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… recipe at a pin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pe built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ancelled by its last waiter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and rebuilt when gone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion of a recipe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at the pin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y under its state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sk without one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esumes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and bound

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he fit step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…thing is spent

The row waits in received while the image builds, so the build costs its
budget nothing. A cancel or the route's own signal abandons the wait, and the
approved digest is checked again after it. A cancel that lands during the
image step now refuses before any thread is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e is refused on the record

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…build

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… throw, or its deadline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tag moves no verdict

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s image at the fit step

The runtime's registry lives in the lane's own state dir, so intake builds the
cli-flags image (cache-served) and journals image_prepare_started, the budget
pause, image_prepared and image_bound before the oracle, as Task 13 intends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it ends

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… moved binding is refused

Reconciliation wrote `image_prepare_aborted {reason: restart}` for a dispatch still
building its image in this process (a supervisor's /reconcile while it waited),
because `isTracked` knows only builder runs. The context now says whether a dispatch
of the id is live (`isPreparingImage`), and reconciliation writes the restart marker
only for one that is not, whenever the journal shows the dispatch preparing
(`dispatchPreparing`), not only when the last line is a build's start: a crash after
a failed or aborted build, before the refusal, no longer leaves a follower waiting.

At dispatch, a binding for another target or pin than the task names now (a shipped
task retried after its target's default pin moved) is refused as `image_changed`
(target_moved) before the key, and a daemon that cannot answer whether it holds the
bound image is a refusal, not a throw. The image-wait entry is released when the
wait ends, not only on transition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uched and never strands a build

The schema version is read before this factory creates its tables, so a newer
registry is refused without being written to. A caller counts as a build's waiter
before its `onBuild` hook runs, so a hook that throws cancels the build like any
leaving waiter instead of leaving it running with nobody waiting. The runtime
releases its registry even when closing the factory throws, and the sandbox lane's
setup closes the registry it opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the upgrade

The handoff refusal names the tag's `<key>` suffix (controller and server alike), the
intake comment says targets available at the pin, and the README says builds are
shared per target and pin, what happens to work orders dispatched before images were
bound, and that built images accumulate on the daemon until a reaper exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
b4-run Ignored Ignored Preview Sep 25, 2026 7:21pm UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@blove
blove deployed to vercel-preview September 25, 2026 19:21 — with GitHub Actions Active
@blove
blove merged commit 69e78d9 into main Sep 25, 2026
31 of 32 checks passed
@blove
blove deleted the blove/images-on-demand-pr1 branch September 25, 2026 19:39

This branch was successfully deployed

1 active deployment
vercel-preview — 68b80e11 Deployed Sep 25, 2026 by blove via vercel-native #2409
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant