iop/packages/go/agentpolicy/quota.go
toki 3c48879c44 feat(agent-runtime): 독립 Agent CLI 런타임 기반을 구현한다
독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
2026-07-29 13:12:21 +09:00

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...)
}