Skip to content

Select remote Build Machines automatically - #846

Merged
nick-potts merged 2 commits into
mainfrom
t3code/implement-request
Sep 9, 2026
Merged

nick-potts merged 2 commits into
mainfrom
t3code/implement-request

Conversation

@nick-potts

@nick-potts nick-potts commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #804.

What changed

Plain --remote now selects a Build Machine instead of refusing. --remote=<Machine> still pins one, --local states the local default explicitly, and the default is unchanged. Both build and deploy share the interface.

  --local │ --remote │ --remote=X │ (nothing)
     └────────┴──────────┴─────────────┘
              Location::requested
                       │
              Local ───┴─── Remote
                │             │
         local Docker  ┌──────┴──────┐
                    Pinned        Automatic
               visible_machine()  list → probe each concurrently
               + BUILD capability        │
                                  choose(candidates, platforms)
                              most native platforms, then Machine ID
                                         │
                            report Machine ─→ then upload

Selection lives in a new build_location module as a pure function of observed evidence: visible Machines that advertise ployz.build.v1, ranked by how many requested build.platforms their reported architecture runs natively (reusing the existing platform_compatible), with Machine ID breaking ties so the choice does not depend on observation order. The selected Machine is reported before any upload, along with the platforms this client could not confirm and any Machine that did not answer for itself. The Machine still admits or refuses the Build itself.

Capture runs before selection so the platforms it fixes decide the candidates. No source leaves the client before admission.

Descoped: the Compose build location

AC2 asked for the preferred execution location in Compose, and an earlier revision of this PR added a top-level x-build-machine:. That has been removed deliberately — see the discussion on #804.

A Machine ID is unique only "within one Cluster" (CONTEXT.md:30) and a Machine Name is "a human-facing selector that may be ambiguous... not an identity" (CONTEXT.md:33). A committed Compose file is portable, so x-build-machine: tower names a different Machine in every checkout and nothing at all in CI — the same class of mistake as a hardcoded hostname. Which Machine builds is a per-invocation fact, and --remote=<Machine> is where it belongs.

This drops AC1's "Compose overrides the local default" tier and AC2's location clause. Recipe (build.x-recipe) and platforms (build.platforms) were already in Compose and are untouched.

Refusals do not turn silence into a verdict

A Machine that never answered may still have been able to build, so "no visible Machine can run this Build" is only available when every visible Machine answered for itself:

  every visible Machine answered?
            │
     ┌──────┴──────┐
    yes             no
     │               │
  any capable?   Inconclusive        ← wait and retry
     │           (refused + unresolved)
  ┌──┴──┐
 yes    no
  │      │
choose  Incapable                    ← upgrade a Machine

  nothing visible  →  Invisible      ← ployz machine add

Membership ineligibility counts as silence, not as a refusal: a Membership Observation is "one Machine's potentially incomplete or stale judgment that another Machine is... down. It is not the observed Machine's lifecycle or an authoritative liveness fact" (CONTEXT.md:50), so a stale down cannot settle capability either.

Both follow DESIGN.md bet 8 — a diagnosis "may remain Unknown", and "inferring certainty the observer lacks" is a listed red flag.

Testing rungs

  • Rung 1 — build_location.rs: flag precedence and pin rejection, permuted observation order, ID tie-break, multi-platform ranking, unresolved evidence on a successful pick, and the three refusal kinds including the mixed refused+silent case.
  • Rung 3 — cli_shape.rs (flag/positional parsing, conflicts, --local on both commands) and remote_build_cli.rs (automatic selection, explicit pin, ambiguity refusal, capability refusal text, --local override).
  • Rung 4 — build_layer3/remote.rs: a two-candidate build-and-deploy exercise covering automatic selection, an explicit pin, and the local override. build_layer3 is already registered in scripts/run-layer3-tests.sh.

cargo fmt, cargo clippy --workspace --all-targets, the full workspace suite, and the Go helper tests all pass, rebased onto current main (which includes #844).

One deliberate interpretation

Platform compatibility ranks candidates; it does not filter them. AC4 reads as though an incompatible request should be refused, but emulation support is not observable from the client and the README documents emulated cross-platform builds — filtering would refuse builds that work today. The best candidate is chosen, unverified platforms are printed, and the Machine rechecks admission.

Separately, only ployz.build.v1 is verified rather than image-store capabilities: local.rs advertises the whole CapabilityAdvertisement::Container class as one block, and BUILD_CAPABILITY sits in it alongside LIST_IMAGES_CAPABILITY and ENSURE_IMAGE_INGEST_CAPABILITY, so a separate check would be dead code rather than verification.

Plain `--remote` now selects a Build Machine instead of refusing.
`--remote=<Machine>` still pins one and `--local` forces the local
default; Compose sets the preference between them with a top-level
`x-build-machine:` that any of the three flags overrides. `auto` and
`local` name modes wherever they appear, so a Machine called either is
pinned by its ID.

Selection is a pure function of observed evidence in `build_location`:
visible Machines that advertise `ployz.build.v1`, ranked by how many
requested `build.platforms` their reported architecture runs natively
via the existing `platform_compatible`, with Machine ID breaking ties so
the choice does not depend on observation order. The selected Machine is
reported before any upload, along with the platforms this client could
not confirm and any Machine that never answered; the Machine still
admits or refuses the Build itself.

Refusals separate the three reasons a Cluster offers no Build Machine —
nothing visible, nothing answered, nothing capable — so a partition is
not reported as a capability problem, and each carries the corrective
action that actually applies. Capture runs before selection so the
platforms it fixes decide the candidates; no source leaves the client
before admission.

Closes #804
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-09T12:09:10.615133Z 8737565 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 72cafcdc4b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/ployz/src/build_location.rs Outdated
Comment on lines +182 to +184
_ => NoBuildMachine::Incapable {
observed: excluded.into_iter().chain(unanswered).collect(),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unknown status when any candidate is unanswered

When no eligible Machine is found and one visible Machine refuses builds while another probe times out, this branch returns Incapable, producing “no visible Machine can run this Build” and upgrade advice. The unanswered Machine may still be capable, so this mixed case turns unknown evidence into a capability conclusion; classify it as reachability/unknown or add a distinct mixed outcome with retry guidance. This conflicts with DESIGN.md’s evidence-over-claims invariant.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and fixed in 8737565.

The discriminant is now "did every visible Machine answer for itself?" rather than "which buckets are non-empty":

enum NoBuildMachine {
    Invisible,                                // nothing observed at all
    Inconclusive { refused, unresolved },     // silence → no verdict, wait and retry
    Incapable { refused },                    // all answered, none capable
}

So the mixed case you describe — one Machine refuses, another times out — is Inconclusive, reads "no Machine that answered can run this Build, and 1 did not answer", and advises waiting rather than upgrading. Still three variants: the old Silent is just the refused.is_empty() case.

Your comment also surfaced a second instance I had wrong. Evidence::Ineligible (membership doesn't invite an RPC) was in the refused bucket on the grounds that it's a known ineligibility. But CONTEXT.md:50 defines a Membership Observation as "one Machine's potentially incomplete or stale judgment that another Machine is... down. It is not the observed Machine's lifecycle or an authoritative liveness fact" — so a stale down is hearsay, not the Machine speaking, and can't settle capability either. It now counts as silence, keeping its own evidence wording so the user still sees why each Machine is unresolved.

Rung-1 coverage asserts both: a lone silent Machine and a refused+silent pair are each Inconclusive, and only an all-answered set reaches Incapable.

Comment thread crates/ployz/src/compose/model.rs Outdated
Comment on lines +34 to +36
/// Preferred Build execution location from the Compose `x-build-machine`
/// extension: a Machine Target, `auto`, or `local`.
pub build_machine: Option<String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate Compose build locations at deserialization

Deserializing x-build-machine as an arbitrary String means invalid values such as x-build-machine: "*" survive load_project; only the build and deploy handlers later call Location::requested, while ployz changes accepts and ignores the same invalid configuration that ployz deploy rejects. This also violates the CODING_STANDARDS.md requirement that string modes and Machine Targets be represented by types that cannot hold illegal states. Deserialize into a typed local/automatic/pinned preference so every Compose consumer gets identical validation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved by removing the field entirely in 8737565, rather than by typing it.

You were right that Option<String> couldn't hold the invariant and that ployz changes silently accepted what deploy rejected. Grilling the fix surfaced a bigger problem with the feature itself: CONTEXT.md:30 scopes Machine ID uniqueness to "within one Cluster", and CONTEXT.md:33 defines Machine Name as "a human-facing selector... that may be ambiguous. It is not an identity." A Compose file is portable and usually committed, so x-build-machine: tower names a different Machine in each checkout and none in CI.

Typing it would have made an unportable value well-formed. Build location is a per-invocation fact, so it now lives only on --remote=<Machine>; the extension, the ComposeProject field, and the Go validation are all gone, which removes the fourth-consumer problem by construction. Rationale recorded on #804.

Recipe (build.x-recipe) and platforms (build.platforms) stay in Compose — both are portable properties of the project rather than of the Cluster.

…fusals

Machine selectors are Cluster-local and may be ambiguous (CONTEXT.md:30,
33), so a committed Compose file cannot name one honestly: the same
`x-build-machine: tower` means a different Machine in every checkout, and
none at all in CI. Remove the extension rather than validate it. Build
location is per invocation: `--remote=<Machine>`, plain `--remote`,
`--local`, or the local default. `auto` and `local` stop being reserved
words, since no Compose key spells modes any more.

Refusals no longer turn silence into a capability conclusion. A Machine
that never answered may still have been able to build, so the
"none can build" verdict is only available when every visible Machine
answered for itself; otherwise the refusal is Inconclusive and advises
waiting and retrying. Membership ineligibility counts as silence, not as
a refusal: a Membership Observation is one Machine's potentially stale
judgment of another and is not the observed Machine speaking
(CONTEXT.md:50), so it cannot settle capability either.

Both follow DESIGN.md bet 8, which permits a diagnosis to remain Unknown
and flags inferring certainty the observer lacks.
@nick-potts nick-potts changed the title Select remote Build Machines automatically and honor Compose defaults Select remote Build Machines automatically Sep 9, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8737565f55

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +52 to +55
let location = Location::requested(
leaf.get_one::<String>("remote").map(String::as_str),
leaf.get_flag("local"),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor the Compose build-machine preference

When no CLI location flag is supplied, this call considers only --remote and --local, so a Compose x-build-machine preference is ignored and the build silently runs locally; deploy has the same omission. This contradicts the commit’s explicit flag > Compose > local contract, and a repo-wide search at this commit finds no remaining x-build-machine handling. Fresh evidence relative to the earlier validation comment is that the field has now been removed entirely rather than given typed validation.

Useful? React with 👍 / 👎.

@nick-potts
nick-potts merged commit 99d86f0 into main Sep 9, 2026
7 checks passed
@nick-potts
nick-potts deleted the t3code/implement-request branch September 30, 2026 15:15
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.

Select remote builders automatically and honor Compose defaults

1 participant