diff --git a/docs-web/content/docs/developer-mcp-tools.mdx b/docs-web/content/docs/developer-mcp-tools.mdx index 5c375d9da5..71ce93c088 100644 --- a/docs-web/content/docs/developer-mcp-tools.mdx +++ b/docs-web/content/docs/developer-mcp-tools.mdx @@ -183,6 +183,12 @@ the current reply is sent, allowing an agent to answer first and continue with M turn. `afterSprintId` and `afterTaskId` create one-time completion anchors; `offsetMinutes` delays the wakeup after the source sprint or task finishes. +For MCP-backed dashboard chat turns, an omitted, null, or blank `threadId` defaults to the originating +dashboard thread and the resolved id is persisted in the scheduler target. An explicit non-empty +`threadId` overrides that default. Standalone MCP calls have no originating thread, so an omitted or +empty target remains threadless. The normal project/thread ownership check applies to contextual and +explicit targets when the wakeup is delivered. + 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, diff --git a/docs-web/content/docs/user-dashboard-scheduler.mdx b/docs-web/content/docs/user-dashboard-scheduler.mdx index 3331436bb2..a00eb00213 100644 --- a/docs-web/content/docs/user-dashboard-scheduler.mdx +++ b/docs-web/content/docs/user-dashboard-scheduler.mdx @@ -48,6 +48,12 @@ Agent wakeups created through `scheduler_code_ux` can use one timing mode at a t - `afterSprintId`, with optional `offsetMinutes`, to wake after a sprint completes successfully - `afterTaskId`, with optional `offsetMinutes`, to wake after a task reaches a terminal project status +When the call comes from a dashboard chat turn, an omitted, null, or blank `threadId` defaults to the +originating thread and that resolved target is stored with the scheduler entry. A non-empty `threadId` +explicitly overrides the default. Standalone MCP calls have no originating thread, so omitted or empty +targets remain threadless. Both contextual and explicit targets must belong to the selected project when +the wakeup is delivered. + Completion-anchored wakeups are one-time entries. A sprint anchor resolves only when its source sprint reaches effective successful `completed` status; failed, cancelled, and otherwise non-completed source sprints do not trigger it. The scheduler uses the latest successful sprint run finish time when diff --git a/docs-web/developer/mcp-tools.md b/docs-web/developer/mcp-tools.md index 172d8b43b6..67419be864 100644 --- a/docs-web/developer/mcp-tools.md +++ b/docs-web/developer/mcp-tools.md @@ -183,6 +183,12 @@ the current reply is sent, allowing an agent to answer first and continue with M turn. `afterSprintId` and `afterTaskId` create one-time completion anchors; `offsetMinutes` delays the wakeup after the source sprint or task finishes. +For MCP-backed dashboard chat turns, an omitted, null, or blank `threadId` defaults to the originating +dashboard thread and the resolved id is persisted in the scheduler target. An explicit non-empty +`threadId` overrides that default. Standalone MCP calls have no originating thread, so an omitted or +empty target remains threadless. The normal project/thread ownership check applies to contextual and +explicit targets when the wakeup is delivered. + 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, diff --git a/docs-web/user/dashboard/scheduler.md b/docs-web/user/dashboard/scheduler.md index 34481144bb..39c480cab0 100644 --- a/docs-web/user/dashboard/scheduler.md +++ b/docs-web/user/dashboard/scheduler.md @@ -48,6 +48,12 @@ Agent wakeups created through `scheduler_code_ux` can use one timing mode at a t - `afterSprintId`, with optional `offsetMinutes`, to wake after a sprint completes successfully - `afterTaskId`, with optional `offsetMinutes`, to wake after a task reaches a terminal project status +When the call comes from a dashboard chat turn, an omitted, null, or blank `threadId` defaults to the +originating thread and that resolved target is stored with the scheduler entry. A non-empty `threadId` +explicitly overrides the default. Standalone MCP calls have no originating thread, so omitted or empty +targets remain threadless. Both contextual and explicit targets must belong to the selected project when +the wakeup is delivered. + Completion-anchored wakeups are one-time entries. A sprint anchor resolves only when its source sprint reaches effective successful `completed` status; failed, cancelled, and otherwise non-completed source sprints do not trigger it. The scheduler uses the latest successful sprint run finish time when diff --git a/docs/dashboard/scheduler.md b/docs/dashboard/scheduler.md index 583234204c..35de0ee92e 100644 --- a/docs/dashboard/scheduler.md +++ b/docs/dashboard/scheduler.md @@ -26,6 +26,8 @@ The runtime contract additionally accepts: 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`. +For `scheduler_code_ux` calls made during a dashboard chat turn, an omitted, null, or blank `threadId` defaults to the originating MCP thread and the resolved id is persisted in `agentWakeupTarget`. A supplied non-empty `threadId` remains an explicit override. Standalone MCP requests have no originating thread, so omitted or empty targets remain threadless. Contextual and explicit targets follow the same project/thread ownership validation when the wakeup is delivered; the fallback does not permit cross-project delivery. + 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` 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. diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index 1abdca945c..69ad6f5fcc 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -235,6 +235,8 @@ The restricted `scheduler_code_ux` tool accepts exactly one wakeup timing mode: `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? }`. +When `schedule_wakeup` runs inside an MCP-backed dashboard chat turn, an omitted, null, or blank `threadId` defaults to the originating dashboard thread. Code UX persists that resolved id in `agentWakeupTarget`; an explicit non-empty `threadId` overrides the contextual default. Standalone MCP calls have no thread context, so the target remains threadless when `threadId` is not supplied. Both contextual defaults and explicit overrides remain subject to the normal project/thread ownership validation at delivery. + 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/src/mcp/management/agent-scheduler-actions.ts b/src/mcp/management/agent-scheduler-actions.ts index 4a8a6528f0..a01438b580 100644 --- a/src/mcp/management/agent-scheduler-actions.ts +++ b/src/mcp/management/agent-scheduler-actions.ts @@ -7,6 +7,7 @@ import type { SchedulerEntryRecord, } from "../../contracts/scheduler-types.js"; import type { SchedulerService } from "../../services/scheduler-service.js"; +import { getCurrentMcpThreadId } from "../../server/mcp-agent-context.js"; import { managementValidationError, parseOptionalNullableString, @@ -186,10 +187,10 @@ export class AgentSchedulerActions { source: AGENT_SCHEDULER_SOURCE, createdByAgentId: agentId, }; - const threadId = parseOptionalNullableString(payload, "threadId"); + const threadId = parseOptionalString(payload, "threadId") ?? getCurrentMcpThreadId(); const connectionId = parseOptionalNullableString(payload, "connectionId"); if (title) agentWakeupTarget.title = title; - if (threadId !== undefined) agentWakeupTarget.threadId = threadId; + if (threadId) agentWakeupTarget.threadId = threadId; if (connectionId !== undefined) agentWakeupTarget.connectionId = connectionId; const entry = this.schedulerService.createEntry(projectId, { diff --git a/src/server/mcp-agent-context.ts b/src/server/mcp-agent-context.ts index d9e3fc235b..7bc32d9cb3 100644 --- a/src/server/mcp-agent-context.ts +++ b/src/server/mcp-agent-context.ts @@ -26,4 +26,5 @@ export function runWithMcpAgentContext( export const getCurrentMcpAgentId = (): string | null => storage.getStore()?.agentId ?? null; +/** Returns the originating dashboard thread, or null for standalone MCP requests. */ export const getCurrentMcpThreadId = (): string | null => storage.getStore()?.threadId ?? null; diff --git a/tests/backend/mcp/management-scheduler-actions.test.ts b/tests/backend/mcp/management-scheduler-actions.test.ts index 58c6c7a076..dac7d60097 100644 --- a/tests/backend/mcp/management-scheduler-actions.test.ts +++ b/tests/backend/mcp/management-scheduler-actions.test.ts @@ -501,6 +501,77 @@ describe("AgentSchedulerActions", () => { }); }); + it.each([ + ["omitted", undefined], + ["empty", ""], + ["null", null], + ])("defaults an %s wakeup thread target to the current MCP thread", async (_label, threadId) => { + vi.mocked(schedulerService.createEntry).mockReturnValue(makeEntry()); + const handler = createHandler(); + + await runWithMcpAgentContext("agent-1", "context-thread", () => handler.handleScheduler({ + action: "schedule_wakeup", + projectId: "p1", + scheduledFor: "2026-06-09T12:05:00.000Z", + bodyMarkdown: "Continue in the originating dashboard thread.", + ...(threadId === undefined ? {} : { threadId }), + })); + + expect(schedulerService.createEntry).toHaveBeenCalledWith("p1", { + targetType: "agent_wakeup", + scheduledFor: "2026-06-09T12:05:00.000Z", + agentWakeupTarget: { + bodyMarkdown: "Continue in the originating dashboard thread.", + threadId: "context-thread", + origin: "agent_scheduler", + source: "agent_scheduler", + createdByAgentId: "agent-1", + }, + }); + }); + + it("preserves an explicit non-empty thread target over the current MCP thread", async () => { + vi.mocked(schedulerService.createEntry).mockReturnValue(makeEntry()); + const handler = createHandler(); + + await runWithMcpAgentContext("agent-1", "context-thread", () => handler.handleScheduler({ + action: "schedule_wakeup", + projectId: "p1", + scheduledFor: "2026-06-09T12:05:00.000Z", + bodyMarkdown: "Continue in the selected thread.", + threadId: "explicit-thread", + })); + + expect(schedulerService.createEntry).toHaveBeenCalledWith("p1", expect.objectContaining({ + agentWakeupTarget: expect.objectContaining({ + threadId: "explicit-thread", + }), + })); + }); + + it("keeps standalone authenticated MCP wakeups threadless", async () => { + vi.mocked(schedulerService.createEntry).mockReturnValue(makeEntry()); + const handler = createHandler(); + + await runWithMcpAgentContext("agent-1", () => handler.handleScheduler({ + action: "schedule_wakeup", + projectId: "p1", + scheduledFor: "2026-06-09T12:05:00.000Z", + bodyMarkdown: "Create a standalone follow-up.", + })); + + expect(schedulerService.createEntry).toHaveBeenCalledWith("p1", { + targetType: "agent_wakeup", + scheduledFor: "2026-06-09T12:05:00.000Z", + agentWakeupTarget: { + bodyMarkdown: "Create a standalone follow-up.", + origin: "agent_scheduler", + source: "agent_scheduler", + createdByAgentId: "agent-1", + }, + }); + }); + it("schedules wakeups anchored to sprint and task completion", () => { vi.mocked(schedulerService.createEntry).mockReturnValue(makeEntry({ scheduledFor: fixedNow.toISOString(), nextRunAt: null }));