From e5c6c06faee50c507c11b1ced5df60656a9b223c Mon Sep 17 00:00:00 2001 From: setkyar Date: Tue, 15 Sep 2026 00:58:26 +0800 Subject: [PATCH 1/2] fix(server): apply a schedule's configured model to its run pi restores a session's model and thinking level from session history only when the session already has at least one message. A freshly created schedule session has none, so the implicit entries written by CreateSessionFileWithSettings are ignored and pi falls back to its global default model. A schedule configured for one model therefore silently ran on another (and failed, for example, when the default model's auth had expired). Apply the schedule's explicit model and thinking level to the worker after it is ensured, before the instructions are sent. When the schedule leaves them unset, keep pi's defaults. --- internal/server/scheduler.go | 18 +++++++ internal/server/scheduler_test.go | 78 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/internal/server/scheduler.go b/internal/server/scheduler.go index 43fb22ca..3cf3aa0c 100644 --- a/internal/server/scheduler.go +++ b/internal/server/scheduler.go @@ -172,6 +172,24 @@ func (s *Server) fireScheduleContext(ctx context.Context, sc schedules.Schedule) _ = s.schedules.FailRun(runID, err.Error()) return sessionID, fmt.Errorf("ensure worker: %w", err) } + // pi restores a session's model and thinking level from its history only + // once the session has at least one message. A freshly created schedule + // session has none, so the implicit settings written by + // CreateSessionFileWithSettings are ignored and pi would run with its global + // default model. Apply the schedule's explicit settings to the worker here + // so the run uses the model the user configured. + if sc.ModelProvider != "" && sc.ModelID != "" { + if err := s.chatSender.SetModel(ctx, sessionID, resolved.Path, sc.ModelProvider, sc.ModelID); err != nil { + _ = s.schedules.FailRun(runID, err.Error()) + return sessionID, fmt.Errorf("set model: %w", err) + } + } + if sc.ThinkingLevel != "" { + if err := s.chatSender.SetThinkingLevel(ctx, sessionID, resolved.Path, sc.ThinkingLevel); err != nil { + _ = s.schedules.FailRun(runID, err.Error()) + return sessionID, fmt.Errorf("set thinking level: %w", err) + } + } if err := s.chatSender.Send(ctx, sessionID, resolved.Path, chat.Request{Message: sc.Instructions}); err != nil { _ = s.schedules.FailRun(runID, err.Error()) return sessionID, fmt.Errorf("send: %w", err) diff --git a/internal/server/scheduler_test.go b/internal/server/scheduler_test.go index d0567185..79b4144e 100644 --- a/internal/server/scheduler_test.go +++ b/internal/server/scheduler_test.go @@ -89,6 +89,84 @@ func TestFireScheduleCreatesSessionAndSends(t *testing.T) { } } +// A scheduled session has no messages yet, so pi cannot restore the model from +// its history. The runner must apply the schedule's configured model and +// thinking level to the worker explicitly. +func TestFireScheduleAppliesConfiguredModel(t *testing.T) { + s, sender := newScheduleTestServer(t) + + sc, err := s.schedules.Create(schedules.Schedule{ + ID: "sched-model", + Name: "Model run", + Instructions: "go", + ModelProvider: "opencode-go", + ModelID: "deepseek-v4.1-flash", + ThinkingLevel: "high", + ProjectPath: t.TempDir(), + Enabled: true, + }) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + + sessionID, err := s.fireSchedule(sc) + if err != nil { + t.Fatalf("fireSchedule: %v", err) + } + + sender.mu.Lock() + provider, modelID := sender.setModelProvider, sender.setModelID + modelSession := sender.setModelSessionID + thinking, thinkingSession := sender.setThinkingLevel, sender.setThinkingSessionID + sender.mu.Unlock() + + if provider != "opencode-go" || modelID != "deepseek-v4.1-flash" { + t.Errorf("SetModel = %s/%s, want opencode-go/deepseek-v4.1-flash", provider, modelID) + } + if modelSession != sessionID { + t.Errorf("SetModel session = %q, want %q", modelSession, sessionID) + } + if thinking != "high" { + t.Errorf("SetThinkingLevel = %q, want high", thinking) + } + if thinkingSession != sessionID { + t.Errorf("SetThinkingLevel session = %q, want %q", thinkingSession, sessionID) + } +} + +// Without explicit settings the schedule must keep pi's defaults, so the runner +// must not call SetModel/SetThinkingLevel. +func TestFireScheduleKeepsDefaultsWhenUnset(t *testing.T) { + s, sender := newScheduleTestServer(t) + + sc, err := s.schedules.Create(schedules.Schedule{ + ID: "sched-default", + Name: "Default run", + Instructions: "go", + ProjectPath: t.TempDir(), + Enabled: true, + }) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + + if _, err := s.fireSchedule(sc); err != nil { + t.Fatalf("fireSchedule: %v", err) + } + + sender.mu.Lock() + provider, modelID := sender.setModelProvider, sender.setModelID + thinking := sender.setThinkingLevel + sender.mu.Unlock() + + if provider != "" || modelID != "" { + t.Errorf("SetModel called with %s/%s, want no call", provider, modelID) + } + if thinking != "" { + t.Errorf("SetThinkingLevel called with %q, want no call", thinking) + } +} + func TestEvaluateSchedulesSkipsMissedRuns(t *testing.T) { s, sender := newScheduleTestServer(t) // A daily 09:00 schedule; "now" is 08:00. First evaluation must only arm the From 7f69ac41e3a4871a93506b639ba901271a3999a2 Mon Sep 17 00:00:00 2001 From: setkyar Date: Thu, 17 Sep 2026 01:16:54 +0700 Subject: [PATCH 2/2] docs(schedules): document fire-path model RPC and trim comments --- docs/sequence-flows/schedules.md | 4 +++- internal/server/scheduler.go | 7 +------ internal/server/scheduler_test.go | 5 ----- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/docs/sequence-flows/schedules.md b/docs/sequence-flows/schedules.md index af8eca70..d675a0d1 100644 --- a/docs/sequence-flows/schedules.md +++ b/docs/sequence-flows/schedules.md @@ -56,13 +56,15 @@ the chat workers and SSE broadcast. │── RecordRun(running) ────────────▶│ │ │ │── SetLastRun ────────────────────▶│ │ │ │── CreateSessionFileWithSettings ───────────────────▶│ │ - │ (project dir or home; model/thinking as implicit entries) │ + │ (project dir or home; implicit model/thinking │ │ + │ entries are not restored on an empty session) │ │ │◀── filename ──────────────────────────────────────│ │ │── ResolveByID ────────────────────────────────────▶│ │ │◀── session UUID + path ───────────│ │ │ │── AttachSession(runID, uuid) ────▶│ │ │ │ │ │ │ │ │── EnsureWorker(uuid, path) ───────────────────────▶│ │ + │── SetModel / SetThinkingLevel (when configured) ──▶│ │ │── Send(uuid, path, {instructions}) ───────────────▶│─── pi runs ──▶│ │ │ │ │ │ │ (file watcher sees the new .jsonl → broadcasts `new-session`) │ diff --git a/internal/server/scheduler.go b/internal/server/scheduler.go index 3cf3aa0c..84036bfe 100644 --- a/internal/server/scheduler.go +++ b/internal/server/scheduler.go @@ -172,12 +172,7 @@ func (s *Server) fireScheduleContext(ctx context.Context, sc schedules.Schedule) _ = s.schedules.FailRun(runID, err.Error()) return sessionID, fmt.Errorf("ensure worker: %w", err) } - // pi restores a session's model and thinking level from its history only - // once the session has at least one message. A freshly created schedule - // session has none, so the implicit settings written by - // CreateSessionFileWithSettings are ignored and pi would run with its global - // default model. Apply the schedule's explicit settings to the worker here - // so the run uses the model the user configured. + // Empty sessions skip history restore, so implicit file entries are ignored. if sc.ModelProvider != "" && sc.ModelID != "" { if err := s.chatSender.SetModel(ctx, sessionID, resolved.Path, sc.ModelProvider, sc.ModelID); err != nil { _ = s.schedules.FailRun(runID, err.Error()) diff --git a/internal/server/scheduler_test.go b/internal/server/scheduler_test.go index 79b4144e..1b8306fb 100644 --- a/internal/server/scheduler_test.go +++ b/internal/server/scheduler_test.go @@ -89,9 +89,6 @@ func TestFireScheduleCreatesSessionAndSends(t *testing.T) { } } -// A scheduled session has no messages yet, so pi cannot restore the model from -// its history. The runner must apply the schedule's configured model and -// thinking level to the worker explicitly. func TestFireScheduleAppliesConfiguredModel(t *testing.T) { s, sender := newScheduleTestServer(t) @@ -134,8 +131,6 @@ func TestFireScheduleAppliesConfiguredModel(t *testing.T) { } } -// Without explicit settings the schedule must keep pi's defaults, so the runner -// must not call SetModel/SetThinkingLevel. func TestFireScheduleKeepsDefaultsWhenUnset(t *testing.T) { s, sender := newScheduleTestServer(t)