Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs-web/content/docs/developer-mcp-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions docs-web/content/docs/user-dashboard-scheduler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs-web/developer/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions docs-web/user/dashboard/scheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/dashboard/scheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp/tools-and-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/mcp/management/agent-scheduler-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
1 change: 1 addition & 0 deletions src/server/mcp-agent-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ export function runWithMcpAgentContext<T>(

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;
71 changes: 71 additions & 0 deletions tests/backend/mcp/management-scheduler-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));

Expand Down
Loading