From dc4856609ebb822c3910c367899e015208a504db Mon Sep 17 00:00:00 2001 From: Pierre Date: Thu, 9 Jul 2026 00:10:03 +0200 Subject: [PATCH] fix: enable dashboard reply scheduler wakeups --- .code-ux/agents/planning_agent.md | 18 ++- .code-ux/agents/project_manager.md | 64 ++++++++-- .code-ux/agents/project_setup_agent.md | 14 ++- .code-ux/agents/quality_assurance_agent.md | 18 ++- .code-ux/agents/worker.md | 18 ++- docs-web/architecture/mcp-server.md | 5 +- .../content/docs/architecture-mcp-server.mdx | 5 +- docs-web/content/docs/developer-mcp-tools.mdx | 21 ++-- .../content/docs/user-dashboard-agents.mdx | 4 +- .../content/docs/user-dashboard-scheduler.mdx | 23 +++- .../content/docs/user-dashboard-settings.mdx | 4 +- docs-web/developer/mcp-tools.md | 21 ++-- docs-web/user/dashboard/agents.md | 4 +- docs-web/user/dashboard/scheduler.md | 23 +++- docs-web/user/dashboard/settings.md | 4 +- docs/architecture/agent-preset-foundation.md | 2 +- docs/dashboard/design-system-agents.md | 2 +- docs/dashboard/scheduler.md | 17 ++- docs/mcp/runtime-and-dispatch.md | 2 +- docs/mcp/tools-and-contracts.md | 13 +- docs/settings/subcategories/built-in-mcp.md | 2 +- .../dependency-factory/dashboard-factory.ts | 6 + src/contracts/internal-management-types.ts | 18 ++- src/contracts/mcp-tool-definitions.ts | 34 ++--- src/contracts/scheduler-types.ts | 10 +- src/mcp/management/agent-scheduler-actions.ts | 108 ++++++++-------- src/mcp/management/scheduler-actions.ts | 23 +++- src/repositories/scheduler-repository.ts | 25 ++-- src/server/code-ux-server.ts | 25 +++- src/services/agent-mcp-access.ts | 14 +++ src/services/chat-reply-prompt.ts | 4 +- src/services/chat-thread-runtime-service.ts | 25 +++- src/services/scheduler-service.ts | 118 +++++++++++++++--- src/services/worker-inbox-reply-service.ts | 8 +- .../mcp/management-scheduler-actions.test.ts | 115 +++++++++++++---- tests/backend/mcp/tool-validators.test.ts | 25 +++- .../backend/services/agent-mcp-access.test.ts | 23 ++++ .../services/chat-reply-prompt.test.ts | 2 + .../chat-thread-runtime-service.test.ts | 45 ++++++- .../services/scheduler-service.test.ts | 86 +++++++++++++ .../worker-inbox-reply-service.test.ts | 4 +- 41 files changed, 755 insertions(+), 247 deletions(-) diff --git a/.code-ux/agents/planning_agent.md b/.code-ux/agents/planning_agent.md index 32073e8a3c..f6af6d0c6e 100644 --- a/.code-ux/agents/planning_agent.md +++ b/.code-ux/agents/planning_agent.md @@ -1,16 +1,14 @@ ---json { "avatarConfig": { - "body": "male", - "hair": "style3", - "face": "style4", - "shirt": "style3", - "bottom": "style4", - "chassis": "capsule", - "eyes": "pixel", - "antenna": "dual", - "wings": "propeller", - "accent": "sky" + "chassis": "classic", + "eyes": "smile", + "antenna": "jewel", + "wings": "dust", + "accent": "jade", + "baseColor": "pearl", + "visorColor": "noir", + "headphones": "bumper" }, "memoryTemplateOverrideEnabled": false } diff --git a/.code-ux/agents/project_manager.md b/.code-ux/agents/project_manager.md index efcfa00caa..4f4235dad4 100644 --- a/.code-ux/agents/project_manager.md +++ b/.code-ux/agents/project_manager.md @@ -2,19 +2,14 @@ { "description": "Project manager - the main point of contact for orchestrating Code UX.", "avatarConfig": { - "body": "female", - "hair": "style2", - "face": "style3", - "shirt": "style4", - "bottom": "style1", - "chassis": "pebble", - "eyes": "pixel", - "antenna": "beam", - "wings": "orbit", - "headphones": "loop", - "accent": "coral", - "baseColor": "plum", - "visorColor": "violet" + "chassis": "classic", + "eyes": "smile", + "antenna": "jewel", + "wings": "dust", + "accent": "jade", + "baseColor": "pearl", + "visorColor": "noir", + "headphones": "bumper" }, "memoryTemplateOverrideEnabled": false, "memoryConfig": { @@ -83,6 +78,49 @@ Execution rules: 4. After action, report concrete state: ids, names, status, URL, or changed setting. 5. If only a legacy umbrella tool exists, use its domain/action/payload structure. +## Programming Work Delegation + +When the user asks for programming work, implementation work, refactors, migrations, tests, fixes, QA follow-up, or "do these tasks", you are an orchestrator. You must delegate through Code UX sprint planning instead of manually constructing a sprint task list yourself. + +- Use `manage_sprints` with action `plan` as the default route for programming work delegation. If the request needs a new sprint first, create the sprint with the user's goal and immediately use the sprint planning route for task decomposition. +- Do not invent, hand-write, or directly create a set of implementation tasks unless the user explicitly asks you to manually construct tasks or bypass planning. +- Do not start coding yourself. Your job is to collect the minimum missing context, start planning, monitor outcomes, and report state. +- If the user gives enough context, start planning. Ask only for missing essentials that would make the plan unsafe or impossible. +- If the user asks to start execution after planning, use the planning route options that start the planned sprint when available; otherwise plan first, report the planned state, then start the sprint through the proper sprint lifecycle action. + +## Scheduler Protocol + +Use `scheduler_code_ux` to wake yourself for continuation work. The scheduler is for your own future dashboard reply turns, not for creating worker tasks. + +Use a wakeup before any operation where you need to answer first and continue after the answer is sent, including: + +- starting a planning run +- retrieving project, sprint, task, telemetry, preview, settings, memory, or knowledge data through MCP +- calling an MCP tool that may take noticeable time +- waiting for a sprint, task, planning run, preview, or external condition to finish + +Immediate continuation pattern: + +1. Call `scheduler_code_ux` with `action: "schedule_wakeup"`, `projectId`, `wakeAfterReply: true`, and a precise `bodyMarkdown` describing the exact next action. +2. Answer the user concisely, for example: "I’ll retrieve the current sprint data now and report back." +3. On the scheduled wakeup, perform the promised MCP call or management action, then report the result or schedule the next wakeup if more waiting is required. + +Use delayed or anchored wakeups when continuation depends on time or completion state: + +- Use `delaySeconds`, `delayMinutes`, or `scheduledFor` for a known time delay. +- Use `afterSprintId` with optional `offsetMinutes` when the user asks for a report or follow-up after a sprint ends. +- Use `afterTaskId` with optional `offsetMinutes` when the user asks for a report, inspection, or follow-up after a task ends. + +For completion-triggered requests, include the promised action in `bodyMarkdown`, not just a reminder. Example body: "Sprint completion follow-up: inspect sprint ``, summarize final status, blockers, merged work, and next recommended action for the user." For task completion: "Task completion follow-up: inspect task ``, check run/PR/QA state, and send a concise report." + +Scheduler discipline: + +- Use exactly one timing mode per wakeup: `scheduledFor`, `delaySeconds`/`delayMinutes`, `wakeAfterReply`, `afterSprintId`, or `afterTaskId`. +- Include enough context in `bodyMarkdown` for your future turn to act without guessing: ids, user request, intended tool call, and expected report. +- Use `list` before creating a duplicate wakeup when you are unsure whether one already exists. +- Use `cancel` for obsolete wakeups you created. +- Do not use the scheduler for simple answers that require no tool call, no wait, and no continuation. + ## Custom Dashboard Requests When the user asks to create, revise, validate, publish, or inspect a user-created dashboard, treat it as a custom dashboard management request. diff --git a/.code-ux/agents/project_setup_agent.md b/.code-ux/agents/project_setup_agent.md index 0019527b90..08eaa0ce0c 100644 --- a/.code-ux/agents/project_setup_agent.md +++ b/.code-ux/agents/project_setup_agent.md @@ -1,12 +1,14 @@ ---json { "avatarConfig": { - "chassis": "capsule", - "eyes": "pixel", - "antenna": "dual", - "wings": "propeller", - "accent": "lime", - "baseColor": "slate" + "chassis": "classic", + "eyes": "smile", + "antenna": "jewel", + "wings": "dust", + "accent": "jade", + "baseColor": "pearl", + "visorColor": "noir", + "headphones": "bumper" }, "memoryTemplateOverrideEnabled": false } diff --git a/.code-ux/agents/quality_assurance_agent.md b/.code-ux/agents/quality_assurance_agent.md index 46720bd80f..596569fde9 100644 --- a/.code-ux/agents/quality_assurance_agent.md +++ b/.code-ux/agents/quality_assurance_agent.md @@ -1,16 +1,14 @@ ---json { "avatarConfig": { - "body": "male", - "hair": "style3", - "face": "style2", - "shirt": "style3", - "bottom": "style2", - "chassis": "capsule", - "eyes": "pixel", - "antenna": "none", - "wings": "propeller", - "accent": "violet" + "chassis": "classic", + "eyes": "smile", + "antenna": "jewel", + "wings": "dust", + "accent": "jade", + "baseColor": "pearl", + "visorColor": "noir", + "headphones": "bumper" }, "memoryTemplateOverrideEnabled": false } diff --git a/.code-ux/agents/worker.md b/.code-ux/agents/worker.md index 5d9666f170..ed78f74fb1 100644 --- a/.code-ux/agents/worker.md +++ b/.code-ux/agents/worker.md @@ -2,16 +2,14 @@ { "description": "Default coding worker template for production-grade task execution.", "avatarConfig": { - "body": "female", - "hair": "style4", - "face": "style1", - "shirt": "style2", - "bottom": "style3", - "chassis": "egg", - "eyes": "cyclops", - "antenna": "single", - "wings": "tiny", - "accent": "pink" + "chassis": "classic", + "eyes": "smile", + "antenna": "jewel", + "wings": "dust", + "accent": "jade", + "baseColor": "pearl", + "visorColor": "noir", + "headphones": "bumper" }, "memoryTemplateOverrideEnabled": false, "memoryConfig": { diff --git a/docs-web/architecture/mcp-server.md b/docs-web/architecture/mcp-server.md index e541acc031..37cfa4e3c0 100644 --- a/docs-web/architecture/mcp-server.md +++ b/docs-web/architecture/mcp-server.md @@ -125,9 +125,8 @@ Advertised agent identities fail closed when malformed, unknown, or missing an e policy. This prevents an unknown worker agent from inheriting broad project-manager tools. Agent-scoped provider runs use the same default-deny posture for built-in Code UX tools. Default custom MCP links such as `playwright` are stored separately and do not imply `code_ux` access. -Dashboard chat replies are the only route-local default exception: unconfigured reply agents receive -the restricted `scheduler_code_ux` tool only, with broad tools such as `manage_scheduler`, `manage_tasks`, -`manage_sprints`, `manage_settings`, and `manage_code_ux` disabled. +Dashboard chat replies are the only route-local default exception: assigned reply agents receive +the full built-in Code UX MCP surface plus `scheduler_code_ux` by default, even when their saved preset access disables Code UX. ### `CallTool` diff --git a/docs-web/content/docs/architecture-mcp-server.mdx b/docs-web/content/docs/architecture-mcp-server.mdx index f8ba527302..02c7c3ee8c 100644 --- a/docs-web/content/docs/architecture-mcp-server.mdx +++ b/docs-web/content/docs/architecture-mcp-server.mdx @@ -125,9 +125,8 @@ Advertised agent identities fail closed when malformed, unknown, or missing an e policy. This prevents an unknown worker agent from inheriting broad project-manager tools. Agent-scoped provider runs use the same default-deny posture for built-in Code UX tools. Default custom MCP links such as `playwright` are stored separately and do not imply `code_ux` access. -Dashboard chat replies are the only route-local default exception: unconfigured reply agents receive -the restricted `scheduler_code_ux` tool only, with broad tools such as `manage_scheduler`, `manage_tasks`, -`manage_sprints`, `manage_settings`, and `manage_code_ux` disabled. +Dashboard chat replies are the only route-local default exception: assigned reply agents receive +the full built-in Code UX MCP surface plus `scheduler_code_ux` by default, even when their saved preset access disables Code UX. ### `CallTool` diff --git a/docs-web/content/docs/developer-mcp-tools.mdx b/docs-web/content/docs/developer-mcp-tools.mdx index a8cc679f0d..f4f2bc337f 100644 --- a/docs-web/content/docs/developer-mcp-tools.mdx +++ b/docs-web/content/docs/developer-mcp-tools.mdx @@ -25,9 +25,8 @@ Tools are filtered before being advertised on `ListTools`: Agent-scoped provider runs are also default-deny for built-in Code UX tools. Newly synced Worker, Project manager, and generated coding agents may link the default `playwright` custom MCP server, but that custom-server link does not imply `code_ux` access. The dashboard chat reply route is the -only default exception: when the reply agent has no explicit MCP access, Code UX enables only the -restricted `scheduler_code_ux` tool and explicitly disables the broad management tools such as -`manage_scheduler`, `manage_tasks`, `manage_sprints`, `manage_settings`, and `manage_code_ux`. +only default exception: when the reply agent has no explicit MCP access, Code UX enables the full +built-in management surface plus the restricted `scheduler_code_ux` tool for that dashboard chat turn. All inputs are validated against their declared JSON Schema (AJV) before dispatch; validation failures return `InvalidParams` with the failing JSON path. @@ -45,7 +44,7 @@ action-specific fields, and an optional `approval` object for destructive action | `manage_tasks` | orchestration | Create, edit, start, stop, pause, and inspect tasks. | | `manage_quicksprints` | orchestration | Manage quicksprint templates and execute them. | | `manage_scheduler` | orchestration | Create and run scheduled sprints, quicksprints, messages, and node flows. | -| `scheduler_code_ux` | orchestration | Agent-owned wakeups and task reruns with restricted list/schedule/cancel actions. | +| `scheduler_code_ux` | orchestration | Agent-owned wakeups with restricted list/schedule/cancel actions. | | `manage_agents` | agents & memory | Manage agent presets and sync them to project markdown. | | `manage_node_flows` | agents & memory | Manage reusable node workflows, run them, and attach them as agent skills. | | `manage_memory` | agents & memory | Inspect, search, promote, and re-embed short/long-term memory. | @@ -69,7 +68,7 @@ Every tool requires `runtimeRoles: ["project_manager"]` and is enabled by defaul | `manage_tasks` | `list`, `get`, `create`, `update`, `delete`, `start`, `stop`, `force_stop`, `pause`, `inspect_run` | | `manage_quicksprints` | `list_templates`, `get_template`, `create_template`, `update_template`, `delete_template`, `execute`, `start` | | `manage_scheduler` | `list`, `create`, `update`, `delete`, `run_due`, `schedule_sprint`, `schedule_quicksprint`, `schedule_chat`, `schedule_node_flow` | -| `scheduler_code_ux` | `list`, `schedule_wakeup`, `schedule_task`, `cancel` | +| `scheduler_code_ux` | `list`, `schedule_wakeup`, `cancel` | | `manage_agents` | `list`, `get`, `create`, `update`, `delete`, `sync` | | `manage_node_flows` | `list`, `get`, `create`, `update`, `delete`, `validate`, `run`, `list_runs`, `get_run`, `attach_to_agent`, `detach_from_agent` | | `manage_memory` | `list`, `get`, `count`, `create`, `update`, `delete`, `search`, `promote`, `get_map`, `model_status`, `start_reembed` | @@ -92,15 +91,19 @@ updates, deletion, and due-entry execution. Allowed actions: -- `list` — requires `projectId`; returns only `agent_scheduler` wakeup/task entries created by the calling agent. -- `schedule_wakeup` — requires `projectId`, `bodyMarkdown`, and either `scheduledFor`, `delaySeconds`, or `delayMinutes`; optional `title`, `timezone`, `threadId`, and `connectionId`. -- `schedule_task` — requires `projectId`, `taskId`, and either `scheduledFor`, `delaySeconds`, or `delayMinutes`; optional `title`, `timezone`, and `provider`. +- `list` — requires `projectId`; returns only `agent_scheduler` wakeup entries created by the calling agent. +- `schedule_wakeup` — requires `projectId`, `bodyMarkdown`, and exactly one timing mode: `scheduledFor`, `delaySeconds`/`delayMinutes`, `wakeAfterReply: true`, `afterSprintId`, or `afterTaskId`; optional `offsetMinutes`, `title`, `timezone`, `threadId`, and `connectionId`. - `cancel` — requires `entryId`; changes the entry status to `cancelled` only when the entry was created by the calling agent through `scheduler_code_ux`. +`wakeAfterReply: true` creates a due-now wakeup that the dashboard chat runtime drains immediately after +the current reply is sent, allowing an agent to answer first and continue with MCP calls in the next +turn. `afterSprintId` and `afterTaskId` create one-time completion anchors; `offsetMinutes` delays the +wakeup after the source sprint or task finishes. + Security model: Code UX stamps restricted scheduler entries with `origin: "agent_scheduler"`, `source: "agent_scheduler"`, and `createdByAgentId` from the current MCP agent context. The server enforces this metadata on list and cancel, so an agent cannot cancel dashboard-created entries, -entries created through `manage_scheduler`, or entries created by another agent. The restricted tool +task entries, entries created through `manage_scheduler`, or entries created by another agent. The restricted tool does not expose `run_due`, arbitrary updates, recurrence editing, sprint or quicksprint scheduling, memory remediation, or global scheduler destructive controls. diff --git a/docs-web/content/docs/user-dashboard-agents.mdx b/docs-web/content/docs/user-dashboard-agents.mdx index b57d5ba932..8093b443ea 100644 --- a/docs-web/content/docs/user-dashboard-agents.mdx +++ b/docs-web/content/docs/user-dashboard-agents.mdx @@ -118,6 +118,6 @@ Agent MCP access is default-deny. If a preset has no saved MCP access record, Co The **Connected MCPs** editor panel opens a risk-gated manager for Code UX tools. Turning on Code UX for the dashboard reply agent enables the built-in MCP surface plus the restricted `scheduler_code_ux` tool. Turning on Code UX for other agents keeps `scheduler_code_ux` explicitly disabled by default while broader tools remain visible for review. -Scheduler access lets an agent create its own wakeups or task reruns through the secured agent scheduler surface. It does not grant full scheduler administration, due-entry execution, recurrence editing, sprint scheduling, or destructive scheduler actions. +Scheduler access lets an agent create its own wakeups through the secured agent scheduler surface. It does not grant full scheduler administration, due-entry execution, recurrence editing, sprint scheduling, task reruns, or destructive scheduler actions. -The dashboard reply route always receives Code UX MCP plus scheduler for dashboard chat turns. Enabling scheduler or any other Code UX tool for planning, coding, QA, CI repair, merge-conflict, or other non-chat agents is riskier because those agents run during operational workflows and can affect project state without being part of a direct dashboard chat exchange. +The dashboard reply route always receives the full built-in Code UX MCP surface plus scheduler for dashboard chat turns. Enabling scheduler or any other Code UX tool for planning, coding, QA, CI repair, merge-conflict, or other non-chat agents is riskier because those agents run during operational workflows and can affect project state without being part of a direct dashboard chat exchange. diff --git a/docs-web/content/docs/user-dashboard-scheduler.mdx b/docs-web/content/docs/user-dashboard-scheduler.mdx index 45d33c55cc..dc51c10fa5 100644 --- a/docs-web/content/docs/user-dashboard-scheduler.mdx +++ b/docs-web/content/docs/user-dashboard-scheduler.mdx @@ -27,17 +27,28 @@ target JSON payload, validate that the flow belongs to the selected project, and node-flow runtime with scheduler trigger metadata when due. Blank dashboard input is omitted, and supplied input must be a JSON object. -The backend scheduler contract also supports agent-created wakeups and scheduled task reruns. -Agent wakeups and task reruns are stored in the same target JSON payload with `origin` and `source` set to +The backend scheduler contract also supports agent-created wakeups. +Agent wakeups are stored in the target JSON payload with `origin` and `source` set to `agent_scheduler`, plus `createdByAgentId` when the creating agent provides it. Agent wakeups post -through the chat runtime with scheduler metadata, and task reruns reuse the normal task rerun -service so workspaces, telemetry, and cancellation behavior stay consistent. +through the chat runtime with scheduler metadata. -Agent wakeups and task reruns may appear in the Scheduler calendar, day view, stats, and scheduled +Agent wakeups may appear in the Scheduler calendar, day view, stats, and scheduled entry list after they are created by the secured MCP scheduler tool. They use their own target labels and compact summaries instead of appearing as chat messages. The dashboard create/edit form supports Sprint, Quicksprint, Node flow, Message, and Memory remediation entries; MCP-created agent -wakeups and task reruns can still be paused, resumed, or deleted from the list. +wakeups can still be paused, resumed, or deleted from the list. + +Agent wakeups created through `scheduler_code_ux` can use one timing mode at a time: + +- an absolute `scheduledFor` timestamp +- a positive `delaySeconds` or `delayMinutes` value +- `wakeAfterReply: true`, which wakes the agent immediately after its current dashboard reply is sent +- `afterSprintId`, with optional `offsetMinutes`, to wake after a sprint reaches a terminal state +- `afterTaskId`, with optional `offsetMinutes`, to wake after a task reaches a terminal project status + +Completion-anchored wakeups are one-time entries. Sprint anchors use the terminal sprint run finish +time when available, and task anchors use terminal task run or dispatch finish evidence before +falling back to the task update time. ## Recurrence diff --git a/docs-web/content/docs/user-dashboard-settings.mdx b/docs-web/content/docs/user-dashboard-settings.mdx index 25ac5fda06..05703afd3b 100644 --- a/docs-web/content/docs/user-dashboard-settings.mdx +++ b/docs-web/content/docs/user-dashboard-settings.mdx @@ -793,9 +793,9 @@ Controls which built-in Code UX MCP tool categories are available to containeriz **What it controls:** Tool-category and individual-tool toggles decide what trusted provider and project-manager clients may call on their next run. Agent presets add their own access layer in the Agents editor. -**Recommended defaults:** Keep the global surface aligned with project-manager workflows. Dashboard chat receives Code UX MCP plus scheduler at runtime. For individual non-dashboard agents, start with Code UX disabled; if built-in tools are enabled, keep scheduler disabled unless the preset specifically needs agent-owned wakeups or task reruns. +**Recommended defaults:** Keep the global surface aligned with project-manager workflows. Dashboard chat receives the full built-in Code UX MCP surface plus scheduler by default for the assigned reply agent. For individual non-dashboard agents, start with Code UX disabled; if built-in tools are enabled, keep scheduler disabled unless the preset specifically needs agent-owned wakeups. -**Risks and gotchas:** Disabling required tools can make provider workflows fail; enabling broad tools increases capability exposure. The restricted `scheduler_code_ux` tool lets an agent create its own wakeups or task reruns, while `manage_scheduler` and other management tools expose broader runtime control. Non-chat agents should not receive scheduler or management tools unless that capability is intentional. +**Risks and gotchas:** Disabling required tools can make provider workflows fail; enabling broad tools increases capability exposure. The restricted `scheduler_code_ux` tool lets an agent create its own wakeups, while `manage_scheduler` and other management tools expose broader runtime control. Non-chat agents should not receive scheduler or management tools unless that capability is intentional. Related docs: diff --git a/docs-web/developer/mcp-tools.md b/docs-web/developer/mcp-tools.md index dd3bd44921..af7f4dc0c6 100644 --- a/docs-web/developer/mcp-tools.md +++ b/docs-web/developer/mcp-tools.md @@ -25,9 +25,8 @@ Tools are filtered before being advertised on `ListTools`: Agent-scoped provider runs are also default-deny for built-in Code UX tools. Newly synced Worker, Project manager, and generated coding agents may link the default `playwright` custom MCP server, but that custom-server link does not imply `code_ux` access. The dashboard chat reply route is the -only default exception: when the reply agent has no explicit MCP access, Code UX enables only the -restricted `scheduler_code_ux` tool and explicitly disables the broad management tools such as -`manage_scheduler`, `manage_tasks`, `manage_sprints`, `manage_settings`, and `manage_code_ux`. +only default exception: when the reply agent has no explicit MCP access, Code UX enables the full +built-in management surface plus the restricted `scheduler_code_ux` tool for that dashboard chat turn. All inputs are validated against their declared JSON Schema (AJV) before dispatch; validation failures return `InvalidParams` with the failing JSON path. @@ -45,7 +44,7 @@ action-specific fields, and an optional `approval` object for destructive action | `manage_tasks` | orchestration | Create, edit, start, stop, pause, and inspect tasks. | | `manage_quicksprints` | orchestration | Manage quicksprint templates and execute them. | | `manage_scheduler` | orchestration | Create and run scheduled sprints, quicksprints, messages, and node flows. | -| `scheduler_code_ux` | orchestration | Agent-owned wakeups and task reruns with restricted list/schedule/cancel actions. | +| `scheduler_code_ux` | orchestration | Agent-owned wakeups with restricted list/schedule/cancel actions. | | `manage_agents` | agents & memory | Manage agent presets and sync them to project markdown. | | `manage_node_flows` | agents & memory | Manage reusable node workflows, run them, and attach them as agent skills. | | `manage_memory` | agents & memory | Inspect, search, promote, and re-embed short/long-term memory. | @@ -69,7 +68,7 @@ Every tool requires `runtimeRoles: ["project_manager"]` and is enabled by defaul | `manage_tasks` | `list`, `get`, `create`, `update`, `delete`, `start`, `stop`, `force_stop`, `pause`, `inspect_run` | | `manage_quicksprints` | `list_templates`, `get_template`, `create_template`, `update_template`, `delete_template`, `execute`, `start` | | `manage_scheduler` | `list`, `create`, `update`, `delete`, `run_due`, `schedule_sprint`, `schedule_quicksprint`, `schedule_chat`, `schedule_node_flow` | -| `scheduler_code_ux` | `list`, `schedule_wakeup`, `schedule_task`, `cancel` | +| `scheduler_code_ux` | `list`, `schedule_wakeup`, `cancel` | | `manage_agents` | `list`, `get`, `create`, `update`, `delete`, `sync` | | `manage_node_flows` | `list`, `get`, `create`, `update`, `delete`, `validate`, `run`, `list_runs`, `get_run`, `attach_to_agent`, `detach_from_agent` | | `manage_memory` | `list`, `get`, `count`, `create`, `update`, `delete`, `search`, `promote`, `get_map`, `model_status`, `start_reembed` | @@ -92,15 +91,19 @@ updates, deletion, and due-entry execution. Allowed actions: -- `list` — requires `projectId`; returns only `agent_scheduler` wakeup/task entries created by the calling agent. -- `schedule_wakeup` — requires `projectId`, `bodyMarkdown`, and either `scheduledFor`, `delaySeconds`, or `delayMinutes`; optional `title`, `timezone`, `threadId`, and `connectionId`. -- `schedule_task` — requires `projectId`, `taskId`, and either `scheduledFor`, `delaySeconds`, or `delayMinutes`; optional `title`, `timezone`, and `provider`. +- `list` — requires `projectId`; returns only `agent_scheduler` wakeup entries created by the calling agent. +- `schedule_wakeup` — requires `projectId`, `bodyMarkdown`, and exactly one timing mode: `scheduledFor`, `delaySeconds`/`delayMinutes`, `wakeAfterReply: true`, `afterSprintId`, or `afterTaskId`; optional `offsetMinutes`, `title`, `timezone`, `threadId`, and `connectionId`. - `cancel` — requires `entryId`; changes the entry status to `cancelled` only when the entry was created by the calling agent through `scheduler_code_ux`. +`wakeAfterReply: true` creates a due-now wakeup that the dashboard chat runtime drains immediately after +the current reply is sent, allowing an agent to answer first and continue with MCP calls in the next +turn. `afterSprintId` and `afterTaskId` create one-time completion anchors; `offsetMinutes` delays the +wakeup after the source sprint or task finishes. + Security model: Code UX stamps restricted scheduler entries with `origin: "agent_scheduler"`, `source: "agent_scheduler"`, and `createdByAgentId` from the current MCP agent context. The server enforces this metadata on list and cancel, so an agent cannot cancel dashboard-created entries, -entries created through `manage_scheduler`, or entries created by another agent. The restricted tool +task entries, entries created through `manage_scheduler`, or entries created by another agent. The restricted tool does not expose `run_due`, arbitrary updates, recurrence editing, sprint or quicksprint scheduling, memory remediation, or global scheduler destructive controls. diff --git a/docs-web/user/dashboard/agents.md b/docs-web/user/dashboard/agents.md index 4b39769320..5f45af7f98 100644 --- a/docs-web/user/dashboard/agents.md +++ b/docs-web/user/dashboard/agents.md @@ -118,6 +118,6 @@ Agent MCP access is default-deny. If a preset has no saved MCP access record, Co The **Connected MCPs** editor panel opens a risk-gated manager for Code UX tools. Turning on Code UX for the dashboard reply agent enables the built-in MCP surface plus the restricted `scheduler_code_ux` tool. Turning on Code UX for other agents keeps `scheduler_code_ux` explicitly disabled by default while broader tools remain visible for review. -Scheduler access lets an agent create its own wakeups or task reruns through the secured agent scheduler surface. It does not grant full scheduler administration, due-entry execution, recurrence editing, sprint scheduling, or destructive scheduler actions. +Scheduler access lets an agent create its own wakeups through the secured agent scheduler surface. It does not grant full scheduler administration, due-entry execution, recurrence editing, sprint scheduling, task reruns, or destructive scheduler actions. -The dashboard reply route always receives Code UX MCP plus scheduler for dashboard chat turns. Enabling scheduler or any other Code UX tool for planning, coding, QA, CI repair, merge-conflict, or other non-chat agents is riskier because those agents run during operational workflows and can affect project state without being part of a direct dashboard chat exchange. +The dashboard reply route always receives the full built-in Code UX MCP surface plus scheduler for dashboard chat turns. Enabling scheduler or any other Code UX tool for planning, coding, QA, CI repair, merge-conflict, or other non-chat agents is riskier because those agents run during operational workflows and can affect project state without being part of a direct dashboard chat exchange. diff --git a/docs-web/user/dashboard/scheduler.md b/docs-web/user/dashboard/scheduler.md index 7113f3ea7b..ced99c850e 100644 --- a/docs-web/user/dashboard/scheduler.md +++ b/docs-web/user/dashboard/scheduler.md @@ -27,17 +27,28 @@ target JSON payload, validate that the flow belongs to the selected project, and node-flow runtime with scheduler trigger metadata when due. Blank dashboard input is omitted, and supplied input must be a JSON object. -The backend scheduler contract also supports agent-created wakeups and scheduled task reruns. -Agent wakeups and task reruns are stored in the same target JSON payload with `origin` and `source` set to +The backend scheduler contract also supports agent-created wakeups. +Agent wakeups are stored in the target JSON payload with `origin` and `source` set to `agent_scheduler`, plus `createdByAgentId` when the creating agent provides it. Agent wakeups post -through the chat runtime with scheduler metadata, and task reruns reuse the normal task rerun -service so workspaces, telemetry, and cancellation behavior stay consistent. +through the chat runtime with scheduler metadata. -Agent wakeups and task reruns may appear in the Scheduler calendar, day view, stats, and scheduled +Agent wakeups may appear in the Scheduler calendar, day view, stats, and scheduled entry list after they are created by the secured MCP scheduler tool. They use their own target labels and compact summaries instead of appearing as chat messages. The dashboard create/edit form supports Sprint, Quicksprint, Node flow, Message, and Memory remediation entries; MCP-created agent -wakeups and task reruns can still be paused, resumed, or deleted from the list. +wakeups can still be paused, resumed, or deleted from the list. + +Agent wakeups created through `scheduler_code_ux` can use one timing mode at a time: + +- an absolute `scheduledFor` timestamp +- a positive `delaySeconds` or `delayMinutes` value +- `wakeAfterReply: true`, which wakes the agent immediately after its current dashboard reply is sent +- `afterSprintId`, with optional `offsetMinutes`, to wake after a sprint reaches a terminal state +- `afterTaskId`, with optional `offsetMinutes`, to wake after a task reaches a terminal project status + +Completion-anchored wakeups are one-time entries. Sprint anchors use the terminal sprint run finish +time when available, and task anchors use terminal task run or dispatch finish evidence before +falling back to the task update time. ## Recurrence diff --git a/docs-web/user/dashboard/settings.md b/docs-web/user/dashboard/settings.md index ecc24e7014..90023b67f7 100644 --- a/docs-web/user/dashboard/settings.md +++ b/docs-web/user/dashboard/settings.md @@ -789,9 +789,9 @@ Controls which built-in Code UX MCP tool categories are available to containeriz **What it controls:** Tool-category and individual-tool toggles decide what trusted provider and project-manager clients may call on their next run. Agent presets add their own access layer in the Agents editor. -**Recommended defaults:** Keep the global surface aligned with project-manager workflows. Dashboard chat receives Code UX MCP plus scheduler at runtime. For individual non-dashboard agents, start with Code UX disabled; if built-in tools are enabled, keep scheduler disabled unless the preset specifically needs agent-owned wakeups or task reruns. +**Recommended defaults:** Keep the global surface aligned with project-manager workflows. Dashboard chat receives the full built-in Code UX MCP surface plus scheduler by default for the assigned reply agent. For individual non-dashboard agents, start with Code UX disabled; if built-in tools are enabled, keep scheduler disabled unless the preset specifically needs agent-owned wakeups. -**Risks and gotchas:** Disabling required tools can make provider workflows fail; enabling broad tools increases capability exposure. The restricted `scheduler_code_ux` tool lets an agent create its own wakeups or task reruns, while `manage_scheduler` and other management tools expose broader runtime control. Non-chat agents should not receive scheduler or management tools unless that capability is intentional. +**Risks and gotchas:** Disabling required tools can make provider workflows fail; enabling broad tools increases capability exposure. The restricted `scheduler_code_ux` tool lets an agent create its own wakeups, while `manage_scheduler` and other management tools expose broader runtime control. Non-chat agents should not receive scheduler or management tools unless that capability is intentional. Related docs: diff --git a/docs/architecture/agent-preset-foundation.md b/docs/architecture/agent-preset-foundation.md index 6713d5ac59..749e830c1d 100644 --- a/docs/architecture/agent-preset-foundation.md +++ b/docs/architecture/agent-preset-foundation.md @@ -117,7 +117,7 @@ Agent MCP access is default-deny for built-in Code UX tools. Absent, malformed, Built-in Worker and Project manager presets seed `mcp_access_json` with the default `playwright` custom MCP server linked, but `code_ux` remains disabled in that seeded access. Planning and QA presets do not receive that link by default. Existing agents with a user-edited MCP access payload keep their selections; only newly imported/generated defaults or previously unconfigured built-in Worker/Project manager records receive the seeded custom-server-only link. -Dashboard chat replies are the only default exception. Each dashboard chat turn receives the normal Code UX MCP project-manager surface plus the restricted `scheduler_code_ux` tool, even when the selected reply preset has Code UX disabled or a narrower saved access record. Runtime dispatch preserves the selected agent's linked custom MCP servers, but it injects the built-in `code_ux` connection without the per-agent MCP header so project, sprint, task, settings, preview, and Code UX scheduler tools remain available to dashboard chat. Planning, coding, CI fix, merge-conflict, clarification, QA, generated setup, and general Project manager agent runs remain denied unless their preset explicitly enables Code UX tools; when Code UX is first enabled for those non-dashboard agents, `scheduler_code_ux` is explicitly off by default. +Dashboard chat replies are the only default exception. Each assigned dashboard reply agent receives the full built-in Code UX MCP surface plus the restricted `scheduler_code_ux` tool by default, even when the selected reply preset has Code UX disabled. Runtime dispatch preserves the selected agent's linked custom MCP servers and sends the assigned agent id through the built-in `code_ux` connection, allowing the MCP router to apply the dashboard-reply full-access default. Planning, coding, CI fix, merge-conflict, clarification, QA, generated setup, and general Project manager agent runs remain denied unless their preset explicitly enables Code UX tools; when Code UX is first enabled for those non-dashboard agents, `scheduler_code_ux` is explicitly off by default. ## Dashboard Interaction Contract diff --git a/docs/dashboard/design-system-agents.md b/docs/dashboard/design-system-agents.md index 24f00fa2c4..04e4d7af8a 100644 --- a/docs/dashboard/design-system-agents.md +++ b/docs/dashboard/design-system-agents.md @@ -43,7 +43,7 @@ Use explicit badging inside `.code-ux/agents` lists: - Missing per-agent MCP access must display as default-deny: Code UX built-in tools are off, and custom MCP links are shown independently from built-in Code UX access. - The editor must not enable Code UX directly from the inactive chip. It should open the MCP manager so the visible risk warning is presented before the user grants built-in tools. - Enabling Code UX from the manager for the dashboard reply agent enables the built-in MCP surface plus `scheduler_code_ux`. -- Enabling Code UX from the manager for non-dashboard agents starts with every built-in tool represented explicitly and `scheduler_code_ux` disabled by default. Planning, coding, QA, CI repair, merge-conflict, and other non-chat agents need stronger visible warning copy because Code UX scheduler access can create agent-owned wakeups or task reruns during operational workflows. +- Enabling Code UX from the manager for non-dashboard agents starts with every built-in tool represented explicitly and `scheduler_code_ux` disabled by default. Planning, coding, QA, CI repair, merge-conflict, and other non-chat agents need stronger visible warning copy because Code UX scheduler access can create agent-owned wakeups during operational workflows. ## Empty States For empty states on the Agents page, avoid generic `` implementations. Instead, use tailored rounded containers (`rounded-[1.9rem]`), dashed borders (`border-dashed border-black/[0.08]`), and a highly blured backdrop (`backdrop-blur-2xl`) that houses an oversized icon container (`h-16 w-16 bg-signal-500/10 text-signal-600 shadow-sm ring-1 ring-slate-900/5`). diff --git a/docs/dashboard/scheduler.md b/docs/dashboard/scheduler.md index bc584ecb11..06c6a806d2 100644 --- a/docs/dashboard/scheduler.md +++ b/docs/dashboard/scheduler.md @@ -1,6 +1,6 @@ # Scheduler -The Scheduler page provides project-scoped automation for future sprint starts, quicksprint launches, scheduled node-flow runs, timed chat-agent messages, and long-term memory remediation. The backend contract also supports agent-created wakeups and scheduled task reruns for agent-facing surfaces that are not exposed in the dashboard form yet. +The Scheduler page provides project-scoped automation for future sprint starts, quicksprint launches, scheduled node-flow runs, timed chat-agent messages, and long-term memory remediation. The backend contract also supports agent-created wakeups for the restricted agent-facing scheduler surface. ## Dashboard Behavior @@ -24,11 +24,11 @@ The runtime contract additionally accepts: - `agent_wakeup` targets, which post a scheduled wakeup message back into a chat thread with `bodyMarkdown`, optional `threadId`, optional `connectionId`, optional `title`, and agent-scheduler metadata. - `task` targets, which rerun an existing task by `taskId` and optional `provider`. -Those agent-created target types are intentionally backend-only in this task. They provide the storage and execution model that later MCP security and dashboard notification surfaces can consume without changing the dashboard target picker. +Task targets are created through the broad scheduler management surface, not through `scheduler_code_ux`. Agent wakeups are intentionally backend-only in the dashboard form; they provide the storage and execution model for the restricted agent scheduler without changing the dashboard target picker. The restricted agent scheduler can create immediate post-reply wakeups with `wakeAfterReply: true`, timed wakeups with `scheduledFor` or relative delays, and completion-anchored wakeups with `afterSprintId` or `afterTaskId`. -When `agent_wakeup` or `task` entries are created by the secured MCP scheduler tool, the Scheduler page can display them in the calendar, 24-hour view, stats, and scheduled-entry list. They use their own concise target labels, chips, and summaries, for example an agent wakeup thread or task rerun ID, instead of falling back to chat labels. +When `agent_wakeup` entries are created by the secured MCP scheduler tool, the Scheduler page can display them in the calendar, 24-hour view, stats, and scheduled-entry list. They use their own concise target labels, chips, and summaries, for example an agent wakeup thread, instead of falling back to chat labels. -The dashboard form supports operator-created sprint, quicksprint, node-flow, chat, and memory remediation targets. Node-flow entries select a saved project flow and may include optional JSON object input; blank input is omitted from the scheduler payload, and invalid JSON or non-object JSON is rejected before submission. MCP-created `agent_wakeup` and `task` entries cannot be safely edited in that form, so their Edit action explains that dashboard editing is unavailable while Pause, Resume, and Delete remain available. +The dashboard form supports operator-created sprint, quicksprint, node-flow, chat, and memory remediation targets. Node-flow entries select a saved project flow and may include optional JSON object input; blank input is omitted from the scheduler payload, and invalid JSON or non-object JSON is rejected before submission. MCP-created `agent_wakeup` entries cannot be safely edited in that form, so their Edit action explains that dashboard editing is unavailable while Pause, Resume, and Delete remain available. The Sprint Composer also exposes a `Schedule` execution mode. That path saves the sprint definition first, including the sprint key override, name, goal, original prompt, planning route/model overrides, agent preset selections, linked issues, and imported tasks, then creates a scheduler entry targeting the saved sprint. It does not call planning or execution immediately. @@ -64,6 +64,7 @@ Scheduler state is persisted in SQLite in `scheduler_entries`. Entries support two scheduling modes: - **Absolute time**: the default path. `scheduledFor` is required on create, `nextRunAt` is populated from that timestamp, and recurrence expansion keeps using the existing UTC recurrence helpers. - **After sprint end**: set `scheduleAnchor = { mode: "after_sprint_end", sourceSprintId, offsetMinutes? }`. The source sprint must exist in the same project. `offsetMinutes` is optional, defaults to `0`, and must be non-negative. +- **After task end**: set `scheduleAnchor = { mode: "after_task_end", sourceTaskId, offsetMinutes? }`. The source task must exist in the same project and the wakeup becomes due only after that task reaches `completed` or `QA_REVIEW_FAILED`. `offsetMinutes` is optional, defaults to `0`, and must be non-negative. Composer and quicksprint shortcut scheduling both use this same contract. Absolute shortcut submissions send `scheduledFor`; after-sprint-end shortcut submissions send `scheduleAnchor`. @@ -139,6 +140,8 @@ Due entries execute through existing production paths: - agent wakeup entries call `ChatThreadRuntimeService.postMessage` with `metadata.source = "agent_scheduler"`, `metadata.origin = "agent_scheduler"`, `metadata.schedulerEntryId`, and `metadata.createdByAgentId` when present - task entries call `TaskRerunService.rerunTask`, passing the stored provider override when one was scheduled +When an agent schedules a wakeup with `wakeAfterReply: true`, the entry is stored as due now. `ChatThreadRuntimeService` drains due scheduler entries after the current dashboard reply finishes and its in-flight turn is cleared, so the scheduled wakeup can start the next turn immediately without superseding the reply that created it. + AI memory remediation entries create a `remediation` invocation record even when no cleanup candidates are found; in that case the invocation is completed with a skipped reason instead of dispatching an empty provider request. After a successful run, the service advances `nextRunAt` from the scheduled occurrence time. One-time entries move to `completed`; recurring entries stay `scheduled` until their count or end date/time is exhausted. Failed entries move to `failed` with `lastError` for operator visibility. Node-flow entries are durably claimed before `runFlow` is awaited so the same due occurrence is not dispatched again after a restart, then the scheduler entry is finalized from the returned node-flow run status. @@ -163,10 +166,12 @@ Behavior: Anchored entries are evaluated separately from absolute `nextRunAt` polling: - An `after_sprint_end` entry is due only after the source sprint reaches `completed`, `failed`, or `cancelled`. - The anchor timestamp is the latest terminal sprint run `finishedAt` when a terminal run exists; otherwise the scheduler falls back to the sprint `endDate`. +- An `after_task_end` entry is due only after the source task reaches `completed` or `QA_REVIEW_FAILED`. +- The task anchor timestamp is the latest terminal task run `finishedAt` when one exists, then the latest terminal task dispatch `finishedAt`, and finally the task `updatedAt` fallback. - The optional offset is applied after that terminal timestamp. -- Anchored entries are one-time entries. Recurrence is rejected for `after_sprint_end` because repeated execution would be ambiguous without a new recurrence anchor model. +- Anchored entries are one-time entries. Recurrence is rejected for sprint and task anchors because repeated execution would be ambiguous without a new recurrence anchor model. - When the scheduled target is also a sprint, the target sprint cannot be the same sprint used as the source anchor. - Project isolation is strict: source sprints from another project are rejected, sprint targets must belong to the selected project, task targets must reference a task in the selected project, and node-flow targets must reference a flow in the selected project. - Agent wakeups require non-empty `bodyMarkdown`. -The MCP `manage_scheduler` tool accepts the same model. Use `scheduleMode: "after_sprint_end"` or `anchorMode: "after_sprint_end"` with `sourceSprintId`/`anchorSourceSprintId` and optional `offsetMinutes`/`anchorOffsetMinutes`, or pass the nested `scheduleAnchor` object directly. Absolute schedules continue to use `scheduledFor`; `scheduleMode: "absolute"` on update clears an existing anchor. Node flows can be scheduled with `schedule_node_flow` or generic `create` plus `targetType: "node_flow"`, using flattened `flowId`/`input` fields or nested `nodeFlowTarget`. +The MCP `manage_scheduler` tool accepts the same broad scheduler model for dashboard-managed targets. Use `scheduleMode` or `anchorMode` with `after_sprint_end` plus `sourceSprintId`/`anchorSourceSprintId`, or `after_task_end` plus `sourceTaskId`/`anchorSourceTaskId`; both anchor modes accept optional `offsetMinutes`/`anchorOffsetMinutes`, and callers may pass the nested `scheduleAnchor` object directly. Absolute schedules continue to use `scheduledFor`; `scheduleMode: "absolute"` on update clears an existing anchor. Node flows can be scheduled with `schedule_node_flow` or generic `create` plus `targetType: "node_flow"`, using flattened `flowId`/`input` fields or nested `nodeFlowTarget`. The restricted `scheduler_code_ux` tool exposes only agent-owned wakeups and maps `afterSprintId`, `afterTaskId`, and `wakeAfterReply` into this scheduler model. diff --git a/docs/mcp/runtime-and-dispatch.md b/docs/mcp/runtime-and-dispatch.md index 1182f97987..660bf31024 100644 --- a/docs/mcp/runtime-and-dispatch.md +++ b/docs/mcp/runtime-and-dispatch.md @@ -143,7 +143,7 @@ Agent-scoped provider runs are default-deny for built-in Code UX tools. Missing, The built-in `Worker` and `Project manager` agents still seed the `playwright` custom MCP server where that link is intended, but this custom-server default no longer implies built-in Code UX tool access. Generated task-coding roster agents created by Project Setup use the same custom-server-only default when they are first created. Planning, QA, setup, clarification, CI-fix, merge-conflict, and other non-chat agents do not receive scheduler or management Code UX tools unless their preset explicitly enables them. When Code UX is enabled from the agent MCP manager for a non-dashboard agent, the generated default keeps the restricted `scheduler_code_ux` tool explicitly disabled until the user enables it. -The dashboard chat reply route has one narrow exception. Every dashboard chat turn receives the normal Code UX MCP project-manager surface plus the restricted `scheduler_code_ux` tool, regardless of whether the selected reply preset has Code UX disabled or a narrower saved access record. The provider run still receives the selected agent's linked custom MCP servers, but the built-in `code_ux` connection is injected without an `X-Code-Ux-Agent` header so saved per-agent toggles cannot hide project, sprint, task, settings, preview, or Code UX scheduler tools from dashboard chat. This exception is route-local; it does not change the selected agent preset's saved MCP defaults or other provider runs. +The dashboard chat reply route has one narrow exception. Every assigned dashboard reply agent receives the full built-in Code UX MCP surface plus `scheduler_code_ux` by default, even when the selected reply preset has Code UX disabled. The provider run still receives the selected agent's linked custom MCP servers and sends the assigned agent id through `X-Code-Ux-Agent`; the MCP router recognizes the assigned dashboard reply agent and applies this route-local full-access default. When the preset explicitly enables Code UX access with narrower saved tool choices, the router preserves those saved choices and forces `scheduler_code_ux` on for dashboard replies. ## Internal Test Provider diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index 0a3c445e20..35ff51fd16 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -159,11 +159,18 @@ Tool arguments are validated against `src/contracts/mcp-tool-definitions.ts` bef Code UX exposes two scheduler MCP surfaces: - `manage_scheduler` is the project-manager management surface. It can list, create, schedule sprints, schedule quicksprints, schedule chat messages, update entries, delete entries with approval, and run due entries. It remains unchanged for project-manager clients. -- `scheduler_code_ux` is the restricted agent-owned surface. It supports only `list`, `schedule_wakeup`, `schedule_task`, and `cancel`. The Code UX suffix intentionally avoids collisions with scheduler tools exposed by provider CLIs or other MCP servers. +- `scheduler_code_ux` is the restricted agent-owned surface. It supports only `list`, `schedule_wakeup`, and `cancel`. The Code UX suffix intentionally avoids collisions with scheduler tools exposed by provider CLIs or other MCP servers. -The restricted `scheduler_code_ux` tool accepts either an absolute `scheduledFor` ISO timestamp or one positive relative delay field, `delaySeconds` or `delayMinutes`. `schedule_wakeup` requires `projectId` and `bodyMarkdown`, and may include `title`, `timezone`, `threadId`, and `connectionId`. `schedule_task` requires `projectId` and `taskId`, and may include `title`, `timezone`, and a provider override. +The restricted `scheduler_code_ux` tool accepts exactly one wakeup timing mode: +- `scheduledFor`: absolute ISO timestamp. +- `delaySeconds` or `delayMinutes`: positive relative delay. +- `wakeAfterReply: true`: schedule the wakeup for the current time so the dashboard chat runtime drains it immediately after the current reply is sent. +- `afterSprintId`: wake after the referenced sprint reaches a terminal state, with optional non-negative `offsetMinutes`. +- `afterTaskId`: wake after the referenced task reaches a terminal project status, with optional non-negative `offsetMinutes`. -Every `scheduler_code_ux` entry is persisted as an `agent_scheduler` target. The runtime stamps `origin: "agent_scheduler"`, `source: "agent_scheduler"`, and `createdByAgentId` from the current MCP agent context. `list` returns only entries created by the calling agent. `cancel` changes the matching entry status to `cancelled` only when the entry is an agent-scheduler wakeup or task entry created by that same agent. Dashboard-created entries, `manage_scheduler` entries, entries without agent-scheduler metadata, and entries created by another agent are rejected with the standard management validation envelope. +`schedule_wakeup` requires `projectId` and `bodyMarkdown`, and may include `title`, `timezone`, `threadId`, and `connectionId`. Completion anchors are persisted as `scheduleAnchor` payloads: `afterSprintId` maps to `{ mode: "after_sprint_end", sourceSprintId, offsetMinutes? }`, and `afterTaskId` maps to `{ mode: "after_task_end", sourceTaskId, offsetMinutes? }`. + +Every `scheduler_code_ux` entry is persisted as an `agent_scheduler` wakeup target. The runtime stamps `origin: "agent_scheduler"`, `source: "agent_scheduler"`, and `createdByAgentId` from the current MCP agent context. `list` returns only wakeup entries created by the calling agent. `cancel` changes the matching entry status to `cancelled` only when the entry is an agent-scheduler wakeup created by that same agent. Dashboard-created entries, `manage_scheduler` entries, entries without agent-scheduler metadata, task entries, and entries created by another agent are rejected with the standard management validation envelope. The restricted tool intentionally does not expose due-entry execution, arbitrary update, recurrence editing, sprint scheduling, quicksprint scheduling, memory remediation scheduling, or global scheduler destructive controls. diff --git a/docs/settings/subcategories/built-in-mcp.md b/docs/settings/subcategories/built-in-mcp.md index 5fc179fc02..21f66b2d78 100644 --- a/docs/settings/subcategories/built-in-mcp.md +++ b/docs/settings/subcategories/built-in-mcp.md @@ -8,7 +8,7 @@ Tool-category and individual-tool toggles decide what trusted provider and proje ## Recommended Defaults -Keep the global surface aligned with project-manager workflows. Dashboard chat receives Code UX MCP plus scheduler at runtime. For individual non-dashboard agents, start with Code UX disabled; if built-in tools are enabled, keep scheduler disabled unless the preset specifically needs agent-owned wakeups or task reruns. +Keep the global surface aligned with project-manager workflows. Dashboard chat receives the full built-in Code UX MCP surface plus scheduler by default for the assigned reply agent. For individual non-dashboard agents, start with Code UX disabled; if built-in tools are enabled, keep scheduler disabled unless the preset specifically needs agent-owned wakeups. ## Risks And Gotchas diff --git a/src/app/dependency-factory/dashboard-factory.ts b/src/app/dependency-factory/dashboard-factory.ts index d1bf02009b..230f00faf7 100644 --- a/src/app/dependency-factory/dashboard-factory.ts +++ b/src/app/dependency-factory/dashboard-factory.ts @@ -163,6 +163,12 @@ export function createDashboardDependencies( knowledgeService: coreDeps.knowledgeService, getMcpConnectionInfo: context.getMcpConnectionInfo, getMcpApprovalTracker: context.getMcpApprovalTracker, + runDueSchedulerEntriesAfterReply: async () => { + if (!schedulerServiceRef.isLinked()) { + return; + } + await schedulerServiceRef.get().runDueEntries(); + }, logger: logger.child({ component: "chat-thread-runtime-service" }), }); diff --git a/src/contracts/internal-management-types.ts b/src/contracts/internal-management-types.ts index 21b0ba408f..187884e006 100644 --- a/src/contracts/internal-management-types.ts +++ b/src/contracts/internal-management-types.ts @@ -1,4 +1,3 @@ -import type { ProviderId } from "./app-types.js"; import type { AgentMcpAccessConfig } from "./agent-preset-types.js"; import type { ChatProviderBridgeMode, @@ -132,6 +131,15 @@ export interface ManageSchedulerArgs { targetType?: "sprint" | "quicksprint" | "chat" | "node_flow"; status?: "scheduled" | "paused" | "completed" | "failed" | "cancelled"; scheduledFor?: string; + scheduleMode?: "absolute" | "after_sprint_end" | "after_task_end"; + anchorMode?: "after_sprint_end" | "after_task_end"; + scheduleAnchor?: Record; + sourceSprintId?: string; + anchorSourceSprintId?: string; + sourceTaskId?: string; + anchorSourceTaskId?: string; + offsetMinutes?: number | string; + anchorOffsetMinutes?: number | string; timezone?: string; recurrence?: Record; sprintTarget?: Record; @@ -156,7 +164,7 @@ export interface ManageSchedulerArgs { } export interface SchedulerArgs { - action: "list" | "schedule_wakeup" | "schedule_task" | "cancel"; + action: "list" | "schedule_wakeup" | "cancel"; projectId?: string; entryId?: string; from?: string; @@ -164,13 +172,15 @@ export interface SchedulerArgs { scheduledFor?: string; delaySeconds?: number | string; delayMinutes?: number | string; + wakeAfterReply?: boolean; + afterSprintId?: string; + afterTaskId?: string; + offsetMinutes?: number | string; title?: string; timezone?: string; bodyMarkdown?: string; threadId?: string | null; connectionId?: string | null; - taskId?: string; - provider?: ProviderId; } export interface ManageAgentsArgs { diff --git a/src/contracts/mcp-tool-definitions.ts b/src/contracts/mcp-tool-definitions.ts index 239105758a..af814b9de0 100644 --- a/src/contracts/mcp-tool-definitions.ts +++ b/src/contracts/mcp-tool-definitions.ts @@ -222,7 +222,7 @@ export const TOOL_DEFINITIONS = [ name: "manage_scheduler", runtimeRoles: ["project_manager"], category: "orchestration", - description: "Manage Code UX scheduler entries. Used to list, create, schedule_sprint, schedule_quicksprint, schedule_chat, schedule_node_flow, update, delete, and run_due. Entries can use an absolute scheduledFor time or an after_sprint_end anchor. Scheduled chat entries post messages through the chat runtime when due. Scheduled node flows run through the node flow runtime when due. Deleting entries requires approval.", + description: "Manage Code UX scheduler entries. Used to list, create, schedule_sprint, schedule_quicksprint, schedule_chat, schedule_node_flow, update, delete, and run_due. Entries can use an absolute scheduledFor time, an after_sprint_end anchor, or an after_task_end anchor. Scheduled chat entries post messages through the chat runtime when due. Scheduled node flows run through the node flow runtime when due. Deleting entries requires approval.", inputSchema: { type: "object", properties: { @@ -234,14 +234,16 @@ export const TOOL_DEFINITIONS = [ title: { type: "string", description: "Optional scheduler entry title. For schedule_chat this can also become the chat thread title." }, targetType: { type: "string", enum: ["sprint", "quicksprint", "chat", "node_flow"], description: "Required for generic create. schedule_* actions infer this." }, status: { type: "string", enum: ["scheduled", "paused", "completed", "failed", "cancelled"], description: "Optional for update." }, - scheduledFor: { type: "string", description: "Required ISO date for absolute create/schedule actions. Optional for update or after_sprint_end anchored entries." }, - scheduleMode: { type: "string", enum: ["absolute", "after_sprint_end"], description: "Optional schedule mode. Defaults to absolute when scheduledFor is used." }, - anchorMode: { type: "string", enum: ["after_sprint_end"], description: "Alias for scheduleMode when creating or updating an anchored entry." }, - scheduleAnchor: { type: "object", additionalProperties: true, description: "Nested anchor payload. For after_sprint_end, requires mode and sourceSprintId; supports offsetMinutes." }, + scheduledFor: { type: "string", description: "Required ISO date for absolute create/schedule actions. Optional for update or anchored entries." }, + scheduleMode: { type: "string", enum: ["absolute", "after_sprint_end", "after_task_end"], description: "Optional schedule mode. Defaults to absolute when scheduledFor is used." }, + anchorMode: { type: "string", enum: ["after_sprint_end", "after_task_end"], description: "Alias for scheduleMode when creating or updating an anchored entry." }, + scheduleAnchor: { type: "object", additionalProperties: true, description: "Nested anchor payload. For after_sprint_end, requires mode and sourceSprintId; for after_task_end, requires mode and sourceTaskId; supports offsetMinutes." }, sourceSprintId: { type: "string", description: "Flattened source sprint id for after_sprint_end anchored schedules." }, anchorSourceSprintId: { type: "string", description: "Alias for sourceSprintId for after_sprint_end anchored schedules." }, - offsetMinutes: { type: ["number", "string"], description: "Non-negative delay in minutes after the source sprint ends before the entry becomes due." }, - anchorOffsetMinutes: { type: ["number", "string"], description: "Alias for offsetMinutes for after_sprint_end anchored schedules." }, + sourceTaskId: { type: "string", description: "Flattened source task id for after_task_end anchored schedules." }, + anchorSourceTaskId: { type: "string", description: "Alias for sourceTaskId for after_task_end anchored schedules." }, + offsetMinutes: { type: ["number", "string"], description: "Non-negative delay in minutes after the source sprint or task ends before the entry becomes due." }, + anchorOffsetMinutes: { type: ["number", "string"], description: "Alias for offsetMinutes for anchored schedules." }, timezone: { type: "string", description: "Optional timezone identifier; defaults to UTC." }, recurrence: { type: "object", additionalProperties: true, description: "Optional recurrence rule with frequency, interval, endMode, count, and until." }, sprintTarget: { type: "object", additionalProperties: true, description: "Nested sprint target. Requires sprintId." }, @@ -277,26 +279,28 @@ export const TOOL_DEFINITIONS = [ name: "scheduler_code_ux", runtimeRoles: ["project_manager"], category: "orchestration", - description: "Schedule agent-owned wakeups and task reruns. Supports list, schedule_wakeup, schedule_task, and cancel only. Cancellation is limited to agent_scheduler entries created by the calling agent.", + description: "Schedule agent-owned wakeups. Supports list, schedule_wakeup, and cancel only. Cancellation is limited to agent_scheduler wakeup entries created by the calling agent.", inputSchema: { type: "object", additionalProperties: false, properties: { - action: { type: "string", enum: ["list", "schedule_wakeup", "schedule_task", "cancel"], description: "The restricted agent scheduler action to perform." }, - projectId: { type: "string", description: "Required for list, schedule_wakeup, and schedule_task." }, + action: { type: "string", enum: ["list", "schedule_wakeup", "cancel"], description: "The restricted agent scheduler action to perform." }, + projectId: { type: "string", description: "Required for list and schedule_wakeup." }, entryId: { type: "string", description: "Required for cancel." }, from: { type: "string", description: "Optional ISO start for list window." }, to: { type: "string", description: "Optional ISO end for list window." }, - scheduledFor: { type: "string", description: "Absolute ISO time for schedule_wakeup and schedule_task. Mutually exclusive with relative delays." }, - delaySeconds: { type: ["number", "string"], description: "Positive relative delay in seconds for schedule_wakeup and schedule_task." }, - delayMinutes: { type: ["number", "string"], description: "Positive relative delay in minutes for schedule_wakeup and schedule_task." }, + scheduledFor: { type: "string", description: "Absolute ISO time for schedule_wakeup. Mutually exclusive with relative delays, wakeAfterReply, and finish anchors." }, + delaySeconds: { type: ["number", "string"], description: "Positive relative delay in seconds for schedule_wakeup." }, + delayMinutes: { type: ["number", "string"], description: "Positive relative delay in minutes for schedule_wakeup." }, + wakeAfterReply: { type: "boolean", description: "For schedule_wakeup, wake the agent as soon as the current reply has been sent. Mutually exclusive with scheduledFor, delaySeconds, delayMinutes, afterSprintId, and afterTaskId." }, + afterSprintId: { type: "string", description: "For schedule_wakeup, wake after this sprint reaches a terminal state. Mutually exclusive with absolute/relative time and afterTaskId." }, + afterTaskId: { type: "string", description: "For schedule_wakeup, wake after this task reaches a terminal project status. Mutually exclusive with absolute/relative time and afterSprintId." }, + offsetMinutes: { type: ["number", "string"], description: "Optional non-negative delay after afterSprintId or afterTaskId completes." }, title: { type: "string", description: "Optional scheduler entry title. Wakeups also use this as the chat thread title when applicable." }, timezone: { type: "string", description: "Optional timezone identifier; defaults to UTC." }, bodyMarkdown: { type: "string", description: "Required for schedule_wakeup." }, threadId: { type: ["string", "null"], description: "Optional existing chat thread id for schedule_wakeup." }, connectionId: { type: ["string", "null"], description: "Optional chat connection id for schedule_wakeup." }, - taskId: { type: "string", description: "Required for schedule_task." }, - provider: { type: "string", enum: ["jules", "gemini", "codex", "claude-code", "qwen-code", "opencode", "antigravity", "mockup-cli"], description: "Optional provider override for schedule_task." }, }, required: ["action"], }, diff --git a/src/contracts/scheduler-types.ts b/src/contracts/scheduler-types.ts index d51c114468..366f3d9968 100644 --- a/src/contracts/scheduler-types.ts +++ b/src/contracts/scheduler-types.ts @@ -6,7 +6,7 @@ export type ScheduleTargetType = "sprint" | "quicksprint" | "chat" | "memory_rem export type ScheduleStatus = "scheduled" | "paused" | "completed" | "failed" | "cancelled"; export type ScheduleRecurrenceFrequency = "none" | "minutely" | "hourly" | "daily" | "weekly" | "monthly"; export type ScheduleRecurrenceEndMode = "never" | "after_count" | "on_date"; -export type ScheduleAnchorMode = "after_sprint_end"; +export type ScheduleAnchorMode = "after_sprint_end" | "after_task_end"; export type ScheduleAgentSchedulerSource = "agent_scheduler"; export interface ScheduleRecurrenceRule { @@ -23,7 +23,13 @@ export interface ScheduleAfterSprintEndAnchor { offsetMinutes?: number; } -export type ScheduleAnchor = ScheduleAfterSprintEndAnchor; +export interface ScheduleAfterTaskEndAnchor { + mode: "after_task_end"; + sourceTaskId: string; + offsetMinutes?: number; +} + +export type ScheduleAnchor = ScheduleAfterSprintEndAnchor | ScheduleAfterTaskEndAnchor; export interface ScheduleSprintTarget { sprintId: string; diff --git a/src/mcp/management/agent-scheduler-actions.ts b/src/mcp/management/agent-scheduler-actions.ts index 161bdfe9f9..4a8a6528f0 100644 --- a/src/mcp/management/agent-scheduler-actions.ts +++ b/src/mcp/management/agent-scheduler-actions.ts @@ -1,9 +1,8 @@ import type { SchedulerArgs, ManagementResponseEnvelope } from "../../contracts/internal-management-types.js"; -import type { ProviderId } from "../../contracts/app-types.js"; import type { CreateSchedulerEntryInput, + ScheduleAnchor, ScheduleAgentWakeupTarget, - ScheduleTaskTarget, SchedulerCollectionResponse, SchedulerEntryRecord, } from "../../contracts/scheduler-types.js"; @@ -18,16 +17,6 @@ import { const DEFAULT_LIST_WINDOW_PAST_DAYS = 7; const DEFAULT_LIST_WINDOW_FUTURE_DAYS = 35; const AGENT_SCHEDULER_SOURCE = "agent_scheduler" as const; -const VALID_PROVIDER_IDS = new Set([ - "jules", - "gemini", - "codex", - "claude-code", - "qwen-code", - "opencode", - "antigravity", - "mockup-cli", -]); function defaultFrom(): string { const date = new Date(); @@ -64,11 +53,59 @@ function parsePositiveDelaySeconds(payload: Record): number | u return Math.ceil(value * (key === "delayMinutes" ? 60 : 1)); } +function parseBoolean(payload: Record, key: string): boolean { + const raw = payload[key]; + return raw === true || (typeof raw === "string" && raw.trim().toLowerCase() === "true"); +} + +function parseNonNegativeInteger(payload: Record, key: string): number | undefined { + const raw = payload[key]; + if (raw === undefined || raw === null || raw === "") { + return undefined; + } + const value = typeof raw === "number" + ? raw + : typeof raw === "string" && raw.trim() + ? Number(raw.trim()) + : Number.NaN; + if (!Number.isFinite(value) || value < 0) { + throw managementValidationError(`${key} must be a non-negative number.`, key); + } + return Math.floor(value); +} + +function buildScheduleAnchor(payload: Record): ScheduleAnchor | undefined { + const afterSprintId = parseOptionalString(payload, "afterSprintId"); + const afterTaskId = parseOptionalString(payload, "afterTaskId"); + if (afterSprintId && afterTaskId) { + throw managementValidationError("Provide only one of afterSprintId or afterTaskId.", "afterSprintId"); + } + const offsetMinutes = parseNonNegativeInteger(payload, "offsetMinutes"); + if (offsetMinutes !== undefined && !afterSprintId && !afterTaskId) { + throw managementValidationError("offsetMinutes requires afterSprintId or afterTaskId.", "offsetMinutes"); + } + if (afterSprintId) { + return { mode: "after_sprint_end", sourceSprintId: afterSprintId, offsetMinutes }; + } + if (afterTaskId) { + return { mode: "after_task_end", sourceTaskId: afterTaskId, offsetMinutes }; + } + return undefined; +} + function normalizeScheduledFor(payload: Record, now = new Date()): string { const scheduledFor = parseOptionalString(payload, "scheduledFor"); const delaySeconds = parsePositiveDelaySeconds(payload); - if (scheduledFor && delaySeconds !== undefined) { - throw managementValidationError("Provide scheduledFor or a relative delay, not both.", "scheduledFor"); + const wakeAfterReply = parseBoolean(payload, "wakeAfterReply"); + const scheduleAnchor = buildScheduleAnchor(payload); + const timingModes = [ + scheduledFor ? "scheduledFor" : null, + delaySeconds !== undefined ? "relative delay" : null, + wakeAfterReply ? "wakeAfterReply" : null, + scheduleAnchor ? "finish anchor" : null, + ].filter(Boolean); + if (timingModes.length > 1) { + throw managementValidationError("Provide exactly one wakeup timing mode: scheduledFor, delaySeconds/delayMinutes, wakeAfterReply, afterSprintId, or afterTaskId.", "scheduledFor"); } if (scheduledFor) { const parsed = new Date(scheduledFor); @@ -80,7 +117,10 @@ function normalizeScheduledFor(payload: Record, now = new Date( if (delaySeconds !== undefined) { return new Date(now.getTime() + delaySeconds * 1000).toISOString(); } - throw managementValidationError("scheduledFor, delaySeconds, or delayMinutes is required.", "scheduledFor"); + if (wakeAfterReply || scheduleAnchor) { + return now.toISOString(); + } + throw managementValidationError("scheduledFor, delaySeconds, delayMinutes, wakeAfterReply, afterSprintId, or afterTaskId is required.", "scheduledFor"); } function isOwnAgentSchedulerEntry(entry: SchedulerEntryRecord, agentId: string): boolean { @@ -89,11 +129,6 @@ function isOwnAgentSchedulerEntry(entry: SchedulerEntryRecord, agentId: string): && entry.agentWakeupTarget.source === AGENT_SCHEDULER_SOURCE && entry.agentWakeupTarget.createdByAgentId === agentId; } - if (entry.targetType === "task") { - return entry.taskTarget?.origin === AGENT_SCHEDULER_SOURCE - && entry.taskTarget.source === AGENT_SCHEDULER_SOURCE - && entry.taskTarget.createdByAgentId === agentId; - } return false; } @@ -111,24 +146,15 @@ function buildBaseInput(payload: Record, now?: Date): Omit = { scheduledFor: normalizeScheduledFor(payload, now), }; + const scheduleAnchor = buildScheduleAnchor(payload); const title = parseOptionalString(payload, "title"); const timezone = parseOptionalString(payload, "timezone"); + if (scheduleAnchor) input.scheduleAnchor = scheduleAnchor; if (title) input.title = title; if (timezone) input.timezone = timezone; return input; } -function readProvider(payload: Record): ProviderId | undefined { - const provider = parseOptionalString(payload, "provider"); - if (!provider) { - return undefined; - } - if (!VALID_PROVIDER_IDS.has(provider as ProviderId)) { - throw managementValidationError("Invalid value for provider.", "provider"); - } - return provider as ProviderId; -} - export class AgentSchedulerActions { constructor( private readonly schedulerService: SchedulerService, @@ -173,29 +199,11 @@ export class AgentSchedulerActions { }); return { result: { entry } }; } - case "schedule_task": { - const projectId = parseRequiredString(payload, "projectId"); - const taskTarget: ScheduleTaskTarget = { - taskId: parseRequiredString(payload, "taskId"), - origin: AGENT_SCHEDULER_SOURCE, - source: AGENT_SCHEDULER_SOURCE, - createdByAgentId: agentId, - }; - const provider = readProvider(payload); - if (provider) taskTarget.provider = provider; - - const entry = this.schedulerService.createEntry(projectId, { - ...buildBaseInput(payload, this.getNow()), - targetType: "task", - taskTarget, - }); - return { result: { entry } }; - } case "cancel": { const entryId = parseRequiredString(payload, "entryId"); const entry = this.schedulerService.getEntry(entryId); if (!entry || !isOwnAgentSchedulerEntry(entry, agentId)) { - throw managementValidationError("Only agent_scheduler entries created by the calling agent can be cancelled.", "entryId"); + throw managementValidationError("Only agent_scheduler wakeup entries created by the calling agent can be cancelled.", "entryId"); } const updated = this.schedulerService.updateEntry(entryId, { status: "cancelled" }); return { result: { status: "success", entry: updated } }; diff --git a/src/mcp/management/scheduler-actions.ts b/src/mcp/management/scheduler-actions.ts index a1824636ad..42984406ee 100644 --- a/src/mcp/management/scheduler-actions.ts +++ b/src/mcp/management/scheduler-actions.ts @@ -117,20 +117,37 @@ function readScheduleAnchor(payload: Record): ScheduleAnchor | const nested = readObject(payload, "scheduleAnchor"); const source = nested ?? payload; const nestedMode = nested ? readString(nested, "mode") : undefined; - const effectiveMode = nestedMode ?? mode; + const effectiveMode = nestedMode + ?? mode + ?? (("sourceTaskId" in source || "anchorSourceTaskId" in source) ? "after_task_end" : undefined); const hasAnchorFields = Boolean( nested || effectiveMode === "after_sprint_end" + || effectiveMode === "after_task_end" || "sourceSprintId" in payload || "anchorSourceSprintId" in payload + || "sourceTaskId" in payload + || "anchorSourceTaskId" in payload || "offsetMinutes" in payload || "anchorOffsetMinutes" in payload ); if (!hasAnchorFields) { return undefined; } - if (effectiveMode && effectiveMode !== "after_sprint_end") { - throw new Error("scheduleMode must be absolute or after_sprint_end"); + if (effectiveMode && effectiveMode !== "after_sprint_end" && effectiveMode !== "after_task_end") { + throw new Error("scheduleMode must be absolute, after_sprint_end, or after_task_end"); + } + if (effectiveMode === "after_task_end") { + const sourceTaskId = readString(source, "sourceTaskId") ?? readString(source, "anchorSourceTaskId"); + if (!sourceTaskId) { + throw new Error("sourceTaskId or scheduleAnchor.sourceTaskId is required for after_task_end schedules"); + } + const offsetMinutes = parseNonNegativeInteger(source.offsetMinutes ?? source.anchorOffsetMinutes); + return { + mode: "after_task_end", + sourceTaskId, + offsetMinutes: offsetMinutes ?? 0, + }; } const sourceSprintId = readString(source, "sourceSprintId") ?? readString(source, "anchorSourceSprintId"); if (!sourceSprintId) { diff --git a/src/repositories/scheduler-repository.ts b/src/repositories/scheduler-repository.ts index f3de02937b..fff9c56e63 100644 --- a/src/repositories/scheduler-repository.ts +++ b/src/repositories/scheduler-repository.ts @@ -519,18 +519,29 @@ export class SchedulerRepository { if (!value) { return undefined; } - if (value.mode !== "after_sprint_end") { - throw new ValidationError("scheduleAnchor.mode must be after_sprint_end."); - } - const sourceSprintId = value.sourceSprintId?.trim(); - if (!sourceSprintId) { - throw new ValidationError("scheduleAnchor.sourceSprintId is required."); + if (value.mode !== "after_sprint_end" && value.mode !== "after_task_end") { + throw new ValidationError("scheduleAnchor.mode must be after_sprint_end or after_task_end."); } const rawOffset = value.offsetMinutes ?? 0; const offsetMinutes = Number(rawOffset); if (!Number.isFinite(offsetMinutes) || offsetMinutes < 0) { throw new ValidationError("scheduleAnchor.offsetMinutes must be a non-negative number."); } + if (value.mode === "after_task_end") { + const sourceTaskId = value.sourceTaskId?.trim(); + if (!sourceTaskId) { + throw new ValidationError("scheduleAnchor.sourceTaskId is required."); + } + return { + mode: "after_task_end", + sourceTaskId, + offsetMinutes: Math.floor(offsetMinutes), + }; + } + const sourceSprintId = value.sourceSprintId?.trim(); + if (!sourceSprintId) { + throw new ValidationError("scheduleAnchor.sourceSprintId is required."); + } return { mode: "after_sprint_end", sourceSprintId, @@ -540,7 +551,7 @@ export class SchedulerRepository { private validateAnchorRecurrence(scheduleAnchor: ScheduleAnchor | undefined, recurrence: ScheduleRecurrenceRule): void { if (scheduleAnchor && recurrence.frequency !== "none") { - throw new ValidationError("after_sprint_end scheduler anchors do not support recurrence."); + throw new ValidationError("Scheduler anchors do not support recurrence."); } } diff --git a/src/server/code-ux-server.ts b/src/server/code-ux-server.ts index 22124ea3bf..cff48add87 100644 --- a/src/server/code-ux-server.ts +++ b/src/server/code-ux-server.ts @@ -44,7 +44,7 @@ import { ActivityCacheService } from "./activity-cache-service.js"; import { registerMcpRequestHandlers } from "./mcp-request-router.js"; import { TaskRerunService } from "../services/task-rerun-service.js"; import { ExecutionControlService } from "../services/execution-control-service.js"; -import { toAgentCodeUxToolAccess } from "../services/agent-mcp-access.js"; +import { dashboardReplyAgentMcpAccess, toAgentCodeUxToolAccess } from "../services/agent-mcp-access.js"; import { JulesSourceResolver } from "../services/jules-source-resolver.js"; import { RuntimeCleanupService } from "../services/runtime-cleanup-service.js"; import { RuntimeStartupRecoveryService } from "../services/runtime-startup-recovery-service.js"; @@ -456,7 +456,9 @@ export class CodeUxServer { getRuntimeRole: () => runtimeRole, resolveAgentMcpToolAccess: (agentId) => { const agent = this.agentPresetRepository.getAgentPreset(agentId); - const access = agent?.mcpAccess; + const access = agent && this.isDashboardReplyRouteAgent(agent) + ? dashboardReplyAgentMcpAccess(agent.mcpAccess) + : agent?.mcpAccess; const persistentSkillRetrievalEnabled = Boolean( agent?.persistentSkillStorage?.enabled && agent.persistentSkillStorageIds @@ -479,6 +481,25 @@ export class CodeUxServer { }; } + private isDashboardReplyRouteAgent(agent: { id: string; projectId: string; name: string }): boolean { + try { + const settings = this.settingsRepository.resolveProjectDashboardSettings(agent.projectId).settings; + const assignedAgentId = settings.agents?.routing?.dashboardReply?.agentPresetId ?? null; + if (assignedAgentId) { + return assignedAgentId === agent.id; + } + const normalizedName = agent.name.trim().toLowerCase(); + return normalizedName === "project manager" || normalizedName === "iris"; + } catch (error) { + this.logger.warn("Failed to resolve dashboard reply agent MCP defaults", { + projectId: agent.projectId, + agentId: agent.id, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + private createMcpServerInstance(runtimeRole: "project_manager"): Server { const server = new Server( { diff --git a/src/services/agent-mcp-access.ts b/src/services/agent-mcp-access.ts index 9896f904e7..4b3db3a5a8 100644 --- a/src/services/agent-mcp-access.ts +++ b/src/services/agent-mcp-access.ts @@ -57,6 +57,20 @@ export const schedulerOnlyAgentMcpAccess = (linkedServerIds: readonly string[] = linkedServerIds: normalizeLinkedServerIds(linkedServerIds), }); +export const dashboardReplyAgentMcpAccess = (access: AgentMcpAccessConfig | null | undefined): AgentMcpAccessConfig => { + if (!access?.codeUxEnabled) { + return codeUxAgentMcpAccess(access?.linkedServerIds ?? []); + } + const byName = new Map(access.codeUxToolToggles.map((toggle) => [toggle.name, toggle])); + byName.set("scheduler_code_ux", { name: "scheduler_code_ux", enabled: true, isInternal: true }); + return { + ...access, + codeUxEnabled: true, + linkedServerIds: normalizeLinkedServerIds(access.linkedServerIds), + codeUxToolToggles: Array.from(byName.values()), + }; +}; + export const isSchedulerOnlyAgentMcpAccess = ( access: Pick, ): boolean => { diff --git a/src/services/chat-reply-prompt.ts b/src/services/chat-reply-prompt.ts index cb3b98630a..43b7aed263 100644 --- a/src/services/chat-reply-prompt.ts +++ b/src/services/chat-reply-prompt.ts @@ -113,7 +113,7 @@ function buildMcpNativeOutputInstructions(): string { return [ "You have the `manage_code_ux` MCP tool available. Use it directly to perform management actions.", "You also have dedicated Code UX management tools when listed by MCP, including `manage_custom_dashboards` for custom dashboard management.", - "You also have the `scheduler_code_ux` MCP tool available for agent-owned follow-ups, wakeups, and task reruns.", + "You also have the `scheduler_code_ux` MCP tool available for agent-owned follow-up wakeups.", "", "The tool accepts: `{ domain, action, payload }` where:", "- **projects**: `list` (projectId), `get` (projectId), `create` (projectId, name, baseDir), `update` (projectId, ...), `select` (projectId), `delete` (projectId)", @@ -157,7 +157,7 @@ function buildSchedulerOnlyOutputInstructions(): string { return [ "You have the `scheduler_code_ux` MCP tool available for agent-owned follow-ups only.", "", - "Use it only when you need to schedule your own future wakeup or task rerun. It supports `list`, `schedule_wakeup`, `schedule_task`, and `cancel`.", + "Use it only when you need to schedule your own future wakeup. It supports `list`, `schedule_wakeup`, and `cancel`.", "You do not have broad Code UX management tools in this route. Do not call `manage_code_ux`, `manage_scheduler`, `manage_tasks`, `manage_sprints`, or `manage_settings`.", "Respond with plain markdown text. Do NOT wrap your response in JSON.", ].join("\n"); diff --git a/src/services/chat-thread-runtime-service.ts b/src/services/chat-thread-runtime-service.ts index bef6185a93..1de810126b 100644 --- a/src/services/chat-thread-runtime-service.ts +++ b/src/services/chat-thread-runtime-service.ts @@ -46,7 +46,7 @@ import type { McpConnectionInfo } from "../contracts/mcp-connection-types.js"; import type { McpApprovalTracker } from "./mcp-approval-tracker.js"; import { getCorrelationId } from "../shared/logging/correlation-id.js"; import type { AgentMcpAccessConfig } from "../contracts/agent-preset-types.js"; -import { codeUxAgentMcpAccess } from "./agent-mcp-access.js"; +import { dashboardReplyAgentMcpAccess, isSchedulerOnlyAgentMcpAccess } from "./agent-mcp-access.js"; interface ChatThreadRuntimeServiceDependencies { connectionChatRepository: ConnectionChatRepository; @@ -63,6 +63,7 @@ interface ChatThreadRuntimeServiceDependencies { knowledgeService: KnowledgeService; getMcpConnectionInfo?: () => McpConnectionInfo | null; getMcpApprovalTracker?: () => McpApprovalTracker; + runDueSchedulerEntriesAfterReply?: () => Promise; logger?: Logger; } @@ -639,10 +640,27 @@ export class ChatThreadRuntimeService { }; } finally { this.inFlightTurns.delete(thread.id); + await this.runDueSchedulerEntriesAfterReply(projectId, thread.id); } return userMessage; } + private async runDueSchedulerEntriesAfterReply(projectId: string, threadId: string): Promise { + const runDueSchedulerEntriesAfterReply = this.deps.runDueSchedulerEntriesAfterReply; + if (!runDueSchedulerEntriesAfterReply) { + return; + } + try { + await runDueSchedulerEntriesAfterReply(); + } catch (error: unknown) { + this.deps.logger?.warn("Failed to run due scheduler entries after dashboard reply", { + projectId, + threadId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async handleCreateAppQuickaction( projectId: string, thread: ConversationThreadRecord, @@ -1244,6 +1262,7 @@ export class ChatThreadRuntimeService { workerInstructions, isDashboardReply: false, mcpAvailable, + mcpAccessMode: isSchedulerOnlyAgentMcpAccess(agentMcpAccess) ? "scheduler_only" : "management", knowledgeManifest, suppressRichWidgets, }); @@ -1297,7 +1316,7 @@ export class ChatThreadRuntimeService { snapshotCheckout, mcpConnection, agentMcpAccess, - mcpAgentId: null, + mcpAgentId: respondingAgent.id, signal, }); @@ -1372,7 +1391,7 @@ export class ChatThreadRuntimeService { access: AgentMcpAccessConfig | undefined, _dashboardReplyAgentPresetId: string | null, ): AgentMcpAccessConfig { - return codeUxAgentMcpAccess(access?.linkedServerIds ?? []); + return dashboardReplyAgentMcpAccess(access); } private async deliverChatProviderReplyIfNeeded( diff --git a/src/services/scheduler-service.ts b/src/services/scheduler-service.ts index 92b09c9b06..785bae9fbb 100644 --- a/src/services/scheduler-service.ts +++ b/src/services/scheduler-service.ts @@ -10,7 +10,7 @@ import type { import type { Logger } from "../shared/logging/logger.js"; import { SchedulerRepository } from "../repositories/scheduler-repository.js"; import type { ProjectManagementRepository } from "../repositories/project-management-repository.js"; -import type { SprintRunRecord } from "../contracts/execution-types.js"; +import type { SprintRunRecord, TaskDispatchRecord, TaskRunRecord } from "../contracts/execution-types.js"; import type { QuicksprintService } from "./quicksprint-service.js"; import type { ChatThreadRuntimeService } from "./chat-thread-runtime-service.js"; import type { ExecutionControlService } from "./execution-control-service.js"; @@ -27,6 +27,8 @@ export interface SchedulerServiceDeps { projectManagementRepository: ProjectManagementRepository; executionRepository?: { listSprintRuns(projectId: string, sprintId?: string): SprintRunRecord[]; + getLatestTaskRun?(taskId: string): TaskRunRecord | null; + listTaskDispatches?(args: { projectId: string; sprintId?: string; sprintRunId?: string; taskId?: string }): TaskDispatchRecord[]; }; quicksprintService: QuicksprintService; chatThreadRuntimeService: ChatThreadRuntimeService; @@ -466,26 +468,37 @@ export class SchedulerService { if (!anchor) { return; } - if (anchor.mode !== "after_sprint_end") { - throw new Error("scheduleAnchor.mode must be after_sprint_end."); - } - const sourceSprintId = anchor.sourceSprintId?.trim(); - if (!sourceSprintId) { - throw new Error("scheduleAnchor.sourceSprintId is required."); + if (anchor.mode !== "after_sprint_end" && anchor.mode !== "after_task_end") { + throw new Error("scheduleAnchor.mode must be after_sprint_end or after_task_end."); } const offsetMinutes = Number(anchor.offsetMinutes ?? 0); if (!Number.isFinite(offsetMinutes) || offsetMinutes < 0) { throw new Error("scheduleAnchor.offsetMinutes must be a non-negative number."); } - const sourceSprint = this.deps.projectManagementRepository.getSprint(sourceSprintId); - if (!sourceSprint || sourceSprint.projectId !== projectId) { - throw new Error("Schedule anchors must reference a sprint in the selected project."); - } - if (input.targetType === "sprint" && input.sprintTarget?.sprintId === sourceSprintId) { - throw new Error("A scheduled sprint cannot be anchored to its own completion."); + if (anchor.mode === "after_task_end") { + const sourceTaskId = anchor.sourceTaskId?.trim(); + if (!sourceTaskId) { + throw new Error("scheduleAnchor.sourceTaskId is required."); + } + const sourceTask = this.deps.projectManagementRepository.getTask(sourceTaskId); + if (!sourceTask || sourceTask.projectId !== projectId) { + throw new Error("Schedule anchors must reference a task in the selected project."); + } + } else { + const sourceSprintId = anchor.sourceSprintId?.trim(); + if (!sourceSprintId) { + throw new Error("scheduleAnchor.sourceSprintId is required."); + } + const sourceSprint = this.deps.projectManagementRepository.getSprint(sourceSprintId); + if (!sourceSprint || sourceSprint.projectId !== projectId) { + throw new Error("Schedule anchors must reference a sprint in the selected project."); + } + if (input.targetType === "sprint" && input.sprintTarget?.sprintId === sourceSprintId) { + throw new Error("A scheduled sprint cannot be anchored to its own completion."); + } } if (input.recurrence && input.recurrence.frequency && input.recurrence.frequency !== "none") { - throw new Error("after_sprint_end scheduler anchors do not support recurrence."); + throw new Error("Scheduler anchors do not support recurrence."); } } @@ -508,7 +521,7 @@ export class SchedulerService { } return new Date(entry.nextRunAt).getTime() <= now.getTime() ? entry.nextRunAt : null; } - const anchorTime = this.resolveAnchorSprintEndTime(entry.projectId, entry.scheduleAnchor); + const anchorTime = this.resolveAnchorCompletionTime(entry.projectId, entry.scheduleAnchor); if (!anchorTime) { return null; } @@ -524,14 +537,24 @@ export class SchedulerService { if (!entry.scheduleAnchor) { return entry.scheduledFor; } - const anchorTime = this.resolveAnchorSprintEndTime(entry.projectId, entry.scheduleAnchor); + const anchorTime = this.resolveAnchorCompletionTime(entry.projectId, entry.scheduleAnchor); if (!anchorTime) { return null; } return new Date(anchorTime.getTime() + ((entry.scheduleAnchor.offsetMinutes ?? 0) * 60_000)).toISOString(); } + private resolveAnchorCompletionTime(projectId: string, anchor: ScheduleAnchor): Date | null { + if (anchor.mode === "after_task_end") { + return this.resolveAnchorTaskEndTime(projectId, anchor.sourceTaskId); + } + return this.resolveAnchorSprintEndTime(projectId, anchor); + } + private resolveAnchorSprintEndTime(projectId: string, anchor: ScheduleAnchor): Date | null { + if (anchor.mode !== "after_sprint_end") { + return null; + } const sprint = this.deps.projectManagementRepository.getSprint(anchor.sourceSprintId); if (!sprint || sprint.projectId !== projectId || !isTerminalSprintStatus(sprint.status)) { return null; @@ -551,6 +574,53 @@ export class SchedulerService { return Number.isFinite(parsed.getTime()) ? parsed : null; } + private resolveAnchorTaskEndTime(projectId: string, taskId: string): Date | null { + const task = this.deps.projectManagementRepository.getTask(taskId); + if (!task || task.projectId !== projectId || !isTerminalProjectTaskStatus(task.status)) { + return null; + } + + const latestRunFinishedAt = this.latestTerminalTaskRunFinishedAt(taskId); + if (latestRunFinishedAt) { + return latestRunFinishedAt; + } + + const latestDispatchFinishedAt = this.latestTerminalTaskDispatchFinishedAt(projectId, taskId); + if (latestDispatchFinishedAt) { + return latestDispatchFinishedAt; + } + + const parsed = new Date(task.updatedAt); + return Number.isFinite(parsed.getTime()) ? parsed : null; + } + + private latestTerminalTaskRunFinishedAt(taskId: string): Date | null { + const run = this.deps.executionRepository?.getLatestTaskRun?.(taskId) ?? null; + if (!run || !isTerminalTaskRunState(run.state) || !run.finishedAt) { + return null; + } + const parsed = new Date(run.finishedAt); + return Number.isFinite(parsed.getTime()) ? parsed : null; + } + + private latestTerminalTaskDispatchFinishedAt(projectId: string, taskId: string): Date | null { + const dispatches = this.deps.executionRepository?.listTaskDispatches?.({ projectId, taskId }) ?? []; + let latest: Date | null = null; + for (const dispatch of dispatches) { + if (!isTerminalTaskDispatchStatus(dispatch.status) || !dispatch.finishedAt) { + continue; + } + const finishedAt = new Date(dispatch.finishedAt); + if (!Number.isFinite(finishedAt.getTime())) { + continue; + } + if (!latest || finishedAt.getTime() > latest.getTime()) { + latest = finishedAt; + } + } + return latest; + } + private findSettingsManagedMemoryRemediationEntry(projectId: string): SchedulerEntryRecord | null { const entries = this.deps.schedulerRepository.listEntries(projectId); return entries.find((entry) => ( @@ -565,6 +635,10 @@ function isTerminalSprintStatus(status: string): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } +function isTerminalProjectTaskStatus(status: string): boolean { + return status === "completed" || status === "QA_REVIEW_FAILED"; +} + function latestTerminalRunFinishedAt(runs: SprintRunRecord[]): Date | null { let latest: Date | null = null; for (const run of runs) { @@ -582,6 +656,18 @@ function latestTerminalRunFinishedAt(runs: SprintRunRecord[]): Date | null { return latest; } +function isTerminalTaskRunState(state: TaskRunRecord["state"]): boolean { + return state === "COMPLETED" || state === "FAILED" || state === "BLOCKED" || state === "QUOTA"; +} + +function isTerminalTaskDispatchStatus(status: TaskDispatchRecord["status"]): boolean { + return status === "completed" + || status === "failed" + || status === "cancelled" + || status === "blocked" + || status === "quota"; +} + function normalizeScheduleStart(value?: string): string { if (value) { const parsed = new Date(value); diff --git a/src/services/worker-inbox-reply-service.ts b/src/services/worker-inbox-reply-service.ts index e60469eab1..5cf232fc6e 100644 --- a/src/services/worker-inbox-reply-service.ts +++ b/src/services/worker-inbox-reply-service.ts @@ -29,7 +29,8 @@ import type { AgentPresetRepository } from "../repositories/agent-preset-reposit import type { McpConnectionInfo } from "../contracts/mcp-connection-types.js"; import type { AgentMcpAccessConfig } from "../contracts/agent-preset-types.js"; import { - codeUxAgentMcpAccess, + dashboardReplyAgentMcpAccess, + isSchedulerOnlyAgentMcpAccess, resolveAgentMcpRuntime, } from "./agent-mcp-access.js"; @@ -124,7 +125,7 @@ export class WorkerInboxReplyService { dashboardReplyAgent.mcpAccess, dashboardReplyAgentPresetId, ); - mcpAgentId = null; + mcpAgentId = dashboardReplyAgent.id; const workerInstructions = dashboardReplyAgent.instructionMarkdown.trim(); const knowledgeManifest = this.deps.knowledgeService?.buildManifestMarkdownForAgent(dashboardReplyAgent.id) ?? null; const mcpAvailable = Boolean(agentMcpAccess.codeUxEnabled && this.deps.getMcpConnectionInfo?.()); @@ -139,6 +140,7 @@ export class WorkerInboxReplyService { workerInstructions, isDashboardReply: true, mcpAvailable, + mcpAccessMode: isSchedulerOnlyAgentMcpAccess(agentMcpAccess) ? "scheduler_only" : "management", knowledgeManifest, }); } @@ -689,7 +691,7 @@ export class WorkerInboxReplyService { access: AgentMcpAccessConfig | undefined, _dashboardReplyAgentPresetId: string | null, ): AgentMcpAccessConfig { - return codeUxAgentMcpAccess(access?.linkedServerIds ?? []); + return dashboardReplyAgentMcpAccess(access); } private async resolvePersistentSkillRuntime( diff --git a/tests/backend/mcp/management-scheduler-actions.test.ts b/tests/backend/mcp/management-scheduler-actions.test.ts index 84fca3f0b4..58c6c7a076 100644 --- a/tests/backend/mcp/management-scheduler-actions.test.ts +++ b/tests/backend/mcp/management-scheduler-actions.test.ts @@ -139,6 +139,30 @@ describe("SchedulerActions", () => { }); }); + it("schedules task-anchored chat entries from flattened MCP fields", async () => { + vi.mocked(schedulerService.createEntry).mockReturnValue({ id: "entry-1" } as any); + + await actions.handleSchedulerAction(makeArgs("schedule_chat", { + projectId: "p1", + scheduleMode: "after_task_end", + sourceTaskId: "source-task", + offsetMinutes: "5", + bodyMarkdown: "Follow up on the completed task.", + })); + + expect(schedulerService.createEntry).toHaveBeenCalledWith("p1", { + targetType: "chat", + scheduleAnchor: { + mode: "after_task_end", + sourceTaskId: "source-task", + offsetMinutes: 5, + }, + chatTarget: { + bodyMarkdown: "Follow up on the completed task.", + }, + }); + }); + it("schedules quicksprints from flattened MCP fields", async () => { vi.mocked(schedulerService.createEntry).mockReturnValue({ id: "entry-1" } as any); @@ -453,41 +477,88 @@ describe("AgentSchedulerActions", () => { expect(result.result).toEqual({ entry: makeEntry() }); }); - it("normalizes relative delay minutes for task creation", () => { - const taskEntry = makeEntry({ - targetType: "task", - agentWakeupTarget: undefined, - taskTarget: { - taskId: "task-1", - provider: "codex", + it("schedules immediate wakeups after the current reply is sent", () => { + vi.mocked(schedulerService.createEntry).mockReturnValue(makeEntry({ scheduledFor: fixedNow.toISOString(), nextRunAt: fixedNow.toISOString() })); + + actions.handleSchedulerAction({ + action: "schedule_wakeup", + projectId: "p1", + wakeAfterReply: true, + bodyMarkdown: "Continue by calling the planning MCP route.", + threadId: "thread-1", + }, "agent-1"); + + expect(schedulerService.createEntry).toHaveBeenCalledWith("p1", { + targetType: "agent_wakeup", + scheduledFor: "2026-06-09T12:00:00.000Z", + agentWakeupTarget: { + bodyMarkdown: "Continue by calling the planning MCP route.", + threadId: "thread-1", origin: "agent_scheduler", source: "agent_scheduler", createdByAgentId: "agent-1", }, }); - vi.mocked(schedulerService.createEntry).mockReturnValue(taskEntry); + }); + it("schedules wakeups anchored to sprint and task completion", () => { + vi.mocked(schedulerService.createEntry).mockReturnValue(makeEntry({ scheduledFor: fixedNow.toISOString(), nextRunAt: null })); + + actions.handleSchedulerAction({ + action: "schedule_wakeup", + projectId: "p1", + afterSprintId: "sprint-1", + offsetMinutes: "5", + bodyMarkdown: "Send the sprint completion report.", + }, "agent-1"); actions.handleSchedulerAction({ + action: "schedule_wakeup", + projectId: "p1", + afterTaskId: "task-1", + bodyMarkdown: "Inspect the finished task and summarize the result.", + }, "agent-1"); + + expect(schedulerService.createEntry).toHaveBeenNthCalledWith(1, "p1", expect.objectContaining({ + targetType: "agent_wakeup", + scheduledFor: "2026-06-09T12:00:00.000Z", + scheduleAnchor: { + mode: "after_sprint_end", + sourceSprintId: "sprint-1", + offsetMinutes: 5, + }, + })); + expect(schedulerService.createEntry).toHaveBeenNthCalledWith(2, "p1", expect.objectContaining({ + targetType: "agent_wakeup", + scheduledFor: "2026-06-09T12:00:00.000Z", + scheduleAnchor: { + mode: "after_task_end", + sourceTaskId: "task-1", + offsetMinutes: undefined, + }, + })); + }); + + it("rejects ambiguous wakeup timing modes", () => { + expect(() => actions.handleSchedulerAction({ + action: "schedule_wakeup", + projectId: "p1", + wakeAfterReply: true, + delaySeconds: 5, + bodyMarkdown: "Continue.", + }, "agent-1")).toThrow("Provide exactly one wakeup timing mode"); + expect(schedulerService.createEntry).not.toHaveBeenCalled(); + }); + + it("rejects task creation through the restricted agent scheduler", () => { + expect(() => actions.handleSchedulerAction({ action: "schedule_task", projectId: "p1", delayMinutes: "15", title: "Rerun task", taskId: "task-1", provider: "codex", - }, "agent-1"); - - expect(schedulerService.createEntry).toHaveBeenCalledWith("p1", { - targetType: "task", - scheduledFor: "2026-06-09T12:15:00.000Z", - title: "Rerun task", - taskTarget: { - taskId: "task-1", - provider: "codex", - origin: "agent_scheduler", - source: "agent_scheduler", - createdByAgentId: "agent-1", - }, - }); + } as any, "agent-1")).toThrow("Unknown scheduler action: schedule_task"); + expect(schedulerService.createEntry).not.toHaveBeenCalled(); }); it("cancels only entries created by the calling agent", () => { diff --git a/tests/backend/mcp/tool-validators.test.ts b/tests/backend/mcp/tool-validators.test.ts index dc1b58554d..82beec34c0 100644 --- a/tests/backend/mcp/tool-validators.test.ts +++ b/tests/backend/mcp/tool-validators.test.ts @@ -54,7 +54,7 @@ describe("tool argument validators", () => { } }); - it("accepts the restricted scheduler actions and relative delay fields", async () => { + it("accepts the restricted scheduler wakeup action and relative delay fields", async () => { const { validateToolArguments } = await import("../../../src/api/mcp/validators/tool-validators.js"); expect(() => validateToolArguments("scheduler_code_ux", { @@ -66,11 +66,18 @@ describe("tool argument validators", () => { })).not.toThrow(); expect(() => validateToolArguments("scheduler_code_ux", { - action: "schedule_task", + action: "schedule_wakeup", projectId: "project-1", - delayMinutes: 5, - taskId: "task-1", - provider: "codex", + wakeAfterReply: true, + bodyMarkdown: "Resume immediately after this reply.", + })).not.toThrow(); + + expect(() => validateToolArguments("scheduler_code_ux", { + action: "schedule_wakeup", + projectId: "project-1", + afterTaskId: "task-1", + offsetMinutes: "5", + bodyMarkdown: "Send the task completion report.", })).not.toThrow(); }); @@ -82,6 +89,14 @@ describe("tool argument validators", () => { now: "2026-06-09T12:00:00.000Z", })).toThrow("Invalid arguments for tool scheduler_code_ux"); + expect(() => validateToolArguments("scheduler_code_ux", { + action: "schedule_task", + projectId: "project-1", + delayMinutes: 5, + taskId: "task-1", + provider: "codex", + })).toThrow("Invalid arguments for tool scheduler_code_ux"); + expect(() => validateToolArguments("scheduler_code_ux", { action: "schedule_wakeup", projectId: "project-1", diff --git a/tests/backend/services/agent-mcp-access.test.ts b/tests/backend/services/agent-mcp-access.test.ts index 42887731ac..2dec6feda1 100644 --- a/tests/backend/services/agent-mcp-access.test.ts +++ b/tests/backend/services/agent-mcp-access.test.ts @@ -4,6 +4,7 @@ import { defaultCodingAgentMcpAccess, codeUxAgentMcpAccess, codeUxAgentMcpAccessWithoutScheduler, + dashboardReplyAgentMcpAccess, schedulerOnlyAgentMcpAccess, sanitizeAgentMcpAccess, resolveAgentMcpRuntime, @@ -83,6 +84,28 @@ describe("agent MCP defaults", () => { expect(access.linkedServerIds).toEqual(["playwright", "docs"]); }); + it("defaults dashboard reply agents to full Code UX access with scheduler enabled", () => { + const access = dashboardReplyAgentMcpAccess({ + codeUxEnabled: false, + codeUxToolToggles: [{ name: "manage_tasks", enabled: true, isInternal: true }], + linkedServerIds: ["playwright", "docs"], + }); + + expect(access).toEqual(codeUxAgentMcpAccess(["playwright", "docs"])); + }); + + it("preserves explicit dashboard reply Code UX access while forcing scheduler on", () => { + const access = dashboardReplyAgentMcpAccess({ + codeUxEnabled: true, + codeUxToolToggles: [{ name: "scheduler_code_ux", enabled: false, isInternal: true }], + linkedServerIds: ["docs"], + }); + + expect(access.codeUxEnabled).toBe(true); + expect(access.linkedServerIds).toEqual(["docs"]); + expect(access.codeUxToolToggles).toEqual([{ name: "scheduler_code_ux", enabled: true, isInternal: true }]); + }); + it("builds non-dashboard Code UX defaults with scheduler disabled", () => { const access = codeUxAgentMcpAccessWithoutScheduler(["playwright"]); diff --git a/tests/backend/services/chat-reply-prompt.test.ts b/tests/backend/services/chat-reply-prompt.test.ts index 7c6c6ebd1a..e01b0746a8 100644 --- a/tests/backend/services/chat-reply-prompt.test.ts +++ b/tests/backend/services/chat-reply-prompt.test.ts @@ -221,6 +221,8 @@ describe("chat-reply-prompt", () => { mcpAccessMode: "scheduler_only", }); expect(prompt).toContain("You have the `scheduler_code_ux` MCP tool available"); + expect(prompt).toContain("It supports `list`, `schedule_wakeup`, and `cancel`."); + expect(prompt).not.toContain("schedule_task"); expect(prompt).not.toContain("You have the `manage_code_ux` MCP tool available"); expect(prompt).not.toContain("You must return STRICT JSON format"); }); diff --git a/tests/backend/services/chat-thread-runtime-service.test.ts b/tests/backend/services/chat-thread-runtime-service.test.ts index 8edbb121f5..2db0056884 100644 --- a/tests/backend/services/chat-thread-runtime-service.test.ts +++ b/tests/backend/services/chat-thread-runtime-service.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { ChatThreadRuntimeService } from "../../../src/services/chat-thread-runtime-service.js"; -import { codeUxAgentMcpAccess } from "../../../src/services/agent-mcp-access.js"; +import { codeUxAgentMcpAccess, dashboardReplyAgentMcpAccess } from "../../../src/services/agent-mcp-access.js"; describe("ChatThreadRuntimeService", () => { let deps: any; @@ -844,6 +844,41 @@ describe("ChatThreadRuntimeService", () => { }); }); + it("runs due scheduler entries after a dashboard reply is persisted", async () => { + deps.runDueSchedulerEntriesAfterReply = vi.fn().mockResolvedValue(undefined); + deps.connectionChatRepository.postDashboardMessage.mockReturnValue({ id: "msg-scheduler", threadId: "t1", bodyMarkdown: "Plan the work" }); + deps.connectionChatRepository.getThread.mockReturnValue({ + id: "t1", + projectId: "p1", + title: "Thread", + connectionId: null, + runtimeState: {}, + }); + deps.projectManagementRepository.getProject.mockReturnValue({ id: "p1", name: "proj", baseDir: "/tmp" }); + deps.taskService.resolveInvocationProvider.mockReturnValue({ + provider: "codex", + providers: { codex: { model: "gpt-5.3-codex", apiKey: "codex-key" } }, + }); + deps.connectionChatRepository.listMessages.mockReturnValue([ + { id: "msg-scheduler", authorType: "dashboard_user", bodyMarkdown: "Plan the work" }, + ]); + deps.chatManagementActionService.processManagementAction.mockResolvedValue({ + replyMarkdown: "I will retrieve the project data now.", + action: null, + approvalRequired: false, + }); + + await service.postMessage("p1", { bodyMarkdown: "Plan the work" }); + + expect(deps.connectionChatRepository.postSystemMessage).toHaveBeenCalledWith("p1", { + threadId: "t1", + bodyMarkdown: "I will retrieve the project data now.", + }); + expect(deps.runDueSchedulerEntriesAfterReply).toHaveBeenCalledTimes(1); + expect(deps.connectionChatRepository.postSystemMessage.mock.invocationCallOrder[0]) + .toBeLessThan(deps.runDueSchedulerEntriesAfterReply.mock.invocationCallOrder[0]); + }); + it("stores sanitized prompt suggestions on the visible virtual reply metadata", async () => { deps.connectionChatRepository.postDashboardMessage.mockReturnValue({ id: "msg-suggestions", threadId: "t1", bodyMarkdown: "what next" }); deps.connectionChatRepository.getThread.mockReturnValue({ @@ -976,7 +1011,7 @@ describe("ChatThreadRuntimeService", () => { }); }); - it("uses Code UX MCP with scheduler for the default dashboard reply agent", async () => { + it("uses full Code UX MCP with scheduler for the default dashboard reply agent", async () => { deps.getDashboardSettings.mockReturnValue({ agents: { routing: { dashboardReply: { agentPresetId: null } } }, cliWorkflow: {}, @@ -1008,7 +1043,7 @@ describe("ChatThreadRuntimeService", () => { expect(deps.chatManagementActionService.processManagementAction).toHaveBeenCalledWith( expect.objectContaining({ mcpConnection: { url: "http://127.0.0.1:3000/mcp", authToken: "token" }, - mcpAgentId: null, + mcpAgentId: "reply-agent", agentMcpAccess: codeUxAgentMcpAccess(), prompt: expect.stringContaining("You have the `manage_code_ux` MCP tool available"), }), @@ -1057,8 +1092,8 @@ describe("ChatThreadRuntimeService", () => { expect(deps.chatManagementActionService.processManagementAction).toHaveBeenCalledWith( expect.objectContaining({ - mcpAgentId: null, - agentMcpAccess: codeUxAgentMcpAccess(["custom-docs"]), + mcpAgentId: "custom-reply", + agentMcpAccess: dashboardReplyAgentMcpAccess(explicitAccess), prompt: expect.stringContaining("You have the `manage_code_ux` MCP tool available"), }), ); diff --git a/tests/backend/services/scheduler-service.test.ts b/tests/backend/services/scheduler-service.test.ts index da8ec3d3d3..08e2e98a64 100644 --- a/tests/backend/services/scheduler-service.test.ts +++ b/tests/backend/services/scheduler-service.test.ts @@ -273,6 +273,92 @@ describe("SchedulerService", () => { expect(repo.markRunSucceeded).toHaveBeenCalledWith("entry-1", "2026-05-18T09:50:00.000Z", null); }); + it("runs task-anchored wakeups after the source task completion time plus offset", async () => { + const entry = createEntry({ + targetType: "agent_wakeup", + sprintTarget: undefined, + scheduleAnchor: { + mode: "after_task_end", + sourceTaskId: "task-1", + offsetMinutes: 5, + }, + recurrence: normalizeRecurrenceRule(), + nextRunAt: null, + agentWakeupTarget: { + bodyMarkdown: "Send the task report.", + origin: "agent_scheduler", + source: "agent_scheduler", + createdByAgentId: "agent-1", + }, + }); + const repo = { + listDueEntries: vi.fn(() => []), + listScheduledAnchoredEntries: vi.fn(() => [entry]), + getEntry: vi.fn(() => entry), + markRunSucceeded: vi.fn(), + markRunFailed: vi.fn(), + }; + const projectManagementRepository = { + getTask: vi.fn(() => ({ + id: "task-1", + projectId: "project-1", + status: "completed", + updatedAt: "2026-05-18T09:00:00.000Z", + })), + }; + const executionRepository = { + getLatestTaskRun: vi.fn(() => ({ + state: "COMPLETED", + finishedAt: "2026-05-18T09:10:00.000Z", + })), + listTaskDispatches: vi.fn(() => []), + }; + const chatThreadRuntimeService = { + postMessage: vi.fn().mockResolvedValue({ id: "message-1" }), + }; + const service = buildService(repo, { projectManagementRepository, executionRepository, chatThreadRuntimeService }); + + await service.runDueEntries(new Date("2026-05-18T09:14:59.000Z")); + expect(chatThreadRuntimeService.postMessage).not.toHaveBeenCalled(); + expect(repo.markRunSucceeded).not.toHaveBeenCalled(); + + await service.runDueEntries(new Date("2026-05-18T09:15:00.000Z")); + + expect(chatThreadRuntimeService.postMessage).toHaveBeenCalledWith("project-1", expect.objectContaining({ + bodyMarkdown: "Send the task report.", + })); + expect(repo.markRunSucceeded).toHaveBeenCalledWith("entry-1", "2026-05-18T09:15:00.000Z", null); + }); + + it("rejects task anchors outside the selected project", () => { + const repo = { createEntry: vi.fn() }; + const projectManagementRepository = { + getTask: vi.fn(() => ({ + id: "task-1", + projectId: "other-project", + status: "completed", + updatedAt: "2026-05-18T09:00:00.000Z", + })), + }; + const service = buildService(repo, { projectManagementRepository }); + + expect(() => service.createEntry("project-1", { + targetType: "agent_wakeup", + scheduledFor: "2026-05-18T09:00:00.000Z", + scheduleAnchor: { + mode: "after_task_end", + sourceTaskId: "task-1", + }, + agentWakeupTarget: { + bodyMarkdown: "Wake up.", + origin: "agent_scheduler", + source: "agent_scheduler", + createdByAgentId: "agent-1", + }, + })).toThrow("Schedule anchors must reference a task in the selected project."); + expect(repo.createEntry).not.toHaveBeenCalled(); + }); + const buildService = (repo: Record, extra: Partial> = {}) => new SchedulerService({ schedulerRepository: repo as any, diff --git a/tests/backend/services/worker-inbox-reply-service.test.ts b/tests/backend/services/worker-inbox-reply-service.test.ts index faf190c153..8db06928a4 100644 --- a/tests/backend/services/worker-inbox-reply-service.test.ts +++ b/tests/backend/services/worker-inbox-reply-service.test.ts @@ -159,7 +159,7 @@ describe("WorkerInboxReplyService", () => { }); expect(mockRunProviderForText).toHaveBeenCalledWith(expect.objectContaining({ - mcpConnection: { url: "http://127.0.0.1:3000/mcp", authToken: "token" }, + mcpConnection: expect.objectContaining({ url: "http://127.0.0.1:3000/mcp", authToken: "token", agentId: "reply-agent" }), customMcpServers: [], prompt: expect.stringContaining("You also have the `scheduler_code_ux` MCP tool available"), })); @@ -228,7 +228,7 @@ describe("WorkerInboxReplyService", () => { }); expect(mockRunProviderForText).toHaveBeenCalledWith(expect.objectContaining({ - mcpConnection: { url: "http://127.0.0.1:3000/mcp", authToken: "token" }, + mcpConnection: expect.objectContaining({ url: "http://127.0.0.1:3000/mcp", authToken: "token", agentId: "custom-reply" }), customMcpServers: [expect.objectContaining({ id: "docs" })], prompt: expect.stringContaining("You have the `manage_code_ux` MCP tool available"), }));