PR #4934
Sections
Review

feat(plugins): add native-worker capabilities for remote executor plugins ↗

main ← feature/core-spec-provider-c-9ve ? files +? −? PR #4934 ↗

Adds opt-in host capabilities that let remote executor plugins prepare the workspace, serve traffic through a host-opened tunnel, and prove drain before cleanup, with a durable handoff for resources that no inventory row can hold.

Why this PR exists

The existing plugin executor contract cannot report a workspace identity, receive approved repositories, use a host-owned transport, or return drain evidence. A launch that fails after the provider allocates a resource can also leave that resource outside the inventory.

Who is affected: Executor plugin authors and operators who run provider-backed task environments.

Result: A provider can opt into the capabilities it needs. The host validates each declaration, pins the negotiated set, and keeps cleanup evidence and resource records until destroy is confirmed.

What it does

Impact at a glance

User experience

Entry pointBeforeAfterSource
Settings > Executors > plugin executor profile formString fields ignored schema length limits, and schema defaults were not shown or applied in the form.String fields validate minLength and maxLength, show a field error, and display the schema default below the input.apps/web/components/settings/plugin-executor-profile-fields.tsx

Kandev plugin interfaces

API / interfaceChangeBefore / after contractCompatibility / actionSource
Executor provider manifestchangedAdds requires_host_capabilities plus workspace_preparation, runtime_api_url, state_budget_bytes, destroy_timeout_seconds, and launch_policy_ids. Install rejects unknown, retired, out-of-range, or unsatisfied capability declarations.Opt in. A provider without requires_host_capabilities keeps the existing manifest rules. Cross-field settings such as workspace_preparation: provider require the matching capabilities.apps/backend/internal/plugins/manifest/executor_provider_capabilities.go
Plugin gRPC contractchangedAdds OpenExecutorTunnel, typed profile values and secret references, repository intent, host_tunnel leases, drain outcome and receipt fields, checkpoint sequence and claim fields, and workspace fields.Additive proto fields plus one optional RPC. The host calls the tunnel only for tunnel_transport and sends capability-specific fields only after negotiation.pluginsdk/proto/kandev/plugin/v1/plugin.proto
Executor profile schemachangedString properties can carry minLength and maxLength. Non-secret properties can carry a schema default that the form displays and applies.Schemas without these keywords behave as before. Secret defaults are ignored.apps/web/lib/plugins/executor-profile-schema.ts

Database changes

Migration / databaseTable / column / indexBefore / after schemaExisting data / upgrade / rollbackSource
ensureUnrecordedPluginExecutorsSchema at startupunrecorded_plugin_executors table and idx_unrecorded_plugin_executors_environment indexNew table keyed by execution_id with session, task, environment, running JSON, and created_at. It has no foreign key so a record outlives the environment until destroy is confirmed.The table is created in place. An environment transfer or delete is refused while a handoff row names it. A row is deleted only after confirmed destroy.apps/backend/internal/task/repository/sqlite/unrecorded_plugin_executor.go
Existing executors_running metadataexecutors_running.metadata plugin_executor envelopeAdds cleanup and claim progress phases, a claim sequence and acquisition stamp, cleanup_requested retention, and workspace immutability checks.Existing envelopes read with revision 0 when the field is absent. No destructive rewrite runs. PostgreSQL and SQLite paths share the transition table.apps/backend/internal/task/repository/sqlite/executor_plugin_inventory.go

Feature flags and rollout

No runtime flag gates this work. New provider behavior activates only when an installed manifest declares a host capability. Providers without declarations keep the base profile, launch, and cleanup path, while the profile-form validation changes ship to every plugin profile.

UX changes with flags off

Entry pointBeforeAfterSource
Settings > Executors > plugin executor profile formString length limits and schema defaults are absent from the form.Length errors and default-value hints appear for any plugin profile whose schema declares them, with no runtime flag to disable them.apps/web/components/settings/plugin-executor-profile-fields.tsx

Architecture, end to end

An installed manifest declares capabilities. The host validates them against one registry, confirms the running provider echo, and pins the negotiated set. Allocation can use provider workspace preparation and the host tunnel. Cleanup seals the runtime, records drain evidence, then destroys the provider resource.

flowchart TD
  Manifest[Provider manifest] --> Registry[Host capability registry]
  Registry --> Validation[Install-time validation]
  Registry --> Gate[Pre-allocation gate]
  Validation --> Gate
  Gate --> Provider[Provider provision or attach]
  Provider --> Inventory[(Executor inventory)]
  Provider --> Tunnel[Host-opened tunnel]
  Inventory --> Drain[Seal and drain decision]
  Drain --> Seal[agentctl seal endpoint]
  Drain --> Destroy[Provider destroy]
  Inventory --> Handoff[(Unrecorded handoff)]
  Handoff --> Destroy

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Manifest and registry
Lifecycle admission
Lifecycle cleanup
agentctl runtime
func ValidateRequirements(reqs []Requirement) []error
Click for details →

The registry gives every capability one versioned row and rejects duplicate, unknown, out-of-range, and retired identities before a provider can use them.

Entry
type Entry struct {
	Name         string
	Min          int
	Max          int
	IntroducedIn string
	DeprecatedIn string
	RetireIn     string
	Replacement  string
	Requires     []string
}
Validation
func ValidateRequirements(reqs []Requirement) []error {
	if len(reqs) > len(registry) {
		return []error{fmt.Errorf("requires_host_capabilities must list at most %d capabilities", len(registry))}
	}
	var errs []error
	declared := make(map[string]bool, len(reqs))
	for _, req := range reqs {
		entry, known := Lookup(req.Name)
		switch {
		case declared[req.Name]:
			errs = append(errs, fmt.Errorf("duplicate capability %q", req.Name))
			continue
		case req.Version < 1:
			errs = append(errs, fmt.Errorf("capability %q version must be an integer of at least 1", req.Name))
		}
		declared[req.Name] = true
		if pair, isRetired := retiredPair(req.Name, req.Version); isRetired {
			errs = append(errs, fmt.Errorf("capability_retired: capability %q version %d was retired in %s", pair.Name, pair.Version, pair.RetiredIn))
			continue
		}
		if !known {
			errs = append(errs, fmt.Errorf("unknown capability %q", req.Name))
			continue
		}
		if req.Version >= 1 && (req.Version < entry.Min || req.Version > entry.Max) {
			errs = append(errs, fmt.Errorf("capability %q version %d is outside the supported range %d-%d", req.Name, req.Version, entry.Min, entry.Max))
		}
	}
	return errs
}
func (m *Manifest) validateHostCapabilities(prefix string, provider *ExecutorProvider) []error
Click for details →

The manifest validation applies the capability registry and the cross-field rules that decide when provider workspace preparation, API forwarding, or a longer destroy deadline is legal.

Budgets and defaults
const (
	// DefaultExecutorStateBudgetBytes is the provider state budget applied to a
	// provider that declares a capability and no state_budget_bytes.
	DefaultExecutorStateBudgetBytes = 8192
	// MinExecutorStateBudgetBytes and MaxExecutorStateBudgetBytes bound
	// state_budget_bytes; the maximum plus the 20480-byte host reserve is the
	// 32768-byte envelope cap.
	MinExecutorStateBudgetBytes = 1024
	MaxExecutorStateBudgetBytes = 12288

	// RuntimeAPIURLHostPublic and RuntimeAPIURLProviderForwarded are the two
	// values of a provider's runtime_api_url.
	RuntimeAPIURLHostPublic        = "host_public"
	RuntimeAPIURLProviderForwarded = "provider_forwarded"

	// WorkspacePreparationCore and WorkspacePreparationProvider are the two
	// values of a provider's workspace_preparation.
	WorkspacePreparationCore     = "core"
	WorkspacePreparationProvider = "provider"

	// DefaultExecutorDestroyTimeoutSeconds is the destroy deadline; a provider
	// that requires drain_receipt can raise it up to MaxExecutorDestroyTimeoutSeconds.
	DefaultExecutorDestroyTimeoutSeconds = 30
	MaxExecutorDestroyTimeoutSeconds     = 300

	maxLaunchPolicyIDs    = 16
	maxStateSchemaVersion = 16
)
Workspace preparation
func validateWorkspacePreparation(prefix string, provider *ExecutorProvider) []error {
	switch provider.WorkspacePreparation {
	case "":
		provider.WorkspacePreparation = WorkspacePreparationCore
	case WorkspacePreparationCore:
	case WorkspacePreparationProvider:
		if !declaresCapability(provider, "repository_intent") || !declaresCapability(provider, "workspace_descriptor") {
			return []error{fmt.Errorf("%s.workspace_preparation provider requires the repository_intent and workspace_descriptor capabilities", prefix)}
		}
	default:
		return []error{fmt.Errorf("%s.workspace_preparation must be %s or %s", prefix, WorkspacePreparationCore, WorkspacePreparationProvider)}
	}
	return nil
}
func (r *PluginRemoteExecutor) confirmPluginExecutorCapabilities(ctx context.Context, profile models.ExecutorProviderLaunchProfile) error
Click for details →

The gate refuses to allocate for a capability-declaring provider unless the installed manifest, host registry, and running provider echo agree. A provider that declares nothing remains ungated.

Gate
// PluginExecutorCapabilityConfirmer is implemented by the plugin service. It
// confirms that the installed manifest, the host registry, and the accepted
// echo of the running provider agree before any provider allocation call.
type PluginExecutorCapabilityConfirmer interface {
	ConfirmExecutorProviderCapabilities(ctx context.Context, pluginID, providerKey string, profile models.ExecutorProviderProfile) error
}

// negotiatedCapabilities returns the sorted `name@version` snapshot pinned to
// an environment, or nil for a provider that declares no capability.
func negotiatedCapabilities(provider models.ExecutorProvider) []string {
	if len(provider.RequiredCapabilities) == 0 {
		return nil
	}
	out := make([]string, 0, len(provider.RequiredCapabilities))
	for _, requirement := range provider.RequiredCapabilities {
		out = append(out, requirement.Name+"@"+strconv.Itoa(requirement.Version))
	}
	slices.Sort(out)
	return out
}

// confirmPluginExecutorCapabilities gates one launch on capability agreement.
// A provider declaring nothing is not gated; one declaring capabilities with no
// confirmer available is refused, never admitted unchecked.
func (r *PluginRemoteExecutor) confirmPluginExecutorCapabilities(ctx context.Context, profile models.ExecutorProviderLaunchProfile) error {
	if len(profile.Provider.RequiredCapabilities) == 0 {
		return nil
	}
	confirmer, ok := r.operations.(PluginExecutorCapabilityConfirmer)
	if !ok {
		return fmt.Errorf("%w: capability confirmation is unavailable", models.ErrExecutorCapabilityMismatch)
	}
	return confirmer.ConfirmExecutorProviderCapabilities(ctx, profile.Provider.PluginID, profile.Provider.Key, models.ExecutorProviderProfile{
		ProfileID: profile.ProfileID, Config: profile.Config, SecretValues: profile.SecretValues,
	})
}
func (r *PluginRemoteExecutor) drainPluginRuntime(ctx context.Context, token string, operationContext *pluginsdk.ExecutorProviderRequestContext, inventory pluginExecutorInventory, resource *pluginsdk.ExecutorResourceDescriptor, snapshot *pluginsdk.ExecutorProfileSnapshot, claimOperationID string, reason string) pluginDrainDecision
Click for details →

Cleanup chooses one closed drain outcome, binds a receipt to the cleanup claim, and treats an ambiguous failure as uncertain rather than assuming the runtime stopped.

Outcome set
const (
	pluginExecutorCapabilityDrainReceipt = "drain_receipt"

	// The closed set of drain outcomes the destroy request carries.
	pluginDrainOutcomeDrained      = "drained"
	pluginDrainOutcomeUnreachable  = "unreachable"
	pluginDrainOutcomeUncertain    = "uncertain"
	pluginDrainOutcomeNotAttempted = "not_attempted"

	pluginExecutorDrainDeadline = 60 * time.Second
	// pluginExecutorCleanupClaimWait bounds how long a failed launch waits for a
	// concurrent cleanup to release the environment's cleanup claim.
	pluginExecutorCleanupClaimWait  = 20 * time.Second
	pluginExecutorCleanupClaimRetry = 250 * time.Millisecond
	// pluginExecutorPersistGrace is the time left for inventory writes after
	// the provider calls of a cleanup have used their deadlines.
	pluginExecutorPersistGrace = 30 * time.Second

	pluginDrainReceiptDigestDomain = "kandev.plugin.drain-receipt.v1"

	// pluginDrainReceiptNeverHeldCredential is the first drain_receipt version
	// whose not_attempted means the host never held an agentctl credential.
	pluginDrainReceiptNeverHeldCredential = 2
)
Failure classification
func classifyPluginDrainFailure(err error) string {
	if errors.Is(err, syscall.ECONNREFUSED) {
		return pluginDrainOutcomeUnreachable
	}
	var rejected *agentctl.RuntimeDrainRejectedError
	if errors.As(err, &rejected) {
		if rejected.StatusCode == http.StatusPreconditionFailed {
			return pluginDrainOutcomeUnreachable
		}
	}
	return pluginDrainOutcomeUncertain
}
func (m *ControlServer) handleRuntimeSealAndDrain(c *gin.Context)
Click for details →

The runtime accepts one drain owner, makes a retry of that request wait, and returns the stored receipt after sealing without stopping the runtime again.

Handler
func (m *ControlServer) handleRuntimeSealAndDrain(c *gin.Context) {
	// Control auth permits unauthenticated local use when no credential was
	// configured. A receipt is evidence, so it requires a credential.
	if m.credentials.Latest() == "" {
		c.JSON(http.StatusUnauthorized, gin.H{errKey: "runtime drain requires control credentials"})
		return
	}
	req, ok := m.decodeRuntimeDrainRequest(c)
	if !ok {
		return
	}
	ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.DeadlineMS)*time.Millisecond)
	defer cancel()

	for {
		done, handled := m.claimRuntimeDrain(c, ctx, req)
		if handled {
			return
		}
		if done == nil {
			continue
		}
		m.runDrainAndRespond(c, ctx, req, done)
		return
	}
}
Claim
func (m *ControlServer) claimRuntimeDrain(c *gin.Context, ctx context.Context, req agentctltypes.RuntimeDrainRequest) (done chan struct{}, handled bool) {
	st := &m.runtimeDrain
	st.mu.Lock()
	if receipt, ok := st.acks[req.RequestID]; ok {
		st.mu.Unlock()
		c.JSON(http.StatusOK, receipt)
		return nil, true
	}
	if st.sealed {
		receipt := runtimeDrainReceipt(req)
		st.acks[req.RequestID] = receipt
		st.mu.Unlock()
		c.JSON(http.StatusOK, receipt)
		return nil, true
	}
	if st.running != "" {
		if st.running != req.RequestID {
			st.mu.Unlock()
			c.JSON(http.StatusConflict, gin.H{errKey: "runtime drain already running for another request"})
			return nil, true
		}
		wait := st.done
		st.mu.Unlock()
		select {
		case <-wait:
			return nil, false
		case <-ctx.Done():
			c.JSON(http.StatusGatewayTimeout, gin.H{errKey: "runtime drain deadline exceeded"})
			return nil, true
		}
	}
	st.running = req.RequestID
	st.done = make(chan struct{})
	done = st.done
	st.mu.Unlock()
	return done, false
}
func (r *PluginRemoteExecutor) handOffUnrecordedPluginExecutorResource(ctx context.Context, req *ExecutorCreateRequest, inventory pluginExecutorInventory, resource *pluginsdk.ExecutorResourceDescriptor, stage string, cause error) error
Click for details →

A failed launch records a provisioned resource that has no inventory row or cleanup claim. The environment cleanup adopts it and keeps the record until destroy is confirmed.

Record the handoff
// handOffUnrecordedPluginExecutorResource durably records a resource that has
// neither an inventory row nor a cleanup claim to hold it, as when an archive
// holds the environment while the provider returns. A provider mutates a
// resource only under a persisted claim, so the launch never destroys it: the
// environment cleanup adopts the record, destroys the resource under its own
// claim, and deletes the record only after the destroy is confirmed.
func (r *PluginRemoteExecutor) handOffUnrecordedPluginExecutorResource(
	ctx context.Context,
	req *ExecutorCreateRequest,
	inventory pluginExecutorInventory,
	resource *pluginsdk.ExecutorResourceDescriptor,
	stage string,
	cause error,
) error {
	inventory.Resource = resource
	inventory.Phase = pluginExecutorPhaseCleanupPending
	var recordErr error
	if req.RecordUnrecordedRuntimeInventory == nil {
		recordErr = errors.New("durable handoff is unavailable")
	} else {
		recordErr = req.RecordUnrecordedRuntimeInventory(ctx, map[string]interface{}{MetadataKeyPluginExecutor: inventory})
	}
	fields := []zap.Field{
		zap.String("execution_id", req.InstanceID), zap.String("environment_id", inventory.EnvironmentID),
		zap.String("stage", stage), zap.String("resource_handle", resource.GetResourceHandle()), zap.NamedError("launch_cause", cause),
	}
	if recordErr != nil {
		r.logger.Error("plugin executor resource is unrecorded and its handoff failed",
			append(fields, zap.String("cleanup_outcome", "handoff_failed"), zap.Error(recordErr))...)
		return withPluginExecutorLaunchCode(cause, errPluginExecutorHandoffUnavailable)
	}
	r.logger.Error("plugin executor resource is unrecorded; its removal is left to the environment cleanup",
		append(fields, zap.String("cleanup_outcome", "handed_off"))...)
	return withPluginExecutorLaunchCode(cause, errors.New("plugin executor launch failed; allocated resource was not recorded and will be removed by the environment cleanup"))
}
Keep unsettled handoffs
// remainingUnrecordedPluginExecutors names the handoffs still recorded for the
// environment after the cleanup ran: a resource nothing has confirmed destroyed
// must keep the environment from being reported cleaned.
func (r *PluginRemoteExecutor) remainingUnrecordedPluginExecutors(ctx context.Context, environment *models.TaskEnvironment) []error {
	remaining, err := r.inventoryStore.ListUnrecordedPluginExecutors(ctx, environment.TaskID, environment.ID)
	if err != nil {
		return []error{fmt.Errorf("re-read unrecorded plugin executors: %w", err)}
	}
	unsettled := make([]error, 0, len(remaining))
	for _, item := range remaining {
		unsettled = append(unsettled, fmt.Errorf("plugin executor launch %s provisioned a resource that is not confirmed destroyed; cleanup cannot settle", item.ExecutionID))
	}
	return unsettled
}
Read the changes as a list

Closed capability registry

apps/backend/internal/plugins/hostcaps/registry.go

The registry gives every capability one versioned row and rejects duplicate, unknown, out-of-range, and retired identities before a provider can use them.

Entry
type Entry struct {
	Name         string
	Min          int
	Max          int
	IntroducedIn string
	DeprecatedIn string
	RetireIn     string
	Replacement  string
	Requires     []string
}
Validation
func ValidateRequirements(reqs []Requirement) []error {
	if len(reqs) > len(registry) {
		return []error{fmt.Errorf("requires_host_capabilities must list at most %d capabilities", len(registry))}
	}
	var errs []error
	declared := make(map[string]bool, len(reqs))
	for _, req := range reqs {
		entry, known := Lookup(req.Name)
		switch {
		case declared[req.Name]:
			errs = append(errs, fmt.Errorf("duplicate capability %q", req.Name))
			continue
		case req.Version < 1:
			errs = append(errs, fmt.Errorf("capability %q version must be an integer of at least 1", req.Name))
		}
		declared[req.Name] = true
		if pair, isRetired := retiredPair(req.Name, req.Version); isRetired {
			errs = append(errs, fmt.Errorf("capability_retired: capability %q version %d was retired in %s", pair.Name, pair.Version, pair.RetiredIn))
			continue
		}
		if !known {
			errs = append(errs, fmt.Errorf("unknown capability %q", req.Name))
			continue
		}
		if req.Version >= 1 && (req.Version < entry.Min || req.Version > entry.Max) {
			errs = append(errs, fmt.Errorf("capability %q version %d is outside the supported range %d-%d", req.Name, req.Version, entry.Min, entry.Max))
		}
	}
	return errs
}

Manifest limits

apps/backend/internal/plugins/manifest/executor_provider_capabilities.go

The manifest validation applies the capability registry and the cross-field rules that decide when provider workspace preparation, API forwarding, or a longer destroy deadline is legal.

Budgets and defaults
const (
	// DefaultExecutorStateBudgetBytes is the provider state budget applied to a
	// provider that declares a capability and no state_budget_bytes.
	DefaultExecutorStateBudgetBytes = 8192
	// MinExecutorStateBudgetBytes and MaxExecutorStateBudgetBytes bound
	// state_budget_bytes; the maximum plus the 20480-byte host reserve is the
	// 32768-byte envelope cap.
	MinExecutorStateBudgetBytes = 1024
	MaxExecutorStateBudgetBytes = 12288

	// RuntimeAPIURLHostPublic and RuntimeAPIURLProviderForwarded are the two
	// values of a provider's runtime_api_url.
	RuntimeAPIURLHostPublic        = "host_public"
	RuntimeAPIURLProviderForwarded = "provider_forwarded"

	// WorkspacePreparationCore and WorkspacePreparationProvider are the two
	// values of a provider's workspace_preparation.
	WorkspacePreparationCore     = "core"
	WorkspacePreparationProvider = "provider"

	// DefaultExecutorDestroyTimeoutSeconds is the destroy deadline; a provider
	// that requires drain_receipt can raise it up to MaxExecutorDestroyTimeoutSeconds.
	DefaultExecutorDestroyTimeoutSeconds = 30
	MaxExecutorDestroyTimeoutSeconds     = 300

	maxLaunchPolicyIDs    = 16
	maxStateSchemaVersion = 16
)
Workspace preparation
func validateWorkspacePreparation(prefix string, provider *ExecutorProvider) []error {
	switch provider.WorkspacePreparation {
	case "":
		provider.WorkspacePreparation = WorkspacePreparationCore
	case WorkspacePreparationCore:
	case WorkspacePreparationProvider:
		if !declaresCapability(provider, "repository_intent") || !declaresCapability(provider, "workspace_descriptor") {
			return []error{fmt.Errorf("%s.workspace_preparation provider requires the repository_intent and workspace_descriptor capabilities", prefix)}
		}
	default:
		return []error{fmt.Errorf("%s.workspace_preparation must be %s or %s", prefix, WorkspacePreparationCore, WorkspacePreparationProvider)}
	}
	return nil
}

Allocation gate

apps/backend/internal/agent/runtime/lifecycle/executor_plugin_capability_gate.go

The gate refuses to allocate for a capability-declaring provider unless the installed manifest, host registry, and running provider echo agree. A provider that declares nothing remains ungated.

Gate
// PluginExecutorCapabilityConfirmer is implemented by the plugin service. It
// confirms that the installed manifest, the host registry, and the accepted
// echo of the running provider agree before any provider allocation call.
type PluginExecutorCapabilityConfirmer interface {
	ConfirmExecutorProviderCapabilities(ctx context.Context, pluginID, providerKey string, profile models.ExecutorProviderProfile) error
}

// negotiatedCapabilities returns the sorted `name@version` snapshot pinned to
// an environment, or nil for a provider that declares no capability.
func negotiatedCapabilities(provider models.ExecutorProvider) []string {
	if len(provider.RequiredCapabilities) == 0 {
		return nil
	}
	out := make([]string, 0, len(provider.RequiredCapabilities))
	for _, requirement := range provider.RequiredCapabilities {
		out = append(out, requirement.Name+"@"+strconv.Itoa(requirement.Version))
	}
	slices.Sort(out)
	return out
}

// confirmPluginExecutorCapabilities gates one launch on capability agreement.
// A provider declaring nothing is not gated; one declaring capabilities with no
// confirmer available is refused, never admitted unchecked.
func (r *PluginRemoteExecutor) confirmPluginExecutorCapabilities(ctx context.Context, profile models.ExecutorProviderLaunchProfile) error {
	if len(profile.Provider.RequiredCapabilities) == 0 {
		return nil
	}
	confirmer, ok := r.operations.(PluginExecutorCapabilityConfirmer)
	if !ok {
		return fmt.Errorf("%w: capability confirmation is unavailable", models.ErrExecutorCapabilityMismatch)
	}
	return confirmer.ConfirmExecutorProviderCapabilities(ctx, profile.Provider.PluginID, profile.Provider.Key, models.ExecutorProviderProfile{
		ProfileID: profile.ProfileID, Config: profile.Config, SecretValues: profile.SecretValues,
	})
}

Drain evidence

apps/backend/internal/agent/runtime/lifecycle/executor_plugin_drain.go

Cleanup chooses one closed drain outcome, binds a receipt to the cleanup claim, and treats an ambiguous failure as uncertain rather than assuming the runtime stopped.

Outcome set
const (
	pluginExecutorCapabilityDrainReceipt = "drain_receipt"

	// The closed set of drain outcomes the destroy request carries.
	pluginDrainOutcomeDrained      = "drained"
	pluginDrainOutcomeUnreachable  = "unreachable"
	pluginDrainOutcomeUncertain    = "uncertain"
	pluginDrainOutcomeNotAttempted = "not_attempted"

	pluginExecutorDrainDeadline = 60 * time.Second
	// pluginExecutorCleanupClaimWait bounds how long a failed launch waits for a
	// concurrent cleanup to release the environment's cleanup claim.
	pluginExecutorCleanupClaimWait  = 20 * time.Second
	pluginExecutorCleanupClaimRetry = 250 * time.Millisecond
	// pluginExecutorPersistGrace is the time left for inventory writes after
	// the provider calls of a cleanup have used their deadlines.
	pluginExecutorPersistGrace = 30 * time.Second

	pluginDrainReceiptDigestDomain = "kandev.plugin.drain-receipt.v1"

	// pluginDrainReceiptNeverHeldCredential is the first drain_receipt version
	// whose not_attempted means the host never held an agentctl credential.
	pluginDrainReceiptNeverHeldCredential = 2
)
Failure classification
func classifyPluginDrainFailure(err error) string {
	if errors.Is(err, syscall.ECONNREFUSED) {
		return pluginDrainOutcomeUnreachable
	}
	var rejected *agentctl.RuntimeDrainRejectedError
	if errors.As(err, &rejected) {
		if rejected.StatusCode == http.StatusPreconditionFailed {
			return pluginDrainOutcomeUnreachable
		}
	}
	return pluginDrainOutcomeUncertain
}

Seal-and-drain endpoint

apps/backend/internal/agentctl/server/api/runtime_drain.go

The runtime accepts one drain owner, makes a retry of that request wait, and returns the stored receipt after sealing without stopping the runtime again.

Handler
func (m *ControlServer) handleRuntimeSealAndDrain(c *gin.Context) {
	// Control auth permits unauthenticated local use when no credential was
	// configured. A receipt is evidence, so it requires a credential.
	if m.credentials.Latest() == "" {
		c.JSON(http.StatusUnauthorized, gin.H{errKey: "runtime drain requires control credentials"})
		return
	}
	req, ok := m.decodeRuntimeDrainRequest(c)
	if !ok {
		return
	}
	ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.DeadlineMS)*time.Millisecond)
	defer cancel()

	for {
		done, handled := m.claimRuntimeDrain(c, ctx, req)
		if handled {
			return
		}
		if done == nil {
			continue
		}
		m.runDrainAndRespond(c, ctx, req, done)
		return
	}
}
Claim
func (m *ControlServer) claimRuntimeDrain(c *gin.Context, ctx context.Context, req agentctltypes.RuntimeDrainRequest) (done chan struct{}, handled bool) {
	st := &m.runtimeDrain
	st.mu.Lock()
	if receipt, ok := st.acks[req.RequestID]; ok {
		st.mu.Unlock()
		c.JSON(http.StatusOK, receipt)
		return nil, true
	}
	if st.sealed {
		receipt := runtimeDrainReceipt(req)
		st.acks[req.RequestID] = receipt
		st.mu.Unlock()
		c.JSON(http.StatusOK, receipt)
		return nil, true
	}
	if st.running != "" {
		if st.running != req.RequestID {
			st.mu.Unlock()
			c.JSON(http.StatusConflict, gin.H{errKey: "runtime drain already running for another request"})
			return nil, true
		}
		wait := st.done
		st.mu.Unlock()
		select {
		case <-wait:
			return nil, false
		case <-ctx.Done():
			c.JSON(http.StatusGatewayTimeout, gin.H{errKey: "runtime drain deadline exceeded"})
			return nil, true
		}
	}
	st.running = req.RequestID
	st.done = make(chan struct{})
	done = st.done
	st.mu.Unlock()
	return done, false
}

Unrecorded handoff

apps/backend/internal/agent/runtime/lifecycle/executor_plugin_unrecorded.go

A failed launch records a provisioned resource that has no inventory row or cleanup claim. The environment cleanup adopts it and keeps the record until destroy is confirmed.

Record the handoff
// handOffUnrecordedPluginExecutorResource durably records a resource that has
// neither an inventory row nor a cleanup claim to hold it, as when an archive
// holds the environment while the provider returns. A provider mutates a
// resource only under a persisted claim, so the launch never destroys it: the
// environment cleanup adopts the record, destroys the resource under its own
// claim, and deletes the record only after the destroy is confirmed.
func (r *PluginRemoteExecutor) handOffUnrecordedPluginExecutorResource(
	ctx context.Context,
	req *ExecutorCreateRequest,
	inventory pluginExecutorInventory,
	resource *pluginsdk.ExecutorResourceDescriptor,
	stage string,
	cause error,
) error {
	inventory.Resource = resource
	inventory.Phase = pluginExecutorPhaseCleanupPending
	var recordErr error
	if req.RecordUnrecordedRuntimeInventory == nil {
		recordErr = errors.New("durable handoff is unavailable")
	} else {
		recordErr = req.RecordUnrecordedRuntimeInventory(ctx, map[string]interface{}{MetadataKeyPluginExecutor: inventory})
	}
	fields := []zap.Field{
		zap.String("execution_id", req.InstanceID), zap.String("environment_id", inventory.EnvironmentID),
		zap.String("stage", stage), zap.String("resource_handle", resource.GetResourceHandle()), zap.NamedError("launch_cause", cause),
	}
	if recordErr != nil {
		r.logger.Error("plugin executor resource is unrecorded and its handoff failed",
			append(fields, zap.String("cleanup_outcome", "handoff_failed"), zap.Error(recordErr))...)
		return withPluginExecutorLaunchCode(cause, errPluginExecutorHandoffUnavailable)
	}
	r.logger.Error("plugin executor resource is unrecorded; its removal is left to the environment cleanup",
		append(fields, zap.String("cleanup_outcome", "handed_off"))...)
	return withPluginExecutorLaunchCode(cause, errors.New("plugin executor launch failed; allocated resource was not recorded and will be removed by the environment cleanup"))
}
Keep unsettled handoffs
// remainingUnrecordedPluginExecutors names the handoffs still recorded for the
// environment after the cleanup ran: a resource nothing has confirmed destroyed
// must keep the environment from being reported cleaned.
func (r *PluginRemoteExecutor) remainingUnrecordedPluginExecutors(ctx context.Context, environment *models.TaskEnvironment) []error {
	remaining, err := r.inventoryStore.ListUnrecordedPluginExecutors(ctx, environment.TaskID, environment.ID)
	if err != nil {
		return []error{fmt.Errorf("re-read unrecorded plugin executors: %w", err)}
	}
	unsettled := make([]error, 0, len(remaining))
	for _, item := range remaining {
		unsettled = append(unsettled, fmt.Errorf("plugin executor launch %s provisioned a resource that is not confirmed destroyed; cleanup cannot settle", item.ExecutionID))
	}
	return unsettled
}

Risk

8 / 10 High
1 low5 medium10 high

Why this score

  • The change spans the public plugin contract, lifecycle admission, agentctl, and SQLite persistence, so a mistake can strand an environment or its provider resource.
  • Drain and cleanup correctness depends on ordering across crashes, claims, checkpoints, and a sealed runtime.
  • The new database table and inventory fields are additive, and capability gating keeps non-declaring providers on the existing path.

Trade-offs and review notes

Where to look first

  1. Read executor_plugin_drain.go and runtime_drain.go first. Check receipt identity, retry behavior, and the four outcome classes.
  2. Then read the capability gate and echo path. Confirm every allocation and retained attach checks the same set.
  3. Then read the claim checkpoint and unrecorded handoff paths. Check ordering, claim fencing, and what keeps a record alive.
  4. Then read the SQLite migration and inventory changes. Confirm both dialects and existing-row behavior.
  5. Finally read the SDK proto additions against the fixture provider proof.