summaryrefslogtreecommitdiff
path: root/internal/scheduler/scheduler_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/scheduler/scheduler_test.go')
-rw-r--r--internal/scheduler/scheduler_test.go253
1 files changed, 253 insertions, 0 deletions
diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go
index 18c9e42..258bb5d 100644
--- a/internal/scheduler/scheduler_test.go
+++ b/internal/scheduler/scheduler_test.go
@@ -25,6 +25,11 @@ type fakeStore struct {
agentUpdates []task.AgentConfig
stateUpdates []task.State
events []*event.Event
+
+ // ask-user-timeout fakes (Phase 7c).
+ questionUpdates []string
+ interactions []task.Interaction
+ needsReviewUpdates []bool
}
func newFakeStore() *fakeStore {
@@ -92,6 +97,36 @@ func (f *fakeStore) CreateEvent(e *event.Event) error {
return nil
}
+func (f *fakeStore) UpdateTaskQuestion(taskID, questionJSON string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.questionUpdates = append(f.questionUpdates, questionJSON)
+ if t, ok := f.tasks[taskID]; ok {
+ t.QuestionJSON = questionJSON
+ }
+ return nil
+}
+
+func (f *fakeStore) AppendTaskInteraction(taskID string, interaction task.Interaction) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.interactions = append(f.interactions, interaction)
+ if t, ok := f.tasks[taskID]; ok {
+ t.Interactions = append(t.Interactions, interaction)
+ }
+ return nil
+}
+
+func (f *fakeStore) UpdateTaskNeedsReview(id string, needsReview bool) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.needsReviewUpdates = append(f.needsReviewUpdates, needsReview)
+ if t, ok := f.tasks[id]; ok {
+ t.NeedsReview = needsReview
+ }
+ return nil
+}
+
func (f *fakeStore) eventsOfKind(k event.Kind) []*event.Event {
f.mu.Lock()
defer f.mu.Unlock()
@@ -109,6 +144,17 @@ type fakePool struct {
mu sync.Mutex
submitted []*task.Task
err error
+
+ // resumed records every SubmitResume call (Phase 7c's ask-user-timeout
+ // path); resumeErr lets tests exercise the error-logging path.
+ resumed []resumeCall
+ resumeErr error
+}
+
+// resumeCall captures one SubmitResume invocation's arguments.
+type resumeCall struct {
+ task *task.Task
+ exec *storage.Execution
}
func (f *fakePool) Submit(_ context.Context, t *task.Task) error {
@@ -118,6 +164,13 @@ func (f *fakePool) Submit(_ context.Context, t *task.Task) error {
return f.err
}
+func (f *fakePool) SubmitResume(_ context.Context, t *task.Task, exec *storage.Execution) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.resumed = append(f.resumed, resumeCall{task: t, exec: exec})
+ return f.resumeErr
+}
+
func (f *fakePool) submitCount() int {
f.mu.Lock()
defer f.mu.Unlock()
@@ -178,6 +231,30 @@ func failedExec(rung int) *storage.Execution {
}
}
+// blockedTask builds a role-typed task BLOCKED on an ask_user question,
+// with UpdatedAt standing in for "outstanding since" (see
+// tickAskUserTimeouts' doc comment on why task.UpdatedAt, not a new column).
+func blockedTask(id, roleName, agentType, questionJSON string, updatedAt time.Time) *task.Task {
+ return &task.Task{
+ ID: id,
+ Name: "test",
+ Agent: task.AgentConfig{Type: agentType, Role: roleName, MaxBudgetUSD: 0.5},
+ State: task.StateBlocked,
+ QuestionJSON: questionJSON,
+ UpdatedAt: updatedAt,
+ }
+}
+
+func blockedExec(rung int, sessionID string) *storage.Execution {
+ return &storage.Execution{
+ ID: uuid.NewString(),
+ Status: "BLOCKED",
+ EscalationRung: rung,
+ SessionID: sessionID,
+ StartTime: time.Now(),
+ }
+}
+
// TestScheduler_RetriesSameRung_WhileUnderMaxRetries proves that a task
// whose current rung has fewer attempts than tier.MaxRetries is resubmitted
// at the same rung, with no escalation event and no Agent.Type/Model change.
@@ -367,3 +444,179 @@ func TestScheduler_Convergence_DoesNotReprocessSameExecution(t *testing.T) {
t.Fatalf("expected 0 submissions, got %d", pool.submitCount())
}
}
+
+// TestScheduler_AskUserTimeout_EscalatesAndResumes proves the core
+// ask-user-timeout flow: a BLOCKED role-typed task whose question has been
+// outstanding longer than the configured timeout gets resumed at the next
+// escalation tier with a system-authored answer, needs_review gets set, and
+// a KindEscalated event with the ask-user-timeout trigger is emitted.
+func TestScheduler_AskUserTimeout_EscalatesAndResumes(t *testing.T) {
+ store := newFakeStore()
+ seedRoleConfig(t, store, twoTierLadder())
+
+ askedAt := time.Now().Add(-20 * time.Minute)
+ tk := blockedTask("t1", "coder", "local", `{"text":"Which approach?","options":["a","b"]}`, askedAt)
+ store.tasks[tk.ID] = tk
+ store.execsByTask[tk.ID] = []*storage.Execution{blockedExec(0, "sess-1")}
+
+ pool := &fakePool{}
+ sch := &Scheduler{Store: store, Pool: pool, AskUserTimeout: 10 * time.Minute}
+ sch.Tick(context.Background())
+
+ if len(pool.resumed) != 1 {
+ t.Fatalf("expected 1 resume submission, got %d", len(pool.resumed))
+ }
+ rc := pool.resumed[0]
+ if rc.task.Agent.Type != "anthropic" || rc.task.Agent.Model != "claude-sonnet-5" {
+ t.Errorf("resumed task should carry the escalated tier's provider/model: got %q/%q", rc.task.Agent.Type, rc.task.Agent.Model)
+ }
+ if rc.task.State != task.StateBlocked {
+ t.Errorf("resumed task struct should retain BLOCKED state (matches api.answerTaskQuestion's convention for SubmitResume's resumable-state check), got %v", rc.task.State)
+ }
+ if rc.exec.ResumeSessionID != "sess-1" {
+ t.Errorf("resume exec ResumeSessionID: got %q want sess-1", rc.exec.ResumeSessionID)
+ }
+ if rc.exec.ResumeAnswer != fallbackAnswer {
+ t.Errorf("resume exec ResumeAnswer: got %q want the fallback answer marker", rc.exec.ResumeAnswer)
+ }
+
+ if len(store.needsReviewUpdates) != 1 || !store.needsReviewUpdates[0] {
+ t.Errorf("expected needs_review to be set true exactly once, got %+v", store.needsReviewUpdates)
+ }
+ if len(store.interactions) != 1 || store.interactions[0].Answer != fallbackAnswer || store.interactions[0].QuestionText != "Which approach?" {
+ t.Errorf("expected the fallback answer recorded as an interaction, got %+v", store.interactions)
+ }
+ if len(store.questionUpdates) != 1 || store.questionUpdates[0] != "" {
+ t.Errorf("expected the question to be cleared, got %+v", store.questionUpdates)
+ }
+ if len(store.agentUpdates) != 1 {
+ t.Fatalf("expected 1 UpdateTaskAgent call, got %d", len(store.agentUpdates))
+ }
+
+ evs := store.eventsOfKind(event.KindEscalated)
+ if len(evs) != 1 {
+ t.Fatalf("expected 1 KindEscalated event, got %d", len(evs))
+ }
+ var payload struct {
+ FromRung int `json:"from_rung"`
+ ToRung int `json:"to_rung"`
+ FromProvider string `json:"from_provider"`
+ ToProvider string `json:"to_provider"`
+ Final bool `json:"final"`
+ Trigger string `json:"trigger"`
+ }
+ if err := json.Unmarshal(evs[0].Payload, &payload); err != nil {
+ t.Fatalf("unmarshal event payload: %v", err)
+ }
+ if payload.Trigger != "ask_user_timeout" {
+ t.Errorf("expected trigger=ask_user_timeout, got %q", payload.Trigger)
+ }
+ if payload.Final {
+ t.Errorf("escalating to a higher tier should not be final")
+ }
+ if payload.FromRung != 0 || payload.ToRung != 1 || payload.FromProvider != "local" || payload.ToProvider != "anthropic" {
+ t.Errorf("expected escalation local(rung0) -> anthropic(rung1), got %+v", payload)
+ }
+}
+
+// TestScheduler_AskUserTimeout_WithinWindow_LeftAlone proves that a BLOCKED
+// task still within the configured timeout window is left completely alone:
+// no resume, no needs_review, no event.
+func TestScheduler_AskUserTimeout_WithinWindow_LeftAlone(t *testing.T) {
+ store := newFakeStore()
+ seedRoleConfig(t, store, twoTierLadder())
+
+ tk := blockedTask("t1", "coder", "local", `{"text":"Which approach?"}`, time.Now())
+ store.tasks[tk.ID] = tk
+ store.execsByTask[tk.ID] = []*storage.Execution{blockedExec(0, "sess-1")}
+
+ pool := &fakePool{}
+ sch := &Scheduler{Store: store, Pool: pool, AskUserTimeout: 10 * time.Minute}
+ sch.Tick(context.Background())
+
+ if len(pool.resumed) != 0 {
+ t.Fatalf("task within the timeout window should not be resumed, got %d resumes", len(pool.resumed))
+ }
+ if len(store.needsReviewUpdates) != 0 {
+ t.Errorf("task within the timeout window should not be flagged needs_review, got %+v", store.needsReviewUpdates)
+ }
+ if len(store.eventsOfKind(event.KindEscalated)) != 0 {
+ t.Errorf("task within the timeout window should not emit a KindEscalated event")
+ }
+ if len(store.questionUpdates) != 0 {
+ t.Errorf("task within the timeout window should not have its question cleared")
+ }
+}
+
+// TestScheduler_AskUserTimeout_IgnoresSubtaskBlocked proves that a BLOCKED
+// task with no pending question (i.e. blocked waiting on subtasks, per
+// task.go's state machine, not on ask_user) is never touched by
+// tickAskUserTimeouts, however long it's been BLOCKED.
+func TestScheduler_AskUserTimeout_IgnoresSubtaskBlocked(t *testing.T) {
+ store := newFakeStore()
+ seedRoleConfig(t, store, twoTierLadder())
+
+ tk := blockedTask("t1", "coder", "local", "", time.Now().Add(-1*time.Hour))
+ store.tasks[tk.ID] = tk
+ store.execsByTask[tk.ID] = []*storage.Execution{blockedExec(0, "sess-1")}
+
+ pool := &fakePool{}
+ sch := &Scheduler{Store: store, Pool: pool, AskUserTimeout: 10 * time.Minute}
+ sch.Tick(context.Background())
+
+ if len(pool.resumed) != 0 {
+ t.Fatalf("subtask-blocked task should not be resumed, got %d resumes", len(pool.resumed))
+ }
+}
+
+// TestScheduler_AskUserTimeout_LadderExhausted_StillResumesAtSameTier proves
+// the documented fallback for when no higher tier exists: the task is still
+// unblocked (resumed) so it doesn't stay stuck forever, but at its current
+// tier's provider/model (no UpdateTaskAgent call), and the escalation event
+// is marked final:true to distinguish "resumed without escalating" from a
+// genuine tier bump.
+func TestScheduler_AskUserTimeout_LadderExhausted_StillResumesAtSameTier(t *testing.T) {
+ store := newFakeStore()
+ seedRoleConfig(t, store, twoTierLadder())
+
+ askedAt := time.Now().Add(-20 * time.Minute)
+ tk := blockedTask("t1", "coder", "anthropic", `{"text":"Which approach?"}`, askedAt)
+ store.tasks[tk.ID] = tk
+ store.execsByTask[tk.ID] = []*storage.Execution{blockedExec(1, "sess-1")} // already at the last tier
+
+ pool := &fakePool{}
+ sch := &Scheduler{Store: store, Pool: pool, AskUserTimeout: 10 * time.Minute}
+ sch.Tick(context.Background())
+
+ if len(pool.resumed) != 1 {
+ t.Fatalf("expected the task to still be resumed even with no higher tier, got %d resumes", len(pool.resumed))
+ }
+ if len(store.agentUpdates) != 0 {
+ t.Errorf("no higher tier exists, so Agent should be unchanged: got %d UpdateTaskAgent calls", len(store.agentUpdates))
+ }
+ rc := pool.resumed[0]
+ if rc.task.Agent.Type != "anthropic" {
+ t.Errorf("resumed task should keep its current provider, got %q", rc.task.Agent.Type)
+ }
+
+ evs := store.eventsOfKind(event.KindEscalated)
+ if len(evs) != 1 {
+ t.Fatalf("expected 1 KindEscalated event, got %d", len(evs))
+ }
+ var payload struct {
+ Final bool `json:"final"`
+ Trigger string `json:"trigger"`
+ }
+ if err := json.Unmarshal(evs[0].Payload, &payload); err != nil {
+ t.Fatalf("unmarshal event payload: %v", err)
+ }
+ if !payload.Final {
+ t.Errorf("resuming without an escalation should be marked final=true")
+ }
+ if payload.Trigger != "ask_user_timeout" {
+ t.Errorf("expected trigger=ask_user_timeout, got %q", payload.Trigger)
+ }
+ if len(store.needsReviewUpdates) != 1 || !store.needsReviewUpdates[0] {
+ t.Errorf("expected needs_review to still be set even without an escalation, got %+v", store.needsReviewUpdates)
+ }
+}