feat(agent): add in-process cron scheduler
- commit
- 8d00c1be3f372d9542c0fa1b0546501e1b833a43
- author
- brice · · 2026-08-15 14:42 UTC
- parent
- d7aa01a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +1032 -10
M README.md +2 -1
| @@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 9 | 9 | ||
| 10 | 10 | - one static binary. | |
| 11 | 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | |
| 12 | - | - nine core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. | |
| 12 | + | - ten core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, update_goal, and schedule); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. | |
| 13 | 13 | - three run modes (interactive tui, print, json). | |
| 14 | 14 | - built-in telegram bot. | |
| 15 | 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | |
| @@ -255,6 +255,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 255 | 255 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | |
| 256 | 256 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | |
| 257 | 257 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | |
| 258 | + | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. | |
| 258 | 259 | ||
| 259 | 260 | When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | |
| 260 | 261 | ||
| @@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 9 | 9 | ||||
| 10 | - one static binary. | 10 | - one static binary. | ||
| 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | ||
| 12 | - | - nine core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. | 12 | + | - ten core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, update_goal, and schedule); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. |
| 13 | - three run modes (interactive tui, print, json). | 13 | - three run modes (interactive tui, print, json). | ||
| 14 | - built-in telegram bot. | 14 | - built-in telegram bot. | ||
| 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | ||
| @@ -255,6 +255,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 255 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | 255 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | ||
| 256 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | 256 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | ||
| 257 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | 257 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | ||
| 258 | + | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. | |||
| 258 | 259 | ||||
| 259 | When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | 260 | When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | ||
| 260 | 261 | ||||
M packages/agent/cli.go +73 -5
| @@ -19,6 +19,7 @@ import ( | |||
| 19 | 19 | "github.com/bnema/zut/packages/agent/extensions" | |
| 20 | 20 | "github.com/bnema/zut/packages/agent/extproto" | |
| 21 | 21 | "github.com/bnema/zut/packages/agent/modes" | |
| 22 | + | "github.com/bnema/zut/packages/agent/scheduler" | |
| 22 | 23 | "github.com/bnema/zut/packages/agent/skills" | |
| 23 | 24 | "github.com/bnema/zut/packages/agent/subagents" | |
| 24 | 25 | "github.com/bnema/zut/packages/agent/tools" | |
| @@ -1484,6 +1485,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1484 | 1485 | var sessionTransitionMu sync.RWMutex | |
| 1485 | 1486 | activeProvider := r.Provider | |
| 1486 | 1487 | activeModel := r.Model | |
| 1488 | + | var sess *core.Session | |
| 1489 | + | scheduleEngine := scheduler.NewEngine(nil) | |
| 1487 | 1490 | ||
| 1488 | 1491 | // Build the extension manager BEFORE the agent so we can fold | |
| 1489 | 1492 | // extension-defined tools into the registry. Attach the interactive | |
| @@ -1665,6 +1668,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1665 | 1668 | return a | |
| 1666 | 1669 | } | |
| 1667 | 1670 | ||
| 1671 | + | scheduleTool := &tools.ScheduleTool{ | |
| 1672 | + | Engine: scheduleEngine, | |
| 1673 | + | SessionID: func() string { | |
| 1674 | + | persistMu.Lock() | |
| 1675 | + | defer persistMu.Unlock() | |
| 1676 | + | if sess == nil { | |
| 1677 | + | return "" | |
| 1678 | + | } | |
| 1679 | + | return sess.ID | |
| 1680 | + | }, | |
| 1681 | + | Location: func() *time.Location { return time.Local }, | |
| 1682 | + | } | |
| 1683 | + | schedulerAllowed := !args.NoTools && (!args.ToolsSet && len(args.Tools) == 0 || toolListContains(args.Tools, tools.ScheduleToolName)) | |
| 1684 | + | installSchedulerTool := func(registry core.Registry) core.Registry { | |
| 1685 | + | if schedulerAllowed { | |
| 1686 | + | registry[tools.ScheduleToolName] = scheduleTool | |
| 1687 | + | } | |
| 1688 | + | return registry | |
| 1689 | + | } | |
| 1690 | + | installSchedulerTool(r.ToolRegistry) | |
| 1691 | + | prepareInteractiveRegistryWithScheduler := func(registry core.Registry) core.Registry { | |
| 1692 | + | return prepareInteractiveRegistry(installSchedulerTool(registry)) | |
| 1693 | + | } | |
| 1694 | + | ||
| 1668 | 1695 | var ag *core.Agent | |
| 1669 | 1696 | buildAgent := func() (*core.Agent, string, string, error) { | |
| 1670 | 1697 | resolved, err := Resolve(args, true) | |
| @@ -1676,6 +1703,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1676 | 1703 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1677 | 1704 | runtime.SetFastMode(resolved.FastMode) | |
| 1678 | 1705 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1706 | + | installSchedulerTool(resolved.ToolRegistry) | |
| 1679 | 1707 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | |
| 1680 | 1708 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 1681 | 1709 | } | |
| @@ -1698,6 +1726,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1698 | 1726 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1699 | 1727 | runtime.SetFastMode(resolved.FastMode) | |
| 1700 | 1728 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1729 | + | installSchedulerTool(resolved.ToolRegistry) | |
| 1701 | 1730 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | |
| 1702 | 1731 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 1703 | 1732 | } | |
| @@ -1728,6 +1757,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1728 | 1757 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1729 | 1758 | runtime.SetFastMode(resolved.FastMode) | |
| 1730 | 1759 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1760 | + | installSchedulerTool(resolved.ToolRegistry) | |
| 1731 | 1761 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | |
| 1732 | 1762 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 1733 | 1763 | } | |
| @@ -1747,12 +1777,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1747 | 1777 | if current == nil { | |
| 1748 | 1778 | return | |
| 1749 | 1779 | } | |
| 1750 | - | if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil { | |
| 1780 | + | if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil && iv != nil { | |
| 1751 | 1781 | iv.ReportError(err) | |
| 1752 | 1782 | } | |
| 1753 | 1783 | }) | |
| 1754 | 1784 | ||
| 1755 | - | var sess *core.Session | |
| 1756 | 1785 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 1757 | 1786 | var sessionTitlePending bool | |
| 1758 | 1787 | if !args.NoSess && ag != nil { | |
| @@ -2476,14 +2505,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2476 | 2505 | _ = extMgr.Reload(context.Background(), 2*time.Second) | |
| 2477 | 2506 | } | |
| 2478 | 2507 | if current != nil { | |
| 2479 | - | if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil { | |
| 2508 | + | if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil { | |
| 2480 | 2509 | iv.ReportError(err) | |
| 2481 | 2510 | } | |
| 2482 | 2511 | } | |
| 2483 | 2512 | }, | |
| 2484 | 2513 | RefreshTools: func() error { | |
| 2485 | 2514 | current := liveInteractiveAgent(iv, ag) | |
| 2486 | - | webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv) | |
| 2515 | + | webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv) | |
| 2487 | 2516 | if err != nil { | |
| 2488 | 2517 | if !errors.Is(err, errInteractiveAgentChanged) { | |
| 2489 | 2518 | setWebSearchAvailable(false) | |
| @@ -2496,7 +2525,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2496 | 2525 | SetWebSearchAvailable: setWebSearchAvailable, | |
| 2497 | 2526 | RefreshPrompt: func() error { | |
| 2498 | 2527 | current := liveInteractiveAgent(iv, ag) | |
| 2499 | - | webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv) | |
| 2528 | + | webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv) | |
| 2500 | 2529 | if err != nil { | |
| 2501 | 2530 | return err | |
| 2502 | 2531 | } | |
| @@ -2682,6 +2711,45 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2682 | 2711 | }, | |
| 2683 | 2712 | }) | |
| 2684 | 2713 | extHooks.attachInteractive(iv) | |
| 2714 | + | schedulerCtx, cancelScheduler := context.WithCancel(ctx) | |
| 2715 | + | defer cancelScheduler() | |
| 2716 | + | go func() { | |
| 2717 | + | if err := scheduleEngine.Run(schedulerCtx, func(task scheduler.Task) error { | |
| 2718 | + | // Keep a background session run mutually exclusive with /session | |
| 2719 | + | // transitions. The active target uses Interactive's distinct-turn | |
| 2720 | + | // ingress instead, so it never injects into an in-flight model loop. | |
| 2721 | + | sessionTransitionMu.RLock() | |
| 2722 | + | defer sessionTransitionMu.RUnlock() | |
| 2723 | + | persistMu.Lock() | |
| 2724 | + | activeTarget := sess != nil && sess.ID == task.SessionID | |
| 2725 | + | persistMu.Unlock() | |
| 2726 | + | if activeTarget { | |
| 2727 | + | return iv.SubmitFollowUp(schedulerCtx, task.Message) | |
| 2728 | + | } | |
| 2729 | + | // A resumed background agent must schedule any follow-up work against | |
| 2730 | + | // the transcript it resumed, not whichever session is visible in the | |
| 2731 | + | // interactive TUI while it runs. | |
| 2732 | + | backgroundRegistry := func(registry core.Registry) core.Registry { | |
| 2733 | + | if schedulerAllowed { | |
| 2734 | + | registry[tools.ScheduleToolName] = &tools.ScheduleTool{ | |
| 2735 | + | Engine: scheduleEngine, | |
| 2736 | + | SessionID: func() string { return task.SessionID }, | |
| 2737 | + | Location: func() *time.Location { return time.Local }, | |
| 2738 | + | } | |
| 2739 | + | } | |
| 2740 | + | return registry | |
| 2741 | + | } | |
| 2742 | + | err := runScheduledSession(schedulerCtx, task, args, r, extMgr, backgroundRegistry) | |
| 2743 | + | if err != nil { | |
| 2744 | + | iv.Notify("scheduler", "error", fmt.Sprintf("scheduled task %s failed: %v", task.ID, err)) | |
| 2745 | + | } else { | |
| 2746 | + | iv.Notify("scheduler", "success", fmt.Sprintf("scheduled task %s completed in session %s", task.ID, task.SessionID)) | |
| 2747 | + | } | |
| 2748 | + | return err | |
| 2749 | + | }); err != nil && schedulerCtx.Err() == nil { | |
| 2750 | + | iv.ReportError(fmt.Errorf("scheduler stopped: %w", err)) | |
| 2751 | + | } | |
| 2752 | + | }() | |
| 2685 | 2753 | go func() { | |
| 2686 | 2754 | <-reloadDone | |
| 2687 | 2755 | for _, reloadErr := range reloadErrs { | |
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | "github.com/bnema/zut/packages/agent/extensions" | 19 | "github.com/bnema/zut/packages/agent/extensions" | ||
| 20 | "github.com/bnema/zut/packages/agent/extproto" | 20 | "github.com/bnema/zut/packages/agent/extproto" | ||
| 21 | "github.com/bnema/zut/packages/agent/modes" | 21 | "github.com/bnema/zut/packages/agent/modes" | ||
| 22 | + | "github.com/bnema/zut/packages/agent/scheduler" | |||
| 22 | "github.com/bnema/zut/packages/agent/skills" | 23 | "github.com/bnema/zut/packages/agent/skills" | ||
| 23 | "github.com/bnema/zut/packages/agent/subagents" | 24 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 24 | "github.com/bnema/zut/packages/agent/tools" | 25 | "github.com/bnema/zut/packages/agent/tools" | ||
| @@ -1484,6 +1485,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1484 | var sessionTransitionMu sync.RWMutex | 1485 | var sessionTransitionMu sync.RWMutex | ||
| 1485 | activeProvider := r.Provider | 1486 | activeProvider := r.Provider | ||
| 1486 | activeModel := r.Model | 1487 | activeModel := r.Model | ||
| 1488 | + | var sess *core.Session | |||
| 1489 | + | scheduleEngine := scheduler.NewEngine(nil) | |||
| 1487 | 1490 | ||||
| 1488 | // Build the extension manager BEFORE the agent so we can fold | 1491 | // Build the extension manager BEFORE the agent so we can fold | ||
| 1489 | // extension-defined tools into the registry. Attach the interactive | 1492 | // extension-defined tools into the registry. Attach the interactive | ||
| @@ -1665,6 +1668,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1665 | return a | 1668 | return a | ||
| 1666 | } | 1669 | } | ||
| 1667 | 1670 | ||||
| 1671 | + | scheduleTool := &tools.ScheduleTool{ | |||
| 1672 | + | Engine: scheduleEngine, | |||
| 1673 | + | SessionID: func() string { | |||
| 1674 | + | persistMu.Lock() | |||
| 1675 | + | defer persistMu.Unlock() | |||
| 1676 | + | if sess == nil { | |||
| 1677 | + | return "" | |||
| 1678 | + | } | |||
| 1679 | + | return sess.ID | |||
| 1680 | + | }, | |||
| 1681 | + | Location: func() *time.Location { return time.Local }, | |||
| 1682 | + | } | |||
| 1683 | + | schedulerAllowed := !args.NoTools && (!args.ToolsSet && len(args.Tools) == 0 || toolListContains(args.Tools, tools.ScheduleToolName)) | |||
| 1684 | + | installSchedulerTool := func(registry core.Registry) core.Registry { | |||
| 1685 | + | if schedulerAllowed { | |||
| 1686 | + | registry[tools.ScheduleToolName] = scheduleTool | |||
| 1687 | + | } | |||
| 1688 | + | return registry | |||
| 1689 | + | } | |||
| 1690 | + | installSchedulerTool(r.ToolRegistry) | |||
| 1691 | + | prepareInteractiveRegistryWithScheduler := func(registry core.Registry) core.Registry { | |||
| 1692 | + | return prepareInteractiveRegistry(installSchedulerTool(registry)) | |||
| 1693 | + | } | |||
| 1694 | + | ||||
| 1668 | var ag *core.Agent | 1695 | var ag *core.Agent | ||
| 1669 | buildAgent := func() (*core.Agent, string, string, error) { | 1696 | buildAgent := func() (*core.Agent, string, string, error) { | ||
| 1670 | resolved, err := Resolve(args, true) | 1697 | resolved, err := Resolve(args, true) | ||
| @@ -1676,6 +1703,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1676 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | 1703 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | ||
| 1677 | runtime.SetFastMode(resolved.FastMode) | 1704 | runtime.SetFastMode(resolved.FastMode) | ||
| 1678 | resolved.MergeExtensionTools(extToolAdapter) | 1705 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 1706 | + | installSchedulerTool(resolved.ToolRegistry) | |||
| 1679 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | 1707 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | ||
| 1680 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 1708 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 1681 | } | 1709 | } | ||
| @@ -1698,6 +1726,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1698 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | 1726 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | ||
| 1699 | runtime.SetFastMode(resolved.FastMode) | 1727 | runtime.SetFastMode(resolved.FastMode) | ||
| 1700 | resolved.MergeExtensionTools(extToolAdapter) | 1728 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 1729 | + | installSchedulerTool(resolved.ToolRegistry) | |||
| 1701 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | 1730 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | ||
| 1702 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 1731 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 1703 | } | 1732 | } | ||
| @@ -1728,6 +1757,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1728 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | 1757 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | ||
| 1729 | runtime.SetFastMode(resolved.FastMode) | 1758 | runtime.SetFastMode(resolved.FastMode) | ||
| 1730 | resolved.MergeExtensionTools(extToolAdapter) | 1759 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 1760 | + | installSchedulerTool(resolved.ToolRegistry) | |||
| 1731 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | 1761 | prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | ||
| 1732 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 1762 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 1733 | } | 1763 | } | ||
| @@ -1747,12 +1777,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1747 | if current == nil { | 1777 | if current == nil { | ||
| 1748 | return | 1778 | return | ||
| 1749 | } | 1779 | } | ||
| 1750 | - | if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil { | 1780 | + | if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil && iv != nil { |
| 1751 | iv.ReportError(err) | 1781 | iv.ReportError(err) | ||
| 1752 | } | 1782 | } | ||
| 1753 | }) | 1783 | }) | ||
| 1754 | 1784 | ||||
| 1755 | - | var sess *core.Session | |||
| 1756 | var sessBaselineMsgs int // messages already on disk when current session opened | 1785 | var sessBaselineMsgs int // messages already on disk when current session opened | ||
| 1757 | var sessionTitlePending bool | 1786 | var sessionTitlePending bool | ||
| 1758 | if !args.NoSess && ag != nil { | 1787 | if !args.NoSess && ag != nil { | ||
| @@ -2476,14 +2505,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2476 | _ = extMgr.Reload(context.Background(), 2*time.Second) | 2505 | _ = extMgr.Reload(context.Background(), 2*time.Second) | ||
| 2477 | } | 2506 | } | ||
| 2478 | if current != nil { | 2507 | if current != nil { | ||
| 2479 | - | if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil { | 2508 | + | if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv); err != nil { |
| 2480 | iv.ReportError(err) | 2509 | iv.ReportError(err) | ||
| 2481 | } | 2510 | } | ||
| 2482 | } | 2511 | } | ||
| 2483 | }, | 2512 | }, | ||
| 2484 | RefreshTools: func() error { | 2513 | RefreshTools: func() error { | ||
| 2485 | current := liveInteractiveAgent(iv, ag) | 2514 | current := liveInteractiveAgent(iv, ag) | ||
| 2486 | - | webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv) | 2515 | + | webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv) |
| 2487 | if err != nil { | 2516 | if err != nil { | ||
| 2488 | if !errors.Is(err, errInteractiveAgentChanged) { | 2517 | if !errors.Is(err, errInteractiveAgentChanged) { | ||
| 2489 | setWebSearchAvailable(false) | 2518 | setWebSearchAvailable(false) | ||
| @@ -2496,7 +2525,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2496 | SetWebSearchAvailable: setWebSearchAvailable, | 2525 | SetWebSearchAvailable: setWebSearchAvailable, | ||
| 2497 | RefreshPrompt: func() error { | 2526 | RefreshPrompt: func() error { | ||
| 2498 | current := liveInteractiveAgent(iv, ag) | 2527 | current := liveInteractiveAgent(iv, ag) | ||
| 2499 | - | webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv) | 2528 | + | webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistryWithScheduler, iv) |
| 2500 | if err != nil { | 2529 | if err != nil { | ||
| 2501 | return err | 2530 | return err | ||
| 2502 | } | 2531 | } | ||
| @@ -2682,6 +2711,45 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2682 | }, | 2711 | }, | ||
| 2683 | }) | 2712 | }) | ||
| 2684 | extHooks.attachInteractive(iv) | 2713 | extHooks.attachInteractive(iv) | ||
| 2714 | + | schedulerCtx, cancelScheduler := context.WithCancel(ctx) | |||
| 2715 | + | defer cancelScheduler() | |||
| 2716 | + | go func() { | |||
| 2717 | + | if err := scheduleEngine.Run(schedulerCtx, func(task scheduler.Task) error { | |||
| 2718 | + | // Keep a background session run mutually exclusive with /session | |||
| 2719 | + | // transitions. The active target uses Interactive's distinct-turn | |||
| 2720 | + | // ingress instead, so it never injects into an in-flight model loop. | |||
| 2721 | + | sessionTransitionMu.RLock() | |||
| 2722 | + | defer sessionTransitionMu.RUnlock() | |||
| 2723 | + | persistMu.Lock() | |||
| 2724 | + | activeTarget := sess != nil && sess.ID == task.SessionID | |||
| 2725 | + | persistMu.Unlock() | |||
| 2726 | + | if activeTarget { | |||
| 2727 | + | return iv.SubmitFollowUp(schedulerCtx, task.Message) | |||
| 2728 | + | } | |||
| 2729 | + | // A resumed background agent must schedule any follow-up work against | |||
| 2730 | + | // the transcript it resumed, not whichever session is visible in the | |||
| 2731 | + | // interactive TUI while it runs. | |||
| 2732 | + | backgroundRegistry := func(registry core.Registry) core.Registry { | |||
| 2733 | + | if schedulerAllowed { | |||
| 2734 | + | registry[tools.ScheduleToolName] = &tools.ScheduleTool{ | |||
| 2735 | + | Engine: scheduleEngine, | |||
| 2736 | + | SessionID: func() string { return task.SessionID }, | |||
| 2737 | + | Location: func() *time.Location { return time.Local }, | |||
| 2738 | + | } | |||
| 2739 | + | } | |||
| 2740 | + | return registry | |||
| 2741 | + | } | |||
| 2742 | + | err := runScheduledSession(schedulerCtx, task, args, r, extMgr, backgroundRegistry) | |||
| 2743 | + | if err != nil { | |||
| 2744 | + | iv.Notify("scheduler", "error", fmt.Sprintf("scheduled task %s failed: %v", task.ID, err)) | |||
| 2745 | + | } else { | |||
| 2746 | + | iv.Notify("scheduler", "success", fmt.Sprintf("scheduled task %s completed in session %s", task.ID, task.SessionID)) | |||
| 2747 | + | } | |||
| 2748 | + | return err | |||
| 2749 | + | }); err != nil && schedulerCtx.Err() == nil { | |||
| 2750 | + | iv.ReportError(fmt.Errorf("scheduler stopped: %w", err)) | |||
| 2751 | + | } | |||
| 2752 | + | }() | |||
| 2685 | go func() { | 2753 | go func() { | ||
| 2686 | <-reloadDone | 2754 | <-reloadDone | ||
| 2687 | for _, reloadErr := range reloadErrs { | 2755 | for _, reloadErr := range reloadErrs { | ||
M packages/agent/modes/interactive.go +10 -0
| @@ -612,6 +612,11 @@ func encodeCompactHandoff(state compactContinuationState) json.RawMessage { | |||
| 612 | 612 | return encoded | |
| 613 | 613 | } | |
| 614 | 614 | ||
| 615 | + | type scheduledFollowUp struct { | |
| 616 | + | text string | |
| 617 | + | accepted chan error | |
| 618 | + | } | |
| 619 | + | ||
| 615 | 620 | type Interactive struct { | |
| 616 | 621 | cfg InteractiveConfig | |
| 617 | 622 | view *tui.View | |
| @@ -728,6 +733,11 @@ type Interactive struct { | |||
| 728 | 733 | // above the status bar as "sliding in: ..." chips. | |
| 729 | 734 | queued []string | |
| 730 | 735 | ||
| 736 | + | // scheduled holds prompt-only follow-ups injected by the in-process | |
| 737 | + | // scheduler. Unlike agent.QueueMessage, these always wait until the | |
| 738 | + | // current turn has completed before starting their own turn. | |
| 739 | + | scheduled []scheduledFollowUp | |
| 740 | + | ||
| 731 | 741 | // runCtx is the top-level context passed to Run(). Follow-up turns | |
| 732 | 742 | // drained from `queued` are started against this context so they | |
| 733 | 743 | // survive past the ctx of the key event that enqueued them. | |
| @@ -612,6 +612,11 @@ func encodeCompactHandoff(state compactContinuationState) json.RawMessage { | |||||
| 612 | return encoded | 612 | return encoded | ||
| 613 | } | 613 | } | ||
| 614 | 614 | ||||
| 615 | + | type scheduledFollowUp struct { | |||
| 616 | + | text string | |||
| 617 | + | accepted chan error | |||
| 618 | + | } | |||
| 619 | + | ||||
| 615 | type Interactive struct { | 620 | type Interactive struct { | ||
| 616 | cfg InteractiveConfig | 621 | cfg InteractiveConfig | ||
| 617 | view *tui.View | 622 | view *tui.View | ||
| @@ -728,6 +733,11 @@ type Interactive struct { | |||||
| 728 | // above the status bar as "sliding in: ..." chips. | 733 | // above the status bar as "sliding in: ..." chips. | ||
| 729 | queued []string | 734 | queued []string | ||
| 730 | 735 | ||||
| 736 | + | // scheduled holds prompt-only follow-ups injected by the in-process | |||
| 737 | + | // scheduler. Unlike agent.QueueMessage, these always wait until the | |||
| 738 | + | // current turn has completed before starting their own turn. | |||
| 739 | + | scheduled []scheduledFollowUp | |||
| 740 | + | ||||
| 731 | // runCtx is the top-level context passed to Run(). Follow-up turns | 741 | // runCtx is the top-level context passed to Run(). Follow-up turns | ||
| 732 | // drained from `queued` are started against this context so they | 742 | // drained from `queued` are started against this context so they | ||
| 733 | // survive past the ctx of the key event that enqueued them. | 743 | // survive past the ctx of the key event that enqueued them. | ||
M packages/agent/modes/interactive_host.go +30 -0
| @@ -201,6 +201,36 @@ func (i *Interactive) SubmitSlash(text string) { | |||
| 201 | 201 | func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |
| 202 | 202 | i.submitOrQueue(text, images, true) | |
| 203 | 203 | } | |
| 204 | + | ||
| 205 | + | // SubmitFollowUp submits a scheduler-owned prompt without interpreting shell | |
| 206 | + | // escapes or steering an active agent loop. A busy interactive waits for its | |
| 207 | + | // current turn to end, then starts this prompt as a distinct follow-up turn. | |
| 208 | + | func (i *Interactive) SubmitFollowUp(ctx context.Context, text string) error { | |
| 209 | + | text = strings.TrimSpace(text) | |
| 210 | + | if text == "" { | |
| 211 | + | return fmt.Errorf("scheduled prompt is empty") | |
| 212 | + | } | |
| 213 | + | i.mu.Lock() | |
| 214 | + | if i.agent == nil { | |
| 215 | + | i.mu.Unlock() | |
| 216 | + | return fmt.Errorf("no agent running; log in first") | |
| 217 | + | } | |
| 218 | + | if i.busy { | |
| 219 | + | accepted := make(chan error, 1) | |
| 220 | + | i.scheduled = append(i.scheduled, scheduledFollowUp{text: text, accepted: accepted}) | |
| 221 | + | i.mu.Unlock() | |
| 222 | + | i.invalidate() | |
| 223 | + | select { | |
| 224 | + | case err := <-accepted: | |
| 225 | + | return err | |
| 226 | + | case <-ctx.Done(): | |
| 227 | + | return ctx.Err() | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | i.mu.Unlock() | |
| 231 | + | i.startTurn(i.runCtx, text) | |
| 232 | + | return nil | |
| 233 | + | } | |
| 204 | 234 | func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) { | |
| 205 | 235 | if cmd, ok := shellEscapeCommand(text); ok { | |
| 206 | 236 | i.startShellEscape(i.runCtx, cmd) | |
| @@ -201,6 +201,36 @@ func (i *Interactive) SubmitSlash(text string) { | |||||
| 201 | func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | 201 | func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | ||
| 202 | i.submitOrQueue(text, images, true) | 202 | i.submitOrQueue(text, images, true) | ||
| 203 | } | 203 | } | ||
| 204 | + | ||||
| 205 | + | // SubmitFollowUp submits a scheduler-owned prompt without interpreting shell | |||
| 206 | + | // escapes or steering an active agent loop. A busy interactive waits for its | |||
| 207 | + | // current turn to end, then starts this prompt as a distinct follow-up turn. | |||
| 208 | + | func (i *Interactive) SubmitFollowUp(ctx context.Context, text string) error { | |||
| 209 | + | text = strings.TrimSpace(text) | |||
| 210 | + | if text == "" { | |||
| 211 | + | return fmt.Errorf("scheduled prompt is empty") | |||
| 212 | + | } | |||
| 213 | + | i.mu.Lock() | |||
| 214 | + | if i.agent == nil { | |||
| 215 | + | i.mu.Unlock() | |||
| 216 | + | return fmt.Errorf("no agent running; log in first") | |||
| 217 | + | } | |||
| 218 | + | if i.busy { | |||
| 219 | + | accepted := make(chan error, 1) | |||
| 220 | + | i.scheduled = append(i.scheduled, scheduledFollowUp{text: text, accepted: accepted}) | |||
| 221 | + | i.mu.Unlock() | |||
| 222 | + | i.invalidate() | |||
| 223 | + | select { | |||
| 224 | + | case err := <-accepted: | |||
| 225 | + | return err | |||
| 226 | + | case <-ctx.Done(): | |||
| 227 | + | return ctx.Err() | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | i.mu.Unlock() | |||
| 231 | + | i.startTurn(i.runCtx, text) | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 204 | func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) { | 234 | func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) { | ||
| 205 | if cmd, ok := shellEscapeCommand(text); ok { | 235 | if cmd, ok := shellEscapeCommand(text); ok { | ||
| 206 | i.startShellEscape(i.runCtx, cmd) | 236 | i.startShellEscape(i.runCtx, cmd) | ||
M packages/agent/modes/interactive_turn.go +18 -4
| @@ -337,14 +337,22 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 337 | 337 | next, i.queued = i.queued[0], i.queued[1:] | |
| 338 | 338 | hasNext = true | |
| 339 | 339 | } | |
| 340 | - | // If the turn was cancelled or errored, drop the queue so the | |
| 340 | + | // If the turn was cancelled or errored, drop ordinary input so the | |
| 341 | 341 | // user isn't bombarded with stale messages after an interrupt. | |
| 342 | + | // Scheduler follow-ups deliberately remain: they are distinct turns | |
| 343 | + | // whose due time must not let them steer this failed turn. | |
| 342 | 344 | if ctx.Err() != nil || (err != nil && !recoverContextOverflow) { | |
| 343 | 345 | i.queued = nil | |
| 344 | 346 | if i.agent != nil { | |
| 345 | 347 | i.agent.DrainQueuedMessages() | |
| 346 | 348 | } | |
| 347 | 349 | } | |
| 350 | + | var scheduled scheduledFollowUp | |
| 351 | + | var hasScheduled bool | |
| 352 | + | if !awaitingPre && !hasNext && len(i.scheduled) > 0 { | |
| 353 | + | scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:] | |
| 354 | + | hasScheduled = true | |
| 355 | + | } | |
| 348 | 356 | // Decide whether the next thing to do is an auto-compaction. | |
| 349 | 357 | // Only fires when the turn completed cleanly AND no host-side | |
| 350 | 358 | // or agent-side queued messages are waiting (otherwise a queued | |
| @@ -371,7 +379,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 371 | 379 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 372 | 380 | } | |
| 373 | 381 | } | |
| 374 | - | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | |
| 382 | + | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | |
| 375 | 383 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 376 | 384 | } | |
| 377 | 385 | // The agent run can finish before the paced final text reaches the | |
| @@ -381,8 +389,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 381 | 389 | if recoverContextOverflow || shouldAutoCompact { | |
| 382 | 390 | i.resetStreamingStateLocked() | |
| 383 | 391 | } | |
| 384 | - | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact) | |
| 385 | - | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | |
| 392 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact) | |
| 393 | + | i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | |
| 386 | 394 | i.mu.Unlock() | |
| 387 | 395 | if persistHandoff { | |
| 388 | 396 | i.persistCompactHandoff(handoff) | |
| @@ -402,6 +410,12 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 402 | 410 | switch { | |
| 403 | 411 | case hasNext: | |
| 404 | 412 | i.startTurn(parent, next) | |
| 413 | + | case hasScheduled: | |
| 414 | + | // The scheduler holds the session transition read lock until this | |
| 415 | + | // acknowledgement, so accepting here binds the prompt to the agent | |
| 416 | + | // that just completed rather than a subsequently loaded session. | |
| 417 | + | scheduled.accepted <- nil | |
| 418 | + | i.startTurn(parent, scheduled.text) | |
| 405 | 419 | case continueQueued: | |
| 406 | 420 | i.startTurnRequest(parent, "", nil, true, false) | |
| 407 | 421 | case continueStatusRescue: | |
| @@ -337,14 +337,22 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 337 | next, i.queued = i.queued[0], i.queued[1:] | 337 | next, i.queued = i.queued[0], i.queued[1:] | ||
| 338 | hasNext = true | 338 | hasNext = true | ||
| 339 | } | 339 | } | ||
| 340 | - | // If the turn was cancelled or errored, drop the queue so the | 340 | + | // If the turn was cancelled or errored, drop ordinary input so the |
| 341 | // user isn't bombarded with stale messages after an interrupt. | 341 | // user isn't bombarded with stale messages after an interrupt. | ||
| 342 | + | // Scheduler follow-ups deliberately remain: they are distinct turns | |||
| 343 | + | // whose due time must not let them steer this failed turn. | |||
| 342 | if ctx.Err() != nil || (err != nil && !recoverContextOverflow) { | 344 | if ctx.Err() != nil || (err != nil && !recoverContextOverflow) { | ||
| 343 | i.queued = nil | 345 | i.queued = nil | ||
| 344 | if i.agent != nil { | 346 | if i.agent != nil { | ||
| 345 | i.agent.DrainQueuedMessages() | 347 | i.agent.DrainQueuedMessages() | ||
| 346 | } | 348 | } | ||
| 347 | } | 349 | } | ||
| 350 | + | var scheduled scheduledFollowUp | |||
| 351 | + | var hasScheduled bool | |||
| 352 | + | if !awaitingPre && !hasNext && len(i.scheduled) > 0 { | |||
| 353 | + | scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:] | |||
| 354 | + | hasScheduled = true | |||
| 355 | + | } | |||
| 348 | // Decide whether the next thing to do is an auto-compaction. | 356 | // Decide whether the next thing to do is an auto-compaction. | ||
| 349 | // Only fires when the turn completed cleanly AND no host-side | 357 | // Only fires when the turn completed cleanly AND no host-side | ||
| 350 | // or agent-side queued messages are waiting (otherwise a queued | 358 | // or agent-side queued messages are waiting (otherwise a queued | ||
| @@ -371,7 +379,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 371 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 379 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 372 | } | 380 | } | ||
| 373 | } | 381 | } | ||
| 374 | - | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | 382 | + | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { |
| 375 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 383 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 376 | } | 384 | } | ||
| 377 | // The agent run can finish before the paced final text reaches the | 385 | // The agent run can finish before the paced final text reaches the | ||
| @@ -381,8 +389,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 381 | if recoverContextOverflow || shouldAutoCompact { | 389 | if recoverContextOverflow || shouldAutoCompact { | ||
| 382 | i.resetStreamingStateLocked() | 390 | i.resetStreamingStateLocked() | ||
| 383 | } | 391 | } | ||
| 384 | - | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact) | 392 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact) |
| 385 | - | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | 393 | + | i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact |
| 386 | i.mu.Unlock() | 394 | i.mu.Unlock() | ||
| 387 | if persistHandoff { | 395 | if persistHandoff { | ||
| 388 | i.persistCompactHandoff(handoff) | 396 | i.persistCompactHandoff(handoff) | ||
| @@ -402,6 +410,12 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 402 | switch { | 410 | switch { | ||
| 403 | case hasNext: | 411 | case hasNext: | ||
| 404 | i.startTurn(parent, next) | 412 | i.startTurn(parent, next) | ||
| 413 | + | case hasScheduled: | |||
| 414 | + | // The scheduler holds the session transition read lock until this | |||
| 415 | + | // acknowledgement, so accepting here binds the prompt to the agent | |||
| 416 | + | // that just completed rather than a subsequently loaded session. | |||
| 417 | + | scheduled.accepted <- nil | |||
| 418 | + | i.startTurn(parent, scheduled.text) | |||
| 405 | case continueQueued: | 419 | case continueQueued: | ||
| 406 | i.startTurnRequest(parent, "", nil, true, false) | 420 | i.startTurnRequest(parent, "", nil, true, false) | ||
| 407 | case continueStatusRescue: | 421 | case continueStatusRescue: | ||
A packages/agent/scheduler/cron.go +208 -0
| @@ -0,0 +1,208 @@ | |||
| 1 | + | // Package scheduler provides portable, in-process calendar scheduling. | |
| 2 | + | package scheduler | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Cron is a five-field cron schedule (minute, hour, day of month, month, day | |
| 12 | + | // of week). It evaluates calendar times in the location captured when the | |
| 13 | + | // schedule is created. A repeated local minute during a DST fall-back matches | |
| 14 | + | // twice; a local minute skipped during a spring-forward does not match. | |
| 15 | + | type Cron struct { | |
| 16 | + | minute field | |
| 17 | + | hour field | |
| 18 | + | day field | |
| 19 | + | month field | |
| 20 | + | week field | |
| 21 | + | loc *time.Location | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type field struct { | |
| 25 | + | values []bool | |
| 26 | + | wildcard bool | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | var cronMacros = map[string]string{ | |
| 30 | + | "@yearly": "0 0 1 1 *", | |
| 31 | + | "@annually": "0 0 1 1 *", | |
| 32 | + | "@monthly": "0 0 1 * *", | |
| 33 | + | "@weekly": "0 0 * * 0", | |
| 34 | + | "@daily": "0 0 * * *", | |
| 35 | + | "@midnight": "0 0 * * *", | |
| 36 | + | "@hourly": "0 * * * *", | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | var monthNames = map[string]int{ | |
| 40 | + | "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, | |
| 41 | + | "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | var weekdayNames = map[string]int{ | |
| 45 | + | "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ParseCron parses a conventional five-field cron expression. Month and | |
| 49 | + | // weekday names use the English three-letter abbreviations. The supplied | |
| 50 | + | // location is retained by the schedule, so later changes to time.Local do not | |
| 51 | + | // change an existing task's calendar semantics. | |
| 52 | + | func ParseCron(expression string, location *time.Location) (Cron, error) { | |
| 53 | + | if location == nil { | |
| 54 | + | return Cron{}, fmt.Errorf("cron timezone is required") | |
| 55 | + | } | |
| 56 | + | expression = strings.ToLower(strings.TrimSpace(expression)) | |
| 57 | + | if replacement, ok := cronMacros[expression]; ok { | |
| 58 | + | expression = replacement | |
| 59 | + | } | |
| 60 | + | parts := strings.Fields(expression) | |
| 61 | + | if len(parts) != 5 { | |
| 62 | + | return Cron{}, fmt.Errorf("cron requires five fields (minute hour day-of-month month day-of-week)") | |
| 63 | + | } | |
| 64 | + | minute, err := parseField(parts[0], 0, 59, nil, false) | |
| 65 | + | if err != nil { | |
| 66 | + | return Cron{}, fmt.Errorf("invalid cron minute: %w", err) | |
| 67 | + | } | |
| 68 | + | hour, err := parseField(parts[1], 0, 23, nil, false) | |
| 69 | + | if err != nil { | |
| 70 | + | return Cron{}, fmt.Errorf("invalid cron hour: %w", err) | |
| 71 | + | } | |
| 72 | + | day, err := parseField(parts[2], 1, 31, nil, false) | |
| 73 | + | if err != nil { | |
| 74 | + | return Cron{}, fmt.Errorf("invalid cron day-of-month: %w", err) | |
| 75 | + | } | |
| 76 | + | month, err := parseField(parts[3], 1, 12, monthNames, false) | |
| 77 | + | if err != nil { | |
| 78 | + | return Cron{}, fmt.Errorf("invalid cron month: %w", err) | |
| 79 | + | } | |
| 80 | + | week, err := parseField(parts[4], 0, 7, weekdayNames, true) | |
| 81 | + | if err != nil { | |
| 82 | + | return Cron{}, fmt.Errorf("invalid cron day-of-week: %w", err) | |
| 83 | + | } | |
| 84 | + | return Cron{minute: minute, hour: hour, day: day, month: month, week: week, loc: location}, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Next returns the first schedule occurrence strictly after after. An invalid | |
| 88 | + | // calendar expression such as 31 February has no occurrence and returns the | |
| 89 | + | // zero time. | |
| 90 | + | func (c Cron) Next(after time.Time) time.Time { | |
| 91 | + | if c.loc == nil { | |
| 92 | + | return time.Time{} | |
| 93 | + | } | |
| 94 | + | candidate := after.In(c.loc).Truncate(time.Minute).Add(time.Minute) | |
| 95 | + | // Eight years covers every leap-year placement while keeping impossible | |
| 96 | + | // expressions bounded. Normal schedules resolve in a handful of steps. | |
| 97 | + | const maxMinutes = 8 * 366 * 24 * 60 | |
| 98 | + | for range maxMinutes { | |
| 99 | + | if c.matches(candidate) { | |
| 100 | + | return candidate | |
| 101 | + | } | |
| 102 | + | candidate = candidate.Add(time.Minute) | |
| 103 | + | } | |
| 104 | + | return time.Time{} | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (c Cron) matches(t time.Time) bool { | |
| 108 | + | if !c.minute.matches(t.Minute()) || !c.hour.matches(t.Hour()) || | |
| 109 | + | !c.month.matches(int(t.Month())) { | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | dom := c.day.matches(t.Day()) | |
| 113 | + | dow := c.week.matches(int(t.Weekday())) | |
| 114 | + | // Traditional cron treats restricted day-of-month and day-of-week fields | |
| 115 | + | // as an OR. When either is *, the other field controls matching. | |
| 116 | + | if !c.day.wildcard && !c.week.wildcard { | |
| 117 | + | return dom || dow | |
| 118 | + | } | |
| 119 | + | return dom && dow | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (f field) matches(value int) bool { | |
| 123 | + | return value >= 0 && value < len(f.values) && f.values[value] | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func parseField(raw string, min, max int, aliases map[string]int, sundaySeven bool) (field, error) { | |
| 127 | + | out := field{values: make([]bool, max+1), wildcard: raw == "*"} | |
| 128 | + | if raw == "" { | |
| 129 | + | return field{}, fmt.Errorf("empty field") | |
| 130 | + | } | |
| 131 | + | for _, part := range strings.Split(raw, ",") { | |
| 132 | + | if part == "" { | |
| 133 | + | return field{}, fmt.Errorf("empty list item") | |
| 134 | + | } | |
| 135 | + | base, step, hasStep, err := splitStep(part) | |
| 136 | + | if err != nil { | |
| 137 | + | return field{}, err | |
| 138 | + | } | |
| 139 | + | start, end := min, max | |
| 140 | + | switch { | |
| 141 | + | case base == "*": | |
| 142 | + | case strings.Contains(base, "-"): | |
| 143 | + | pieces := strings.Split(base, "-") | |
| 144 | + | if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" { | |
| 145 | + | return field{}, fmt.Errorf("invalid range %q", base) | |
| 146 | + | } | |
| 147 | + | start, err = parseCronValue(pieces[0], min, max, aliases, sundaySeven) | |
| 148 | + | if err != nil { | |
| 149 | + | return field{}, err | |
| 150 | + | } | |
| 151 | + | end, err = parseCronValue(pieces[1], min, max, aliases, sundaySeven) | |
| 152 | + | if err != nil { | |
| 153 | + | return field{}, err | |
| 154 | + | } | |
| 155 | + | if start > end { | |
| 156 | + | return field{}, fmt.Errorf("range %q descends", base) | |
| 157 | + | } | |
| 158 | + | default: | |
| 159 | + | if hasStep { | |
| 160 | + | return field{}, fmt.Errorf("step requires * or a range") | |
| 161 | + | } | |
| 162 | + | start, err = parseCronValue(base, min, max, aliases, sundaySeven) | |
| 163 | + | if err != nil { | |
| 164 | + | return field{}, err | |
| 165 | + | } | |
| 166 | + | end = start | |
| 167 | + | } | |
| 168 | + | if !hasStep { | |
| 169 | + | step = 1 | |
| 170 | + | } | |
| 171 | + | for value := start; value <= end; value += step { | |
| 172 | + | out.values[value] = true | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | return out, nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func splitStep(part string) (base string, step int, hasStep bool, err error) { | |
| 179 | + | pieces := strings.Split(part, "/") | |
| 180 | + | if len(pieces) == 1 { | |
| 181 | + | return part, 0, false, nil | |
| 182 | + | } | |
| 183 | + | if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" { | |
| 184 | + | return "", 0, false, fmt.Errorf("invalid step %q", part) | |
| 185 | + | } | |
| 186 | + | step, err = strconv.Atoi(pieces[1]) | |
| 187 | + | if err != nil || step <= 0 { | |
| 188 | + | return "", 0, false, fmt.Errorf("invalid step %q", part) | |
| 189 | + | } | |
| 190 | + | return pieces[0], step, true, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func parseCronValue(raw string, min, max int, aliases map[string]int, sundaySeven bool) (int, error) { | |
| 194 | + | if value, ok := aliases[raw]; ok { | |
| 195 | + | return value, nil | |
| 196 | + | } | |
| 197 | + | value, err := strconv.Atoi(raw) | |
| 198 | + | if err != nil { | |
| 199 | + | return 0, fmt.Errorf("%q is not a number", raw) | |
| 200 | + | } | |
| 201 | + | if sundaySeven && value == 7 { | |
| 202 | + | return 0, nil | |
| 203 | + | } | |
| 204 | + | if value < min || value > max { | |
| 205 | + | return 0, fmt.Errorf("%d is outside %d-%d", value, min, max) | |
| 206 | + | } | |
| 207 | + | return value, nil | |
| 208 | + | } | |
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | // Package scheduler provides portable, in-process calendar scheduling. | |||
| 2 | + | package scheduler | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "fmt" | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Cron is a five-field cron schedule (minute, hour, day of month, month, day | |||
| 12 | + | // of week). It evaluates calendar times in the location captured when the | |||
| 13 | + | // schedule is created. A repeated local minute during a DST fall-back matches | |||
| 14 | + | // twice; a local minute skipped during a spring-forward does not match. | |||
| 15 | + | type Cron struct { | |||
| 16 | + | minute field | |||
| 17 | + | hour field | |||
| 18 | + | day field | |||
| 19 | + | month field | |||
| 20 | + | week field | |||
| 21 | + | loc *time.Location | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type field struct { | |||
| 25 | + | values []bool | |||
| 26 | + | wildcard bool | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | var cronMacros = map[string]string{ | |||
| 30 | + | "@yearly": "0 0 1 1 *", | |||
| 31 | + | "@annually": "0 0 1 1 *", | |||
| 32 | + | "@monthly": "0 0 1 * *", | |||
| 33 | + | "@weekly": "0 0 * * 0", | |||
| 34 | + | "@daily": "0 0 * * *", | |||
| 35 | + | "@midnight": "0 0 * * *", | |||
| 36 | + | "@hourly": "0 * * * *", | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | var monthNames = map[string]int{ | |||
| 40 | + | "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, | |||
| 41 | + | "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | var weekdayNames = map[string]int{ | |||
| 45 | + | "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // ParseCron parses a conventional five-field cron expression. Month and | |||
| 49 | + | // weekday names use the English three-letter abbreviations. The supplied | |||
| 50 | + | // location is retained by the schedule, so later changes to time.Local do not | |||
| 51 | + | // change an existing task's calendar semantics. | |||
| 52 | + | func ParseCron(expression string, location *time.Location) (Cron, error) { | |||
| 53 | + | if location == nil { | |||
| 54 | + | return Cron{}, fmt.Errorf("cron timezone is required") | |||
| 55 | + | } | |||
| 56 | + | expression = strings.ToLower(strings.TrimSpace(expression)) | |||
| 57 | + | if replacement, ok := cronMacros[expression]; ok { | |||
| 58 | + | expression = replacement | |||
| 59 | + | } | |||
| 60 | + | parts := strings.Fields(expression) | |||
| 61 | + | if len(parts) != 5 { | |||
| 62 | + | return Cron{}, fmt.Errorf("cron requires five fields (minute hour day-of-month month day-of-week)") | |||
| 63 | + | } | |||
| 64 | + | minute, err := parseField(parts[0], 0, 59, nil, false) | |||
| 65 | + | if err != nil { | |||
| 66 | + | return Cron{}, fmt.Errorf("invalid cron minute: %w", err) | |||
| 67 | + | } | |||
| 68 | + | hour, err := parseField(parts[1], 0, 23, nil, false) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return Cron{}, fmt.Errorf("invalid cron hour: %w", err) | |||
| 71 | + | } | |||
| 72 | + | day, err := parseField(parts[2], 1, 31, nil, false) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return Cron{}, fmt.Errorf("invalid cron day-of-month: %w", err) | |||
| 75 | + | } | |||
| 76 | + | month, err := parseField(parts[3], 1, 12, monthNames, false) | |||
| 77 | + | if err != nil { | |||
| 78 | + | return Cron{}, fmt.Errorf("invalid cron month: %w", err) | |||
| 79 | + | } | |||
| 80 | + | week, err := parseField(parts[4], 0, 7, weekdayNames, true) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return Cron{}, fmt.Errorf("invalid cron day-of-week: %w", err) | |||
| 83 | + | } | |||
| 84 | + | return Cron{minute: minute, hour: hour, day: day, month: month, week: week, loc: location}, nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Next returns the first schedule occurrence strictly after after. An invalid | |||
| 88 | + | // calendar expression such as 31 February has no occurrence and returns the | |||
| 89 | + | // zero time. | |||
| 90 | + | func (c Cron) Next(after time.Time) time.Time { | |||
| 91 | + | if c.loc == nil { | |||
| 92 | + | return time.Time{} | |||
| 93 | + | } | |||
| 94 | + | candidate := after.In(c.loc).Truncate(time.Minute).Add(time.Minute) | |||
| 95 | + | // Eight years covers every leap-year placement while keeping impossible | |||
| 96 | + | // expressions bounded. Normal schedules resolve in a handful of steps. | |||
| 97 | + | const maxMinutes = 8 * 366 * 24 * 60 | |||
| 98 | + | for range maxMinutes { | |||
| 99 | + | if c.matches(candidate) { | |||
| 100 | + | return candidate | |||
| 101 | + | } | |||
| 102 | + | candidate = candidate.Add(time.Minute) | |||
| 103 | + | } | |||
| 104 | + | return time.Time{} | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (c Cron) matches(t time.Time) bool { | |||
| 108 | + | if !c.minute.matches(t.Minute()) || !c.hour.matches(t.Hour()) || | |||
| 109 | + | !c.month.matches(int(t.Month())) { | |||
| 110 | + | return false | |||
| 111 | + | } | |||
| 112 | + | dom := c.day.matches(t.Day()) | |||
| 113 | + | dow := c.week.matches(int(t.Weekday())) | |||
| 114 | + | // Traditional cron treats restricted day-of-month and day-of-week fields | |||
| 115 | + | // as an OR. When either is *, the other field controls matching. | |||
| 116 | + | if !c.day.wildcard && !c.week.wildcard { | |||
| 117 | + | return dom || dow | |||
| 118 | + | } | |||
| 119 | + | return dom && dow | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (f field) matches(value int) bool { | |||
| 123 | + | return value >= 0 && value < len(f.values) && f.values[value] | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func parseField(raw string, min, max int, aliases map[string]int, sundaySeven bool) (field, error) { | |||
| 127 | + | out := field{values: make([]bool, max+1), wildcard: raw == "*"} | |||
| 128 | + | if raw == "" { | |||
| 129 | + | return field{}, fmt.Errorf("empty field") | |||
| 130 | + | } | |||
| 131 | + | for _, part := range strings.Split(raw, ",") { | |||
| 132 | + | if part == "" { | |||
| 133 | + | return field{}, fmt.Errorf("empty list item") | |||
| 134 | + | } | |||
| 135 | + | base, step, hasStep, err := splitStep(part) | |||
| 136 | + | if err != nil { | |||
| 137 | + | return field{}, err | |||
| 138 | + | } | |||
| 139 | + | start, end := min, max | |||
| 140 | + | switch { | |||
| 141 | + | case base == "*": | |||
| 142 | + | case strings.Contains(base, "-"): | |||
| 143 | + | pieces := strings.Split(base, "-") | |||
| 144 | + | if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" { | |||
| 145 | + | return field{}, fmt.Errorf("invalid range %q", base) | |||
| 146 | + | } | |||
| 147 | + | start, err = parseCronValue(pieces[0], min, max, aliases, sundaySeven) | |||
| 148 | + | if err != nil { | |||
| 149 | + | return field{}, err | |||
| 150 | + | } | |||
| 151 | + | end, err = parseCronValue(pieces[1], min, max, aliases, sundaySeven) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return field{}, err | |||
| 154 | + | } | |||
| 155 | + | if start > end { | |||
| 156 | + | return field{}, fmt.Errorf("range %q descends", base) | |||
| 157 | + | } | |||
| 158 | + | default: | |||
| 159 | + | if hasStep { | |||
| 160 | + | return field{}, fmt.Errorf("step requires * or a range") | |||
| 161 | + | } | |||
| 162 | + | start, err = parseCronValue(base, min, max, aliases, sundaySeven) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return field{}, err | |||
| 165 | + | } | |||
| 166 | + | end = start | |||
| 167 | + | } | |||
| 168 | + | if !hasStep { | |||
| 169 | + | step = 1 | |||
| 170 | + | } | |||
| 171 | + | for value := start; value <= end; value += step { | |||
| 172 | + | out.values[value] = true | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | return out, nil | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func splitStep(part string) (base string, step int, hasStep bool, err error) { | |||
| 179 | + | pieces := strings.Split(part, "/") | |||
| 180 | + | if len(pieces) == 1 { | |||
| 181 | + | return part, 0, false, nil | |||
| 182 | + | } | |||
| 183 | + | if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" { | |||
| 184 | + | return "", 0, false, fmt.Errorf("invalid step %q", part) | |||
| 185 | + | } | |||
| 186 | + | step, err = strconv.Atoi(pieces[1]) | |||
| 187 | + | if err != nil || step <= 0 { | |||
| 188 | + | return "", 0, false, fmt.Errorf("invalid step %q", part) | |||
| 189 | + | } | |||
| 190 | + | return pieces[0], step, true, nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func parseCronValue(raw string, min, max int, aliases map[string]int, sundaySeven bool) (int, error) { | |||
| 194 | + | if value, ok := aliases[raw]; ok { | |||
| 195 | + | return value, nil | |||
| 196 | + | } | |||
| 197 | + | value, err := strconv.Atoi(raw) | |||
| 198 | + | if err != nil { | |||
| 199 | + | return 0, fmt.Errorf("%q is not a number", raw) | |||
| 200 | + | } | |||
| 201 | + | if sundaySeven && value == 7 { | |||
| 202 | + | return 0, nil | |||
| 203 | + | } | |||
| 204 | + | if value < min || value > max { | |||
| 205 | + | return 0, fmt.Errorf("%d is outside %d-%d", value, min, max) | |||
| 206 | + | } | |||
| 207 | + | return value, nil | |||
| 208 | + | } | |||
A packages/agent/scheduler/cron_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | package scheduler | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestParseCronNext(t *testing.T) { | |
| 9 | + | loc := time.FixedZone("UTC+2", 2*60*60) | |
| 10 | + | schedule, err := ParseCron("*/15 9-10 * * 1-5", loc) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("ParseCron() error = %v", err) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | after := time.Date(2026, time.August, 14, 9, 14, 45, 0, loc) // Friday | |
| 16 | + | want := time.Date(2026, time.August, 14, 9, 15, 0, 0, loc) | |
| 17 | + | if got := schedule.Next(after); !got.Equal(want) { | |
| 18 | + | t.Fatalf("Next() = %v, want %v", got, want) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestParseCronDayOfMonthOrDayOfWeek(t *testing.T) { | |
| 23 | + | loc := time.UTC | |
| 24 | + | schedule, err := ParseCron("0 9 15 * 1", loc) | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatalf("ParseCron() error = %v", err) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Vixie cron semantics: when both fields are restricted, either match is | |
| 30 | + | // sufficient. Monday 2026-06-08 is not the fifteenth. | |
| 31 | + | after := time.Date(2026, time.June, 7, 9, 0, 0, 0, loc) | |
| 32 | + | want := time.Date(2026, time.June, 8, 9, 0, 0, 0, loc) | |
| 33 | + | if got := schedule.Next(after); !got.Equal(want) { | |
| 34 | + | t.Fatalf("Next() = %v, want %v", got, want) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestParseCronDSTTransitions(t *testing.T) { | |
| 39 | + | loc, err := time.LoadLocation("America/New_York") | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatalf("LoadLocation() error = %v", err) | |
| 42 | + | } | |
| 43 | + | fallBack, err := ParseCron("30 1 * * *", loc) | |
| 44 | + | if err != nil { | |
| 45 | + | t.Fatalf("ParseCron() error = %v", err) | |
| 46 | + | } | |
| 47 | + | first := fallBack.Next(time.Date(2026, time.November, 1, 0, 59, 0, 0, loc)) | |
| 48 | + | second := fallBack.Next(first) | |
| 49 | + | if first.Hour() != 1 || first.Minute() != 30 || second.Hour() != 1 || second.Minute() != 30 || first.Equal(second) { | |
| 50 | + | t.Fatalf("fall-back occurrences = %v, %v; want two distinct 01:30 occurrences", first, second) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | springForward, err := ParseCron("30 2 * * *", loc) | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatalf("ParseCron() error = %v", err) | |
| 56 | + | } | |
| 57 | + | got := springForward.Next(time.Date(2026, time.March, 8, 1, 59, 0, 0, loc)) | |
| 58 | + | want := time.Date(2026, time.March, 9, 2, 30, 0, 0, loc) | |
| 59 | + | if !got.Equal(want) { | |
| 60 | + | t.Fatalf("spring-forward Next() = %v, want %v", got, want) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestParseCronRejectsInvalidField(t *testing.T) { | |
| 65 | + | if _, err := ParseCron("61 * * * *", time.UTC); err == nil { | |
| 66 | + | t.Fatal("ParseCron() error = nil, want invalid minute error") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestParseCronSupportsMacro(t *testing.T) { | |
| 71 | + | schedule, err := ParseCron("@daily", time.UTC) | |
| 72 | + | if err != nil { | |
| 73 | + | t.Fatalf("ParseCron() error = %v", err) | |
| 74 | + | } | |
| 75 | + | got := schedule.Next(time.Date(2026, time.August, 14, 23, 59, 0, 0, time.UTC)) | |
| 76 | + | want := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC) | |
| 77 | + | if !got.Equal(want) { | |
| 78 | + | t.Fatalf("Next() = %v, want %v", got, want) | |
| 79 | + | } | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package scheduler | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestParseCronNext(t *testing.T) { | |||
| 9 | + | loc := time.FixedZone("UTC+2", 2*60*60) | |||
| 10 | + | schedule, err := ParseCron("*/15 9-10 * * 1-5", loc) | |||
| 11 | + | if err != nil { | |||
| 12 | + | t.Fatalf("ParseCron() error = %v", err) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | after := time.Date(2026, time.August, 14, 9, 14, 45, 0, loc) // Friday | |||
| 16 | + | want := time.Date(2026, time.August, 14, 9, 15, 0, 0, loc) | |||
| 17 | + | if got := schedule.Next(after); !got.Equal(want) { | |||
| 18 | + | t.Fatalf("Next() = %v, want %v", got, want) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestParseCronDayOfMonthOrDayOfWeek(t *testing.T) { | |||
| 23 | + | loc := time.UTC | |||
| 24 | + | schedule, err := ParseCron("0 9 15 * 1", loc) | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatalf("ParseCron() error = %v", err) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Vixie cron semantics: when both fields are restricted, either match is | |||
| 30 | + | // sufficient. Monday 2026-06-08 is not the fifteenth. | |||
| 31 | + | after := time.Date(2026, time.June, 7, 9, 0, 0, 0, loc) | |||
| 32 | + | want := time.Date(2026, time.June, 8, 9, 0, 0, 0, loc) | |||
| 33 | + | if got := schedule.Next(after); !got.Equal(want) { | |||
| 34 | + | t.Fatalf("Next() = %v, want %v", got, want) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestParseCronDSTTransitions(t *testing.T) { | |||
| 39 | + | loc, err := time.LoadLocation("America/New_York") | |||
| 40 | + | if err != nil { | |||
| 41 | + | t.Fatalf("LoadLocation() error = %v", err) | |||
| 42 | + | } | |||
| 43 | + | fallBack, err := ParseCron("30 1 * * *", loc) | |||
| 44 | + | if err != nil { | |||
| 45 | + | t.Fatalf("ParseCron() error = %v", err) | |||
| 46 | + | } | |||
| 47 | + | first := fallBack.Next(time.Date(2026, time.November, 1, 0, 59, 0, 0, loc)) | |||
| 48 | + | second := fallBack.Next(first) | |||
| 49 | + | if first.Hour() != 1 || first.Minute() != 30 || second.Hour() != 1 || second.Minute() != 30 || first.Equal(second) { | |||
| 50 | + | t.Fatalf("fall-back occurrences = %v, %v; want two distinct 01:30 occurrences", first, second) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | springForward, err := ParseCron("30 2 * * *", loc) | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatalf("ParseCron() error = %v", err) | |||
| 56 | + | } | |||
| 57 | + | got := springForward.Next(time.Date(2026, time.March, 8, 1, 59, 0, 0, loc)) | |||
| 58 | + | want := time.Date(2026, time.March, 9, 2, 30, 0, 0, loc) | |||
| 59 | + | if !got.Equal(want) { | |||
| 60 | + | t.Fatalf("spring-forward Next() = %v, want %v", got, want) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestParseCronRejectsInvalidField(t *testing.T) { | |||
| 65 | + | if _, err := ParseCron("61 * * * *", time.UTC); err == nil { | |||
| 66 | + | t.Fatal("ParseCron() error = nil, want invalid minute error") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestParseCronSupportsMacro(t *testing.T) { | |||
| 71 | + | schedule, err := ParseCron("@daily", time.UTC) | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Fatalf("ParseCron() error = %v", err) | |||
| 74 | + | } | |||
| 75 | + | got := schedule.Next(time.Date(2026, time.August, 14, 23, 59, 0, 0, time.UTC)) | |||
| 76 | + | want := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC) | |||
| 77 | + | if !got.Equal(want) { | |||
| 78 | + | t.Fatalf("Next() = %v, want %v", got, want) | |||
| 79 | + | } | |||
| 80 | + | } | |||
A packages/agent/scheduler/scheduler.go +272 -0
| @@ -0,0 +1,272 @@ | |||
| 1 | + | package scheduler | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sort" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/google/uuid" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Task is one recurring prompt owned by a session branch. Tasks are kept only | |
| 15 | + | // in process memory; callers are responsible for stopping the engine with the | |
| 16 | + | // host process. | |
| 17 | + | type Task struct { | |
| 18 | + | ID string | |
| 19 | + | SessionID string | |
| 20 | + | Cron string | |
| 21 | + | Timezone string | |
| 22 | + | Message string | |
| 23 | + | CreatedAt time.Time | |
| 24 | + | NextRun time.Time | |
| 25 | + | LastRun time.Time | |
| 26 | + | RunCount int | |
| 27 | + | LastError string | |
| 28 | + | ||
| 29 | + | schedule Cron | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // NewTaskInput describes a task to add to an Engine. Location is captured at | |
| 33 | + | // creation rather than consulting time.Local during later evaluations. | |
| 34 | + | type NewTaskInput struct { | |
| 35 | + | SessionID string | |
| 36 | + | Cron string | |
| 37 | + | Message string | |
| 38 | + | Location *time.Location | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Engine serializes task scheduling and execution through one timer loop. Its | |
| 42 | + | // callback runs outside the engine lock but is intentionally called one task | |
| 43 | + | // at a time so a session cannot receive overlapping scheduled turns. | |
| 44 | + | type Engine struct { | |
| 45 | + | mu sync.Mutex | |
| 46 | + | now func() time.Time | |
| 47 | + | tasks map[string]Task | |
| 48 | + | wake chan struct{} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // NewEngine creates an empty scheduler. now is injectable for tests; nil uses | |
| 52 | + | // time.Now. | |
| 53 | + | func NewEngine(now func() time.Time) *Engine { | |
| 54 | + | if now == nil { | |
| 55 | + | now = time.Now | |
| 56 | + | } | |
| 57 | + | return &Engine{now: now, tasks: make(map[string]Task), wake: make(chan struct{}, 1)} | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Add validates and records a new recurring task. | |
| 61 | + | func (e *Engine) Add(input NewTaskInput) (Task, error) { | |
| 62 | + | if e == nil { | |
| 63 | + | return Task{}, fmt.Errorf("scheduler is not initialized") | |
| 64 | + | } | |
| 65 | + | input.SessionID = strings.TrimSpace(input.SessionID) | |
| 66 | + | input.Cron = strings.TrimSpace(input.Cron) | |
| 67 | + | input.Message = strings.TrimSpace(input.Message) | |
| 68 | + | if input.SessionID == "" { | |
| 69 | + | return Task{}, fmt.Errorf("session ID is required") | |
| 70 | + | } | |
| 71 | + | if input.Message == "" { | |
| 72 | + | return Task{}, fmt.Errorf("scheduled message is required") | |
| 73 | + | } | |
| 74 | + | if input.Location == nil { | |
| 75 | + | return Task{}, fmt.Errorf("schedule timezone is required") | |
| 76 | + | } | |
| 77 | + | schedule, err := ParseCron(input.Cron, input.Location) | |
| 78 | + | if err != nil { | |
| 79 | + | return Task{}, err | |
| 80 | + | } | |
| 81 | + | now := e.now() | |
| 82 | + | next := schedule.Next(now) | |
| 83 | + | if next.IsZero() { | |
| 84 | + | return Task{}, fmt.Errorf("cron has no occurrence in the next eight years") | |
| 85 | + | } | |
| 86 | + | task := Task{ | |
| 87 | + | ID: uuid.NewString(), | |
| 88 | + | SessionID: input.SessionID, | |
| 89 | + | Cron: input.Cron, | |
| 90 | + | Timezone: input.Location.String(), | |
| 91 | + | Message: input.Message, | |
| 92 | + | CreatedAt: now, | |
| 93 | + | NextRun: next, | |
| 94 | + | schedule: schedule, | |
| 95 | + | } | |
| 96 | + | e.mu.Lock() | |
| 97 | + | e.tasks[task.ID] = task | |
| 98 | + | e.mu.Unlock() | |
| 99 | + | e.signalWake() | |
| 100 | + | return task, nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Cancel removes a scheduled task. It returns false when the ID is unknown. | |
| 104 | + | func (e *Engine) Cancel(id string) bool { | |
| 105 | + | if e == nil { | |
| 106 | + | return false | |
| 107 | + | } | |
| 108 | + | e.mu.Lock() | |
| 109 | + | _, ok := e.tasks[id] | |
| 110 | + | if ok { | |
| 111 | + | delete(e.tasks, id) | |
| 112 | + | } | |
| 113 | + | e.mu.Unlock() | |
| 114 | + | if ok { | |
| 115 | + | e.signalWake() | |
| 116 | + | } | |
| 117 | + | return ok | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Get returns a task snapshot by ID. | |
| 121 | + | func (e *Engine) Get(id string) (Task, bool) { | |
| 122 | + | if e == nil { | |
| 123 | + | return Task{}, false | |
| 124 | + | } | |
| 125 | + | e.mu.Lock() | |
| 126 | + | defer e.mu.Unlock() | |
| 127 | + | task, ok := e.tasks[id] | |
| 128 | + | return task, ok | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // List returns snapshots ordered by next execution and then ID. | |
| 132 | + | func (e *Engine) List() []Task { | |
| 133 | + | if e == nil { | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | e.mu.Lock() | |
| 137 | + | tasks := make([]Task, 0, len(e.tasks)) | |
| 138 | + | for _, task := range e.tasks { | |
| 139 | + | tasks = append(tasks, task) | |
| 140 | + | } | |
| 141 | + | e.mu.Unlock() | |
| 142 | + | sort.Slice(tasks, func(i, j int) bool { | |
| 143 | + | if tasks[i].NextRun.Equal(tasks[j].NextRun) { | |
| 144 | + | return tasks[i].ID < tasks[j].ID | |
| 145 | + | } | |
| 146 | + | return tasks[i].NextRun.Before(tasks[j].NextRun) | |
| 147 | + | }) | |
| 148 | + | return tasks | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // Run waits for due tasks until ctx is cancelled. The executor is called | |
| 152 | + | // synchronously for each due task, which prevents a scheduled task from | |
| 153 | + | // overlapping a prior scheduled turn. | |
| 154 | + | func (e *Engine) Run(ctx context.Context, executor func(Task) error) error { | |
| 155 | + | if e == nil { | |
| 156 | + | return fmt.Errorf("scheduler is not initialized") | |
| 157 | + | } | |
| 158 | + | if executor == nil { | |
| 159 | + | return fmt.Errorf("scheduler executor is required") | |
| 160 | + | } | |
| 161 | + | for { | |
| 162 | + | now := e.now() | |
| 163 | + | e.dispatchDue(now, executor) | |
| 164 | + | next, ok := e.nextRun() | |
| 165 | + | if !ok { | |
| 166 | + | select { | |
| 167 | + | case <-ctx.Done(): | |
| 168 | + | return nil | |
| 169 | + | case <-e.wake: | |
| 170 | + | } | |
| 171 | + | continue | |
| 172 | + | } | |
| 173 | + | wait := time.Until(next) | |
| 174 | + | if wait < 0 { | |
| 175 | + | wait = 0 | |
| 176 | + | } | |
| 177 | + | timer := time.NewTimer(wait) | |
| 178 | + | select { | |
| 179 | + | case <-ctx.Done(): | |
| 180 | + | if !timer.Stop() { | |
| 181 | + | select { | |
| 182 | + | case <-timer.C: | |
| 183 | + | default: | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | return nil | |
| 187 | + | case <-e.wake: | |
| 188 | + | if !timer.Stop() { | |
| 189 | + | select { | |
| 190 | + | case <-timer.C: | |
| 191 | + | default: | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | case <-timer.C: | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // dispatchDue executes all tasks due at or before now. Run is its only | |
| 200 | + | // production caller; keeping it private preserves Engine's single executor | |
| 201 | + | // ownership and prevents concurrent duplicate dispatches. | |
| 202 | + | func (e *Engine) dispatchDue(now time.Time, executor func(Task) error) { | |
| 203 | + | if e == nil || executor == nil { | |
| 204 | + | return | |
| 205 | + | } | |
| 206 | + | for _, task := range e.takeDue(now) { | |
| 207 | + | err := executor(task) | |
| 208 | + | e.finish(task.ID, task.NextRun, err) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (e *Engine) takeDue(now time.Time) []Task { | |
| 213 | + | e.mu.Lock() | |
| 214 | + | defer e.mu.Unlock() | |
| 215 | + | due := make([]Task, 0) | |
| 216 | + | for _, task := range e.tasks { | |
| 217 | + | if !task.NextRun.After(now) { | |
| 218 | + | due = append(due, task) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | sort.Slice(due, func(i, j int) bool { | |
| 222 | + | if due[i].NextRun.Equal(due[j].NextRun) { | |
| 223 | + | return due[i].ID < due[j].ID | |
| 224 | + | } | |
| 225 | + | return due[i].NextRun.Before(due[j].NextRun) | |
| 226 | + | }) | |
| 227 | + | return due | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (e *Engine) finish(id string, scheduledFor time.Time, err error) { | |
| 231 | + | e.mu.Lock() | |
| 232 | + | defer e.mu.Unlock() | |
| 233 | + | task, ok := e.tasks[id] | |
| 234 | + | if !ok || !task.NextRun.Equal(scheduledFor) { | |
| 235 | + | return // cancelled or replaced while the executor ran | |
| 236 | + | } | |
| 237 | + | task.LastRun = scheduledFor | |
| 238 | + | task.RunCount++ | |
| 239 | + | if err != nil { | |
| 240 | + | task.LastError = err.Error() | |
| 241 | + | } else { | |
| 242 | + | task.LastError = "" | |
| 243 | + | } | |
| 244 | + | // Calculate from completion time, rather than repeatedly from the prior | |
| 245 | + | // deadline, so a sleeping machine or a slow task never creates a burst of | |
| 246 | + | // stale catch-up executions. | |
| 247 | + | task.NextRun = task.schedule.Next(e.now()) | |
| 248 | + | if task.NextRun.IsZero() { | |
| 249 | + | delete(e.tasks, id) | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | e.tasks[id] = task | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func (e *Engine) nextRun() (time.Time, bool) { | |
| 256 | + | e.mu.Lock() | |
| 257 | + | defer e.mu.Unlock() | |
| 258 | + | var next time.Time | |
| 259 | + | for _, task := range e.tasks { | |
| 260 | + | if next.IsZero() || task.NextRun.Before(next) { | |
| 261 | + | next = task.NextRun | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | return next, !next.IsZero() | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (e *Engine) signalWake() { | |
| 268 | + | select { | |
| 269 | + | case e.wake <- struct{}{}: | |
| 270 | + | default: | |
| 271 | + | } | |
| 272 | + | } | |
| @@ -0,0 +1,272 @@ | |||||
| 1 | + | package scheduler | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/google/uuid" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Task is one recurring prompt owned by a session branch. Tasks are kept only | |||
| 15 | + | // in process memory; callers are responsible for stopping the engine with the | |||
| 16 | + | // host process. | |||
| 17 | + | type Task struct { | |||
| 18 | + | ID string | |||
| 19 | + | SessionID string | |||
| 20 | + | Cron string | |||
| 21 | + | Timezone string | |||
| 22 | + | Message string | |||
| 23 | + | CreatedAt time.Time | |||
| 24 | + | NextRun time.Time | |||
| 25 | + | LastRun time.Time | |||
| 26 | + | RunCount int | |||
| 27 | + | LastError string | |||
| 28 | + | ||||
| 29 | + | schedule Cron | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // NewTaskInput describes a task to add to an Engine. Location is captured at | |||
| 33 | + | // creation rather than consulting time.Local during later evaluations. | |||
| 34 | + | type NewTaskInput struct { | |||
| 35 | + | SessionID string | |||
| 36 | + | Cron string | |||
| 37 | + | Message string | |||
| 38 | + | Location *time.Location | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Engine serializes task scheduling and execution through one timer loop. Its | |||
| 42 | + | // callback runs outside the engine lock but is intentionally called one task | |||
| 43 | + | // at a time so a session cannot receive overlapping scheduled turns. | |||
| 44 | + | type Engine struct { | |||
| 45 | + | mu sync.Mutex | |||
| 46 | + | now func() time.Time | |||
| 47 | + | tasks map[string]Task | |||
| 48 | + | wake chan struct{} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // NewEngine creates an empty scheduler. now is injectable for tests; nil uses | |||
| 52 | + | // time.Now. | |||
| 53 | + | func NewEngine(now func() time.Time) *Engine { | |||
| 54 | + | if now == nil { | |||
| 55 | + | now = time.Now | |||
| 56 | + | } | |||
| 57 | + | return &Engine{now: now, tasks: make(map[string]Task), wake: make(chan struct{}, 1)} | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Add validates and records a new recurring task. | |||
| 61 | + | func (e *Engine) Add(input NewTaskInput) (Task, error) { | |||
| 62 | + | if e == nil { | |||
| 63 | + | return Task{}, fmt.Errorf("scheduler is not initialized") | |||
| 64 | + | } | |||
| 65 | + | input.SessionID = strings.TrimSpace(input.SessionID) | |||
| 66 | + | input.Cron = strings.TrimSpace(input.Cron) | |||
| 67 | + | input.Message = strings.TrimSpace(input.Message) | |||
| 68 | + | if input.SessionID == "" { | |||
| 69 | + | return Task{}, fmt.Errorf("session ID is required") | |||
| 70 | + | } | |||
| 71 | + | if input.Message == "" { | |||
| 72 | + | return Task{}, fmt.Errorf("scheduled message is required") | |||
| 73 | + | } | |||
| 74 | + | if input.Location == nil { | |||
| 75 | + | return Task{}, fmt.Errorf("schedule timezone is required") | |||
| 76 | + | } | |||
| 77 | + | schedule, err := ParseCron(input.Cron, input.Location) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return Task{}, err | |||
| 80 | + | } | |||
| 81 | + | now := e.now() | |||
| 82 | + | next := schedule.Next(now) | |||
| 83 | + | if next.IsZero() { | |||
| 84 | + | return Task{}, fmt.Errorf("cron has no occurrence in the next eight years") | |||
| 85 | + | } | |||
| 86 | + | task := Task{ | |||
| 87 | + | ID: uuid.NewString(), | |||
| 88 | + | SessionID: input.SessionID, | |||
| 89 | + | Cron: input.Cron, | |||
| 90 | + | Timezone: input.Location.String(), | |||
| 91 | + | Message: input.Message, | |||
| 92 | + | CreatedAt: now, | |||
| 93 | + | NextRun: next, | |||
| 94 | + | schedule: schedule, | |||
| 95 | + | } | |||
| 96 | + | e.mu.Lock() | |||
| 97 | + | e.tasks[task.ID] = task | |||
| 98 | + | e.mu.Unlock() | |||
| 99 | + | e.signalWake() | |||
| 100 | + | return task, nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Cancel removes a scheduled task. It returns false when the ID is unknown. | |||
| 104 | + | func (e *Engine) Cancel(id string) bool { | |||
| 105 | + | if e == nil { | |||
| 106 | + | return false | |||
| 107 | + | } | |||
| 108 | + | e.mu.Lock() | |||
| 109 | + | _, ok := e.tasks[id] | |||
| 110 | + | if ok { | |||
| 111 | + | delete(e.tasks, id) | |||
| 112 | + | } | |||
| 113 | + | e.mu.Unlock() | |||
| 114 | + | if ok { | |||
| 115 | + | e.signalWake() | |||
| 116 | + | } | |||
| 117 | + | return ok | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // Get returns a task snapshot by ID. | |||
| 121 | + | func (e *Engine) Get(id string) (Task, bool) { | |||
| 122 | + | if e == nil { | |||
| 123 | + | return Task{}, false | |||
| 124 | + | } | |||
| 125 | + | e.mu.Lock() | |||
| 126 | + | defer e.mu.Unlock() | |||
| 127 | + | task, ok := e.tasks[id] | |||
| 128 | + | return task, ok | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // List returns snapshots ordered by next execution and then ID. | |||
| 132 | + | func (e *Engine) List() []Task { | |||
| 133 | + | if e == nil { | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | e.mu.Lock() | |||
| 137 | + | tasks := make([]Task, 0, len(e.tasks)) | |||
| 138 | + | for _, task := range e.tasks { | |||
| 139 | + | tasks = append(tasks, task) | |||
| 140 | + | } | |||
| 141 | + | e.mu.Unlock() | |||
| 142 | + | sort.Slice(tasks, func(i, j int) bool { | |||
| 143 | + | if tasks[i].NextRun.Equal(tasks[j].NextRun) { | |||
| 144 | + | return tasks[i].ID < tasks[j].ID | |||
| 145 | + | } | |||
| 146 | + | return tasks[i].NextRun.Before(tasks[j].NextRun) | |||
| 147 | + | }) | |||
| 148 | + | return tasks | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // Run waits for due tasks until ctx is cancelled. The executor is called | |||
| 152 | + | // synchronously for each due task, which prevents a scheduled task from | |||
| 153 | + | // overlapping a prior scheduled turn. | |||
| 154 | + | func (e *Engine) Run(ctx context.Context, executor func(Task) error) error { | |||
| 155 | + | if e == nil { | |||
| 156 | + | return fmt.Errorf("scheduler is not initialized") | |||
| 157 | + | } | |||
| 158 | + | if executor == nil { | |||
| 159 | + | return fmt.Errorf("scheduler executor is required") | |||
| 160 | + | } | |||
| 161 | + | for { | |||
| 162 | + | now := e.now() | |||
| 163 | + | e.dispatchDue(now, executor) | |||
| 164 | + | next, ok := e.nextRun() | |||
| 165 | + | if !ok { | |||
| 166 | + | select { | |||
| 167 | + | case <-ctx.Done(): | |||
| 168 | + | return nil | |||
| 169 | + | case <-e.wake: | |||
| 170 | + | } | |||
| 171 | + | continue | |||
| 172 | + | } | |||
| 173 | + | wait := time.Until(next) | |||
| 174 | + | if wait < 0 { | |||
| 175 | + | wait = 0 | |||
| 176 | + | } | |||
| 177 | + | timer := time.NewTimer(wait) | |||
| 178 | + | select { | |||
| 179 | + | case <-ctx.Done(): | |||
| 180 | + | if !timer.Stop() { | |||
| 181 | + | select { | |||
| 182 | + | case <-timer.C: | |||
| 183 | + | default: | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | return nil | |||
| 187 | + | case <-e.wake: | |||
| 188 | + | if !timer.Stop() { | |||
| 189 | + | select { | |||
| 190 | + | case <-timer.C: | |||
| 191 | + | default: | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | case <-timer.C: | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // dispatchDue executes all tasks due at or before now. Run is its only | |||
| 200 | + | // production caller; keeping it private preserves Engine's single executor | |||
| 201 | + | // ownership and prevents concurrent duplicate dispatches. | |||
| 202 | + | func (e *Engine) dispatchDue(now time.Time, executor func(Task) error) { | |||
| 203 | + | if e == nil || executor == nil { | |||
| 204 | + | return | |||
| 205 | + | } | |||
| 206 | + | for _, task := range e.takeDue(now) { | |||
| 207 | + | err := executor(task) | |||
| 208 | + | e.finish(task.ID, task.NextRun, err) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func (e *Engine) takeDue(now time.Time) []Task { | |||
| 213 | + | e.mu.Lock() | |||
| 214 | + | defer e.mu.Unlock() | |||
| 215 | + | due := make([]Task, 0) | |||
| 216 | + | for _, task := range e.tasks { | |||
| 217 | + | if !task.NextRun.After(now) { | |||
| 218 | + | due = append(due, task) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | sort.Slice(due, func(i, j int) bool { | |||
| 222 | + | if due[i].NextRun.Equal(due[j].NextRun) { | |||
| 223 | + | return due[i].ID < due[j].ID | |||
| 224 | + | } | |||
| 225 | + | return due[i].NextRun.Before(due[j].NextRun) | |||
| 226 | + | }) | |||
| 227 | + | return due | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (e *Engine) finish(id string, scheduledFor time.Time, err error) { | |||
| 231 | + | e.mu.Lock() | |||
| 232 | + | defer e.mu.Unlock() | |||
| 233 | + | task, ok := e.tasks[id] | |||
| 234 | + | if !ok || !task.NextRun.Equal(scheduledFor) { | |||
| 235 | + | return // cancelled or replaced while the executor ran | |||
| 236 | + | } | |||
| 237 | + | task.LastRun = scheduledFor | |||
| 238 | + | task.RunCount++ | |||
| 239 | + | if err != nil { | |||
| 240 | + | task.LastError = err.Error() | |||
| 241 | + | } else { | |||
| 242 | + | task.LastError = "" | |||
| 243 | + | } | |||
| 244 | + | // Calculate from completion time, rather than repeatedly from the prior | |||
| 245 | + | // deadline, so a sleeping machine or a slow task never creates a burst of | |||
| 246 | + | // stale catch-up executions. | |||
| 247 | + | task.NextRun = task.schedule.Next(e.now()) | |||
| 248 | + | if task.NextRun.IsZero() { | |||
| 249 | + | delete(e.tasks, id) | |||
| 250 | + | return | |||
| 251 | + | } | |||
| 252 | + | e.tasks[id] = task | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func (e *Engine) nextRun() (time.Time, bool) { | |||
| 256 | + | e.mu.Lock() | |||
| 257 | + | defer e.mu.Unlock() | |||
| 258 | + | var next time.Time | |||
| 259 | + | for _, task := range e.tasks { | |||
| 260 | + | if next.IsZero() || task.NextRun.Before(next) { | |||
| 261 | + | next = task.NextRun | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | return next, !next.IsZero() | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (e *Engine) signalWake() { | |||
| 268 | + | select { | |||
| 269 | + | case e.wake <- struct{}{}: | |||
| 270 | + | default: | |||
| 271 | + | } | |||
| 272 | + | } | |||
A packages/agent/scheduler/scheduler_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package scheduler | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestEngineDispatchDueReschedulesTask(t *testing.T) { | |
| 11 | + | loc := time.UTC | |
| 12 | + | now := time.Date(2026, time.August, 15, 12, 0, 0, 0, loc) | |
| 13 | + | engine := NewEngine(func() time.Time { return now }) | |
| 14 | + | created, err := engine.Add(NewTaskInput{ | |
| 15 | + | SessionID: "session-1", | |
| 16 | + | Cron: "* * * * *", | |
| 17 | + | Message: "check status", | |
| 18 | + | Location: loc, | |
| 19 | + | }) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatalf("Add() error = %v", err) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | var executed []Task | |
| 25 | + | now = now.Add(time.Minute) | |
| 26 | + | engine.dispatchDue(now, func(task Task) error { | |
| 27 | + | executed = append(executed, task) | |
| 28 | + | return nil | |
| 29 | + | }) | |
| 30 | + | if len(executed) != 1 || executed[0].ID != created.ID { | |
| 31 | + | t.Fatalf("executed = %#v, want task %q", executed, created.ID) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | tasks := engine.List() | |
| 35 | + | if len(tasks) != 1 { | |
| 36 | + | t.Fatalf("List() length = %d, want 1", len(tasks)) | |
| 37 | + | } | |
| 38 | + | if want := now.Add(time.Minute); !tasks[0].NextRun.Equal(want) { | |
| 39 | + | t.Fatalf("NextRun = %v, want %v", tasks[0].NextRun, want) | |
| 40 | + | } | |
| 41 | + | if !tasks[0].LastRun.Equal(now) || tasks[0].RunCount != 1 { | |
| 42 | + | t.Fatalf("task after execution = %#v", tasks[0]) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestEngineRecordsExecutionFailure(t *testing.T) { | |
| 47 | + | now := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC) | |
| 48 | + | engine := NewEngine(func() time.Time { return now }) | |
| 49 | + | task, err := engine.Add(NewTaskInput{SessionID: "session-1", Cron: "* * * * *", Message: "run", Location: time.UTC}) | |
| 50 | + | if err != nil { | |
| 51 | + | t.Fatalf("Add() error = %v", err) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | engine.dispatchDue(now.Add(time.Minute), func(Task) error { return errors.New("provider unavailable") }) | |
| 55 | + | got, ok := engine.Get(task.ID) | |
| 56 | + | if !ok { | |
| 57 | + | t.Fatal("Get() did not return scheduled task") | |
| 58 | + | } | |
| 59 | + | if got.LastError != "provider unavailable" { | |
| 60 | + | t.Fatalf("LastError = %q", got.LastError) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestEngineCancel(t *testing.T) { | |
| 65 | + | engine := NewEngine(nil) | |
| 66 | + | task, err := engine.Add(NewTaskInput{SessionID: "session-1", Cron: "@hourly", Message: "run", Location: time.UTC}) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatalf("Add() error = %v", err) | |
| 69 | + | } | |
| 70 | + | if !engine.Cancel(task.ID) { | |
| 71 | + | t.Fatal("Cancel() = false, want true") | |
| 72 | + | } | |
| 73 | + | if _, ok := engine.Get(task.ID); ok { | |
| 74 | + | t.Fatal("Get() found cancelled task") | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestEngineRunStopsWithContext(t *testing.T) { | |
| 79 | + | engine := NewEngine(nil) | |
| 80 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 81 | + | cancel() | |
| 82 | + | if err := engine.Run(ctx, func(Task) error { return nil }); err != nil { | |
| 83 | + | t.Fatalf("Run() error = %v", err) | |
| 84 | + | } | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package scheduler | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestEngineDispatchDueReschedulesTask(t *testing.T) { | |||
| 11 | + | loc := time.UTC | |||
| 12 | + | now := time.Date(2026, time.August, 15, 12, 0, 0, 0, loc) | |||
| 13 | + | engine := NewEngine(func() time.Time { return now }) | |||
| 14 | + | created, err := engine.Add(NewTaskInput{ | |||
| 15 | + | SessionID: "session-1", | |||
| 16 | + | Cron: "* * * * *", | |||
| 17 | + | Message: "check status", | |||
| 18 | + | Location: loc, | |||
| 19 | + | }) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("Add() error = %v", err) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | var executed []Task | |||
| 25 | + | now = now.Add(time.Minute) | |||
| 26 | + | engine.dispatchDue(now, func(task Task) error { | |||
| 27 | + | executed = append(executed, task) | |||
| 28 | + | return nil | |||
| 29 | + | }) | |||
| 30 | + | if len(executed) != 1 || executed[0].ID != created.ID { | |||
| 31 | + | t.Fatalf("executed = %#v, want task %q", executed, created.ID) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | tasks := engine.List() | |||
| 35 | + | if len(tasks) != 1 { | |||
| 36 | + | t.Fatalf("List() length = %d, want 1", len(tasks)) | |||
| 37 | + | } | |||
| 38 | + | if want := now.Add(time.Minute); !tasks[0].NextRun.Equal(want) { | |||
| 39 | + | t.Fatalf("NextRun = %v, want %v", tasks[0].NextRun, want) | |||
| 40 | + | } | |||
| 41 | + | if !tasks[0].LastRun.Equal(now) || tasks[0].RunCount != 1 { | |||
| 42 | + | t.Fatalf("task after execution = %#v", tasks[0]) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestEngineRecordsExecutionFailure(t *testing.T) { | |||
| 47 | + | now := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC) | |||
| 48 | + | engine := NewEngine(func() time.Time { return now }) | |||
| 49 | + | task, err := engine.Add(NewTaskInput{SessionID: "session-1", Cron: "* * * * *", Message: "run", Location: time.UTC}) | |||
| 50 | + | if err != nil { | |||
| 51 | + | t.Fatalf("Add() error = %v", err) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | engine.dispatchDue(now.Add(time.Minute), func(Task) error { return errors.New("provider unavailable") }) | |||
| 55 | + | got, ok := engine.Get(task.ID) | |||
| 56 | + | if !ok { | |||
| 57 | + | t.Fatal("Get() did not return scheduled task") | |||
| 58 | + | } | |||
| 59 | + | if got.LastError != "provider unavailable" { | |||
| 60 | + | t.Fatalf("LastError = %q", got.LastError) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestEngineCancel(t *testing.T) { | |||
| 65 | + | engine := NewEngine(nil) | |||
| 66 | + | task, err := engine.Add(NewTaskInput{SessionID: "session-1", Cron: "@hourly", Message: "run", Location: time.UTC}) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatalf("Add() error = %v", err) | |||
| 69 | + | } | |||
| 70 | + | if !engine.Cancel(task.ID) { | |||
| 71 | + | t.Fatal("Cancel() = false, want true") | |||
| 72 | + | } | |||
| 73 | + | if _, ok := engine.Get(task.ID); ok { | |||
| 74 | + | t.Fatal("Get() found cancelled task") | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestEngineRunStopsWithContext(t *testing.T) { | |||
| 79 | + | engine := NewEngine(nil) | |||
| 80 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 81 | + | cancel() | |||
| 82 | + | if err := engine.Run(ctx, func(Task) error { return nil }); err != nil { | |||
| 83 | + | t.Fatalf("Run() error = %v", err) | |||
| 84 | + | } | |||
| 85 | + | } | |||
A packages/agent/scheduler_runtime.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/agent/extensions" | |
| 11 | + | "github.com/bnema/zut/packages/agent/scheduler" | |
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 13 | + | "github.com/bnema/zut/packages/provider" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // runScheduledSession reconstructs an inactive session in the current zut | |
| 17 | + | // process and appends the resulting turn to its existing transcript. The | |
| 18 | + | // caller serializes this with session transitions before invoking it. | |
| 19 | + | func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, base Resolved, extMgr *extensions.Manager, prepareRegistry func(core.Registry) core.Registry) error { | |
| 20 | + | path, err := core.FindManagedSessionByID(ctx, filepath.Join(ZutHome(), "sessions"), task.SessionID) | |
| 21 | + | if err != nil { | |
| 22 | + | return fmt.Errorf("find scheduled session %q: %w", task.SessionID, err) | |
| 23 | + | } | |
| 24 | + | if path == "" { | |
| 25 | + | return fmt.Errorf("scheduled session %q no longer exists", task.SessionID) | |
| 26 | + | } | |
| 27 | + | sess, messages, err := core.OpenSession(path) | |
| 28 | + | if err != nil { | |
| 29 | + | return fmt.Errorf("open scheduled session: %w", err) | |
| 30 | + | } | |
| 31 | + | defer sess.Close() | |
| 32 | + | ||
| 33 | + | providerName := strings.TrimSpace(sess.Meta.Provider) | |
| 34 | + | if providerName == "" { | |
| 35 | + | providerName = base.Provider | |
| 36 | + | } | |
| 37 | + | model := strings.TrimSpace(sess.Meta.Model) | |
| 38 | + | if model == "" { | |
| 39 | + | model = base.Model | |
| 40 | + | } | |
| 41 | + | ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry) | |
| 42 | + | if err != nil { | |
| 43 | + | return fmt.Errorf("build scheduled session agent: %w", err) | |
| 44 | + | } | |
| 45 | + | ag.SetMessages(trimMessagesForResume(messages, 100)) | |
| 46 | + | if cumulative, lastTurn, usageErr := core.SessionUsageDetail(path); usageErr == nil { | |
| 47 | + | ag.SeedCost(cumulative) | |
| 48 | + | ag.SeedLastTurnUsage(lastTurn) | |
| 49 | + | } | |
| 50 | + | ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset) | |
| 51 | + | var appendMu sync.Mutex | |
| 52 | + | var appendErr error | |
| 53 | + | recordAppendError := func(err error) { | |
| 54 | + | if err == nil { | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | appendMu.Lock() | |
| 58 | + | defer appendMu.Unlock() | |
| 59 | + | if appendErr == nil { | |
| 60 | + | appendErr = err | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ag.OnMessageAppended = func(message provider.Message) { | |
| 64 | + | recordAppendError(sess.AppendMessage(message)) | |
| 65 | + | } | |
| 66 | + | ag.OnUsage = func(cumulative provider.Usage) { | |
| 67 | + | recordAppendError(sess.AppendUsage(cumulative, cumulative)) | |
| 68 | + | } | |
| 69 | + | ag.OnTranscriptCompacted = func(messages []provider.Message) { | |
| 70 | + | recordAppendError(sess.AppendCompaction(messages)) | |
| 71 | + | } | |
| 72 | + | if err := ag.Prompt(ctx, task.Message, nil, func(core.AgentEvent) {}); err != nil { | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | appendMu.Lock() | |
| 76 | + | err = appendErr | |
| 77 | + | appendMu.Unlock() | |
| 78 | + | if err != nil { | |
| 79 | + | return fmt.Errorf("append scheduled transcript: %w", err) | |
| 80 | + | } | |
| 81 | + | if err := sess.Flush(); err != nil { | |
| 82 | + | return fmt.Errorf("flush scheduled session: %w", err) | |
| 83 | + | } | |
| 84 | + | return nil | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/agent/extensions" | |||
| 11 | + | "github.com/bnema/zut/packages/agent/scheduler" | |||
| 12 | + | "github.com/bnema/zut/packages/core" | |||
| 13 | + | "github.com/bnema/zut/packages/provider" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // runScheduledSession reconstructs an inactive session in the current zut | |||
| 17 | + | // process and appends the resulting turn to its existing transcript. The | |||
| 18 | + | // caller serializes this with session transitions before invoking it. | |||
| 19 | + | func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, base Resolved, extMgr *extensions.Manager, prepareRegistry func(core.Registry) core.Registry) error { | |||
| 20 | + | path, err := core.FindManagedSessionByID(ctx, filepath.Join(ZutHome(), "sessions"), task.SessionID) | |||
| 21 | + | if err != nil { | |||
| 22 | + | return fmt.Errorf("find scheduled session %q: %w", task.SessionID, err) | |||
| 23 | + | } | |||
| 24 | + | if path == "" { | |||
| 25 | + | return fmt.Errorf("scheduled session %q no longer exists", task.SessionID) | |||
| 26 | + | } | |||
| 27 | + | sess, messages, err := core.OpenSession(path) | |||
| 28 | + | if err != nil { | |||
| 29 | + | return fmt.Errorf("open scheduled session: %w", err) | |||
| 30 | + | } | |||
| 31 | + | defer sess.Close() | |||
| 32 | + | ||||
| 33 | + | providerName := strings.TrimSpace(sess.Meta.Provider) | |||
| 34 | + | if providerName == "" { | |||
| 35 | + | providerName = base.Provider | |||
| 36 | + | } | |||
| 37 | + | model := strings.TrimSpace(sess.Meta.Model) | |||
| 38 | + | if model == "" { | |||
| 39 | + | model = base.Model | |||
| 40 | + | } | |||
| 41 | + | ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return fmt.Errorf("build scheduled session agent: %w", err) | |||
| 44 | + | } | |||
| 45 | + | ag.SetMessages(trimMessagesForResume(messages, 100)) | |||
| 46 | + | if cumulative, lastTurn, usageErr := core.SessionUsageDetail(path); usageErr == nil { | |||
| 47 | + | ag.SeedCost(cumulative) | |||
| 48 | + | ag.SeedLastTurnUsage(lastTurn) | |||
| 49 | + | } | |||
| 50 | + | ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset) | |||
| 51 | + | var appendMu sync.Mutex | |||
| 52 | + | var appendErr error | |||
| 53 | + | recordAppendError := func(err error) { | |||
| 54 | + | if err == nil { | |||
| 55 | + | return | |||
| 56 | + | } | |||
| 57 | + | appendMu.Lock() | |||
| 58 | + | defer appendMu.Unlock() | |||
| 59 | + | if appendErr == nil { | |||
| 60 | + | appendErr = err | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ag.OnMessageAppended = func(message provider.Message) { | |||
| 64 | + | recordAppendError(sess.AppendMessage(message)) | |||
| 65 | + | } | |||
| 66 | + | ag.OnUsage = func(cumulative provider.Usage) { | |||
| 67 | + | recordAppendError(sess.AppendUsage(cumulative, cumulative)) | |||
| 68 | + | } | |||
| 69 | + | ag.OnTranscriptCompacted = func(messages []provider.Message) { | |||
| 70 | + | recordAppendError(sess.AppendCompaction(messages)) | |||
| 71 | + | } | |||
| 72 | + | if err := ag.Prompt(ctx, task.Message, nil, func(core.AgentEvent) {}); err != nil { | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 75 | + | appendMu.Lock() | |||
| 76 | + | err = appendErr | |||
| 77 | + | appendMu.Unlock() | |||
| 78 | + | if err != nil { | |||
| 79 | + | return fmt.Errorf("append scheduled transcript: %w", err) | |||
| 80 | + | } | |||
| 81 | + | if err := sess.Flush(); err != nil { | |||
| 82 | + | return fmt.Errorf("flush scheduled session: %w", err) | |||
| 83 | + | } | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
A packages/agent/tools/schedule.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zut/packages/agent/scheduler" | |
| 13 | + | "github.com/bnema/zut/packages/core" | |
| 14 | + | "github.com/bnema/zut/packages/provider" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const scheduleToolSchema = `{"type":"object","properties":{"action":{"type":"string","enum":["add","list","cancel"],"description":"Scheduling operation."},"cron":{"type":"string","description":"Five-field cron expression, for example '0 9 * * 1-5'. Required for add."},"message":{"type":"string","description":"Prompt to run in this session when due. Required for add."},"id":{"type":"string","description":"Task ID. Required for cancel."}},"required":["action"],"additionalProperties":false}` | |
| 18 | + | ||
| 19 | + | // ScheduleToolName is the stable built-in scheduler tool name. | |
| 20 | + | const ScheduleToolName = "schedule" | |
| 21 | + | ||
| 22 | + | // ScheduleTool manages process-lifetime scheduled follow-up prompts. Its | |
| 23 | + | // host-provided session callback prevents an agent from selecting another | |
| 24 | + | // session as a task target. | |
| 25 | + | type ScheduleTool struct { | |
| 26 | + | Engine *scheduler.Engine | |
| 27 | + | SessionID func() string | |
| 28 | + | Location func() *time.Location | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type scheduleToolArgs struct { | |
| 32 | + | Action string `json:"action"` | |
| 33 | + | Cron string `json:"cron,omitempty"` | |
| 34 | + | Message string `json:"message,omitempty"` | |
| 35 | + | ID string `json:"id,omitempty"` | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (t *ScheduleTool) Name() string { return ScheduleToolName } | |
| 39 | + | ||
| 40 | + | func (t *ScheduleTool) Description() string { | |
| 41 | + | return "Create, list, or cancel in-process cron follow-ups for the current session. Scheduled work runs only while this zut process remains open." | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (t *ScheduleTool) Schema() json.RawMessage { return json.RawMessage(scheduleToolSchema) } | |
| 45 | + | ||
| 46 | + | func (t *ScheduleTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 47 | + | if t == nil || t.Engine == nil || t.SessionID == nil { | |
| 48 | + | return scheduleToolError("scheduler is unavailable"), nil | |
| 49 | + | } | |
| 50 | + | var args scheduleToolArgs | |
| 51 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) | |
| 52 | + | decoder.DisallowUnknownFields() | |
| 53 | + | if err := decoder.Decode(&args); err != nil { | |
| 54 | + | return scheduleToolError("invalid arguments"), nil | |
| 55 | + | } | |
| 56 | + | if err := decoder.Decode(&struct{}{}); err != io.EOF { | |
| 57 | + | return scheduleToolError("invalid arguments"), nil | |
| 58 | + | } | |
| 59 | + | args.Action = strings.TrimSpace(args.Action) | |
| 60 | + | args.Cron = strings.TrimSpace(args.Cron) | |
| 61 | + | args.Message = strings.TrimSpace(args.Message) | |
| 62 | + | args.ID = strings.TrimSpace(args.ID) | |
| 63 | + | ||
| 64 | + | switch args.Action { | |
| 65 | + | case "add": | |
| 66 | + | if args.Cron == "" || args.Message == "" { | |
| 67 | + | return scheduleToolError("cron and message are required when adding a task"), nil | |
| 68 | + | } | |
| 69 | + | location := time.Local | |
| 70 | + | if t.Location != nil && t.Location() != nil { | |
| 71 | + | location = t.Location() | |
| 72 | + | } | |
| 73 | + | task, err := t.Engine.Add(scheduler.NewTaskInput{ | |
| 74 | + | SessionID: t.SessionID(), | |
| 75 | + | Cron: args.Cron, | |
| 76 | + | Message: args.Message, | |
| 77 | + | Location: location, | |
| 78 | + | }) | |
| 79 | + | if err != nil { | |
| 80 | + | return scheduleToolError(err.Error()), nil | |
| 81 | + | } | |
| 82 | + | return scheduleToolResult(fmt.Sprintf("scheduled %s for %s (%s)", task.ID, task.NextRun.Format(time.RFC3339), task.Timezone)) | |
| 83 | + | case "list": | |
| 84 | + | tasks := t.Engine.List() | |
| 85 | + | currentSession := t.SessionID() | |
| 86 | + | lines := make([]string, 0, len(tasks)) | |
| 87 | + | for _, task := range tasks { | |
| 88 | + | if task.SessionID != currentSession { | |
| 89 | + | continue | |
| 90 | + | } | |
| 91 | + | line := fmt.Sprintf("%s %s next %s", task.ID, task.Cron, task.NextRun.Format(time.RFC3339)) | |
| 92 | + | if task.LastError != "" { | |
| 93 | + | line += " last error: " + task.LastError | |
| 94 | + | } | |
| 95 | + | lines = append(lines, line) | |
| 96 | + | } | |
| 97 | + | if len(lines) == 0 { | |
| 98 | + | return scheduleToolResult("no scheduled tasks for this session") | |
| 99 | + | } | |
| 100 | + | return scheduleToolResult(strings.Join(lines, "\n")) | |
| 101 | + | case "cancel": | |
| 102 | + | if args.ID == "" { | |
| 103 | + | return scheduleToolError("id is required when cancelling a task"), nil | |
| 104 | + | } | |
| 105 | + | task, ok := t.Engine.Get(args.ID) | |
| 106 | + | if !ok || task.SessionID != t.SessionID() { | |
| 107 | + | return scheduleToolError("scheduled task not found in this session"), nil | |
| 108 | + | } | |
| 109 | + | t.Engine.Cancel(args.ID) | |
| 110 | + | return scheduleToolResult("cancelled scheduled task " + args.ID) | |
| 111 | + | default: | |
| 112 | + | return scheduleToolError("action must be add, list, or cancel"), nil | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func scheduleToolResult(text string) (core.ToolResult, error) { | |
| 117 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}}, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func scheduleToolError(text string) core.ToolResult { | |
| 121 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: true} | |
| 122 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zut/packages/agent/scheduler" | |||
| 13 | + | "github.com/bnema/zut/packages/core" | |||
| 14 | + | "github.com/bnema/zut/packages/provider" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const scheduleToolSchema = `{"type":"object","properties":{"action":{"type":"string","enum":["add","list","cancel"],"description":"Scheduling operation."},"cron":{"type":"string","description":"Five-field cron expression, for example '0 9 * * 1-5'. Required for add."},"message":{"type":"string","description":"Prompt to run in this session when due. Required for add."},"id":{"type":"string","description":"Task ID. Required for cancel."}},"required":["action"],"additionalProperties":false}` | |||
| 18 | + | ||||
| 19 | + | // ScheduleToolName is the stable built-in scheduler tool name. | |||
| 20 | + | const ScheduleToolName = "schedule" | |||
| 21 | + | ||||
| 22 | + | // ScheduleTool manages process-lifetime scheduled follow-up prompts. Its | |||
| 23 | + | // host-provided session callback prevents an agent from selecting another | |||
| 24 | + | // session as a task target. | |||
| 25 | + | type ScheduleTool struct { | |||
| 26 | + | Engine *scheduler.Engine | |||
| 27 | + | SessionID func() string | |||
| 28 | + | Location func() *time.Location | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type scheduleToolArgs struct { | |||
| 32 | + | Action string `json:"action"` | |||
| 33 | + | Cron string `json:"cron,omitempty"` | |||
| 34 | + | Message string `json:"message,omitempty"` | |||
| 35 | + | ID string `json:"id,omitempty"` | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (t *ScheduleTool) Name() string { return ScheduleToolName } | |||
| 39 | + | ||||
| 40 | + | func (t *ScheduleTool) Description() string { | |||
| 41 | + | return "Create, list, or cancel in-process cron follow-ups for the current session. Scheduled work runs only while this zut process remains open." | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (t *ScheduleTool) Schema() json.RawMessage { return json.RawMessage(scheduleToolSchema) } | |||
| 45 | + | ||||
| 46 | + | func (t *ScheduleTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |||
| 47 | + | if t == nil || t.Engine == nil || t.SessionID == nil { | |||
| 48 | + | return scheduleToolError("scheduler is unavailable"), nil | |||
| 49 | + | } | |||
| 50 | + | var args scheduleToolArgs | |||
| 51 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) | |||
| 52 | + | decoder.DisallowUnknownFields() | |||
| 53 | + | if err := decoder.Decode(&args); err != nil { | |||
| 54 | + | return scheduleToolError("invalid arguments"), nil | |||
| 55 | + | } | |||
| 56 | + | if err := decoder.Decode(&struct{}{}); err != io.EOF { | |||
| 57 | + | return scheduleToolError("invalid arguments"), nil | |||
| 58 | + | } | |||
| 59 | + | args.Action = strings.TrimSpace(args.Action) | |||
| 60 | + | args.Cron = strings.TrimSpace(args.Cron) | |||
| 61 | + | args.Message = strings.TrimSpace(args.Message) | |||
| 62 | + | args.ID = strings.TrimSpace(args.ID) | |||
| 63 | + | ||||
| 64 | + | switch args.Action { | |||
| 65 | + | case "add": | |||
| 66 | + | if args.Cron == "" || args.Message == "" { | |||
| 67 | + | return scheduleToolError("cron and message are required when adding a task"), nil | |||
| 68 | + | } | |||
| 69 | + | location := time.Local | |||
| 70 | + | if t.Location != nil && t.Location() != nil { | |||
| 71 | + | location = t.Location() | |||
| 72 | + | } | |||
| 73 | + | task, err := t.Engine.Add(scheduler.NewTaskInput{ | |||
| 74 | + | SessionID: t.SessionID(), | |||
| 75 | + | Cron: args.Cron, | |||
| 76 | + | Message: args.Message, | |||
| 77 | + | Location: location, | |||
| 78 | + | }) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return scheduleToolError(err.Error()), nil | |||
| 81 | + | } | |||
| 82 | + | return scheduleToolResult(fmt.Sprintf("scheduled %s for %s (%s)", task.ID, task.NextRun.Format(time.RFC3339), task.Timezone)) | |||
| 83 | + | case "list": | |||
| 84 | + | tasks := t.Engine.List() | |||
| 85 | + | currentSession := t.SessionID() | |||
| 86 | + | lines := make([]string, 0, len(tasks)) | |||
| 87 | + | for _, task := range tasks { | |||
| 88 | + | if task.SessionID != currentSession { | |||
| 89 | + | continue | |||
| 90 | + | } | |||
| 91 | + | line := fmt.Sprintf("%s %s next %s", task.ID, task.Cron, task.NextRun.Format(time.RFC3339)) | |||
| 92 | + | if task.LastError != "" { | |||
| 93 | + | line += " last error: " + task.LastError | |||
| 94 | + | } | |||
| 95 | + | lines = append(lines, line) | |||
| 96 | + | } | |||
| 97 | + | if len(lines) == 0 { | |||
| 98 | + | return scheduleToolResult("no scheduled tasks for this session") | |||
| 99 | + | } | |||
| 100 | + | return scheduleToolResult(strings.Join(lines, "\n")) | |||
| 101 | + | case "cancel": | |||
| 102 | + | if args.ID == "" { | |||
| 103 | + | return scheduleToolError("id is required when cancelling a task"), nil | |||
| 104 | + | } | |||
| 105 | + | task, ok := t.Engine.Get(args.ID) | |||
| 106 | + | if !ok || task.SessionID != t.SessionID() { | |||
| 107 | + | return scheduleToolError("scheduled task not found in this session"), nil | |||
| 108 | + | } | |||
| 109 | + | t.Engine.Cancel(args.ID) | |||
| 110 | + | return scheduleToolResult("cancelled scheduled task " + args.ID) | |||
| 111 | + | default: | |||
| 112 | + | return scheduleToolError("action must be add, list, or cancel"), nil | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func scheduleToolResult(text string) (core.ToolResult, error) { | |||
| 117 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}}, nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func scheduleToolError(text string) core.ToolResult { | |||
| 121 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: true} | |||
| 122 | + | } | |||
A packages/agent/tools/schedule_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/agent/scheduler" | |
| 9 | + | "github.com/bnema/zut/packages/core" | |
| 10 | + | "github.com/bnema/zut/packages/provider" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestScheduleToolScopesTasksToCurrentSession(t *testing.T) { | |
| 14 | + | now := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC) | |
| 15 | + | engine := scheduler.NewEngine(func() time.Time { return now }) | |
| 16 | + | tool := &ScheduleTool{ | |
| 17 | + | Engine: engine, | |
| 18 | + | SessionID: func() string { return "session-a" }, | |
| 19 | + | Location: func() *time.Location { return time.UTC }, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | result, err := tool.Execute(nil, json.RawMessage(`{"action":"add","cron":"@hourly","message":"check build"}`), nil) | |
| 23 | + | if err != nil || result.IsError { | |
| 24 | + | t.Fatalf("Execute(add) = %#v, %v", result, err) | |
| 25 | + | } | |
| 26 | + | if got := resultText(result); got == "" { | |
| 27 | + | t.Fatal("Execute(add) returned no confirmation") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | otherSession, err := engine.Add(scheduler.NewTaskInput{SessionID: "session-b", Cron: "@hourly", Message: "hidden", Location: time.UTC}) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatalf("Engine.Add() error = %v", err) | |
| 33 | + | } | |
| 34 | + | result, err = tool.Execute(nil, json.RawMessage(`{"action":"cancel","id":"`+otherSession.ID+`"}`), nil) | |
| 35 | + | if err != nil || !result.IsError { | |
| 36 | + | t.Fatalf("Execute(cancel other session) = %#v, %v; want scoped error", result, err) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func resultText(result core.ToolResult) string { | |
| 41 | + | for _, content := range result.Content { | |
| 42 | + | if text, ok := content.(provider.TextBlock); ok { | |
| 43 | + | return text.Text | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | return "" | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/agent/scheduler" | |||
| 9 | + | "github.com/bnema/zut/packages/core" | |||
| 10 | + | "github.com/bnema/zut/packages/provider" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestScheduleToolScopesTasksToCurrentSession(t *testing.T) { | |||
| 14 | + | now := time.Date(2026, time.August, 15, 12, 0, 0, 0, time.UTC) | |||
| 15 | + | engine := scheduler.NewEngine(func() time.Time { return now }) | |||
| 16 | + | tool := &ScheduleTool{ | |||
| 17 | + | Engine: engine, | |||
| 18 | + | SessionID: func() string { return "session-a" }, | |||
| 19 | + | Location: func() *time.Location { return time.UTC }, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | result, err := tool.Execute(nil, json.RawMessage(`{"action":"add","cron":"@hourly","message":"check build"}`), nil) | |||
| 23 | + | if err != nil || result.IsError { | |||
| 24 | + | t.Fatalf("Execute(add) = %#v, %v", result, err) | |||
| 25 | + | } | |||
| 26 | + | if got := resultText(result); got == "" { | |||
| 27 | + | t.Fatal("Execute(add) returned no confirmation") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | otherSession, err := engine.Add(scheduler.NewTaskInput{SessionID: "session-b", Cron: "@hourly", Message: "hidden", Location: time.UTC}) | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatalf("Engine.Add() error = %v", err) | |||
| 33 | + | } | |||
| 34 | + | result, err = tool.Execute(nil, json.RawMessage(`{"action":"cancel","id":"`+otherSession.ID+`"}`), nil) | |||
| 35 | + | if err != nil || !result.IsError { | |||
| 36 | + | t.Fatalf("Execute(cancel other session) = %#v, %v; want scoped error", result, err) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func resultText(result core.ToolResult) string { | |||
| 41 | + | for _, content := range result.Content { | |||
| 42 | + | if text, ok := content.(provider.TextBlock); ok { | |||
| 43 | + | return text.Text | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | return "" | |||
| 47 | + | } | |||