Skip to content

Add external execution capacity behind a disabled feature flag - #749

Open
weilei0120 wants to merge 21 commits into
mainfrom
feat/external-execution-capacity
Open

weilei0120 wants to merge 21 commits into
mainfrom
feat/external-execution-capacity

Conversation

@weilei0120

Copy link
Copy Markdown
Collaborator

Adds the SaFE side of external execution capacity: workloads queue against a
provider that owns the GPUs, and capacity arrives as virtual nodes rather than
machines SaFE provisions.

Everything sits behind external_execution.enabled, off by default. Objects
without the external markers keep the native path unchanged, and the native
predicates in IsMachineReady and IsManaged are untouched.

Admission

An external workspace is marked by the primus-safe.workspace.external label,
immutable after creation. Workload admission skips the workspace quota check
there, because it compares against status.totalResources, which stays empty
until the provider publishes a node -- applying it would reject every submission
while the workspace is idle, the scale-from-zero case the mode exists for.
Request shape is still bounded by validateResourceEnough against the node
flavor. Preemption is withheld on this path: marking a victim preempted records
an intent, not a release, so the capacity a preemptor was admitted against would
not exist yet.

Scheduling

Ordering, FIFO, priority, dependencies and pause handling are unchanged. When the
workspace aggregate has room the workload goes straight to plan and claim; when it
does not, the shortage is stated to the provider and the workload waits. A
capacity refusal despite local room restates the need, since that combination
means the two sides disagree and this side cannot tell why.

Nothing leaves the queue until the provider has granted a reservation for that
exact workload and dispatch generation. Identifiers are persisted before the
request that uses them, so a lost reply is reconciled by replaying the same id
rather than asking for a second reservation.

The dispatcher rechecks the claim immediately before creating the execution
object, then carries the claim identity, the digest-pinned image and required node
affinity onto the pod. Service account token projection is disabled there.

Nodes

Virtual nodes are created, updated and removed by the provider. processNode
routes them to a path that performs no host operation, deletion no longer removes
the execution cluster node, and the apiserver refuses create, patch, delete and
retry on them. Two guards keep them out of the kubespray inventory, which they
would otherwise enter now that IsManaged holds for them.

Readiness becomes the freshness of the provider observation, since there is no
host to probe. That makes it the one predicate that changes answer without an
event, so external workspaces also get a short resync.

Reclamation

Capacity is not returned when a workload ends. The reservation is withdrawn at the
terminal state and stays charged until the provider reports Released; Revoking
only records that the withdrawal was accepted.

Not included

ExternalOCI is not expressible in the published contract yet -- backend,
result_source, the native artifact requirements on Image and PreparedPayload,
network_mode and fingerprint all need agreement first, so no profile can be
published from this change alone. Multi-replica workloads are declined because
their child pods carry no stable role and index a reservation can bind to.

Verification

Build and vet clean on apis, common, webhooks, resource-manager and job-manager.
New tests cover the freshness boundaries on both sides, demand renewal timing and
unit construction. Existing webhook, scheduler, syncer, utils and resource tests
pass. helm lint passes and the provider RBAC renders only when the flag is on.

Three test failures predate this branch and reproduce on main:
TestConstructLocalDownloadOpsJob, job-manager/pkg/server, and two cases in
job-manager/pkg/dispatcher. The apiserver module does not build end to end in
this environment for lack of libbtrfs-dev, also on main.

Made with Cursor

weilei and others added 18 commits September 14, 2026 08:57
Introduce the node lifecycle mode, the provider allocation reference and the
provider status carried by external nodes, plus the workspace label that selects
the external path.

IsMachineReady and IsManaged gain an external branch. A virtual node has no host
to probe over SSH and never joins through kubespray, so readiness becomes the
freshness of the provider observation and managed becomes cluster ownership.
Native nodes keep their existing predicates unchanged.

GetPhase reports ExternalStale rather than the empty MachineStatus phase that an
external node would otherwise surface.

Co-authored-by: Cursor <cursoragent@cursor.com>
The flag gates admission of external objects and publication of capacity demand,
so an integration that is only half deployed cannot dispatch work. It defaults to
off, which keeps every existing deployment on the native path.

Two companion settings come with it. The observation max age backstops the
validUntil a provider sets for itself, and the workspace resync shortens the
fifteen minute reconcile backstop that would otherwise let a stale observation
stay counted as available capacity.

Co-authored-by: Cursor <cursoragent@cursor.com>
Implements the demand, plan, claim, get and release calls SaFE makes against an
external capacity controller, with service mTLS and a bounded per-call timeout.

Three refusals are deliberate. A response carrying the contract mock header is
rejected outside local tests, because a synthetic reservation reaching the
dispatcher would place pods on capacity that does not exist. An unknown field in
a reply is an error rather than an ignored value, so a profile this build cannot
honour cannot pass as a supported one. Redirects are not followed, since that
would carry the service identity to an address the configuration never approved.

Error codes are exposed as predicates: IsQueueable marks the answers that mean
the workload keeps waiting rather than faults worth retrying.

Co-authored-by: Cursor <cursoragent@cursor.com>
Workload admission skips the workspace quota check for an external workspace. That
check compares the request against status.totalResources, which stays empty until
the provider publishes a node, so applying it would reject every submission while
the workspace is idle -- the scale-from-zero case the mode exists for. The budget
is arbitrated by the provider at claim time, and request shape is still bounded by
validateResourceEnough against the node flavor.

Node admission replaces the private IP, SSH secret and port requirements with the
provider allocation reference, and keeps the flavor and taint checks on both paths.
The hostname stays required because mutateMeta derives the object name from it and
node_k8s_controller matches the execution cluster node by that name.

The workspace label, the lifecycle mode and the allocation reference are immutable.
Flipping any of them would move a live object between two reconcile paths that make
opposite assumptions about whether a host exists behind it.

All three creation paths refuse external objects while the feature flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
processNode dispatches external nodes to a path that performs no host operation:
no SSH, no hostname or DNS change, no addon install, no kubespray, no kubeadm
reset and no reboot. It does not reuse manage or unmanage, because their early
exits do not hold for a virtual node -- the execution cluster node carries no SaFE
cluster label, so the "already managed" check fails and control would fall through
to syncClusterStatus, which opens an SSH connection.

Deletion no longer removes the execution cluster node. The provider deregisters it
while releasing the allocation; deleting it here would evict pods whose tasks the
provider has not finished stopping.

shouldSyncMachineStatus returns false for external nodes. Their MachineStatus is
never written, so its zero update time reads as permanently overdue.

Two kubespray guards come with the IsManaged change: an external node reports
managed as soon as it belongs to a cluster, which would otherwise have placed it
in the upgrade inventory for ansible to try to reach.

Co-authored-by: Cursor <cursoragent@cursor.com>
An external workspace does not scale. Its capacity comes from the provider's pool
rather than from spec.Replica, which is deliberately left unset, so the scaling
switch would read the virtual nodes the provider just published as a surplus and
scale down would answer by clearing spec.workspace on them -- removing the very
capacity that was delivered.

The same workspace gets a short requeue. The controller's own backstop is fifteen
minutes, and the events that normally drive a reconcile stop arriving in exactly
the case observation freshness exists to catch: the execution cluster going
unreachable. Without the shorter interval a stale node stays counted as available
for up to a quarter of an hour.

Scale-up candidate selection now excludes external nodes. They satisfy machine
ready and managed while idle, so a native workspace short of a replica would
otherwise bind one and take capacity that is handed out through claims.

Co-authored-by: Cursor <cursoragent@cursor.com>
Records the demand and claim identifiers a workload needs to resume after a
restart or a lost reply, together with the placements the provider approved.

Each identifier is written before the request it belongs to is sent. A request
whose response never arrived can only be reconciled by replaying the same id with
the same body: allocating a fresh one would ask for a second reservation while the
first may already exist, and nothing afterwards would notice the surplus.

Co-authored-by: Cursor <cursoragent@cursor.com>
Ordering, FIFO, priority, dependencies and pause handling are untouched. The
external path attaches at the two points where the existing decision is already
made: when capacity is short, and when it is not.

On a shortage the provider is asked to acquire capacity. Only a genuine shortage
reaches that call -- the earlier checks have already excluded workloads waiting on
a dependency, a start time or a pause, which have no unmet capacity need and would
otherwise grow the pool for work that cannot start.

When capacity looks available locally, the workload still may not leave the queue
until the provider has granted a reservation for this exact workload and dispatch
generation. A plan holds nothing, so losing the devices between planning and
claiming is normal and simply replans on the next pass.

Identifiers are persisted before the request that uses them. A reply lost in
transit can then be reconciled by replaying the same id, where a freshly generated
one would ask for a second reservation while the first may already exist.

Refusals are mapped to distinct waiting reasons. An unprepared image or an
unvalidated profile reported as a capacity shortage would grow the pool for a
problem more nodes cannot solve.

The first release accepts single replica workloads only: a multi-replica workload
needs a stable role and index on every child pod, and the operators creating those
pods do not expose one a reservation can bind to.

Co-authored-by: Cursor <cursoragent@cursor.com>
The dispatcher rechecks the reservation immediately before creating the execution
object. The scheduler already verified it, but time passes while the pods are
built, and a claim revoked or expired inside that window would place a pod on
devices the provider has started reclaiming -- a pod that looks legitimate to
everything downstream.

The pod carries the workload uid, dispatch generation, claim id and revision, unit
key and profile, which is what the provider matches against the reservation after
binding. These are derived from the approved workload and claim; an annotation a
user could write by hand would otherwise amount to an authorisation.

Placement is expressed as required node affinity over the approved virtual nodes
rather than spec.nodeName, so the execution cluster scheduler still performs the
binding and the provider can verify the binding it observes.

Service account token projection is disabled on external pods. The task runs on
provider hardware and speaks a protocol carrying its own identity; a projected
token would place an execution cluster credential inside it for no purpose.

Co-authored-by: Cursor <cursoragent@cursor.com>
Capacity is not returned when a workload ends. The provider has to stop the task
and verify its cleanup first, so the reservation is withdrawn at the terminal
state and the resources stay charged to the workspace until it reports Released.
Revoking is not that confirmation: it only records that the withdrawal was
accepted.

Two places had to agree with that. The scheduler's accounting no longer drops a
finished workload whose claim is still outstanding, because doing so would offer
the next workload capacity the claim transaction is about to refuse -- after this
side had already admitted it. And the release is retried on a timer, since the
provider moving from Revoking to Released produces no event here.

Workspace aggregation stops charging a stale virtual node the flavor's full
resources. For a physical node that accounting is right, the machine is still
there; for a virtual one the allocation behind it may already be gone, and the
same arithmetic would advertise capacity nobody holds.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds the external_execution section to the chart values and threads it into the
three components that read it: job-manager needs the controller endpoint and mTLS
secret path, resource-manager needs the freshness and resync bounds, and the
webhooks need only the flag that decides admission.

The provider identity is a separate ServiceAccount, rendered only when the feature
is enabled so an untouched deployment grants nothing. Its permissions follow the
ownership split: it may write virtual nodes and their status.external, and read
the workspaces and flavors a node has to be matched against, but nothing that SaFE
itself owns -- no workloads, no clusters, and none of the aggregate fields the
resource manager derives.

Co-authored-by: Cursor <cursoragent@cursor.com>
The freshness predicate is the one piece of new behaviour that changes answer
without any event: only the clock moves. The tests pin it and walk both bounds,
including a far future validUntil paired with a provider that stopped reporting,
which is what a crashed provider looks like from here.

Native predicates are asserted unchanged in the same file, since the external
branch was added inside the functions every physical node also goes through.

On the scheduler side the tests fix the unit expansion, the stability of the
constraints digest the provider recomputes, the refusal of shapes the first
release cannot honour, and the reason mapping -- separating a genuine shortage
from an unprepared image, an unvalidated profile or a transport failure, none of
which more nodes would fix.

Co-authored-by: Cursor <cursoragent@cursor.com>
Three pieces were written but never reached from anywhere. Each was a real gap
rather than an unused helper.

The pod was still built from the image reference the user submitted. The
reservation was granted against the digest the provider resolved at claim time, so
a tag that moved between claim and dispatch would have run content nothing was
admitted for, and the provider would have refused the task after the pod had
already bound.

The configured observation max age was loaded and then ignored. The node
predicates live in the apis package, which cannot read configuration, so every
deployment silently kept the compiled-in default no matter what the chart said.
Both controllers now pass it in after loading config.

Demand withdrawal is called when a workload reaches a terminal state. The demand
would lapse at expires_at on its own, but that window is long enough for the
provider to acquire a node for work that has already finished. It is best effort
and does not hold up the release, which is the part that returns devices.

Also drops the dispatcher's duplicate release path; the scheduler owns that
transition and already retries it until the provider confirms.

Co-authored-by: Cursor <cursoragent@cursor.com>
The local resource comparison decided whether to publish demand or go straight to
plan and claim. That was wrong in both directions, and one of them deadlocked.

What the local view measures is the aggregate of nodes the provider has already
published. It says nothing about what the provider could still acquire, so gating
on it withheld the demand exactly when acquisition was the thing being waited for.
And because a failed plan produced no demand either, a workload whose local view
said "enough" would plan, be refused, queue, and repeat -- with nobody ever asked
to buy anything.

Deduplication was never this side's job: the provider subtracts its ready layout
and in-flight requests before acting on a demand.

So the queue head now states its need unconditionally and then tries for a seat. A
revision is republished only when there is none or the current one is close to
lapsing, because the contract refuses a revision whose body changed and the body
carries its own observation time -- re-sending every pass would either conflict
with the stored revision or make it climb without end. The window is persisted so
a replay reproduces the exact body.

Preemption is withheld at the workload webhook for external workspaces rather than
by where this branch sits. Marking a victim preempted records an intent, not a
release: the devices return only once the provider has stopped the task and
verified cleanup, so the capacity a preemptor was admitted against would not exist
yet. It can be enabled after stop and cleanup close end to end.

An unsupported shape is now a typed error, so a workload that no amount of
capacity could place is rejected rather than left waiting.

Co-authored-by: Cursor <cursoragent@cursor.com>
Restores the local resource comparison on the external path. The capacity the
provider publishes is synced into the workspace aggregate, so this side can tell
whether a workload fits: a virtual node belongs to exactly one workspace, and
every claim against it is visible here.

When it fits, the workload goes straight to plan and claim -- the provider is not
asked to acquire anything. When it does not, the shortage is stated and the
workload waits. A demand still has to exist in the first case, because plan and
claim both reference one and the provider validates its revision, but the renewal
window keeps that to roughly one call per workload every few minutes rather than
one per pass.

A capacity refusal despite local room now restates the need. That combination
means the two sides disagree -- devices held by an unconfirmed cleanup, or an
allocation gone while its node object lingers -- and this side cannot tell which.
Without it the workload would replan forever against a local view nothing
corrects.

Preemption is skipped rather than merely unmarked, so the ordering here does not
depend on the webhook that withholds the annotation.

Co-authored-by: Cursor <cursoragent@cursor.com>
status.external belongs to the capacity provider, which refreshes it on its own
cadence. Replacing the whole status object on every sync carried a stale copy of
that block back with it and made the two writers contend for the same
resourceVersion. Only the five observed fields are patched now.

Co-authored-by: Cursor <cursoragent@cursor.com>
Create, patch, delete and retry now reject a node whose lifecycle belongs to an
external capacity provider. The object is the provider's record of an allocation
it is accounting for; this API cannot hand that allocation back, stop the tasks on
it, or tell the provider its record no longer matches, so a change here would only
leave the two sides disagreeing about what exists.

Batch delete routes through the same guard, and create is checked on the built
object rather than the request so it survives changes to the request shape.

Co-authored-by: Cursor <cursoragent@cursor.com>
Three test files sat apart from their subject. canScheduleWorkload is defined in
scheduler.go, so its cases move into scheduler_test.go and can_schedule_test.go
goes away. The CICD proxy acceptance suite covers pkg/dispatcher and referenced
nothing from its own package, so it moves there too, keeping the integration
build tag that holds it out of a plain go test run.

Also renames the new external node test to match node_types.go, the file whose
predicates it exercises.

Co-authored-by: Cursor <cursoragent@cursor.com>
@weilei0120
weilei0120 requested a review from a team as a code owner September 15, 2026 06:01
Copilot AI lite review requested due to automatic review settings September 15, 2026 06:01

Copilot AI 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.

🟡 Changes recommended

Critical and moderate implementation issues remain unresolved, blocking safe approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds opt-in provider-backed external execution capacity using virtual nodes, provider reservations, claim-aware dispatch, and Helm configuration.

Changes:

  • Adds external admission and demand/claim/release scheduling flows.
  • Adds provider-owned virtual-node lifecycle and freshness handling.
  • Updates dispatching, APIs, configuration, RBAC, and tests.
File summaries
File Description
SaFE/webhooks/pkg/workspace_webhook.go External workspace validation and preemption handling
SaFE/webhooks/pkg/workload_webhook.go External workload admission and preemption behavior
SaFE/webhooks/pkg/node_webhook.go Virtual-node validation and immutability
SaFE/resource-manager/pkg/server/server.go Observation-age configuration
SaFE/resource-manager/pkg/resource/workspace_controller.go External workspace reconciliation and capacity accounting
SaFE/resource-manager/pkg/resource/node_k8s_controller.go Kubernetes status synchronization
SaFE/resource-manager/pkg/resource/node_controller.go Virtual-node lifecycle handling
SaFE/resource-manager/pkg/resource/cluster_helper.go Excludes virtual nodes from Kubespray
SaFE/job-manager/test/integration/README.md Integration documentation update
SaFE/job-manager/pkg/server/server.go Observation-age configuration
SaFE/job-manager/pkg/scheduler/scheduler.go External scheduling and reclamation integration
SaFE/job-manager/pkg/scheduler/scheduler_test.go Scheduler test consolidation
SaFE/job-manager/pkg/scheduler/external.go Provider demand, placement, claim, and release logic
SaFE/job-manager/pkg/scheduler/external_test.go External scheduler unit tests
SaFE/job-manager/pkg/scheduler/can_schedule_test.go Removes moved duplicate tests
SaFE/job-manager/pkg/dispatcher/external.go Claim verification and pod metadata
SaFE/job-manager/pkg/dispatcher/dispatcher.go Pre-dispatch claim checks
SaFE/job-manager/pkg/dispatcher/dispatcher_help.go Pod affinity, image, and token changes
SaFE/job-manager/pkg/dispatcher/cicd_proxy_test.go Test package correction
SaFE/job-manager/go.mod UUID direct dependency
SaFE/common/pkg/execution/types.go External execution contract types
SaFE/common/pkg/execution/provider.go Shared provider client
SaFE/common/pkg/execution/errors.go Provider error handling
SaFE/common/pkg/execution/client.go HTTPS/mTLS provider client
SaFE/common/pkg/config/define.go External execution configuration keys
SaFE/common/pkg/config/config.go Configuration accessors
SaFE/charts/primus-safe/values.yaml External execution Helm values
SaFE/charts/primus-safe/templates/webhooks/config.yaml Webhook feature flag
SaFE/charts/primus-safe/templates/resource-manager/config.yaml Resource-manager settings
SaFE/charts/primus-safe/templates/rbac/external_execution_provider.yaml Conditional provider RBAC
SaFE/charts/primus-safe/templates/job-manager/config.yaml Job-manager provider settings
SaFE/apiserver/pkg/handlers/resources/node.go API mutation guards for virtual nodes
SaFE/apis/pkg/client/applyconfiguration/utils.go Apply configuration registration
SaFE/apis/pkg/client/applyconfiguration/amd/v1/workloadstatus.go External workload status apply support
SaFE/apis/pkg/client/applyconfiguration/amd/v1/workloadexternalplacement.go Placement apply configuration
SaFE/apis/pkg/client/applyconfiguration/amd/v1/workloadexternalexecution.go External execution apply configuration
SaFE/apis/pkg/client/applyconfiguration/amd/v1/nodestatus.go External node status apply support
SaFE/apis/pkg/client/applyconfiguration/amd/v1/nodespec.go External node spec apply support
SaFE/apis/pkg/client/applyconfiguration/amd/v1/nodeexternalstatus.go External status apply configuration
SaFE/apis/pkg/client/applyconfiguration/amd/v1/nodeexternalref.go External reference apply configuration
SaFE/apis/pkg/apis/amd/v1/zz_generated.deepcopy.go Generated deep-copy support
SaFE/apis/pkg/apis/amd/v1/workload_types.go External workload execution state
SaFE/apis/pkg/apis/amd/v1/well_known_constants.go External labels and annotations
SaFE/apis/pkg/apis/amd/v1/utils.go External workspace helper
SaFE/apis/pkg/apis/amd/v1/node_types.go Virtual-node lifecycle and freshness
SaFE/apis/pkg/apis/amd/v1/node_types_test.go Node freshness and predicate tests
Review details

Files not reviewed (9)

  • SaFE/apis/pkg/apis/amd/v1/zz_generated.deepcopy.go: Generated file
  • SaFE/apis/pkg/client/applyconfiguration/amd/v1/nodeexternalref.go: Generated file
  • SaFE/apis/pkg/client/applyconfiguration/amd/v1/nodeexternalstatus.go: Generated file
  • SaFE/apis/pkg/client/applyconfiguration/amd/v1/nodespec.go: Generated file
  • SaFE/apis/pkg/client/applyconfiguration/amd/v1/nodestatus.go: Generated file
  • SaFE/apis/pkg/client/applyconfiguration/amd/v1/workloadexternalexecution.go: Generated file
  • SaFE/apis/pkg/client/applyconfiguration/amd/v1/workloadexternalplacement.go: Generated file
  • SaFE/apis/pkg/client/applyconfiguration/amd/v1/workloadstatus.go: Generated file
  • SaFE/apis/pkg/client/applyconfiguration/utils.go: Generated file

Suppressed comments (10)

SaFE/job-manager/pkg/dispatcher/dispatcher.go:327

  • A failed claim recheck only requeues the dispatcher, while the workload remains marked Scheduled and the scheduler is never asked to acquire a replacement claim. Once the claim is revoked, expires, or disappears before the pod is created, this loop will retry the same invalid state forever instead of rescheduling the workload.
		// Recheck the reservation before creating anything. The scheduler verified it, but
		// time passes before the pods are built, and a claim revoked or expired inside that
		// window would otherwise place a pod on devices the provider is reclaiming.
		if isExternalWorkload(adminWorkload) {
			if verifyErr := r.verifyExternalClaim(ctx, adminWorkload); verifyErr != nil {
				klog.ErrorS(verifyErr, "external claim recheck failed, not dispatching",
					"workload", adminWorkload.Name)
				return ctrlruntime.Result{RequeueAfter: externalClaimRecheckDelay}, nil

SaFE/job-manager/pkg/dispatcher/external.go:83

  • The pod's profile annotations are read from the current process configuration, not from the profile/revision used when the demand and claim were created. A restart or configuration rollout between admission and dispatch can therefore label an old claim with a different profile, causing provider verification to reject or misattribute the task. Persist the claimed profile identity or derive it from the provider's claim.
    SaFE/job-manager/pkg/scheduler/external.go:37
  • These reasons say Rejected, but canScheduleWorkload returns them as an ordinary ok == false result and scheduleWorkloads only writes a queue message; it never transitions the workload to a terminal rejection. Unsupported multi-replica/CPU shapes and unsatisfiable constraints will therefore remain Pending indefinitely (and can block FIFO queues). Reject them during admission or explicitly mark them failed here.
    SaFE/job-manager/pkg/scheduler/external.go:59
  • ClaimId is generated and persisted before CreateClaim runs (ensureExternalState), so a queued workload with no provider claim already satisfies this predicate. If it then reaches a terminal state, it is included in the scheduler's unfinished/scheduled accounting and can be sent through planning/claiming again even though it should only withdraw its demand. Treat only an actually Active/Revoking claim as reclaiming, while handling pending demand withdrawal separately.
    SaFE/job-manager/pkg/scheduler/external.go:485
  • RdmaResource is parsed by CvtToResourceList but there is no RDMA field in execution.ResourceVector, and the parsed value is then dropped from the demand. External admission does not reject GPU workloads carrying an RDMA request, so the provider can reserve capacity without accounting for a resource the pod will require. Reject this shape or extend the external resource contract before admitting it.
    SaFE/job-manager/pkg/scheduler/external.go:206
  • The state is persisted with a future DemandExpiresAt before PublishDemand is attempted. If that call fails (including a temporary controller/mTLS/transport failure), the next reconcile sees demandNeedsRefresh == false and skips publication, so it does not replay the same request; only a later refresh creates a new revision/request ID. Track publication success or explicitly retry the persisted revision/body before advancing it.
    SaFE/job-manager/pkg/scheduler/external.go:277
  • Unlike the normal demand path, withdrawal is not persisted: every reconcile sends DemandRevision + 1 with a new request ID and new timestamps. Repeated calls therefore submit different bodies under the same revision, which conflicts with the revision/body immutability described above; a failed or repeated withdrawal can leave the provider's demand active. Persist the withdrawal revision/request body (or an acknowledged withdrawn state) and replay it idempotently.
    SaFE/job-manager/pkg/scheduler/external.go:193
  • demandNeedsRefresh only considers expiry, but pending workloads may still update Spec.Resources, Spec.Images, or Spec.CustomerLabels before they are dispatched. The existing demand revision is then reused for the new workload shape, allowing planning/claiming against stale resources or image data. Invalidate or fingerprint the persisted demand when the admitted shape changes.
    SaFE/job-manager/pkg/scheduler/external.go:418
  • When a workload is failover-rescheduled, DispatchGeneration changes and this code replaces the stored identifiers, but the previous active claim is never released. The syncer resets the workload for rescheduling without ending it, so the old provider reservation remains charged while a new claim is created, leaking capacity on every failover. Release or reconcile the old claim before replacing its state.
    SaFE/resource-manager/pkg/resource/workspace_controller.go:1128
  • The new NodeExternalStatus.Resources is documented as the provider's verified usable capacity, but this aggregation continues to use only node.Status.Resources, which is populated from the execution-cluster node. A provider that updates the external observation without a matching allocatable update contributes zero or stale capacity, so fresh virtual nodes can remain unusable for scheduling. Project or aggregate the validated external resource list for external nodes.
  • Files reviewed: 37/46 changed files
  • Comments generated: 11
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +106 to +110
// Lifecycle mode of the node. Empty keeps the managed physical-host lifecycle.
LifecycleMode NodeLifecycleMode `json:"lifecycleMode,omitempty"`
// Provider allocation backing this node. Required and immutable when lifecycleMode
// is external, and rejected otherwise.
ExternalRef *NodeExternalRef `json:"externalRef,omitempty"`
Comment on lines +241 to +243
// Bookkeeping for workloads admitted through an external capacity provider. Absent on
// the native path.
ExternalExecution *WorkloadExternalExecution `json:"externalExecution,omitempty"`
# Off by default; external objects are refused at admission while it is false.
enabled: {{ default false (default .Values.external_execution).enabled }}
controller_url: "{{ default "" (default .Values.external_execution).controller_url }}"
controller_secret_path: {{ default "/etc/secrets/external-execution" (default .Values.external_execution).controller_secret_path }}
Comment thread SaFE/charts/primus-safe/values.yaml Outdated
Comment on lines +347 to +348
# Mounted secret holding ca.crt, tls.crt and tls.key for service mTLS to the controller.
controller_secret_path: "/etc/secrets/external-execution"
Comment on lines +137 to +139
if placement := findPlacement(state, unitKey); placement != nil {
return placement.ImageRef
}
Comment on lines +293 to +294
if adminNode.IsExternal() {
return r.processExternalNode(adminNode)
patch := map[string]any{
"metadata": map[string]any{"resourceVersion": adminNode.ResourceVersion},
"status": map[string]any{
"machineStatus": map[string]any{"privateIP": adminNode.Status.MachineStatus.PrivateIP},
Comment on lines +89 to +93
claim, err := client.ReleaseClaim(ctx, state.ClaimId, &execution.ReleaseRequest{
RequestID: uuid.NewString(),
ExpectedRevision: state.ClaimRevision,
DispatchGeneration: state.DispatchGeneration,
Reason: string(workload.Status.Phase),
Comment on lines +135 to +137
if err := r.ensureExternalDemand(ctx, workload, workspace); err != nil {
var unsupported *unsupportedShapeError
if errors.As(err, &unsupported) {
Comment on lines +457 to +460
constraints := execution.PlacementConstraints{
NodeSelector: map[string]string{},
AllowedNodeNames: []string{},
}
weilei and others added 3 commits September 15, 2026 06:55
Nine fixes, three of them reachable from a user request.

IsExternal now requires the allocation reference alongside the lifecycle mode.
Callers read that reference straight off the back of the predicate, and an object
that never went through admission -- a request body being validated -- could set
the mode without it, so a POST naming the external mode and nothing else crashed
the apiserver. Admission branches on the declared mode instead, so such a request
is told what is missing rather than that it has no IP address.

External execution state is written with a JSON patch. Every field is omitempty,
so under merge semantics a value returning to zero simply dropped out of the
payload and the stored one survived: Reclaiming could never be cleared, and a new
dispatch generation inherited the previous demand revision and placements, then
planned against a demand id the provider had never seen.

A finished workload no longer reaches the scheduling list. It stays in the
accounting to hold its resources until the provider confirms release, but the
previous arrangement also let it request capacity for work that had ended and be
marked scheduled again.

Node status is patched with a JSON patch for the same reason as above: resources
is a map, and merge semantics would union it, so a device disappearing from
allocatable would linger and keep attracting work.

The claim request id is regenerated for each set of placements. It is the server's
idempotency key and replanning changes the body, so reusing it meant a claim that
failed once could never succeed again in that generation. A claim that was in fact
created is still found by the preceding GetClaim and never re-posted.

Demand withdrawal persists its revision and happens once. Repeating it republished
the same revision under a changed body and would eventually collide with a
revision issued for the opposite meaning.

Dispatch is refused when a claim approved no nodes or an image that is not digest
pinned. The node restriction is expressed as affinity built from the placements,
so an empty set did not narrow the pod at all -- it let the execution cluster
place it anywhere.

The chart now mounts the controller secret it already pointed at, so an install
can actually reach the provider, and external workspaces advance their phase
instead of staying on whatever they were created with.

Also drops WaitingReason, which nothing read, and corrects the claim on
NodeExternalStatus that SaFE validates those fields: only the two freshness
timestamps are consumed today.

Co-authored-by: Cursor <cursoragent@cursor.com>
Seven fixes across the external execution exchange.

The reservation is returned before the finalizer is dropped. Deletion previously
ran the release after the object had already been allowed to disappear, so a
failed release had nothing left to retry from and the Revoking to Released
confirmation was lost with the object -- the provider would hold those devices
with no record on this side that they were owed back.

A new dispatch generation hands back the previous reservation before replacing its
id. Overwriting it stranded the reservation on the provider with nothing naming
it, so a workload that kept failing over leaked one per attempt. A failure there
blocks the new attempt deliberately: waiting is recoverable, a leak is not.

The release path re-reads the state after the withdrawal that rewrote it.
Continuing from the earlier copy patched the withdrawal back out, and the retry
loop then republished one revision under changing bodies forever.

Demand publication records its expiry only once the provider has accepted it, and
a retry resends the identical revision, request id and observation time. Writing
the expiry upfront made a failed publish look like a live demand and suppressed
every retry for the length of the window -- permanently, in the withdrawal case,
where the guard would then skip it forever.

A claim that is definitively gone sends the workload back through admission. The
dispatcher used to recheck a dead reservation every ten seconds with no path to
obtaining a new one, leaving the workload in Pending while it held its share of
the workspace. Failures that might still resolve are still retried in place.

Errors from the external exchange no longer abandon the scheduling pass. A lost
connection or a stale cached object failing its resourceVersion test says nothing
about the workloads behind this one; propagating it stalled admission for the
whole workspace.

The apiserver now applies the configured observation max age and its config map
carries the section, so it no longer judges node freshness by the compiled-in
default while the controllers use the configured one.

Co-authored-by: Cursor <cursoragent@cursor.com>

This branch has not been deployed

No deployments
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.

2 participants