mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-10 10:13:50 +08:00
Part of #791 — the last planned piece of the [implementation plan](https://github.com/agent-substrate/substrate/issues/791#issuecomment-5226674097) (after #810, #812, #813). #791 stays open until #817 (gVisor Full-capture → Data-commit conversion, blocked on #790) is done; this PR covers micro-VM fully and gVisor for scope-matched suspends. Also part of the actor state machine (#119) and a prerequisite for system upgrade flows (#473). `SuspendActor` now accepts a PAUSED actor: instead of checkpointing a running workload, ateapi dials the atelet on the node holding the pause snapshot and has it upload the node-local files to object storage, then finalizes as usual — durable `ActorSnapshot`, `SUSPENDED` status, node pinning cleared. Two commits, reviewable independently: ## Commit 1 — atelet: `UploadPausedCheckpoint` RPC (dead code until commit 2) - New `AteomHerder` RPC: a pure disk→object-storage copy driven by the snapshot's self-describing manifest — no ateom involved (the sandbox is gone). - **Scope conversion** dispatches per sandbox class (`narrowFullCaptureToData`): a micro-VM FULL capture narrows to a DATA upload by carving out `durable-dir.tar` (constant hoisted to `ateompath`, shared with ateom-microvm); gVisor returns `Unimplemented` until split checkpoints land (#790); DATA can never widen to FULL; a scope-less manifest (older atelet) is rejected rather than guessed at. - **Idempotent retry**: local files gone + remote manifest present ⇒ a previous invocation committed, succeed; gone on both sides ⇒ unrecoverable (`LOCAL_SNAPSHOT_GONE`, crashes the actor). Upload failures stay plain retryable errors; the manifest uploads last as the commit marker, never in parallel. - The golden atespace is rejected at validation (fully on the `field.ErrorList` framework): golden actors are never paused. ## Commit 2 — control plane: enable suspend from PAUSED - `FromPaused` discriminator: PAUSED status, or SUSPENDING with no worker assignment and a `LocalSnapshotInfo` — the field alone is stale on resumed-from-pause RUNNING actors, so the nil-assignment conjunct is load-bearing. - `MarkSuspendingStep` accepts PAUSED and rejects a Data-captured pause against a Full commit *before* the actor leaves PAUSED (an upload cannot fabricate memory), using the `content_scope` recorded at pause (#812) with an `onPause` fallback. - `CallAteletSuspendStep` paused branch dials by node (`DialForAteletOnNode`, #813): missing node record ⇒ crash (the snapshot can never be found); unreachable atelet ⇒ retryable; atelet's `LOCAL_SNAPSHOT_GONE` ⇒ crash via `maybeCrashActor`. `FinalizeSuspendedStep` needed no changes thanks to the #813 hoist. - Root-cause guard: `MarkPausingStep` rejects pausing golden-atespace actors. - Docs: pause states + the new `PAUSED → SUSPENDING` edge in the architecture state diagram; glossary Suspend entry covers both origins. ## Tests - **atelet unit**: 10 upload-helper subtests (conversion matrix, idempotency probe, data-loss crash, retryable upload failure) via a recording object-storage fake; validation table. - **control-plane unit**: discriminator table, scope-rejection table (incl. onPause fallback), paused preconditions (no node ⇒ CRASHED, no atelet ⇒ `ErrNoAteletOnNode` + still SUSPENDING), golden-pause rejection, prerequisite matrix updated. - **functional (envtest)**: `TestSuspendActor_FromPaused` (upload called with the pause snapshot name, no Checkpoint RPC, SUSPENDED, pinning cleared, ActorSnapshot at the upload destination) + retry-after-failed-upload (same destination on retry). - **e2e (demo suite)**: the lifecycle driver gains a suspend-from-PAUSED mode; three durable-dir cases — Full/Full, Data/Data, and the micro-VM Full→Data extraction — assert memory/file counters survive the full pause→suspend→resume journey and the node pinning is gone. `go test -race ./...` clean (except the pre-existing macOS-only `internal/atunnel` unix-socket-path failures, untouched by this PR), `gofmt`/`go vet` clean, protos regenerated via `hack/protoc.sh`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
187 lines
6.8 KiB
Go
187 lines
6.8 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package ateerrors
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
|
|
epb "google.golang.org/genproto/googleapis/rpc/errdetails"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// errorDomain is the AIP-193 ErrorInfo.errorDomain (https://google.aip.dev/193) stamped
|
|
// into every error built by NewGRPCError, identifying Agent Substrate as the
|
|
// source service.
|
|
const errorDomain = "substrate.dev"
|
|
|
|
// Reason is the AIP-193 ErrorInfo.Reason: a bounded, UPPER_SNAKE_CASE enum of
|
|
// failure causes the control plane can classify on. A Reason is also an error:
|
|
// source layers tag failures with fmt.Errorf("%w: ...", ReasonX, err), and
|
|
// each RPC boundary claims the Reasons it treats as terminal (CrashIfReason);
|
|
// untagged errors stay retriable.
|
|
type Reason string
|
|
|
|
// Error makes a Reason wrappable with %w and matchable with errors.Is/As.
|
|
func (r Reason) Error() string { return string(r) }
|
|
|
|
// NOTE: When adding a Reason constant below, also add it to AllReasons.
|
|
const (
|
|
ReasonTerminalFileSystemError Reason = "TERMINAL_FILE_SYSTEM_ERROR"
|
|
ReasonInvalidSandboxAsset Reason = "INVALID_SANDBOX_ASSET"
|
|
ReasonInvalidCheckpointResult Reason = "INVALID_CHECKPOINT_RESULT"
|
|
ReasonFaileSaveSnapshot Reason = "FAILED_SAVE_SNAPSHOT"
|
|
ReasonInvalidObjectURL Reason = "INVALID_OBJECT_URL"
|
|
ReasonFailedGetExternalObject Reason = "FAILED_GET_EXTERNAL_OBJECT"
|
|
// ReasonInvalidContainerConfig marks a container whose configuration cannot
|
|
// produce a runnable process (e.g. the resolved argv is empty because the
|
|
// image defines no ENTRYPOINT/CMD and the ActorTemplate sets no command/args).
|
|
ReasonInvalidContainerConfig Reason = "INVALID_CONTAINER_CONFIG"
|
|
|
|
// ReasonLocalSnapshotGone marks a paused actor whose local snapshot is
|
|
// missing from the node it was recorded on and absent from object storage:
|
|
// its state is unrecoverable.
|
|
ReasonLocalSnapshotGone Reason = "LOCAL_SNAPSHOT_GONE"
|
|
|
|
// Control-plane failure reasons for ate.actor.crashes metric.
|
|
ReasonCorruptedAssignment Reason = "CORRUPTED_ASSIGNMENT"
|
|
ReasonWorkerReassigned Reason = "WORKER_REASSIGNED"
|
|
ReasonWorkerPodGone Reason = "WORKER_POD_GONE"
|
|
ReasonUnknown Reason = "UNKNOWN"
|
|
)
|
|
|
|
// AllReasons contains all valid Reason constants for validation. Keep in sync with const block above.
|
|
var AllReasons = []Reason{
|
|
ReasonTerminalFileSystemError,
|
|
ReasonInvalidSandboxAsset,
|
|
ReasonInvalidCheckpointResult,
|
|
ReasonFaileSaveSnapshot,
|
|
ReasonInvalidObjectURL,
|
|
ReasonFailedGetExternalObject,
|
|
ReasonInvalidContainerConfig,
|
|
ReasonLocalSnapshotGone,
|
|
ReasonCorruptedAssignment,
|
|
ReasonWorkerReassigned,
|
|
ReasonWorkerPodGone,
|
|
ReasonUnknown,
|
|
}
|
|
|
|
// MetadataKeyActorCrashed marks (in ErrorInfo.Metadata) a failure that requires
|
|
// the control plane to crash the actor.
|
|
const MetadataKeyActorCrashed = "actorCrashed"
|
|
|
|
// ActorCrashedMetadata returns the AIP-193 metadata marking a failure as
|
|
// requiring the actor to be crashed. The control plane reads it via
|
|
// ActorCrashRequested.
|
|
func ActorCrashedMetadata() map[string]string {
|
|
return map[string]string{MetadataKeyActorCrashed: "true"}
|
|
}
|
|
|
|
// NewGRPCError builds an internal gRPC status error per AIP-193
|
|
// (https://google.aip.dev/193#status-message), with a google.rpc.ErrorInfo detail
|
|
// carrying the given Reason ("UNSET" when empty).
|
|
// metadata carries additional structured directives such as ActorCrashedMetadata(),
|
|
// which the control plane reads via ActorCrashRequested to decide whether to crash
|
|
// the actor.
|
|
func NewGRPCError(ctx context.Context, grpcCode codes.Code, reason Reason, metadata map[string]string, err error) error {
|
|
// Validate the input parameters.
|
|
if err == nil || grpcCode == codes.OK {
|
|
return fmt.Errorf("cannot use NewGRPCError with OK error code or a nil err grpcCode=%v, err=%w. Return nil instead", grpcCode, err)
|
|
}
|
|
if reason == "" {
|
|
reason = "UNSET"
|
|
}
|
|
st, derr := status.New(grpcCode, err.Error()).WithDetails(
|
|
&epb.ErrorInfo{
|
|
Domain: errorDomain,
|
|
Reason: string(reason),
|
|
Metadata: metadata,
|
|
},
|
|
)
|
|
if derr != nil {
|
|
// WithDetails on *epb.ErrorInfo should never fail; but if it ever does, the
|
|
// reason and metadata are lost and the control plane will misclassify the
|
|
// failure (e.g. a real crash read as a transient error). Log loudly for
|
|
// debugging purpose.
|
|
slog.ErrorContext(ctx, "ateerrors: failed to attach ErrorInfo to gRPC status; adding Reason/metadata to the error message instead",
|
|
"err", derr, "reason", reason, "metadata", metadata, "code", grpcCode)
|
|
return status.Error(grpcCode, fmt.Errorf("reason:%s metadata:%v, error %w", reason, metadata, err).Error())
|
|
}
|
|
return st.Err()
|
|
}
|
|
|
|
// CrashIfReason sets the err to a DataLoss gRPC status with the actor-crash
|
|
// directive iff its chain carries one of the given Reasons; any other error is
|
|
// returned unchanged. Claiming is per call site: the same tagged failure may
|
|
// crash the actor in one RPC and stay retriable in another.
|
|
func CrashIfReason(ctx context.Context, err error, reasons ...Reason) error {
|
|
r, ok := errors.AsType[Reason](err)
|
|
if !ok || !slices.Contains(reasons, r) {
|
|
return err
|
|
}
|
|
return NewGRPCError(ctx, codes.DataLoss, r, ActorCrashedMetadata(), err)
|
|
}
|
|
|
|
// ActorCrashRequested reports whether any ErrorInfo carried by err has the
|
|
// actorCrashed=true directive, i.e. the failure requires the control plane to
|
|
// crash the actor.
|
|
func ActorCrashRequested(err error) bool {
|
|
st, ok := status.FromError(err)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, d := range st.Details() {
|
|
if info, ok := d.(*epb.ErrorInfo); ok {
|
|
if info.GetMetadata()[MetadataKeyActorCrashed] == "true" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsValidReason reports whether a string matches a known ateerrors.Reason enum.
|
|
func IsValidReason(s string) bool {
|
|
return slices.Contains(AllReasons, Reason(s))
|
|
}
|
|
|
|
// ExtractReason returns the validated enum reason string from an error's AIP-193 ErrorInfo detail
|
|
// or wrapped ateerrors.Reason, or empty string if unclassified.
|
|
func ExtractReason(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
var r Reason
|
|
if errors.As(err, &r) && IsValidReason(string(r)) {
|
|
return string(r)
|
|
}
|
|
st, ok := status.FromError(err)
|
|
if ok {
|
|
for _, d := range st.Details() {
|
|
if info, ok := d.(*epb.ErrorInfo); ok {
|
|
if rStr := info.GetReason(); rStr != "" && IsValidReason(rStr) {
|
|
return rStr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|