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

1317 lines
50 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
iop "iop/proto/gen/iop"
)
// capturingObserver captures every emitted DTO for test assertions.
type capturingObserver struct {
mu sync.Mutex
events []singleRequestDTO
failures int64
hookErr error
}
func (c *capturingObserver) Emit(dto singleRequestDTO) error {
c.mu.Lock()
c.events = append(c.events, dto)
c.mu.Unlock()
return c.hookErr
}
func (c *capturingObserver) snapshot() []singleRequestDTO {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]singleRequestDTO, len(c.events))
copy(out, c.events)
return out
}
func (c *capturingObserver) count() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.events)
}
func assertSingleRequestCorrelation(t *testing.T, events []singleRequestDTO, forbidden ...string) string {
t.Helper()
if len(events) == 0 {
t.Fatal("expected lifecycle observation events")
}
correlation := events[0].Correlation
if correlation == "" || len(correlation) > 64 {
t.Fatalf("invalid correlation %q", correlation)
}
for _, event := range events {
if event.Correlation != correlation {
t.Fatalf("event correlation=%q, want request correlation %q: %#v", event.Correlation, correlation, event)
}
if singleRequestContainsSecretSentinel(event.Correlation) {
t.Fatalf("correlation leaked secret sentinel: %q", event.Correlation)
}
for _, value := range forbidden {
if value != "" && strings.Contains(event.Correlation, value) {
t.Fatalf("correlation leaked caller-controlled value %q: %q", value, event.Correlation)
}
}
}
return correlation
}
// panickingObserver always panics on Emit to test failure isolation.
type panickingObserver struct{}
func (panickingObserver) Emit(_ singleRequestDTO) error {
panic("observer panic")
}
func TestSingleRequestObservationClosedEnums(t *testing.T) {
// Unknown enum values normalize to empty and are dropped.
if singleRequestEventClassIsValid("unknown") {
t.Fatal("unknown event class should be invalid")
}
if singleRequestStageIsValid("unknown") {
t.Fatal("unknown stage should be invalid")
}
if singleRequestOperationIsValid("unknown") {
t.Fatal("unknown operation should be invalid")
}
if singleRequestOutcomeIsValid("unknown") {
t.Fatal("unknown outcome should be invalid")
}
if singleRequestErrorClassIsValid("unknown") {
t.Fatal("unknown error class should be invalid")
}
// Known values are valid.
if !singleRequestEventClassIsValid(singleRequestEventClassRequest) {
t.Fatal("request event class should be valid")
}
if !singleRequestStageIsValid(singleRequestStagePlan) {
t.Fatal("plan stage should be valid")
}
if !singleRequestOperationIsValid(singleRequestOperationTotal) {
t.Fatal("total operation should be valid")
}
if !singleRequestOutcomeIsValid(singleRequestOutcomeSuccess) {
t.Fatal("success outcome should be valid")
}
if !singleRequestErrorClassIsValid(singleRequestErrorClassTimeout) {
t.Fatal("timeout error class should be valid")
}
// Normalizers return empty for unknown.
if singleRequestNormalizeStage("unknown") != "" {
t.Fatal("normalizer should return empty for unknown")
}
if singleRequestNormalizeOperation("unknown") != "" {
t.Fatal("normalizer should return empty for unknown")
}
if singleRequestNormalizeOutcome("unknown") != "" {
t.Fatal("normalizer should return empty for unknown")
}
if singleRequestNormalizeErrorClass("unknown") != "" {
t.Fatal("normalizer should return empty for unknown")
}
}
func TestSingleRequestObservationSanitization(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"clean", "hello world", "hello world"},
{"truncated", strings.Repeat("x", 100), strings.Repeat("x", 64)},
{"secret", "my secret token", ""},
{"bearer", "bearer abc123", ""},
{"api_key", "api_key=xyz", ""},
{"null_byte", "hello\x00world", ""},
{"case_insensitive", "SECRET here", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := singleRequestSanitizeString(tt.input)
if got != tt.expected {
t.Fatalf("sanitize(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
if !singleRequestContainsSecretSentinel("my secret") {
t.Fatal("should detect secret")
}
if !singleRequestContainsSecretSentinel("bearer abc") {
t.Fatal("should detect bearer")
}
if !singleRequestContainsSecretSentinel("api_key=xyz") {
t.Fatal("should detect api_key")
}
if !singleRequestContainsSecretSentinel("has\x00null") {
t.Fatal("should detect null byte")
}
if singleRequestContainsSecretSentinel("clean text") {
t.Fatal("should not flag clean text")
}
}
func TestSingleRequestObservationDeterministicTiming(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
captured := &capturingObserver{}
acc := newSingleRequestTimingAccumulator(clock, captured)
// Request event.
acc.onRequest()
// Stage enter: plan.
acc.onStageEnter()
clock.Advance(100 * time.Millisecond)
// Stage exit: plan -> success.
acc.onStageExit(singleRequestStagePlan, singleRequestOutcomeSuccess, "")
// Stage enter: work.
acc.onStageEnter()
clock.Advance(200 * time.Millisecond)
// Tool enter/exit (should be excluded from stage active time).
acc.onToolEnter()
clock.Advance(50 * time.Millisecond)
acc.onToolExit(singleRequestOutcomeSuccess, "")
// Continue work.
clock.Advance(150 * time.Millisecond)
// Stage exit: work -> success.
acc.onStageExit(singleRequestStageWork, singleRequestOutcomeSuccess, "")
// Stage enter: review.
acc.onStageEnter()
clock.Advance(80 * time.Millisecond)
// Stage exit: review -> success.
acc.onStageExit(singleRequestStageReview, singleRequestOutcomeSuccess, "")
// Cleanup (after all stages, before terminal).
acc.onCleanupEnter()
clock.Advance(30 * time.Millisecond)
acc.onCleanupExit(singleRequestOutcomeSuccess, "")
// Terminal.
acc.onTerminal(singleRequestOutcomeSuccess, "", true)
events := captured.snapshot()
// Expected events include the actual internal tool outcome.
if got := len(events); got != 7 {
t.Fatalf("event count = %d, want 7", got)
}
// Verify event classes.
expectedClasses := []singleRequestEventClass{
singleRequestEventClassRequest,
singleRequestEventClassStage,
singleRequestEventClassTool,
singleRequestEventClassStage,
singleRequestEventClassStage,
singleRequestEventClassCleanup,
singleRequestEventClassTerminal,
}
for i, expected := range expectedClasses {
if events[i].EventClass != expected {
t.Fatalf("event[%d].EventClass = %s, want %s", i, events[i].EventClass, expected)
}
}
// Verify timing math: stage_active + tool + cleanup <= total.
// plan=100ms, work=200+150=350ms (tool 50ms excluded), review=80ms => stageActive=530ms
// ToolMs=50ms, CleanupMs=30ms, Total=610ms (100+200+50+150+80+30)
snap := acc.timingSnapshot()
if snap.StageActiveMs != 530 {
t.Fatalf("stageActiveMs = %d, want 530", snap.StageActiveMs)
}
if snap.ToolMs != 50 {
t.Fatalf("toolMs = %d, want 50", snap.ToolMs)
}
if snap.CleanupMs != 30 {
t.Fatalf("cleanupMs = %d, want 30", snap.CleanupMs)
}
if snap.TotalMs != 610 {
t.Fatalf("totalMs = %d, want 610", snap.TotalMs)
}
// Invariant: stage_active + tool + cleanup <= total.
if snap.StageActiveMs+snap.ToolMs+snap.CleanupMs > snap.TotalMs {
t.Fatalf("timing invariant violated: stage(%d) + tool(%d) + cleanup(%d) > total(%d)",
snap.StageActiveMs, snap.ToolMs, snap.CleanupMs, snap.TotalMs)
}
}
func TestSingleRequestObservationTerminalRacesExactlyOnce(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
captured := &capturingObserver{}
acc := newSingleRequestTimingAccumulator(clock, captured)
// Simulate multiple concurrent terminal calls.
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
acc.onTerminal(singleRequestOutcomeSuccess, "", true)
}()
}
wg.Wait()
// Should have exactly one terminal event.
terminalCount := 0
for _, e := range captured.snapshot() {
if e.EventClass == singleRequestEventClassTerminal {
terminalCount++
}
}
if terminalCount != 1 {
t.Fatalf("terminal event count = %d, want 1", terminalCount)
}
}
func TestSingleRequestObservationObserverPanicIsolation(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
// Panicking observer should not affect request lifecycle.
acc := newSingleRequestTimingAccumulator(clock, panickingObserver{})
// These should all complete without panic propagating.
acc.onRequest()
acc.onStageEnter()
clock.Advance(10 * time.Millisecond)
acc.onStageExit(singleRequestStagePlan, singleRequestOutcomeSuccess, "")
acc.onTerminal(singleRequestOutcomeSuccess, "", false)
// Verify events were still emitted (safe observer captures them).
if acc.observer.failureCount() == 0 {
t.Fatal("expected isolated failures from panicking observer")
}
}
func TestSingleRequestObservationObserverErrorIsolation(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
expectedErr := errors.New("observer write failure")
captured := &capturingObserver{hookErr: expectedErr}
acc := newSingleRequestTimingAccumulator(clock, captured)
// Emit should not propagate the error.
acc.onRequest()
acc.onStageEnter()
clock.Advance(10 * time.Millisecond)
acc.onStageExit(singleRequestStagePlan, singleRequestOutcomeSuccess, "")
acc.onTerminal(singleRequestOutcomeSuccess, "", false)
// Verify failures were counted.
if acc.observer.failureCount() == 0 {
t.Fatal("expected isolated failures from erroring observer")
}
}
func TestSingleRequestObservationSuccessLifecycle(t *testing.T) {
executor := &channelFakeExecutor{fn: func(_ context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
return submitToFinalizing(req, ctrl, &SingleRequestResult{Output: "success"})
}}
handle := startTestExecution(t, executor)
waitForState(t, handle, SingleRequestStateFinalizing)
if err := handle.AcknowledgeTerminal(true); err != nil {
t.Fatalf("AcknowledgeTerminal: %v", err)
}
_, err := waitForExecution(t, handle)
if err != nil {
t.Fatalf("Wait error = %v", err)
}
}
func TestSingleRequestObservationErrorLifecycle(t *testing.T) {
executor := &channelFakeExecutor{fn: func(_ context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
return errors.New("provider failure")
}}
handle := startTestExecution(t, executor)
_, err := waitForExecution(t, handle)
if err == nil || handle.State() != SingleRequestStateFailed {
t.Fatalf("Wait=(%v, state=%s), want failed", err, handle.State())
}
}
func TestSingleRequestObservationCancelLifecycle(t *testing.T) {
release := make(chan struct{})
executor := &channelFakeExecutor{fn: func(ctx context.Context, _ SingleRequestRequest, _ SingleRequestController) error {
<-ctx.Done()
return ctx.Err()
}}
handle := startTestExecution(t, executor)
handle.Cancel()
close(release)
_, err := waitForExecution(t, handle)
if !errors.Is(err, ErrSingleRequestCancelled) {
t.Fatalf("Wait error = %v, want ErrSingleRequestCancelled", err)
}
}
func TestSingleRequestObservationToolTimingExcludedFromStage(t *testing.T) {
// This test verifies that tool execution time is excluded from stage active time.
// We test the accumulator directly with a simulated lifecycle.
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
captured := &capturingObserver{}
acc := newSingleRequestTimingAccumulator(clock, captured)
// Simulate: plan stage with tool call inside.
acc.onStageEnter()
clock.Advance(100 * time.Millisecond)
// Tool enter/exit.
acc.onToolEnter()
clock.Advance(50 * time.Millisecond)
acc.onToolExit(singleRequestOutcomeSuccess, "")
// Continue plan.
clock.Advance(50 * time.Millisecond)
acc.onStageExit(singleRequestStagePlan, singleRequestOutcomeSuccess, "")
// Emit terminal to set total.
acc.onTerminal(singleRequestOutcomeSuccess, "", false)
snap := acc.timingSnapshot()
// Stage active should be 100 + 50 = 150ms (tool 50ms excluded).
if snap.StageActiveMs != 150 {
t.Fatalf("stageActiveMs = %d, want 150 (tool time excluded)", snap.StageActiveMs)
}
// Tool time should be 50ms.
if snap.ToolMs != 50 {
t.Fatalf("toolMs = %d, want 50", snap.ToolMs)
}
// Invariant: stage + tool <= total.
if snap.StageActiveMs+snap.ToolMs > snap.TotalMs {
t.Fatalf("invariant violated: stage(%d) + tool(%d) > total(%d)",
snap.StageActiveMs, snap.ToolMs, snap.TotalMs)
}
}
func TestSingleRequestObservationSentinelExclusion(t *testing.T) {
// Verify that DTOs with secret sentinels in correlation are sanitized.
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
captured := &capturingObserver{}
acc := newSingleRequestTimingAccumulator(clock, captured)
acc.onRequest()
events := captured.snapshot()
if len(events) != 1 {
t.Fatalf("event count = %d, want 1", len(events))
}
// Correlation should be sanitized (no secrets).
if singleRequestContainsSecretSentinel(events[0].Correlation) {
t.Fatal("correlation should not contain secret sentinels")
}
}
func TestSingleRequestObservationNoopObserver(t *testing.T) {
// nil observer should become noop.
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
acc := newSingleRequestTimingAccumulator(clock, nil)
// Should not panic.
acc.onRequest()
acc.onStageEnter()
clock.Advance(10 * time.Millisecond)
acc.onStageExit(singleRequestStagePlan, singleRequestOutcomeSuccess, "")
acc.onTerminal(singleRequestOutcomeSuccess, "", false)
}
func TestSingleRequestObservationTimingInvariant(t *testing.T) {
// Verify that stage_active + tool + cleanup <= total for various scenarios.
scenarios := []struct {
name string
stageMs int64
toolMs int64
cleanupMs int64
terminalMs int64
}{
{"minimal", 10, 5, 3, 20},
{"no_tool", 100, 0, 10, 115},
{"no_cleanup", 50, 20, 0, 75},
{"heavy_tool", 30, 200, 5, 240},
}
for _, sc := range scenarios {
t.Run(sc.name, func(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
captured := &capturingObserver{}
acc := newSingleRequestTimingAccumulator(clock, captured)
acc.onRequest()
acc.onStageEnter()
clock.Advance(time.Duration(sc.stageMs) * time.Millisecond)
acc.onStageExit(singleRequestStagePlan, singleRequestOutcomeSuccess, "")
if sc.toolMs > 0 {
acc.onToolEnter()
clock.Advance(time.Duration(sc.toolMs) * time.Millisecond)
acc.onToolExit(singleRequestOutcomeSuccess, "")
}
if sc.cleanupMs > 0 {
acc.onCleanupEnter()
clock.Advance(time.Duration(sc.cleanupMs) * time.Millisecond)
acc.onCleanupExit(singleRequestOutcomeSuccess, "")
}
remaining := sc.terminalMs - sc.stageMs - sc.toolMs - sc.cleanupMs
if remaining > 0 {
clock.Advance(time.Duration(remaining) * time.Millisecond)
}
acc.onTerminal(singleRequestOutcomeSuccess, "", true)
snap := acc.timingSnapshot()
if snap.StageActiveMs+snap.ToolMs+snap.CleanupMs > snap.TotalMs {
t.Fatalf("invariant violated: stage(%d) + tool(%d) + cleanup(%d) > total(%d)",
snap.StageActiveMs, snap.ToolMs, snap.CleanupMs, snap.TotalMs)
}
})
}
}
func TestSingleRequestObservationTerminalOutcome(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
captured := &capturingObserver{}
acc := newSingleRequestTimingAccumulator(clock, captured)
// Request event.
acc.onRequest()
// Success terminal (first terminal should be recorded).
acc.onTerminal(singleRequestOutcomeSuccess, "", true)
// Error terminal (should be ignored, already have terminal).
acc.onTerminal(singleRequestOutcomeError, singleRequestErrorClassProvider, false)
// Cancel terminal (should be ignored, already have terminal).
acc.onTerminal(singleRequestOutcomeCancel, singleRequestErrorClassCancel, false)
// Only the first terminal event should be recorded.
terminalCount := 0
for _, e := range captured.snapshot() {
if e.EventClass == singleRequestEventClassTerminal {
terminalCount++
}
}
if terminalCount != 1 {
t.Fatalf("terminal event count = %d, want 1", terminalCount)
}
// Verify the first (and only) terminal's outcome.
events := captured.snapshot()
if len(events) < 2 {
t.Fatal("expected at least request and terminal events")
}
firstTerminal := events[1] // index 0 is request event
if firstTerminal.Outcome != singleRequestOutcomeSuccess {
t.Fatalf("first terminal outcome = %s, want success", firstTerminal.Outcome)
}
}
func TestSingleRequestObservationDTOValidation(t *testing.T) {
// Invalid DTO should be dropped by emitSafe.
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
captured := &capturingObserver{}
acc := newSingleRequestTimingAccumulator(clock, captured)
// Direct call to emitSafe with invalid DTO.
acc.emitSafe(singleRequestDTO{EventClass: "invalid"})
if captured.count() != 0 {
t.Fatalf("invalid DTO should be dropped, got %d events", captured.count())
}
}
func TestSingleRequestObservationCorrelationID(t *testing.T) {
first := newSingleRequestCorrelationID()
second := newSingleRequestCorrelationID()
if first == "" || second == "" {
t.Fatal("correlations should not be empty")
}
if first == second {
t.Fatalf("separate correlations must differ: %q", first)
}
for _, correlation := range []string{first, second} {
if len(correlation) > 64 {
t.Fatalf("correlation exceeds bound: %q", correlation)
}
if strings.Contains(correlation, "request-secret-sentinel") || singleRequestContainsSecretSentinel(correlation) {
t.Fatalf("correlation includes caller or secret material: %q", correlation)
}
for _, character := range correlation {
if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-') {
t.Fatalf("correlation contains disallowed character %q in %q", character, correlation)
}
}
}
}
func TestSingleRequestObservationAccumulatorCorrelationsAreDistinct(t *testing.T) {
const accumulatorCount = 32
correlations := make(chan string, accumulatorCount)
var group sync.WaitGroup
for range accumulatorCount {
group.Add(1)
go func() {
defer group.Done()
observer := &capturingObserver{}
accumulator := newSingleRequestTimingAccumulator(nil, observer)
accumulator.onRequest()
correlations <- assertSingleRequestCorrelation(t, observer.snapshot(), "request-secret-sentinel")
}()
}
group.Wait()
close(correlations)
seen := make(map[string]struct{}, accumulatorCount)
for correlation := range correlations {
if _, duplicate := seen[correlation]; duplicate {
t.Fatalf("duplicate accumulator correlation %q", correlation)
}
seen[correlation] = struct{}{}
}
}
func TestSingleRequestObservationSafeObserverConcurrency(t *testing.T) {
// Verify that safe observer is concurrent-safe.
inner := &capturingObserver{}
safe := &singleRequestSafeObserver{inner: inner}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
safe.Emit(singleRequestDTO{EventClass: singleRequestEventClassRequest})
}()
}
wg.Wait()
if inner.count() != 100 {
t.Fatalf("event count = %d, want 100", inner.count())
}
}
func TestSingleRequestObservationSafeObserverNilInner(t *testing.T) {
safe := &singleRequestSafeObserver{}
// Should not panic with nil inner.
err := safe.Emit(singleRequestDTO{EventClass: singleRequestEventClassRequest})
if err != nil {
t.Fatalf("emit with nil inner should return nil, got %v", err)
}
}
func TestSingleRequestObservationManualClock(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
if !clock.Now().Equal(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatal("initial time mismatch")
}
clock.Advance(100 * time.Millisecond)
if since := clock.Since(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)); since != 100*time.Millisecond {
t.Fatalf("since = %v, want 100ms", since)
}
clock.Advance(50 * time.Millisecond)
if since := clock.Since(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)); since != 150*time.Millisecond {
t.Fatalf("since = %v, want 150ms", since)
}
}
func TestSingleRequestObservationRealClock(t *testing.T) {
clock := singleRequestRealClock{}
now := clock.Now()
if now.IsZero() {
t.Fatal("now should not be zero")
}
since := clock.Since(now)
if since < 0 {
t.Fatalf("since should be non-negative, got %v", since)
}
}
func TestSingleRequestObservationTimingSnapshotCopySafe(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
captured := &capturingObserver{}
acc := newSingleRequestTimingAccumulator(clock, captured)
acc.onRequest()
acc.onStageEnter()
clock.Advance(100 * time.Millisecond)
acc.onStageExit(singleRequestStagePlan, singleRequestOutcomeSuccess, "")
acc.onTerminal(singleRequestOutcomeSuccess, "", true)
snap1 := acc.timingSnapshot()
clock.Advance(50 * time.Millisecond)
acc.onTerminal(singleRequestOutcomeSuccess, "", false) // ignored, already terminal
snap2 := acc.timingSnapshot()
// Snapshots should be independent.
if snap1.StageActiveMs != snap2.StageActiveMs {
t.Fatal("snapshots should be independent")
}
}
func TestSingleRequestObservationObserverFailureHook(t *testing.T) {
var hookCalled atomic.Bool
var hookDTO singleRequestDTO
var hookErr error
inner := panickingObserver{}
hook := func(dto singleRequestDTO, err error) {
hookCalled.Store(true)
hookDTO = dto
hookErr = err
}
safe := &singleRequestSafeObserver{inner: inner, onFailure: hook}
safe.Emit(singleRequestDTO{EventClass: singleRequestEventClassRequest})
if !hookCalled.Load() {
t.Fatal("hook should have been called")
}
if hookDTO.EventClass != singleRequestEventClassRequest {
t.Fatalf("hook DTO event class = %s, want request", hookDTO.EventClass)
}
if hookErr == nil {
t.Fatal("hook error should not be nil")
}
if hookErr == nil || !strings.Contains(hookErr.Error(), "panic") {
t.Fatalf("hook error = %v, want observer panic", hookErr)
}
}
func TestSingleRequestObservationHookPanicIsolation(t *testing.T) {
// Hook that panics should not affect the safe observer.
inner := &capturingObserver{}
hook := func(_ singleRequestDTO, _ error) {
panic("hook panic")
}
safe := &singleRequestSafeObserver{inner: inner, onFailure: hook}
// Should not panic.
safe.Emit(singleRequestDTO{EventClass: singleRequestEventClassRequest})
// Inner should still receive the event.
if inner.count() != 1 {
t.Fatalf("inner event count = %d, want 1", inner.count())
}
}
type observationToolExecutor struct {
clock *singleRequestManualClock
results chan InternalWorkspaceToolResult
}
func (e *observationToolExecutor) ExecuteSingleRequest(ctx context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
if err := ctrl.SubmitEnvelope(testEnvelope(req.RequestID, 1, SingleRequestStatePlanning)); err != nil {
return err
}
e.clock.Advance(10 * time.Millisecond)
if err := ctrl.SubmitEnvelope(SingleRequestEnvelope{
RequestID: req.RequestID, Sequence: 2,
Stage: SingleRequestStateInternalTool, SavedStage: SingleRequestStatePlanning,
ToolCall: &InternalWorkspaceToolCall{
RequestID: req.RequestID, StageID: "plan", ToolCallID: "observed-tool",
Name: InternalWorkspaceToolRead, Arguments: json.RawMessage(`{"relative_path":"README.md"}`),
},
}); err != nil {
return err
}
select {
case <-e.results:
case <-ctx.Done():
return ctx.Err()
}
if err := ctrl.SubmitEnvelope(SingleRequestEnvelope{
RequestID: req.RequestID, Sequence: 3,
Stage: SingleRequestStatePlanning, SavedStage: SingleRequestStatePlanning,
}); err != nil {
return err
}
e.clock.Advance(7 * time.Millisecond)
if err := ctrl.SubmitEnvelope(testEnvelope(req.RequestID, 4, SingleRequestStateWorking)); err != nil {
return err
}
e.clock.Advance(11 * time.Millisecond)
if err := ctrl.SubmitEnvelope(testEnvelope(req.RequestID, 5, SingleRequestStateReviewing)); err != nil {
return err
}
e.clock.Advance(13 * time.Millisecond)
return ctrl.SubmitEnvelope(SingleRequestEnvelope{
RequestID: req.RequestID, Sequence: 6, Stage: SingleRequestStateFinalizing,
Result: &SingleRequestResult{Output: "final result"},
})
}
func (e *observationToolExecutor) ContinueInternalTool(_ context.Context, result InternalWorkspaceToolResult) error {
e.results <- result.Clone()
return nil
}
func TestSingleRequestObservationLifecycleIntegration(t *testing.T) {
t.Run("observer panic cannot alter service result", func(t *testing.T) {
service, _ := newInternalToolLoopService(t, &channelFakeExecutor{fn: func(_ context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
return submitToFinalizing(req, ctrl, &SingleRequestResult{Output: "success"})
}})
service.SetSingleRequestObserver(panickingObserver{})
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, nil))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
waitForState(t, handle, SingleRequestStateFinalizing)
if err := handle.AcknowledgeTerminal(true); err != nil {
t.Fatalf("AcknowledgeTerminal: %v", err)
}
if result, err := waitForExecution(t, handle); err != nil || result.Output != "success" {
t.Fatalf("Wait=(%q, %v)", result.Output, err)
}
if failures := handle.(*singleRequestHandle).timing.observer.failureCount(); failures == 0 {
t.Fatal("panicking observer failure was not isolated and recorded")
}
})
t.Run("service terminal race emits once", func(t *testing.T) {
observer := &capturingObserver{}
service, _ := newInternalToolLoopService(t, &channelFakeExecutor{fn: func(_ context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
return submitToFinalizing(req, ctrl, &SingleRequestResult{Output: "candidate"})
}})
service.SetSingleRequestObserver(observer)
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, nil))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
waitForState(t, handle, SingleRequestStateFinalizing)
var group sync.WaitGroup
group.Add(2)
go func() { defer group.Done(); _ = handle.AcknowledgeTerminal(true) }()
go func() { defer group.Done(); handle.Cancel() }()
group.Wait()
_, _ = waitForExecution(t, handle)
terminalCount := 0
for _, event := range observer.snapshot() {
if event.EventClass == singleRequestEventClassTerminal {
terminalCount++
}
}
if terminalCount != 1 {
t.Fatalf("terminal events=%d, want 1", terminalCount)
}
})
t.Run("service tool pause cleanup and terminal", func(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
observer := &capturingObserver{}
executor := &observationToolExecutor{clock: clock, results: make(chan InternalWorkspaceToolResult, 1)}
service, node := newInternalToolLoopService(t, executor)
service.SetSingleRequestClock(clock)
service.SetSingleRequestObserver(observer)
var openCount atomic.Int32
toki.AddRequestListenerTyped[*iop.WorkspaceOpenRequest, *iop.WorkspaceOpenResponse](&node.Communicator, func(req *iop.WorkspaceOpenRequest) (*iop.WorkspaceOpenResponse, error) {
openCount.Add(1)
return &iop.WorkspaceOpenResponse{RequestId: req.GetRequestId(), WorkspaceRef: req.GetWorkspaceRef(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
})
toki.AddRequestListenerTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&node.Communicator, func(req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
clock.Advance(50 * time.Millisecond)
return &iop.WorkspaceToolResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Content: []byte("redacted")}, nil
})
toki.AddRequestListenerTyped[*iop.WorkspaceCleanupRequest, *iop.WorkspaceCleanupResponse](&node.Communicator, func(req *iop.WorkspaceCleanupRequest) (*iop.WorkspaceCleanupResponse, error) {
clock.Advance(20 * time.Millisecond)
return &iop.WorkspaceCleanupResponse{RequestId: req.GetRequestId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
})
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, nil))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
waitForState(t, handle, SingleRequestStateFinalizing)
waitForSingleRequestCleanup(t, handle)
clock.Advance(5 * time.Millisecond)
if err := handle.AcknowledgeTerminal(true); err != nil {
t.Fatalf("AcknowledgeTerminal: %v", err)
}
if _, err := waitForExecution(t, handle); err != nil {
t.Fatalf("Wait: %v", err)
}
events := observer.snapshot()
wantClasses := []singleRequestEventClass{
singleRequestEventClassRequest, singleRequestEventClassTool,
singleRequestEventClassStage, singleRequestEventClassStage, singleRequestEventClassStage,
singleRequestEventClassCleanup, singleRequestEventClassTerminal,
}
if len(events) != len(wantClasses) {
t.Fatalf("event count=%d, want %d: %#v", len(events), len(wantClasses), events)
}
for index, want := range wantClasses {
if events[index].EventClass != want {
t.Fatalf("event[%d].EventClass=%q, want %q", index, events[index].EventClass, want)
}
if singleRequestContainsSecretSentinel(events[index].Correlation) {
t.Fatalf("event[%d] correlation leaked a sentinel: %q", index, events[index].Correlation)
}
}
assertSingleRequestCorrelation(t, events, "request-loop", "observed-tool", "redacted")
if events[1].DurationMS != 50 || events[1].Outcome != singleRequestOutcomeSuccess {
t.Fatalf("tool event=%+v, want one successful 50ms tool", events[1])
}
if events[2].Stage != singleRequestStagePlan || events[2].DurationMS != 17 || events[2].ToolCount != 1 {
t.Fatalf("plan stage=%+v, want 17ms with one tool", events[2])
}
if events[5].DurationMS != 20 || events[5].Outcome != singleRequestOutcomeSuccess {
t.Fatalf("cleanup event=%+v", events[5])
}
if events[6].Outcome != singleRequestOutcomeSuccess || events[6].DurationMS != 116 {
t.Fatalf("terminal event=%+v, want successful 116ms terminal", events[6])
}
if openCount.Load() != 1 {
t.Fatalf("workspace open count=%d, want 1", openCount.Load())
}
})
t.Run("cleanup failure closes service observation", func(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
observer := &capturingObserver{}
executor := &observationToolExecutor{clock: clock, results: make(chan InternalWorkspaceToolResult, 1)}
service, node := newInternalToolLoopService(t, executor)
service.SetSingleRequestClock(clock)
service.SetSingleRequestObserver(observer)
toki.AddRequestListenerTyped[*iop.WorkspaceOpenRequest, *iop.WorkspaceOpenResponse](&node.Communicator, func(req *iop.WorkspaceOpenRequest) (*iop.WorkspaceOpenResponse, error) {
return &iop.WorkspaceOpenResponse{RequestId: req.GetRequestId(), WorkspaceRef: req.GetWorkspaceRef(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
})
toki.AddRequestListenerTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&node.Communicator, func(req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
return &iop.WorkspaceToolResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
})
toki.AddRequestListenerTyped[*iop.WorkspaceCleanupRequest, *iop.WorkspaceCleanupResponse](&node.Communicator, func(req *iop.WorkspaceCleanupRequest) (*iop.WorkspaceCleanupResponse, error) {
clock.Advance(9 * time.Millisecond)
return &iop.WorkspaceCleanupResponse{RequestId: req.GetRequestId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR}, nil
})
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, nil))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
if _, err := waitForExecution(t, handle); !errors.Is(err, ErrSingleRequestWorkspaceCleanup) {
t.Fatalf("Wait error=%v, want cleanup failure", err)
}
events := observer.snapshot()
if len(events) != 7 || events[5].EventClass != singleRequestEventClassCleanup ||
events[5].Outcome != singleRequestOutcomeError || events[5].ErrorClass != singleRequestErrorClassWorkspaceCleanup ||
events[6].EventClass != singleRequestEventClassTerminal || events[6].Outcome != singleRequestOutcomeError {
t.Fatalf("cleanup failure observations=%#v", events)
}
if events[6].ErrorClass != singleRequestErrorClassWorkspaceCleanup {
t.Fatalf("cleanup conversion terminal class=%q, want workspace_cleanup", events[6].ErrorClass)
}
assertSingleRequestCorrelation(t, events, "request-loop")
})
for _, test := range []struct {
name string
execute func(*singleRequestManualClock, chan struct{}) SingleRequestExecutor
outcome singleRequestOutcome
}{
{
name: "provider failure",
execute: func(clock *singleRequestManualClock, _ chan struct{}) SingleRequestExecutor {
return &channelFakeExecutor{fn: func(_ context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
if err := ctrl.SubmitEnvelope(testEnvelope(req.RequestID, 1, SingleRequestStatePlanning)); err != nil {
return err
}
clock.Advance(4 * time.Millisecond)
return errors.New("provider failure")
}}
}, outcome: singleRequestOutcomeError,
},
{
name: "caller cancellation",
execute: func(_ *singleRequestManualClock, started chan struct{}) SingleRequestExecutor {
return &channelFakeExecutor{fn: func(ctx context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
if err := ctrl.SubmitEnvelope(testEnvelope(req.RequestID, 1, SingleRequestStatePlanning)); err != nil {
return err
}
close(started)
<-ctx.Done()
return ctx.Err()
}}
}, outcome: singleRequestOutcomeCancel,
},
} {
t.Run(test.name, func(t *testing.T) {
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
observer := &capturingObserver{}
started := make(chan struct{})
service, _ := newInternalToolLoopService(t, test.execute(clock, started))
service.SetSingleRequestClock(clock)
service.SetSingleRequestObserver(observer)
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, nil))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
if test.outcome == singleRequestOutcomeCancel {
<-started
clock.Advance(6 * time.Millisecond)
handle.Cancel()
}
if _, err := waitForExecution(t, handle); err == nil {
t.Fatal("Wait error=nil, want terminal failure")
}
events := observer.snapshot()
if len(events) != 4 {
t.Fatalf("event count=%d, want request/stage/cleanup/terminal", len(events))
}
if events[1].EventClass != singleRequestEventClassStage || events[1].Outcome != test.outcome ||
events[3].EventClass != singleRequestEventClassTerminal || events[3].Outcome != test.outcome {
t.Fatalf("unexpected terminal lifecycle events: %#v", events)
}
})
}
}
func TestSingleRequestObservationInFlightToolTerminalOrdering(t *testing.T) {
for _, test := range []struct {
name string
toolResponder func(*iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error)
cancel bool
wantOutcome singleRequestOutcome
wantTerminalClass singleRequestErrorClass
wantWait error
}{
{
name: "tool failure preserves primary class across cleanup failure",
toolResponder: func(req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
return &iop.WorkspaceToolResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR}, nil
},
wantOutcome: singleRequestOutcomeError,
wantTerminalClass: singleRequestErrorClassInternalToolFailed,
wantWait: ErrSingleRequestInternalToolFailed,
},
{
name: "caller cancellation settles in-flight tool before stage",
toolResponder: func(req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
return &iop.WorkspaceToolResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
},
cancel: true,
wantOutcome: singleRequestOutcomeCancel,
wantTerminalClass: singleRequestErrorClassCancel,
wantWait: ErrSingleRequestCancelled,
},
} {
t.Run(test.name, func(t *testing.T) {
observer := &capturingObserver{}
clock := newSingleRequestManualClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
executor := &observationToolExecutor{clock: clock, results: make(chan InternalWorkspaceToolResult, 1)}
service, node := newInternalToolLoopService(t, executor)
service.SetSingleRequestClock(clock)
service.SetSingleRequestObserver(observer)
toki.AddRequestListenerTyped[*iop.WorkspaceOpenRequest, *iop.WorkspaceOpenResponse](&node.Communicator, func(req *iop.WorkspaceOpenRequest) (*iop.WorkspaceOpenResponse, error) {
return &iop.WorkspaceOpenResponse{RequestId: req.GetRequestId(), WorkspaceRef: req.GetWorkspaceRef(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
})
toolStarted := make(chan struct{})
toolRelease := make(chan struct{})
var toolStartOnce sync.Once
toki.AddRequestListenerTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&node.Communicator, func(req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
toolStartOnce.Do(func() { close(toolStarted) })
if test.cancel {
<-toolRelease
}
return test.toolResponder(req)
})
toki.AddRequestListenerTyped[*iop.WorkspaceCleanupRequest, *iop.WorkspaceCleanupResponse](&node.Communicator, func(req *iop.WorkspaceCleanupRequest) (*iop.WorkspaceCleanupResponse, error) {
return &iop.WorkspaceCleanupResponse{RequestId: req.GetRequestId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR}, nil
})
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, nil))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
if test.cancel {
select {
case <-toolStarted:
case <-time.After(2 * time.Second):
t.Fatal("tool did not start")
}
handle.Cancel()
close(toolRelease)
}
if _, err := waitForExecution(t, handle); !errors.Is(err, test.wantWait) {
t.Fatalf("Wait error=%v, want %v", err, test.wantWait)
}
events := observer.snapshot()
if len(events) != 5 {
t.Fatalf("event count=%d, want request/tool/stage/cleanup/terminal: %#v", len(events), events)
}
if events[1].EventClass != singleRequestEventClassTool || events[1].Outcome != test.wantOutcome ||
events[2].EventClass != singleRequestEventClassStage || events[2].Outcome != test.wantOutcome || events[2].ToolCount != 1 {
t.Fatalf("tool/stage ordering=%#v", events)
}
if events[4].EventClass != singleRequestEventClassTerminal || events[4].Outcome != test.wantOutcome || events[4].ErrorClass != test.wantTerminalClass {
t.Fatalf("terminal=%#v, want outcome=%q class=%q", events[4], test.wantOutcome, test.wantTerminalClass)
}
assertSingleRequestCorrelation(t, events, "request-loop", "observed-tool")
})
}
}
type admissionRaceExecutor struct {
planningStarted chan struct{}
submitTool chan struct{}
}
func (e *admissionRaceExecutor) ExecuteSingleRequest(ctx context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
if err := ctrl.SubmitEnvelope(testEnvelope(req.RequestID, 1, SingleRequestStatePlanning)); err != nil {
return err
}
close(e.planningStarted)
select {
case <-e.submitTool:
case <-ctx.Done():
return ctx.Err()
}
err := ctrl.SubmitEnvelope(SingleRequestEnvelope{
RequestID: req.RequestID, Sequence: 2,
Stage: SingleRequestStateInternalTool, SavedStage: SingleRequestStatePlanning,
ToolCall: &InternalWorkspaceToolCall{
RequestID: req.RequestID, StageID: "plan", ToolCallID: "admission-race-tool",
Name: InternalWorkspaceToolRead, Arguments: json.RawMessage(`{"relative_path":"README.md"}`),
},
})
if err != nil {
return err
}
<-ctx.Done()
return ctx.Err()
}
func (e *admissionRaceExecutor) ContinueInternalTool(_ context.Context, _ InternalWorkspaceToolResult) error {
return nil
}
func TestSingleRequestObservationDeadlineClassifications(t *testing.T) {
terminalEvent := func(t *testing.T, observer *capturingObserver) singleRequestDTO {
t.Helper()
for _, event := range observer.snapshot() {
if event.EventClass == singleRequestEventClassTerminal {
return event
}
}
t.Fatal("terminal observation was not emitted")
return singleRequestDTO{}
}
t.Run("request wall-clock expiry preserves the budget sentinel", func(t *testing.T) {
observer := &capturingObserver{}
service, _ := newInternalToolLoopService(t, &channelFakeExecutor{fn: func(ctx context.Context, _ SingleRequestRequest, _ SingleRequestController) error {
<-ctx.Done()
return ctx.Err()
}})
service.SetSingleRequestObserver(observer)
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, func(binding *SingleRequestBinding) {
binding.Limits.WallClockMS = 50
binding.Limits.StageTimeoutMS = 50
}))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
if _, err := waitForExecution(t, handle); !errors.Is(err, ErrSingleRequestInternalToolBudget) {
t.Fatalf("Wait error=%v, want internal tool budget sentinel", err)
}
if terminal := terminalEvent(t, observer); terminal.ErrorClass != singleRequestErrorClassTimeout {
t.Fatalf("terminal error class=%q, want timeout", terminal.ErrorClass)
}
})
t.Run("stage timer expiry is observed as timeout", func(t *testing.T) {
observer := &capturingObserver{}
service, _ := newInternalToolLoopService(t, &channelFakeExecutor{fn: func(ctx context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
if err := ctrl.SubmitEnvelope(testEnvelope(req.RequestID, 1, SingleRequestStatePlanning)); err != nil {
return err
}
<-ctx.Done()
return ctx.Err()
}})
service.SetSingleRequestObserver(observer)
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, func(binding *SingleRequestBinding) {
binding.Limits.WallClockMS = 250
binding.Limits.StageTimeoutMS = 50
}))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
if _, err := waitForExecution(t, handle); !errors.Is(err, ErrSingleRequestInternalToolBudget) {
t.Fatalf("Wait error=%v, want internal tool budget sentinel", err)
}
var stage singleRequestDTO
for _, event := range observer.snapshot() {
if event.EventClass == singleRequestEventClassStage {
stage = event
}
}
if stage.ErrorClass != singleRequestErrorClassTimeout {
t.Fatalf("stage error class=%q, want timeout", stage.ErrorClass)
}
if terminal := terminalEvent(t, observer); terminal.ErrorClass != singleRequestErrorClassTimeout {
t.Fatalf("terminal error class=%q, want timeout", terminal.ErrorClass)
}
})
t.Run("in-flight tool deadline wins before cleanup failure", func(t *testing.T) {
observer := &capturingObserver{}
executor := newScriptedInternalToolExecutor(InternalWorkspaceToolCall{
ToolCallID: "deadline-tool", Name: InternalWorkspaceToolRead,
Arguments: json.RawMessage(`{"relative_path":"README.md"}`),
})
service, node := newInternalToolLoopService(t, executor)
service.SetSingleRequestObserver(observer)
var openCount atomic.Int32
toki.AddRequestListenerTyped[*iop.WorkspaceOpenRequest, *iop.WorkspaceOpenResponse](&node.Communicator, func(req *iop.WorkspaceOpenRequest) (*iop.WorkspaceOpenResponse, error) {
openCount.Add(1)
return &iop.WorkspaceOpenResponse{RequestId: req.GetRequestId(), WorkspaceRef: req.GetWorkspaceRef(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
})
toolStarted := make(chan struct{})
toolRelease := make(chan struct{})
var toolStartOnce sync.Once
toki.AddRequestListenerTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&node.Communicator, func(req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
toolStartOnce.Do(func() { close(toolStarted) })
<-toolRelease
return &iop.WorkspaceToolResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED}, nil
})
toki.AddRequestListenerTyped[*iop.WorkspaceCleanupRequest, *iop.WorkspaceCleanupResponse](&node.Communicator, func(req *iop.WorkspaceCleanupRequest) (*iop.WorkspaceCleanupResponse, error) {
return &iop.WorkspaceCleanupResponse{RequestId: req.GetRequestId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR}, nil
})
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, func(binding *SingleRequestBinding) {
binding.Limits.WallClockMS = 250
binding.Limits.StageTimeoutMS = 50
}))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
select {
case <-toolStarted:
case <-time.After(2 * time.Second):
t.Fatal("tool did not reach Node")
}
waitForState(t, handle, SingleRequestStateFailed)
close(toolRelease)
if _, err := waitForExecution(t, handle); !errors.Is(err, ErrSingleRequestInternalToolBudget) || !errors.Is(err, ErrSingleRequestWorkspaceCleanup) {
t.Fatalf("Wait error=%v, want deadline sentinel joined with cleanup failure", err)
}
events := observer.snapshot()
if len(events) != 5 || events[1].EventClass != singleRequestEventClassTool || events[1].ErrorClass != singleRequestErrorClassTimeout ||
events[2].EventClass != singleRequestEventClassStage || events[2].ErrorClass != singleRequestErrorClassTimeout || events[2].ToolCount != 1 ||
events[3].EventClass != singleRequestEventClassCleanup || events[3].ErrorClass != singleRequestErrorClassWorkspaceCleanup {
t.Fatalf("tool deadline lifecycle=%#v", events)
}
if terminal := terminalEvent(t, observer); terminal.ErrorClass != singleRequestErrorClassTimeout {
t.Fatalf("terminal error class=%q, want timeout", terminal.ErrorClass)
}
if openCount.Load() != 1 {
t.Fatalf("workspace open count=%d, want 1", openCount.Load())
}
})
t.Run("iteration exhaustion remains an internal tool budget", func(t *testing.T) {
observer := &capturingObserver{}
executor := newScriptedInternalToolExecutor(
InternalWorkspaceToolCall{ToolCallID: "budget-tool-1", Name: InternalWorkspaceToolRead, Arguments: json.RawMessage(`{"relative_path":"README.md"}`)},
InternalWorkspaceToolCall{ToolCallID: "budget-tool-2", Name: InternalWorkspaceToolRead, Arguments: json.RawMessage(`{"relative_path":"README.md"}`)},
)
service, node := newInternalToolLoopService(t, executor)
service.SetSingleRequestObserver(observer)
var openCount atomic.Int32
installInternalLoopOpenResponder(node, &openCount)
toki.AddRequestListenerTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&node.Communicator, func(req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
return &iop.WorkspaceToolResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
})
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, func(binding *SingleRequestBinding) {
binding.Limits.MaxToolIterations = 1
}))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
if _, err := waitForExecution(t, handle); !errors.Is(err, ErrSingleRequestInternalToolBudget) {
t.Fatalf("Wait error=%v, want internal tool budget", err)
}
if terminal := terminalEvent(t, observer); terminal.ErrorClass != singleRequestErrorClassInternalToolBudget {
t.Fatalf("terminal error class=%q, want internal_tool_budget", terminal.ErrorClass)
}
if openCount.Load() != 1 {
t.Fatalf("workspace open count=%d, want 1", openCount.Load())
}
})
t.Run("expired stage deadline at tool admission is observed as timeout", func(t *testing.T) {
observer := &capturingObserver{}
executor := &admissionRaceExecutor{
planningStarted: make(chan struct{}),
submitTool: make(chan struct{}),
}
service, _ := newInternalToolLoopService(t, executor)
service.SetSingleRequestObserver(observer)
handle, err := service.StartSingleRequest(context.Background(), internalLoopRequest(t, func(binding *SingleRequestBinding) {
binding.Limits.WallClockMS = 250
binding.Limits.StageTimeoutMS = 30
}))
if err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
select {
case <-executor.planningStarted:
case <-time.After(2 * time.Second):
t.Fatal("planning state did not start")
}
h := handle.(*singleRequestHandle)
h.mu.Lock()
close(executor.submitTool)
time.Sleep(50 * time.Millisecond)
h.mu.Unlock()
if _, err := waitForExecution(t, handle); !errors.Is(err, ErrSingleRequestInternalToolBudget) {
t.Fatalf("Wait error=%v, want internal tool budget sentinel", err)
}
if terminal := terminalEvent(t, observer); terminal.ErrorClass != singleRequestErrorClassTimeout {
t.Fatalf("terminal error class=%q, want timeout", terminal.ErrorClass)
}
})
}
func TestSingleRequestObservationErrorClassMapping(t *testing.T) {
for _, test := range []struct {
name string
err error
want singleRequestErrorClass
}{
{name: "provider", err: errors.New("provider failure"), want: singleRequestErrorClassProvider},
{name: "validation", err: fmt.Errorf("wrapped: %w", ErrSingleRequestIdentityMismatch), want: singleRequestErrorClassValidation},
{name: "budget", err: fmt.Errorf("wrapped: %w", ErrSingleRequestInternalToolBudget), want: singleRequestErrorClassInternalToolBudget},
{name: "tool", err: fmt.Errorf("wrapped: %w", ErrSingleRequestInternalToolFailed), want: singleRequestErrorClassInternalToolFailed},
{name: "cleanup", err: fmt.Errorf("wrapped: %w", ErrSingleRequestWorkspaceCleanup), want: singleRequestErrorClassWorkspaceCleanup},
{name: "timeout", err: context.DeadlineExceeded, want: singleRequestErrorClassTimeout},
{name: "cancel", err: context.Canceled, want: singleRequestErrorClassCancel},
} {
t.Run(test.name, func(t *testing.T) {
if got := singleRequestErrorClassFromErr(test.err); got != test.want {
t.Fatalf("error class=%q, want %q", got, test.want)
}
})
}
}