독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
365 lines
12 KiB
Go
365 lines
12 KiB
Go
package agentpolicy
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"iop/packages/go/agentprovider/cli/status"
|
|
"iop/packages/go/agentruntime"
|
|
)
|
|
|
|
// QuotaState is the only quota result admitted to a durable work-attempt
|
|
// observation. It intentionally has no provider output or diagnostic field.
|
|
type QuotaState string
|
|
|
|
const (
|
|
QuotaStateAvailable QuotaState = "available"
|
|
QuotaStateExhausted QuotaState = "exhausted"
|
|
QuotaStateUnknown QuotaState = "unknown"
|
|
QuotaStateNotApplicable QuotaState = "not_applicable"
|
|
)
|
|
|
|
// ObservationValidity distinguishes a valid unknown quota from evidence that
|
|
// is too old or malformed to make a continuation decision.
|
|
type ObservationValidity string
|
|
|
|
const (
|
|
ObservationValid ObservationValidity = "valid"
|
|
ObservationStale ObservationValidity = "stale"
|
|
ObservationCorrupt ObservationValidity = "corrupt"
|
|
)
|
|
|
|
// QuotaObservation is the safe, immutable projection of one provider quota
|
|
// snapshot. It excludes raw status output, checker errors, and cap details.
|
|
type QuotaObservation struct {
|
|
SnapshotID string
|
|
Adapter string
|
|
Target string
|
|
State QuotaState
|
|
CheckedAt time.Time
|
|
Validity ObservationValidity
|
|
Reasons []string
|
|
projectionIntegrity string
|
|
}
|
|
|
|
// quotaObservationJSON is the strict durable representation of a quota
|
|
// projection. The integrity value is serialized without exposing a caller-settable
|
|
// Go field, and is verified before the value is returned from JSON decoding.
|
|
type quotaObservationJSON struct {
|
|
SnapshotID string `json:"snapshot_id"`
|
|
Adapter string `json:"adapter"`
|
|
Target string `json:"target"`
|
|
State QuotaState `json:"state"`
|
|
CheckedAt time.Time `json:"checked_at"`
|
|
Validity ObservationValidity `json:"validity"`
|
|
Reasons []string `json:"reasons"`
|
|
ProjectionIntegrity string `json:"projection_integrity"`
|
|
}
|
|
|
|
// FailureObservation is the safe, policy-relevant projection of a runtime
|
|
// failure. Message and Metadata are deliberately excluded from durable state.
|
|
type FailureObservation struct {
|
|
Code agentruntime.FailureCode
|
|
Retryable bool
|
|
}
|
|
|
|
// AttemptObservation binds safe quota and failure evidence to one work
|
|
// attempt. Callers retain the returned value; this package never shares input
|
|
// slices or maps with it.
|
|
type AttemptObservation struct {
|
|
ObservedAt time.Time
|
|
Quota QuotaObservation
|
|
Failure FailureObservation
|
|
}
|
|
|
|
// NormalizeAttemptObservation converts quota and failure input into the only
|
|
// observation shape a continuation policy can consume. It never copies a
|
|
// failure message, failure metadata, provider output, or checker error.
|
|
func NormalizeAttemptObservation(
|
|
snapshot status.QuotaSnapshot,
|
|
failure *agentruntime.Failure,
|
|
observedAt time.Time,
|
|
maxAge time.Duration,
|
|
) AttemptObservation {
|
|
return AttemptObservation{
|
|
ObservedAt: observedAt.UTC(),
|
|
Quota: NormalizeQuotaObservation(snapshot, observedAt, maxAge),
|
|
Failure: NormalizeFailureObservation(failure),
|
|
}
|
|
}
|
|
|
|
// NormalizeQuotaObservation creates a safe quota projection. Invalid source
|
|
// identity and timestamps are marked corrupt; an otherwise valid but old
|
|
// snapshot is marked stale. Both states must block continuation.
|
|
func NormalizeQuotaObservation(
|
|
snapshot status.QuotaSnapshot,
|
|
observedAt time.Time,
|
|
maxAge time.Duration,
|
|
) QuotaObservation {
|
|
if observedAt.IsZero() || status.ValidateQuotaSnapshot(snapshot) != nil {
|
|
return CorruptQuotaObservation()
|
|
}
|
|
checkedAt, err := time.Parse(time.RFC3339Nano, snapshot.CheckedAt)
|
|
if err != nil || checkedAt.IsZero() || checkedAt.After(observedAt.UTC()) {
|
|
return CorruptQuotaObservation()
|
|
}
|
|
target := snapshot.Targets[0]
|
|
state, ok := parseQuotaState(target.Status)
|
|
if !ok {
|
|
return CorruptQuotaObservation()
|
|
}
|
|
observation := QuotaObservation{
|
|
SnapshotID: snapshot.SnapshotID,
|
|
Adapter: target.Adapter,
|
|
Target: target.Target,
|
|
State: state,
|
|
CheckedAt: checkedAt.UTC(),
|
|
Validity: ObservationValid,
|
|
Reasons: cloneStrings(snapshot.ReasonCodes),
|
|
}
|
|
if maxAge > 0 && observation.CheckedAt.Add(maxAge).Before(observedAt.UTC()) {
|
|
observation.Validity = ObservationStale
|
|
}
|
|
observation.projectionIntegrity = quotaObservationIntegrity(observation)
|
|
return observation
|
|
}
|
|
|
|
// CorruptQuotaObservation is the canonical secret-free replacement for quota
|
|
// evidence that cannot be validated. It deliberately retains no caller data.
|
|
func CorruptQuotaObservation() QuotaObservation {
|
|
return QuotaObservation{Validity: ObservationCorrupt}
|
|
}
|
|
|
|
// MarshalJSON writes only a valid, sealed quota projection. This prevents an
|
|
// invalid in-memory value from becoming durable evidence through a generic
|
|
// ManagerState JSON encode.
|
|
func (o QuotaObservation) MarshalJSON() ([]byte, error) {
|
|
if !validateQuotaObservation(o) {
|
|
return nil, fmt.Errorf("agentpolicy: cannot encode corrupt quota observation")
|
|
}
|
|
return json.Marshal(quotaObservationJSON{
|
|
SnapshotID: o.SnapshotID,
|
|
Adapter: o.Adapter,
|
|
Target: o.Target,
|
|
State: o.State,
|
|
CheckedAt: o.CheckedAt.UTC(),
|
|
Validity: o.Validity,
|
|
Reasons: cloneStrings(o.Reasons),
|
|
ProjectionIntegrity: o.projectionIntegrity,
|
|
})
|
|
}
|
|
|
|
// UnmarshalJSON accepts one exact, sealed durable quota projection. It
|
|
// rejects unknown fields, malformed data, and any projection-integrity drift
|
|
// before the containing manager state can be used.
|
|
func (o *QuotaObservation) UnmarshalJSON(data []byte) error {
|
|
var encoded quotaObservationJSON
|
|
if err := decodeStrictJSON(data, &encoded); err != nil {
|
|
return fmt.Errorf("agentpolicy: decode quota observation: %w", err)
|
|
}
|
|
next := QuotaObservation{
|
|
SnapshotID: encoded.SnapshotID,
|
|
Adapter: encoded.Adapter,
|
|
Target: encoded.Target,
|
|
State: encoded.State,
|
|
CheckedAt: encoded.CheckedAt.UTC(),
|
|
Validity: encoded.Validity,
|
|
Reasons: cloneStrings(encoded.Reasons),
|
|
projectionIntegrity: encoded.ProjectionIntegrity,
|
|
}
|
|
if !validateQuotaObservation(next) {
|
|
return fmt.Errorf("agentpolicy: invalid quota observation")
|
|
}
|
|
*o = next
|
|
return nil
|
|
}
|
|
|
|
// SanitizeQuotaObservation returns a defensive copy of valid durable evidence
|
|
// or the canonical corrupt observation. It is safe to use at untrusted port
|
|
// boundaries before persistence.
|
|
func SanitizeQuotaObservation(observation QuotaObservation) QuotaObservation {
|
|
observation.Reasons = cloneStrings(observation.Reasons)
|
|
if !validateQuotaObservation(observation) {
|
|
return CorruptQuotaObservation()
|
|
}
|
|
return observation
|
|
}
|
|
|
|
// SanitizeAttemptObservation canonicalizes an untrusted invocation
|
|
// observation before the manager stores or evaluates it. A missing timestamp
|
|
// uses the manager-supplied fallback; malformed quota data retains no source
|
|
// identity, reason, cap, or diagnostic.
|
|
func SanitizeAttemptObservation(
|
|
observation AttemptObservation,
|
|
fallbackObservedAt time.Time,
|
|
) AttemptObservation {
|
|
observedAt := observation.ObservedAt.UTC()
|
|
if observedAt.IsZero() {
|
|
observedAt = fallbackObservedAt.UTC()
|
|
}
|
|
if observedAt.IsZero() {
|
|
observedAt = time.Unix(0, 0).UTC()
|
|
}
|
|
failure := observation.Failure
|
|
if !knownFailureCode(failure.Code) {
|
|
failure = FailureObservation{Code: agentruntime.FailureCodeUnknown}
|
|
}
|
|
quota := SanitizeQuotaObservation(observation.Quota)
|
|
if quota.Validity != ObservationCorrupt && quota.CheckedAt.After(observedAt) {
|
|
quota = CorruptQuotaObservation()
|
|
}
|
|
return AttemptObservation{
|
|
ObservedAt: observedAt,
|
|
Quota: quota,
|
|
Failure: failure,
|
|
}
|
|
}
|
|
|
|
// NormalizeFailureObservation converts unknown future failure codes to the
|
|
// explicit unknown category without retaining provider diagnostics.
|
|
func NormalizeFailureObservation(failure *agentruntime.Failure) FailureObservation {
|
|
if failure == nil || !knownFailureCode(failure.Code) {
|
|
return FailureObservation{Code: agentruntime.FailureCodeUnknown}
|
|
}
|
|
return FailureObservation{Code: failure.Code, Retryable: failure.Retryable}
|
|
}
|
|
|
|
// Clone returns an independent attempt value suitable for state snapshots.
|
|
func (o AttemptObservation) Clone() AttemptObservation {
|
|
out := o
|
|
out.Quota.Reasons = cloneStrings(o.Quota.Reasons)
|
|
return out
|
|
}
|
|
|
|
// ValidateAttemptObservation checks the safe snapshot shape without treating
|
|
// unknown, stale, or corrupt evidence as a successful observation.
|
|
func ValidateAttemptObservation(observation AttemptObservation) bool {
|
|
if observation.ObservedAt.IsZero() || !knownFailureCode(observation.Failure.Code) {
|
|
return false
|
|
}
|
|
if !validateQuotaObservation(observation.Quota) {
|
|
return false
|
|
}
|
|
return observation.Quota.Validity == ObservationCorrupt ||
|
|
!observation.Quota.CheckedAt.After(observation.ObservedAt.UTC())
|
|
}
|
|
|
|
func parseQuotaState(value string) (QuotaState, bool) {
|
|
switch QuotaState(value) {
|
|
case QuotaStateAvailable, QuotaStateExhausted, QuotaStateUnknown,
|
|
QuotaStateNotApplicable:
|
|
return QuotaState(value), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func validateQuotaObservation(observation QuotaObservation) bool {
|
|
if observation.Validity == ObservationCorrupt {
|
|
return observation.SnapshotID == "" &&
|
|
observation.Adapter == "" &&
|
|
observation.Target == "" &&
|
|
observation.State == "" &&
|
|
observation.CheckedAt.IsZero() &&
|
|
len(observation.Reasons) == 0 &&
|
|
observation.projectionIntegrity == ""
|
|
}
|
|
if observation.Validity != ObservationValid && observation.Validity != ObservationStale {
|
|
return false
|
|
}
|
|
if !validQuotaSnapshotIdentity(observation.SnapshotID) ||
|
|
!safeObservationIdentity(observation.Adapter) ||
|
|
!safeObservationIdentity(observation.Target) ||
|
|
observation.CheckedAt.IsZero() ||
|
|
status.ValidateQuotaReasonCodes(observation.Reasons) != nil {
|
|
return false
|
|
}
|
|
state, ok := parseQuotaState(string(observation.State))
|
|
if !ok {
|
|
return false
|
|
}
|
|
var reasonsValid bool
|
|
switch state {
|
|
case QuotaStateNotApplicable:
|
|
reasonsValid = len(observation.Reasons) == 1 &&
|
|
observation.Reasons[0] == "quota_not_applicable"
|
|
case QuotaStateUnknown:
|
|
reasonsValid = len(observation.Reasons) == 1 &&
|
|
(observation.Reasons[0] == "checker_error" ||
|
|
observation.Reasons[0] == "cap_evidence_unknown")
|
|
case QuotaStateAvailable, QuotaStateExhausted:
|
|
reasonsValid = len(observation.Reasons) == 0
|
|
default:
|
|
return false
|
|
}
|
|
if !reasonsValid {
|
|
return false
|
|
}
|
|
return constantBytesEqual(
|
|
observation.projectionIntegrity,
|
|
quotaObservationIntegrity(observation),
|
|
)
|
|
}
|
|
|
|
// quotaObservationIntegrity seals every policy-visible projection field after
|
|
// snapshot validation and final valid/stale classification. Ordered reasons
|
|
// are deliberate: their sequence is part of the original evidence.
|
|
func quotaObservationIntegrity(observation QuotaObservation) string {
|
|
parts := []string{
|
|
"quota-observation-v1",
|
|
observation.SnapshotID,
|
|
observation.Adapter,
|
|
observation.Target,
|
|
string(observation.State),
|
|
observation.CheckedAt.UTC().Format(time.RFC3339Nano),
|
|
string(observation.Validity),
|
|
}
|
|
parts = append(parts, observation.Reasons...)
|
|
return digestParts(parts...)
|
|
}
|
|
|
|
func knownFailureCode(code agentruntime.FailureCode) bool {
|
|
switch code {
|
|
case agentruntime.FailureCodeUnknown,
|
|
agentruntime.FailureCodeCancelled,
|
|
agentruntime.FailureCodeDeadlineExceeded,
|
|
agentruntime.FailureCodeInvalidRequest,
|
|
agentruntime.FailureCodeSessionNotFound,
|
|
agentruntime.FailureCodeUnavailable,
|
|
agentruntime.FailureCodeQuotaExhausted,
|
|
agentruntime.FailureCodeProcessExit,
|
|
agentruntime.FailureCodeProvider,
|
|
agentruntime.FailureCodeInternal:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func safeObservationIdentity(value string) bool {
|
|
return value != "" && strings.TrimSpace(value) == value &&
|
|
!strings.ContainsAny(value, "\x00\r\n")
|
|
}
|
|
|
|
func validQuotaSnapshotIdentity(value string) bool {
|
|
const prefix = "quota-"
|
|
if len(value) != len(prefix)+64 || !strings.HasPrefix(value, prefix) {
|
|
return false
|
|
}
|
|
for _, character := range value[len(prefix):] {
|
|
if (character < '0' || character > '9') &&
|
|
(character < 'a' || character > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func cloneStrings(input []string) []string {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
return append([]string(nil), input...)
|
|
}
|