package agent import ( "context" "fmt" "strings" "sync" "github.com/bnema/zut/packages/agent/extensions" "github.com/bnema/zut/packages/agent/scheduler" "github.com/bnema/zut/packages/core" "github.com/bnema/zut/packages/provider" ) // scheduledSessionRegistry keeps background runs from opening foreground // panels and waiting indefinitely for input from a hidden conversation. func scheduledSessionRegistry(registry core.Registry) core.Registry { for name, tool := range registry { if extensions.IsInteractiveTool(tool) { delete(registry, name) } } return registry } // runScheduledSession reconstructs an inactive session in the current zut // process and appends the resulting turn to its existing transcript. The // caller serializes this with session transitions before invoking it. func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, base Resolved, extMgr *extensions.Manager, prepareRegistry func(core.Registry) core.Registry) error { path, err := core.FindManagedSessionByID(ctx, ZutHome(), task.SessionID) if err != nil { return fmt.Errorf("find scheduled session %q: %w", task.SessionID, err) } if path == "" { return fmt.Errorf("scheduled session %q no longer exists", task.SessionID) } sess, messages, err := core.OpenSession(path) if err != nil { return fmt.Errorf("open scheduled session: %w", err) } if sess.ID != task.SessionID { closeErr := sess.Close() if closeErr != nil { return fmt.Errorf("scheduled session %q changed during resume: %w", task.SessionID, closeErr) } return fmt.Errorf("scheduled session %q changed during resume", task.SessionID) } defer sess.Close() providerName := strings.TrimSpace(sess.Meta.Provider) if providerName == "" { providerName = base.Provider } model := strings.TrimSpace(sess.Meta.Model) if model == "" { model = base.Model } // The shared manager may be attached to the foreground TUI, but this // reconstructed session has no visible user to answer interactive tools. backgroundRegistry := func(registry core.Registry) core.Registry { if prepareRegistry != nil { registry = prepareRegistry(registry) } return scheduledSessionRegistry(registry) } ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, backgroundRegistry) if err != nil { return fmt.Errorf("build scheduled session agent: %w", err) } ag.SetMessages(trimMessagesForResume(messages, 100)) // Seed the agent-owned plan from the reconstructed session so a scheduled // turn sees the persisted checklist instead of empty core state. ag.SetPlan(sess.Meta.PlanSteps()) // Route tool-result commits through the session so plan (and goal) // mutations the scheduled turn makes are durable, matching the other modes. ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(nil, sess, result) } if cumulative, lastTurn, usageErr := core.SessionUsageDetail(path); usageErr == nil { ag.SeedCost(cumulative) ag.SeedLastTurnUsage(lastTurn) } ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset) var appendMu sync.Mutex var appendErr error recordAppendError := func(err error) { if err == nil { return } appendMu.Lock() defer appendMu.Unlock() if appendErr == nil { appendErr = err } } ag.DisableRepetitionGuard = true ag.OnMessageAppended = func(message provider.Message) { recordAppendError(sess.AppendMessage(message)) } ag.OnUsage = func(cumulative provider.Usage) { recordAppendError(sess.AppendUsage(cumulative, cumulative)) } ag.OnTranscriptCompacted = func(messages []provider.Message) { recordAppendError(sess.AppendCompaction(messages)) } if err := ag.Prompt(ctx, task.Message, nil, func(core.AgentEvent) {}); err != nil { return err } appendMu.Lock() err = appendErr appendMu.Unlock() if err != nil { return fmt.Errorf("append scheduled transcript: %w", err) } if err := sess.Flush(); err != nil { return fmt.Errorf("flush scheduled session: %w", err) } return nil }