iop/packages/go/execution/liveness.go
toki fef1f7a9dc feat(liveness): provider 실행 stall 관측을 구현한다
Node의 provider progress 기반 stall timeout, watchdog fencing과 bounded health probe evidence를 실행 경로에 반영한다. Edge-Node 계약과 구현 스펙, 테스트 및 Milestone 완료 evidence를 현재 상태와 맞춘다.
2026-08-05 09:45:14 +09:00

296 lines
12 KiB
Go

package execution
import (
"context"
"errors"
"math"
"time"
)
// DefaultResponseStallTimeoutMS is the default response-stall timeout in
// milliseconds. It is used when no provider-configured value is available
// (zero wire value, omitted config, direct/legacy dispatch).
const DefaultResponseStallTimeoutMS = 300000
// maxSafeStallTimeoutMS is the largest millisecond value that can safely
// become a time.Duration without overflow. Values above this bound are
// rejected by the config validator and treated as invalid on the wire.
const maxSafeStallTimeoutMS = math.MaxInt64 / int64(time.Millisecond)
// ResolveStallTimeoutMS validates and normalizes a raw response-stall timeout
// value in one pass. It is the single validate-then-normalize entry point used
// by config load and both Node wire boundaries: zero maps to the documented
// default, safe positive values pass through unchanged, and negative or
// duration-overflow values return a StallTimeoutValidationError before any
// router or provider invocation. It never silently converts an invalid value.
func ResolveStallTimeoutMS(ms int64) (int64, error) {
if err := ValidateStallTimeoutMS(ms); err != nil {
return 0, err
}
if ms == 0 {
return DefaultResponseStallTimeoutMS, nil
}
return ms, nil
}
// ValidateStallTimeoutMS returns nil when ms is zero (use default) or a
// positive value that can safely become a time.Duration in milliseconds.
// Negative values and values exceeding the safe duration bound are rejected.
// It is the single validation entry point used by config and the wire boundary.
func ValidateStallTimeoutMS(ms int64) error {
if ms < 0 {
return &StallTimeoutValidationError{
Value: ms,
Msg: "response_stall_timeout_ms must be non-negative",
}
}
if ms > maxSafeStallTimeoutMS {
return &StallTimeoutValidationError{
Value: ms,
Msg: "response_stall_timeout_ms exceeds safe duration bound",
}
}
return nil
}
// StallTimeoutValidationError is returned when a response_stall_timeout_ms
// value is negative or exceeds the safe duration bound.
type StallTimeoutValidationError struct {
Value int64
Msg string
}
func (e *StallTimeoutValidationError) Error() string {
if e.Msg != "" {
return e.Msg
}
return "invalid response_stall_timeout_ms"
}
// ProviderActivityDisposition classifies a provider output signal for the
// watchdog. The classifier is the single source of truth for progress and
// terminal decisions; handlers never switch on kind independently.
type ProviderActivityDisposition string
const (
// DispositionNone means the signal carries no provider progress
// information and must not reset the watchdog timer.
DispositionNone ProviderActivityDisposition = "none"
// DispositionStart establishes the initial baseline for the watchdog.
// It is emitted once per run before any progress signals and lets the
// observer record a known starting point without conflating that
// transition with later progress resets.
DispositionStart ProviderActivityDisposition = "start"
// DispositionProgress means the provider is actively making progress
// and must reset the watchdog timer.
DispositionProgress ProviderActivityDisposition = "progress"
// DispositionTerminal means the provider has produced a terminal
// signal (complete, error, cancelled, end). The watchdog must stop
// observing this run.
DispositionTerminal ProviderActivityDisposition = "terminal"
)
// ClassifyRuntimeEvent classifies a RuntimeEvent into a ProviderActivityDisposition.
// Terminality is decided by the event type, never by token counts.
//
// Rules:
// - start → DispositionStart
// - complete/error/cancelled → DispositionTerminal (takes precedence over any payload/usage)
// - non-terminal delta/reasoning_delta with non-empty delta/message or a usage observation → DispositionProgress
// - empty/unknown type → DispositionNone
func ClassifyRuntimeEvent(event RuntimeEvent) ProviderActivityDisposition {
switch event.Type {
case EventTypeStart:
return DispositionStart
case EventTypeComplete, EventTypeError, EventTypeCancelled:
return DispositionTerminal
case EventTypeDelta, EventTypeReasoningDelta:
if event.Delta != "" || event.Message != "" || event.Usage != nil {
return DispositionProgress
}
return DispositionNone
default:
return DispositionNone
}
}
// ClassifyProviderTunnelFrame classifies a ProviderTunnelFrame into a
// ProviderActivityDisposition.
//
// Rules:
// - response_start (including headers) → DispositionProgress
// - non-empty body → DispositionProgress
// - usage frame → DispositionProgress
// - end/error → DispositionTerminal (takes precedence over payload)
// - empty/unknown kind → DispositionNone
func ClassifyProviderTunnelFrame(frame ProviderTunnelFrame) ProviderActivityDisposition {
switch frame.Kind {
case ProviderTunnelFrameKindEnd, ProviderTunnelFrameKindError:
return DispositionTerminal
case ProviderTunnelFrameKindResponseStart:
// response_start with or without headers is progress.
return DispositionProgress
case ProviderTunnelFrameKindBody:
if len(frame.Body) > 0 {
return DispositionProgress
}
return DispositionNone
case ProviderTunnelFrameKindUsage:
// A usage frame is always progress for the tunnel path; the watchdog
// observes token consumption as active provider work.
return DispositionProgress
default:
return DispositionNone
}
}
// ErrProbeUnsupported is carried in a ProbeOutcome when an adapter does not
// implement active provider probing. It is one of the inconclusive outcomes
// the probe normalizer collapses to HealthUnknown rather than treating as a
// definitive available or exact-target-unavailable result.
var ErrProbeUnsupported = errors.New("execution: adapter does not support provider probing")
// ProviderHealth is the stable, fail-closed classification of a provider's
// health as observed by a single bounded exact-target probe. It is the only
// value terminal assembly consumes from a probe: probe completion is evidence
// only and must never reset original request progress, change the attempt
// fence, or authorize retry.
type ProviderHealth string
const (
// HealthUnknown is the fail-closed default. The probe could not establish
// a definitive available or exact-target-unavailable result. Every error,
// timeout, unsupported adapter, unknown status, and identity mismatch maps
// here.
HealthUnknown ProviderHealth = "health_unknown"
// ProviderUnhealthy means a valid probe positively reported the exact
// target as absent.
ProviderUnhealthy ProviderHealth = "provider_unhealthy"
// RequestStalled means a valid probe positively reported the exact target
// as available, corroborating that the stalled request targets a live
// target rather than a missing endpoint.
RequestStalled ProviderHealth = "request_stalled"
)
// LivenessClassification is the stable, observable category a bounded
// exact-target probe outcome reduces to before it becomes a ProviderHealth.
// It exists so every fail-closed branch is independently testable; the
// normalizer is the single mapping from classification to health.
type LivenessClassification string
const (
// LivenessAvailable means a valid probe reported the exact target present.
LivenessAvailable LivenessClassification = "available"
// LivenessUnavailable means a valid probe reported the exact target absent.
LivenessUnavailable LivenessClassification = "unavailable"
// LivenessTimeout means the bounded probe context expired before a result.
LivenessTimeout LivenessClassification = "timeout"
// LivenessError means the probe returned a transport, protocol, or decode
// error that is not itself a definitive target-absent result.
LivenessError LivenessClassification = "error"
// LivenessUnsupported means the adapter does not implement active probing.
LivenessUnsupported LivenessClassification = "unsupported"
// LivenessUnknown means the probe returned an unrecognized status.
LivenessUnknown LivenessClassification = "unknown"
// LivenessIdentityMismatch means the probe identity did not match the
// requested adapter or target identity.
LivenessIdentityMismatch LivenessClassification = "identity_mismatch"
)
// ProbeOutcome is the typed, target-aware input to the fail-closed probe
// outcome normalizer. The coordinator validates and populates every field
// from a single bounded exact-target probe attempt; the normalizer never
// copies arbitrary provider metadata from it.
type ProbeOutcome struct {
// AdapterName is the adapter identity reported by the probe result.
AdapterName string
// InstanceKey is the stable registry instance key reported by the probe.
InstanceKey string
// Target is the exact target reported by the probe result.
Target string
// Status is the normalized provider status reported by the probe.
Status ProviderStatus
// Err is the inconclusive error returned by the probe, if any.
Err error
// ExpectedAdapter is the adapter identity the caller required.
ExpectedAdapter string
// ExpectedInstance is the instance key the caller required; empty means the
// caller does not pin a specific registry instance.
ExpectedInstance string
// ExpectedTarget is the exact target the caller required.
ExpectedTarget string
}
// ClassifyProbeOutcome reduces a bounded exact-target probe outcome to its
// stable liveness classification. It is pure and fail-closed: any error, probe
// expiry, unsupported adapter, unknown status, or identity mismatch is an
// inconclusive classification rather than a definitive one. A returned error
// takes precedence over any reported status.
func ClassifyProbeOutcome(outcome ProbeOutcome) LivenessClassification {
if outcome.Err != nil {
if errors.Is(outcome.Err, context.Canceled) || errors.Is(outcome.Err, context.DeadlineExceeded) {
return LivenessTimeout
}
if errors.Is(outcome.Err, ErrProbeUnsupported) {
return LivenessUnsupported
}
return LivenessError
}
if !probeIdentityValid(outcome) {
return LivenessIdentityMismatch
}
switch outcome.Status {
case ProviderStatusAvailable:
return LivenessAvailable
case ProviderStatusUnavailable:
return LivenessUnavailable
default:
return LivenessUnknown
}
}
// HealthFromClassification maps a liveness classification to its stable
// ProviderHealth value. Available yields RequestStalled, unavailable yields
// ProviderUnhealthy, and every inconclusive classification yields
// HealthUnknown.
func HealthFromClassification(classification LivenessClassification) ProviderHealth {
switch classification {
case LivenessAvailable:
return RequestStalled
case LivenessUnavailable:
return ProviderUnhealthy
default:
return HealthUnknown
}
}
// NormalizeProbeOutcome maps a bounded exact-target probe outcome to its
// stable fail-closed ProviderHealth value. It is the composition of
// ClassifyProbeOutcome and HealthFromClassification: a validated matching
// available result yields RequestStalled, a validated matching unavailable
// result yields ProviderUnhealthy, and every error, timeout, unsupported
// adapter, unknown status, and identity mismatch yields HealthUnknown. It is
// pure and side-effect free.
func NormalizeProbeOutcome(outcome ProbeOutcome) ProviderHealth {
return HealthFromClassification(ClassifyProbeOutcome(outcome))
}
// probeIdentityValid reports whether a probe result's adapter and target
// identity is non-empty and exactly matches what the caller required. When the
// caller pins an instance key, the probe must confirm it. An empty or
// mismatched identity is inconclusive and must fail closed.
func probeIdentityValid(outcome ProbeOutcome) bool {
if outcome.AdapterName == "" || outcome.ExpectedAdapter == "" {
return false
}
if outcome.Target == "" || outcome.ExpectedTarget == "" {
return false
}
if outcome.AdapterName != outcome.ExpectedAdapter || outcome.Target != outcome.ExpectedTarget {
return false
}
if outcome.ExpectedInstance != "" && outcome.InstanceKey != outcome.ExpectedInstance {
return false
}
return true
}