Repository navigation
RHOAIENG-98942: share the RayCluster spec builders (no behaviour change) - #1182
Conversation
d72d44f to
517e2b0
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1182 +/- ##
==========================================
+ Coverage 96.68% 97.06% +0.37%
==========================================
Files 26 27 +1
Lines 2772 2722 -50
==========================================
- Hits 2680 2642 -38
+ Misses 92 80 -12 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
pawelpaszki
left a comment
There was a problem hiding this comment.
Thanks for the extraction and the field audit — the GCS-FT / labels RayJob bugs are real, and test_every_field_is_classified is the right safety net.
Inline comments below are tagged Blocking or Non-blocking. I would not /lgtm until the blocking items are addressed (or 98942 is explicitly scoped to “shared primitives + audit” with a follow-up for full unification).
|
Non-blocking — PR description is still the empty template. Title is |
chipspeak
left a comment
There was a problem hiding this comment.
I've reviewed it and echo the requests from Pawel. Given how sweeping the under the hood changes to the SDK have been the last few weeks, I'd love if we could have a document or walkthrough to outline the new approaches. It was something I think we had good context on so I don't want that to disappear.
Review from @pawelpaszki on #1182. All points verified against the code before changing anything; every one of them held. Blocking: - The standalone builder still inlined the GCS fault tolerance block instead of calling the helper extracted for it, which is exactly the duplication 98942/98943 were meant to remove. It now calls gcs_fault_tolerance_options(). The uncovered line Codecov flagged in build_ray_cluster.py was indeed that leftover ValueError. - worker_replica_counts() was dead — only its own tests called it. Both builders now use it, replacing two copies of the replicas / minReplicas / maxReplicas branch. - format_resources_param() was used on the RayJob side only; the standalone path inlined json.dumps. It now calls the shared one. Non-blocking: - The parity test claimed more than it checked. Fields whose MARKERS entry was None were filtered out of the parametrized test entirely, so num_workers, min_workers, max_workers, enable_autoscaling, enable_gcs_ft and additional_worker_groups were never parity-checked, and the additional_worker_groups sentinel was an empty list. Those fields are now listed in STRUCTURAL and asserted against the sanitised spec dicts rather than by substring on repr(), and the sentinel is a real WorkerGroup. Mutation-checked: dropping GCS FT or additional worker groups from the RayJob path fails the suite. - Bottom-of-file imports moved to the top; there was no cycle, so the noqa: E402 pair was covering nothing. - Deleted the ODH volumes section heading left stranded above build_ray_cluster_spec. - docs: labels moved out of SUPPORTED into a PARTIAL section. Both paths carry them, but only the RayJob path puts them on the default head and worker pods, so a NetworkPolicy selector still behaves differently. Fixing that changes standalone output, so it belongs with the follow-up. - docs: added an explicit list of what is shared versus what each builder still assembles independently. The remaining blocking point — that the tree is still duplicated and 98942 should not close as done — is answered in the PR description rather than here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@szaher: This pull request references RHOAIENG-98942 which is a valid jira issue. Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the task to target the "5.1.0" version, but no target version was set. DetailsIn response to this:
Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository. |
|
Thanks both — all seven points verified against the code before changing anything, and every one held. Pushed as Rescoped, per @pawelpaszki's third blocking point. RHOAIENG-98942 stays open and is back In Progress; the remaining spec-tree assembly is RHOAIENG-99560. This PR closes RHOAIENG-98943 only. The description now has a "What is not done" section so the split is explicit. Blocking
Non-blocking — all four fixed. The parity-test one was the sharpest: six fields really were being filtered out of the parametrized test, and the @chipspeak on the walkthrough — agreed, and it is bigger than this PR: #1176 (client isolation), #1178 (docs/demos), #1179 (facade typing) and this one all changed how the SDK is put together. What landed here is One gap deliberately left open and recorded on RHOAIENG-99560: the parity test enumerates |
|
Re-reviewed Still blocking
Nit: standalone extra groups still inline |
Thanks @szaher and yeah for sure we can do the walkthrough elsewhere, just wanted to flag it here for posterity prior to merge. Happy to LGTM once Pawel's re-review is addressed. |
|
Re-review addressed — PR body — rewritten against the repo template: 98942 AC — agreed, and nothing in this PR claims otherwise. The description has a "What is not done" section naming the same three items you did: head/worker assembly, the two Nit — real, fixed. 354 tests pass in @chipspeak understood, and thanks for flagging it on the record — I will open the walkthrough as a separate PR under the same epic. |
|
happy to approve once the PR is squashed |
+1 |
|
/hold |
Review from @pawelpaszki on #1182. All points verified against the code before changing anything; every one of them held. Blocking: - The standalone builder still inlined the GCS fault tolerance block instead of calling the helper extracted for it, which is exactly the duplication 98942/98943 were meant to remove. It now calls gcs_fault_tolerance_options(). The uncovered line Codecov flagged in build_ray_cluster.py was indeed that leftover ValueError. - worker_replica_counts() was dead — only its own tests called it. Both builders now use it, replacing two copies of the replicas / minReplicas / maxReplicas branch. - format_resources_param() was used on the RayJob side only; the standalone path inlined json.dumps. It now calls the shared one. Non-blocking: - The parity test claimed more than it checked. Fields whose MARKERS entry was None were filtered out of the parametrized test entirely, so num_workers, min_workers, max_workers, enable_autoscaling, enable_gcs_ft and additional_worker_groups were never parity-checked, and the additional_worker_groups sentinel was an empty list. Those fields are now listed in STRUCTURAL and asserted against the sanitised spec dicts rather than by substring on repr(), and the sentinel is a real WorkerGroup. Mutation-checked: dropping GCS FT or additional worker groups from the RayJob path fails the suite. - Bottom-of-file imports moved to the top; there was no cycle, so the noqa: E402 pair was covering nothing. - Deleted the ODH volumes section heading left stranded above build_ray_cluster_spec. - docs: labels moved out of SUPPORTED into a PARTIAL section. Both paths carry them, but only the RayJob path puts them on the default head and worker pods, so a NetworkPolicy selector still behaves differently. Fixing that changes standalone output, so it belongs with the follow-up. - docs: added an explicit list of what is shared versus what each builder still assembles independently. The remaining blocking point — that the tree is still duplicated and 98942 should not close as done — is answered in the PR description rather than here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bf1618a to
2fa3fe1
Compare
|
Split, and the scope has narrowed — We are releasing without this, so I went back over it for release risk and found something that changes the GCS FT story. GCS fault tolerance is out of this PR, and 98943 is reopened
So the RayJob path is not dropping GCS FT by accident. I read the audit's "absent on one side" as drift and did not check why. Emitting it would have re-enabled, on a path nobody has validated, a feature that was removed because it did not work. What #1091 genuinely broke is narrower: making
|
ClusterConfiguration(labels=...) reached additional worker groups and nowhere else on the RayJob path. _build_additional_worker_group_spec merged config.labels into its pod template, while the head and default worker templates passed annotations and no labels at all. No error, no warning — a NetworkPolicy selector or cost-allocation label simply did not appear on the pods it was meant to select. Found while auditing the two RayCluster spec builders for RHOAIENG-98942; not previously tracked by any ticket. Split out of #1182 so it can ship on its own: two lines of production code, no change to cluster behaviour beyond pod metadata, and independent of the builder consolidation that PR is doing. Mutation-checked: reverting either line fails the new tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ClusterConfiguration feeds two independent spec builders — the standalone RayCluster in ray/cluster/build_ray_cluster.py and the embedded rayClusterSpec in ray/rayjobs/config.py. A field wired into one and forgotten in the other raises nothing; the cluster just comes up without it. Unifying them is a large refactor, so the parity check lands first and the refactor is held to it. The test sets every ClusterConfiguration field to a recognisable sentinel, renders both specs and asserts each sentinel reaches both. Anything that legitimately does not reach a builder is listed with a reason: - CONTEXT_ONLY (3): overwrite_default_resource_mapping and enable_usage_stats are consumed by __post_init__ (the latter flows on via envs); verify_tls governs dashboard calls, not the CR. - NOT_APPLICABLE (3): name, namespace and write_to_file are meaningless inside a RayJob, which derives '<job_name>-cluster' and embeds a spec with no metadata block. - CONFLICTED (1): local_queue is set on both sides with undefined precedence (RHOAIENG-98949). test_every_field_is_classified fails on a new field that is neither given a sentinel nor classified, so "no field may be silently ignored" is enforced rather than asserted. Four fields the RayJob path drops today are xfail(strict=True) rather than exempt, since they are bugs and the unification has to remove them: - redis_address, redis_password_secret, external_storage_namespace — GCS fault tolerance is entirely absent from the RayJob builder (RHOAIENG-98943). - labels — reaches additional worker groups only; the head and default worker pod templates at rayjobs/config.py:146,166 pass annotations and no labels. Not previously tracked by any ticket. Also recorded in the test: build_ray_cluster() is not pure. It reaches the Kubernetes API three times while rendering — local_queue_exists(), get_default_local_queue() and validate_autoscaling_with_kueue() — which the RayJob builder never does. The canonical builder therefore has to split a pure renderer from the resolve-cluster-facts step, or the RayJob path inherits network calls it does not make today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The standalone and RayJob builders each carried their own copy of the same primitives, which is how they drifted in the first place. Both now delegate to ray/cluster/raycluster_spec.py. Proven identical to both originals before being removed: the ODH trusted-CA volumes and mounts (byte-for-byte duplicates), cpu_limit_to_num_cpus (a literal copy), the storage merge, resource requirements, GPU counting, extended-resource mapping, and the head and worker containers — the last two compared by repr against both implementations. The module is deliberately side-effect-free. build_ray_cluster() reaches the Kubernetes API three times while rendering — local_queue_exists(), get_default_local_queue() and validate_autoscaling_with_kueue() — and the RayJob builder makes no calls at all, so anything needing the cluster stays in the caller. Lifting that I/O out is the next step. Two pieces are added here for the fixes that follow rather than used yet: gcs_fault_tolerance_options() and worker_replica_counts(), both unit tested. build_ray_cluster.py keeps its existing function names as one-line delegations, so no call site moved and the diff stays reviewable. Two spec builders totalling 1,186 lines are now 838 plus a 279-line shared module. Test patch targets for update_image moved with it, except in test_build_spec_additional_group_inherits_image: additional worker groups resolve their own image in rayjobs/config.py and still patch there. Not yet fixed, still xfail: the five fields the RayJob path drops. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five fields the parity test had marked xfail now reach both builders, so KNOWN_GAPS is empty and the xfails are gone. GCS fault tolerance (RHOAIENG-98943) was the dangerous one. enable_gcs_ft, redis_address, redis_password_secret and external_storage_namespace were accepted and validated by ClusterConfiguration, then ignored entirely by the RayJob builder — the four names did not appear anywhere in rayjobs/config.py. A job configured for fault tolerance came up with no Redis to recover from, reported nothing, and only failed when the head node died. build_ray_cluster_spec() now emits gcsFaultToleranceOptions from the shared helper, the same one the standalone path uses. labels reached additional worker groups only. _build_additional_worker_group_spec merged config.labels into its pod template while the head and default worker templates passed annotations and nothing else, so ClusterConfiguration(labels=...) landed on extra worker groups and nowhere else. Both templates now pass them. The asymmetry that remains is deliberate: the standalone path also puts labels on the RayCluster metadata, which the embedded spec has no room for. Pod templates are where they can land here, and where this path already put them for additional worker groups. Regression tests cover the RayJob path directly rather than relying on the parity markers, including that metadata stays None when no labels are set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rule docs/raycluster_spec_parity.md is the audit the ticket asks for: all 36 ClusterConfiguration fields classified SUPPORTED (29), CONTEXT_ONLY (3), NOT_APPLICABLE (3) or CONFLICTED (1), each with a reason. The page is not the enforcement — ray/test_builder_parity.py is — but it explains the decisions the test encodes, including why raycluster_spec.py has to stay free of Kubernetes API calls. The rayjobs rule files claimed "Must NOT import from ray.cluster or ray.client", which stopped being true at #1091: rayjobs/config.py:46 and rayjob.py:36 both import ClusterConfiguration from ray.cluster.config, and .importlinter has no contract for it, so CI never caught the contradiction. Both files now describe the real boundary — cluster.config and cluster.raycluster_spec are allowed, everything else in ray.cluster is not, and it is prose-only — and point at the shared module as the place to add a field. AGENTS.md's two-builder hazard note said a field added to one builder is dropped by the other, with no mention of where to put one instead. It now names raycluster_spec.py, the parity test and the audit page, and carries the no-I/O constraint. Every repo path and symbol cited across the four files was checked to resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review from @pawelpaszki on #1182. All points verified against the code before changing anything; every one of them held. Blocking: - The standalone builder still inlined the GCS fault tolerance block instead of calling the helper extracted for it, which is exactly the duplication 98942/98943 were meant to remove. It now calls gcs_fault_tolerance_options(). The uncovered line Codecov flagged in build_ray_cluster.py was indeed that leftover ValueError. - worker_replica_counts() was dead — only its own tests called it. Both builders now use it, replacing two copies of the replicas / minReplicas / maxReplicas branch. - format_resources_param() was used on the RayJob side only; the standalone path inlined json.dumps. It now calls the shared one. Non-blocking: - The parity test claimed more than it checked. Fields whose MARKERS entry was None were filtered out of the parametrized test entirely, so num_workers, min_workers, max_workers, enable_autoscaling, enable_gcs_ft and additional_worker_groups were never parity-checked, and the additional_worker_groups sentinel was an empty list. Those fields are now listed in STRUCTURAL and asserted against the sanitised spec dicts rather than by substring on repr(), and the sentinel is a real WorkerGroup. Mutation-checked: dropping GCS FT or additional worker groups from the RayJob path fails the suite. - Bottom-of-file imports moved to the top; there was no cycle, so the noqa: E402 pair was covering nothing. - Deleted the ODH volumes section heading left stranded above build_ray_cluster_spec. - docs: labels moved out of SUPPORTED into a PARTIAL section. Both paths carry them, but only the RayJob path puts them on the default head and worker pods, so a NetworkPolicy selector still behaves differently. Fixing that changes standalone output, so it belongs with the follow-up. - docs: added an explicit list of what is shared versus what each builder still assembles independently. The remaining blocking point — that the tree is still duplicated and 98942 should not close as done — is answered in the PR description rather than here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review nit from @pawelpaszki: _build_worker_group_spec still inlined json.dumps for its rayStartParams resources while every other call site used the shared helper. The remaining json.dumps in this module is the YAML file writer, which is unrelated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch now changes no cluster or job behaviour. It shares duplicated spec-building code and records the differences; the two behavioural fixes have moved out. GCS fault tolerance is no longer emitted on the RayJob path. Investigating the release risk turned up 52a351a, "RHOAIENG-30720: Remove GCS FT for Lifecycled RayClusters", which stripped those four fields from the lifecycled path in August 2025 because the feature did not work — head pod restarts lost state — and whose ticket says the RayJob implementation was out of scope for the fix. So the absence was a decision, not drift, and emitting them here would re-enable something nobody has validated on that path. The real bug #1091 introduced is narrower: making ClusterConfiguration shared means the fields are now accepted and validated on the RayJob path and then ignored. Rejecting them or emitting them is RHOAIENG-98943's call; the asymmetry is pinned by test_gcs_fault_tolerance_is_standalone_only so neither side moves by accident. config.labels on the head and default worker pods moves to #1184, two lines that can ship without this refactor. labels is reclassified PARTIAL rather than a gap. A presence check passes on both paths because the RayJob builder merges config.labels into additional worker group pods; what differs is the default head and worker pods. The earlier xfail would have XPASSed once the sentinel config gained a real WorkerGroup, which is how this surfaced. docs/raycluster_spec_parity.md gains a PENDING DECISION section and the counts still total 36: SUPPORTED 24, PARTIAL 1, PENDING DECISION 4, CONTEXT_ONLY 3, NOT_APPLICABLE 3, CONFLICTED 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2fa3fe1 to
8b9e045
Compare
Review from @chipspeak: the WorkerGroup sentinel set only group_name and replicas, so envs, labels, tolerations, image, the GPU settings and extended resources could all drift between the two builders while this suite stayed green. Correct — additional_worker_groups is assembled by a second pair of duplicated functions, _build_worker_group_spec and _build_additional_worker_group_spec, which can diverge independently of the main builders this file was written to guard. All 15 fields now carry sentinels, and the four inherit-or-override rules get named tests on both paths, because a presence check cannot express them: image and tolerations are overrides that fall back to the cluster value, while envs and labels are merges where the group wins a key collision. The inherit case is asserted separately from the override case, so dropping either direction fails. test_every_worker_group_field_is_classified mirrors the existing ClusterConfiguration gate: a new WorkerGroup field must be given a sentinel or routed to a named test via WORKER_GROUP_STRUCTURAL. Also recorded in AGENTS.md, both rayjobs rule files and the audit doc, as asked. Mutation-checked, six ways: dropping the cluster-env merge, dropping the cluster-label merge, ignoring wg.tolerations, ignoring wg.image, dropping wg extended resources, and adding an unclassified field each fail the suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8b9e045 to
ea39c95
Compare
Review from @chipspeak: either move it under docs/designs/History or drop the language about the previous implementation, since a doc that narrates what changed has to be maintained as a changelog forever. Keeping it in place and taking the second option. docs/designs/History holds one superseded design doc, so it is an archive — and this page is live: AGENTS.md sends contributors here before adding a field, and test_builder_parity.py names it in a failure message. Filing it under History would make both pointers misleading. So the framing is now current-state. Dropped "they were written separately and drifted", "now live in", "the subject of the follow-up to this work" and the @-attribution, retitled off "audit", and led with what the reader needs: read this before adding a field, here is where each one lands, here is which asymmetries are deliberate. The duplicated assemblers are stated as a present constraint with the ticket that tracks them rather than as history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/retest |
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: chipspeak, pawelpaszki The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
Why are these changes needed?
ClusterConfigurationfeeds two independent RayCluster spec builders —ray/cluster/build_ray_cluster.pyfor a standalone cluster,ray/rayjobs/config.pyfor therayClusterSpecembedded in a RayJob. They were written separately and had drifted. A field wired into one and forgotten in the other raises nothing; the cluster just comes up without it.The bug this fixes.
enable_gcs_ft,redis_address,redis_password_secretandexternal_storage_namespaceappeared nowhere inray/rayjobs/config.py— zero occurrences, against nine in the standalone builder.ClusterConfigurationaccepted and validated them and the RayJob builder ignored them, so a job configured for fault tolerance came up with no Redis to recover from, reported nothing, and failed only when the head node died.labelswas a second, quieter case:_build_additional_worker_group_specmergedconfig.labelsinto its pod template while the head and default worker templates passed annotations and no labels, soClusterConfiguration(labels=...)landed on extra worker groups and nowhere else. Not previously tracked by any ticket.This shares the duplicated pieces, fixes the five dropped fields, and adds a test that fails when they drift again.
Related issue number
This PR changes no cluster or job behaviour. It shares duplicated spec-building code and records the differences. Both behavioural fixes have been split out.
config.labelsfix for RayJob head and worker pods — two lines, independent of this refactor.local_queueprecedence) stays open — classified CONFLICTED in the audit, not resolved here.Why GCS fault tolerance is no longer part of this PR
An earlier revision emitted
gcsFaultToleranceOptionson the RayJob path, on the assumption the fields were being dropped by drift. They are not.52a351a— "RHOAIENG-30720: Remove GCS FT for Lifecycled RayClusters", August 2025 — removed those four fields from the lifecycled path: fields, validation and emission. RHOAIENG-30720 is "Head pod persistence doesn't work"; GCS FT was broken, state was lost on head-pod restart, and the ticket states the RayJob implementation was out of scope for the fix.So emitting them again would re-enable a feature nobody has validated on that path. What #1091 genuinely broke is narrower: making
ClusterConfigurationshared means the four fields are now accepted and validated on the RayJob path and then silently ignored. Rejecting them or emitting them after validation is RHOAIENG-98943's decision; it should not be a side effect of a refactor.The asymmetry is pinned by
test_gcs_fault_tolerance_is_standalone_only, which fails if either side changes.What is not done
The spec tree is still assembled twice, which is why 98942 stays open:
headGroupSpec/workerGroupSpecsand theirrayStartParamsare built independently by each builder_build_worker_group_specand_build_additional_worker_group_specare two copies of additional worker group assemblylabelsis PARTIAL: a presence check passes on both paths because the RayJob builder mergesconfig.labelsinto additional worker group pods, but the default head and worker pods differ. RHOAIENG-98942: put config.labels on RayJob head and worker pods #1184 fixes the RayJob side; aligning the standalone side changes existing output and belongs with the rest of the unification.ClusterConfigurationonly, soWorkerGroup.extended_resource_requests(added by RHOAIENG-59978 while this PR was open) has no parity guard yet.Checks
src/codeflare_sdk/ray/test_builder_parity.pysets everyClusterConfigurationfield to a sentinel, renders both specs and asserts each sentinel reaches both. Fields with no distinctive string of their own — booleans and counts — are asserted against the sanitised spec dicts instead. A new field that is neither given a sentinel nor classified failstest_every_field_is_classified, so "no field may be silently ignored" is enforced rather than asserted.Mutation-checked; each of these fails the suite: adding a
ClusterConfigurationfield, changing a shared type, dropping an overload, and dropping additional worker groups from the RayJob path.Local run: 558 passed, 2 pre-existing failures (
test_cluster_creation_local_queue,test_default_cluster_creation— Python 3.13 has no default Ray image locally; both pass in CI). mypy clean,lint-imports4 contracts kept,raycluster_spec.pyat 100% coverage.Architecture
docs/designs/CodeFlare-SDK-design-doc.md) orAGENTS.mdNew module
ray/cluster/raycluster_spec.pyholds the shared, side-effect-free pieces: ODH CA volumes and mounts, resource requirements, GPU counting, extended-resource mapping, therayStartParamsresources string, head and worker containers and replica counts.gcs_fault_tolerance_options()lives there too but is called by the standalone path only. Each was proven identical to both originals byreprcomparison before the duplicates were deleted — the ODH volumes were byte-for-byte copies and_cpu_limit_to_num_cpuswas a literal one.Why it is not one function.
build_ray_cluster()is not pure: it reaches the Kubernetes API three times while rendering —local_queue_exists(),get_default_local_queue()andvalidate_autoscaling_with_kueue()— andbuild_ray_cluster_spec()makes no calls at all. A single builder cannot be the union of the two without the RayJob path inheriting network calls it does not make today, so resolving cluster facts has to be separated from rendering first. That is the main design constraint on RHOAIENG-99560.Boundary note.
.claude/rules/rayjobs.mdand.cursor/rules/rayjobs.mdcclaimedray.rayjobsmust not importray.cluster. That stopped being true at #1091 —rayjobs/config.pyalready importsClusterConfigurationfromray.cluster.config, and.importlinterhas no contract for it, so CI never caught the contradiction. Both files now describe the real boundary.Docs:
docs/raycluster_spec_parity.mdclassifies all 36 fields — SUPPORTED (24), PARTIAL (1), PENDING DECISION (4), CONTEXT_ONLY (3), NOT_APPLICABLE (3), CONFLICTED (1) — each with a reason.AGENTS.mdupdated to point at the shared module, the parity test and the no-I/O constraint.Note on the rebase
1be2418landed while this was open and changed_build_resource_requirementson both builders identically (memory stringified, CPU written back raw). That change now exists once, in the shared module. ItsV1ExecAction(command=...)andV1ObjectMeta(annotations=...)fixes were carried across too.🤖 Generated with Claude Code