iop/apps/edge/internal/service/single_request_observation.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

700 lines
23 KiB
Go

package service
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// singleRequestEventClass is the closed top-level event class for every
// single-request observation. It scopes the lifecycle without exposing request
// or stage identity (SDD S07).
type singleRequestEventClass string
const (
singleRequestEventClassRequest singleRequestEventClass = "request"
singleRequestEventClassStage singleRequestEventClass = "stage"
singleRequestEventClassTool singleRequestEventClass = "tool"
singleRequestEventClassCleanup singleRequestEventClass = "cleanup"
singleRequestEventClassTerminal singleRequestEventClass = "terminal"
)
// singleRequestStage is the closed stage role observed on stage events.
type singleRequestStage string
const (
singleRequestStagePlan singleRequestStage = "plan"
singleRequestStageWork singleRequestStage = "work"
singleRequestStageReview singleRequestStage = "review"
)
// singleRequestOperation is the closed operation observed on request/tool/cleanup events.
type singleRequestOperation string
const (
singleRequestOperationPlan singleRequestOperation = "plan"
singleRequestOperationWork singleRequestOperation = "work"
singleRequestOperationReview singleRequestOperation = "review"
singleRequestOperationTool singleRequestOperation = "tool"
singleRequestOperationCleanup singleRequestOperation = "cleanup"
singleRequestOperationTerminal singleRequestOperation = "terminal"
singleRequestOperationTotal singleRequestOperation = "total"
)
// singleRequestOutcome is the closed outcome observed on stage/terminal events.
type singleRequestOutcome string
const (
singleRequestOutcomeSuccess singleRequestOutcome = "success"
singleRequestOutcomeError singleRequestOutcome = "error"
singleRequestOutcomeCancel singleRequestOutcome = "cancel"
)
// singleRequestErrorClass is the closed error classification observed on
// stage/terminal events when outcome is error or cancel. It never carries
// raw error text.
type singleRequestErrorClass string
const (
singleRequestErrorClassProvider singleRequestErrorClass = "provider"
singleRequestErrorClassValidation singleRequestErrorClass = "validation"
singleRequestErrorClassTimeout singleRequestErrorClass = "timeout"
singleRequestErrorClassCancel singleRequestErrorClass = "cancel"
singleRequestErrorClassInternalToolBudget singleRequestErrorClass = "internal_tool_budget"
singleRequestErrorClassInternalToolFailed singleRequestErrorClass = "internal_tool_failed"
singleRequestErrorClassWorkspaceCleanup singleRequestErrorClass = "workspace_cleanup"
)
// singleRequestDTO is the closed, copy-safe single-request observation record.
// It contains only closed identities, durations/counts, and truncated booleans.
// It never contains request text, public model, provider id, Node/root/path,
// command/template/env, tool input/output, error string, header, credential,
// or raw terminal output.
type singleRequestDTO struct {
// EventClass is the closed top-level event class.
EventClass singleRequestEventClass
// Stage is the closed stage role (plan/work/review). Empty for non-stage events.
Stage singleRequestStage
// Operation is the closed operation observed on request/tool/cleanup events.
Operation singleRequestOperation
// Outcome is the closed outcome (success/error/cancel).
Outcome singleRequestOutcome
// ErrorClass is the closed error classification. Empty when outcome is success.
ErrorClass singleRequestErrorClass
// DurationMS is the duration in milliseconds for stage/tool/cleanup/total events.
DurationMS int64
// ToolCount is the number of tool calls during a stage. Zero for non-stage events.
ToolCount int
// HasResult is true when a finalizing candidate was prepared. Truncated boolean.
HasResult bool
// Correlation is a bounded generated execution correlation id for later logs.
Correlation string
}
// singleRequestDTOIsValid reports whether d has a valid event class.
func singleRequestDTOIsValid(d singleRequestDTO) bool {
return singleRequestEventClassIsValid(d.EventClass)
}
// singleRequestEventClassIsValid reports whether c is a known event class.
func singleRequestEventClassIsValid(c singleRequestEventClass) bool {
switch c {
case singleRequestEventClassRequest, singleRequestEventClassStage,
singleRequestEventClassTool, singleRequestEventClassCleanup,
singleRequestEventClassTerminal:
return true
default:
return false
}
}
// singleRequestStageIsValid reports whether s is a known stage role.
func singleRequestStageIsValid(s singleRequestStage) bool {
switch s {
case singleRequestStagePlan, singleRequestStageWork, singleRequestStageReview:
return true
default:
return false
}
}
// singleRequestOperationIsValid reports whether o is a known operation.
func singleRequestOperationIsValid(o singleRequestOperation) bool {
switch o {
case singleRequestOperationPlan, singleRequestOperationWork, singleRequestOperationReview,
singleRequestOperationTool, singleRequestOperationCleanup,
singleRequestOperationTerminal, singleRequestOperationTotal:
return true
default:
return false
}
}
// singleRequestOutcomeIsValid reports whether o is a known outcome.
func singleRequestOutcomeIsValid(o singleRequestOutcome) bool {
switch o {
case singleRequestOutcomeSuccess, singleRequestOutcomeError, singleRequestOutcomeCancel:
return true
default:
return false
}
}
// singleRequestErrorClassIsValid reports whether e is a known error class.
func singleRequestErrorClassIsValid(e singleRequestErrorClass) bool {
switch e {
case singleRequestErrorClassProvider, singleRequestErrorClassValidation,
singleRequestErrorClassTimeout, singleRequestErrorClassCancel,
singleRequestErrorClassInternalToolBudget, singleRequestErrorClassInternalToolFailed,
singleRequestErrorClassWorkspaceCleanup:
return true
default:
return false
}
}
// singleRequestNormalizeStage converts a raw stage string to its closed form.
// Unknown values become empty so callers cannot smuggle arbitrary text.
func singleRequestNormalizeStage(raw string) singleRequestStage {
switch singleRequestStage(raw) {
case singleRequestStagePlan, singleRequestStageWork, singleRequestStageReview:
return singleRequestStage(raw)
default:
return ""
}
}
// singleRequestNormalizeOperation converts a raw operation string to its closed form.
// Unknown values become empty.
func singleRequestNormalizeOperation(raw string) singleRequestOperation {
switch singleRequestOperation(raw) {
case singleRequestOperationPlan, singleRequestOperationWork, singleRequestOperationReview,
singleRequestOperationTool, singleRequestOperationCleanup,
singleRequestOperationTerminal, singleRequestOperationTotal:
return singleRequestOperation(raw)
default:
return ""
}
}
// singleRequestNormalizeOutcome converts a raw outcome string to its closed form.
// Unknown values become empty.
func singleRequestNormalizeOutcome(raw string) singleRequestOutcome {
switch singleRequestOutcome(raw) {
case singleRequestOutcomeSuccess, singleRequestOutcomeError, singleRequestOutcomeCancel:
return singleRequestOutcome(raw)
default:
return ""
}
}
// singleRequestNormalizeErrorClass converts a raw error class string to its closed form.
// Unknown values become empty.
func singleRequestNormalizeErrorClass(raw string) singleRequestErrorClass {
switch singleRequestErrorClass(raw) {
case singleRequestErrorClassProvider, singleRequestErrorClassValidation,
singleRequestErrorClassTimeout, singleRequestErrorClassCancel,
singleRequestErrorClassInternalToolBudget, singleRequestErrorClassInternalToolFailed,
singleRequestErrorClassWorkspaceCleanup:
return singleRequestErrorClass(raw)
default:
return ""
}
}
// singleRequestContainsSecretSentinel reports whether s contains secret sentinels.
func singleRequestContainsSecretSentinel(s string) bool {
lower := strings.ToLower(s)
return strings.Contains(lower, "secret") ||
strings.Contains(lower, "bearer") ||
strings.Contains(lower, "api_key") ||
strings.Contains(lower, "token") ||
strings.Contains(s, "\x00")
}
// singleRequestSanitizeString truncates strings that contain secret sentinels
// or exceed the bounded length. Returns empty for secret-containing strings.
func singleRequestSanitizeString(s string) string {
if singleRequestContainsSecretSentinel(s) {
return ""
}
if len(s) > 64 {
return s[:64]
}
return s
}
var singleRequestCorrelationFallback atomic.Uint64
// newSingleRequestCorrelationID creates one bounded, raw-input-independent
// correlation id. It is generated once per accumulator rather than derived
// from caller request or stage identifiers, which may contain sensitive input.
func newSingleRequestCorrelationID() string {
var bytes [16]byte
if _, err := rand.Read(bytes[:]); err == nil {
return "sr-" + hex.EncodeToString(bytes[:])
}
return "sr-fallback-" + strconv.FormatUint(singleRequestCorrelationFallback.Add(1), 36)
}
// singleRequestObserver is the service-owned observation contract. Implementations
// own storage and retention; callers only own the bounded DTO inputs.
// Emit must not block indefinitely — sinks that need bounded work should apply
// their own timeout internally.
type singleRequestObserver interface {
Emit(dto singleRequestDTO) error
}
// singleRequestNoopObserver discards every observation. It is the default
// observer for hosts that have not wired a logging backend yet.
type singleRequestNoopObserver struct{}
// Emit discards the observation and always returns nil.
func (singleRequestNoopObserver) Emit(_ singleRequestDTO) error {
return nil
}
// singleRequestObserverFailureHook is called when an observer failure occurs.
// It is optional; the observer isolates failures so they never affect request
// results.
type singleRequestObserverFailureHook func(dto singleRequestDTO, err error)
// singleRequestSafeObserver wraps an inner observer with failure isolation.
// If the inner observer panics or returns an error, the failure is reported
// through the hook (which is also panic-isolated) and Emit returns nil.
// Both observer and hook panics are completely isolated so the request path
// is never interrupted.
type singleRequestSafeObserver struct {
inner singleRequestObserver
onFailure singleRequestObserverFailureHook
failures int64
mu sync.Mutex
}
// Emit forwards the DTO to the inner observer with failure isolation.
// If the inner observer returns an error or panics, the failure is reported
// through the hook (which is also panic-isolated) and Emit returns nil.
func (s *singleRequestSafeObserver) Emit(dto singleRequestDTO) error {
if s == nil || s.inner == nil {
return nil
}
func() {
defer func() {
if r := recover(); r != nil {
s.mu.Lock()
s.failures++
s.mu.Unlock()
if s.onFailure != nil {
func() {
defer func() {
_ = recover()
}()
s.onFailure(dto, errObserverPanic(r))
}()
}
}
}()
if err := s.inner.Emit(dto); err != nil {
s.mu.Lock()
s.failures++
s.mu.Unlock()
if s.onFailure != nil {
func() {
defer func() {
_ = recover()
}()
s.onFailure(dto, err)
}()
}
return
}
}()
return nil
}
// failureCount returns the number of isolated failures observed so far.
// Safe for concurrent reads from tests.
func (s *singleRequestSafeObserver) failureCount() int64 {
if s == nil {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
return s.failures
}
// errObserverPanic wraps a recovered panic value into a sentinel error.
// The hook receives this error to distinguish panic vs. Emit error.
func errObserverPanic(r any) error {
return errSingleRequestObserverPanic{reason: r}
}
type errSingleRequestObserverPanic struct {
reason any
}
func (e errSingleRequestObserverPanic) Error() string {
return "single-request observer panic"
}
// singleRequestClock is the injectable clock interface for deterministic testing.
// The production implementation delegates to time.Now and time.Since.
type singleRequestClock interface {
Now() time.Time
Since(time.Time) time.Duration
}
// singleRequestRealClock is the production clock implementation.
type singleRequestRealClock struct{}
// Now returns the current wall-clock time.
func (singleRequestRealClock) Now() time.Time {
return time.Now()
}
// Since returns the duration since t.
func (singleRequestRealClock) Since(t time.Time) time.Duration {
return time.Since(t)
}
// singleRequestManualClock is the deterministic clock for tests. It advances
// only when Advance is called, allowing precise timing assertions without
// real elapsed time.
type singleRequestManualClock struct {
mu sync.Mutex
now time.Time
advance time.Duration
}
// newSingleRequestManualClock creates a manual clock starting at the given time.
func newSingleRequestManualClock(start time.Time) *singleRequestManualClock {
return &singleRequestManualClock{now: start}
}
// Now returns the current manual clock time.
func (c *singleRequestManualClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
// Since returns the duration since t using the manual clock.
func (c *singleRequestManualClock) Since(t time.Time) time.Duration {
c.mu.Lock()
defer c.mu.Unlock()
return c.now.Sub(t)
}
// Advance advances the manual clock by d. Safe for concurrent use.
func (c *singleRequestManualClock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// singleRequestTimingAccumulator accumulates provider-active stage time,
// tool time, cleanup time, and request total time. It is copy-safe and
// thread-safe.
type singleRequestTimingAccumulator struct {
mu sync.Mutex
startTime time.Time
clock singleRequestClock
observer *singleRequestSafeObserver
// correlation joins every lifecycle DTO for this request without carrying
// any caller-controlled identifier.
correlation string
// Provider-active stage time: time spent in plan/work/review stages.
// Pauses during internal_tool execution.
stageActiveStart time.Time
stageActiveMs int64
// Tool time: time spent in internal_tool execution.
toolStart time.Time
toolMs int64
// Cleanup time: time spent in cleanup.
cleanupStart time.Time
cleanupMs int64
// Total time: measured from request start to terminal resolution.
totalMs int64
// Terminal outcome and error class, set exactly once by the terminal winner.
terminalOutcome singleRequestOutcome
terminalErrorClass singleRequestErrorClass
terminalHasResult bool
// Stage event count per stage role.
stageEventCount int
// Tool call count during active stage.
toolCallCount int
// pendingStageDuration accumulates stage time between tool enter/exit pairs.
// It is added to stageActiveMs on stage exit.
pendingStageDurationMs int64
activeStage singleRequestStage
stageActive bool
pendingStageClose *singleRequestPendingStageClose
}
type singleRequestPendingStageClose struct {
stage singleRequestStage
outcome singleRequestOutcome
errorClass singleRequestErrorClass
}
// newSingleRequestTimingAccumulator creates a new timing accumulator.
// The observer is wrapped in a safe observer for failure isolation.
func newSingleRequestTimingAccumulator(clock singleRequestClock, observer singleRequestObserver) *singleRequestTimingAccumulator {
if clock == nil {
clock = singleRequestRealClock{}
}
if observer == nil {
observer = singleRequestNoopObserver{}
}
safe := &singleRequestSafeObserver{inner: observer}
return &singleRequestTimingAccumulator{
startTime: clock.Now(),
clock: clock,
observer: safe,
correlation: newSingleRequestCorrelationID(),
}
}
// onStageEnter records the start of a provider-active stage (plan/work/review).
// It resets the pending stage duration accumulator.
func (a *singleRequestTimingAccumulator) onStageEnter(stage ...singleRequestStage) {
a.mu.Lock()
defer a.mu.Unlock()
if len(stage) > 0 && stage[0] != "" {
if a.activeStage == stage[0] {
return
}
a.activeStage = stage[0]
}
a.stageActiveStart = a.clock.Now()
a.pendingStageDurationMs = 0
a.stageActive = true
}
// onStageExit records the end of a provider-active stage and emits a stage event.
// It includes all accumulated stage time (excluding tool time).
func (a *singleRequestTimingAccumulator) onStageExit(stage singleRequestStage, outcome singleRequestOutcome, errorClass singleRequestErrorClass) {
a.mu.Lock()
if stage == "" || a.activeStage != "" && a.activeStage != stage {
a.mu.Unlock()
return
}
if !a.toolStart.IsZero() {
// A terminal can win while a Node tool is still settling. Keep the
// semantic stage open until the tool has emitted and contributed to its
// count, then emit the stage without restarting its active timer.
if a.pendingStageClose == nil {
a.pendingStageClose = &singleRequestPendingStageClose{stage: stage, outcome: outcome, errorClass: errorClass}
}
a.mu.Unlock()
return
}
dto := a.closeStageLocked(stage, outcome, errorClass)
a.mu.Unlock()
a.emitSafe(dto)
}
// closeStageLocked finalizes the active semantic stage. Caller must hold a.mu.
func (a *singleRequestTimingAccumulator) closeStageLocked(stage singleRequestStage, outcome singleRequestOutcome, errorClass singleRequestErrorClass) singleRequestDTO {
duration := a.pendingStageDurationMs
if !a.stageActiveStart.IsZero() {
duration += a.clock.Since(a.stageActiveStart).Milliseconds()
}
a.stageActiveMs += duration
a.stageActiveStart = time.Time{}
a.pendingStageDurationMs = 0
a.stageEventCount++
toolCount := a.toolCallCount
a.toolCallCount = 0
a.activeStage = ""
a.stageActive = false
a.pendingStageClose = nil
return singleRequestDTO{
EventClass: singleRequestEventClassStage,
Stage: stage,
Operation: singleRequestNormalizeOperation(string(stage)),
Outcome: outcome,
ErrorClass: errorClass,
DurationMS: duration,
ToolCount: toolCount,
Correlation: a.correlation,
}
}
// onToolEnter records the start of internal_tool execution and pauses stage timing.
// The elapsed stage time is accumulated in pendingStageDurationMs.
func (a *singleRequestTimingAccumulator) onToolEnter() {
a.mu.Lock()
defer a.mu.Unlock()
if !a.stageActiveStart.IsZero() {
a.pendingStageDurationMs += a.clock.Since(a.stageActiveStart).Milliseconds()
a.stageActiveStart = time.Time{}
}
if a.toolStart.IsZero() {
a.toolStart = a.clock.Now()
}
}
// onToolExit records one actual Node tool outcome, emits its closed DTO, and
// resumes the still-active semantic provider stage.
func (a *singleRequestTimingAccumulator) onToolExit(outcome singleRequestOutcome, errorClass singleRequestErrorClass) {
a.mu.Lock()
if a.toolStart.IsZero() {
a.mu.Unlock()
return
}
duration := a.clock.Since(a.toolStart).Milliseconds()
a.toolMs += duration
a.toolStart = time.Time{}
a.toolCallCount++
toolDTO := singleRequestDTO{
EventClass: singleRequestEventClassTool,
Operation: singleRequestOperationTool,
Outcome: outcome,
ErrorClass: errorClass,
DurationMS: duration,
Correlation: a.correlation,
}
var stageDTO *singleRequestDTO
if pending := a.pendingStageClose; pending != nil {
dto := a.closeStageLocked(pending.stage, pending.outcome, pending.errorClass)
stageDTO = &dto
} else if a.stageActive {
// Resume only a still-active semantic stage. A terminal stage close must
// never leave a phantom active timer behind.
a.stageActiveStart = a.clock.Now()
}
a.mu.Unlock()
a.emitSafe(toolDTO)
if stageDTO != nil {
a.emitSafe(*stageDTO)
}
}
// onCleanupEnter records the start of cleanup.
func (a *singleRequestTimingAccumulator) onCleanupEnter() {
a.mu.Lock()
defer a.mu.Unlock()
a.cleanupStart = a.clock.Now()
}
// onCleanupExit records the end of cleanup and emits a cleanup event.
func (a *singleRequestTimingAccumulator) onCleanupExit(outcome singleRequestOutcome, errorClass singleRequestErrorClass) {
a.mu.Lock()
if a.cleanupStart.IsZero() {
a.mu.Unlock()
return
}
duration := a.clock.Since(a.cleanupStart).Milliseconds()
a.cleanupMs += duration
a.cleanupStart = time.Time{}
a.mu.Unlock()
dto := singleRequestDTO{
EventClass: singleRequestEventClassCleanup,
Operation: singleRequestOperationCleanup,
Outcome: outcome,
ErrorClass: errorClass,
DurationMS: duration,
Correlation: a.correlation,
}
a.emitSafe(dto)
}
// onTerminal records the terminal outcome exactly once and emits a terminal event.
// The terminal winner owns exactly one terminal event and one request-total event.
func (a *singleRequestTimingAccumulator) onTerminal(outcome singleRequestOutcome, errorClass singleRequestErrorClass, hasResult bool) {
a.mu.Lock()
if a.terminalOutcome != "" {
// Already recorded by another caller; ignore.
a.mu.Unlock()
return
}
a.terminalOutcome = outcome
a.terminalErrorClass = errorClass
a.terminalHasResult = hasResult
a.totalMs = a.clock.Since(a.startTime).Milliseconds()
a.mu.Unlock()
dto := singleRequestDTO{
EventClass: singleRequestEventClassTerminal,
Operation: singleRequestOperationTerminal,
Outcome: outcome,
ErrorClass: errorClass,
DurationMS: a.totalMs,
HasResult: hasResult,
Correlation: a.correlation,
}
a.emitSafe(dto)
}
// onRequest records the initial request event.
func (a *singleRequestTimingAccumulator) onRequest() {
dto := singleRequestDTO{
EventClass: singleRequestEventClassRequest,
Operation: singleRequestOperationTotal,
Outcome: singleRequestOutcomeSuccess,
Correlation: a.correlation,
}
a.emitSafe(dto)
}
// emitSafe emits a DTO through the safe observer. Observer failures are isolated
// and never propagate to the caller.
func (a *singleRequestTimingAccumulator) emitSafe(dto singleRequestDTO) {
if !singleRequestDTOIsValid(dto) {
return
}
// Normalize any non-empty string fields to closed form.
dto.Stage = singleRequestNormalizeStage(string(dto.Stage))
dto.Operation = singleRequestNormalizeOperation(string(dto.Operation))
dto.Outcome = singleRequestNormalizeOutcome(string(dto.Outcome))
dto.ErrorClass = singleRequestNormalizeErrorClass(string(dto.ErrorClass))
// Sanitize correlation id.
dto.Correlation = singleRequestSanitizeString(string(dto.Correlation))
a.observer.Emit(dto)
}
// timingSnapshot returns a copy-safe snapshot of the accumulated timing data.
// Used for verification in tests.
func (a *singleRequestTimingAccumulator) timingSnapshot() singleRequestTimingSnapshot {
a.mu.Lock()
defer a.mu.Unlock()
return singleRequestTimingSnapshot{
StageActiveMs: a.stageActiveMs,
ToolMs: a.toolMs,
CleanupMs: a.cleanupMs,
TotalMs: a.totalMs,
StageEventCount: a.stageEventCount,
ToolCallCount: a.toolCallCount,
TerminalOutcome: a.terminalOutcome,
TerminalErrorClass: a.terminalErrorClass,
TerminalHasResult: a.terminalHasResult,
}
}
// singleRequestTimingSnapshot is a copy-safe snapshot of accumulated timing data.
type singleRequestTimingSnapshot struct {
StageActiveMs int64
ToolMs int64
CleanupMs int64
TotalMs int64
StageEventCount int
ToolCallCount int
TerminalOutcome singleRequestOutcome
TerminalErrorClass singleRequestErrorClass
TerminalHasResult bool
}