From 339448fd20e705058bfb63494d3d4dc312e1bda2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 18 May 2026 17:27:45 -0700 Subject: [PATCH 01/17] feat(scheduler): Add Slack scheduled tasks Add a state-adapter backed scheduler for Slack-authored Junior tasks. Use active-context Slack tools, calendar recurrence, and an authenticated cron tick endpoint. Wrap scheduled runs in marker-delimited task prompts and deliver results back through Slack. Add integration and eval coverage for task management, recurrence, tick dispatch, and delivery. Refs #114 Co-Authored-By: GPT-5 Codex --- AGENTS.md | 1 + .../content/docs/reference/config-and-env.md | 27 +- .../junior-evals/evals/behavior-harness.ts | 20 + .../junior-evals/evals/core/scheduler.eval.ts | 73 +++ packages/junior-evals/evals/helpers.ts | 3 + packages/junior/src/app.ts | 5 + packages/junior/src/chat/prompt.ts | 1 + packages/junior/src/chat/respond.ts | 3 + .../junior/src/chat/runtime/reply-executor.ts | 3 + .../junior/src/chat/runtime/thread-context.ts | 15 + packages/junior/src/chat/scheduler/cadence.ts | 465 +++++++++++++++ .../junior/src/chat/scheduler/executor.ts | 198 +++++++ packages/junior/src/chat/scheduler/prompt.ts | 89 +++ .../junior/src/chat/scheduler/slack-runner.ts | 308 ++++++++++ packages/junior/src/chat/scheduler/store.ts | 289 +++++++++ packages/junior/src/chat/scheduler/types.ts | 90 +++ packages/junior/src/chat/tools/index.ts | 13 + .../src/chat/tools/slack/schedule-tools.ts | 553 ++++++++++++++++++ packages/junior/src/chat/tools/types.ts | 6 + .../src/handlers/diagnostics-dashboard.ts | 1 + .../junior/src/handlers/scheduler-tick.ts | 57 ++ packages/junior/src/vercel.ts | 6 + .../integration/scheduler-executor.test.ts | 214 +++++++ .../scheduler-slack-runner.test.ts | 155 +++++ .../tests/integration/scheduler-tick.test.ts | 53 ++ .../integration/slack-schedule-tools.test.ts | 207 +++++++ specs/index.md | 2 + specs/scheduler-spec.md | 189 ++++++ 28 files changed, 3033 insertions(+), 13 deletions(-) create mode 100644 packages/junior-evals/evals/core/scheduler.eval.ts create mode 100644 packages/junior/src/chat/scheduler/cadence.ts create mode 100644 packages/junior/src/chat/scheduler/executor.ts create mode 100644 packages/junior/src/chat/scheduler/prompt.ts create mode 100644 packages/junior/src/chat/scheduler/slack-runner.ts create mode 100644 packages/junior/src/chat/scheduler/store.ts create mode 100644 packages/junior/src/chat/scheduler/types.ts create mode 100644 packages/junior/src/chat/tools/slack/schedule-tools.ts create mode 100644 packages/junior/src/handlers/scheduler-tick.ts create mode 100644 packages/junior/tests/integration/scheduler-executor.test.ts create mode 100644 packages/junior/tests/integration/scheduler-slack-runner.test.ts create mode 100644 packages/junior/tests/integration/scheduler-tick.test.ts create mode 100644 packages/junior/tests/integration/slack-schedule-tools.test.ts create mode 100644 specs/scheduler-spec.md diff --git a/AGENTS.md b/AGENTS.md index 3a829054..56bed0e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ Co-Authored-By: (agent model name) - `specs/oauth-flows-spec.md` (OAuth authorization code flow + Slack UX contract) - `specs/agent-prompt-spec.md` (core prompt ownership, execution-bias, and bloat-control contract) - `specs/advisor-tool-spec.md` (draft provider-agnostic advisor tool contract) +- `specs/scheduler-spec.md` (draft scheduled Junior task contract) - `specs/harness-agent-spec.md` (agent loop and output contract) - `specs/agent-session-resumability-spec.md` (multi-slice turn resumability and timeout recovery contract) - `specs/agent-execution-spec.md` (agent execution rubric and completion gates) diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index f8fea1eb..7682c2bc 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -12,19 +12,20 @@ related: ## Core runtime -| Variable | Required | Purpose | -| ------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. | -| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. | -| `REDIS_URL` | Yes | Queue and runtime state storage. | -| `JUNIOR_SECRET` | Yes | Signs internal timeout-resume callbacks and sandbox egress requester context. | -| `JUNIOR_BOT_NAME` | No | Bot display/config naming. | -| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. | -| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. | -| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. | -| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. | -| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. | -| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. | +| Variable | Required | Purpose | +| ------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. | +| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. | +| `REDIS_URL` | Yes | Queue and runtime state storage. | +| `JUNIOR_SECRET` | Yes | Signs internal timeout-resume callbacks and sandbox egress requester context. | +| `JUNIOR_BOT_NAME` | No | Bot display/config naming. | +| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. | +| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. | +| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. | +| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. | +| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. | +| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for `/api/internal/scheduler/tick`; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for an external scheduler. | +| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. | Generate `JUNIOR_SECRET` with Node, then store the generated value in every environment that runs the same app: diff --git a/packages/junior-evals/evals/behavior-harness.ts b/packages/junior-evals/evals/behavior-harness.ts index 95433ec3..99dbec16 100644 --- a/packages/junior-evals/evals/behavior-harness.ts +++ b/packages/junior-evals/evals/behavior-harness.ts @@ -195,6 +195,7 @@ export interface EvalCanvasArtifact { } export interface EvalToolInvocation { + arguments?: Record; tool: string; bash_command?: string; mcp_arguments?: Record; @@ -325,6 +326,24 @@ function toEvalToolInvocation(input: { }): EvalToolInvocation { const invocation: EvalToolInvocation = { tool: input.toolName }; + if (input.toolName.startsWith("slackSchedule")) { + invocation.arguments = Object.fromEntries( + [ + "title", + "objective", + "schedule_description", + "timezone", + "next_run_at_iso", + "recurrence_frequency", + "recurrence_interval", + "recurrence_weekdays", + "status", + ] + .filter((key) => key in input.params) + .map((key) => [key, input.params[key]]), + ); + } + if (input.toolName === "bash" && typeof input.params.command === "string") { invocation.bash_command = input.params.command.trim(); } @@ -708,6 +727,7 @@ function toIncomingMessage(event: MentionEvent | SubscribedMessageEvent) { runId: event.thread.run_id, raw: { channel: event.thread.channel_id, + team_id: "T_EVAL", ts: messageTs, thread_ts: event.thread.thread_ts, }, diff --git a/packages/junior-evals/evals/core/scheduler.eval.ts b/packages/junior-evals/evals/core/scheduler.eval.ts new file mode 100644 index 00000000..01aed11d --- /dev/null +++ b/packages/junior-evals/evals/core/scheduler.eval.ts @@ -0,0 +1,73 @@ +import { describeEval } from "vitest-evals"; +import { mention, rubric, slackEvals } from "../helpers"; + +describeEval("Scheduler", slackEvals, (it) => { + it("when asked to schedule recurring work, create a scheduled task for the active Slack context", async ({ + run, + }) => { + await run({ + events: [ + mention( + "@bot schedule this every Monday at 9am Pacific: check open GitHub issues about the scheduler and post a short digest here.", + ), + ], + criteria: rubric({ + contract: + "A future or recurring task request is turned into a scheduled Junior task for the active Slack context.", + pass: [ + "observed_tool_invocations contains slackScheduleCreateTask.", + "The scheduled task title/objective/instructions describe checking scheduler-related GitHub issues, not creating a schedule.", + "The tool call uses an exact next_run_at_iso and a calendar recurrence for Mondays at 9am Pacific.", + "The reply confirms the scheduled task and mentions the cadence or next run.", + ], + fail: [ + "Do not ask the user to provide a channel ID.", + "Do not use Slack chat.scheduleMessage.", + "Do not only give instructions for how the user can set up an external cron.", + ], + }), + }); + }); + + it("when executing a scheduled-task prompt, perform the task instead of creating another schedule", async ({ + run, + }) => { + await run({ + events: [ + mention(`@bot +This is an autonomous scheduled run. Treat the stored task contract as the user request for this turn. + + +- id: sched_eval +- title: Weekly scheduler digest +- objective: Summarize open scheduler issues. + +- Check for open scheduler issues. +- Post a concise digest. + + + + +- Execute the scheduled task described in ; do not create, update, pause, delete, or list schedules. + + + +Execute the scheduled task now and provide the final result for the configured destination. + +`), + ], + criteria: rubric({ + contract: + "A scheduled-task execution prompt is treated as the task to run, not as a request to schedule something.", + pass: [ + "observed_tool_invocations does not contain slackScheduleCreateTask.", + "The assistant attempts to produce or explain a scheduler issue digest.", + ], + fail: [ + "Do not create, edit, delete, or list scheduled tasks.", + "Do not say the task has been scheduled.", + ], + }), + }); + }); +}); diff --git a/packages/junior-evals/evals/helpers.ts b/packages/junior-evals/evals/helpers.ts index 717c93fc..495a043e 100644 --- a/packages/junior-evals/evals/helpers.ts +++ b/packages/junior-evals/evals/helpers.ts @@ -68,6 +68,9 @@ function toToolCallRecord( invocation: EvalResult["toolInvocations"][number], ): ToolCallRecord { const args: Record = {}; + if (invocation.arguments) { + args.arguments = toJson(invocation.arguments); + } if (invocation.bash_command) { args.command = invocation.bash_command; } diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index ea0c0fa9..84c813e9 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -19,6 +19,7 @@ import { GET as dashboardGET } from "@/handlers/diagnostics-dashboard"; import { GET as healthGET } from "@/handlers/health"; import { GET as mcpOauthCallbackGET } from "@/handlers/mcp-oauth-callback"; import { GET as oauthCallbackGET } from "@/handlers/oauth-callback"; +import { ALL as schedulerTickALL } from "@/handlers/scheduler-tick"; import { ALL as sandboxEgressProxyALL, isSandboxEgressRequest, @@ -243,6 +244,10 @@ export async function createApp(options?: JuniorAppOptions): Promise { return turnResumePOST(c.req.raw, waitUntil); }); + app.all("/api/internal/scheduler/tick", (c) => { + return schedulerTickALL(c.req.raw, waitUntil); + }); + app.post("/api/webhooks/:platform", (c) => { return webhooksPOST(c.req.raw, c.req.param("platform"), waitUntil); }); diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index 562bd4ee..0422e7ea 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -426,6 +426,7 @@ const SLACK_ACTION_RULES = [ "- Context-bound Slack tools use runtime-owned targets; do not invent channel, canvas, list, or message IDs.", "- Use first-class Slack tools for Slack side effects; do not use bash, curl, or provider APIs to bypass Slack tool targeting.", "- Use channel-post and emoji-reaction tools only when the user explicitly asks for that Slack side effect.", + "- Use Slack schedule tools only when the user explicitly asks to create, list, edit, pause, resume, remove, or run future/recurring Junior work; scheduled task destinations are always the active Slack context, and task creation needs an exact next-run ISO timestamp.", "- For explicit channel-post or emoji-reaction requests, skip a duplicate thread text reply when the tool result already satisfies the request.", "- Do not claim an attachment, canvas, channel post, list update, or reaction succeeded unless the tool returned success this turn; when it did, include any link the tool returned.", "- Do not use reactions as progress indicators.", diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index cf8cd7b2..cca81e29 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -127,6 +127,7 @@ export interface ReplyRequestContext { turnId?: string; runId?: string; channelId?: string; + teamId?: string; messageTs?: string; threadTs?: string; requesterId?: string; @@ -738,6 +739,8 @@ export async function generateAssistantReply( { channelId: toolChannelId, channelCapabilities, + requester: context.requester, + teamId: context.correlation?.teamId, messageTs: context.correlation?.messageTs, threadTs: context.correlation?.threadTs, userText: userInput, diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index a614bc09..2209a677 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -23,6 +23,7 @@ import { getAssistantThreadContext, getChannelId, getMessageTs, + getTeamId, getThreadId, getThreadTs, getRunId, @@ -211,6 +212,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const threadTs = getThreadTs(threadId); const assistantThreadContext = getAssistantThreadContext(message); const messageTs = getMessageTs(message); + const teamId = getTeamId(message); const runId = getRunId(thread, message); const conversationId = threadId ?? runId; @@ -528,6 +530,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { turnId, threadTs, messageTs, + teamId, runId, channelId, requesterId: message.author.userId, diff --git a/packages/junior/src/chat/runtime/thread-context.ts b/packages/junior/src/chat/runtime/thread-context.ts index e6cc25ac..8427bbed 100644 --- a/packages/junior/src/chat/runtime/thread-context.ts +++ b/packages/junior/src/chat/runtime/thread-context.ts @@ -127,3 +127,18 @@ export function getMessageTs(message: Message): string | undefined { toOptionalString((rawRecord.message as { ts?: unknown } | undefined)?.ts) ); } + +/** Resolve the Slack workspace/team id from the raw inbound message payload. */ +export function getTeamId(message: Message): string | undefined { + const raw = (message as unknown as { raw?: unknown }).raw; + if (!raw || typeof raw !== "object") { + return undefined; + } + + const rawRecord = raw as Record; + return ( + toOptionalString(rawRecord.team_id) ?? + toOptionalString(rawRecord.team) ?? + toOptionalString(rawRecord.user_team) + ); +} diff --git a/packages/junior/src/chat/scheduler/cadence.ts b/packages/junior/src/chat/scheduler/cadence.ts new file mode 100644 index 00000000..ff4ac345 --- /dev/null +++ b/packages/junior/src/chat/scheduler/cadence.ts @@ -0,0 +1,465 @@ +import type { + ScheduledCalendarFrequency, + ScheduledLocalTime, + ScheduledTask, + ScheduledTaskRecurrence, +} from "@/chat/scheduler/types"; + +/** Parse an ISO timestamp into a finite Unix timestamp in milliseconds. */ +export function parseScheduleTimestamp(value: string): number | undefined { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export interface ZonedDateTimeParts { + day: number; + hour: number; + minute: number; + month: number; + second: number; + weekday: number; + year: number; +} + +interface LocalDate { + day: number; + month: number; + year: number; +} + +const FORMATTERS = new Map(); + +function getFormatter(timezone: string): Intl.DateTimeFormat { + const existing = FORMATTERS.get(timezone); + if (existing) { + return existing; + } + + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + FORMATTERS.set(timezone, formatter); + return formatter; +} + +function normalizeHour(hour: number): number { + return hour === 24 ? 0 : hour; +} + +function getLocalDateWeekday(date: LocalDate): number { + return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay(); +} + +/** Resolve a UTC timestamp into calendar parts for a named time zone. */ +export function getZonedDateTimeParts( + timestampMs: number, + timezone: string, +): ZonedDateTimeParts { + const parts = getFormatter(timezone).formatToParts(new Date(timestampMs)); + const values = new Map(parts.map((part) => [part.type, part.value])); + const year = Number(values.get("year")); + const month = Number(values.get("month")); + const day = Number(values.get("day")); + const hour = normalizeHour(Number(values.get("hour"))); + const minute = Number(values.get("minute")); + const second = Number(values.get("second")); + + return { + year, + month, + day, + hour, + minute, + second, + weekday: getLocalDateWeekday({ year, month, day }), + }; +} + +function getTimeZoneOffsetMs(timestampMs: number, timezone: string): number { + const parts = getZonedDateTimeParts(timestampMs, timezone); + return ( + Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ) - timestampMs + ); +} + +function localDateTimeToTimestampMs(args: { + date: LocalDate; + time: ScheduledLocalTime; + timezone: string; +}): number { + const localAsUtcMs = Date.UTC( + args.date.year, + args.date.month - 1, + args.date.day, + args.time.hour, + args.time.minute, + 0, + ); + let timestampMs = + localAsUtcMs - getTimeZoneOffsetMs(localAsUtcMs, args.timezone); + + for (let index = 0; index < 3; index += 1) { + const next = localAsUtcMs - getTimeZoneOffsetMs(timestampMs, args.timezone); + if (next === timestampMs) { + break; + } + timestampMs = next; + } + + return timestampMs; +} + +function compareDate(left: LocalDate, right: LocalDate): number { + return ( + Date.UTC(left.year, left.month - 1, left.day) - + Date.UTC(right.year, right.month - 1, right.day) + ); +} + +function addDays(date: LocalDate, days: number): LocalDate { + const next = new Date(Date.UTC(date.year, date.month - 1, date.day + days)); + return { + year: next.getUTCFullYear(), + month: next.getUTCMonth() + 1, + day: next.getUTCDate(), + }; +} + +function daysInMonth(year: number, month: number): number { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} + +function parseLocalDate(value: string): LocalDate | undefined { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return undefined; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) + ) { + return undefined; + } + + return { year, month, day }; +} + +function formatLocalDate(date: LocalDate): string { + return [ + String(date.year).padStart(4, "0"), + String(date.month).padStart(2, "0"), + String(date.day).padStart(2, "0"), + ].join("-"); +} + +function getLocalDate(timestampMs: number, timezone: string): LocalDate { + const parts = getZonedDateTimeParts(timestampMs, timezone); + return { year: parts.year, month: parts.month, day: parts.day }; +} + +function normalizeWeekdays(values: number[] | undefined): number[] { + return [ + ...new Set((values ?? []).filter((value) => value >= 0 && value <= 6)), + ].sort((a, b) => a - b); +} + +function buildCandidate(args: { + date: LocalDate; + recurrence: ScheduledTaskRecurrence; + timezone: string; +}): number { + return localDateTimeToTimestampMs({ + date: args.date, + time: args.recurrence.time, + timezone: args.timezone, + }); +} + +function getDailyNextRunAtMs(args: { + afterMs: number; + recurrence: ScheduledTaskRecurrence; + scheduledForMs: number; + timezone: string; +}): number | undefined { + const start = parseLocalDate(args.recurrence.startDate); + if (!start) { + return undefined; + } + + let candidateDate = addDays( + getLocalDate(args.scheduledForMs, args.timezone), + args.recurrence.interval, + ); + if (compareDate(candidateDate, start) < 0) { + candidateDate = start; + } + + let candidate = buildCandidate({ + date: candidateDate, + recurrence: args.recurrence, + timezone: args.timezone, + }); + while (candidate <= args.afterMs) { + candidateDate = addDays(candidateDate, args.recurrence.interval); + candidate = buildCandidate({ + date: candidateDate, + recurrence: args.recurrence, + timezone: args.timezone, + }); + } + return candidate; +} + +function getWeeklyNextRunAtMs(args: { + afterMs: number; + recurrence: ScheduledTaskRecurrence; + scheduledForMs: number; + timezone: string; +}): number | undefined { + const start = parseLocalDate(args.recurrence.startDate); + if (!start) { + return undefined; + } + + const weekdays = normalizeWeekdays(args.recurrence.weekdays); + if (weekdays.length === 0) { + return undefined; + } + + let candidateDate = addDays( + getLocalDate(args.scheduledForMs, args.timezone), + 1, + ); + for (let attempts = 0; attempts < 3660; attempts += 1) { + const weeksSinceStart = Math.floor( + (Date.UTC( + candidateDate.year, + candidateDate.month - 1, + candidateDate.day, + ) - + Date.UTC(start.year, start.month - 1, start.day)) / + (7 * 24 * 60 * 60 * 1000), + ); + const isInCycle = + weeksSinceStart >= 0 && weeksSinceStart % args.recurrence.interval === 0; + if (isInCycle && weekdays.includes(getLocalDateWeekday(candidateDate))) { + const candidate = buildCandidate({ + date: candidateDate, + recurrence: args.recurrence, + timezone: args.timezone, + }); + if (candidate > args.afterMs) { + return candidate; + } + } + candidateDate = addDays(candidateDate, 1); + } + + return undefined; +} + +function getMonthlyNextRunAtMs(args: { + afterMs: number; + recurrence: ScheduledTaskRecurrence; + scheduledForMs: number; + timezone: string; +}): number | undefined { + const start = parseLocalDate(args.recurrence.startDate); + const dayOfMonth = args.recurrence.dayOfMonth; + if (!start || !dayOfMonth) { + return undefined; + } + + const scheduledDate = getLocalDate(args.scheduledForMs, args.timezone); + let monthIndex = scheduledDate.year * 12 + scheduledDate.month - 1; + const startMonthIndex = start.year * 12 + start.month - 1; + + for (let attempts = 0; attempts < 1200; attempts += 1) { + monthIndex += args.recurrence.interval; + if (monthIndex < startMonthIndex) { + monthIndex = startMonthIndex; + } + const year = Math.floor(monthIndex / 12); + const month = (monthIndex % 12) + 1; + if (dayOfMonth > daysInMonth(year, month)) { + continue; + } + const candidate = buildCandidate({ + date: { year, month, day: dayOfMonth }, + recurrence: args.recurrence, + timezone: args.timezone, + }); + if (candidate > args.afterMs) { + return candidate; + } + } + + return undefined; +} + +function getYearlyNextRunAtMs(args: { + afterMs: number; + recurrence: ScheduledTaskRecurrence; + scheduledForMs: number; + timezone: string; +}): number | undefined { + const start = parseLocalDate(args.recurrence.startDate); + const month = args.recurrence.month; + const dayOfMonth = args.recurrence.dayOfMonth; + if (!start || !month || !dayOfMonth) { + return undefined; + } + + const scheduledDate = getLocalDate(args.scheduledForMs, args.timezone); + let year = scheduledDate.year; + + for (let attempts = 0; attempts < 100; attempts += 1) { + year += args.recurrence.interval; + if (year < start.year) { + year = start.year; + } + if (dayOfMonth > daysInMonth(year, month)) { + continue; + } + const candidate = buildCandidate({ + date: { year, month, day: dayOfMonth }, + recurrence: args.recurrence, + timezone: args.timezone, + }); + if (candidate > args.afterMs) { + return candidate; + } + } + + return undefined; +} + +/** Build a calendar recurrence anchored to an exact first run timestamp. */ +export function buildCalendarRecurrence(args: { + frequency: ScheduledCalendarFrequency; + interval?: number; + nextRunAtMs: number; + timezone: string; + weekdays?: number[]; +}): ScheduledTaskRecurrence { + const interval = args.interval && args.interval > 0 ? args.interval : 1; + const parts = getZonedDateTimeParts(args.nextRunAtMs, args.timezone); + const time = { hour: parts.hour, minute: parts.minute }; + const startDate = formatLocalDate(parts); + + if (args.frequency === "weekly") { + const weekdays = normalizeWeekdays(args.weekdays); + return { + frequency: args.frequency, + interval, + startDate, + time, + weekdays: weekdays.length > 0 ? weekdays : [parts.weekday], + }; + } + + if (args.frequency === "monthly") { + return { + dayOfMonth: parts.day, + frequency: args.frequency, + interval, + startDate, + time, + }; + } + + if (args.frequency === "yearly") { + return { + dayOfMonth: parts.day, + frequency: args.frequency, + interval, + month: parts.month, + startDate, + time, + }; + } + + return { + frequency: args.frequency, + interval, + startDate, + time, + }; +} + +/** Return the next fire time after a completed run, when the task recurs. */ +export function getNextRunAtMs( + task: ScheduledTask, + scheduledForMs: number, + afterMs: number = scheduledForMs, +): number | undefined { + if (task.schedule.kind !== "recurring") { + return undefined; + } + + const recurrence = task.schedule.recurrence; + if ( + !recurrence || + !Number.isFinite(recurrence.interval) || + recurrence.interval <= 0 + ) { + return undefined; + } + + if (recurrence.frequency === "daily") { + return getDailyNextRunAtMs({ + recurrence, + timezone: task.schedule.timezone, + scheduledForMs, + afterMs, + }); + } + + if (recurrence.frequency === "weekly") { + return getWeeklyNextRunAtMs({ + recurrence, + timezone: task.schedule.timezone, + scheduledForMs, + afterMs, + }); + } + + if (recurrence.frequency === "monthly") { + return getMonthlyNextRunAtMs({ + recurrence, + timezone: task.schedule.timezone, + scheduledForMs, + afterMs, + }); + } + + return getYearlyNextRunAtMs({ + recurrence, + timezone: task.schedule.timezone, + scheduledForMs, + afterMs, + }); +} diff --git a/packages/junior/src/chat/scheduler/executor.ts b/packages/junior/src/chat/scheduler/executor.ts new file mode 100644 index 00000000..159339b3 --- /dev/null +++ b/packages/junior/src/chat/scheduler/executor.ts @@ -0,0 +1,198 @@ +import { buildScheduledTaskRunPrompt } from "@/chat/scheduler/prompt"; +import { getNextRunAtMs } from "@/chat/scheduler/cadence"; +import type { SchedulerStore } from "@/chat/scheduler/store"; +import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; + +export type ScheduledTaskRunResult = + | { + status: "completed"; + resultMessageTs?: string; + } + | { + status: "blocked" | "failed"; + errorMessage: string; + }; + +export interface ScheduledTaskRunner { + run(args: { + nowMs: number; + prompt: string; + run: ScheduledRun; + task: ScheduledTask; + }): Promise; +} + +async function updateTaskAfterRun(args: { + errorMessage?: string; + nowMs: number; + run: ScheduledRun; + status: ScheduledTaskRunResult["status"]; + store: SchedulerStore; + task: ScheduledTask; +}): Promise { + const current = await args.store.getTask(args.task.id); + if (!current || current.status === "deleted") { + return; + } + + if ( + current.status !== "active" || + current.nextRunAtMs !== args.run.scheduledForMs + ) { + await args.store.saveTask({ + ...current, + lastRunAtMs: args.run.scheduledForMs, + updatedAtMs: args.nowMs, + version: current.version + 1, + }); + return; + } + + const nextRunAtMs = + args.status === "blocked" + ? undefined + : getNextRunAtMs(current, args.run.scheduledForMs, args.nowMs); + + await args.store.saveTask({ + ...current, + lastRunAtMs: args.run.scheduledForMs, + nextRunAtMs, + status: + args.status === "blocked" ? "blocked" : nextRunAtMs ? "active" : "paused", + statusReason: args.status === "blocked" ? args.errorMessage : undefined, + updatedAtMs: args.nowMs, + version: current.version + 1, + }); +} + +/** Execute one claimed scheduled run through the compiled task prompt. */ +export async function executeScheduledRun(args: { + nowMs: number; + run: ScheduledRun; + runner: ScheduledTaskRunner; + store: SchedulerStore; +}): Promise { + const task = await args.store.getTask(args.run.taskId); + if (!task) { + return await args.store.markRunFailed({ + runId: args.run.id, + completedAtMs: args.nowMs, + errorMessage: `Scheduled task ${args.run.taskId} was not found`, + }); + } + + const startedRun = await args.store.markRunStarted({ + runId: args.run.id, + nowMs: args.nowMs, + }); + if (!startedRun) { + return undefined; + } + + const prompt = buildScheduledTaskRunPrompt({ + task, + run: startedRun, + nowMs: args.nowMs, + }); + + try { + const result = await args.runner.run({ + task, + run: startedRun, + prompt, + nowMs: args.nowMs, + }); + + if (result.status === "completed") { + const completed = await args.store.markRunCompleted({ + runId: startedRun.id, + completedAtMs: args.nowMs, + resultMessageTs: result.resultMessageTs, + }); + await updateTaskAfterRun({ + store: args.store, + task, + run: startedRun, + status: result.status, + nowMs: args.nowMs, + }); + return completed; + } + + if (result.status === "blocked") { + const blocked = await args.store.markRunBlocked({ + runId: startedRun.id, + completedAtMs: args.nowMs, + errorMessage: result.errorMessage, + }); + await updateTaskAfterRun({ + store: args.store, + task, + run: startedRun, + status: result.status, + errorMessage: result.errorMessage, + nowMs: args.nowMs, + }); + return blocked; + } + + const failed = await args.store.markRunFailed({ + runId: startedRun.id, + completedAtMs: args.nowMs, + errorMessage: result.errorMessage, + }); + await updateTaskAfterRun({ + store: args.store, + task, + run: startedRun, + status: result.status, + errorMessage: result.errorMessage, + nowMs: args.nowMs, + }); + return failed; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const failed = await args.store.markRunFailed({ + runId: startedRun.id, + completedAtMs: args.nowMs, + errorMessage, + }); + await updateTaskAfterRun({ + store: args.store, + task, + run: startedRun, + status: "failed", + errorMessage, + nowMs: args.nowMs, + }); + return failed; + } +} + +/** Claim due scheduled runs and execute each through the supplied runner. */ +export async function processDueScheduledRuns(args: { + limit: number; + nowMs: number; + runner: ScheduledTaskRunner; + store: SchedulerStore; +}): Promise { + const claimedRuns = await args.store.claimDueRuns({ + limit: args.limit, + nowMs: args.nowMs, + }); + const completedRuns: ScheduledRun[] = []; + + for (const run of claimedRuns) { + const completed = await executeScheduledRun({ + store: args.store, + runner: args.runner, + run, + nowMs: args.nowMs, + }); + if (completed) { + completedRuns.push(completed); + } + } + + return completedRuns; +} diff --git a/packages/junior/src/chat/scheduler/prompt.ts b/packages/junior/src/chat/scheduler/prompt.ts new file mode 100644 index 00000000..95d691ed --- /dev/null +++ b/packages/junior/src/chat/scheduler/prompt.ts @@ -0,0 +1,89 @@ +import { escapeXml } from "@/chat/xml"; +import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; + +const EXECUTION_RULES = [ + "- Execute the scheduled task described in ; do not create, update, pause, delete, or list schedules.", + "- Complete the task without asking follow-up questions unless access, approval, or required input is missing.", + "- Use the available tools and skills that are relevant to the task contract.", + "- If blocked, report the specific missing provider, permission, configuration, or input.", + "- Keep the final result shaped for the configured destination audience.", +]; + +function renderList(tag: string, values: string[] | undefined): string[] { + const entries = (values ?? []).map((value) => value.trim()).filter(Boolean); + if (entries.length === 0) { + return [`<${tag}>`, ""]; + } + return [ + `<${tag}>`, + ...entries.map((value) => `- ${escapeXml(value)}`), + ``, + ]; +} + +function renderOptionalLine(name: string, value: string | undefined): string[] { + return value?.trim() ? [`- ${name}: ${escapeXml(value.trim())}`] : []; +} + +/** Build the marker-delimited user prompt for one scheduled task execution. */ +export function buildScheduledTaskRunPrompt(args: { + nowMs: number; + run: ScheduledRun; + task: ScheduledTask; +}): string { + const { run, task } = args; + const destination = task.destination; + const creator = task.createdBy; + + return [ + "", + "This is an autonomous scheduled run. Treat the stored task contract as the user request for this turn.", + "", + "", + `- id: ${escapeXml(task.id)}`, + `- title: ${escapeXml(task.task.title)}`, + `- objective: ${escapeXml(task.task.objective)}`, + ...renderOptionalLine("expected_output", task.task.expectedOutput), + "", + ...task.task.instructions.map( + (instruction) => `- ${escapeXml(instruction)}`, + ), + "", + ...renderList("constraints", task.task.constraints), + ...renderList("source-context", task.task.sourceContext), + "", + "", + "", + `- run_id: ${escapeXml(run.id)}`, + `- task_version: ${run.taskVersion}`, + `- scheduled_for: ${new Date(run.scheduledForMs).toISOString()}`, + `- running_at: ${new Date(args.nowMs).toISOString()}`, + `- schedule: ${escapeXml(task.schedule.description)}`, + `- timezone: ${escapeXml(task.schedule.timezone)}`, + `- schedule_kind: ${task.schedule.kind}`, + ...(task.schedule.recurrence + ? [ + `- recurrence_frequency: ${task.schedule.recurrence.frequency}`, + `- recurrence_interval: ${task.schedule.recurrence.interval}`, + `- recurrence_start_date: ${escapeXml(task.schedule.recurrence.startDate)}`, + ] + : []), + `- creator_slack_user_id: ${escapeXml(creator.slackUserId)}`, + ...renderOptionalLine("creator_user_name", creator.userName), + ...renderOptionalLine("creator_full_name", creator.fullName), + `- destination_platform: ${destination.platform}`, + `- destination_team_id: ${escapeXml(destination.teamId)}`, + `- destination_channel_id: ${escapeXml(destination.channelId)}`, + ...renderOptionalLine("destination_thread_ts", destination.threadTs), + "", + "", + "", + ...EXECUTION_RULES, + "", + "", + '', + "Execute the scheduled task now and provide the final result for the configured destination.", + "", + "", + ].join("\n"); +} diff --git a/packages/junior/src/chat/scheduler/slack-runner.ts b/packages/junior/src/chat/scheduler/slack-runner.ts new file mode 100644 index 00000000..8942cfe6 --- /dev/null +++ b/packages/junior/src/chat/scheduler/slack-runner.ts @@ -0,0 +1,308 @@ +import { botConfig } from "@/chat/config"; +import { generateAssistantReply as generateAssistantReplyImpl } from "@/chat/respond"; +import type { ScheduledTaskRunner } from "@/chat/scheduler/executor"; +import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; +import { logException } from "@/chat/logging"; +import { applyPendingAuthUpdate } from "@/chat/services/pending-auth"; +import { + buildConversationContext, + generateConversationId, + markConversationMessage, + normalizeConversationText, + updateConversationStats, + upsertConversationMessage, +} from "@/chat/services/conversation-memory"; +import { finalizeFailedTurnReply } from "@/chat/services/turn-failure-response"; +import { + coerceThreadConversationState, + type ThreadConversationState, +} from "@/chat/state/conversation"; +import { + coerceThreadArtifactsState, + type ThreadArtifactsState, +} from "@/chat/state/artifacts"; +import { + getChannelConfigurationServiceById, + getPersistedThreadState, + persistThreadStateById, +} from "@/chat/runtime/thread-state"; +import { + planSlackReplyPosts, + postSlackApiReplyPosts, +} from "@/chat/slack/reply"; +import { buildSlackReplyFooter } from "@/chat/slack/footer"; +import { mergeArtifactsState } from "@/chat/runtime/thread-state"; + +export interface SlackScheduledTaskRunnerDeps { + generateAssistantReply?: typeof generateAssistantReplyImpl; +} + +function getConversationId(task: ScheduledTask): string { + return `slack:${task.destination.channelId}:${task.destination.threadTs}`; +} + +function buildScheduledConversationText(task: ScheduledTask): string { + return `[scheduled task] ${task.task.title}: ${task.task.objective}`; +} + +function upsertScheduledUserMessage(args: { + conversation: ThreadConversationState; + run: ScheduledRun; + task: ScheduledTask; +}): string { + return upsertConversationMessage(args.conversation, { + id: `scheduled-run:${args.run.id}:user`, + role: "user", + text: normalizeConversationText(buildScheduledConversationText(args.task)), + createdAtMs: args.run.scheduledForMs, + author: { + userId: args.task.createdBy.slackUserId, + userName: args.task.createdBy.userName, + fullName: args.task.createdBy.fullName, + isBot: false, + }, + meta: { + explicitMention: true, + }, + }); +} + +async function persistRuntimePatch(args: { + artifacts?: ThreadArtifactsState; + conversation: ThreadConversationState; + sandboxDependencyProfileHash?: string; + sandboxId?: string; + threadId: string; +}): Promise { + await persistThreadStateById(args.threadId, { + artifacts: args.artifacts, + conversation: args.conversation, + sandboxId: args.sandboxId, + sandboxDependencyProfileHash: args.sandboxDependencyProfileHash, + }); +} + +/** Create the Slack runner used by scheduler tick dispatch. */ +export function createSlackScheduledTaskRunner( + deps: SlackScheduledTaskRunnerDeps = {}, +): ScheduledTaskRunner { + const generateAssistantReply = + deps.generateAssistantReply ?? generateAssistantReplyImpl; + + return { + run: async ({ prompt, run, task, nowMs }) => { + const threadTs = task.destination.threadTs; + if (!threadTs) { + return { + status: "blocked", + errorMessage: "Scheduled Slack task has no thread destination.", + }; + } + + const conversationId = getConversationId(task); + const persisted = await getPersistedThreadState(conversationId); + const conversation = coerceThreadConversationState(persisted); + const artifacts = coerceThreadArtifactsState(persisted); + const channelConfiguration = getChannelConfigurationServiceById( + task.destination.channelId, + ); + const configuration = await channelConfiguration.resolveValues(); + const userMessageId = upsertScheduledUserMessage({ + conversation, + run, + task, + }); + updateConversationStats(conversation); + const conversationContext = buildConversationContext(conversation, { + excludeMessageId: userMessageId, + }); + + let currentArtifacts = artifacts; + let sandboxId = + typeof persisted.app_sandbox_id === "string" + ? persisted.app_sandbox_id + : undefined; + let sandboxDependencyProfileHash = + typeof persisted.app_sandbox_dependency_profile_hash === "string" + ? persisted.app_sandbox_dependency_profile_hash + : undefined; + let authPendingErrorMessage: string | undefined; + + try { + let reply = await generateAssistantReply(prompt, { + requester: { + userId: task.createdBy.slackUserId, + userName: task.createdBy.userName, + fullName: task.createdBy.fullName, + }, + conversationContext, + artifactState: currentArtifacts, + piMessages: conversation.piMessages, + pendingAuth: conversation.processing.pendingAuth, + configuration, + channelConfiguration, + correlation: { + conversationId, + threadId: conversationId, + turnId: `scheduled:${run.id}`, + runId: run.id, + channelId: task.destination.channelId, + teamId: task.destination.teamId, + requesterId: task.createdBy.slackUserId, + threadTs, + }, + toolChannelId: task.destination.channelId, + sandbox: { + sandboxId, + sandboxDependencyProfileHash, + }, + onSandboxAcquired: async (sandbox) => { + sandboxId = sandbox.sandboxId; + sandboxDependencyProfileHash = sandbox.sandboxDependencyProfileHash; + await persistRuntimePatch({ + threadId: conversationId, + conversation, + artifacts: currentArtifacts, + sandboxId, + sandboxDependencyProfileHash, + }); + }, + onArtifactStateUpdated: async (nextArtifacts) => { + currentArtifacts = nextArtifacts; + await persistRuntimePatch({ + threadId: conversationId, + conversation, + artifacts: currentArtifacts, + sandboxId, + sandboxDependencyProfileHash, + }); + }, + onAuthPending: async (pendingAuth) => { + authPendingErrorMessage = `Scheduled task requires ${pendingAuth.provider} authorization.`; + await applyPendingAuthUpdate({ + conversation, + conversationId, + nextPendingAuth: pendingAuth, + }); + await persistRuntimePatch({ + threadId: conversationId, + conversation, + artifacts: currentArtifacts, + sandboxId, + sandboxDependencyProfileHash, + }); + }, + }); + + const turnFailureErrorMessage = + reply.diagnostics.outcome === "success" + ? undefined + : (reply.diagnostics.errorMessage ?? + `Agent turn ended with ${reply.diagnostics.outcome}.`); + if (turnFailureErrorMessage) { + reply = finalizeFailedTurnReply({ + reply, + logException, + context: { + conversationId, + slackThreadId: conversationId, + slackChannelId: task.destination.channelId, + slackUserId: task.createdBy.slackUserId, + runId: run.id, + assistantUserName: botConfig.userName, + modelId: reply.diagnostics.modelId, + }, + }); + } + + const plannedPosts = planSlackReplyPosts({ reply }); + const footer = buildSlackReplyFooter({ + conversationId, + durationMs: reply.diagnostics.durationMs, + thinkingLevel: reply.diagnostics.thinkingLevel, + usage: reply.diagnostics.usage, + }); + const resultMessageTs = await postSlackApiReplyPosts({ + channelId: task.destination.channelId, + threadTs, + posts: plannedPosts, + footer, + fileUploadFailureMode: "strict", + }); + + markConversationMessage(conversation, userMessageId, { + replied: true, + skippedReason: undefined, + }); + upsertConversationMessage(conversation, { + id: generateConversationId("assistant"), + role: "assistant", + text: normalizeConversationText(reply.text) || "[empty response]", + createdAtMs: nowMs, + author: { + userName: botConfig.userName, + isBot: true, + }, + meta: { + replied: true, + slackTs: resultMessageTs, + }, + }); + if (reply.piMessages) { + conversation.piMessages = reply.piMessages; + } + updateConversationStats(conversation); + + const nextArtifacts = reply.artifactStatePatch + ? mergeArtifactsState(currentArtifacts, reply.artifactStatePatch) + : currentArtifacts; + await persistRuntimePatch({ + threadId: conversationId, + conversation, + artifacts: nextArtifacts, + sandboxId: reply.sandboxId ?? sandboxId, + sandboxDependencyProfileHash: + reply.sandboxDependencyProfileHash ?? sandboxDependencyProfileHash, + }); + + if (authPendingErrorMessage) { + return { + status: "blocked", + errorMessage: authPendingErrorMessage, + }; + } + if (turnFailureErrorMessage) { + return { + status: "failed", + errorMessage: turnFailureErrorMessage, + }; + } + + return { + status: "completed", + resultMessageTs, + }; + } catch (error) { + logException( + error, + "scheduled_task_run_failed", + { + conversationId, + slackThreadId: conversationId, + slackChannelId: task.destination.channelId, + slackUserId: task.createdBy.slackUserId, + runId: run.id, + assistantUserName: botConfig.userName, + modelId: botConfig.modelId, + }, + {}, + "Scheduled task run failed", + ); + return { + status: "failed", + errorMessage: error instanceof Error ? error.message : String(error), + }; + } + }, + }; +} diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts new file mode 100644 index 00000000..e3827648 --- /dev/null +++ b/packages/junior/src/chat/scheduler/store.ts @@ -0,0 +1,289 @@ +import type { Lock, StateAdapter } from "chat"; +import { getStateAdapter } from "@/chat/state/adapter"; +import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; + +const SCHEDULER_KEY_PREFIX = "junior:scheduler"; +const SCHEDULER_RECORD_TTL_MS = 5 * 365 * 24 * 60 * 60 * 1000; +const SCHEDULED_RUN_TTL_MS = 90 * 24 * 60 * 60 * 1000; +const CLAIM_TTL_MS = 6 * 60 * 60 * 1000; +const LOCK_TTL_MS = 10_000; + +export interface SchedulerStore { + claimDueRuns(args: { limit: number; nowMs: number }): Promise; + getRun(runId: string): Promise; + getTask(taskId: string): Promise; + listTasksForTeam(teamId: string): Promise; + markRunBlocked(args: { + completedAtMs: number; + errorMessage: string; + runId: string; + }): Promise; + markRunCompleted(args: { + completedAtMs: number; + resultMessageTs?: string; + runId: string; + }): Promise; + markRunFailed(args: { + completedAtMs: number; + errorMessage: string; + runId: string; + }): Promise; + markRunStarted(args: { + nowMs: number; + runId: string; + }): Promise; + saveTask(task: ScheduledTask): Promise; +} + +function taskKey(taskId: string): string { + return `${SCHEDULER_KEY_PREFIX}:task:${taskId}`; +} + +function runKey(runId: string): string { + return `${SCHEDULER_KEY_PREFIX}:run:${runId}`; +} + +function claimKey(taskId: string, scheduledForMs: number): string { + return `${SCHEDULER_KEY_PREFIX}:claim:${taskId}:${scheduledForMs}`; +} + +function globalTaskIndexKey(): string { + return `${SCHEDULER_KEY_PREFIX}:tasks`; +} + +function teamTaskIndexKey(teamId: string): string { + return `${SCHEDULER_KEY_PREFIX}:team:${teamId}:tasks`; +} + +function indexLockKey(indexKey: string): string { + return `${indexKey}:lock`; +} + +function buildRunId(taskId: string, scheduledForMs: number): string { + return `${taskId}:${scheduledForMs}`; +} + +function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +async function withLock( + state: StateAdapter, + key: string, + callback: () => Promise, +): Promise { + const lock: Lock | null = await state.acquireLock(key, LOCK_TTL_MS); + if (!lock) { + throw new Error(`Could not acquire scheduler lock for ${key}`); + } + + try { + return await callback(); + } finally { + await state.releaseLock(lock); + } +} + +async function addToIndex( + state: StateAdapter, + key: string, + taskId: string, +): Promise { + await withLock(state, indexLockKey(key), async () => { + const current = ((await state.get(key)) ?? []).filter( + (value): value is string => typeof value === "string", + ); + await state.set(key, unique([...current, taskId]), SCHEDULER_RECORD_TTL_MS); + }); +} + +async function getIndex(state: StateAdapter, key: string): Promise { + const values = (await state.get(key)) ?? []; + return unique( + values.filter((value): value is string => typeof value === "string"), + ); +} + +function isDueTask( + task: ScheduledTask, + nowMs: number, +): task is ScheduledTask & { + nextRunAtMs: number; +} { + return ( + task.status === "active" && + typeof task.nextRunAtMs === "number" && + Number.isFinite(task.nextRunAtMs) && + task.nextRunAtMs <= nowMs + ); +} + +function buildScheduledRun(args: { + claimedAtMs: number; + scheduledForMs: number; + task: ScheduledTask; +}): ScheduledRun { + const idempotencyKey = `${args.task.id}:${args.scheduledForMs}`; + return { + id: buildRunId(args.task.id, args.scheduledForMs), + attempt: 1, + claimedAtMs: args.claimedAtMs, + idempotencyKey, + scheduledForMs: args.scheduledForMs, + status: "pending", + taskId: args.task.id, + taskVersion: args.task.version, + }; +} + +class StateAdapterSchedulerStore implements SchedulerStore { + private readonly state: StateAdapter; + + constructor(state: StateAdapter) { + this.state = state; + } + + async saveTask(task: ScheduledTask): Promise { + await this.state.connect(); + await this.state.set(taskKey(task.id), task, SCHEDULER_RECORD_TTL_MS); + await addToIndex(this.state, globalTaskIndexKey(), task.id); + await addToIndex( + this.state, + teamTaskIndexKey(task.destination.teamId), + task.id, + ); + } + + async getTask(taskId: string): Promise { + await this.state.connect(); + return (await this.state.get(taskKey(taskId))) ?? undefined; + } + + async listTasksForTeam(teamId: string): Promise { + await this.state.connect(); + const ids = await getIndex(this.state, teamTaskIndexKey(teamId)); + const tasks = await Promise.all(ids.map((id) => this.getTask(id))); + return tasks + .filter((task): task is ScheduledTask => Boolean(task)) + .filter((task) => task.status !== "deleted") + .sort((a, b) => a.createdAtMs - b.createdAtMs); + } + + async claimDueRuns(args: { + limit: number; + nowMs: number; + }): Promise { + await this.state.connect(); + const ids = await getIndex(this.state, globalTaskIndexKey()); + const runs: ScheduledRun[] = []; + + for (const id of ids) { + if (runs.length >= args.limit) { + break; + } + + const task = await this.getTask(id); + if (!task || !isDueTask(task, args.nowMs)) { + continue; + } + + const scheduledForMs = task.nextRunAtMs; + const claimed = await this.state.setIfNotExists( + claimKey(task.id, scheduledForMs), + { claimedAtMs: args.nowMs }, + CLAIM_TTL_MS, + ); + if (!claimed) { + continue; + } + + const run = buildScheduledRun({ + claimedAtMs: args.nowMs, + scheduledForMs, + task, + }); + await this.state.set(runKey(run.id), run, SCHEDULED_RUN_TTL_MS); + runs.push(run); + } + + return runs; + } + + async getRun(runId: string): Promise { + await this.state.connect(); + return (await this.state.get(runKey(runId))) ?? undefined; + } + + async markRunStarted(args: { + nowMs: number; + runId: string; + }): Promise { + return await this.updateRun(args.runId, (run) => ({ + ...run, + startedAtMs: args.nowMs, + status: "running", + })); + } + + async markRunCompleted(args: { + completedAtMs: number; + resultMessageTs?: string; + runId: string; + }): Promise { + return await this.updateRun(args.runId, (run) => ({ + ...run, + completedAtMs: args.completedAtMs, + resultMessageTs: args.resultMessageTs, + status: "completed", + })); + } + + async markRunFailed(args: { + completedAtMs: number; + errorMessage: string; + runId: string; + }): Promise { + return await this.updateRun(args.runId, (run) => ({ + ...run, + completedAtMs: args.completedAtMs, + errorMessage: args.errorMessage, + status: "failed", + })); + } + + async markRunBlocked(args: { + completedAtMs: number; + errorMessage: string; + runId: string; + }): Promise { + return await this.updateRun(args.runId, (run) => ({ + ...run, + completedAtMs: args.completedAtMs, + errorMessage: args.errorMessage, + status: "blocked", + })); + } + + private async updateRun( + runId: string, + update: (run: ScheduledRun) => ScheduledRun, + ): Promise { + await this.state.connect(); + return await withLock(this.state, indexLockKey(runKey(runId)), async () => { + const current = await this.getRun(runId); + if (!current) { + return undefined; + } + const next = update(current); + await this.state.set(runKey(runId), next, SCHEDULED_RUN_TTL_MS); + return next; + }); + } +} + +/** Create the production scheduler store backed by Junior's state adapter. */ +export function createStateSchedulerStore( + stateAdapter: StateAdapter = getStateAdapter(), +): SchedulerStore { + return new StateAdapterSchedulerStore(stateAdapter); +} diff --git a/packages/junior/src/chat/scheduler/types.ts b/packages/junior/src/chat/scheduler/types.ts new file mode 100644 index 00000000..77f50455 --- /dev/null +++ b/packages/junior/src/chat/scheduler/types.ts @@ -0,0 +1,90 @@ +export type ScheduledTaskStatus = "active" | "paused" | "blocked" | "deleted"; + +export type ScheduledRunStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "blocked" + | "skipped"; + +export interface ScheduledTaskPrincipal { + slackUserId: string; + fullName?: string; + userName?: string; +} + +export interface ScheduledTaskDestination { + platform: "slack"; + teamId: string; + channelId: string; + threadTs?: string; +} + +export type ScheduledCalendarFrequency = + | "daily" + | "weekly" + | "monthly" + | "yearly"; + +export interface ScheduledLocalTime { + hour: number; + minute: number; +} + +export interface ScheduledTaskRecurrence { + dayOfMonth?: number; + frequency: ScheduledCalendarFrequency; + interval: number; + month?: number; + startDate: string; + time: ScheduledLocalTime; + weekdays?: number[]; +} + +export interface ScheduledTaskSchedule { + description: string; + timezone: string; + kind: "one_off" | "recurring"; + recurrence?: ScheduledTaskRecurrence; +} + +export interface ScheduledTaskSpec { + title: string; + objective: string; + instructions: string[]; + expectedOutput?: string; + constraints?: string[]; + sourceContext?: string[]; +} + +export interface ScheduledTask { + id: string; + createdAtMs: number; + createdBy: ScheduledTaskPrincipal; + destination: ScheduledTaskDestination; + lastRunAtMs?: number; + nextRunAtMs?: number; + originalRequest?: string; + schedule: ScheduledTaskSchedule; + status: ScheduledTaskStatus; + statusReason?: string; + task: ScheduledTaskSpec; + updatedAtMs: number; + version: number; +} + +export interface ScheduledRun { + id: string; + attempt: number; + claimedAtMs: number; + completedAtMs?: number; + errorMessage?: string; + idempotencyKey: string; + resultMessageTs?: string; + scheduledForMs: number; + startedAtMs?: number; + status: ScheduledRunStatus; + taskId: string; + taskVersion: number; +} diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index 5de3d625..848437be 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -14,6 +14,12 @@ import { createReportProgressTool } from "@/chat/tools/runtime/report-progress"; import { createSlackChannelListMessagesTool } from "@/chat/tools/slack/channel-list-messages"; import { createSlackChannelPostMessageTool } from "@/chat/tools/slack/channel-post-message"; import { createSlackMessageAddReactionTool } from "@/chat/tools/slack/message-add-reaction"; +import { + createSlackScheduleCreateTaskTool, + createSlackScheduleDeleteTaskTool, + createSlackScheduleListTasksTool, + createSlackScheduleUpdateTaskTool, +} from "@/chat/tools/slack/schedule-tools"; import { createSlackCanvasCreateTool, createSlackCanvasEditTool, @@ -152,5 +158,12 @@ export function createTools( ); } + if (context.channelId) { + tools.slackScheduleCreateTask = createSlackScheduleCreateTaskTool(context); + tools.slackScheduleListTasks = createSlackScheduleListTasksTool(context); + tools.slackScheduleUpdateTask = createSlackScheduleUpdateTaskTool(context); + tools.slackScheduleDeleteTask = createSlackScheduleDeleteTaskTool(context); + } + return tools; } diff --git a/packages/junior/src/chat/tools/slack/schedule-tools.ts b/packages/junior/src/chat/tools/slack/schedule-tools.ts new file mode 100644 index 00000000..339503e0 --- /dev/null +++ b/packages/junior/src/chat/tools/slack/schedule-tools.ts @@ -0,0 +1,553 @@ +import { randomUUID } from "node:crypto"; +import { Type } from "@sinclair/typebox"; +import { + buildCalendarRecurrence, + parseScheduleTimestamp, +} from "@/chat/scheduler/cadence"; +import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import type { + ScheduledCalendarFrequency, + ScheduledTask, + ScheduledTaskDestination, + ScheduledTaskPrincipal, + ScheduledTaskRecurrence, + ScheduledTaskStatus, +} from "@/chat/scheduler/types"; +import { normalizeSlackConversationId } from "@/chat/slack/client"; +import { tool } from "@/chat/tools/definition"; +import type { ToolRuntimeContext } from "@/chat/tools/types"; + +const TASK_ID_PREFIX = "sched"; +const MAX_LISTED_TASKS = 50; + +function requireActiveDestination( + context: ToolRuntimeContext, +): + | { ok: true; destination: ScheduledTaskDestination } + | { ok: false; error: string } { + const channelId = normalizeSlackConversationId(context.channelId); + if (!channelId) { + return { + ok: false, + error: "No active Slack channel context is available.", + }; + } + if (!context.teamId) { + return { + ok: false, + error: "No active Slack workspace context is available.", + }; + } + if (!context.threadTs) { + return { + ok: false, + error: "No active Slack thread context is available.", + }; + } + + return { + ok: true, + destination: { + platform: "slack", + teamId: context.teamId, + channelId, + threadTs: context.threadTs, + }, + }; +} + +function requireRequester( + context: ToolRuntimeContext, +): + | { ok: true; requester: ScheduledTaskPrincipal } + | { ok: false; error: string } { + const userId = context.requester?.userId; + if (!userId) { + return { + ok: false, + error: "No active Slack requester context is available.", + }; + } + + return { + ok: true, + requester: { + slackUserId: userId, + ...(context.requester?.userName + ? { userName: context.requester.userName } + : {}), + ...(context.requester?.fullName + ? { fullName: context.requester.fullName } + : {}), + }, + }; +} + +function sameDestination( + task: ScheduledTask, + destination: ScheduledTaskDestination, +): boolean { + return ( + task.destination.platform === destination.platform && + task.destination.teamId === destination.teamId && + task.destination.channelId === destination.channelId && + (task.destination.threadTs ?? "") === (destination.threadTs ?? "") + ); +} + +async function getWritableTask(args: { + context: ToolRuntimeContext; + taskId: string; +}): Promise< + | { ok: true; task: ScheduledTask; destination: ScheduledTaskDestination } + | { ok: false; error: string } +> { + const destination = requireActiveDestination(args.context); + if (!destination.ok) { + return destination; + } + + const task = await createStateSchedulerStore().getTask(args.taskId); + if (!task || task.status === "deleted") { + return { + ok: false, + error: "Scheduled task was not found in the active destination.", + }; + } + + if (!sameDestination(task, destination.destination)) { + return { + ok: false, + error: + "Scheduled task can only be managed from the Slack destination where it was created.", + }; + } + + return { + ok: true, + task, + destination: destination.destination, + }; +} + +function compactTask(task: ScheduledTask): Record { + return { + id: task.id, + status: task.status, + title: task.task.title, + objective: task.task.objective, + schedule: task.schedule.description, + timezone: task.schedule.timezone, + recurrence: task.schedule.recurrence + ? { + frequency: task.schedule.recurrence.frequency, + interval: task.schedule.recurrence.interval, + start_date: task.schedule.recurrence.startDate, + time: task.schedule.recurrence.time, + weekdays: task.schedule.recurrence.weekdays, + month: task.schedule.recurrence.month, + day_of_month: task.schedule.recurrence.dayOfMonth, + } + : null, + next_run_at: task.nextRunAtMs + ? new Date(task.nextRunAtMs).toISOString() + : null, + last_run_at: task.lastRunAtMs + ? new Date(task.lastRunAtMs).toISOString() + : null, + version: task.version, + }; +} + +function buildTaskId(): string { + return `${TASK_ID_PREFIX}_${randomUUID()}`; +} + +function normalizeStatus( + value: string | undefined, +): ScheduledTaskStatus | undefined { + if (value === "active" || value === "paused" || value === "blocked") { + return value; + } + return undefined; +} + +function normalizeFrequency( + value: unknown, +): ScheduledCalendarFrequency | undefined { + if ( + value === "daily" || + value === "weekly" || + value === "monthly" || + value === "yearly" + ) { + return value; + } + return undefined; +} + +function buildRecurrence(args: { + existing?: ScheduledTaskRecurrence; + input: { + recurrence_frequency?: unknown; + recurrence_interval?: number; + recurrence_weekdays?: number[]; + }; + nextRunAtMs: number | undefined; + timezone: string; +}): + | { ok: true; recurrence?: ScheduledTaskRecurrence } + | { ok: false; error: string } { + if (args.input.recurrence_frequency === null) { + return { ok: true, recurrence: undefined }; + } + + const frequency = + normalizeFrequency(args.input.recurrence_frequency) ?? + args.existing?.frequency; + if (!frequency) { + return { ok: true, recurrence: undefined }; + } + if (!args.nextRunAtMs) { + return { + ok: false, + error: "Recurring scheduled tasks require next_run_at_iso.", + }; + } + + try { + return { + ok: true, + recurrence: buildCalendarRecurrence({ + frequency, + interval: args.input.recurrence_interval ?? args.existing?.interval, + nextRunAtMs: args.nextRunAtMs, + timezone: args.timezone, + weekdays: + frequency === "weekly" + ? (args.input.recurrence_weekdays ?? args.existing?.weekdays) + : undefined, + }), + }; + } catch (error) { + return { + ok: false, + error: + error instanceof RangeError + ? "timezone must be a valid IANA time zone." + : error instanceof Error + ? error.message + : String(error), + }; + } +} + +/** Create a tool that stores a scheduled task for the active Slack context. */ +export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { + return tool({ + description: + "Create a Junior scheduled task for the active Slack destination. The destination is always the current Slack channel/thread context; never accept or invent another destination. Use only after the user asks to schedule future or recurring Junior work. For recurring work, provide an exact next_run_at_iso and a calendar recurrence_frequency.", + inputSchema: Type.Object({ + title: Type.String({ minLength: 1, maxLength: 120 }), + objective: Type.String({ minLength: 1, maxLength: 1000 }), + instructions: Type.Array(Type.String({ minLength: 1, maxLength: 1000 }), { + minItems: 1, + maxItems: 12, + }), + expected_output: Type.Optional( + Type.String({ minLength: 1, maxLength: 1000 }), + ), + schedule_description: Type.String({ minLength: 1, maxLength: 300 }), + timezone: Type.String({ minLength: 1, maxLength: 80 }), + next_run_at_iso: Type.String({ + minLength: 1, + description: + "Exact next run time as an ISO timestamp, computed from the user's requested schedule.", + }), + recurrence_frequency: Type.Optional( + Type.Union( + [ + Type.Literal("daily"), + Type.Literal("weekly"), + Type.Literal("monthly"), + Type.Literal("yearly"), + ], + { + description: + "Calendar recurrence for recurring tasks. Omit for exact one-off calendar dates.", + }, + ), + ), + recurrence_interval: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 100, + description: + "Calendar interval. For example, 2 with weekly means every two weeks.", + }), + ), + recurrence_weekdays: Type.Optional( + Type.Array(Type.Integer({ minimum: 0, maximum: 6 }), { + maxItems: 7, + description: + "For weekly schedules only. Sunday is 0, Monday is 1, Saturday is 6.", + }), + ), + constraints: Type.Optional( + Type.Array(Type.String({ minLength: 1, maxLength: 1000 }), { + maxItems: 12, + }), + ), + source_context: Type.Optional( + Type.Array(Type.String({ minLength: 1, maxLength: 1000 }), { + maxItems: 12, + }), + ), + }), + execute: async (input) => { + const destination = requireActiveDestination(context); + if (!destination.ok) return destination; + const requester = requireRequester(context); + if (!requester.ok) return requester; + + const nextRunAtMs = parseScheduleTimestamp(input.next_run_at_iso); + if (!nextRunAtMs) { + return { + ok: false, + error: "next_run_at_iso must be a valid ISO timestamp.", + }; + } + const recurrence = buildRecurrence({ + input, + nextRunAtMs, + timezone: input.timezone, + }); + if (!recurrence.ok) { + return recurrence; + } + + const nowMs = Date.now(); + const task: ScheduledTask = { + id: buildTaskId(), + createdAtMs: nowMs, + updatedAtMs: nowMs, + createdBy: requester.requester, + destination: destination.destination, + nextRunAtMs, + originalRequest: context.userText, + schedule: { + description: input.schedule_description, + timezone: input.timezone, + kind: recurrence.recurrence ? "recurring" : "one_off", + recurrence: recurrence.recurrence, + }, + status: "active", + task: { + title: input.title, + objective: input.objective, + instructions: input.instructions, + expectedOutput: input.expected_output, + constraints: input.constraints, + sourceContext: input.source_context, + }, + version: 1, + }; + + await createStateSchedulerStore().saveTask(task); + return { + ok: true, + task: compactTask(task), + }; + }, + }); +} + +/** Create a tool that lists scheduled tasks for the active Slack destination. */ +export function createSlackScheduleListTasksTool(context: ToolRuntimeContext) { + return tool({ + description: + "List Junior scheduled tasks for the active Slack destination only. Use when the user asks what is scheduled here or wants task IDs before editing/removing schedules.", + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: Type.Object({}), + execute: async () => { + const destination = requireActiveDestination(context); + if (!destination.ok) return destination; + + const tasks = await createStateSchedulerStore().listTasksForTeam( + destination.destination.teamId, + ); + const matching = tasks.filter((task) => + sameDestination(task, destination.destination), + ); + const visible = matching.slice(0, MAX_LISTED_TASKS).map(compactTask); + + return { + ok: true, + tasks: visible, + truncated: matching.length > visible.length, + }; + }, + }); +} + +/** Create a tool that edits a scheduled task in the active Slack destination. */ +export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { + return tool({ + description: + "Edit a Junior scheduled task in the active Slack destination. Use only for task IDs returned from the active destination. Do not move tasks across channels or threads.", + inputSchema: Type.Object({ + task_id: Type.String({ minLength: 1 }), + title: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })), + objective: Type.Optional(Type.String({ minLength: 1, maxLength: 1000 })), + instructions: Type.Optional( + Type.Array(Type.String({ minLength: 1, maxLength: 1000 }), { + minItems: 1, + maxItems: 12, + }), + ), + expected_output: Type.Optional( + Type.String({ minLength: 1, maxLength: 1000 }), + ), + schedule_description: Type.Optional( + Type.String({ minLength: 1, maxLength: 300 }), + ), + timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })), + next_run_at_iso: Type.Optional(Type.String({ minLength: 1 })), + recurrence_frequency: Type.Optional( + Type.Union([ + Type.Literal("daily"), + Type.Literal("weekly"), + Type.Literal("monthly"), + Type.Literal("yearly"), + Type.Null(), + ]), + ), + recurrence_interval: Type.Optional( + Type.Integer({ minimum: 1, maximum: 100 }), + ), + recurrence_weekdays: Type.Optional( + Type.Array(Type.Integer({ minimum: 0, maximum: 6 }), { maxItems: 7 }), + ), + status: Type.Optional( + Type.Union([ + Type.Literal("active"), + Type.Literal("paused"), + Type.Literal("blocked"), + ]), + ), + constraints: Type.Optional( + Type.Array(Type.String({ minLength: 1, maxLength: 1000 }), { + maxItems: 12, + }), + ), + source_context: Type.Optional( + Type.Array(Type.String({ minLength: 1, maxLength: 1000 }), { + maxItems: 12, + }), + ), + }), + execute: async (input) => { + const lookup = await getWritableTask({ + context, + taskId: input.task_id, + }); + if (!lookup.ok) return lookup; + + const nextRunAtMs = input.next_run_at_iso + ? parseScheduleTimestamp(input.next_run_at_iso) + : lookup.task.nextRunAtMs; + if (input.next_run_at_iso && !nextRunAtMs) { + return { + ok: false, + error: "next_run_at_iso must be a valid ISO timestamp.", + }; + } + + const status = normalizeStatus(input.status); + if (input.status && !status) { + return { + ok: false, + error: "status must be active, paused, or blocked.", + }; + } + if (status === "active" && !nextRunAtMs) { + return { + ok: false, + error: + "Active scheduled tasks require next_run_at_iso when no next run is stored.", + }; + } + const timezone = input.timezone ?? lookup.task.schedule.timezone; + const recurrence = buildRecurrence({ + existing: lookup.task.schedule.recurrence, + input, + nextRunAtMs, + timezone, + }); + if (!recurrence.ok) { + return recurrence; + } + + const next: ScheduledTask = { + ...lookup.task, + updatedAtMs: Date.now(), + nextRunAtMs, + status: status ?? lookup.task.status, + schedule: { + ...lookup.task.schedule, + description: + input.schedule_description ?? lookup.task.schedule.description, + timezone, + kind: recurrence.recurrence ? "recurring" : "one_off", + recurrence: recurrence.recurrence, + }, + task: { + ...lookup.task.task, + title: input.title ?? lookup.task.task.title, + objective: input.objective ?? lookup.task.task.objective, + instructions: input.instructions ?? lookup.task.task.instructions, + expectedOutput: + input.expected_output ?? lookup.task.task.expectedOutput, + constraints: input.constraints ?? lookup.task.task.constraints, + sourceContext: input.source_context ?? lookup.task.task.sourceContext, + }, + version: lookup.task.version + 1, + }; + + await createStateSchedulerStore().saveTask(next); + return { + ok: true, + task: compactTask(next), + }; + }, + }); +} + +/** Create a tool that removes a scheduled task from the active Slack destination. */ +export function createSlackScheduleDeleteTaskTool(context: ToolRuntimeContext) { + return tool({ + description: + "Remove a Junior scheduled task from the active Slack destination. Use only for task IDs returned from this destination.", + inputSchema: Type.Object({ + task_id: Type.String({ minLength: 1 }), + }), + execute: async ({ task_id }) => { + const lookup = await getWritableTask({ context, taskId: task_id }); + if (!lookup.ok) return lookup; + + const next: ScheduledTask = { + ...lookup.task, + updatedAtMs: Date.now(), + status: "deleted", + nextRunAtMs: undefined, + version: lookup.task.version + 1, + }; + + await createStateSchedulerStore().saveTask(next); + return { + ok: true, + task: compactTask(next), + }; + }, + }); +} diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index a7ef70ae..e9aed3be 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -46,6 +46,12 @@ export interface ToolRuntimeContext { advisor?: AdvisorToolRuntimeContext; channelId?: string; channelCapabilities: ChannelCapabilities; + requester?: { + userId?: string; + userName?: string; + fullName?: string; + }; + teamId?: string; messageTs?: string; threadTs?: string; userText?: string; diff --git a/packages/junior/src/handlers/diagnostics-dashboard.ts b/packages/junior/src/handlers/diagnostics-dashboard.ts index 5756e0d3..e271b1d1 100644 --- a/packages/junior/src/handlers/diagnostics-dashboard.ts +++ b/packages/junior/src/handlers/diagnostics-dashboard.ts @@ -126,6 +126,7 @@ export async function GET(): Promise { { method: "GET", path: "/api/info" }, { method: "GET", path: "/api/oauth/callback/mcp/:provider" }, { method: "GET", path: "/api/oauth/callback/:provider" }, + { method: "POST", path: "/api/internal/scheduler/tick" }, { method: "POST", path: "/api/webhooks/:platform" }, ]; html += `\n
diff --git a/packages/junior/src/handlers/scheduler-tick.ts b/packages/junior/src/handlers/scheduler-tick.ts new file mode 100644 index 00000000..132af055 --- /dev/null +++ b/packages/junior/src/handlers/scheduler-tick.ts @@ -0,0 +1,57 @@ +import { processDueScheduledRuns } from "@/chat/scheduler/executor"; +import { createSlackScheduledTaskRunner } from "@/chat/scheduler/slack-runner"; +import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import { logException } from "@/chat/logging"; +import type { WaitUntilFn } from "@/handlers/types"; + +const DEFAULT_SCHEDULER_TICK_LIMIT = 10; + +function getSchedulerSecret(): string | undefined { + return ( + process.env.JUNIOR_SCHEDULER_SECRET?.trim() || + process.env.CRON_SECRET?.trim() || + process.env.JUNIOR_INTERNAL_RESUME_SECRET?.trim() + ); +} + +function verifySchedulerRequest(request: Request): boolean { + const secret = getSchedulerSecret(); + if (!secret) { + return false; + } + + const authorization = request.headers.get("authorization")?.trim(); + return authorization === `Bearer ${secret}`; +} + +/** Handle the authenticated internal scheduler tick. */ +export async function ALL( + request: Request, + waitUntil: WaitUntilFn, +): Promise { + if (!verifySchedulerRequest(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + const nowMs = Date.now(); + waitUntil(() => + processDueScheduledRuns({ + store: createStateSchedulerStore(), + runner: createSlackScheduledTaskRunner(), + nowMs, + limit: DEFAULT_SCHEDULER_TICK_LIMIT, + }).catch((error) => { + logException( + error, + "scheduler_tick_failed", + {}, + { + "app.scheduler.now_ms": nowMs, + }, + "Scheduler tick failed", + ); + }), + ); + + return new Response("Accepted", { status: 202 }); +} diff --git a/packages/junior/src/vercel.ts b/packages/junior/src/vercel.ts index cefa2743..5e31ccf6 100644 --- a/packages/junior/src/vercel.ts +++ b/packages/junior/src/vercel.ts @@ -9,6 +9,12 @@ export function juniorVercelConfig(options: JuniorVercelConfigOptions = {}) { const config: Record = { framework: "nitro", + crons: [ + { + path: "/api/internal/scheduler/tick", + schedule: "* * * * *", + }, + ], }; if (buildCommand !== null) { diff --git a/packages/junior/tests/integration/scheduler-executor.test.ts b/packages/junior/tests/integration/scheduler-executor.test.ts new file mode 100644 index 00000000..0afdc896 --- /dev/null +++ b/packages/junior/tests/integration/scheduler-executor.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { + executeScheduledRun, + processDueScheduledRuns, + type ScheduledTaskRunner, +} from "@/chat/scheduler/executor"; +import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import type { ScheduledTask } from "@/chat/scheduler/types"; + +vi.hoisted(() => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; +}); + +function createTask(overrides: Partial = {}): ScheduledTask { + const firstRunAtMs = Date.parse("2026-03-02T17:00:00.000Z"); + return { + id: `sched_executor_${Date.now()}`, + createdAtMs: firstRunAtMs, + updatedAtMs: firstRunAtMs, + createdBy: { + slackUserId: "U123", + userName: "dcramer", + fullName: "David Cramer", + }, + destination: { + platform: "slack", + teamId: "T_EXECUTOR", + channelId: "C123", + threadTs: "1700000000.000000", + }, + nextRunAtMs: firstRunAtMs, + schedule: { + description: "Every Monday at 9am Pacific", + timezone: "America/Los_Angeles", + kind: "recurring", + recurrence: { + frequency: "weekly", + interval: 1, + startDate: "2026-03-02", + time: { + hour: 9, + minute: 0, + }, + weekdays: [1], + }, + }, + status: "active", + task: { + title: "Issue digest", + objective: "Summarize scheduler issues.", + instructions: ["Find open scheduler issues", "Post a concise digest"], + }, + version: 1, + ...overrides, + }; +} + +describe("scheduler executor", () => { + beforeEach(async () => { + await disconnectStateAdapter(); + }); + + afterEach(async () => { + await disconnectStateAdapter(); + }); + + it("wraps claimed tasks in the scheduled-run prompt and advances recurrence", async () => { + const store = createStateSchedulerStore(); + const task = createTask(); + await store.saveTask(task); + const prompts: string[] = []; + const runner: ScheduledTaskRunner = { + run: async ({ prompt }) => { + prompts.push(prompt); + return { status: "completed", resultMessageTs: "1700000000.000001" }; + }, + }; + + const completed = await processDueScheduledRuns({ + store, + runner, + nowMs: Date.parse("2026-03-02T17:00:04.500Z"), + limit: 10, + }); + + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + taskId: task.id, + status: "completed", + scheduledForMs: Date.parse("2026-03-02T17:00:00.000Z"), + }); + expect(prompts[0]).toContain(""); + expect(prompts[0]).toContain( + "Execute the scheduled task now and provide the final result", + ); + + const updated = await store.getTask(task.id); + expect(updated).toMatchObject({ + status: "active", + lastRunAtMs: Date.parse("2026-03-02T17:00:00.000Z"), + nextRunAtMs: Date.parse("2026-03-09T16:00:00.000Z"), + version: 2, + }); + }); + + it("keeps monthly recurrence on the exact calendar date", async () => { + const store = createStateSchedulerStore(); + const firstRunAtMs = Date.parse("2026-01-31T09:00:00.000Z"); + const task = createTask({ + id: `sched_monthly_${Date.now()}`, + nextRunAtMs: firstRunAtMs, + schedule: { + description: "Every month on the 31st at 9am UTC", + timezone: "UTC", + kind: "recurring", + recurrence: { + frequency: "monthly", + interval: 1, + startDate: "2026-01-31", + time: { + hour: 9, + minute: 0, + }, + dayOfMonth: 31, + }, + }, + }); + await store.saveTask(task); + + await processDueScheduledRuns({ + store, + nowMs: Date.parse("2026-02-01T00:00:00.000Z"), + limit: 10, + runner: { + run: async () => ({ status: "completed" }), + }, + }); + + const updated = await store.getTask(task.id); + expect(updated).toMatchObject({ + lastRunAtMs: firstRunAtMs, + nextRunAtMs: Date.parse("2026-03-31T09:00:00.000Z"), + }); + }); + + it("blocks the task when the runner reports missing requirements", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_blocked_${Date.now()}` }); + await store.saveTask(task); + const [run] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:00:00.000Z"), + limit: 10, + }); + + const completed = await executeScheduledRun({ + store, + run, + nowMs: Date.parse("2026-03-02T17:00:01.500Z"), + runner: { + run: async () => ({ + status: "blocked", + errorMessage: "Missing GitHub credentials.", + }), + }, + }); + + expect(completed).toMatchObject({ + status: "blocked", + errorMessage: "Missing GitHub credentials.", + }); + const updated = await store.getTask(task.id); + expect(updated).toMatchObject({ + status: "blocked", + statusReason: "Missing GitHub credentials.", + nextRunAtMs: undefined, + }); + }); + + it("does not resurrect a task deleted while a run is executing", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_deleted_${Date.now()}` }); + await store.saveTask(task); + const [run] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:00:00.000Z"), + limit: 10, + }); + + await executeScheduledRun({ + store, + run, + nowMs: Date.parse("2026-03-02T17:00:01.500Z"), + runner: { + run: async () => { + await store.saveTask({ + ...task, + status: "deleted", + nextRunAtMs: undefined, + updatedAtMs: Date.parse("2026-03-02T17:00:01.000Z"), + version: task.version + 1, + }); + return { status: "completed" }; + }, + }, + }); + + const updated = await store.getTask(task.id); + expect(updated).toMatchObject({ + status: "deleted", + nextRunAtMs: undefined, + version: 2, + }); + }); +}); diff --git a/packages/junior/tests/integration/scheduler-slack-runner.test.ts b/packages/junior/tests/integration/scheduler-slack-runner.test.ts new file mode 100644 index 00000000..cca033fa --- /dev/null +++ b/packages/junior/tests/integration/scheduler-slack-runner.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { createSlackScheduledTaskRunner } from "@/chat/scheduler/slack-runner"; +import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; +import type { AssistantReply } from "@/chat/respond"; +import { chatPostMessageOk } from "../fixtures/slack/factories/api"; +import { + getCapturedSlackApiCalls, + queueSlackApiResponse, +} from "../msw/handlers/slack-api"; + +vi.hoisted(() => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; +}); + +function createTask(): ScheduledTask { + const scheduledForMs = Date.parse("2026-03-02T17:00:00.000Z"); + return { + id: "sched_slack_runner", + createdAtMs: scheduledForMs, + updatedAtMs: scheduledForMs, + createdBy: { + slackUserId: "U123", + userName: "dcramer", + fullName: "David Cramer", + }, + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + threadTs: "1700000000.000000", + }, + nextRunAtMs: scheduledForMs, + schedule: { + description: "Every Monday at 9am Pacific", + timezone: "America/Los_Angeles", + kind: "recurring", + recurrence: { + frequency: "weekly", + interval: 1, + startDate: "2026-03-02", + time: { + hour: 9, + minute: 0, + }, + weekdays: [1], + }, + }, + status: "active", + task: { + title: "Issue digest", + objective: "Summarize scheduler issues.", + instructions: ["Find open scheduler issues", "Post a concise digest"], + }, + version: 1, + }; +} + +function createRun(task: ScheduledTask): ScheduledRun { + const scheduledForMs = task.nextRunAtMs!; + return { + id: `${task.id}:${scheduledForMs}`, + attempt: 1, + claimedAtMs: scheduledForMs, + idempotencyKey: `${task.id}:${scheduledForMs}`, + scheduledForMs, + status: "running", + startedAtMs: scheduledForMs, + taskId: task.id, + taskVersion: task.version, + }; +} + +function createReply(): AssistantReply { + return { + text: "Scheduled digest delivered.", + deliveryMode: "thread", + deliveryPlan: { + mode: "thread", + postThreadText: true, + attachFiles: "none", + }, + diagnostics: { + assistantMessageCount: 1, + durationMs: 1234, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + }; +} + +describe("scheduled Slack runner", () => { + beforeEach(async () => { + await disconnectStateAdapter(); + }); + + afterEach(async () => { + await disconnectStateAdapter(); + }); + + it("delivers scheduled run output through Slack Web API", async () => { + queueSlackApiResponse("chat.postMessage", { + body: chatPostMessageOk({ + channel: "C123", + ts: "1700000000.000001", + }), + }); + const task = createTask(); + const run = createRun(task); + const runner = createSlackScheduledTaskRunner({ + generateAssistantReply: async (_prompt, context) => { + if (!context) { + throw new Error("expected reply context"); + } + expect(context.requester).toMatchObject({ + userId: "U123", + userName: "dcramer", + fullName: "David Cramer", + }); + expect(context.correlation).toMatchObject({ + channelId: "C123", + teamId: "T123", + threadTs: "1700000000.000000", + runId: run.id, + }); + return createReply(); + }, + }); + + const result = await runner.run({ + task, + run, + prompt: "", + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + }); + + expect(result).toEqual({ + status: "completed", + resultMessageTs: "1700000000.000001", + }); + expect(getCapturedSlackApiCalls("chat.postMessage")).toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + channel: "C123", + thread_ts: "1700000000.000000", + text: "Scheduled digest delivered.", + }), + }), + ]); + }); +}); diff --git a/packages/junior/tests/integration/scheduler-tick.test.ts b/packages/junior/tests/integration/scheduler-tick.test.ts new file mode 100644 index 00000000..06c63649 --- /dev/null +++ b/packages/junior/tests/integration/scheduler-tick.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { ALL as schedulerTick } from "@/handlers/scheduler-tick"; +import type { WaitUntilFn } from "@/handlers/types"; + +vi.hoisted(() => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; +}); + +function collectWaitUntil(tasks: Promise[]): WaitUntilFn { + return (task) => { + tasks.push(typeof task === "function" ? task() : task); + }; +} + +describe("scheduler tick handler", () => { + beforeEach(async () => { + process.env.JUNIOR_SCHEDULER_SECRET = "test-secret"; + await disconnectStateAdapter(); + }); + + afterEach(async () => { + await disconnectStateAdapter(); + delete process.env.JUNIOR_SCHEDULER_SECRET; + }); + + it("rejects unauthenticated scheduler ticks", async () => { + const waitUntilTasks: Promise[] = []; + const response = await schedulerTick( + new Request("https://example.invalid/api/internal/scheduler/tick"), + collectWaitUntil(waitUntilTasks), + ); + + expect(response.status).toBe(401); + expect(waitUntilTasks).toHaveLength(0); + }); + + it("accepts bearer-authenticated scheduler ticks", async () => { + const waitUntilTasks: Promise[] = []; + const response = await schedulerTick( + new Request("https://example.invalid/api/internal/scheduler/tick", { + headers: { + authorization: "Bearer test-secret", + }, + }), + collectWaitUntil(waitUntilTasks), + ); + + expect(response.status).toBe(202); + await Promise.all(waitUntilTasks); + expect(waitUntilTasks).toHaveLength(1); + }); +}); diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts new file mode 100644 index 00000000..145760fd --- /dev/null +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import { + createSlackScheduleCreateTaskTool, + createSlackScheduleDeleteTaskTool, + createSlackScheduleListTasksTool, + createSlackScheduleUpdateTaskTool, +} from "@/chat/tools/slack/schedule-tools"; +import type { ToolRuntimeContext } from "@/chat/tools/types"; + +vi.hoisted(() => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; +}); + +const TEST_TEAM_ID = `T_SCHEDULE_${Date.now()}`; + +function createContext( + overrides: Partial = {}, +): ToolRuntimeContext { + return { + channelId: "C123", + teamId: TEST_TEAM_ID, + threadTs: "1700000000.000000", + requester: { + userId: "U123", + userName: "dcramer", + fullName: "David Cramer", + }, + channelCapabilities: { + canCreateCanvas: true, + canPostToChannel: true, + canAddReactions: true, + }, + userText: "schedule this weekly", + sandbox: {} as ToolRuntimeContext["sandbox"], + ...overrides, + }; +} + +async function executeTool(tool: any, input: TInput) { + if (typeof tool?.execute !== "function") { + throw new Error("tool execute function missing"); + } + return await tool.execute(input, {} as any); +} + +async function createTask(context = createContext()) { + const tool = createSlackScheduleCreateTaskTool(context); + return await executeTool(tool, { + title: "Weekly issue digest", + objective: "Summarize open scheduler issues.", + instructions: ["Find open scheduler issues", "Post a concise summary"], + expected_output: "A short Slack digest", + schedule_description: "Every Monday at 9am", + timezone: "America/Los_Angeles", + next_run_at_iso: "2026-05-25T16:00:00.000Z", + recurrence_frequency: "weekly", + recurrence_weekdays: [1], + }); +} + +describe("Slack schedule tools", () => { + beforeEach(async () => { + await disconnectStateAdapter(); + }); + + afterEach(async () => { + await disconnectStateAdapter(); + }); + + it("creates and lists tasks only for the active Slack destination", async () => { + const created = await createTask(); + expect(created).toMatchObject({ + ok: true, + task: { + status: "active", + title: "Weekly issue digest", + recurrence: { + frequency: "weekly", + interval: 1, + weekdays: [1], + }, + next_run_at: "2026-05-25T16:00:00.000Z", + }, + }); + + const listed = await executeTool( + createSlackScheduleListTasksTool(createContext()), + {}, + ); + expect(listed).toMatchObject({ + ok: true, + tasks: [ + { + title: "Weekly issue digest", + schedule: "Every Monday at 9am", + }, + ], + }); + + const wrongThread = await executeTool( + createSlackScheduleListTasksTool( + createContext({ threadTs: "1700000999.000000" }), + ), + {}, + ); + expect(wrongThread).toMatchObject({ + ok: true, + tasks: [], + }); + }); + + it("edits and deletes a task from the same Slack destination", async () => { + const context = createContext({ threadTs: "1700000001.000000" }); + const created = (await createTask(context)) as { + task: { id: string }; + }; + const taskId = created.task.id; + + const updated = await executeTool( + createSlackScheduleUpdateTaskTool(context), + { + task_id: taskId, + title: "Daily scheduler digest", + schedule_description: "Every day at 9am", + recurrence_frequency: "daily", + }, + ); + expect(updated).toMatchObject({ + ok: true, + task: { + id: taskId, + title: "Daily scheduler digest", + schedule: "Every day at 9am", + version: 2, + }, + }); + + const deleted = await executeTool( + createSlackScheduleDeleteTaskTool(context), + { + task_id: taskId, + }, + ); + expect(deleted).toMatchObject({ + ok: true, + task: { + id: taskId, + status: "deleted", + }, + }); + + const listed = await executeTool( + createSlackScheduleListTasksTool(context), + {}, + ); + expect(listed).toMatchObject({ ok: true, tasks: [] }); + }); + + it("rejects edits from another active Slack destination", async () => { + const context = createContext({ threadTs: "1700000002.000000" }); + const created = (await createTask(context)) as { + task: { id: string }; + }; + + const updated = await executeTool( + createSlackScheduleUpdateTaskTool(createContext({ channelId: "C999" })), + { + task_id: created.task.id, + title: "Wrong channel edit", + }, + ); + + expect(updated).toMatchObject({ + ok: false, + error: + "Scheduled task can only be managed from the Slack destination where it was created.", + }); + }); + + it("claims due runs idempotently", async () => { + const context = createContext({ threadTs: "1700000003.000000" }); + const created = (await createTask(context)) as { + task: { id: string }; + }; + const store = createStateSchedulerStore(); + const task = await store.getTask(created.task.id); + expect(task).toBeDefined(); + await store.saveTask({ + ...task!, + nextRunAtMs: 1000, + updatedAtMs: 1000, + }); + + const first = await store.claimDueRuns({ nowMs: 2000, limit: 10 }); + const second = await store.claimDueRuns({ nowMs: 2000, limit: 10 }); + + expect(first).toHaveLength(1); + expect(first[0]).toMatchObject({ + taskId: created.task.id, + scheduledForMs: 1000, + status: "pending", + }); + expect(second).toHaveLength(0); + }); +}); diff --git a/specs/index.md b/specs/index.md index 9c3b8c4a..c05871f5 100644 --- a/specs/index.md +++ b/specs/index.md @@ -17,6 +17,7 @@ - 2026-04-28: Added canonical agent prompt spec. - 2026-05-06: Added draft advisor tool spec. - 2026-05-13: Added ownership map for chat, agent session, and Slack delivery specs. +- 2026-05-18: Added draft scheduler spec for scheduled Junior tasks. ## Status @@ -82,6 +83,7 @@ For chat/agent/Slack turn behavior: ## Draft Specs - `specs/advisor-tool-spec.md` +- `specs/scheduler-spec.md` ## Archive Policy diff --git a/specs/scheduler-spec.md b/specs/scheduler-spec.md new file mode 100644 index 00000000..2b0d143e --- /dev/null +++ b/specs/scheduler-spec.md @@ -0,0 +1,189 @@ +# Scheduler Spec + +## Metadata + +- Created: 2026-05-18 +- Last Edited: 2026-05-18 + +## Changelog + +- 2026-05-18: Clarified V1 calendar model: exact next-run instants plus simple daily/weekly/monthly/yearly recurrence rules. +- 2026-05-18: Initial draft contract for scheduled Junior tasks, prompt framing, no-SQL storage, run idempotency, and eval-first verification. + +## Status + +Draft + +## Purpose + +Define the first scheduler contract for Junior: users can create durable tasks that Junior executes later or repeatedly, with explicit task framing and delivery back to the configured surface. + +## Scope + +- Scheduled task and scheduled run data model. +- Prompt envelope used when executing a scheduled task. +- Storage and idempotency rules. +- Slack authoring and management behavior. +- Verification layer ownership. + +## Non-Goals + +- A generic event-rule engine for GitHub, Slack, Sentry, or webhook events. +- SQL-backed storage as a V1 requirement. +- A full durable workflow runtime such as Temporal or Vercel Workflow. +- Reusing timeout-resume callbacks as the product scheduler. +- Slack `chat.scheduleMessage` as the execution mechanism. + +## Contracts + +### Product Boundary + +A scheduled task is not a stored Slack message. It is a normalized task contract that Junior executes on a time trigger. + +The stored task must include: + +- task title +- objective +- instructions +- expected output +- creator/requester identity +- destination surface +- schedule and timezone +- current status +- next-run timestamp when active +- recurrence rule when recurring +- optional constraints and source context + +The original user utterance may be retained for audit/debugging, but it must not be the sole execution input. + +### Calendar Model + +Every active task must have an exact `nextRunAtMs` instant. For one-off tasks, that instant is the complete schedule. + +Recurring tasks must also store a small calendar recurrence rule: + +- frequency: `daily`, `weekly`, `monthly`, or `yearly` +- positive interval +- local start date +- local time +- timezone +- optional weekly weekdays +- optional monthly/yearly exact day-of-month and month + +V1 recurrence is calendar-based, not fixed-duration. For example, "every Monday at 9am America/Los_Angeles" should continue to run at 9am local time across daylight-saving changes. Monthly and yearly recurrences use exact calendar dates; unsupported dates are skipped rather than converted into "last day" or "business day" behavior. + +The scheduler does not need advanced rules such as first business day, nearest weekday, holiday calendars, or arbitrary cron syntax. + +### Prompt Framing + +Every scheduled run must compile the stored task into a marker-delimited prompt before entering the agent runtime. + +The prompt must make these facts explicit: + +1. This is an autonomous scheduled run. +2. This is not a request to create, update, pause, delete, or list schedules. +3. The task contract is the source of truth for what to execute. +4. The run should complete without asking follow-up questions unless access, approval, or required input is missing. +5. If blocked, the result should identify the missing provider, permission, or input. + +The compiled prompt must separate descriptive task facts from directives. Use marker blocks such as: + +- `` +- `` +- `` +- `` +- `` + +This follows the router and turn-context pattern: background and state live in descriptive blocks, while behavior rules live in a rules block and the actual ask appears last. + +### Storage + +V1 must not require SQL. The scheduler store should use the existing durable state dependency already required by Junior deployments. + +The initial implementation may use the Chat SDK state adapter and a global task index: + +- `junior:scheduler:task:{task_id}` stores the task record. +- `junior:scheduler:tasks` stores task ids for due scans. +- `junior:scheduler:team:{team_id}:tasks` stores task ids for workspace management. +- `junior:scheduler:run:{run_id}` stores run history. +- `junior:scheduler:claim:{task_id}:{scheduled_for_ms}` is the idempotency claim. + +A future Redis-native store may replace the scan index with a sorted due index without changing the runtime-facing scheduler store interface. + +### Run Idempotency + +Scheduled execution is at-least-once at the trigger layer and exactly-once-best-effort at Junior's run layer. + +Rules: + +1. A run idempotency key is `task_id:scheduled_for_ms`. +2. The scheduler must claim that key before dispatch. +3. Duplicate ticks, retries, and overlapping invocations must return the existing run or skip dispatch. +4. Run side effects must be keyed by the scheduled run id where possible. +5. A task must not overlap with itself by default. If one run is active, a later due time should be skipped, coalesced, or blocked according to the task policy. + +### Auth Principal + +Scheduled runs execute as the task creator unless the task contract explicitly names a different supported service principal. + +Requester-bound provider credentials, OAuth state, sandbox egress, and audit metadata must use the scheduled task principal. If that principal lacks valid credentials, Junior must block the run and privately notify the creator when possible. Authorization links must not be posted publicly. + +### Slack UX + +Slack authoring should be confirm-first: + +1. User asks Junior to schedule work. +2. Junior drafts the normalized task: title, cadence, timezone, destination, objective, expected output, and next run. +3. User confirms before the task becomes active. +4. Junior supports list, pause, resume, delete, and run-now commands. + +Confirmation should show the executable task contract, not only echo the user's text. + +## Failure Model + +1. Tick delivery fails: the task remains due and a later tick may claim it. +2. Duplicate tick delivery: the run claim suppresses duplicate dispatch. +3. Run fails after claim: run record captures failure and retry policy decides whether to re-dispatch. +4. Task credentials are missing: mark the run blocked and keep or pause the task according to policy. +5. Prompt framing is ambiguous: evals must catch cases where the model creates/edits a schedule instead of executing the task. + +## Observability + +Scheduler execution should emit safe task/run metadata only: + +- task id +- run id +- scheduled timestamp +- task status +- run status +- destination platform and channel id +- requester Slack user id + +Logs and spans must not include OAuth tokens, provider credentials, raw authorization URLs, or private tool payloads. + +## Verification + +Use evals for model-dependent behavior: + +- natural-language schedule extraction +- task framing quality +- confirmation quality +- scheduled-run execution behavior +- not confusing scheduled execution with schedule creation + +Use integration tests for runtime/storage contracts that do not depend on model interpretation: + +- due claim idempotency +- blocked auth path +- dispatch to Slack delivery +- pause/delete/list management surfaces + +Use unit tests only for small deterministic helpers when integration or eval coverage would be wasteful. + +## Related Specs + +- `./chat-architecture-spec.md` +- `./agent-prompt-spec.md` +- `./agent-session-resumability-spec.md` +- `./slack-agent-delivery-spec.md` +- `./testing/index.md` From e6ed45e01f81b29c78aa8248f5c14efbc6f19dce Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 08:14:12 -0700 Subject: [PATCH 02/17] fix(scheduler): Restrict scheduled task mutations Require scheduled task edits and deletes to come from the creator so a thread member cannot change work that later runs with another user's credentials. Keep scheduler tick auth scoped to scheduler secrets and prune deleted tasks from scheduler scan indexes. Co-Authored-By: GPT-5 Codex --- packages/junior/src/chat/scheduler/store.ts | 50 +++++++++++++ .../src/chat/tools/slack/schedule-tools.ts | 11 +++ .../junior/src/handlers/scheduler-tick.ts | 3 +- .../tests/integration/scheduler-tick.test.ts | 37 ++++++++++ .../integration/slack-schedule-tools.test.ts | 73 ++++++++++++++++++- 5 files changed, 170 insertions(+), 4 deletions(-) diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts index e3827648..d0d5cc37 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior/src/chat/scheduler/store.ts @@ -97,6 +97,29 @@ async function addToIndex( }); } +async function removeFromIndex( + state: StateAdapter, + key: string, + taskId: string, +): Promise { + await withLock(state, indexLockKey(key), async () => { + const current = unique( + ((await state.get(key)) ?? []).filter( + (value): value is string => typeof value === "string", + ), + ); + const next = current.filter((value) => value !== taskId); + if (next.length === current.length) { + return; + } + if (next.length === 0) { + await state.delete(key); + return; + } + await state.set(key, next, SCHEDULER_RECORD_TTL_MS); + }); +} + async function getIndex(state: StateAdapter, key: string): Promise { const values = (await state.get(key)) ?? []; return unique( @@ -145,13 +168,40 @@ class StateAdapterSchedulerStore implements SchedulerStore { async saveTask(task: ScheduledTask): Promise { await this.state.connect(); + const current = + (await this.state.get(taskKey(task.id))) ?? undefined; await this.state.set(taskKey(task.id), task, SCHEDULER_RECORD_TTL_MS); + + if (task.status === "deleted") { + await removeFromIndex(this.state, globalTaskIndexKey(), task.id); + await removeFromIndex( + this.state, + teamTaskIndexKey(task.destination.teamId), + task.id, + ); + if (current && current.destination.teamId !== task.destination.teamId) { + await removeFromIndex( + this.state, + teamTaskIndexKey(current.destination.teamId), + task.id, + ); + } + return; + } + await addToIndex(this.state, globalTaskIndexKey(), task.id); await addToIndex( this.state, teamTaskIndexKey(task.destination.teamId), task.id, ); + if (current && current.destination.teamId !== task.destination.teamId) { + await removeFromIndex( + this.state, + teamTaskIndexKey(current.destination.teamId), + task.id, + ); + } } async getTask(taskId: string): Promise { diff --git a/packages/junior/src/chat/tools/slack/schedule-tools.ts b/packages/junior/src/chat/tools/slack/schedule-tools.ts index 339503e0..a7b76ab9 100644 --- a/packages/junior/src/chat/tools/slack/schedule-tools.ts +++ b/packages/junior/src/chat/tools/slack/schedule-tools.ts @@ -106,6 +106,10 @@ async function getWritableTask(args: { if (!destination.ok) { return destination; } + const requester = requireRequester(args.context); + if (!requester.ok) { + return requester; + } const task = await createStateSchedulerStore().getTask(args.taskId); if (!task || task.status === "deleted") { @@ -122,6 +126,13 @@ async function getWritableTask(args: { "Scheduled task can only be managed from the Slack destination where it was created.", }; } + if (task.createdBy.slackUserId !== requester.requester.slackUserId) { + return { + ok: false, + error: + "Scheduled task can only be managed by the Slack user who created it.", + }; + } return { ok: true, diff --git a/packages/junior/src/handlers/scheduler-tick.ts b/packages/junior/src/handlers/scheduler-tick.ts index 132af055..9b3ba189 100644 --- a/packages/junior/src/handlers/scheduler-tick.ts +++ b/packages/junior/src/handlers/scheduler-tick.ts @@ -9,8 +9,7 @@ const DEFAULT_SCHEDULER_TICK_LIMIT = 10; function getSchedulerSecret(): string | undefined { return ( process.env.JUNIOR_SCHEDULER_SECRET?.trim() || - process.env.CRON_SECRET?.trim() || - process.env.JUNIOR_INTERNAL_RESUME_SECRET?.trim() + process.env.CRON_SECRET?.trim() ); } diff --git a/packages/junior/tests/integration/scheduler-tick.test.ts b/packages/junior/tests/integration/scheduler-tick.test.ts index 06c63649..72ca2a6e 100644 --- a/packages/junior/tests/integration/scheduler-tick.test.ts +++ b/packages/junior/tests/integration/scheduler-tick.test.ts @@ -22,6 +22,8 @@ describe("scheduler tick handler", () => { afterEach(async () => { await disconnectStateAdapter(); delete process.env.JUNIOR_SCHEDULER_SECRET; + delete process.env.CRON_SECRET; + delete process.env.JUNIOR_INTERNAL_RESUME_SECRET; }); it("rejects unauthenticated scheduler ticks", async () => { @@ -50,4 +52,39 @@ describe("scheduler tick handler", () => { await Promise.all(waitUntilTasks); expect(waitUntilTasks).toHaveLength(1); }); + + it("accepts cron bearer authentication", async () => { + delete process.env.JUNIOR_SCHEDULER_SECRET; + process.env.CRON_SECRET = "cron-secret"; + const waitUntilTasks: Promise[] = []; + const response = await schedulerTick( + new Request("https://example.invalid/api/internal/scheduler/tick", { + headers: { + authorization: "Bearer cron-secret", + }, + }), + collectWaitUntil(waitUntilTasks), + ); + + expect(response.status).toBe(202); + await Promise.all(waitUntilTasks); + expect(waitUntilTasks).toHaveLength(1); + }); + + it("does not accept the timeout resume secret for scheduler ticks", async () => { + delete process.env.JUNIOR_SCHEDULER_SECRET; + process.env.JUNIOR_INTERNAL_RESUME_SECRET = "resume-secret"; + const waitUntilTasks: Promise[] = []; + const response = await schedulerTick( + new Request("https://example.invalid/api/internal/scheduler/tick", { + headers: { + authorization: "Bearer resume-secret", + }, + }), + collectWaitUntil(waitUntilTasks), + ); + + expect(response.status).toBe(401); + expect(waitUntilTasks).toHaveLength(0); + }); }); diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index 145760fd..10b6d8d0 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; import { createStateSchedulerStore } from "@/chat/scheduler/store"; import { createSlackScheduleCreateTaskTool, @@ -179,11 +179,80 @@ describe("Slack schedule tools", () => { }); }); - it("claims due runs idempotently", async () => { + it("rejects edits and deletes from another requester in the same Slack destination", async () => { const context = createContext({ threadTs: "1700000003.000000" }); const created = (await createTask(context)) as { task: { id: string }; }; + const otherRequester = createContext({ + threadTs: context.threadTs, + requester: { + userId: "U999", + userName: "alice", + fullName: "Alice Reviewer", + }, + }); + + const updated = await executeTool( + createSlackScheduleUpdateTaskTool(otherRequester), + { + task_id: created.task.id, + title: "Hijacked digest", + }, + ); + const deleted = await executeTool( + createSlackScheduleDeleteTaskTool(otherRequester), + { + task_id: created.task.id, + }, + ); + + expect(updated).toMatchObject({ + ok: false, + error: + "Scheduled task can only be managed by the Slack user who created it.", + }); + expect(deleted).toMatchObject({ + ok: false, + error: + "Scheduled task can only be managed by the Slack user who created it.", + }); + await expect( + createStateSchedulerStore().getTask(created.task.id), + ).resolves.toMatchObject({ + status: "active", + task: { + title: "Weekly issue digest", + }, + version: 1, + }); + }); + + it("removes deleted tasks from scheduler indexes", async () => { + const context = createContext({ threadTs: "1700000004.000000" }); + const created = (await createTask(context)) as { + task: { id: string }; + }; + + await executeTool(createSlackScheduleDeleteTaskTool(context), { + task_id: created.task.id, + }); + + const state = getStateAdapter(); + await state.connect(); + await expect(state.get("junior:scheduler:tasks")).resolves.toBe( + null, + ); + await expect( + state.get(`junior:scheduler:team:${TEST_TEAM_ID}:tasks`), + ).resolves.toBe(null); + }); + + it("claims due runs idempotently", async () => { + const context = createContext({ threadTs: "1700000005.000000" }); + const created = (await createTask(context)) as { + task: { id: string }; + }; const store = createStateSchedulerStore(); const task = await store.getTask(created.task.id); expect(task).toBeDefined(); From 7aa4c6ead20683ebf915ba9ccb4263fb9a407d30 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 08:26:39 -0700 Subject: [PATCH 03/17] fix(slack): Preserve webhook workspace team context Use the inbound Slack workspace context when a message raw payload lacks team_id so schedule tools still receive the destination workspace. Prefer the workspace team over user_team for Slack Connect author payloads, and keep the shared context outside ingress-only modules. Co-Authored-By: GPT-5 Codex --- .../src/chat/ingress/workspace-membership.ts | 16 ++----- .../junior/src/chat/runtime/thread-context.ts | 2 + .../src/chat/slack/workspace-context.ts | 17 +++++++ .../tests/unit/runtime/thread-context.test.ts | 44 ++++++++++++++++++- 4 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 packages/junior/src/chat/slack/workspace-context.ts diff --git a/packages/junior/src/chat/ingress/workspace-membership.ts b/packages/junior/src/chat/ingress/workspace-membership.ts index 3d507afa..9d59f9e2 100644 --- a/packages/junior/src/chat/ingress/workspace-membership.ts +++ b/packages/junior/src/chat/ingress/workspace-membership.ts @@ -1,15 +1,5 @@ -import { AsyncLocalStorage } from "node:async_hooks"; - -const workspaceTeamIdStorage = new AsyncLocalStorage(); - -/** Run a callback with the workspace team ID available for membership checks. */ -export function runWithWorkspaceTeamId( - teamId: string | undefined, - fn: () => T, -): T { - if (!teamId) return fn(); - return workspaceTeamIdStorage.run(teamId, fn); -} +import { getWorkspaceTeamId } from "@/chat/slack/workspace-context"; +export { runWithWorkspaceTeamId } from "@/chat/slack/workspace-context"; /** * Return true when a Slack event's author is from an external workspace. @@ -23,7 +13,7 @@ export function isExternalSlackUser( ): boolean { if (!raw) return false; - const workspaceTeamId = workspaceTeamIdStorage.getStore(); + const workspaceTeamId = getWorkspaceTeamId(); if (!workspaceTeamId) return false; const userTeam = diff --git a/packages/junior/src/chat/runtime/thread-context.ts b/packages/junior/src/chat/runtime/thread-context.ts index 8427bbed..98b3b9fb 100644 --- a/packages/junior/src/chat/runtime/thread-context.ts +++ b/packages/junior/src/chat/runtime/thread-context.ts @@ -2,6 +2,7 @@ import type { Message, Thread } from "chat"; import { botConfig } from "@/chat/config"; import { toOptionalString } from "@/chat/coerce"; import { isDmChannel, normalizeSlackConversationId } from "@/chat/slack/client"; +import { getWorkspaceTeamId } from "@/chat/slack/workspace-context"; import { parseSlackThreadId, resolveSlackChannelIdFromThreadId, @@ -139,6 +140,7 @@ export function getTeamId(message: Message): string | undefined { return ( toOptionalString(rawRecord.team_id) ?? toOptionalString(rawRecord.team) ?? + getWorkspaceTeamId() ?? toOptionalString(rawRecord.user_team) ); } diff --git a/packages/junior/src/chat/slack/workspace-context.ts b/packages/junior/src/chat/slack/workspace-context.ts new file mode 100644 index 00000000..6e26410b --- /dev/null +++ b/packages/junior/src/chat/slack/workspace-context.ts @@ -0,0 +1,17 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +const workspaceTeamIdStorage = new AsyncLocalStorage(); + +/** Run a callback with the Slack workspace team ID for the inbound webhook. */ +export function runWithWorkspaceTeamId( + teamId: string | undefined, + fn: () => T, +): T { + if (!teamId) return fn(); + return workspaceTeamIdStorage.run(teamId, fn); +} + +/** Return the Slack workspace team ID for the current inbound webhook. */ +export function getWorkspaceTeamId(): string | undefined { + return workspaceTeamIdStorage.getStore(); +} diff --git a/packages/junior/tests/unit/runtime/thread-context.test.ts b/packages/junior/tests/unit/runtime/thread-context.test.ts index 79c2c08f..8420e7d1 100644 --- a/packages/junior/tests/unit/runtime/thread-context.test.ts +++ b/packages/junior/tests/unit/runtime/thread-context.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getAssistantThreadContext } from "@/chat/runtime/thread-context"; +import { + getAssistantThreadContext, + getTeamId, +} from "@/chat/runtime/thread-context"; +import { runWithWorkspaceTeamId } from "@/chat/slack/workspace-context"; describe("getAssistantThreadContext", () => { it("uses the current raw message ts for the first non-DM thread reply", () => { @@ -61,3 +65,41 @@ describe("getAssistantThreadContext", () => { ).toBeUndefined(); }); }); + +describe("getTeamId", () => { + it("uses the raw Slack workspace team when Slack provides it", () => { + expect( + getTeamId({ + raw: { + team_id: "T_RAW", + }, + } as any), + ).toBe("T_RAW"); + }); + + it("falls back to the inbound webhook workspace team", async () => { + await runWithWorkspaceTeamId("T_WORKSPACE", async () => { + await Promise.resolve(); + expect( + getTeamId({ + raw: { + channel: "C12345", + ts: "1700000000.200", + }, + } as any), + ).toBe("T_WORKSPACE"); + }); + }); + + it("prefers the inbound workspace over a Slack Connect author team", () => { + runWithWorkspaceTeamId("T_WORKSPACE", () => { + expect( + getTeamId({ + raw: { + user_team: "T_EXTERNAL", + }, + } as any), + ).toBe("T_WORKSPACE"); + }); + }); +}); From f8047df536be7942791307203f557a83e306f5b8 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 08:33:09 -0700 Subject: [PATCH 04/17] fix(scheduler): Preserve recurrence anchors on edits Keep existing recurrence metadata when a schedule update only changes task content. This prevents unrelated edits from shifting bi-weekly or monthly anchors to the current next run. Co-Authored-By: GPT-5 Codex --- .../src/chat/tools/slack/schedule-tools.ts | 30 ++++++++-- .../integration/slack-schedule-tools.test.ts | 55 ++++++++++++++++++- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/packages/junior/src/chat/tools/slack/schedule-tools.ts b/packages/junior/src/chat/tools/slack/schedule-tools.ts index a7b76ab9..c308958f 100644 --- a/packages/junior/src/chat/tools/slack/schedule-tools.ts +++ b/packages/junior/src/chat/tools/slack/schedule-tools.ts @@ -253,6 +253,22 @@ function buildRecurrence(args: { } } +function shouldRebuildRecurrence(input: { + next_run_at_iso?: string; + recurrence_frequency?: unknown; + recurrence_interval?: number; + recurrence_weekdays?: number[]; + timezone?: string; +}): boolean { + return ( + input.next_run_at_iso !== undefined || + input.recurrence_frequency !== undefined || + input.recurrence_interval !== undefined || + input.recurrence_weekdays !== undefined || + input.timezone !== undefined + ); +} + /** Create a tool that stores a scheduled task for the active Slack context. */ export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { return tool({ @@ -489,12 +505,14 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { }; } const timezone = input.timezone ?? lookup.task.schedule.timezone; - const recurrence = buildRecurrence({ - existing: lookup.task.schedule.recurrence, - input, - nextRunAtMs, - timezone, - }); + const recurrence = shouldRebuildRecurrence(input) + ? buildRecurrence({ + existing: lookup.task.schedule.recurrence, + input, + nextRunAtMs, + timezone, + }) + : { ok: true as const, recurrence: lookup.task.schedule.recurrence }; if (!recurrence.ok) { return recurrence; } diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index 10b6d8d0..6e0ab821 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -45,7 +45,10 @@ async function executeTool(tool: any, input: TInput) { return await tool.execute(input, {} as any); } -async function createTask(context = createContext()) { +async function createTask( + context = createContext(), + overrides: Record = {}, +) { const tool = createSlackScheduleCreateTaskTool(context); return await executeTool(tool, { title: "Weekly issue digest", @@ -57,6 +60,7 @@ async function createTask(context = createContext()) { next_run_at_iso: "2026-05-25T16:00:00.000Z", recurrence_frequency: "weekly", recurrence_weekdays: [1], + ...overrides, }); } @@ -228,8 +232,53 @@ describe("Slack schedule tools", () => { }); }); - it("removes deleted tasks from scheduler indexes", async () => { + it("preserves a recurring task calendar anchor on content-only edits", async () => { const context = createContext({ threadTs: "1700000004.000000" }); + const created = (await createTask(context, { + recurrence_interval: 2, + })) as { + task: { id: string }; + }; + const store = createStateSchedulerStore(); + const task = await store.getTask(created.task.id); + expect(task?.schedule.recurrence).toMatchObject({ + interval: 2, + startDate: "2026-05-25", + }); + await store.saveTask({ + ...task!, + nextRunAtMs: Date.parse("2026-06-08T16:00:00.000Z"), + updatedAtMs: Date.parse("2026-05-26T16:00:00.000Z"), + version: task!.version + 1, + }); + + const updated = await executeTool( + createSlackScheduleUpdateTaskTool(context), + { + task_id: created.task.id, + title: "Renamed issue digest", + }, + ); + + expect(updated).toMatchObject({ + ok: true, + task: { + title: "Renamed issue digest", + }, + }); + await expect(store.getTask(created.task.id)).resolves.toMatchObject({ + nextRunAtMs: Date.parse("2026-06-08T16:00:00.000Z"), + schedule: { + recurrence: { + interval: 2, + startDate: "2026-05-25", + }, + }, + }); + }); + + it("removes deleted tasks from scheduler indexes", async () => { + const context = createContext({ threadTs: "1700000005.000000" }); const created = (await createTask(context)) as { task: { id: string }; }; @@ -249,7 +298,7 @@ describe("Slack schedule tools", () => { }); it("claims due runs idempotently", async () => { - const context = createContext({ threadTs: "1700000005.000000" }); + const context = createContext({ threadTs: "1700000006.000000" }); const created = (await createTask(context)) as { task: { id: string }; }; From fce81de60ee43c78c3274bcb37d241e9f95af4f6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 08:41:23 -0700 Subject: [PATCH 05/17] fix(scheduler): Prevent overlapping task runs Claim an active-run slot per scheduled task before creating a run so schedule edits cannot start a second due instant while the previous run is still active. Clear the slot only when that same run reaches a terminal state. Co-Authored-By: GPT-5 Codex --- packages/junior/src/chat/scheduler/store.ts | 46 +++++++++++++++++-- .../integration/scheduler-executor.test.ts | 43 +++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts index d0d5cc37..c78d3412 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior/src/chat/scheduler/store.ts @@ -47,6 +47,10 @@ function claimKey(taskId: string, scheduledForMs: number): string { return `${SCHEDULER_KEY_PREFIX}:claim:${taskId}:${scheduledForMs}`; } +function activeRunKey(taskId: string): string { + return `${SCHEDULER_KEY_PREFIX}:active:${taskId}`; +} + function globalTaskIndexKey(): string { return `${SCHEDULER_KEY_PREFIX}:tasks`; } @@ -127,6 +131,19 @@ async function getIndex(state: StateAdapter, key: string): Promise { ); } +async function clearActiveRun( + state: StateAdapter, + taskId: string, + runId: string, +): Promise { + await withLock(state, indexLockKey(activeRunKey(taskId)), async () => { + const current = await state.get<{ runId?: unknown }>(activeRunKey(taskId)); + if (current?.runId === runId) { + await state.delete(activeRunKey(taskId)); + } + }); +} + function isDueTask( task: ScheduledTask, nowMs: number, @@ -238,12 +255,23 @@ class StateAdapterSchedulerStore implements SchedulerStore { } const scheduledForMs = task.nextRunAtMs; + const runId = buildRunId(task.id, scheduledForMs); + const activeClaimed = await this.state.setIfNotExists( + activeRunKey(task.id), + { claimedAtMs: args.nowMs, runId, scheduledForMs }, + CLAIM_TTL_MS, + ); + if (!activeClaimed) { + continue; + } + const claimed = await this.state.setIfNotExists( claimKey(task.id, scheduledForMs), { claimedAtMs: args.nowMs }, CLAIM_TTL_MS, ); if (!claimed) { + await clearActiveRun(this.state, task.id, runId); continue; } @@ -280,12 +308,16 @@ class StateAdapterSchedulerStore implements SchedulerStore { resultMessageTs?: string; runId: string; }): Promise { - return await this.updateRun(args.runId, (run) => ({ + const next = await this.updateRun(args.runId, (run) => ({ ...run, completedAtMs: args.completedAtMs, resultMessageTs: args.resultMessageTs, status: "completed", })); + if (next) { + await clearActiveRun(this.state, next.taskId, next.id); + } + return next; } async markRunFailed(args: { @@ -293,12 +325,16 @@ class StateAdapterSchedulerStore implements SchedulerStore { errorMessage: string; runId: string; }): Promise { - return await this.updateRun(args.runId, (run) => ({ + const next = await this.updateRun(args.runId, (run) => ({ ...run, completedAtMs: args.completedAtMs, errorMessage: args.errorMessage, status: "failed", })); + if (next) { + await clearActiveRun(this.state, next.taskId, next.id); + } + return next; } async markRunBlocked(args: { @@ -306,12 +342,16 @@ class StateAdapterSchedulerStore implements SchedulerStore { errorMessage: string; runId: string; }): Promise { - return await this.updateRun(args.runId, (run) => ({ + const next = await this.updateRun(args.runId, (run) => ({ ...run, completedAtMs: args.completedAtMs, errorMessage: args.errorMessage, status: "blocked", })); + if (next) { + await clearActiveRun(this.state, next.taskId, next.id); + } + return next; } private async updateRun( diff --git a/packages/junior/tests/integration/scheduler-executor.test.ts b/packages/junior/tests/integration/scheduler-executor.test.ts index 0afdc896..10c165d6 100644 --- a/packages/junior/tests/integration/scheduler-executor.test.ts +++ b/packages/junior/tests/integration/scheduler-executor.test.ts @@ -177,6 +177,49 @@ describe("scheduler executor", () => { }); }); + it("does not claim another due run while the same task is running", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_overlap_${Date.now()}` }); + await store.saveTask(task); + const [firstRun] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:00:00.000Z"), + limit: 10, + }); + await store.markRunStarted({ + runId: firstRun.id, + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + }); + const editedNextRunAtMs = Date.parse("2026-03-09T16:00:00.000Z"); + await store.saveTask({ + ...task, + nextRunAtMs: editedNextRunAtMs, + updatedAtMs: Date.parse("2026-03-02T17:00:02.000Z"), + version: task.version + 1, + }); + + await expect( + store.claimDueRuns({ + nowMs: Date.parse("2026-03-09T16:00:01.000Z"), + limit: 10, + }), + ).resolves.toHaveLength(0); + + await store.markRunCompleted({ + runId: firstRun.id, + completedAtMs: Date.parse("2026-03-02T17:00:03.000Z"), + }); + + const [nextRun] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-09T16:00:01.000Z"), + limit: 10, + }); + expect(nextRun).toMatchObject({ + taskId: task.id, + scheduledForMs: editedNextRunAtMs, + status: "pending", + }); + }); + it("does not resurrect a task deleted while a run is executing", async () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_deleted_${Date.now()}` }); From fd98d779f67d8c0a4ca296de00eb451b5757428b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 08:48:48 -0700 Subject: [PATCH 06/17] fix(scheduler): Block auth-paused scheduled runs Return blocked instead of failed when scheduled Slack execution pauses for MCP or plugin authorization. This keeps recurring tasks from advancing past credential requirements. Co-Authored-By: GPT-5 Codex --- .../junior/src/chat/scheduler/slack-runner.ts | 13 +++++ .../scheduler-slack-runner.test.ts | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/junior/src/chat/scheduler/slack-runner.ts b/packages/junior/src/chat/scheduler/slack-runner.ts index 8942cfe6..15178d30 100644 --- a/packages/junior/src/chat/scheduler/slack-runner.ts +++ b/packages/junior/src/chat/scheduler/slack-runner.ts @@ -1,5 +1,6 @@ import { botConfig } from "@/chat/config"; import { generateAssistantReply as generateAssistantReplyImpl } from "@/chat/respond"; +import { isRetryableTurnError } from "@/chat/runtime/turn"; import type { ScheduledTaskRunner } from "@/chat/scheduler/executor"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; import { logException } from "@/chat/logging"; @@ -283,6 +284,18 @@ export function createSlackScheduledTaskRunner( resultMessageTs, }; } catch (error) { + if ( + isRetryableTurnError(error, "mcp_auth_resume") || + isRetryableTurnError(error, "plugin_auth_resume") + ) { + return { + status: "blocked", + errorMessage: + authPendingErrorMessage ?? + (error instanceof Error ? error.message : String(error)), + }; + } + logException( error, "scheduled_task_run_failed", diff --git a/packages/junior/tests/integration/scheduler-slack-runner.test.ts b/packages/junior/tests/integration/scheduler-slack-runner.test.ts index cca033fa..9ecd1d5d 100644 --- a/packages/junior/tests/integration/scheduler-slack-runner.test.ts +++ b/packages/junior/tests/integration/scheduler-slack-runner.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { disconnectStateAdapter } from "@/chat/state/adapter"; import { createSlackScheduledTaskRunner } from "@/chat/scheduler/slack-runner"; +import { getPersistedThreadState } from "@/chat/runtime/thread-state"; +import { RetryableTurnError } from "@/chat/runtime/turn"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; import type { AssistantReply } from "@/chat/respond"; import { chatPostMessageOk } from "../fixtures/slack/factories/api"; @@ -152,4 +154,55 @@ describe("scheduled Slack runner", () => { }), ]); }); + + it("blocks scheduled runs when authorization pauses the turn", async () => { + const task = createTask(); + const run = createRun(task); + const runner = createSlackScheduledTaskRunner({ + generateAssistantReply: async (_prompt, context) => { + if (!context?.onAuthPending) { + throw new Error("expected auth pending callback"); + } + + await context.onAuthPending({ + kind: "mcp", + provider: "github", + requesterId: "U123", + sessionId: `scheduled:${run.id}`, + linkSentAtMs: Date.parse("2026-03-02T17:00:01.000Z"), + }); + throw new RetryableTurnError( + "mcp_auth_resume", + "MCP authorization required", + ); + }, + }); + + const result = await runner.run({ + task, + run, + prompt: "", + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + }); + + expect(result).toEqual({ + status: "blocked", + errorMessage: "Scheduled task requires github authorization.", + }); + expect(getCapturedSlackApiCalls("chat.postMessage")).toHaveLength(0); + await expect( + getPersistedThreadState("slack:C123:1700000000.000000"), + ).resolves.toMatchObject({ + conversation: { + processing: { + pendingAuth: { + kind: "mcp", + provider: "github", + requesterId: "U123", + sessionId: `scheduled:${run.id}`, + }, + }, + }, + }); + }); }); From 136f7478e9d463d3f0c11e217bb8c9e4a1ac7c80 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 08:56:45 -0700 Subject: [PATCH 07/17] fix(scheduler): Clear stale block reasons on resume Clear statusReason when Slack schedule updates move a task out of the blocked state. This prevents resumed active tasks from carrying old credential error text. Co-Authored-By: GPT-5 Codex --- .../src/chat/tools/slack/schedule-tools.ts | 5 ++- .../integration/slack-schedule-tools.test.ts | 42 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/junior/src/chat/tools/slack/schedule-tools.ts b/packages/junior/src/chat/tools/slack/schedule-tools.ts index c308958f..40fde589 100644 --- a/packages/junior/src/chat/tools/slack/schedule-tools.ts +++ b/packages/junior/src/chat/tools/slack/schedule-tools.ts @@ -516,12 +516,15 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { if (!recurrence.ok) { return recurrence; } + const nextStatus = status ?? lookup.task.status; const next: ScheduledTask = { ...lookup.task, updatedAtMs: Date.now(), nextRunAtMs, - status: status ?? lookup.task.status, + status: nextStatus, + statusReason: + nextStatus === "blocked" ? lookup.task.statusReason : undefined, schedule: { ...lookup.task.schedule, description: diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index 6e0ab821..094eff90 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -277,11 +277,49 @@ describe("Slack schedule tools", () => { }); }); - it("removes deleted tasks from scheduler indexes", async () => { + it("clears stale block reasons when resuming a task", async () => { const context = createContext({ threadTs: "1700000005.000000" }); const created = (await createTask(context)) as { task: { id: string }; }; + const store = createStateSchedulerStore(); + const task = await store.getTask(created.task.id); + expect(task).toBeDefined(); + await store.saveTask({ + ...task!, + status: "blocked", + statusReason: "Missing GitHub credentials.", + updatedAtMs: Date.parse("2026-05-25T16:01:00.000Z"), + version: task!.version + 1, + }); + + const updated = await executeTool( + createSlackScheduleUpdateTaskTool(context), + { + task_id: created.task.id, + status: "active", + }, + ); + + expect(updated).toMatchObject({ + ok: true, + task: { + id: created.task.id, + status: "active", + }, + }); + const resumed = await store.getTask(created.task.id); + expect(resumed).toMatchObject({ + status: "active", + }); + expect(resumed?.statusReason).toBeUndefined(); + }); + + it("removes deleted tasks from scheduler indexes", async () => { + const context = createContext({ threadTs: "1700000006.000000" }); + const created = (await createTask(context)) as { + task: { id: string }; + }; await executeTool(createSlackScheduleDeleteTaskTool(context), { task_id: created.task.id, @@ -298,7 +336,7 @@ describe("Slack schedule tools", () => { }); it("claims due runs idempotently", async () => { - const context = createContext({ threadTs: "1700000006.000000" }); + const context = createContext({ threadTs: "1700000007.000000" }); const created = (await createTask(context)) as { task: { id: string }; }; From bb048ef9ad7844b03ca82bb0492b19af93ae9f24 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 09:10:24 -0700 Subject: [PATCH 08/17] fix(scheduler): Release blocked retry claims Release the per-slot scheduler claim when a blocked task is resumed as active for the same due instant. This lets credential-unblocked tasks retry immediately instead of waiting for claim TTL expiry. Co-Authored-By: GPT-5 Codex --- packages/junior/src/chat/scheduler/store.ts | 8 ++++ .../integration/scheduler-executor.test.ts | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts index c78d3412..6401ce2b 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior/src/chat/scheduler/store.ts @@ -187,6 +187,14 @@ class StateAdapterSchedulerStore implements SchedulerStore { await this.state.connect(); const current = (await this.state.get(taskKey(task.id))) ?? undefined; + if ( + current?.status === "blocked" && + task.status === "active" && + typeof task.nextRunAtMs === "number" && + Number.isFinite(task.nextRunAtMs) + ) { + await this.state.delete(claimKey(task.id, task.nextRunAtMs)); + } await this.state.set(taskKey(task.id), task, SCHEDULER_RECORD_TTL_MS); if (task.status === "deleted") { diff --git a/packages/junior/tests/integration/scheduler-executor.test.ts b/packages/junior/tests/integration/scheduler-executor.test.ts index 10c165d6..3494cf68 100644 --- a/packages/junior/tests/integration/scheduler-executor.test.ts +++ b/packages/junior/tests/integration/scheduler-executor.test.ts @@ -177,6 +177,54 @@ describe("scheduler executor", () => { }); }); + it("allows a resumed blocked task to retry the same due instant", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_blocked_retry_${Date.now()}` }); + await store.saveTask(task); + const [run] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:00:00.000Z"), + limit: 10, + }); + + await executeScheduledRun({ + store, + run, + nowMs: Date.parse("2026-03-02T17:00:01.500Z"), + runner: { + run: async () => ({ + status: "blocked", + errorMessage: "Missing GitHub credentials.", + }), + }, + }); + + const blocked = await store.getTask(task.id); + expect(blocked).toMatchObject({ + status: "blocked", + nextRunAtMs: undefined, + }); + await store.saveTask({ + ...blocked!, + nextRunAtMs: run.scheduledForMs, + status: "active", + statusReason: undefined, + updatedAtMs: Date.parse("2026-03-02T17:00:02.000Z"), + version: blocked!.version + 1, + }); + + const [retryRun] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:00:03.000Z"), + limit: 10, + }); + + expect(retryRun).toMatchObject({ + id: run.id, + taskId: task.id, + scheduledForMs: run.scheduledForMs, + status: "pending", + }); + }); + it("does not claim another due run while the same task is running", async () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_overlap_${Date.now()}` }); From e05c979e98bc2d189e8937f2842598b91e9bc9f3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 09:27:09 -0700 Subject: [PATCH 09/17] fix(scheduler): Disable scheduled auth flows Return a blocked scheduler result when a scheduled run needs new MCP or plugin authorization instead of starting an OAuth handoff. Scheduled tasks can still use existing creator credentials, but missing credentials now require an interactive Slack turn before the task can be resumed. Co-Authored-By: GPT-5 Codex --- packages/junior/src/chat/respond.ts | 12 ++- .../junior/src/chat/scheduler/slack-runner.ts | 41 ++++----- .../junior/src/chat/services/auth-pause.ts | 16 ++++ .../chat/services/mcp-auth-orchestration.ts | 11 ++- .../services/plugin-auth-orchestration.ts | 10 ++- packages/junior/src/chat/tools/agent-tools.ts | 10 ++- .../scheduler-slack-runner.test.ts | 40 +++------ .../services/mcp-auth-orchestration.test.ts | 83 +++++++++++++++++++ .../plugin-auth-orchestration.test.ts | 34 ++++++++ .../tests/unit/tools/agent-tools.test.ts | 48 +++++++++++ 10 files changed, 246 insertions(+), 59 deletions(-) create mode 100644 packages/junior/tests/unit/services/mcp-auth-orchestration.test.ts diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index cca81e29..7d4e2559 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -102,7 +102,11 @@ import { } from "@/chat/services/turn-checkpoint"; import { createMcpAuthOrchestration } from "@/chat/services/mcp-auth-orchestration"; import { createPluginAuthOrchestration } from "@/chat/services/plugin-auth-orchestration"; -import { AuthorizationPauseError } from "@/chat/services/auth-pause"; +import { + AuthorizationFlowDisabledError, + AuthorizationPauseError, + type AuthorizationFlowMode, +} from "@/chat/services/auth-pause"; // Re-export types for backward compatibility with existing consumers. export type { AssistantReply, AgentTurnDiagnostics }; @@ -136,6 +140,7 @@ export interface ReplyRequestContext { conversationContext?: string; artifactState?: ThreadArtifactsState; pendingAuth?: ConversationPendingAuthState; + authorizationFlowMode?: AuthorizationFlowMode; configuration?: Record; /** Durable Pi transcript for this conversation, excluding ephemeral turn context. */ piMessages?: PiMessage[]; @@ -633,6 +638,7 @@ export async function generateAssistantReply( getMergedArtifactState: () => mergeArtifactsState(context.artifactState ?? {}, artifactStatePatch), onPendingAuth: context.onAuthPending, + authorizationFlowMode: context.authorizationFlowMode, }, () => agent?.abort(), ); @@ -647,6 +653,7 @@ export async function generateAssistantReply( channelConfiguration: context.channelConfiguration, currentPendingAuth: context.pendingAuth, onPendingAuth: context.onAuthPending, + authorizationFlowMode: context.authorizationFlowMode, userTokenStore, }, () => agent?.abort(), @@ -1206,6 +1213,9 @@ export async function generateAssistantReply( if (isRetryableTurnError(error)) { throw error; } + if (error instanceof AuthorizationFlowDisabledError) { + throw error; + } logException( error, diff --git a/packages/junior/src/chat/scheduler/slack-runner.ts b/packages/junior/src/chat/scheduler/slack-runner.ts index 15178d30..29759ec2 100644 --- a/packages/junior/src/chat/scheduler/slack-runner.ts +++ b/packages/junior/src/chat/scheduler/slack-runner.ts @@ -1,10 +1,10 @@ import { botConfig } from "@/chat/config"; import { generateAssistantReply as generateAssistantReplyImpl } from "@/chat/respond"; import { isRetryableTurnError } from "@/chat/runtime/turn"; +import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; import type { ScheduledTaskRunner } from "@/chat/scheduler/executor"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; import { logException } from "@/chat/logging"; -import { applyPendingAuthUpdate } from "@/chat/services/pending-auth"; import { buildConversationContext, generateConversationId, @@ -46,6 +46,12 @@ function buildScheduledConversationText(task: ScheduledTask): string { return `[scheduled task] ${task.task.title}: ${task.task.objective}`; } +function buildScheduledAuthError( + error: AuthorizationFlowDisabledError, +): string { + return `Scheduled task requires ${error.provider} authorization. Connect ${error.provider} in an interactive Slack message, then resume the task.`; +} + function upsertScheduledUserMessage(args: { conversation: ThreadConversationState; run: ScheduledRun; @@ -127,7 +133,6 @@ export function createSlackScheduledTaskRunner( typeof persisted.app_sandbox_dependency_profile_hash === "string" ? persisted.app_sandbox_dependency_profile_hash : undefined; - let authPendingErrorMessage: string | undefined; try { let reply = await generateAssistantReply(prompt, { @@ -139,7 +144,7 @@ export function createSlackScheduledTaskRunner( conversationContext, artifactState: currentArtifacts, piMessages: conversation.piMessages, - pendingAuth: conversation.processing.pendingAuth, + authorizationFlowMode: "disabled", configuration, channelConfiguration, correlation: { @@ -178,21 +183,6 @@ export function createSlackScheduledTaskRunner( sandboxDependencyProfileHash, }); }, - onAuthPending: async (pendingAuth) => { - authPendingErrorMessage = `Scheduled task requires ${pendingAuth.provider} authorization.`; - await applyPendingAuthUpdate({ - conversation, - conversationId, - nextPendingAuth: pendingAuth, - }); - await persistRuntimePatch({ - threadId: conversationId, - conversation, - artifacts: currentArtifacts, - sandboxId, - sandboxDependencyProfileHash, - }); - }, }); const turnFailureErrorMessage = @@ -266,12 +256,6 @@ export function createSlackScheduledTaskRunner( reply.sandboxDependencyProfileHash ?? sandboxDependencyProfileHash, }); - if (authPendingErrorMessage) { - return { - status: "blocked", - errorMessage: authPendingErrorMessage, - }; - } if (turnFailureErrorMessage) { return { status: "failed", @@ -284,6 +268,12 @@ export function createSlackScheduledTaskRunner( resultMessageTs, }; } catch (error) { + if (error instanceof AuthorizationFlowDisabledError) { + return { + status: "blocked", + errorMessage: buildScheduledAuthError(error), + }; + } if ( isRetryableTurnError(error, "mcp_auth_resume") || isRetryableTurnError(error, "plugin_auth_resume") @@ -291,8 +281,7 @@ export function createSlackScheduledTaskRunner( return { status: "blocked", errorMessage: - authPendingErrorMessage ?? - (error instanceof Error ? error.message : String(error)), + "Scheduled task requires authorization. Connect the required provider in an interactive Slack message, then resume the task.", }; } diff --git a/packages/junior/src/chat/services/auth-pause.ts b/packages/junior/src/chat/services/auth-pause.ts index 0ffbe036..18d8fadf 100644 --- a/packages/junior/src/chat/services/auth-pause.ts +++ b/packages/junior/src/chat/services/auth-pause.ts @@ -1,5 +1,6 @@ export type AuthorizationPauseKind = "mcp" | "plugin"; export type AuthorizationPauseDisposition = "link_already_sent" | "link_sent"; +export type AuthorizationFlowMode = "interactive" | "disabled"; /** * Runtime-owned signal that the current turn must park until the user @@ -29,3 +30,18 @@ export class AuthorizationPauseError extends Error { this.provider = provider; } } + +/** Error indicating this turn cannot start an external authorization flow. */ +export class AuthorizationFlowDisabledError extends Error { + readonly kind: AuthorizationPauseKind; + readonly provider: string; + + constructor(kind: AuthorizationPauseKind, provider: string) { + super( + `Authorization is required for ${provider}, but this turn cannot start an authorization flow.`, + ); + this.name = "AuthorizationFlowDisabledError"; + this.kind = kind; + this.provider = provider; + } +} diff --git a/packages/junior/src/chat/services/mcp-auth-orchestration.ts b/packages/junior/src/chat/services/mcp-auth-orchestration.ts index ffc14e15..34097659 100644 --- a/packages/junior/src/chat/services/mcp-auth-orchestration.ts +++ b/packages/junior/src/chat/services/mcp-auth-orchestration.ts @@ -7,7 +7,11 @@ import { } from "@/chat/mcp/auth-store"; import { deliverPrivateMessage, formatProviderLabel } from "@/chat/oauth-flow"; import { canReusePendingAuthLink } from "@/chat/services/pending-auth"; -import { AuthorizationPauseError } from "@/chat/services/auth-pause"; +import { + AuthorizationFlowDisabledError, + AuthorizationPauseError, + type AuthorizationFlowMode, +} from "@/chat/services/auth-pause"; import type { ThreadArtifactsState } from "@/chat/state/artifacts"; import type { ConversationPendingAuthState } from "@/chat/state/conversation"; import type { PluginDefinition } from "@/chat/plugins/types"; @@ -36,6 +40,7 @@ export interface McpAuthOrchestrationDeps { onPendingAuth?: ( pendingAuth: ConversationPendingAuthState, ) => void | Promise; + authorizationFlowMode?: AuthorizationFlowMode; } export interface McpAuthOrchestration { @@ -90,6 +95,10 @@ export function createMcpAuthOrchestration( `Missing MCP auth session context for plugin "${provider}"`, ); } + if (deps.authorizationFlowMode === "disabled") { + await deleteMcpAuthSession(authSessionId); + throw new AuthorizationFlowDisabledError("mcp", provider); + } const latestArtifactState = deps.getMergedArtifactState(); await patchMcpAuthSession(authSessionId, { diff --git a/packages/junior/src/chat/services/plugin-auth-orchestration.ts b/packages/junior/src/chat/services/plugin-auth-orchestration.ts index 55f07819..0b0fb261 100644 --- a/packages/junior/src/chat/services/plugin-auth-orchestration.ts +++ b/packages/junior/src/chat/services/plugin-auth-orchestration.ts @@ -3,7 +3,11 @@ import { unlinkProvider } from "@/chat/credentials/unlink-provider"; import type { UserTokenStore } from "@/chat/credentials/user-token-store"; import { formatProviderLabel, startOAuthFlow } from "@/chat/oauth-flow"; import { canReusePendingAuthLink } from "@/chat/services/pending-auth"; -import { AuthorizationPauseError } from "@/chat/services/auth-pause"; +import { + AuthorizationFlowDisabledError, + AuthorizationPauseError, + type AuthorizationFlowMode, +} from "@/chat/services/auth-pause"; import type { ConversationPendingAuthState } from "@/chat/state/conversation"; import { getPluginDefinition, @@ -43,6 +47,7 @@ export interface PluginAuthOrchestrationDeps { onPendingAuth?: ( pendingAuth: ConversationPendingAuthState, ) => void | Promise; + authorizationFlowMode?: AuthorizationFlowMode; userTokenStore?: UserTokenStore; } @@ -219,6 +224,9 @@ export function createPluginAuthOrchestration( if (!deps.requesterId || !getPluginOAuthConfig(provider)) { throw new Error(`Cannot start plugin authorization for ${provider}`); } + if (deps.authorizationFlowMode === "disabled") { + throw new AuthorizationFlowDisabledError("plugin", provider); + } const providerLabel = formatProviderLabel(provider); const reusingPendingLink = canReusePendingAuthLink({ diff --git a/packages/junior/src/chat/tools/agent-tools.ts b/packages/junior/src/chat/tools/agent-tools.ts index 608b86df..5f3f76f1 100644 --- a/packages/junior/src/chat/tools/agent-tools.ts +++ b/packages/junior/src/chat/tools/agent-tools.ts @@ -3,7 +3,10 @@ import { serializeGenAiAttribute } from "@/chat/logging"; import { setSpanAttributes, withSpan, type LogContext } from "@/chat/logging"; import { GEN_AI_PROVIDER_NAME } from "@/chat/pi/client"; import { shouldEmitDevAgentTrace } from "@/chat/runtime/dev-agent-trace"; -import { AuthorizationPauseError } from "@/chat/services/auth-pause"; +import { + AuthorizationFlowDisabledError, + AuthorizationPauseError, +} from "@/chat/services/auth-pause"; import type { PluginAuthOrchestration } from "@/chat/services/plugin-auth-orchestration"; import { buildReportedProgressStatus } from "@/chat/runtime/report-progress"; import type { AssistantStatusSpec } from "@/chat/slack/assistant-thread/status"; @@ -118,7 +121,10 @@ export function createAgentTools( } return normalized; } catch (error) { - if (error instanceof AuthorizationPauseError) { + if ( + error instanceof AuthorizationPauseError || + error instanceof AuthorizationFlowDisabledError + ) { throw error; } handleToolExecutionError( diff --git a/packages/junior/tests/integration/scheduler-slack-runner.test.ts b/packages/junior/tests/integration/scheduler-slack-runner.test.ts index 9ecd1d5d..71c580d6 100644 --- a/packages/junior/tests/integration/scheduler-slack-runner.test.ts +++ b/packages/junior/tests/integration/scheduler-slack-runner.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { disconnectStateAdapter } from "@/chat/state/adapter"; import { createSlackScheduledTaskRunner } from "@/chat/scheduler/slack-runner"; import { getPersistedThreadState } from "@/chat/runtime/thread-state"; -import { RetryableTurnError } from "@/chat/runtime/turn"; +import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; import type { AssistantReply } from "@/chat/respond"; import { chatPostMessageOk } from "../fixtures/slack/factories/api"; @@ -155,26 +155,18 @@ describe("scheduled Slack runner", () => { ]); }); - it("blocks scheduled runs when authorization pauses the turn", async () => { + it("blocks scheduled runs instead of starting authorization", async () => { const task = createTask(); const run = createRun(task); const runner = createSlackScheduledTaskRunner({ generateAssistantReply: async (_prompt, context) => { - if (!context?.onAuthPending) { - throw new Error("expected auth pending callback"); + if (!context) { + throw new Error("expected reply context"); } - - await context.onAuthPending({ - kind: "mcp", - provider: "github", - requesterId: "U123", - sessionId: `scheduled:${run.id}`, - linkSentAtMs: Date.parse("2026-03-02T17:00:01.000Z"), - }); - throw new RetryableTurnError( - "mcp_auth_resume", - "MCP authorization required", - ); + expect(context.authorizationFlowMode).toBe("disabled"); + expect(context.pendingAuth).toBeUndefined(); + expect(context.onAuthPending).toBeUndefined(); + throw new AuthorizationFlowDisabledError("mcp", "github"); }, }); @@ -187,22 +179,14 @@ describe("scheduled Slack runner", () => { expect(result).toEqual({ status: "blocked", - errorMessage: "Scheduled task requires github authorization.", + errorMessage: + "Scheduled task requires github authorization. Connect github in an interactive Slack message, then resume the task.", }); expect(getCapturedSlackApiCalls("chat.postMessage")).toHaveLength(0); await expect( getPersistedThreadState("slack:C123:1700000000.000000"), - ).resolves.toMatchObject({ - conversation: { - processing: { - pendingAuth: { - kind: "mcp", - provider: "github", - requesterId: "U123", - sessionId: `scheduled:${run.id}`, - }, - }, - }, + ).resolves.not.toMatchObject({ + conversation: { processing: { pendingAuth: expect.anything() } }, }); }); }); diff --git a/packages/junior/tests/unit/services/mcp-auth-orchestration.test.ts b/packages/junior/tests/unit/services/mcp-auth-orchestration.test.ts new file mode 100644 index 00000000..c00ea46d --- /dev/null +++ b/packages/junior/tests/unit/services/mcp-auth-orchestration.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createMcpAuthOrchestration } from "@/chat/services/mcp-auth-orchestration"; +import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; + +const { + createMcpOAuthClientProvider, + deleteMcpAuthSession, + deliverPrivateMessage, + formatProviderLabel, + getMcpAuthSession, + patchMcpAuthSession, +} = vi.hoisted(() => ({ + createMcpOAuthClientProvider: vi.fn(), + deleteMcpAuthSession: vi.fn(), + deliverPrivateMessage: vi.fn(), + formatProviderLabel: vi.fn((provider: string) => provider), + getMcpAuthSession: vi.fn(), + patchMcpAuthSession: vi.fn(), +})); + +vi.mock("@/chat/mcp/oauth", () => ({ + createMcpOAuthClientProvider, +})); + +vi.mock("@/chat/mcp/auth-store", () => ({ + deleteMcpAuthSession, + getMcpAuthSession, + patchMcpAuthSession, +})); + +vi.mock("@/chat/oauth-flow", () => ({ + deliverPrivateMessage, + formatProviderLabel, +})); + +describe("createMcpAuthOrchestration", () => { + beforeEach(() => { + createMcpOAuthClientProvider.mockReset(); + createMcpOAuthClientProvider.mockResolvedValue({ + authSessionId: "auth_1", + }); + deleteMcpAuthSession.mockReset(); + deliverPrivateMessage.mockReset(); + formatProviderLabel.mockClear(); + getMcpAuthSession.mockReset(); + patchMcpAuthSession.mockReset(); + }); + + it("returns a deterministic error instead of delivering auth links when authorization is disabled", async () => { + const abortAgent = vi.fn(); + const orchestration = createMcpAuthOrchestration( + { + conversationId: "slack:C123:1700000000.000000", + sessionId: "scheduled:sched_1:1000", + requesterId: "U123", + channelId: "C123", + threadTs: "1700000000.000000", + userMessage: "", + getConfiguration: () => ({}), + getArtifactState: () => undefined, + getMergedArtifactState: () => ({}), + authorizationFlowMode: "disabled", + }, + abortAgent, + ); + + await orchestration.authProviderFactory({ + manifest: { + name: "github", + }, + } as any); + + await expect( + orchestration.onAuthorizationRequired("github"), + ).rejects.toBeInstanceOf(AuthorizationFlowDisabledError); + + expect(deleteMcpAuthSession).toHaveBeenCalledWith("auth_1"); + expect(patchMcpAuthSession).not.toHaveBeenCalled(); + expect(getMcpAuthSession).not.toHaveBeenCalled(); + expect(deliverPrivateMessage).not.toHaveBeenCalled(); + expect(abortAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/junior/tests/unit/services/plugin-auth-orchestration.test.ts b/packages/junior/tests/unit/services/plugin-auth-orchestration.test.ts index 442e094f..9d20559c 100644 --- a/packages/junior/tests/unit/services/plugin-auth-orchestration.test.ts +++ b/packages/junior/tests/unit/services/plugin-auth-orchestration.test.ts @@ -4,6 +4,7 @@ import { PluginAuthorizationPauseError, PluginCredentialFailureError, } from "@/chat/services/plugin-auth-orchestration"; +import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; import type { Skill } from "@/chat/skills"; const { @@ -143,6 +144,39 @@ describe("createPluginAuthOrchestration", () => { ); }); + it("returns a deterministic error instead of starting oauth when authorization is disabled", async () => { + startOAuthFlow.mockResolvedValue({ + ok: true, + delivery: { channelId: "D123" }, + }); + const abortAgent = vi.fn(); + const userTokenStore = {} as any; + const orchestration = createPluginAuthOrchestration( + { + requesterId: "U123", + userMessage: "check Sentry", + userTokenStore, + authorizationFlowMode: "disabled", + }, + abortAgent, + ); + + await expect( + orchestration.handleCommandFailure({ + activeSkill: sentrySkill, + command: "sentry issue list", + details: { + exit_code: 1, + stderr: "junior-auth-required provider=sentry", + }, + }), + ).rejects.toBeInstanceOf(AuthorizationFlowDisabledError); + + expect(startOAuthFlow).not.toHaveBeenCalled(); + expect(unlinkProvider).not.toHaveBeenCalled(); + expect(abortAgent).not.toHaveBeenCalled(); + }); + it("unlinks the stored token only after oauth restart is launched", async () => { const order: string[] = []; const userTokenStore = {} as any; diff --git a/packages/junior/tests/unit/tools/agent-tools.test.ts b/packages/junior/tests/unit/tools/agent-tools.test.ts index edc684fe..a3bb5ded 100644 --- a/packages/junior/tests/unit/tools/agent-tools.test.ts +++ b/packages/junior/tests/unit/tools/agent-tools.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PluginAuthorizationPauseError } from "@/chat/services/plugin-auth-orchestration"; +import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; import { SkillSandbox } from "@/chat/sandbox/skill-sandbox"; import { createAgentTools } from "@/chat/tools/agent-tools"; import type { Skill } from "@/chat/skills"; @@ -310,4 +311,51 @@ describe("createAgentTools", () => { }); expect(handleToolExecutionError).not.toHaveBeenCalled(); }); + + it("rethrows disabled authorization errors without reporting a tool failure", async () => { + const sandbox = new SkillSandbox([githubSkill], [githubSkill]); + const pluginAuthOrchestration = { + handleCommandFailure: vi.fn(async () => { + throw new AuthorizationFlowDisabledError("plugin", "github"); + }), + } as any; + const sandboxExecutor = { + canExecute: (toolName: string) => toolName === "bash", + execute: vi.fn(async () => ({ + result: { + ok: false, + command: "gh issue view 123", + cwd: "/vercel/sandbox", + exit_code: 1, + signal: null, + timed_out: false, + stdout: "", + stderr: "bad credentials", + stdout_truncated: false, + stderr_truncated: false, + }, + })), + } as any; + + const [bashTool] = createAgentTools( + { + bash: { + description: "bash", + inputSchema: {} as any, + execute: async () => ({ ok: true }), + }, + }, + sandbox, + {}, + undefined, + sandboxExecutor, + pluginAuthOrchestration, + undefined, + ); + + await expect( + bashTool!.execute("tool-2", { command: "gh issue view 123" }), + ).rejects.toBeInstanceOf(AuthorizationFlowDisabledError); + expect(handleToolExecutionError).not.toHaveBeenCalled(); + }); }); From 825c31ef01ce20a8e770796dbac34389c3a5181e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 09:54:14 -0700 Subject: [PATCH 10/17] fix(scheduler): Reclaim abandoned pending runs Let scheduler claims recover pending runs that never started after an aborted tick. Only transition pending runs to running so stale duplicate claims cannot restart a run another tick already completed. Co-Authored-By: GPT-5 Codex --- packages/junior/src/chat/scheduler/store.ts | 79 ++++++++++++++----- .../integration/scheduler-executor.test.ts | 72 +++++++++++++++++ 2 files changed, 132 insertions(+), 19 deletions(-) diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts index 6401ce2b..da5919a4 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior/src/chat/scheduler/store.ts @@ -6,6 +6,7 @@ const SCHEDULER_KEY_PREFIX = "junior:scheduler"; const SCHEDULER_RECORD_TTL_MS = 5 * 365 * 24 * 60 * 60 * 1000; const SCHEDULED_RUN_TTL_MS = 90 * 24 * 60 * 60 * 1000; const CLAIM_TTL_MS = 6 * 60 * 60 * 1000; +const PENDING_CLAIM_STALE_MS = 60_000; const LOCK_TTL_MS = 10_000; export interface SchedulerStore { @@ -144,6 +145,16 @@ async function clearActiveRun( }); } +function isStalePendingRun( + run: ScheduledRun | undefined, + nowMs: number, +): boolean { + return ( + run?.status === "pending" && + run.claimedAtMs + PENDING_CLAIM_STALE_MS <= nowMs + ); +} + function isDueTask( task: ScheduledTask, nowMs: number, @@ -264,23 +275,46 @@ class StateAdapterSchedulerStore implements SchedulerStore { const scheduledForMs = task.nextRunAtMs; const runId = buildRunId(task.id, scheduledForMs); - const activeClaimed = await this.state.setIfNotExists( - activeRunKey(task.id), - { claimedAtMs: args.nowMs, runId, scheduledForMs }, - CLAIM_TTL_MS, - ); + const tryClaimActiveRun = async (): Promise => + await this.state.setIfNotExists( + activeRunKey(task.id), + { claimedAtMs: args.nowMs, runId, scheduledForMs }, + CLAIM_TTL_MS, + ); + + let activeClaimed = await tryClaimActiveRun(); if (!activeClaimed) { - continue; + const activeRun = await this.getRun(runId); + if (isStalePendingRun(activeRun, args.nowMs)) { + await clearActiveRun(this.state, task.id, runId); + await this.state.delete(claimKey(task.id, scheduledForMs)); + activeClaimed = await tryClaimActiveRun(); + } + if (!activeClaimed) { + continue; + } } - const claimed = await this.state.setIfNotExists( - claimKey(task.id, scheduledForMs), - { claimedAtMs: args.nowMs }, - CLAIM_TTL_MS, - ); + const tryClaimScheduledSlot = async (): Promise => + await this.state.setIfNotExists( + claimKey(task.id, scheduledForMs), + { claimedAtMs: args.nowMs }, + CLAIM_TTL_MS, + ); + + let claimed = await tryClaimScheduledSlot(); if (!claimed) { - await clearActiveRun(this.state, task.id, runId); - continue; + const existingRun = await this.getRun(runId); + if (isStalePendingRun(existingRun, args.nowMs)) { + await clearActiveRun(this.state, task.id, runId); + await this.state.delete(claimKey(task.id, scheduledForMs)); + activeClaimed = await tryClaimActiveRun(); + claimed = activeClaimed ? await tryClaimScheduledSlot() : false; + } + if (!claimed) { + await clearActiveRun(this.state, task.id, runId); + continue; + } } const run = buildScheduledRun({ @@ -304,11 +338,15 @@ class StateAdapterSchedulerStore implements SchedulerStore { nowMs: number; runId: string; }): Promise { - return await this.updateRun(args.runId, (run) => ({ - ...run, - startedAtMs: args.nowMs, - status: "running", - })); + return await this.updateRun(args.runId, (run) => + run.status === "pending" + ? { + ...run, + startedAtMs: args.nowMs, + status: "running", + } + : undefined, + ); } async markRunCompleted(args: { @@ -364,7 +402,7 @@ class StateAdapterSchedulerStore implements SchedulerStore { private async updateRun( runId: string, - update: (run: ScheduledRun) => ScheduledRun, + update: (run: ScheduledRun) => ScheduledRun | undefined, ): Promise { await this.state.connect(); return await withLock(this.state, indexLockKey(runKey(runId)), async () => { @@ -373,6 +411,9 @@ class StateAdapterSchedulerStore implements SchedulerStore { return undefined; } const next = update(current); + if (!next) { + return undefined; + } await this.state.set(runKey(runId), next, SCHEDULED_RUN_TTL_MS); return next; }); diff --git a/packages/junior/tests/integration/scheduler-executor.test.ts b/packages/junior/tests/integration/scheduler-executor.test.ts index 3494cf68..5af7f2df 100644 --- a/packages/junior/tests/integration/scheduler-executor.test.ts +++ b/packages/junior/tests/integration/scheduler-executor.test.ts @@ -268,6 +268,78 @@ describe("scheduler executor", () => { }); }); + it("reclaims due tasks left pending by an aborted tick", async () => { + const store = createStateSchedulerStore(); + const firstTask = createTask({ id: `sched_aborted_first_${Date.now()}` }); + const secondTask = createTask({ + id: `sched_aborted_second_${Date.now()}`, + }); + await store.saveTask(firstTask); + await store.saveTask(secondTask); + + const [firstRun, abandonedRun] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:00:00.000Z"), + limit: 10, + }); + expect(firstRun).toMatchObject({ taskId: firstTask.id }); + expect(abandonedRun).toMatchObject({ + taskId: secondTask.id, + status: "pending", + }); + + await executeScheduledRun({ + store, + run: firstRun, + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + runner: { + run: async () => ({ status: "completed" }), + }, + }); + + const [retryRun] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:01:00.000Z"), + limit: 10, + }); + expect(retryRun).toMatchObject({ + id: abandonedRun.id, + taskId: secondTask.id, + scheduledForMs: abandonedRun.scheduledForMs, + status: "pending", + }); + }); + + it("does not restart a run another tick already completed", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_completed_claim_${Date.now()}` }); + await store.saveTask(task); + const [run] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:00:00.000Z"), + limit: 10, + }); + + await executeScheduledRun({ + store, + run, + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + runner: { + run: async () => ({ status: "completed" }), + }, + }); + + await expect( + executeScheduledRun({ + store, + run, + nowMs: Date.parse("2026-03-02T17:00:02.000Z"), + runner: { + run: async () => { + throw new Error("completed run should not restart"); + }, + }, + }), + ).resolves.toBeUndefined(); + }); + it("does not resurrect a task deleted while a run is executing", async () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_deleted_${Date.now()}` }); From 2282725bc527c676eec6d3f3bcb611a2a65eeca1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 20 May 2026 10:24:17 -0700 Subject: [PATCH 11/17] fix(scheduler): Harden scheduled run state Guard scheduled run transitions with claim and start ownership so stale workers cannot mutate reclaimed runs. Move post-run task updates behind the scheduler store lock, add persisted Slack delivery idempotency for completed runs, privately notify creators when scheduled auth is blocked, and require explicit confirmation before creating scheduled tasks. Co-Authored-By: GPT-5 Codex --- .../junior-evals/evals/core/scheduler.eval.ts | 12 +- .../junior/src/chat/scheduler/executor.ts | 77 +++------ .../junior/src/chat/scheduler/slack-runner.ts | 51 +++++- packages/junior/src/chat/scheduler/store.ts | 149 +++++++++++++++--- .../src/chat/tools/slack/schedule-tools.ts | 13 +- .../integration/scheduler-executor.test.ts | 42 +++++ .../scheduler-slack-runner.test.ts | 125 ++++++++++++++- .../integration/slack-schedule-tools.test.ts | 26 +++ 8 files changed, 403 insertions(+), 92 deletions(-) diff --git a/packages/junior-evals/evals/core/scheduler.eval.ts b/packages/junior-evals/evals/core/scheduler.eval.ts index 01aed11d..64dfccea 100644 --- a/packages/junior-evals/evals/core/scheduler.eval.ts +++ b/packages/junior-evals/evals/core/scheduler.eval.ts @@ -2,7 +2,7 @@ import { describeEval } from "vitest-evals"; import { mention, rubric, slackEvals } from "../helpers"; describeEval("Scheduler", slackEvals, (it) => { - it("when asked to schedule recurring work, create a scheduled task for the active Slack context", async ({ + it("when asked to schedule recurring work, draft the task for confirmation before creating it", async ({ run, }) => { await run({ @@ -13,14 +13,14 @@ describeEval("Scheduler", slackEvals, (it) => { ], criteria: rubric({ contract: - "A future or recurring task request is turned into a scheduled Junior task for the active Slack context.", + "A future or recurring task request is normalized into a scheduled task draft for the active Slack context before it is persisted.", pass: [ - "observed_tool_invocations contains slackScheduleCreateTask.", - "The scheduled task title/objective/instructions describe checking scheduler-related GitHub issues, not creating a schedule.", - "The tool call uses an exact next_run_at_iso and a calendar recurrence for Mondays at 9am Pacific.", - "The reply confirms the scheduled task and mentions the cadence or next run.", + "observed_tool_invocations does not contain slackScheduleCreateTask unless the tool input includes confirmed_by_user true.", + "The draft task title/objective/instructions describe checking scheduler-related GitHub issues, not creating a schedule.", + "The reply asks the user to confirm the normalized cadence or next run before creating the schedule.", ], fail: [ + "Do not persist a scheduled task before user confirmation.", "Do not ask the user to provide a channel ID.", "Do not use Slack chat.scheduleMessage.", "Do not only give instructions for how the user can set up an external cron.", diff --git a/packages/junior/src/chat/scheduler/executor.ts b/packages/junior/src/chat/scheduler/executor.ts index 159339b3..39619fe7 100644 --- a/packages/junior/src/chat/scheduler/executor.ts +++ b/packages/junior/src/chat/scheduler/executor.ts @@ -1,5 +1,4 @@ import { buildScheduledTaskRunPrompt } from "@/chat/scheduler/prompt"; -import { getNextRunAtMs } from "@/chat/scheduler/cadence"; import type { SchedulerStore } from "@/chat/scheduler/store"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; @@ -22,49 +21,6 @@ export interface ScheduledTaskRunner { }): Promise; } -async function updateTaskAfterRun(args: { - errorMessage?: string; - nowMs: number; - run: ScheduledRun; - status: ScheduledTaskRunResult["status"]; - store: SchedulerStore; - task: ScheduledTask; -}): Promise { - const current = await args.store.getTask(args.task.id); - if (!current || current.status === "deleted") { - return; - } - - if ( - current.status !== "active" || - current.nextRunAtMs !== args.run.scheduledForMs - ) { - await args.store.saveTask({ - ...current, - lastRunAtMs: args.run.scheduledForMs, - updatedAtMs: args.nowMs, - version: current.version + 1, - }); - return; - } - - const nextRunAtMs = - args.status === "blocked" - ? undefined - : getNextRunAtMs(current, args.run.scheduledForMs, args.nowMs); - - await args.store.saveTask({ - ...current, - lastRunAtMs: args.run.scheduledForMs, - nextRunAtMs, - status: - args.status === "blocked" ? "blocked" : nextRunAtMs ? "active" : "paused", - statusReason: args.status === "blocked" ? args.errorMessage : undefined, - updatedAtMs: args.nowMs, - version: current.version + 1, - }); -} - /** Execute one claimed scheduled run through the compiled task prompt. */ export async function executeScheduledRun(args: { nowMs: number; @@ -83,6 +39,7 @@ export async function executeScheduledRun(args: { const startedRun = await args.store.markRunStarted({ runId: args.run.id, + claimedAtMs: args.run.claimedAtMs, nowMs: args.nowMs, }); if (!startedRun) { @@ -108,10 +65,12 @@ export async function executeScheduledRun(args: { runId: startedRun.id, completedAtMs: args.nowMs, resultMessageTs: result.resultMessageTs, + startedAtMs: startedRun.startedAtMs!, }); - await updateTaskAfterRun({ - store: args.store, - task, + if (!completed) { + return undefined; + } + await args.store.updateTaskAfterRun({ run: startedRun, status: result.status, nowMs: args.nowMs, @@ -124,10 +83,12 @@ export async function executeScheduledRun(args: { runId: startedRun.id, completedAtMs: args.nowMs, errorMessage: result.errorMessage, + startedAtMs: startedRun.startedAtMs!, }); - await updateTaskAfterRun({ - store: args.store, - task, + if (!blocked) { + return undefined; + } + await args.store.updateTaskAfterRun({ run: startedRun, status: result.status, errorMessage: result.errorMessage, @@ -140,10 +101,12 @@ export async function executeScheduledRun(args: { runId: startedRun.id, completedAtMs: args.nowMs, errorMessage: result.errorMessage, + startedAtMs: startedRun.startedAtMs!, }); - await updateTaskAfterRun({ - store: args.store, - task, + if (!failed) { + return undefined; + } + await args.store.updateTaskAfterRun({ run: startedRun, status: result.status, errorMessage: result.errorMessage, @@ -156,10 +119,12 @@ export async function executeScheduledRun(args: { runId: startedRun.id, completedAtMs: args.nowMs, errorMessage, + startedAtMs: startedRun.startedAtMs!, }); - await updateTaskAfterRun({ - store: args.store, - task, + if (!failed) { + return undefined; + } + await args.store.updateTaskAfterRun({ run: startedRun, status: "failed", errorMessage, diff --git a/packages/junior/src/chat/scheduler/slack-runner.ts b/packages/junior/src/chat/scheduler/slack-runner.ts index 29759ec2..318bde03 100644 --- a/packages/junior/src/chat/scheduler/slack-runner.ts +++ b/packages/junior/src/chat/scheduler/slack-runner.ts @@ -5,9 +5,9 @@ import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; import type { ScheduledTaskRunner } from "@/chat/scheduler/executor"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; import { logException } from "@/chat/logging"; +import { deliverPrivateMessage } from "@/chat/oauth-flow"; import { buildConversationContext, - generateConversationId, markConversationMessage, normalizeConversationText, updateConversationStats, @@ -39,19 +39,35 @@ export interface SlackScheduledTaskRunnerDeps { } function getConversationId(task: ScheduledTask): string { - return `slack:${task.destination.channelId}:${task.destination.threadTs}`; + return `slack:${task.destination.teamId}:${task.destination.channelId}:${task.destination.threadTs}`; } function buildScheduledConversationText(task: ScheduledTask): string { return `[scheduled task] ${task.task.title}: ${task.task.objective}`; } +function getScheduledAssistantMessageId(run: ScheduledRun): string { + return `scheduled-run:${run.id}:assistant`; +} + function buildScheduledAuthError( error: AuthorizationFlowDisabledError, ): string { return `Scheduled task requires ${error.provider} authorization. Connect ${error.provider} in an interactive Slack message, then resume the task.`; } +async function notifyCreatorOfBlockedRun(args: { + errorMessage: string; + task: ScheduledTask; +}): Promise { + await deliverPrivateMessage({ + channelId: args.task.destination.channelId, + threadTs: args.task.destination.threadTs, + userId: args.task.createdBy.slackUserId, + text: `Scheduled task "${args.task.task.title}" is blocked: ${args.errorMessage}`, + }); +} + function upsertScheduledUserMessage(args: { conversation: ThreadConversationState; run: ScheduledRun; @@ -109,6 +125,19 @@ export function createSlackScheduledTaskRunner( const conversationId = getConversationId(task); const persisted = await getPersistedThreadState(conversationId); const conversation = coerceThreadConversationState(persisted); + const deliveredMessage = conversation.messages.find( + (message) => + message.id === getScheduledAssistantMessageId(run) && + message.meta?.replied === true && + typeof message.meta.slackTs === "string", + ); + if (deliveredMessage?.meta?.slackTs) { + return { + status: "completed", + resultMessageTs: deliveredMessage.meta.slackTs, + }; + } + const artifacts = coerceThreadArtifactsState(persisted); const channelConfiguration = getChannelConfigurationServiceById( task.destination.channelId, @@ -226,7 +255,7 @@ export function createSlackScheduledTaskRunner( skippedReason: undefined, }); upsertConversationMessage(conversation, { - id: generateConversationId("assistant"), + id: getScheduledAssistantMessageId(run), role: "assistant", text: normalizeConversationText(reply.text) || "[empty response]", createdAtMs: nowMs, @@ -269,19 +298,29 @@ export function createSlackScheduledTaskRunner( }; } catch (error) { if (error instanceof AuthorizationFlowDisabledError) { + const errorMessage = buildScheduledAuthError(error); + await notifyCreatorOfBlockedRun({ + task, + errorMessage, + }); return { status: "blocked", - errorMessage: buildScheduledAuthError(error), + errorMessage, }; } if ( isRetryableTurnError(error, "mcp_auth_resume") || isRetryableTurnError(error, "plugin_auth_resume") ) { + const errorMessage = + "Scheduled task requires authorization. Connect the required provider in an interactive Slack message, then resume the task."; + await notifyCreatorOfBlockedRun({ + task, + errorMessage, + }); return { status: "blocked", - errorMessage: - "Scheduled task requires authorization. Connect the required provider in an interactive Slack message, then resume the task.", + errorMessage, }; } diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts index da5919a4..fb47099a 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior/src/chat/scheduler/store.ts @@ -1,4 +1,5 @@ import type { Lock, StateAdapter } from "chat"; +import { getNextRunAtMs } from "@/chat/scheduler/cadence"; import { getStateAdapter } from "@/chat/state/adapter"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; @@ -18,28 +19,42 @@ export interface SchedulerStore { completedAtMs: number; errorMessage: string; runId: string; + startedAtMs: number; }): Promise; markRunCompleted(args: { completedAtMs: number; resultMessageTs?: string; runId: string; + startedAtMs: number; }): Promise; markRunFailed(args: { completedAtMs: number; errorMessage: string; + startedAtMs?: number; runId: string; }): Promise; markRunStarted(args: { + claimedAtMs: number; nowMs: number; runId: string; }): Promise; saveTask(task: ScheduledTask): Promise; + updateTaskAfterRun(args: { + errorMessage?: string; + nowMs: number; + run: ScheduledRun; + status: "blocked" | "completed" | "failed"; + }): Promise; } function taskKey(taskId: string): string { return `${SCHEDULER_KEY_PREFIX}:task:${taskId}`; } +function taskLockKey(taskId: string): string { + return `${taskKey(taskId)}:lock`; +} + function runKey(runId: string): string { return `${SCHEDULER_KEY_PREFIX}:run:${runId}`; } @@ -187,6 +202,16 @@ function buildScheduledRun(args: { }; } +function canFinishRun( + run: ScheduledRun, + startedAtMs: number | undefined, +): boolean { + if (run.status === "pending") { + return startedAtMs === undefined; + } + return run.status === "running" && run.startedAtMs === startedAtMs; +} + class StateAdapterSchedulerStore implements SchedulerStore { private readonly state: StateAdapter; @@ -196,8 +221,17 @@ class StateAdapterSchedulerStore implements SchedulerStore { async saveTask(task: ScheduledTask): Promise { await this.state.connect(); - const current = - (await this.state.get(taskKey(task.id))) ?? undefined; + await withLock(this.state, taskLockKey(task.id), async () => { + const current = + (await this.state.get(taskKey(task.id))) ?? undefined; + await this.saveTaskRecord(task, current); + }); + } + + private async saveTaskRecord( + task: ScheduledTask, + current: ScheduledTask | undefined, + ): Promise { if ( current?.status === "blocked" && task.status === "active" && @@ -335,11 +369,12 @@ class StateAdapterSchedulerStore implements SchedulerStore { } async markRunStarted(args: { + claimedAtMs: number; nowMs: number; runId: string; }): Promise { return await this.updateRun(args.runId, (run) => - run.status === "pending" + run.status === "pending" && run.claimedAtMs === args.claimedAtMs ? { ...run, startedAtMs: args.nowMs, @@ -353,13 +388,18 @@ class StateAdapterSchedulerStore implements SchedulerStore { completedAtMs: number; resultMessageTs?: string; runId: string; + startedAtMs: number; }): Promise { - const next = await this.updateRun(args.runId, (run) => ({ - ...run, - completedAtMs: args.completedAtMs, - resultMessageTs: args.resultMessageTs, - status: "completed", - })); + const next = await this.updateRun(args.runId, (run) => + canFinishRun(run, args.startedAtMs) + ? { + ...run, + completedAtMs: args.completedAtMs, + resultMessageTs: args.resultMessageTs, + status: "completed", + } + : undefined, + ); if (next) { await clearActiveRun(this.state, next.taskId, next.id); } @@ -369,14 +409,19 @@ class StateAdapterSchedulerStore implements SchedulerStore { async markRunFailed(args: { completedAtMs: number; errorMessage: string; + startedAtMs?: number; runId: string; }): Promise { - const next = await this.updateRun(args.runId, (run) => ({ - ...run, - completedAtMs: args.completedAtMs, - errorMessage: args.errorMessage, - status: "failed", - })); + const next = await this.updateRun(args.runId, (run) => + canFinishRun(run, args.startedAtMs) + ? { + ...run, + completedAtMs: args.completedAtMs, + errorMessage: args.errorMessage, + status: "failed", + } + : undefined, + ); if (next) { await clearActiveRun(this.state, next.taskId, next.id); } @@ -387,19 +432,81 @@ class StateAdapterSchedulerStore implements SchedulerStore { completedAtMs: number; errorMessage: string; runId: string; + startedAtMs: number; }): Promise { - const next = await this.updateRun(args.runId, (run) => ({ - ...run, - completedAtMs: args.completedAtMs, - errorMessage: args.errorMessage, - status: "blocked", - })); + const next = await this.updateRun(args.runId, (run) => + canFinishRun(run, args.startedAtMs) + ? { + ...run, + completedAtMs: args.completedAtMs, + errorMessage: args.errorMessage, + status: "blocked", + } + : undefined, + ); if (next) { await clearActiveRun(this.state, next.taskId, next.id); } return next; } + async updateTaskAfterRun(args: { + errorMessage?: string; + nowMs: number; + run: ScheduledRun; + status: "blocked" | "completed" | "failed"; + }): Promise { + await this.state.connect(); + await withLock(this.state, taskLockKey(args.run.taskId), async () => { + const current = + (await this.state.get(taskKey(args.run.taskId))) ?? + undefined; + if (!current || current.status === "deleted") { + return; + } + + if ( + current.status !== "active" || + current.nextRunAtMs !== args.run.scheduledForMs + ) { + await this.saveTaskRecord( + { + ...current, + lastRunAtMs: args.run.scheduledForMs, + updatedAtMs: args.nowMs, + version: current.version + 1, + }, + current, + ); + return; + } + + const nextRunAtMs = + args.status === "blocked" + ? undefined + : getNextRunAtMs(current, args.run.scheduledForMs, args.nowMs); + + await this.saveTaskRecord( + { + ...current, + lastRunAtMs: args.run.scheduledForMs, + nextRunAtMs, + status: + args.status === "blocked" + ? "blocked" + : nextRunAtMs + ? "active" + : "paused", + statusReason: + args.status === "blocked" ? args.errorMessage : undefined, + updatedAtMs: args.nowMs, + version: current.version + 1, + }, + current, + ); + }); + } + private async updateRun( runId: string, update: (run: ScheduledRun) => ScheduledRun | undefined, diff --git a/packages/junior/src/chat/tools/slack/schedule-tools.ts b/packages/junior/src/chat/tools/slack/schedule-tools.ts index 40fde589..28d2f8e1 100644 --- a/packages/junior/src/chat/tools/slack/schedule-tools.ts +++ b/packages/junior/src/chat/tools/slack/schedule-tools.ts @@ -273,8 +273,12 @@ function shouldRebuildRecurrence(input: { export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { return tool({ description: - "Create a Junior scheduled task for the active Slack destination. The destination is always the current Slack channel/thread context; never accept or invent another destination. Use only after the user asks to schedule future or recurring Junior work. For recurring work, provide an exact next_run_at_iso and a calendar recurrence_frequency.", + "Create a Junior scheduled task for the active Slack destination. The destination is always the current Slack channel/thread context; never accept or invent another destination. Use only after the user has confirmed the normalized scheduled task contract. For recurring work, provide an exact next_run_at_iso and a calendar recurrence_frequency.", inputSchema: Type.Object({ + confirmed_by_user: Type.Boolean({ + description: + "Must be true only after the user explicitly confirms the normalized task, cadence, timezone, destination, and next run.", + }), title: Type.String({ minLength: 1, maxLength: 120 }), objective: Type.String({ minLength: 1, maxLength: 1000 }), instructions: Type.Array(Type.String({ minLength: 1, maxLength: 1000 }), { @@ -336,6 +340,13 @@ export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { if (!destination.ok) return destination; const requester = requireRequester(context); if (!requester.ok) return requester; + if (input.confirmed_by_user !== true) { + return { + ok: false, + error: + "Scheduled tasks require explicit user confirmation before they are created. Draft the task contract for the user to confirm.", + }; + } const nextRunAtMs = parseScheduleTimestamp(input.next_run_at_iso); if (!nextRunAtMs) { diff --git a/packages/junior/tests/integration/scheduler-executor.test.ts b/packages/junior/tests/integration/scheduler-executor.test.ts index 5af7f2df..590ee513 100644 --- a/packages/junior/tests/integration/scheduler-executor.test.ts +++ b/packages/junior/tests/integration/scheduler-executor.test.ts @@ -235,6 +235,7 @@ describe("scheduler executor", () => { }); await store.markRunStarted({ runId: firstRun.id, + claimedAtMs: firstRun.claimedAtMs, nowMs: Date.parse("2026-03-02T17:00:01.000Z"), }); const editedNextRunAtMs = Date.parse("2026-03-09T16:00:00.000Z"); @@ -255,6 +256,7 @@ describe("scheduler executor", () => { await store.markRunCompleted({ runId: firstRun.id, completedAtMs: Date.parse("2026-03-02T17:00:03.000Z"), + startedAtMs: Date.parse("2026-03-02T17:00:01.000Z"), }); const [nextRun] = await store.claimDueRuns({ @@ -308,6 +310,46 @@ describe("scheduler executor", () => { }); }); + it("does not let an abandoned claim start after the run is reclaimed", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_stale_claim_${Date.now()}` }); + await store.saveTask(task); + const [abandonedRun] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:00:00.000Z"), + limit: 10, + }); + const [reclaimedRun] = await store.claimDueRuns({ + nowMs: Date.parse("2026-03-02T17:01:00.000Z"), + limit: 10, + }); + + await expect( + executeScheduledRun({ + store, + run: abandonedRun, + nowMs: Date.parse("2026-03-02T17:01:01.000Z"), + runner: { + run: async () => { + throw new Error("stale claim should not start"); + }, + }, + }), + ).resolves.toBeUndefined(); + + await expect( + executeScheduledRun({ + store, + run: reclaimedRun, + nowMs: Date.parse("2026-03-02T17:01:02.000Z"), + runner: { + run: async () => ({ status: "completed" }), + }, + }), + ).resolves.toMatchObject({ + status: "completed", + }); + }); + it("does not restart a run another tick already completed", async () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_completed_claim_${Date.now()}` }); diff --git a/packages/junior/tests/integration/scheduler-slack-runner.test.ts b/packages/junior/tests/integration/scheduler-slack-runner.test.ts index 71c580d6..39b19eef 100644 --- a/packages/junior/tests/integration/scheduler-slack-runner.test.ts +++ b/packages/junior/tests/integration/scheduler-slack-runner.test.ts @@ -5,7 +5,10 @@ import { getPersistedThreadState } from "@/chat/runtime/thread-state"; import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; import type { AssistantReply } from "@/chat/respond"; -import { chatPostMessageOk } from "../fixtures/slack/factories/api"; +import { + chatPostEphemeralOk, + chatPostMessageOk, +} from "../fixtures/slack/factories/api"; import { getCapturedSlackApiCalls, queueSlackApiResponse, @@ -155,7 +158,113 @@ describe("scheduled Slack runner", () => { ]); }); + it("does not post again when a scheduled run already has a delivered result", async () => { + queueSlackApiResponse("chat.postMessage", { + body: chatPostMessageOk({ + channel: "C123", + ts: "1700000000.000001", + }), + }); + const task = createTask(); + const run = createRun(task); + const generateAssistantReply = vi.fn(async () => createReply()); + const runner = createSlackScheduledTaskRunner({ generateAssistantReply }); + + await expect( + runner.run({ + task, + run, + prompt: "", + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + }), + ).resolves.toEqual({ + status: "completed", + resultMessageTs: "1700000000.000001", + }); + await expect( + runner.run({ + task, + run, + prompt: "", + nowMs: Date.parse("2026-03-02T17:00:02.000Z"), + }), + ).resolves.toEqual({ + status: "completed", + resultMessageTs: "1700000000.000001", + }); + + expect(generateAssistantReply).toHaveBeenCalledTimes(1); + expect(getCapturedSlackApiCalls("chat.postMessage")).toHaveLength(1); + }); + + it("isolates scheduled conversation state by Slack workspace", async () => { + queueSlackApiResponse("chat.postMessage", { + body: chatPostMessageOk({ + channel: "C123", + ts: "1700000000.000001", + }), + }); + queueSlackApiResponse("chat.postMessage", { + body: chatPostMessageOk({ + channel: "C123", + ts: "1700000000.000002", + }), + }); + const firstTask = createTask(); + const baseSecondTask = createTask(); + const secondTask = { + ...baseSecondTask, + id: "sched_slack_runner_other_team", + destination: { + ...baseSecondTask.destination, + teamId: "T999", + }, + }; + const runner = createSlackScheduledTaskRunner({ + generateAssistantReply: async () => createReply(), + }); + + await runner.run({ + task: firstTask, + run: createRun(firstTask), + prompt: "", + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + }); + await runner.run({ + task: secondTask, + run: createRun(secondTask), + prompt: "", + nowMs: Date.parse("2026-03-02T17:00:02.000Z"), + }); + + await expect( + getPersistedThreadState("slack:T123:C123:1700000000.000000"), + ).resolves.toMatchObject({ + conversation: { + messages: expect.arrayContaining([ + expect.objectContaining({ + id: `scheduled-run:${createRun(firstTask).id}:assistant`, + }), + ]), + }, + }); + await expect( + getPersistedThreadState("slack:T999:C123:1700000000.000000"), + ).resolves.toMatchObject({ + conversation: { + messages: expect.arrayContaining([ + expect.objectContaining({ + id: `scheduled-run:${createRun(secondTask).id}:assistant`, + }), + ]), + }, + }); + }); + it("blocks scheduled runs instead of starting authorization", async () => { + queueSlackApiResponse("chat.postEphemeral", { + body: chatPostEphemeralOk(), + }); const task = createTask(); const run = createRun(task); const runner = createSlackScheduledTaskRunner({ @@ -183,8 +292,20 @@ describe("scheduled Slack runner", () => { "Scheduled task requires github authorization. Connect github in an interactive Slack message, then resume the task.", }); expect(getCapturedSlackApiCalls("chat.postMessage")).toHaveLength(0); + expect(getCapturedSlackApiCalls("chat.postEphemeral")).toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + channel: "C123", + thread_ts: "1700000000.000000", + user: "U123", + text: expect.stringContaining( + 'Scheduled task "Issue digest" is blocked', + ), + }), + }), + ]); await expect( - getPersistedThreadState("slack:C123:1700000000.000000"), + getPersistedThreadState("slack:T123:C123:1700000000.000000"), ).resolves.not.toMatchObject({ conversation: { processing: { pendingAuth: expect.anything() } }, }); diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index 094eff90..0633c9a1 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -51,6 +51,7 @@ async function createTask( ) { const tool = createSlackScheduleCreateTaskTool(context); return await executeTool(tool, { + confirmed_by_user: true, title: "Weekly issue digest", objective: "Summarize open scheduler issues.", instructions: ["Find open scheduler issues", "Post a concise summary"], @@ -115,6 +116,31 @@ describe("Slack schedule tools", () => { }); }); + it("requires explicit confirmation before creating a task", async () => { + const result = await executeTool( + createSlackScheduleCreateTaskTool(createContext()), + { + title: "Weekly issue digest", + objective: "Summarize open scheduler issues.", + instructions: ["Find open scheduler issues", "Post a concise summary"], + schedule_description: "Every Monday at 9am", + timezone: "America/Los_Angeles", + next_run_at_iso: "2026-05-25T16:00:00.000Z", + recurrence_frequency: "weekly", + recurrence_weekdays: [1], + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: + "Scheduled tasks require explicit user confirmation before they are created. Draft the task contract for the user to confirm.", + }); + await expect( + createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + ).resolves.toEqual([]); + }); + it("edits and deletes a task from the same Slack destination", async () => { const context = createContext({ threadTs: "1700000001.000000" }); const created = (await createTask(context)) as { From e9e7946a070e7924b6244d53971caa64dcf09af0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 26 May 2026 08:46:53 -0700 Subject: [PATCH 12/17] feat(scheduler): Harden Slack scheduled task execution Run scheduled tasks as a Junior system actor instead of the creator. Scope management to the active Slack conversation, disable schedule tools during scheduled runs, and block missing auth without starting interactive flows. Add calendar parsing, run-now handling, dedicated scheduler Redis configuration, eval harness fixes, and integration coverage for idempotency, destination scoping, and auth behavior. Co-Authored-By: Codex GPT-5 --- .../content/docs/reference/config-and-env.md | 2 + .../junior-evals/evals/behavior-harness.ts | 6 + .../junior-evals/evals/core/scheduler.eval.ts | 7 +- packages/junior-evals/evals/helpers.ts | 11 +- packages/junior/src/app.ts | 6 +- packages/junior/src/chat/logging.ts | 4 + packages/junior/src/chat/prompt.ts | 2 +- packages/junior/src/chat/respond.ts | 12 + packages/junior/src/chat/scheduler/cadence.ts | 86 +++++- .../junior/src/chat/scheduler/executor.ts | 40 ++- packages/junior/src/chat/scheduler/prompt.ts | 12 +- .../junior/src/chat/scheduler/slack-runner.ts | 86 +++--- packages/junior/src/chat/scheduler/store.ts | 206 ++++++++++++-- packages/junior/src/chat/scheduler/types.ts | 13 +- .../services/plugin-auth-orchestration.ts | 3 + packages/junior/src/chat/slack/reply.ts | 7 +- packages/junior/src/chat/tools/index.ts | 9 +- .../src/chat/tools/slack/schedule-tools.ts | 214 ++++++++++---- packages/junior/src/chat/tools/types.ts | 1 + .../src/handlers/diagnostics-dashboard.ts | 2 +- .../junior/src/handlers/scheduler-tick.ts | 2 +- .../integration/scheduler-executor.test.ts | 248 +++++++++++++---- .../scheduler-slack-runner.test.ts | 138 ++++++++- .../tests/integration/scheduler-tick.test.ts | 2 +- .../integration/slack-schedule-tools.test.ts | 261 ++++++++++++++++-- .../plugin-auth-orchestration.test.ts | 24 ++ .../unit/slack/tool-registration.test.ts | 43 +++ specs/scheduler-spec.md | 85 ++++-- 28 files changed, 1299 insertions(+), 233 deletions(-) diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index 7682c2bc..4ac8730c 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -25,6 +25,8 @@ related: | `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. | | `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. | | `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for `/api/internal/scheduler/tick`; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for an external scheduler. | +| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring and other timezone-sensitive behavior. Defaults to `America/Los_Angeles`. | +| `JUNIOR_SCHEDULER_REDIS_URL` | No | Redis URL for dedicated scheduler persistence. When set, scheduler state uses this Redis instance instead of the shared runtime state adapter. | | `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. | Generate `JUNIOR_SECRET` with Node, then store the generated value in every environment that runs the same app: diff --git a/packages/junior-evals/evals/behavior-harness.ts b/packages/junior-evals/evals/behavior-harness.ts index 99dbec16..da2688da 100644 --- a/packages/junior-evals/evals/behavior-harness.ts +++ b/packages/junior-evals/evals/behavior-harness.ts @@ -131,6 +131,7 @@ interface EvalReplyResultFixture { export interface EvalOverrides { auto_complete_mcp_oauth?: string[]; auto_complete_oauth?: string[]; + disable_schedule_tools?: boolean; enable_test_credentials?: boolean; fail_reply_call?: number; faults?: { @@ -330,7 +331,9 @@ function toEvalToolInvocation(input: { invocation.arguments = Object.fromEntries( [ "title", + "task_id", "objective", + "confirmed_by_user", "schedule_description", "timezone", "next_run_at_iso", @@ -1183,6 +1186,9 @@ function buildRuntimeServices( reply = await Promise.race([ generateAssistantReply(text, { ...context, + disableScheduleTools: + scenario.overrides?.disable_schedule_tools ?? + context?.disableScheduleTools, onToolInvocation: (invocation) => { observations.toolInvocations.push( toEvalToolInvocation(invocation), diff --git a/packages/junior-evals/evals/core/scheduler.eval.ts b/packages/junior-evals/evals/core/scheduler.eval.ts index 64dfccea..e0e8da47 100644 --- a/packages/junior-evals/evals/core/scheduler.eval.ts +++ b/packages/junior-evals/evals/core/scheduler.eval.ts @@ -47,15 +47,14 @@ This is an autonomous scheduled run. Treat the stored task contract as the user - -- Execute the scheduled task described in ; do not create, update, pause, delete, or list schedules. - - Execute the scheduled task now and provide the final result for the configured destination. `), ], + overrides: { + disable_schedule_tools: true, + }, criteria: rubric({ contract: "A scheduled-task execution prompt is treated as the task to run, not as a request to schedule something.", diff --git a/packages/junior-evals/evals/helpers.ts b/packages/junior-evals/evals/helpers.ts index 495a043e..53d80201 100644 --- a/packages/junior-evals/evals/helpers.ts +++ b/packages/junior-evals/evals/helpers.ts @@ -1,5 +1,5 @@ import { - namedJudge, + createJudge, type DescribeEvalOptions, type JudgeContext, } from "vitest-evals"; @@ -391,10 +391,10 @@ export const slackHarness: Harness = { }; /** Scores Slack eval output against the case rubric. */ -export const RubricJudge = namedJudge( +export const RubricJudge = createJudge( "RubricJudge", async ({ - inputValue, + input, output, harness, }: JudgeContext< @@ -404,7 +404,10 @@ export const RubricJudge = namedJudge( >) => { const object = parseJudgeResult( await harness.prompt( - formatJudgePrompt(output, formatRubric(inputValue.criteria)), + formatJudgePrompt( + serializeEvalOutput(output as Record), + formatRubric(input.criteria), + ), { system: EVAL_SYSTEM, metadata: { diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 84c813e9..43caeb06 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -19,7 +19,7 @@ import { GET as dashboardGET } from "@/handlers/diagnostics-dashboard"; import { GET as healthGET } from "@/handlers/health"; import { GET as mcpOauthCallbackGET } from "@/handlers/mcp-oauth-callback"; import { GET as oauthCallbackGET } from "@/handlers/oauth-callback"; -import { ALL as schedulerTickALL } from "@/handlers/scheduler-tick"; +import { GET as schedulerTickGET } from "@/handlers/scheduler-tick"; import { ALL as sandboxEgressProxyALL, isSandboxEgressRequest, @@ -244,8 +244,8 @@ export async function createApp(options?: JuniorAppOptions): Promise { return turnResumePOST(c.req.raw, waitUntil); }); - app.all("/api/internal/scheduler/tick", (c) => { - return schedulerTickALL(c.req.raw, waitUntil); + app.get("/api/internal/scheduler/tick", (c) => { + return schedulerTickGET(c.req.raw, waitUntil); }); app.post("/api/webhooks/:platform", (c) => { diff --git a/packages/junior/src/chat/logging.ts b/packages/junior/src/chat/logging.ts index 040e0277..a274dd40 100644 --- a/packages/junior/src/chat/logging.ts +++ b/packages/junior/src/chat/logging.ts @@ -38,6 +38,8 @@ export interface LogContext { slackUserName?: string; slackChannelId?: string; runId?: string; + actorType?: string; + actorId?: string; assistantUserName?: string; modelId?: string; skillName?: string; @@ -382,6 +384,8 @@ function contextToAttributes(context: LogContext): LogAttributes { "enduser.id": context.slackUserId, "enduser.pseudo.id": context.slackUserName, "app.run.id": context.runId, + "app.actor.type": context.actorType, + "app.actor.id": context.actorId, "gen_ai.agent.name": context.assistantUserName, "gen_ai.request.model": context.modelId, "app.skill.name": context.skillName, diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index 0422e7ea..a4e1d85f 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -426,7 +426,7 @@ const SLACK_ACTION_RULES = [ "- Context-bound Slack tools use runtime-owned targets; do not invent channel, canvas, list, or message IDs.", "- Use first-class Slack tools for Slack side effects; do not use bash, curl, or provider APIs to bypass Slack tool targeting.", "- Use channel-post and emoji-reaction tools only when the user explicitly asks for that Slack side effect.", - "- Use Slack schedule tools only when the user explicitly asks to create, list, edit, pause, resume, remove, or run future/recurring Junior work; scheduled task destinations are always the active Slack context, and task creation needs an exact next-run ISO timestamp.", + "- Use Slack schedule tools only when the user explicitly asks to create, list, edit, pause, resume, remove, run now, or run future/recurring Junior work; scheduled task destinations are always the active Slack DM or channel, never an existing thread, and task creation needs an exact next-run ISO timestamp or supported relative next-run text. When no timezone is given, let the scheduler use its configured default timezone.", "- For explicit channel-post or emoji-reaction requests, skip a duplicate thread text reply when the tool result already satisfies the request.", "- Do not claim an attachment, canvas, channel post, list update, or reaction succeeded unless the tool returned success this turn; when it did, include any link the tool returned.", "- Do not use reactions as progress indicators.", diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index 7d4e2559..1519b1ee 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -135,8 +135,11 @@ export interface ReplyRequestContext { messageTs?: string; threadTs?: string; requesterId?: string; + actorType?: string; + actorId?: string; }; toolChannelId?: string; + disableScheduleTools?: boolean; conversationContext?: string; artifactState?: ThreadArtifactsState; pendingAuth?: ConversationPendingAuthState; @@ -348,6 +351,8 @@ export async function generateAssistantReply( requesterId: context.correlation?.requesterId, channelId: context.correlation?.channelId, runId: context.correlation?.runId, + actorType: context.correlation?.actorType, + actorId: context.correlation?.actorId, assistantUserName: botConfig.userName, modelId: botConfig.modelId, }; @@ -375,6 +380,8 @@ export async function generateAssistantReply( slackUserId: context.correlation?.requesterId, slackChannelId: context.correlation?.channelId, runId: context.correlation?.runId, + actorType: context.correlation?.actorType, + actorId: context.correlation?.actorId, assistantUserName: botConfig.userName, modelId: botConfig.modelId, }; @@ -675,6 +682,8 @@ export async function generateAssistantReply( slackUserId: context.correlation?.requesterId, slackChannelId: context.correlation?.channelId, runId: context.correlation?.runId, + actorType: context.correlation?.actorType, + actorId: context.correlation?.actorId, assistantUserName: botConfig.userName, modelId: botConfig.modelId, }); @@ -750,6 +759,7 @@ export async function generateAssistantReply( teamId: context.correlation?.teamId, messageTs: context.correlation?.messageTs, threadTs: context.correlation?.threadTs, + disableScheduleTools: context.disableScheduleTools, userText: userInput, artifactState: context.artifactState, configuration: configurationValues, @@ -1225,6 +1235,8 @@ export async function generateAssistantReply( slackUserId: context.correlation?.requesterId, slackChannelId: context.correlation?.channelId, runId: context.correlation?.runId, + actorType: context.correlation?.actorType, + actorId: context.correlation?.actorId, assistantUserName: botConfig.userName, modelId: botConfig.modelId, }, diff --git a/packages/junior/src/chat/scheduler/cadence.ts b/packages/junior/src/chat/scheduler/cadence.ts index ff4ac345..48bc34e4 100644 --- a/packages/junior/src/chat/scheduler/cadence.ts +++ b/packages/junior/src/chat/scheduler/cadence.ts @@ -7,7 +7,43 @@ import type { /** Parse an ISO timestamp into a finite Unix timestamp in milliseconds. */ export function parseScheduleTimestamp(value: string): number | undefined { - const parsed = Date.parse(value); + const trimmed = value.trim(); + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/.exec( + trimmed, + ); + if (!match) { + return undefined; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = match[6] ? Number(match[6]) : 0; + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + !Number.isInteger(hour) || + !Number.isInteger(minute) || + !Number.isInteger(second) || + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 || + second < 0 || + second > 59 + ) { + return undefined; + } + + const parsed = Date.parse(trimmed); return Number.isFinite(parsed) ? parsed : undefined; } @@ -198,6 +234,54 @@ function buildCandidate(args: { }); } +function parseLocalTime(value: string): ScheduledLocalTime | undefined { + const match = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i.exec(value.trim()); + if (!match) { + return undefined; + } + + let hour = Number(match[1]); + const minute = match[2] ? Number(match[2]) : 0; + const meridiem = match[3].toLowerCase(); + if ( + !Number.isInteger(hour) || + !Number.isInteger(minute) || + hour < 1 || + hour > 12 || + minute < 0 || + minute > 59 + ) { + return undefined; + } + if (meridiem === "am" && hour === 12) { + hour = 0; + } else if (meridiem === "pm" && hour !== 12) { + hour += 12; + } + return { hour, minute }; +} + +/** Parse supported relative one-off schedule text into a UTC timestamp. */ +export function parseRelativeScheduleTimestamp(args: { + nowMs: number; + text: string; + timezone: string; +}): number | undefined { + const match = /^tomorrow(?:\s+at)?\s+(.+)$/i.exec(args.text.trim()); + if (!match) { + return undefined; + } + const time = parseLocalTime(match[1]); + if (!time) { + return undefined; + } + return localDateTimeToTimestampMs({ + date: addDays(getLocalDate(args.nowMs, args.timezone), 1), + time, + timezone: args.timezone, + }); +} + function getDailyNextRunAtMs(args: { afterMs: number; recurrence: ScheduledTaskRecurrence; diff --git a/packages/junior/src/chat/scheduler/executor.ts b/packages/junior/src/chat/scheduler/executor.ts index 39619fe7..c05387d2 100644 --- a/packages/junior/src/chat/scheduler/executor.ts +++ b/packages/junior/src/chat/scheduler/executor.ts @@ -21,6 +21,25 @@ export interface ScheduledTaskRunner { }): Promise; } +function shouldSkipRun( + task: ScheduledTask, + run: ScheduledRun, +): string | undefined { + if (task.status === "deleted") { + return `Scheduled task ${task.id} was deleted before the run started.`; + } + if (task.status !== "active") { + return `Scheduled task ${task.id} was ${task.status} before the run started.`; + } + if ( + task.nextRunAtMs !== run.scheduledForMs && + task.runNowAtMs !== run.scheduledForMs + ) { + return `Scheduled task ${task.id} no longer targets ${new Date(run.scheduledForMs).toISOString()}.`; + } + return undefined; +} + /** Execute one claimed scheduled run through the compiled task prompt. */ export async function executeScheduledRun(args: { nowMs: number; @@ -37,6 +56,15 @@ export async function executeScheduledRun(args: { }); } + const skippedReason = shouldSkipRun(task, args.run); + if (skippedReason) { + return await args.store.markRunSkipped({ + runId: args.run.id, + completedAtMs: args.nowMs, + errorMessage: skippedReason, + }); + } + const startedRun = await args.store.markRunStarted({ runId: args.run.id, claimedAtMs: args.run.claimedAtMs, @@ -141,13 +169,15 @@ export async function processDueScheduledRuns(args: { runner: ScheduledTaskRunner; store: SchedulerStore; }): Promise { - const claimedRuns = await args.store.claimDueRuns({ - limit: args.limit, - nowMs: args.nowMs, - }); const completedRuns: ScheduledRun[] = []; - for (const run of claimedRuns) { + for (let index = 0; index < args.limit; index += 1) { + const run = await args.store.claimDueRun({ + nowMs: args.nowMs, + }); + if (!run) { + break; + } const completed = await executeScheduledRun({ store: args.store, runner: args.runner, diff --git a/packages/junior/src/chat/scheduler/prompt.ts b/packages/junior/src/chat/scheduler/prompt.ts index 95d691ed..c1c254c4 100644 --- a/packages/junior/src/chat/scheduler/prompt.ts +++ b/packages/junior/src/chat/scheduler/prompt.ts @@ -1,8 +1,12 @@ import { escapeXml } from "@/chat/xml"; -import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; +import { + SCHEDULED_TASK_SYSTEM_ACTOR, + type ScheduledRun, + type ScheduledTask, +} from "@/chat/scheduler/types"; const EXECUTION_RULES = [ - "- Execute the scheduled task described in ; do not create, update, pause, delete, or list schedules.", + "- Execute as the scheduled-task system actor; creator metadata is audit context, not an active user identity.", "- Complete the task without asking follow-up questions unless access, approval, or required input is missing.", "- Use the available tools and skills that are relevant to the task contract.", "- If blocked, report the specific missing provider, permission, configuration, or input.", @@ -34,6 +38,7 @@ export function buildScheduledTaskRunPrompt(args: { const { run, task } = args; const destination = task.destination; const creator = task.createdBy; + const executionActor = task.executionActor ?? SCHEDULED_TASK_SYSTEM_ACTOR; return [ "", @@ -61,6 +66,8 @@ export function buildScheduledTaskRunPrompt(args: { `- schedule: ${escapeXml(task.schedule.description)}`, `- timezone: ${escapeXml(task.schedule.timezone)}`, `- schedule_kind: ${task.schedule.kind}`, + `- execution_actor_type: ${executionActor.type}`, + `- execution_actor_id: ${escapeXml(executionActor.id)}`, ...(task.schedule.recurrence ? [ `- recurrence_frequency: ${task.schedule.recurrence.frequency}`, @@ -74,7 +81,6 @@ export function buildScheduledTaskRunPrompt(args: { `- destination_platform: ${destination.platform}`, `- destination_team_id: ${escapeXml(destination.teamId)}`, `- destination_channel_id: ${escapeXml(destination.channelId)}`, - ...renderOptionalLine("destination_thread_ts", destination.threadTs), "", "", "", diff --git a/packages/junior/src/chat/scheduler/slack-runner.ts b/packages/junior/src/chat/scheduler/slack-runner.ts index 318bde03..a90b18b7 100644 --- a/packages/junior/src/chat/scheduler/slack-runner.ts +++ b/packages/junior/src/chat/scheduler/slack-runner.ts @@ -1,9 +1,17 @@ import { botConfig } from "@/chat/config"; -import { generateAssistantReply as generateAssistantReplyImpl } from "@/chat/respond"; +import { + generateAssistantReply as generateAssistantReplyImpl, + type AssistantReply, +} from "@/chat/respond"; import { isRetryableTurnError } from "@/chat/runtime/turn"; import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; +import { PluginCredentialFailureError } from "@/chat/services/plugin-auth-orchestration"; import type { ScheduledTaskRunner } from "@/chat/scheduler/executor"; -import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; +import { + SCHEDULED_TASK_SYSTEM_ACTOR, + type ScheduledRun, + type ScheduledTask, +} from "@/chat/scheduler/types"; import { logException } from "@/chat/logging"; import { deliverPrivateMessage } from "@/chat/oauth-flow"; import { @@ -39,7 +47,7 @@ export interface SlackScheduledTaskRunnerDeps { } function getConversationId(task: ScheduledTask): string { - return `slack:${task.destination.teamId}:${task.destination.channelId}:${task.destination.threadTs}`; + return `slack:${task.destination.teamId}:${task.destination.channelId}`; } function buildScheduledConversationText(task: ScheduledTask): string { @@ -50,19 +58,33 @@ function getScheduledAssistantMessageId(run: ScheduledRun): string { return `scheduled-run:${run.id}:assistant`; } +function getExecutionActor(task: ScheduledTask) { + return task.executionActor ?? SCHEDULED_TASK_SYSTEM_ACTOR; +} + function buildScheduledAuthError( error: AuthorizationFlowDisabledError, ): string { return `Scheduled task requires ${error.provider} authorization. Connect ${error.provider} in an interactive Slack message, then resume the task.`; } +function ensureVisibleDeliveryText(reply: AssistantReply): AssistantReply { + if (reply.text.trim().length > 0 || !reply.files?.length) { + return reply; + } + + return { + ...reply, + text: "Generated files are attached.", + }; +} + async function notifyCreatorOfBlockedRun(args: { errorMessage: string; task: ScheduledTask; }): Promise { await deliverPrivateMessage({ channelId: args.task.destination.channelId, - threadTs: args.task.destination.threadTs, userId: args.task.createdBy.slackUserId, text: `Scheduled task "${args.task.task.title}" is blocked: ${args.errorMessage}`, }); @@ -73,16 +95,15 @@ function upsertScheduledUserMessage(args: { run: ScheduledRun; task: ScheduledTask; }): string { + const executionActor = getExecutionActor(args.task); return upsertConversationMessage(args.conversation, { id: `scheduled-run:${args.run.id}:user`, role: "user", text: normalizeConversationText(buildScheduledConversationText(args.task)), createdAtMs: args.run.scheduledForMs, author: { - userId: args.task.createdBy.slackUserId, - userName: args.task.createdBy.userName, - fullName: args.task.createdBy.fullName, - isBot: false, + userName: `system:${executionActor.id}`, + isBot: true, }, meta: { explicitMention: true, @@ -114,15 +135,8 @@ export function createSlackScheduledTaskRunner( return { run: async ({ prompt, run, task, nowMs }) => { - const threadTs = task.destination.threadTs; - if (!threadTs) { - return { - status: "blocked", - errorMessage: "Scheduled Slack task has no thread destination.", - }; - } - const conversationId = getConversationId(task); + const executionActor = getExecutionActor(task); const persisted = await getPersistedThreadState(conversationId); const conversation = coerceThreadConversationState(persisted); const deliveredMessage = conversation.messages.find( @@ -165,11 +179,6 @@ export function createSlackScheduledTaskRunner( try { let reply = await generateAssistantReply(prompt, { - requester: { - userId: task.createdBy.slackUserId, - userName: task.createdBy.userName, - fullName: task.createdBy.fullName, - }, conversationContext, artifactState: currentArtifacts, piMessages: conversation.piMessages, @@ -183,10 +192,11 @@ export function createSlackScheduledTaskRunner( runId: run.id, channelId: task.destination.channelId, teamId: task.destination.teamId, - requesterId: task.createdBy.slackUserId, - threadTs, + actorType: executionActor.type, + actorId: executionActor.id, }, toolChannelId: task.destination.channelId, + disableScheduleTools: true, sandbox: { sandboxId, sandboxDependencyProfileHash, @@ -229,22 +239,24 @@ export function createSlackScheduledTaskRunner( slackChannelId: task.destination.channelId, slackUserId: task.createdBy.slackUserId, runId: run.id, + actorType: executionActor.type, + actorId: executionActor.id, assistantUserName: botConfig.userName, modelId: reply.diagnostics.modelId, }, }); } - const plannedPosts = planSlackReplyPosts({ reply }); + const deliveryReply = ensureVisibleDeliveryText(reply); + const plannedPosts = planSlackReplyPosts({ reply: deliveryReply }); const footer = buildSlackReplyFooter({ conversationId, - durationMs: reply.diagnostics.durationMs, - thinkingLevel: reply.diagnostics.thinkingLevel, - usage: reply.diagnostics.usage, + durationMs: deliveryReply.diagnostics.durationMs, + thinkingLevel: deliveryReply.diagnostics.thinkingLevel, + usage: deliveryReply.diagnostics.usage, }); const resultMessageTs = await postSlackApiReplyPosts({ channelId: task.destination.channelId, - threadTs, posts: plannedPosts, footer, fileUploadFailureMode: "strict", @@ -257,7 +269,8 @@ export function createSlackScheduledTaskRunner( upsertConversationMessage(conversation, { id: getScheduledAssistantMessageId(run), role: "assistant", - text: normalizeConversationText(reply.text) || "[empty response]", + text: + normalizeConversationText(deliveryReply.text) || "[empty response]", createdAtMs: nowMs, author: { userName: botConfig.userName, @@ -268,9 +281,6 @@ export function createSlackScheduledTaskRunner( slackTs: resultMessageTs, }, }); - if (reply.piMessages) { - conversation.piMessages = reply.piMessages; - } updateConversationStats(conversation); const nextArtifacts = reply.artifactStatePatch @@ -308,6 +318,16 @@ export function createSlackScheduledTaskRunner( errorMessage, }; } + if (error instanceof PluginCredentialFailureError) { + await notifyCreatorOfBlockedRun({ + task, + errorMessage: error.message, + }); + return { + status: "blocked", + errorMessage: error.message, + }; + } if ( isRetryableTurnError(error, "mcp_auth_resume") || isRetryableTurnError(error, "plugin_auth_resume") @@ -333,6 +353,8 @@ export function createSlackScheduledTaskRunner( slackChannelId: task.destination.channelId, slackUserId: task.createdBy.slackUserId, runId: run.id, + actorType: executionActor.type, + actorId: executionActor.id, assistantUserName: botConfig.userName, modelId: botConfig.modelId, }, diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts index fb47099a..f85d29d4 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior/src/chat/scheduler/store.ts @@ -1,3 +1,4 @@ +import { createRedisState } from "@chat-adapter/state-redis"; import type { Lock, StateAdapter } from "chat"; import { getNextRunAtMs } from "@/chat/scheduler/cadence"; import { getStateAdapter } from "@/chat/state/adapter"; @@ -10,8 +11,10 @@ const CLAIM_TTL_MS = 6 * 60 * 60 * 1000; const PENDING_CLAIM_STALE_MS = 60_000; const LOCK_TTL_MS = 10_000; +let schedulerStateAdapter: StateAdapter | undefined; + export interface SchedulerStore { - claimDueRuns(args: { limit: number; nowMs: number }): Promise; + claimDueRun(args: { nowMs: number }): Promise; getRun(runId: string): Promise; getTask(taskId: string): Promise; listTasksForTeam(teamId: string): Promise; @@ -33,6 +36,11 @@ export interface SchedulerStore { startedAtMs?: number; runId: string; }): Promise; + markRunSkipped(args: { + completedAtMs: number; + errorMessage: string; + runId: string; + }): Promise; markRunStarted(args: { claimedAtMs: number; nowMs: number; @@ -83,6 +91,28 @@ function buildRunId(taskId: string, scheduledForMs: number): string { return `${taskId}:${scheduledForMs}`; } +function shouldUseDedicatedSchedulerState(): boolean { + return Boolean(process.env.JUNIOR_SCHEDULER_REDIS_URL?.trim()); +} + +function createSchedulerStateAdapter(): StateAdapter { + const redisUrl = process.env.JUNIOR_SCHEDULER_REDIS_URL?.trim(); + if (redisUrl) { + return createRedisState({ url: redisUrl }); + } + return getStateAdapter(); +} + +function getSchedulerStateAdapter(): StateAdapter { + if (!shouldUseDedicatedSchedulerState()) { + return getStateAdapter(); + } + if (!schedulerStateAdapter) { + schedulerStateAdapter = createSchedulerStateAdapter(); + } + return schedulerStateAdapter; +} + function unique(values: string[]): string[] { return [...new Set(values.filter(Boolean))]; } @@ -160,6 +190,58 @@ async function clearActiveRun( }); } +async function clearStaleActiveRun( + state: StateAdapter, + taskId: string, + nowMs: number, +): Promise { + const active = await state.get<{ + claimedAtMs?: unknown; + runId?: unknown; + scheduledForMs?: unknown; + }>(activeRunKey(taskId)); + if (typeof active?.runId !== "string") { + await state.delete(activeRunKey(taskId)); + return true; + } + + const activeRun = + (await state.get(runKey(active.runId))) ?? undefined; + if (!isStaleActiveRun(active, activeRun, nowMs)) { + return false; + } + + await clearActiveRun(state, taskId, active.runId); + if (typeof active.scheduledForMs === "number") { + await state.delete(claimKey(taskId, active.scheduledForMs)); + } + return true; +} + +function isFinishedRun(run: ScheduledRun): boolean { + return ( + run.status === "completed" || + run.status === "failed" || + run.status === "blocked" || + run.status === "skipped" + ); +} + +function isStaleActiveRun( + active: { claimedAtMs?: unknown }, + run: ScheduledRun | undefined, + nowMs: number, +): boolean { + if (run) { + return isFinishedRun(run) || isStalePendingRun(run, nowMs); + } + + return ( + typeof active.claimedAtMs === "number" && + active.claimedAtMs + PENDING_CLAIM_STALE_MS <= nowMs + ); +} + function isStalePendingRun( run: ScheduledRun | undefined, nowMs: number, @@ -174,14 +256,36 @@ function isDueTask( task: ScheduledTask, nowMs: number, ): task is ScheduledTask & { - nextRunAtMs: number; + nextRunAtMs?: number; + runNowAtMs?: number; } { return ( task.status === "active" && + ((typeof task.runNowAtMs === "number" && + Number.isFinite(task.runNowAtMs) && + task.runNowAtMs <= nowMs) || + (typeof task.nextRunAtMs === "number" && + Number.isFinite(task.nextRunAtMs) && + task.nextRunAtMs <= nowMs)) + ); +} + +function getDueRunAtMs(task: ScheduledTask, nowMs: number): number | undefined { + if ( + typeof task.runNowAtMs === "number" && + Number.isFinite(task.runNowAtMs) && + task.runNowAtMs <= nowMs + ) { + return task.runNowAtMs; + } + if ( typeof task.nextRunAtMs === "number" && Number.isFinite(task.nextRunAtMs) && task.nextRunAtMs <= nowMs - ); + ) { + return task.nextRunAtMs; + } + return undefined; } function buildScheduledRun(args: { @@ -289,25 +393,22 @@ class StateAdapterSchedulerStore implements SchedulerStore { .sort((a, b) => a.createdAtMs - b.createdAtMs); } - async claimDueRuns(args: { - limit: number; + async claimDueRun(args: { nowMs: number; - }): Promise { + }): Promise { await this.state.connect(); const ids = await getIndex(this.state, globalTaskIndexKey()); - const runs: ScheduledRun[] = []; for (const id of ids) { - if (runs.length >= args.limit) { - break; - } - const task = await this.getTask(id); if (!task || !isDueTask(task, args.nowMs)) { continue; } - const scheduledForMs = task.nextRunAtMs; + const scheduledForMs = getDueRunAtMs(task, args.nowMs); + if (scheduledForMs === undefined) { + continue; + } const runId = buildRunId(task.id, scheduledForMs); const tryClaimActiveRun = async (): Promise => await this.state.setIfNotExists( @@ -318,10 +419,7 @@ class StateAdapterSchedulerStore implements SchedulerStore { let activeClaimed = await tryClaimActiveRun(); if (!activeClaimed) { - const activeRun = await this.getRun(runId); - if (isStalePendingRun(activeRun, args.nowMs)) { - await clearActiveRun(this.state, task.id, runId); - await this.state.delete(claimKey(task.id, scheduledForMs)); + if (await clearStaleActiveRun(this.state, task.id, args.nowMs)) { activeClaimed = await tryClaimActiveRun(); } if (!activeClaimed) { @@ -357,10 +455,10 @@ class StateAdapterSchedulerStore implements SchedulerStore { task, }); await this.state.set(runKey(run.id), run, SCHEDULED_RUN_TTL_MS); - runs.push(run); + return run; } - return runs; + return undefined; } async getRun(runId: string): Promise { @@ -428,6 +526,27 @@ class StateAdapterSchedulerStore implements SchedulerStore { return next; } + async markRunSkipped(args: { + completedAtMs: number; + errorMessage: string; + runId: string; + }): Promise { + const next = await this.updateRun(args.runId, (run) => + run.status === "pending" + ? { + ...run, + completedAtMs: args.completedAtMs, + errorMessage: args.errorMessage, + status: "skipped", + } + : undefined, + ); + if (next) { + await clearActiveRun(this.state, next.taskId, next.id); + } + return next; + } + async markRunBlocked(args: { completedAtMs: number; errorMessage: string; @@ -465,6 +584,42 @@ class StateAdapterSchedulerStore implements SchedulerStore { return; } + const isRunNow = current.runNowAtMs === args.run.scheduledForMs; + if (isRunNow) { + let nextRunAtMs = current.nextRunAtMs; + if ( + args.status !== "blocked" && + typeof current.nextRunAtMs === "number" && + current.nextRunAtMs <= args.run.scheduledForMs + ) { + nextRunAtMs = getNextRunAtMs( + current, + current.nextRunAtMs, + args.nowMs, + ); + } + await this.saveTaskRecord( + { + ...current, + lastRunAtMs: args.run.scheduledForMs, + nextRunAtMs, + runNowAtMs: undefined, + status: + args.status === "blocked" + ? "blocked" + : nextRunAtMs + ? current.status + : "paused", + statusReason: + args.status === "blocked" ? args.errorMessage : undefined, + updatedAtMs: args.nowMs, + version: current.version + 1, + }, + current, + ); + return; + } + if ( current.status !== "active" || current.nextRunAtMs !== args.run.scheduledForMs @@ -529,7 +684,20 @@ class StateAdapterSchedulerStore implements SchedulerStore { /** Create the production scheduler store backed by Junior's state adapter. */ export function createStateSchedulerStore( - stateAdapter: StateAdapter = getStateAdapter(), + stateAdapter: StateAdapter = getSchedulerStateAdapter(), ): SchedulerStore { return new StateAdapterSchedulerStore(stateAdapter); } + +/** Disconnect the dedicated scheduler state adapter when one is configured. */ +export async function disconnectSchedulerStateAdapter(): Promise { + if (!schedulerStateAdapter) { + return; + } + + try { + await schedulerStateAdapter.disconnect(); + } finally { + schedulerStateAdapter = undefined; + } +} diff --git a/packages/junior/src/chat/scheduler/types.ts b/packages/junior/src/chat/scheduler/types.ts index 77f50455..f409d10c 100644 --- a/packages/junior/src/chat/scheduler/types.ts +++ b/packages/junior/src/chat/scheduler/types.ts @@ -14,11 +14,20 @@ export interface ScheduledTaskPrincipal { userName?: string; } +export interface ScheduledTaskExecutionActor { + type: "system"; + id: string; +} + +export const SCHEDULED_TASK_SYSTEM_ACTOR = Object.freeze({ + type: "system", + id: "scheduled-task", +} satisfies ScheduledTaskExecutionActor); + export interface ScheduledTaskDestination { platform: "slack"; teamId: string; channelId: string; - threadTs?: string; } export type ScheduledCalendarFrequency = @@ -63,9 +72,11 @@ export interface ScheduledTask { createdAtMs: number; createdBy: ScheduledTaskPrincipal; destination: ScheduledTaskDestination; + executionActor?: ScheduledTaskExecutionActor; lastRunAtMs?: number; nextRunAtMs?: number; originalRequest?: string; + runNowAtMs?: number; schedule: ScheduledTaskSchedule; status: ScheduledTaskStatus; statusReason?: string; diff --git a/packages/junior/src/chat/services/plugin-auth-orchestration.ts b/packages/junior/src/chat/services/plugin-auth-orchestration.ts index 0b0fb261..ca10d1ad 100644 --- a/packages/junior/src/chat/services/plugin-auth-orchestration.ts +++ b/packages/junior/src/chat/services/plugin-auth-orchestration.ts @@ -311,6 +311,9 @@ export function createPluginAuthOrchestration( } if (!deps.requesterId || !deps.userTokenStore) { + if (deps.authorizationFlowMode === "disabled") { + throw new AuthorizationFlowDisabledError("plugin", provider); + } throw buildCredentialFailureError(provider, input.command); } diff --git a/packages/junior/src/chat/slack/reply.ts b/packages/junior/src/chat/slack/reply.ts index 0099c679..5a8b1a11 100644 --- a/packages/junior/src/chat/slack/reply.ts +++ b/packages/junior/src/chat/slack/reply.ts @@ -192,7 +192,7 @@ export async function postSlackApiReplyPosts(args: { messageTs?: string; stage: PlannedSlackReplyStage; }) => Promise | void; - threadTs: string; + threadTs?: string; posts: PlannedSlackReplyPost[]; }): Promise { const lastTextPostIndex = findLastTextPostIndex(args.posts); @@ -224,10 +224,13 @@ export async function postSlackApiReplyPosts(args: { continue; } + if (!args.threadTs && !lastPostedMessageTs) { + throw new Error("Slack file delivery requires a posted message thread"); + } await uploadReplyFiles({ channelId: args.channelId, failureMode: args.fileUploadFailureMode ?? "best_effort", - threadTs: args.threadTs, + threadTs: args.threadTs ?? lastPostedMessageTs!, files: post.files, }); } catch (error) { diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index 848437be..df0b8d76 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -18,6 +18,7 @@ import { createSlackScheduleCreateTaskTool, createSlackScheduleDeleteTaskTool, createSlackScheduleListTasksTool, + createSlackScheduleRunTaskNowTool, createSlackScheduleUpdateTaskTool, } from "@/chat/tools/slack/schedule-tools"; import { @@ -158,11 +159,17 @@ export function createTools( ); } - if (context.channelId) { + if ( + context.disableScheduleTools !== true && + context.channelId && + context.teamId && + context.requester?.userId + ) { tools.slackScheduleCreateTask = createSlackScheduleCreateTaskTool(context); tools.slackScheduleListTasks = createSlackScheduleListTasksTool(context); tools.slackScheduleUpdateTask = createSlackScheduleUpdateTaskTool(context); tools.slackScheduleDeleteTask = createSlackScheduleDeleteTaskTool(context); + tools.slackScheduleRunTaskNow = createSlackScheduleRunTaskNowTool(context); } return tools; diff --git a/packages/junior/src/chat/tools/slack/schedule-tools.ts b/packages/junior/src/chat/tools/slack/schedule-tools.ts index 28d2f8e1..110ac3bf 100644 --- a/packages/junior/src/chat/tools/slack/schedule-tools.ts +++ b/packages/junior/src/chat/tools/slack/schedule-tools.ts @@ -2,9 +2,11 @@ import { randomUUID } from "node:crypto"; import { Type } from "@sinclair/typebox"; import { buildCalendarRecurrence, + parseRelativeScheduleTimestamp, parseScheduleTimestamp, } from "@/chat/scheduler/cadence"; import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import { SCHEDULED_TASK_SYSTEM_ACTOR } from "@/chat/scheduler/types"; import type { ScheduledCalendarFrequency, ScheduledTask, @@ -19,6 +21,7 @@ import type { ToolRuntimeContext } from "@/chat/tools/types"; const TASK_ID_PREFIX = "sched"; const MAX_LISTED_TASKS = 50; +const DEFAULT_SCHEDULE_TIMEZONE = "America/Los_Angeles"; function requireActiveDestination( context: ToolRuntimeContext, @@ -38,12 +41,6 @@ function requireActiveDestination( error: "No active Slack workspace context is available.", }; } - if (!context.threadTs) { - return { - ok: false, - error: "No active Slack thread context is available.", - }; - } return { ok: true, @@ -51,7 +48,6 @@ function requireActiveDestination( platform: "slack", teamId: context.teamId, channelId, - threadTs: context.threadTs, }, }; } @@ -90,26 +86,18 @@ function sameDestination( return ( task.destination.platform === destination.platform && task.destination.teamId === destination.teamId && - task.destination.channelId === destination.channelId && - (task.destination.threadTs ?? "") === (destination.threadTs ?? "") + task.destination.channelId === destination.channelId ); } async function getWritableTask(args: { context: ToolRuntimeContext; taskId: string; -}): Promise< - | { ok: true; task: ScheduledTask; destination: ScheduledTaskDestination } - | { ok: false; error: string } -> { +}): Promise<{ ok: true; task: ScheduledTask } | { ok: false; error: string }> { const destination = requireActiveDestination(args.context); if (!destination.ok) { return destination; } - const requester = requireRequester(args.context); - if (!requester.ok) { - return requester; - } const task = await createStateSchedulerStore().getTask(args.taskId); if (!task || task.status === "deleted") { @@ -126,18 +114,9 @@ async function getWritableTask(args: { "Scheduled task can only be managed from the Slack destination where it was created.", }; } - if (task.createdBy.slackUserId !== requester.requester.slackUserId) { - return { - ok: false, - error: - "Scheduled task can only be managed by the Slack user who created it.", - }; - } - return { ok: true, task, - destination: destination.destination, }; } @@ -166,6 +145,9 @@ function compactTask(task: ScheduledTask): Record { last_run_at: task.lastRunAtMs ? new Date(task.lastRunAtMs).toISOString() : null, + run_now_at: task.runNowAtMs + ? new Date(task.runNowAtMs).toISOString() + : null, version: task.version, }; } @@ -222,7 +204,8 @@ function buildRecurrence(args: { if (!args.nextRunAtMs) { return { ok: false, - error: "Recurring scheduled tasks require next_run_at_iso.", + error: + "Recurring scheduled tasks require next_run_at_iso or next_run_at_text.", }; } @@ -254,6 +237,7 @@ function buildRecurrence(args: { } function shouldRebuildRecurrence(input: { + next_run_at_text?: string; next_run_at_iso?: string; recurrence_frequency?: unknown; recurrence_interval?: number; @@ -261,6 +245,7 @@ function shouldRebuildRecurrence(input: { timezone?: string; }): boolean { return ( + input.next_run_at_text !== undefined || input.next_run_at_iso !== undefined || input.recurrence_frequency !== undefined || input.recurrence_interval !== undefined || @@ -269,11 +254,56 @@ function shouldRebuildRecurrence(input: { ); } +function getDefaultScheduleTimezone(): string { + return process.env.JUNIOR_TIMEZONE?.trim() || DEFAULT_SCHEDULE_TIMEZONE; +} + +function isValidTimeZone(timezone: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(); + return true; + } catch { + return false; + } +} + +function parseNextRunAtMs(args: { + input: { + next_run_at_iso?: string; + next_run_at_text?: string; + }; + nowMs: number; + timezone: string; +}): number | undefined { + try { + if (args.input.next_run_at_iso) { + return parseScheduleTimestamp(args.input.next_run_at_iso); + } + if (args.input.next_run_at_text) { + return parseRelativeScheduleTimestamp({ + nowMs: args.nowMs, + text: args.input.next_run_at_text, + timezone: args.timezone, + }); + } + } catch { + return undefined; + } + return undefined; +} + +function hasConflictingNextRunInputs(input: { + next_run_at_iso?: string; + next_run_at_text?: string; +}): boolean { + return Boolean(input.next_run_at_iso && input.next_run_at_text); +} + /** Create a tool that stores a scheduled task for the active Slack context. */ export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { return tool({ description: - "Create a Junior scheduled task for the active Slack destination. The destination is always the current Slack channel/thread context; never accept or invent another destination. Use only after the user has confirmed the normalized scheduled task contract. For recurring work, provide an exact next_run_at_iso and a calendar recurrence_frequency.", + "Create a Junior scheduled task for the active Slack DM or channel. The destination is always the current Slack conversation, never an existing thread, and never an invented destination. Use only after the user has confirmed the normalized scheduled task contract. Provide either exact next_run_at_iso or supported relative next_run_at_text. For recurring work, provide a calendar recurrence_frequency.", inputSchema: Type.Object({ confirmed_by_user: Type.Boolean({ description: @@ -289,12 +319,22 @@ export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { Type.String({ minLength: 1, maxLength: 1000 }), ), schedule_description: Type.String({ minLength: 1, maxLength: 300 }), - timezone: Type.String({ minLength: 1, maxLength: 80 }), - next_run_at_iso: Type.String({ - minLength: 1, - description: - "Exact next run time as an ISO timestamp, computed from the user's requested schedule.", - }), + timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })), + next_run_at_iso: Type.Optional( + Type.String({ + minLength: 1, + description: + "Exact next run time as an ISO timestamp, computed from the user's requested schedule.", + }), + ), + next_run_at_text: Type.Optional( + Type.String({ + minLength: 1, + maxLength: 120, + description: + 'Supported relative one-off text such as "tomorrow at 9am" in the supplied timezone.', + }), + ), recurrence_frequency: Type.Optional( Type.Union( [ @@ -348,34 +388,53 @@ export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { }; } - const nextRunAtMs = parseScheduleTimestamp(input.next_run_at_iso); + const nowMs = Date.now(); + const timezone = input.timezone ?? getDefaultScheduleTimezone(); + if (hasConflictingNextRunInputs(input)) { + return { + ok: false, + error: "Provide only one of next_run_at_iso or next_run_at_text.", + }; + } + if (!isValidTimeZone(timezone)) { + return { + ok: false, + error: "timezone must be a valid IANA time zone.", + }; + } + const nextRunAtMs = parseNextRunAtMs({ + input, + nowMs, + timezone, + }); if (!nextRunAtMs) { return { ok: false, - error: "next_run_at_iso must be a valid ISO timestamp.", + error: + 'Provide next_run_at_iso as a valid ISO timestamp or next_run_at_text such as "tomorrow at 9am".', }; } const recurrence = buildRecurrence({ input, nextRunAtMs, - timezone: input.timezone, + timezone, }); if (!recurrence.ok) { return recurrence; } - const nowMs = Date.now(); const task: ScheduledTask = { id: buildTaskId(), createdAtMs: nowMs, updatedAtMs: nowMs, createdBy: requester.requester, destination: destination.destination, + executionActor: SCHEDULED_TASK_SYSTEM_ACTOR, nextRunAtMs, originalRequest: context.userText, schedule: { description: input.schedule_description, - timezone: input.timezone, + timezone, kind: recurrence.recurrence ? "recurring" : "one_off", recurrence: recurrence.recurrence, }, @@ -432,7 +491,7 @@ export function createSlackScheduleListTasksTool(context: ToolRuntimeContext) { export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { return tool({ description: - "Edit a Junior scheduled task in the active Slack destination. Use only for task IDs returned from the active destination. Do not move tasks across channels or threads.", + "Edit a Junior scheduled task in the active Slack DM or channel. Use only for task IDs returned from the active destination. Do not move tasks across conversations.", inputSchema: Type.Object({ task_id: Type.String({ minLength: 1 }), title: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })), @@ -451,6 +510,9 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { ), timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })), next_run_at_iso: Type.Optional(Type.String({ minLength: 1 })), + next_run_at_text: Type.Optional( + Type.String({ minLength: 1, maxLength: 120 }), + ), recurrence_frequency: Type.Optional( Type.Union([ Type.Literal("daily"), @@ -491,13 +553,33 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { }); if (!lookup.ok) return lookup; - const nextRunAtMs = input.next_run_at_iso - ? parseScheduleTimestamp(input.next_run_at_iso) - : lookup.task.nextRunAtMs; - if (input.next_run_at_iso && !nextRunAtMs) { + const timezone = input.timezone ?? lookup.task.schedule.timezone; + if (hasConflictingNextRunInputs(input)) { + return { + ok: false, + error: "Provide only one of next_run_at_iso or next_run_at_text.", + }; + } + if (!isValidTimeZone(timezone)) { return { ok: false, - error: "next_run_at_iso must be a valid ISO timestamp.", + error: "timezone must be a valid IANA time zone.", + }; + } + const parsedNextRunAtMs = parseNextRunAtMs({ + input, + nowMs: Date.now(), + timezone, + }); + const nextRunAtMs = + input.next_run_at_iso || input.next_run_at_text + ? parsedNextRunAtMs + : lookup.task.nextRunAtMs; + if ((input.next_run_at_iso || input.next_run_at_text) && !nextRunAtMs) { + return { + ok: false, + error: + 'Provide next_run_at_iso as a valid ISO timestamp or next_run_at_text such as "tomorrow at 9am".', }; } @@ -512,10 +594,9 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { return { ok: false, error: - "Active scheduled tasks require next_run_at_iso when no next run is stored.", + "Active scheduled tasks require next_run_at_iso or next_run_at_text when no next run is stored.", }; } - const timezone = input.timezone ?? lookup.task.schedule.timezone; const recurrence = shouldRebuildRecurrence(input) ? buildRecurrence({ existing: lookup.task.schedule.recurrence, @@ -533,6 +614,8 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { ...lookup.task, updatedAtMs: Date.now(), nextRunAtMs, + runNowAtMs: + nextStatus === "active" ? lookup.task.runNowAtMs : undefined, status: nextStatus, statusReason: nextStatus === "blocked" ? lookup.task.statusReason : undefined, @@ -583,6 +666,43 @@ export function createSlackScheduleDeleteTaskTool(context: ToolRuntimeContext) { updatedAtMs: Date.now(), status: "deleted", nextRunAtMs: undefined, + runNowAtMs: undefined, + version: lookup.task.version + 1, + }; + + await createStateSchedulerStore().saveTask(next); + return { + ok: true, + task: compactTask(next), + }; + }, + }); +} + +/** Create a tool that marks an existing scheduled task due immediately. */ +export function createSlackScheduleRunTaskNowTool(context: ToolRuntimeContext) { + return tool({ + description: + "Run an existing Junior scheduled task as soon as the scheduler tick processes it. Use only for task IDs returned from this destination.", + inputSchema: Type.Object({ + task_id: Type.String({ minLength: 1 }), + }), + execute: async ({ task_id }) => { + const lookup = await getWritableTask({ context, taskId: task_id }); + if (!lookup.ok) return lookup; + if (lookup.task.status !== "active") { + return { + ok: false, + error: + "Scheduled task must be active before it can be run now. Resume the task first if you want it to run.", + }; + } + + const nowMs = Date.now(); + const next: ScheduledTask = { + ...lookup.task, + updatedAtMs: nowMs, + runNowAtMs: nowMs, version: lookup.task.version + 1, }; diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index e9aed3be..5dc005d6 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -54,6 +54,7 @@ export interface ToolRuntimeContext { teamId?: string; messageTs?: string; threadTs?: string; + disableScheduleTools?: boolean; userText?: string; artifactState?: ThreadArtifactsState; configuration?: Record; diff --git a/packages/junior/src/handlers/diagnostics-dashboard.ts b/packages/junior/src/handlers/diagnostics-dashboard.ts index e271b1d1..c7eb6532 100644 --- a/packages/junior/src/handlers/diagnostics-dashboard.ts +++ b/packages/junior/src/handlers/diagnostics-dashboard.ts @@ -126,7 +126,7 @@ export async function GET(): Promise { { method: "GET", path: "/api/info" }, { method: "GET", path: "/api/oauth/callback/mcp/:provider" }, { method: "GET", path: "/api/oauth/callback/:provider" }, - { method: "POST", path: "/api/internal/scheduler/tick" }, + { method: "GET", path: "/api/internal/scheduler/tick" }, { method: "POST", path: "/api/webhooks/:platform" }, ]; html += `\n
diff --git a/packages/junior/src/handlers/scheduler-tick.ts b/packages/junior/src/handlers/scheduler-tick.ts index 9b3ba189..5e676900 100644 --- a/packages/junior/src/handlers/scheduler-tick.ts +++ b/packages/junior/src/handlers/scheduler-tick.ts @@ -24,7 +24,7 @@ function verifySchedulerRequest(request: Request): boolean { } /** Handle the authenticated internal scheduler tick. */ -export async function ALL( +export async function GET( request: Request, waitUntil: WaitUntilFn, ): Promise { diff --git a/packages/junior/tests/integration/scheduler-executor.test.ts b/packages/junior/tests/integration/scheduler-executor.test.ts index 590ee513..07e1eeb8 100644 --- a/packages/junior/tests/integration/scheduler-executor.test.ts +++ b/packages/junior/tests/integration/scheduler-executor.test.ts @@ -1,11 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; import { executeScheduledRun, processDueScheduledRuns, type ScheduledTaskRunner, } from "@/chat/scheduler/executor"; -import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import { + createStateSchedulerStore, + type SchedulerStore, +} from "@/chat/scheduler/store"; import type { ScheduledTask } from "@/chat/scheduler/types"; vi.hoisted(() => { @@ -27,7 +30,6 @@ function createTask(overrides: Partial = {}): ScheduledTask { platform: "slack", teamId: "T_EXECUTOR", channelId: "C123", - threadTs: "1700000000.000000", }, nextRunAtMs: firstRunAtMs, schedule: { @@ -56,6 +58,15 @@ function createTask(overrides: Partial = {}): ScheduledTask { }; } +async function claimDueRun( + store: SchedulerStore, + nowMs = Date.parse("2026-03-02T17:00:00.000Z"), +) { + const run = await store.claimDueRun({ nowMs }); + expect(run).toBeDefined(); + return run!; +} + describe("scheduler executor", () => { beforeEach(async () => { await disconnectStateAdapter(); @@ -144,15 +155,82 @@ describe("scheduler executor", () => { }); }); - it("blocks the task when the runner reports missing requirements", async () => { + it("executes a run-now request without shifting the recurring schedule", async () => { const store = createStateSchedulerStore(); - const task = createTask({ id: `sched_blocked_${Date.now()}` }); + const scheduledNextRunAtMs = Date.parse("2026-03-09T16:00:00.000Z"); + const runNowAtMs = Date.parse("2026-03-04T18:00:00.000Z"); + const task = createTask({ + id: `sched_run_now_${Date.now()}`, + nextRunAtMs: scheduledNextRunAtMs, + runNowAtMs, + }); await store.saveTask(task); - const [run] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:00:00.000Z"), + + const completed = await processDueScheduledRuns({ + store, + nowMs: Date.parse("2026-03-04T18:00:01.000Z"), + limit: 10, + runner: { + run: async () => ({ status: "completed" }), + }, + }); + + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + taskId: task.id, + scheduledForMs: runNowAtMs, + status: "completed", + }); + const updated = await store.getTask(task.id); + expect(updated).toMatchObject({ + status: "active", + lastRunAtMs: runNowAtMs, + nextRunAtMs: scheduledNextRunAtMs, + }); + expect(updated?.runNowAtMs).toBeUndefined(); + }); + + it("coalesces run-now with an overdue scheduled occurrence", async () => { + const store = createStateSchedulerStore(); + const scheduledNextRunAtMs = Date.parse("2026-03-02T17:00:00.000Z"); + const runNowAtMs = Date.parse("2026-03-04T18:00:00.000Z"); + const task = createTask({ + id: `sched_run_now_overdue_${Date.now()}`, + nextRunAtMs: scheduledNextRunAtMs, + runNowAtMs, + }); + await store.saveTask(task); + + const completed = await processDueScheduledRuns({ + store, + nowMs: Date.parse("2026-03-04T18:00:01.000Z"), limit: 10, + runner: { + run: async () => ({ status: "completed" }), + }, }); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + taskId: task.id, + scheduledForMs: runNowAtMs, + status: "completed", + }); + const updated = await store.getTask(task.id); + expect(updated).toMatchObject({ + status: "active", + lastRunAtMs: runNowAtMs, + nextRunAtMs: Date.parse("2026-03-09T16:00:00.000Z"), + }); + expect(updated?.runNowAtMs).toBeUndefined(); + }); + + it("blocks the task when the runner reports missing requirements", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_blocked_${Date.now()}` }); + await store.saveTask(task); + const run = await claimDueRun(store); + const completed = await executeScheduledRun({ store, run, @@ -181,10 +259,7 @@ describe("scheduler executor", () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_blocked_retry_${Date.now()}` }); await store.saveTask(task); - const [run] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:00:00.000Z"), - limit: 10, - }); + const run = await claimDueRun(store); await executeScheduledRun({ store, @@ -212,10 +287,10 @@ describe("scheduler executor", () => { version: blocked!.version + 1, }); - const [retryRun] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:00:03.000Z"), - limit: 10, - }); + const retryRun = await claimDueRun( + store, + Date.parse("2026-03-02T17:00:03.000Z"), + ); expect(retryRun).toMatchObject({ id: run.id, @@ -229,10 +304,7 @@ describe("scheduler executor", () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_overlap_${Date.now()}` }); await store.saveTask(task); - const [firstRun] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:00:00.000Z"), - limit: 10, - }); + const firstRun = await claimDueRun(store); await store.markRunStarted({ runId: firstRun.id, claimedAtMs: firstRun.claimedAtMs, @@ -247,11 +319,10 @@ describe("scheduler executor", () => { }); await expect( - store.claimDueRuns({ + store.claimDueRun({ nowMs: Date.parse("2026-03-09T16:00:01.000Z"), - limit: 10, }), - ).resolves.toHaveLength(0); + ).resolves.toBeUndefined(); await store.markRunCompleted({ runId: firstRun.id, @@ -259,10 +330,10 @@ describe("scheduler executor", () => { startedAtMs: Date.parse("2026-03-02T17:00:01.000Z"), }); - const [nextRun] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-09T16:00:01.000Z"), - limit: 10, - }); + const nextRun = await claimDueRun( + store, + Date.parse("2026-03-09T16:00:01.000Z"), + ); expect(nextRun).toMatchObject({ taskId: task.id, scheduledForMs: editedNextRunAtMs, @@ -279,10 +350,8 @@ describe("scheduler executor", () => { await store.saveTask(firstTask); await store.saveTask(secondTask); - const [firstRun, abandonedRun] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:00:00.000Z"), - limit: 10, - }); + const firstRun = await claimDueRun(store); + const abandonedRun = await claimDueRun(store); expect(firstRun).toMatchObject({ taskId: firstTask.id }); expect(abandonedRun).toMatchObject({ taskId: secondTask.id, @@ -298,10 +367,10 @@ describe("scheduler executor", () => { }, }); - const [retryRun] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:01:00.000Z"), - limit: 10, - }); + const retryRun = await claimDueRun( + store, + Date.parse("2026-03-02T17:01:00.000Z"), + ); expect(retryRun).toMatchObject({ id: abandonedRun.id, taskId: secondTask.id, @@ -314,14 +383,11 @@ describe("scheduler executor", () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_stale_claim_${Date.now()}` }); await store.saveTask(task); - const [abandonedRun] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:00:00.000Z"), - limit: 10, - }); - const [reclaimedRun] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:01:00.000Z"), - limit: 10, - }); + const abandonedRun = await claimDueRun(store); + const reclaimedRun = await claimDueRun( + store, + Date.parse("2026-03-02T17:01:00.000Z"), + ); await expect( executeScheduledRun({ @@ -350,14 +416,67 @@ describe("scheduler executor", () => { }); }); + it("does not let a stale older claim block a retargeted due run", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_stale_retarget_${Date.now()}` }); + await store.saveTask(task); + const abandonedRun = await claimDueRun(store); + const retargetedNextRunAtMs = Date.parse("2026-03-02T17:00:30.000Z"); + await store.saveTask({ + ...task, + nextRunAtMs: retargetedNextRunAtMs, + updatedAtMs: Date.parse("2026-03-02T17:00:10.000Z"), + version: task.version + 1, + }); + + const retargetedRun = await claimDueRun( + store, + Date.parse("2026-03-02T17:01:00.000Z"), + ); + + expect(retargetedRun).toMatchObject({ + id: `${task.id}:${retargetedNextRunAtMs}`, + taskId: task.id, + scheduledForMs: retargetedNextRunAtMs, + }); + expect(retargetedRun.id).not.toBe(abandonedRun.id); + }); + + it("reclaims a due run when an active marker was written without a run record", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_missing_run_${Date.now()}` }); + await store.saveTask(task); + const scheduledForMs = task.nextRunAtMs!; + const state = getStateAdapter(); + await state.connect(); + await state.set( + `junior:scheduler:active:${task.id}`, + { + claimedAtMs: Date.parse("2026-03-02T17:00:00.000Z"), + runId: `${task.id}:${scheduledForMs}`, + scheduledForMs, + }, + 6 * 60 * 60 * 1000, + ); + + const reclaimed = await claimDueRun( + store, + Date.parse("2026-03-02T17:01:00.000Z"), + ); + + expect(reclaimed).toMatchObject({ + id: `${task.id}:${scheduledForMs}`, + taskId: task.id, + scheduledForMs, + status: "pending", + }); + }); + it("does not restart a run another tick already completed", async () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_completed_claim_${Date.now()}` }); await store.saveTask(task); - const [run] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:00:00.000Z"), - limit: 10, - }); + const run = await claimDueRun(store); await executeScheduledRun({ store, @@ -386,10 +505,7 @@ describe("scheduler executor", () => { const store = createStateSchedulerStore(); const task = createTask({ id: `sched_deleted_${Date.now()}` }); await store.saveTask(task); - const [run] = await store.claimDueRuns({ - nowMs: Date.parse("2026-03-02T17:00:00.000Z"), - limit: 10, - }); + const run = await claimDueRun(store); await executeScheduledRun({ store, @@ -416,4 +532,36 @@ describe("scheduler executor", () => { version: 2, }); }); + + it("skips a claimed run when the task is deleted before execution starts", async () => { + const store = createStateSchedulerStore(); + const task = createTask({ id: `sched_deleted_before_start_${Date.now()}` }); + await store.saveTask(task); + const run = await claimDueRun(store); + await store.saveTask({ + ...task, + status: "deleted", + nextRunAtMs: undefined, + updatedAtMs: Date.parse("2026-03-02T17:00:00.500Z"), + version: task.version + 1, + }); + + await expect( + executeScheduledRun({ + store, + run, + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + runner: { + run: async () => { + throw new Error("deleted task should not execute"); + }, + }, + }), + ).resolves.toMatchObject({ + status: "skipped", + errorMessage: expect.stringContaining( + "was deleted before the run started", + ), + }); + }); }); diff --git a/packages/junior/tests/integration/scheduler-slack-runner.test.ts b/packages/junior/tests/integration/scheduler-slack-runner.test.ts index 39b19eef..b93ad2d8 100644 --- a/packages/junior/tests/integration/scheduler-slack-runner.test.ts +++ b/packages/junior/tests/integration/scheduler-slack-runner.test.ts @@ -3,11 +3,14 @@ import { disconnectStateAdapter } from "@/chat/state/adapter"; import { createSlackScheduledTaskRunner } from "@/chat/scheduler/slack-runner"; import { getPersistedThreadState } from "@/chat/runtime/thread-state"; import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; +import { PluginCredentialFailureError } from "@/chat/services/plugin-auth-orchestration"; import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; import type { AssistantReply } from "@/chat/respond"; import { chatPostEphemeralOk, chatPostMessageOk, + filesCompleteUploadOk, + filesGetUploadUrlOk, } from "../fixtures/slack/factories/api"; import { getCapturedSlackApiCalls, @@ -29,11 +32,14 @@ function createTask(): ScheduledTask { userName: "dcramer", fullName: "David Cramer", }, + executionActor: { + type: "system", + id: "scheduled-task", + }, destination: { platform: "slack", teamId: "T123", channelId: "C123", - threadTs: "1700000000.000000", }, nextRunAtMs: scheduledForMs, schedule: { @@ -121,17 +127,15 @@ describe("scheduled Slack runner", () => { if (!context) { throw new Error("expected reply context"); } - expect(context.requester).toMatchObject({ - userId: "U123", - userName: "dcramer", - fullName: "David Cramer", - }); + expect(context.requester).toBeUndefined(); expect(context.correlation).toMatchObject({ channelId: "C123", teamId: "T123", - threadTs: "1700000000.000000", runId: run.id, + actorType: "system", + actorId: "scheduled-task", }); + expect(context.correlation?.requesterId).toBeUndefined(); return createReply(); }, }); @@ -151,7 +155,6 @@ describe("scheduled Slack runner", () => { expect.objectContaining({ params: expect.objectContaining({ channel: "C123", - thread_ts: "1700000000.000000", text: "Scheduled digest delivered.", }), }), @@ -238,10 +241,17 @@ describe("scheduled Slack runner", () => { }); await expect( - getPersistedThreadState("slack:T123:C123:1700000000.000000"), + getPersistedThreadState("slack:T123:C123"), ).resolves.toMatchObject({ conversation: { messages: expect.arrayContaining([ + expect.objectContaining({ + id: `scheduled-run:${createRun(firstTask).id}:user`, + author: expect.objectContaining({ + userName: "system:scheduled-task", + isBot: true, + }), + }), expect.objectContaining({ id: `scheduled-run:${createRun(firstTask).id}:assistant`, }), @@ -249,7 +259,7 @@ describe("scheduled Slack runner", () => { }, }); await expect( - getPersistedThreadState("slack:T999:C123:1700000000.000000"), + getPersistedThreadState("slack:T999:C123"), ).resolves.toMatchObject({ conversation: { messages: expect.arrayContaining([ @@ -261,6 +271,72 @@ describe("scheduled Slack runner", () => { }); }); + it("posts file-only scheduled results under a top-level message", async () => { + queueSlackApiResponse("chat.postMessage", { + body: chatPostMessageOk({ + channel: "C123", + ts: "1700000000.000010", + }), + }); + queueSlackApiResponse("files.getUploadURLExternal", { + body: filesGetUploadUrlOk({ + fileId: "F_SCHEDULED", + uploadUrl: "https://files.slack.com/upload/v1/F_SCHEDULED", + }), + }); + queueSlackApiResponse("files.completeUploadExternal", { + body: filesCompleteUploadOk({ + files: [{ id: "F_SCHEDULED" }], + }), + }); + const task = createTask(); + const run = createRun(task); + const runner = createSlackScheduledTaskRunner({ + generateAssistantReply: async () => ({ + ...createReply(), + text: "", + deliveryPlan: { + mode: "thread", + postThreadText: true, + attachFiles: "inline", + }, + files: [ + { + data: Buffer.from("scheduled report"), + filename: "report.txt", + }, + ], + }), + }); + + await expect( + runner.run({ + task, + run, + prompt: "", + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + }), + ).resolves.toEqual({ + status: "completed", + resultMessageTs: "1700000000.000010", + }); + + expect(getCapturedSlackApiCalls("chat.postMessage")).toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + channel: "C123", + text: "Generated files are attached.", + }), + }), + ]); + expect( + getCapturedSlackApiCalls("files.completeUploadExternal")[0]?.params, + ).toMatchObject({ + channel_id: "C123", + thread_ts: "1700000000.000010", + }); + }); + it("blocks scheduled runs instead of starting authorization", async () => { queueSlackApiResponse("chat.postEphemeral", { body: chatPostEphemeralOk(), @@ -296,7 +372,6 @@ describe("scheduled Slack runner", () => { expect.objectContaining({ params: expect.objectContaining({ channel: "C123", - thread_ts: "1700000000.000000", user: "U123", text: expect.stringContaining( 'Scheduled task "Issue digest" is blocked', @@ -305,9 +380,48 @@ describe("scheduled Slack runner", () => { }), ]); await expect( - getPersistedThreadState("slack:T123:C123:1700000000.000000"), + getPersistedThreadState("slack:T123:C123"), ).resolves.not.toMatchObject({ conversation: { processing: { pendingAuth: expect.anything() } }, }); }); + + it("privately blocks scheduled runs on provider credential failures", async () => { + queueSlackApiResponse("chat.postEphemeral", { + body: chatPostEphemeralOk(), + }); + const task = createTask(); + const run = createRun(task); + const runner = createSlackScheduledTaskRunner({ + generateAssistantReply: async () => { + throw new PluginCredentialFailureError( + "github", + "GitHub credentials were rejected while running `gh auth status`. Verify the GitHub App installation covers the target repository and the host GitHub App environment variables are current.", + ); + }, + }); + + await expect( + runner.run({ + task, + run, + prompt: "", + nowMs: Date.parse("2026-03-02T17:00:01.000Z"), + }), + ).resolves.toMatchObject({ + status: "blocked", + errorMessage: expect.stringContaining("GitHub credentials were rejected"), + }); + + expect(getCapturedSlackApiCalls("chat.postMessage")).toHaveLength(0); + expect(getCapturedSlackApiCalls("chat.postEphemeral")).toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + channel: "C123", + user: "U123", + text: expect.stringContaining("GitHub credentials were rejected"), + }), + }), + ]); + }); }); diff --git a/packages/junior/tests/integration/scheduler-tick.test.ts b/packages/junior/tests/integration/scheduler-tick.test.ts index 72ca2a6e..30bea8c2 100644 --- a/packages/junior/tests/integration/scheduler-tick.test.ts +++ b/packages/junior/tests/integration/scheduler-tick.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { disconnectStateAdapter } from "@/chat/state/adapter"; -import { ALL as schedulerTick } from "@/handlers/scheduler-tick"; +import { GET as schedulerTick } from "@/handlers/scheduler-tick"; import type { WaitUntilFn } from "@/handlers/types"; vi.hoisted(() => { diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index 0633c9a1..594e6177 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -1,10 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; -import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import { + createStateSchedulerStore, + disconnectSchedulerStateAdapter, +} from "@/chat/scheduler/store"; import { createSlackScheduleCreateTaskTool, createSlackScheduleDeleteTaskTool, createSlackScheduleListTasksTool, + createSlackScheduleRunTaskNowTool, createSlackScheduleUpdateTaskTool, } from "@/chat/tools/slack/schedule-tools"; import type { ToolRuntimeContext } from "@/chat/tools/types"; @@ -21,7 +25,6 @@ function createContext( return { channelId: "C123", teamId: TEST_TEAM_ID, - threadTs: "1700000000.000000", requester: { userId: "U123", userName: "dcramer", @@ -71,6 +74,10 @@ describe("Slack schedule tools", () => { }); afterEach(async () => { + vi.useRealTimers(); + delete process.env.JUNIOR_TIMEZONE; + delete process.env.JUNIOR_SCHEDULER_REDIS_URL; + await disconnectSchedulerStateAdapter(); await disconnectStateAdapter(); }); @@ -104,15 +111,20 @@ describe("Slack schedule tools", () => { ], }); - const wrongThread = await executeTool( + const sameChannelOtherThread = await executeTool( createSlackScheduleListTasksTool( createContext({ threadTs: "1700000999.000000" }), ), {}, ); - expect(wrongThread).toMatchObject({ + expect(sameChannelOtherThread).toMatchObject({ ok: true, - tasks: [], + tasks: [ + { + title: "Weekly issue digest", + schedule: "Every Monday at 9am", + }, + ], }); }); @@ -141,8 +153,38 @@ describe("Slack schedule tools", () => { ).resolves.toEqual([]); }); + it("rejects parseable non-ISO next run timestamps", async () => { + const result = await createTask(createContext(), { + next_run_at_iso: "05/25/2026 09:00", + }); + + expect(result).toMatchObject({ + ok: false, + error: + 'Provide next_run_at_iso as a valid ISO timestamp or next_run_at_text such as "tomorrow at 9am".', + }); + await expect( + createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + ).resolves.toEqual([]); + }); + + it("rejects conflicting exact and relative next run inputs", async () => { + const result = await createTask(createContext(), { + next_run_at_iso: "2026-05-25T16:00:00.000Z", + next_run_at_text: "tomorrow at 9am", + }); + + expect(result).toMatchObject({ + ok: false, + error: "Provide only one of next_run_at_iso or next_run_at_text.", + }); + await expect( + createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + ).resolves.toEqual([]); + }); + it("edits and deletes a task from the same Slack destination", async () => { - const context = createContext({ threadTs: "1700000001.000000" }); + const context = createContext(); const created = (await createTask(context)) as { task: { id: string }; }; @@ -189,7 +231,7 @@ describe("Slack schedule tools", () => { }); it("rejects edits from another active Slack destination", async () => { - const context = createContext({ threadTs: "1700000002.000000" }); + const context = createContext(); const created = (await createTask(context)) as { task: { id: string }; }; @@ -209,13 +251,13 @@ describe("Slack schedule tools", () => { }); }); - it("rejects edits and deletes from another requester in the same Slack destination", async () => { - const context = createContext({ threadTs: "1700000003.000000" }); + it("allows another requester to manage tasks in the same Slack destination", async () => { + const context = createContext(); const created = (await createTask(context)) as { task: { id: string }; }; const otherRequester = createContext({ - threadTs: context.threadTs, + threadTs: "1700000003.000000", requester: { userId: "U999", userName: "alice", @@ -227,7 +269,7 @@ describe("Slack schedule tools", () => { createSlackScheduleUpdateTaskTool(otherRequester), { task_id: created.task.id, - title: "Hijacked digest", + title: "Team-owned digest", }, ); const deleted = await executeTool( @@ -238,28 +280,98 @@ describe("Slack schedule tools", () => { ); expect(updated).toMatchObject({ - ok: false, - error: - "Scheduled task can only be managed by the Slack user who created it.", + ok: true, + task: { + id: created.task.id, + title: "Team-owned digest", + version: 2, + }, }); expect(deleted).toMatchObject({ - ok: false, - error: - "Scheduled task can only be managed by the Slack user who created it.", + ok: true, + task: { + id: created.task.id, + status: "deleted", + }, }); await expect( createStateSchedulerStore().getTask(created.task.id), ).resolves.toMatchObject({ - status: "active", + status: "deleted", + executionActor: { + type: "system", + id: "scheduled-task", + }, task: { - title: "Weekly issue digest", + title: "Team-owned digest", + }, + version: 3, + }); + }); + + it("creates one-off tasks from tomorrow text using the default Pacific timezone", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-25T12:00:00.000Z")); + + const created = await createTask(createContext(), { + next_run_at_iso: undefined, + next_run_at_text: "tomorrow at 9am", + recurrence_frequency: undefined, + recurrence_weekdays: undefined, + timezone: undefined, + }); + + expect(created).toMatchObject({ + ok: true, + task: { + next_run_at: "2026-05-26T16:00:00.000Z", + recurrence: null, + timezone: "America/Los_Angeles", }, - version: 1, }); }); + it("uses JUNIOR_TIMEZONE as the default schedule timezone", async () => { + process.env.JUNIOR_TIMEZONE = "America/New_York"; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-25T12:00:00.000Z")); + + const created = await createTask(createContext(), { + next_run_at_iso: undefined, + next_run_at_text: "tomorrow at 9am", + recurrence_frequency: undefined, + recurrence_weekdays: undefined, + timezone: undefined, + }); + + expect(created).toMatchObject({ + ok: true, + task: { + next_run_at: "2026-05-26T13:00:00.000Z", + recurrence: null, + timezone: "America/New_York", + }, + }); + }); + + it("rejects invalid default timezones", async () => { + process.env.JUNIOR_TIMEZONE = "not/a-zone"; + + const created = await createTask(createContext(), { + timezone: undefined, + }); + + expect(created).toMatchObject({ + ok: false, + error: "timezone must be a valid IANA time zone.", + }); + await expect( + createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + ).resolves.toEqual([]); + }); + it("preserves a recurring task calendar anchor on content-only edits", async () => { - const context = createContext({ threadTs: "1700000004.000000" }); + const context = createContext(); const created = (await createTask(context, { recurrence_interval: 2, })) as { @@ -304,7 +416,7 @@ describe("Slack schedule tools", () => { }); it("clears stale block reasons when resuming a task", async () => { - const context = createContext({ threadTs: "1700000005.000000" }); + const context = createContext(); const created = (await createTask(context)) as { task: { id: string }; }; @@ -341,8 +453,100 @@ describe("Slack schedule tools", () => { expect(resumed?.statusReason).toBeUndefined(); }); + it("marks an active task due immediately without changing its scheduled next run", async () => { + const context = createContext(); + const created = (await createTask(context)) as { + task: { id: string }; + }; + const store = createStateSchedulerStore(); + const task = await store.getTask(created.task.id); + expect(task).toBeDefined(); + const scheduledNextRunAtMs = Date.parse("2026-06-01T16:00:00.000Z"); + await store.saveTask({ + ...task!, + nextRunAtMs: scheduledNextRunAtMs, + updatedAtMs: Date.parse("2026-05-25T16:01:00.000Z"), + version: task!.version + 1, + }); + + const beforeMs = Date.now(); + const result = await executeTool( + createSlackScheduleRunTaskNowTool(context), + { + task_id: created.task.id, + }, + ); + const afterMs = Date.now(); + + expect(result).toMatchObject({ + ok: true, + task: { + id: created.task.id, + status: "active", + next_run_at: "2026-06-01T16:00:00.000Z", + }, + }); + const due = await store.getTask(created.task.id); + expect(due).toMatchObject({ + status: "active", + nextRunAtMs: scheduledNextRunAtMs, + destination: { + teamId: context.teamId, + channelId: context.channelId, + }, + createdBy: { + slackUserId: context.requester?.userId, + }, + }); + expect(due?.statusReason).toBeUndefined(); + expect(due?.runNowAtMs).toBeGreaterThanOrEqual(beforeMs); + expect(due?.runNowAtMs).toBeLessThanOrEqual(afterMs); + + await expect(store.claimDueRun({ nowMs: afterMs })).resolves.toMatchObject({ + taskId: created.task.id, + scheduledForMs: due?.runNowAtMs, + status: "pending", + }); + }); + + it("does not run-now a paused task without an explicit resume", async () => { + const context = createContext(); + const created = (await createTask(context)) as { + task: { id: string }; + }; + const store = createStateSchedulerStore(); + const task = await store.getTask(created.task.id); + expect(task).toBeDefined(); + await store.saveTask({ + ...task!, + status: "paused", + statusReason: "Paused by user.", + updatedAtMs: Date.parse("2026-05-25T16:01:00.000Z"), + version: task!.version + 1, + }); + + const result = await executeTool( + createSlackScheduleRunTaskNowTool(context), + { + task_id: created.task.id, + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: + "Scheduled task must be active before it can be run now. Resume the task first if you want it to run.", + }); + const paused = await store.getTask(created.task.id); + expect(paused).toMatchObject({ + status: "paused", + statusReason: "Paused by user.", + }); + expect(paused?.runNowAtMs).toBeUndefined(); + }); + it("removes deleted tasks from scheduler indexes", async () => { - const context = createContext({ threadTs: "1700000006.000000" }); + const context = createContext(); const created = (await createTask(context)) as { task: { id: string }; }; @@ -362,7 +566,7 @@ describe("Slack schedule tools", () => { }); it("claims due runs idempotently", async () => { - const context = createContext({ threadTs: "1700000007.000000" }); + const context = createContext(); const created = (await createTask(context)) as { task: { id: string }; }; @@ -375,15 +579,14 @@ describe("Slack schedule tools", () => { updatedAtMs: 1000, }); - const first = await store.claimDueRuns({ nowMs: 2000, limit: 10 }); - const second = await store.claimDueRuns({ nowMs: 2000, limit: 10 }); + const first = await store.claimDueRun({ nowMs: 2000 }); + const second = await store.claimDueRun({ nowMs: 2000 }); - expect(first).toHaveLength(1); - expect(first[0]).toMatchObject({ + expect(first).toMatchObject({ taskId: created.task.id, scheduledForMs: 1000, status: "pending", }); - expect(second).toHaveLength(0); + expect(second).toBeUndefined(); }); }); diff --git a/packages/junior/tests/unit/services/plugin-auth-orchestration.test.ts b/packages/junior/tests/unit/services/plugin-auth-orchestration.test.ts index 9d20559c..2876a407 100644 --- a/packages/junior/tests/unit/services/plugin-auth-orchestration.test.ts +++ b/packages/junior/tests/unit/services/plugin-auth-orchestration.test.ts @@ -177,6 +177,30 @@ describe("createPluginAuthOrchestration", () => { expect(abortAgent).not.toHaveBeenCalled(); }); + it("blocks oauth recovery when authorization is disabled and no requester is present", async () => { + const orchestration = createPluginAuthOrchestration( + { + userMessage: "", + authorizationFlowMode: "disabled", + }, + vi.fn(), + ); + + await expect( + orchestration.handleCommandFailure({ + activeSkill: sentrySkill, + command: "sentry issue list", + details: { + exit_code: 1, + stderr: "junior-auth-required provider=sentry", + }, + }), + ).rejects.toBeInstanceOf(AuthorizationFlowDisabledError); + + expect(startOAuthFlow).not.toHaveBeenCalled(); + expect(unlinkProvider).not.toHaveBeenCalled(); + }); + it("unlinks the stored token only after oauth restart is launched", async () => { const order: string[] = []; const userTokenStore = {} as any; diff --git a/packages/junior/tests/unit/slack/tool-registration.test.ts b/packages/junior/tests/unit/slack/tool-registration.test.ts index 916b8686..0f3c9798 100644 --- a/packages/junior/tests/unit/slack/tool-registration.test.ts +++ b/packages/junior/tests/unit/slack/tool-registration.test.ts @@ -31,6 +31,49 @@ describe("Slack tool registration", () => { expect(tools).toHaveProperty("slackCanvasCreate"); }); + it("registers schedule tools only with complete Slack turn context", () => { + const incomplete = createTools([], {}, ctx("C12345")); + const complete = createTools( + [], + {}, + { + ...ctx("C12345"), + teamId: "T123", + requester: { + userId: "U123", + }, + }, + ); + + expect(incomplete).not.toHaveProperty("slackScheduleCreateTask"); + expect(complete).toHaveProperty("slackScheduleCreateTask"); + expect(complete).toHaveProperty("slackScheduleListTasks"); + expect(complete).toHaveProperty("slackScheduleUpdateTask"); + expect(complete).toHaveProperty("slackScheduleDeleteTask"); + expect(complete).toHaveProperty("slackScheduleRunTaskNow"); + }); + + it("does not register schedule tools when explicitly disabled", () => { + const tools = createTools( + [], + {}, + { + ...ctx("C12345"), + teamId: "T123", + requester: { + userId: "U123", + }, + disableScheduleTools: true, + }, + ); + + expect(tools).not.toHaveProperty("slackScheduleCreateTask"); + expect(tools).not.toHaveProperty("slackScheduleListTasks"); + expect(tools).not.toHaveProperty("slackScheduleUpdateTask"); + expect(tools).not.toHaveProperty("slackScheduleDeleteTask"); + expect(tools).not.toHaveProperty("slackScheduleRunTaskNow"); + }); + it("does not register canvas create when channel context is unavailable", () => { const tools = createTools([], {}, ctx()); diff --git a/specs/scheduler-spec.md b/specs/scheduler-spec.md index 2b0d143e..0f715919 100644 --- a/specs/scheduler-spec.md +++ b/specs/scheduler-spec.md @@ -3,10 +3,11 @@ ## Metadata - Created: 2026-05-18 -- Last Edited: 2026-05-18 +- Last Edited: 2026-05-26 ## Changelog +- 2026-05-26: Reframed scheduled execution around system actors: creator is metadata/contact, scheduled runs execute as a system actor, and user-bound auth must not be borrowed implicitly. - 2026-05-18: Clarified V1 calendar model: exact next-run instants plus simple daily/weekly/monthly/yearly recurrence rules. - 2026-05-18: Initial draft contract for scheduled Junior tasks, prompt framing, no-SQL storage, run idempotency, and eval-first verification. @@ -24,7 +25,7 @@ Define the first scheduler contract for Junior: users can create durable tasks t - Prompt envelope used when executing a scheduled task. - Storage and idempotency rules. - Slack authoring and management behavior. -- Verification layer ownership. +- Verification layer responsibilities. ## Non-Goals @@ -46,7 +47,8 @@ The stored task must include: - objective - instructions - expected output -- creator/requester identity +- creator metadata +- execution actor metadata - destination surface - schedule and timezone - current status @@ -56,9 +58,16 @@ The stored task must include: The original user utterance may be retained for audit/debugging, but it must not be the sole execution input. +Slack destinations are conversations, not existing threads. A scheduled task may target the active Slack DM or channel, and scheduled output posts as a new message in that conversation. + +Creator metadata records the user who confirmed the task so Junior can audit changes and privately notify someone when the task needs attention. The creator is not an owner, is not an authorization principal, and is not the actor for future scheduled runs. + +Task management is controlled only by access to the destination conversation window. If a user can post or trigger Junior in that Slack DM or channel context, they can manage scheduled tasks for that same context. The scheduler must not add creator-only, owner-only, workspace-admin-only, or channel-admin-only gates for V1 management. + ### Calendar Model Every active task must have an exact `nextRunAtMs` instant. For one-off tasks, that instant is the complete schedule. +Slack authoring may accept supported relative one-off phrases such as "tomorrow at 9am"; these must be resolved to an exact `nextRunAtMs` before storage. When a user does not provide a timezone, scheduler authoring defaults to `America/Los_Angeles` unless `JUNIOR_TIMEZONE` overrides it. Recurring tasks must also store a small calendar recurrence rule: @@ -74,6 +83,16 @@ V1 recurrence is calendar-based, not fixed-duration. For example, "every Monday The scheduler does not need advanced rules such as first business day, nearest weekday, holiday calendars, or arbitrary cron syntax. +Run-now has a separate contract: + +1. Run-now applies only to active tasks. +2. Run-now must not implicitly resume paused or blocked tasks. +3. Run-now must not rewrite the task's stored calendar schedule. +4. A task may store a separate immediate-run timestamp. +5. When both the immediate-run timestamp and ordinary `nextRunAtMs` are due, the scheduler claims the immediate run first. +6. After the manual run reaches a terminal state, clear the immediate-run timestamp. +7. If the ordinary `nextRunAtMs` was already overdue when the manual run completed, consume that scheduled occurrence and advance recurrence once instead of running the same task twice in one tick. + ### Prompt Framing Every scheduled run must compile the stored task into a marker-delimited prompt before entering the agent runtime. @@ -81,8 +100,8 @@ Every scheduled run must compile the stored task into a marker-delimited prompt The prompt must make these facts explicit: 1. This is an autonomous scheduled run. -2. This is not a request to create, update, pause, delete, or list schedules. -3. The task contract is the source of truth for what to execute. +2. The task contract is the source of truth for what to execute. +3. The run executes as a Junior system actor, not as the user who created the task. 4. The run should complete without asking follow-up questions unless access, approval, or required input is missing. 5. If blocked, the result should identify the missing provider, permission, or input. @@ -106,9 +125,11 @@ The initial implementation may use the Chat SDK state adapter and a global task - `junior:scheduler:tasks` stores task ids for due scans. - `junior:scheduler:team:{team_id}:tasks` stores task ids for workspace management. - `junior:scheduler:run:{run_id}` stores run history. +- `junior:scheduler:active:{task_id}` stores the currently active run marker for task-level overlap prevention. - `junior:scheduler:claim:{task_id}:{scheduled_for_ms}` is the idempotency claim. A future Redis-native store may replace the scan index with a sorted due index without changing the runtime-facing scheduler store interface. +Deployments may set `JUNIOR_SCHEDULER_REDIS_URL` to move scheduler persistence onto a dedicated Redis backend. ### Run Idempotency @@ -118,33 +139,61 @@ Rules: 1. A run idempotency key is `task_id:scheduled_for_ms`. 2. The scheduler must claim that key before dispatch. -3. Duplicate ticks, retries, and overlapping invocations must return the existing run or skip dispatch. +3. Duplicate ticks and retries must not dispatch the same scheduled run more than once. 4. Run side effects must be keyed by the scheduled run id where possible. -5. A task must not overlap with itself by default. If one run is active, a later due time should be skipped, coalesced, or blocked according to the task policy. +5. V1 tasks do not overlap with themselves. If a task already has an active run, later due claims for that same task are not dispatched. +6. Stale pending claims may be reclaimed after the scheduler's stale-claim timeout. + +### Actor And Auth Model + +Scheduled tasks must distinguish these V1 identities: + +- **Creator:** the human who confirmed the task. This is audit and notification metadata only. +- **Conversation manager:** any user who can post or trigger Junior in the destination Slack conversation window. This controls who may list, pause, resume, delete, or run-now the task for that same conversation. +- **Execution actor:** the actor used for the autonomous scheduled run. For scheduled tasks, this is a Junior system actor, not a Slack user. + +Scheduled runs must not pass the creator as the runtime requester or treat the creator as if they were present and acting during the run. Audit and correlation metadata should include both the system execution actor and creator metadata, but auth decisions must use the execution actor. + +V1 scheduled execution has no user requester. User OAuth tokens cannot be used merely because that user created the task. Authorization flows are disabled during scheduled runs, and authorization links must not be posted publicly. If no usable non-user credential exists, Junior must block the run and privately notify the creator when possible. + +Future actor-aware auth may add an explicit credential subject: an account, grant, or service principal whose provider credentials may be used by scheduled tools. Future credential subjects may include: + +- system-owned credentials available to the scheduled-run actor +- an explicitly recorded delegated credential grant in the task contract +- a supported service principal named by the task contract -### Auth Principal +Those future credential subjects must be explicit and separate from creator metadata. Until that support exists, scheduled runs may use only credentials already available to the system execution actor. -Scheduled runs execute as the task creator unless the task contract explicitly names a different supported service principal. +### Implementation Plan -Requester-bound provider credentials, OAuth state, sandbox egress, and audit metadata must use the scheduled task principal. If that principal lacks valid credentials, Junior must block the run and privately notify the creator when possible. Authorization links must not be posted publicly. +1. Introduce a small actor contract shared by runtime, scheduler, and auth boundaries. It should represent user actors, system actors, and future service actors without leaking Slack SDK types. +2. Keep `createdBy` as creator metadata and add an execution actor field to scheduled tasks. New scheduled tasks should default to a system actor such as `scheduled-task`; existing tasks may be read with that default until migrated. +3. Update the scheduled runner to enter the agent runtime with the system actor and no user requester. Creator details may remain in run context and notification metadata, but not in the actor slot. +4. Update auth and credential resolution so V1 scheduled runs cannot use requester-scoped OAuth or start interactive auth flows. Missing non-user credentials should produce a blocked run plus private notification. +5. Update telemetry, tests, and eval fixtures so scheduled execution assertions refer to creator metadata and execution actor separately. ### Slack UX Slack authoring should be confirm-first: 1. User asks Junior to schedule work. -2. Junior drafts the normalized task: title, cadence, timezone, destination, objective, expected output, and next run. +2. Junior drafts the normalized task: title, objective, instructions, expected output, cadence, timezone, destination, and next run. 3. User confirms before the task becomes active. -4. Junior supports list, pause, resume, delete, and run-now commands. +4. Junior creates the task only after confirmation and replies with the task id, destination, schedule, timezone, and next run. +5. Junior supports list, pause, resume, delete, and run-now commands from the destination conversation. Confirmation should show the executable task contract, not only echo the user's text. +Anyone who can post or trigger Junior in the destination Slack conversation window may manage that conversation's scheduled tasks. Creator identity remains audit and notification metadata, but it is not an edit/delete/run-now ownership gate and is not the execution actor. +Task creation must use the current active Slack conversation as the destination. Users cannot create scheduled tasks for a different channel, and cannot create DMs for other users. +List output must be scoped to the active destination conversation and must not reveal tasks from other channels or DMs in the same workspace. +Blocked tasks must appear in list output with their blocked reason. After the missing requirement is fixed, a conversation manager can resume the task or run it now from the same destination conversation. ## Failure Model 1. Tick delivery fails: the task remains due and a later tick may claim it. 2. Duplicate tick delivery: the run claim suppresses duplicate dispatch. 3. Run fails after claim: run record captures failure and retry policy decides whether to re-dispatch. -4. Task credentials are missing: mark the run blocked and keep or pause the task according to policy. +4. Required non-user credentials are missing: mark the run blocked, keep or pause the task according to policy, and privately notify the creator when possible. 5. Prompt framing is ambiguous: evals must catch cases where the model creates/edits a schedule instead of executing the task. ## Observability @@ -157,7 +206,8 @@ Scheduler execution should emit safe task/run metadata only: - task status - run status - destination platform and channel id -- requester Slack user id +- execution actor type and id +- creator Slack user id, when available Logs and spans must not include OAuth tokens, provider credentials, raw authorization URLs, or private tool payloads. @@ -174,9 +224,12 @@ Use evals for model-dependent behavior: Use integration tests for runtime/storage contracts that do not depend on model interpretation: - due claim idempotency -- blocked auth path +- blocked auth path for missing non-user credentials +- scheduled runner passes a system actor rather than the creator as requester +- user OAuth tokens are not used implicitly for scheduled tasks - dispatch to Slack delivery -- pause/delete/list management surfaces +- destination-scoped list output +- conversation-access management for pause, resume, delete, and run-now Use unit tests only for small deterministic helpers when integration or eval coverage would be wasteful. From 54f4e569f948866b5979bb9178dddbdcf85588f3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 26 May 2026 12:08:45 -0700 Subject: [PATCH 13/17] feat(scheduler): Add trusted plugin dispatch heartbeat Add a core-owned heartbeat and signed dispatch callback surface for trusted plugins so scheduled work can be moved toward plugin-owned due-run discovery without exposing routes, Slack clients, Vercel primitives, or raw agent runtime internals. Harden the dispatch state model with idempotent records, bounded fanout, stale recovery, retry limits, plugin-scoped lookups, system-actor execution, and serverless-safe continuation. Keep the existing scheduler tick cron active until the scheduler is fully migrated onto the heartbeat path. Document the plugin heartbeat contract, serverless background-work policy, and interface-design policy, and remove the dedicated scheduler Redis configuration so scheduler state uses the existing state adapter. Co-Authored-By: GPT-5 Codex --- .../content/docs/reference/config-and-env.md | 31 +- packages/junior-plugin-api/src/index.ts | 58 ++ packages/junior/src/app.ts | 10 + .../junior/src/chat/agent-dispatch/context.ts | 155 +++++ .../src/chat/agent-dispatch/heartbeat.ts | 195 ++++++ .../junior/src/chat/agent-dispatch/runner.ts | 438 ++++++++++++ .../junior/src/chat/agent-dispatch/signing.ts | 123 ++++ .../junior/src/chat/agent-dispatch/store.ts | 193 ++++++ .../junior/src/chat/agent-dispatch/types.ts | 63 ++ .../src/chat/agent-dispatch/validation.ts | 58 ++ packages/junior/src/chat/scheduler/store.ts | 40 +- .../junior/src/handlers/agent-dispatch.ts | 28 + .../src/handlers/diagnostics-dashboard.ts | 2 + packages/junior/src/handlers/heartbeat.ts | 45 ++ packages/junior/src/vercel.ts | 4 + .../integration/agent-dispatch-runner.test.ts | 169 +++++ .../tests/integration/heartbeat.test.ts | 216 ++++++ .../integration/slack-schedule-tools.test.ts | 7 +- .../runtime/agent-dispatch-signing.test.ts | 72 ++ .../runtime/agent-dispatch-validation.test.ts | 36 + .../unit/runtime/respond-error-path.test.ts | 2 +- packages/junior/tests/unit/vercel.test.ts | 10 + policies/README.md | 2 + policies/interface-design.md | 21 + policies/serverless-background-work.md | 22 + specs/index.md | 4 +- specs/scheduler-spec.md | 1 - specs/trusted-plugin-heartbeat-spec.md | 652 ++++++++++++++++++ 28 files changed, 2593 insertions(+), 64 deletions(-) create mode 100644 packages/junior/src/chat/agent-dispatch/context.ts create mode 100644 packages/junior/src/chat/agent-dispatch/heartbeat.ts create mode 100644 packages/junior/src/chat/agent-dispatch/runner.ts create mode 100644 packages/junior/src/chat/agent-dispatch/signing.ts create mode 100644 packages/junior/src/chat/agent-dispatch/store.ts create mode 100644 packages/junior/src/chat/agent-dispatch/types.ts create mode 100644 packages/junior/src/chat/agent-dispatch/validation.ts create mode 100644 packages/junior/src/handlers/agent-dispatch.ts create mode 100644 packages/junior/src/handlers/heartbeat.ts create mode 100644 packages/junior/tests/integration/agent-dispatch-runner.test.ts create mode 100644 packages/junior/tests/integration/heartbeat.test.ts create mode 100644 packages/junior/tests/unit/runtime/agent-dispatch-signing.test.ts create mode 100644 packages/junior/tests/unit/runtime/agent-dispatch-validation.test.ts create mode 100644 policies/interface-design.md create mode 100644 policies/serverless-background-work.md create mode 100644 specs/trusted-plugin-heartbeat-spec.md diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index 4ac8730c..9dcd4fa5 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -12,22 +12,21 @@ related: ## Core runtime -| Variable | Required | Purpose | -| ------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. | -| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. | -| `REDIS_URL` | Yes | Queue and runtime state storage. | -| `JUNIOR_SECRET` | Yes | Signs internal timeout-resume callbacks and sandbox egress requester context. | -| `JUNIOR_BOT_NAME` | No | Bot display/config naming. | -| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. | -| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. | -| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. | -| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. | -| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. | -| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for `/api/internal/scheduler/tick`; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for an external scheduler. | -| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring and other timezone-sensitive behavior. Defaults to `America/Los_Angeles`. | -| `JUNIOR_SCHEDULER_REDIS_URL` | No | Redis URL for dedicated scheduler persistence. When set, scheduler state uses this Redis instance instead of the shared runtime state adapter. | -| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. | +| Variable | Required | Purpose | +| ------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. | +| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. | +| `REDIS_URL` | Yes | Queue and runtime state storage. | +| `JUNIOR_SECRET` | Yes | Signs internal timeout-resume and agent-dispatch callbacks, plus sandbox egress requester context. | +| `JUNIOR_BOT_NAME` | No | Bot display/config naming. | +| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. | +| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. | +| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. | +| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. | +| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. | +| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for internal scheduler and heartbeat routes; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for an external scheduler. | +| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring and other timezone-sensitive behavior. Defaults to `America/Los_Angeles`. | +| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. | Generate `JUNIOR_SECRET` with Node, then store the generated value in every environment that runs the same app: diff --git a/packages/junior-plugin-api/src/index.ts b/packages/junior-plugin-api/src/index.ts index 157629de..30162c3b 100644 --- a/packages/junior-plugin-api/src/index.ts +++ b/packages/junior-plugin-api/src/index.ts @@ -58,9 +58,67 @@ export interface BeforeToolExecuteHookContext { }; } +export interface DispatchOptions { + destination: { + platform: "slack"; + teamId: string; + channelId: string; + }; + idempotencyKey: string; + input: string; + metadata?: Record; +} + +export interface DispatchResult { + id: string; + status: "created" | "already_exists"; +} + +export interface Dispatch { + errorMessage?: string; + id: string; + resultMessageTs?: string; + status: + | "pending" + | "running" + | "awaiting_resume" + | "completed" + | "failed" + | "blocked"; +} + +export interface AgentPluginState { + delete(key: string): Promise; + get(key: string): Promise; + set(key: string, value: unknown, ttlMs?: number): Promise; +} + +export interface AgentPluginLogger { + error(message: string, metadata?: Record): void; + info(message: string, metadata?: Record): void; + warn(message: string, metadata?: Record): void; +} + +export interface HeartbeatHookContext { + agent: { + dispatch(options: DispatchOptions): Promise; + get(id: string): Promise; + }; + log: AgentPluginLogger; + nowMs: number; + state: AgentPluginState; +} + +export interface HeartbeatResult { + dispatchCount?: number; +} + export interface AgentPluginHooks { sandboxPrepare?(ctx: SandboxPrepareHookContext): Promise | void; beforeToolExecute?(ctx: BeforeToolExecuteHookContext): Promise | void; + heartbeat?( + ctx: HeartbeatHookContext, + ): Promise | HeartbeatResult | void; } export interface JuniorPluginConfig { diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 43caeb06..3d0d1fd3 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -17,6 +17,8 @@ import type { JuniorPlugin } from "@sentry/junior-plugin-api"; import { GET as diagnosticsGET } from "@/handlers/diagnostics"; import { GET as dashboardGET } from "@/handlers/diagnostics-dashboard"; import { GET as healthGET } from "@/handlers/health"; +import { POST as agentDispatchPOST } from "@/handlers/agent-dispatch"; +import { GET as heartbeatGET } from "@/handlers/heartbeat"; import { GET as mcpOauthCallbackGET } from "@/handlers/mcp-oauth-callback"; import { GET as oauthCallbackGET } from "@/handlers/oauth-callback"; import { GET as schedulerTickGET } from "@/handlers/scheduler-tick"; @@ -244,6 +246,14 @@ export async function createApp(options?: JuniorAppOptions): Promise { return turnResumePOST(c.req.raw, waitUntil); }); + app.post("/api/internal/agent-dispatch", (c) => { + return agentDispatchPOST(c.req.raw, waitUntil); + }); + + app.get("/api/internal/heartbeat", (c) => { + return heartbeatGET(c.req.raw, waitUntil); + }); + app.get("/api/internal/scheduler/tick", (c) => { return schedulerTickGET(c.req.raw, waitUntil); }); diff --git a/packages/junior/src/chat/agent-dispatch/context.ts b/packages/junior/src/chat/agent-dispatch/context.ts new file mode 100644 index 00000000..0d6b7ff8 --- /dev/null +++ b/packages/junior/src/chat/agent-dispatch/context.ts @@ -0,0 +1,155 @@ +import { createHash } from "node:crypto"; +import type { + AgentPluginLogger, + AgentPluginState, + Dispatch, + DispatchOptions, + DispatchResult, +} from "@sentry/junior-plugin-api"; +import { logException, logInfo, logWarn } from "@/chat/logging"; +import { getStateAdapter } from "@/chat/state/adapter"; +import { + createOrGetDispatch, + getPluginDispatchProjection, + isTerminalDispatchStatus, +} from "./store"; +import { scheduleDispatchCallback } from "./signing"; +import type { DispatchRecord } from "./types"; +import { validateDispatchOptions } from "./validation"; + +const MAX_PLUGIN_STATE_KEY_LENGTH = 512; +const MAX_DISPATCHES_PER_HEARTBEAT = 25; + +function hashKeyPart(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 32); +} + +function pluginStateKey(plugin: string, key: string): string { + return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`; +} + +function validatePluginStateKey(key: string): void { + if (!key.trim()) { + throw new Error("Plugin state key is required"); + } + if (key.length > MAX_PLUGIN_STATE_KEY_LENGTH) { + throw new Error("Plugin state key exceeds the maximum length"); + } +} + +function createPluginState(plugin: string): AgentPluginState { + return { + async delete(key) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + await state.delete(pluginStateKey(plugin, key)); + }, + async get(key) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + return (await state.get(pluginStateKey(plugin, key))) ?? undefined; + }, + async set(key, value, ttlMs) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + await state.set(pluginStateKey(plugin, key), value, ttlMs); + }, + }; +} + +function createPluginLogger(plugin: string): AgentPluginLogger { + return { + info(message, metadata) { + logInfo( + "trusted_plugin_heartbeat_info", + {}, + { "app.plugin.name": plugin, ...metadata }, + message, + ); + }, + warn(message, metadata) { + logWarn( + "trusted_plugin_heartbeat_warn", + {}, + { "app.plugin.name": plugin, ...metadata }, + message, + ); + }, + error(message, metadata) { + logException( + new Error(message), + "trusted_plugin_heartbeat_error", + {}, + { "app.plugin.name": plugin, ...metadata }, + message, + ); + }, + }; +} + +function shouldScheduleDispatch( + record: DispatchRecord, + nowMs: number, +): boolean { + if (isTerminalDispatchStatus(record.status)) { + return false; + } + return ( + record.status !== "running" || + typeof record.leaseExpiresAtMs !== "number" || + record.leaseExpiresAtMs <= nowMs + ); +} + +export function createHeartbeatContext(args: { + nowMs: number; + plugin: string; +}): { + agent: { + dispatch(options: DispatchOptions): Promise; + get(id: string): Promise; + }; + log: AgentPluginLogger; + nowMs: number; + state: AgentPluginState; +} { + let dispatchCount = 0; + return { + nowMs: args.nowMs, + state: createPluginState(args.plugin), + log: createPluginLogger(args.plugin), + agent: { + async dispatch(options) { + if (dispatchCount >= MAX_DISPATCHES_PER_HEARTBEAT) { + throw new Error("Plugin heartbeat exceeded the dispatch limit"); + } + dispatchCount += 1; + validateDispatchOptions(options); + const result = await createOrGetDispatch({ + plugin: args.plugin, + options, + nowMs: args.nowMs, + }); + if (shouldScheduleDispatch(result.record, args.nowMs)) { + await scheduleDispatchCallback({ + id: result.record.id, + expectedVersion: result.record.version, + }); + } + return { + id: result.record.id, + status: result.status, + }; + }, + async get(id) { + return await getPluginDispatchProjection({ + plugin: args.plugin, + id, + }); + }, + }, + }; +} diff --git a/packages/junior/src/chat/agent-dispatch/heartbeat.ts b/packages/junior/src/chat/agent-dispatch/heartbeat.ts new file mode 100644 index 00000000..3bbb60c4 --- /dev/null +++ b/packages/junior/src/chat/agent-dispatch/heartbeat.ts @@ -0,0 +1,195 @@ +import { getAgentPlugins } from "@/chat/plugins/agent-hooks"; +import { logException, logInfo } from "@/chat/logging"; +import { createHeartbeatContext } from "./context"; +import { scheduleDispatchCallback } from "./signing"; +import { + getDispatchStorageKey, + getDispatchRecord, + isTerminalDispatchStatus, + listIncompleteDispatchIds, + updateDispatchRecord, + withDispatchLock, +} from "./store"; +import type { DispatchRecord } from "./types"; + +const DEFAULT_RECOVERY_LIMIT = 25; +const DEFAULT_PLUGIN_LIMIT = 25; +const DISPATCH_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const PLUGIN_HEARTBEAT_TIMEOUT_MS = 25_000; + +function isStaleDispatch(args: { + nowMs: number; + record: { + lastCallbackAtMs?: number; + leaseExpiresAtMs?: number; + status: string; + }; +}): boolean { + if (args.record.status === "running") { + return ( + typeof args.record.leaseExpiresAtMs === "number" && + args.record.leaseExpiresAtMs <= args.nowMs + ); + } + if (args.record.status === "awaiting_resume") { + return ( + typeof args.record.leaseExpiresAtMs !== "number" || + args.record.leaseExpiresAtMs <= args.nowMs + ); + } + if (args.record.status === "pending") { + return ( + typeof args.record.lastCallbackAtMs !== "number" || + args.record.lastCallbackAtMs + 60_000 <= args.nowMs + ); + } + return false; +} + +async function failDispatch(args: { + errorMessage: string; + record: DispatchRecord; +}): Promise { + await withDispatchLock(args.record.id, async (state) => { + const current = + (await state.get( + getDispatchStorageKey(args.record.id), + )) ?? args.record; + if (isTerminalDispatchStatus(current.status)) { + return; + } + await updateDispatchRecord(state, { + ...current, + errorMessage: args.errorMessage, + status: "failed", + }); + }); +} + +async function runWithTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`Plugin heartbeat exceeded ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +/** Re-drive stale core dispatches before invoking plugin heartbeat hooks. */ +export async function recoverStaleDispatches(args: { + limit?: number; + nowMs: number; +}): Promise { + const ids = await listIncompleteDispatchIds(); + let recovered = 0; + for (const id of ids) { + if (recovered >= (args.limit ?? DEFAULT_RECOVERY_LIMIT)) { + break; + } + const record = await getDispatchRecord(id); + if (!record || isTerminalDispatchStatus(record.status)) { + continue; + } + try { + if (record.createdAtMs + DISPATCH_MAX_AGE_MS <= args.nowMs) { + await failDispatch({ + record, + errorMessage: "Dispatch expired before completion.", + }); + continue; + } + if (record.attempt >= record.maxAttempts) { + await failDispatch({ + record, + errorMessage: "Dispatch exceeded retry attempts.", + }); + continue; + } + if (!isStaleDispatch({ record, nowMs: args.nowMs })) { + continue; + } + await scheduleDispatchCallback({ + id: record.id, + expectedVersion: record.version, + }); + recovered += 1; + } catch (error) { + logException( + error, + "agent_dispatch_recovery_failed", + { runId: record.id }, + { "app.plugin.name": record.plugin }, + "Agent dispatch recovery failed", + ); + } + } + return recovered; +} + +/** Run trusted plugin heartbeat hooks with bounded per-invocation work. */ +export async function runTrustedPluginHeartbeats(args: { + limit?: number; + nowMs: number; +}): Promise { + let count = 0; + for (const plugin of getAgentPlugins()) { + if (count >= (args.limit ?? DEFAULT_PLUGIN_LIMIT)) { + break; + } + const heartbeat = plugin.hooks?.heartbeat; + if (!heartbeat) { + continue; + } + count += 1; + try { + const result = await runWithTimeout( + Promise.resolve( + heartbeat( + createHeartbeatContext({ + plugin: plugin.name, + nowMs: args.nowMs, + }), + ), + ), + PLUGIN_HEARTBEAT_TIMEOUT_MS, + ); + logInfo( + "trusted_plugin_heartbeat_completed", + {}, + { + "app.plugin.name": plugin.name, + ...(typeof result?.dispatchCount === "number" + ? { "app.dispatch.count": result.dispatchCount } + : {}), + }, + "Trusted plugin heartbeat completed", + ); + } catch (error) { + logException( + error, + "trusted_plugin_heartbeat_failed", + {}, + { "app.plugin.name": plugin.name }, + "Trusted plugin heartbeat failed", + ); + } + } +} + +/** Run the core heartbeat phases. */ +export async function runHeartbeat(args: { nowMs: number }): Promise { + await recoverStaleDispatches({ nowMs: args.nowMs }); + await runTrustedPluginHeartbeats({ nowMs: args.nowMs }); +} diff --git a/packages/junior/src/chat/agent-dispatch/runner.ts b/packages/junior/src/chat/agent-dispatch/runner.ts new file mode 100644 index 00000000..d9cf74c8 --- /dev/null +++ b/packages/junior/src/chat/agent-dispatch/runner.ts @@ -0,0 +1,438 @@ +import { botConfig } from "@/chat/config"; +import { + generateAssistantReply as generateAssistantReplyImpl, + type AssistantReply, +} from "@/chat/respond"; +import { logException } from "@/chat/logging"; +import { + buildConversationContext, + markConversationMessage, + normalizeConversationText, + updateConversationStats, + upsertConversationMessage, +} from "@/chat/services/conversation-memory"; +import { + coerceThreadConversationState, + type ThreadConversationState, +} from "@/chat/state/conversation"; +import { + coerceThreadArtifactsState, + type ThreadArtifactsState, +} from "@/chat/state/artifacts"; +import { + getChannelConfigurationServiceById, + getPersistedThreadState, + mergeArtifactsState, + persistThreadStateById, +} from "@/chat/runtime/thread-state"; +import { getStateAdapter } from "@/chat/state/adapter"; +import { + planSlackReplyPosts, + postSlackApiReplyPosts, +} from "@/chat/slack/reply"; +import { buildSlackReplyFooter } from "@/chat/slack/footer"; +import { finalizeFailedTurnReply } from "@/chat/services/turn-failure-response"; +import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; +import { PluginCredentialFailureError } from "@/chat/services/plugin-auth-orchestration"; +import { canScheduleTurnTimeoutResume } from "@/chat/services/timeout-resume"; +import { isRetryableTurnError } from "@/chat/runtime/turn"; +import { scheduleDispatchCallback } from "./signing"; +import { + getDispatchConversationId, + getDispatchStorageKey, + getDispatchTurnId, + isTerminalDispatchStatus, + updateDispatchRecord, + withDispatchLock, +} from "./store"; +import type { DispatchCallback, DispatchRecord } from "./types"; + +const DISPATCH_SLICE_LEASE_MS = 5 * 60 * 1000; + +export interface AgentDispatchRunnerDeps { + generateAssistantReply?: typeof generateAssistantReplyImpl; + scheduleCallback?: typeof scheduleDispatchCallback; +} + +function getUserMessageId(dispatch: DispatchRecord): string { + return `dispatch:${dispatch.id}:user`; +} + +function getAssistantMessageId(dispatch: DispatchRecord): string { + return `dispatch:${dispatch.id}:assistant`; +} + +function buildDispatchConversationText(dispatch: DispatchRecord): string { + return `[dispatched task] ${dispatch.input}`; +} + +function ensureVisibleDeliveryText(reply: AssistantReply): AssistantReply { + if (reply.text.trim().length > 0 || !reply.files?.length) { + return reply; + } + return { + ...reply, + text: "Generated files are attached.", + }; +} + +function upsertDispatchUserMessage(args: { + conversation: ThreadConversationState; + dispatch: DispatchRecord; + nowMs: number; +}): string { + return upsertConversationMessage(args.conversation, { + id: getUserMessageId(args.dispatch), + role: "user", + text: normalizeConversationText( + buildDispatchConversationText(args.dispatch), + ), + createdAtMs: args.nowMs, + author: { + userName: `system:${args.dispatch.actor.id}`, + isBot: true, + }, + meta: { + explicitMention: true, + }, + }); +} + +async function persistRuntimePatch(args: { + artifacts?: ThreadArtifactsState; + conversation: ThreadConversationState; + sandboxDependencyProfileHash?: string; + sandboxId?: string; + threadId: string; +}): Promise { + await persistThreadStateById(args.threadId, { + artifacts: args.artifacts, + conversation: args.conversation, + sandboxId: args.sandboxId, + sandboxDependencyProfileHash: args.sandboxDependencyProfileHash, + }); +} + +async function markDispatch(args: { + dispatch: DispatchRecord; + errorMessage?: string; + resumeCheckpointVersion?: number; + resultMessageTs?: string; + status: DispatchRecord["status"]; +}): Promise { + return await withDispatchLock(args.dispatch.id, async (state) => { + const current = + (await state.get( + getDispatchStorageKey(args.dispatch.id), + )) ?? args.dispatch; + return await updateDispatchRecord(state, { + ...current, + status: args.status, + ...(args.errorMessage ? { errorMessage: args.errorMessage } : {}), + ...(typeof args.resumeCheckpointVersion === "number" + ? { resumeCheckpointVersion: args.resumeCheckpointVersion } + : {}), + ...(args.resultMessageTs + ? { resultMessageTs: args.resultMessageTs } + : {}), + }); + }); +} + +function canClaimDispatch(record: DispatchRecord, nowMs: number): boolean { + if (isTerminalDispatchStatus(record.status)) { + return false; + } + if (record.attempt >= record.maxAttempts) { + return false; + } + if ( + record.status === "running" && + typeof record.leaseExpiresAtMs === "number" && + record.leaseExpiresAtMs > nowMs + ) { + return false; + } + return true; +} + +/** Run one serverless slice for a core-owned agent dispatch. */ +export async function runAgentDispatchSlice( + callback: DispatchCallback, + deps: AgentDispatchRunnerDeps = {}, +): Promise { + const generateAssistantReply = + deps.generateAssistantReply ?? generateAssistantReplyImpl; + const scheduleCallback = deps.scheduleCallback ?? scheduleDispatchCallback; + const nowMs = Date.now(); + const claimedDispatch = await withDispatchLock(callback.id, async (state) => { + const current = + (await state.get(getDispatchStorageKey(callback.id))) ?? + undefined; + if ( + !current || + !canClaimDispatch(current, nowMs) || + current.version !== callback.expectedVersion + ) { + return undefined; + } + return await updateDispatchRecord(state, { + ...current, + attempt: current.attempt + 1, + lastCallbackAtMs: nowMs, + leaseExpiresAtMs: nowMs + DISPATCH_SLICE_LEASE_MS, + status: "running", + }); + }); + if (!claimedDispatch) { + return; + } + let dispatch = claimedDispatch; + + const conversationId = getDispatchConversationId(dispatch.destination); + const stateAdapter = getStateAdapter(); + await stateAdapter.connect(); + const conversationLock = await stateAdapter.acquireLock( + conversationId, + DISPATCH_SLICE_LEASE_MS, + ); + if (!conversationLock) { + await markDispatch({ + dispatch, + status: "pending", + errorMessage: "Destination conversation is busy", + }); + return; + } + + try { + const persisted = await getPersistedThreadState(conversationId); + const conversation = coerceThreadConversationState(persisted); + const deliveredMessage = conversation.messages.find( + (message) => + message.id === getAssistantMessageId(dispatch) && + message.meta?.replied === true && + typeof message.meta.slackTs === "string", + ); + if (typeof deliveredMessage?.meta?.slackTs === "string") { + await markDispatch({ + dispatch, + status: "completed", + resultMessageTs: deliveredMessage.meta.slackTs, + }); + return; + } + + let artifacts = coerceThreadArtifactsState(persisted); + let sandboxId = + typeof persisted.app_sandbox_id === "string" + ? persisted.app_sandbox_id + : undefined; + let sandboxDependencyProfileHash = + typeof persisted.app_sandbox_dependency_profile_hash === "string" + ? persisted.app_sandbox_dependency_profile_hash + : undefined; + const channelConfiguration = getChannelConfigurationServiceById( + dispatch.destination.channelId, + ); + const configuration = await channelConfiguration.resolveValues(); + const userMessageId = upsertDispatchUserMessage({ + conversation, + dispatch, + nowMs, + }); + const conversationContext = buildConversationContext(conversation, { + excludeMessageId: userMessageId, + }); + + let reply = await generateAssistantReply(dispatch.input, { + authorizationFlowMode: "disabled", + configuration, + channelConfiguration, + conversationContext, + artifactState: artifacts, + piMessages: conversation.piMessages, + correlation: { + conversationId, + threadId: conversationId, + turnId: getDispatchTurnId(dispatch.id), + runId: dispatch.id, + channelId: dispatch.destination.channelId, + teamId: dispatch.destination.teamId, + actorType: dispatch.actor.type, + actorId: dispatch.actor.id, + }, + toolChannelId: dispatch.destination.channelId, + disableScheduleTools: true, + sandbox: { + sandboxId, + sandboxDependencyProfileHash, + }, + onSandboxAcquired: async (sandbox) => { + sandboxId = sandbox.sandboxId; + sandboxDependencyProfileHash = sandbox.sandboxDependencyProfileHash; + await persistRuntimePatch({ + threadId: conversationId, + conversation, + artifacts, + sandboxId, + sandboxDependencyProfileHash, + }); + }, + onArtifactStateUpdated: async (nextArtifacts) => { + artifacts = nextArtifacts; + await persistRuntimePatch({ + threadId: conversationId, + conversation, + artifacts, + sandboxId, + sandboxDependencyProfileHash, + }); + }, + }); + + const failure = + reply.diagnostics.outcome === "success" + ? undefined + : (reply.diagnostics.errorMessage ?? + `Agent turn ended with ${reply.diagnostics.outcome}.`); + if (failure) { + reply = finalizeFailedTurnReply({ + reply, + logException, + context: { + conversationId, + slackThreadId: conversationId, + slackChannelId: dispatch.destination.channelId, + runId: dispatch.id, + actorType: dispatch.actor.type, + actorId: dispatch.actor.id, + assistantUserName: botConfig.userName, + modelId: reply.diagnostics.modelId, + }, + }); + } + + const deliveryReply = ensureVisibleDeliveryText(reply); + const resultMessageTs = await postSlackApiReplyPosts({ + channelId: dispatch.destination.channelId, + posts: planSlackReplyPosts({ reply: deliveryReply }), + footer: buildSlackReplyFooter({ + conversationId, + durationMs: deliveryReply.diagnostics.durationMs, + thinkingLevel: deliveryReply.diagnostics.thinkingLevel, + usage: deliveryReply.diagnostics.usage, + }), + fileUploadFailureMode: "strict", + }); + + markConversationMessage(conversation, userMessageId, { + replied: true, + skippedReason: undefined, + }); + upsertConversationMessage(conversation, { + id: getAssistantMessageId(dispatch), + role: "assistant", + text: normalizeConversationText(deliveryReply.text) || "[empty response]", + createdAtMs: nowMs, + author: { + userName: botConfig.userName, + isBot: true, + }, + meta: { + replied: true, + slackTs: resultMessageTs, + }, + }); + updateConversationStats(conversation); + const nextArtifacts = reply.artifactStatePatch + ? mergeArtifactsState(artifacts, reply.artifactStatePatch) + : artifacts; + await persistRuntimePatch({ + threadId: conversationId, + conversation, + artifacts: nextArtifacts, + sandboxId: reply.sandboxId ?? sandboxId, + sandboxDependencyProfileHash: + reply.sandboxDependencyProfileHash ?? sandboxDependencyProfileHash, + }); + dispatch = await markDispatch({ + dispatch, + status: failure ? "failed" : "completed", + ...(failure ? { errorMessage: failure } : {}), + resultMessageTs, + }); + } catch (error) { + if (error instanceof AuthorizationFlowDisabledError) { + await markDispatch({ + dispatch, + status: "blocked", + errorMessage: `Dispatch requires ${error.provider} authorization.`, + }); + return; + } + if (error instanceof PluginCredentialFailureError) { + await markDispatch({ + dispatch, + status: "blocked", + errorMessage: error.message, + }); + return; + } + if ( + isRetryableTurnError(error, "mcp_auth_resume") || + isRetryableTurnError(error, "plugin_auth_resume") + ) { + await markDispatch({ + dispatch, + status: "blocked", + errorMessage: + "Dispatch requires authorization from an interactive user turn.", + }); + return; + } + if (isRetryableTurnError(error, "turn_timeout_resume")) { + const checkpointVersion = error.metadata?.checkpointVersion; + const nextSliceId = error.metadata?.sliceId; + if ( + typeof checkpointVersion === "number" && + canScheduleTurnTimeoutResume(nextSliceId) + ) { + const awaiting = await markDispatch({ + dispatch, + resumeCheckpointVersion: checkpointVersion, + status: "awaiting_resume", + }); + await scheduleCallback({ + id: awaiting.id, + expectedVersion: awaiting.version, + }); + return; + } + } + + logException( + error, + "agent_dispatch_run_failed", + { + conversationId, + slackThreadId: conversationId, + slackChannelId: dispatch.destination.channelId, + runId: dispatch.id, + actorType: dispatch.actor.type, + actorId: dispatch.actor.id, + assistantUserName: botConfig.userName, + modelId: botConfig.modelId, + }, + {}, + "Agent dispatch failed", + ); + await markDispatch({ + dispatch, + status: "failed", + errorMessage: error instanceof Error ? error.message : String(error), + }); + } finally { + await stateAdapter.releaseLock(conversationLock); + } +} diff --git a/packages/junior/src/chat/agent-dispatch/signing.ts b/packages/junior/src/chat/agent-dispatch/signing.ts new file mode 100644 index 00000000..78a3cc64 --- /dev/null +++ b/packages/junior/src/chat/agent-dispatch/signing.ts @@ -0,0 +1,123 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { resolveBaseUrl } from "@/chat/oauth-flow"; +import type { DispatchCallback } from "./types"; + +const DISPATCH_CALLBACK_PATH = "/api/internal/agent-dispatch"; +const DISPATCH_HMAC_CONTEXT = "junior.agent_dispatch.v1"; +const DISPATCH_SIGNATURE_VERSION = "v1"; +const DISPATCH_MAX_SKEW_MS = 5 * 60 * 1000; +const DISPATCH_CALLBACK_TIMEOUT_MS = 10_000; +const DISPATCH_TIMESTAMP_HEADER = "x-junior-dispatch-timestamp"; +const DISPATCH_SIGNATURE_HEADER = "x-junior-dispatch-signature"; + +function getDispatchSecret(): string | undefined { + return process.env.JUNIOR_SECRET?.trim() || undefined; +} + +function buildSignedPayload(timestamp: string, body: string): string { + return `${DISPATCH_HMAC_CONTEXT}:${timestamp}:${body}`; +} + +function signBody(secret: string, timestamp: string, body: string): string { + const digest = createHmac("sha256", secret) + .update(buildSignedPayload(timestamp, body)) + .digest("hex"); + return `${DISPATCH_SIGNATURE_VERSION}=${digest}`; +} + +function timingSafeMatch(expected: string, actual: string): boolean { + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + if (expectedBuffer.length !== actualBuffer.length) { + return false; + } + return timingSafeEqual(expectedBuffer, actualBuffer); +} + +function parseDispatchCallback(value: unknown): DispatchCallback | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as Record; + if ( + typeof record.id !== "string" || + typeof record.expectedVersion !== "number" + ) { + return undefined; + } + return { + id: record.id, + expectedVersion: record.expectedVersion, + }; +} + +/** Schedule an authenticated internal callback to run a dispatched agent slice. */ +export async function scheduleDispatchCallback( + callback: DispatchCallback, +): Promise { + const baseUrl = resolveBaseUrl(); + if (!baseUrl) { + throw new Error( + "Cannot determine base URL for agent dispatch callback (set JUNIOR_BASE_URL or deploy to Vercel)", + ); + } + + const secret = getDispatchSecret(); + if (!secret) { + throw new Error( + "Cannot determine agent dispatch secret (set JUNIOR_SECRET)", + ); + } + + const body = JSON.stringify(callback); + const timestamp = Date.now().toString(); + const response = await fetch(`${baseUrl}${DISPATCH_CALLBACK_PATH}`, { + method: "POST", + headers: { + "content-type": "application/json", + [DISPATCH_TIMESTAMP_HEADER]: timestamp, + [DISPATCH_SIGNATURE_HEADER]: signBody(secret, timestamp, body), + }, + signal: AbortSignal.timeout(DISPATCH_CALLBACK_TIMEOUT_MS), + body, + }); + if (!response.ok) { + throw new Error( + `Agent dispatch callback failed with status ${response.status}`, + ); + } +} + +/** Verify and parse an authenticated agent dispatch callback request. */ +export async function verifyDispatchCallbackRequest( + request: Request, +): Promise { + const timestamp = + request.headers.get(DISPATCH_TIMESTAMP_HEADER)?.trim() ?? ""; + const signature = + request.headers.get(DISPATCH_SIGNATURE_HEADER)?.trim() ?? ""; + const secret = getDispatchSecret(); + if (!timestamp || !signature || !secret) { + return undefined; + } + + const parsedTimestamp = Number.parseInt(timestamp, 10); + if ( + !Number.isFinite(parsedTimestamp) || + Math.abs(Date.now() - parsedTimestamp) > DISPATCH_MAX_SKEW_MS + ) { + return undefined; + } + + const body = await request.text(); + const expectedSignature = signBody(secret, timestamp, body); + if (!timingSafeMatch(expectedSignature, signature)) { + return undefined; + } + + try { + return parseDispatchCallback(JSON.parse(body)); + } catch { + return undefined; + } +} diff --git a/packages/junior/src/chat/agent-dispatch/store.ts b/packages/junior/src/chat/agent-dispatch/store.ts new file mode 100644 index 00000000..1cf0862d --- /dev/null +++ b/packages/junior/src/chat/agent-dispatch/store.ts @@ -0,0 +1,193 @@ +import { createHash } from "node:crypto"; +import { THREAD_STATE_TTL_MS } from "chat"; +import type { Lock, StateAdapter } from "chat"; +import { getStateAdapter } from "@/chat/state/adapter"; +import type { + DispatchCreateResult, + DispatchOptions, + DispatchProjection, + DispatchRecord, + DispatchStatus, +} from "./types"; + +const DISPATCH_PREFIX = "junior:agent_dispatch"; +const DISPATCH_LOCK_TTL_MS = 10 * 60 * 1000; +const DEFAULT_MAX_ATTEMPTS = 5; + +export function getDispatchStorageKey(id: string): string { + return `${DISPATCH_PREFIX}:record:${id}`; +} + +function incompleteDispatchIndexKey(): string { + return `${DISPATCH_PREFIX}:incomplete`; +} + +function dispatchLockKey(id: string): string { + return `${DISPATCH_PREFIX}:lock:${id}`; +} + +function normalizeMetadata( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) { + return undefined; + } + const entries = Object.entries(metadata).filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && typeof entry[1] === "string", + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +export function buildDispatchId( + plugin: string, + idempotencyKey: string, +): string { + const digest = createHash("sha256") + .update(plugin) + .update("\0") + .update(idempotencyKey) + .digest("hex") + .slice(0, 32); + return `dispatch_${digest}`; +} + +export function getDispatchConversationId( + destination: DispatchRecord["destination"], +): string { + return `slack:${destination.teamId}:${destination.channelId}`; +} + +export function getDispatchTurnId(dispatchId: string): string { + return `dispatch:${dispatchId}`; +} + +export function toDispatchProjection( + record: DispatchRecord, +): DispatchProjection { + return { + id: record.id, + status: record.status, + ...(record.resultMessageTs + ? { resultMessageTs: record.resultMessageTs } + : {}), + ...(record.errorMessage ? { errorMessage: record.errorMessage } : {}), + }; +} + +export function isTerminalDispatchStatus(status: DispatchStatus): boolean { + return status === "completed" || status === "failed" || status === "blocked"; +} + +export async function withDispatchLock( + dispatchId: string, + callback: (state: StateAdapter) => Promise, +): Promise { + const state = getStateAdapter(); + await state.connect(); + const lock: Lock | null = await state.acquireLock( + dispatchLockKey(dispatchId), + DISPATCH_LOCK_TTL_MS, + ); + if (!lock) { + throw new Error(`Could not acquire dispatch lock for ${dispatchId}`); + } + + try { + return await callback(state); + } finally { + await state.releaseLock(lock); + } +} + +async function putRecord( + state: StateAdapter, + record: DispatchRecord, +): Promise { + await state.set( + getDispatchStorageKey(record.id), + record, + THREAD_STATE_TTL_MS, + ); + if (!isTerminalDispatchStatus(record.status)) { + await state.appendToList(incompleteDispatchIndexKey(), record.id, { + maxLength: 10_000, + ttlMs: THREAD_STATE_TTL_MS, + }); + } +} + +export async function getDispatchRecord( + id: string, +): Promise { + const state = getStateAdapter(); + await state.connect(); + return ( + (await state.get(getDispatchStorageKey(id))) ?? undefined + ); +} + +export async function createOrGetDispatch(args: { + nowMs: number; + options: DispatchOptions; + plugin: string; +}): Promise { + const id = buildDispatchId(args.plugin, args.options.idempotencyKey); + return await withDispatchLock(id, async (state) => { + const existing = + (await state.get(getDispatchStorageKey(id))) ?? undefined; + if (existing) { + return { record: existing, status: "already_exists" }; + } + + const metadata = normalizeMetadata(args.options.metadata); + const record: DispatchRecord = { + actor: { type: "system", id: args.plugin }, + attempt: 0, + createdAtMs: args.nowMs, + destination: args.options.destination, + id, + idempotencyKey: args.options.idempotencyKey, + input: args.options.input, + maxAttempts: DEFAULT_MAX_ATTEMPTS, + ...(metadata ? { metadata } : {}), + plugin: args.plugin, + status: "pending", + updatedAtMs: args.nowMs, + version: 1, + }; + await putRecord(state, record); + return { record, status: "created" }; + }); +} + +export async function updateDispatchRecord( + state: StateAdapter, + record: DispatchRecord, +): Promise { + const next = { + ...record, + updatedAtMs: Date.now(), + version: record.version + 1, + }; + await putRecord(state, next); + return next; +} + +export async function listIncompleteDispatchIds(): Promise { + const state = getStateAdapter(); + await state.connect(); + const ids = (await state.getList(incompleteDispatchIndexKey())) ?? []; + return [...new Set(ids.filter((id): id is string => typeof id === "string"))]; +} + +export async function getPluginDispatchProjection(args: { + id: string; + plugin: string; +}): Promise { + const record = await getDispatchRecord(args.id); + if (!record || record.plugin !== args.plugin) { + return undefined; + } + return toDispatchProjection(record); +} diff --git a/packages/junior/src/chat/agent-dispatch/types.ts b/packages/junior/src/chat/agent-dispatch/types.ts new file mode 100644 index 00000000..9b259ea0 --- /dev/null +++ b/packages/junior/src/chat/agent-dispatch/types.ts @@ -0,0 +1,63 @@ +export type DispatchStatus = + | "pending" + | "running" + | "awaiting_resume" + | "completed" + | "failed" + | "blocked"; + +export interface DispatchActor { + type: "system"; + id: string; +} + +export interface DispatchDestination { + platform: "slack"; + teamId: string; + channelId: string; +} + +export interface DispatchOptions { + destination: DispatchDestination; + idempotencyKey: string; + input: string; + metadata?: Record; +} + +export interface DispatchRecord { + actor: DispatchActor; + attempt: number; + createdAtMs: number; + destination: DispatchDestination; + errorMessage?: string; + id: string; + idempotencyKey: string; + input: string; + lastCallbackAtMs?: number; + leaseExpiresAtMs?: number; + maxAttempts: number; + metadata?: Record; + plugin: string; + resultMessageTs?: string; + resumeCheckpointVersion?: number; + status: DispatchStatus; + updatedAtMs: number; + version: number; +} + +export interface DispatchProjection { + errorMessage?: string; + id: string; + resultMessageTs?: string; + status: DispatchStatus; +} + +export interface DispatchCallback { + expectedVersion: number; + id: string; +} + +export interface DispatchCreateResult { + record: DispatchRecord; + status: "created" | "already_exists"; +} diff --git a/packages/junior/src/chat/agent-dispatch/validation.ts b/packages/junior/src/chat/agent-dispatch/validation.ts new file mode 100644 index 00000000..7c344a26 --- /dev/null +++ b/packages/junior/src/chat/agent-dispatch/validation.ts @@ -0,0 +1,58 @@ +import type { DispatchOptions } from "./types"; + +const MAX_DISPATCH_INPUT_LENGTH = 32_000; +const MAX_IDEMPOTENCY_KEY_LENGTH = 512; +const MAX_METADATA_KEYS = 20; +const MAX_METADATA_KEY_LENGTH = 128; +const MAX_METADATA_VALUE_LENGTH = 512; + +function isSlackChannelId(value: string): boolean { + return /^(C|G|D)[A-Z0-9]+$/.test(value); +} + +function isSlackTeamId(value: string): boolean { + return /^T[A-Z0-9]+$/.test(value); +} + +/** Validate plugin-provided dispatch options before core persists them. */ +export function validateDispatchOptions(options: DispatchOptions): void { + if (!options.idempotencyKey.trim()) { + throw new Error("Dispatch idempotencyKey is required"); + } + if (options.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH) { + throw new Error("Dispatch idempotencyKey exceeds the maximum length"); + } + if (options.destination.platform !== "slack") { + throw new Error("Dispatch destination platform must be slack"); + } + if (!isSlackTeamId(options.destination.teamId)) { + throw new Error("Dispatch destination teamId must be a Slack team id"); + } + if (!isSlackChannelId(options.destination.channelId)) { + throw new Error( + "Dispatch destination channelId must be a Slack channel id", + ); + } + if (!options.input.trim()) { + throw new Error("Dispatch input is required"); + } + if (options.input.length > MAX_DISPATCH_INPUT_LENGTH) { + throw new Error("Dispatch input exceeds the maximum length"); + } + const metadata = options.metadata ?? {}; + const entries = Object.entries(metadata); + if (entries.length > MAX_METADATA_KEYS) { + throw new Error("Dispatch metadata has too many keys"); + } + for (const [key, value] of entries) { + if (!key.trim() || typeof value !== "string") { + throw new Error("Dispatch metadata values must be strings"); + } + if (key.length > MAX_METADATA_KEY_LENGTH) { + throw new Error("Dispatch metadata key exceeds the maximum length"); + } + if (value.length > MAX_METADATA_VALUE_LENGTH) { + throw new Error("Dispatch metadata value exceeds the maximum length"); + } + } +} diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts index f85d29d4..d8fcff91 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior/src/chat/scheduler/store.ts @@ -1,4 +1,3 @@ -import { createRedisState } from "@chat-adapter/state-redis"; import type { Lock, StateAdapter } from "chat"; import { getNextRunAtMs } from "@/chat/scheduler/cadence"; import { getStateAdapter } from "@/chat/state/adapter"; @@ -11,8 +10,6 @@ const CLAIM_TTL_MS = 6 * 60 * 60 * 1000; const PENDING_CLAIM_STALE_MS = 60_000; const LOCK_TTL_MS = 10_000; -let schedulerStateAdapter: StateAdapter | undefined; - export interface SchedulerStore { claimDueRun(args: { nowMs: number }): Promise; getRun(runId: string): Promise; @@ -91,28 +88,6 @@ function buildRunId(taskId: string, scheduledForMs: number): string { return `${taskId}:${scheduledForMs}`; } -function shouldUseDedicatedSchedulerState(): boolean { - return Boolean(process.env.JUNIOR_SCHEDULER_REDIS_URL?.trim()); -} - -function createSchedulerStateAdapter(): StateAdapter { - const redisUrl = process.env.JUNIOR_SCHEDULER_REDIS_URL?.trim(); - if (redisUrl) { - return createRedisState({ url: redisUrl }); - } - return getStateAdapter(); -} - -function getSchedulerStateAdapter(): StateAdapter { - if (!shouldUseDedicatedSchedulerState()) { - return getStateAdapter(); - } - if (!schedulerStateAdapter) { - schedulerStateAdapter = createSchedulerStateAdapter(); - } - return schedulerStateAdapter; -} - function unique(values: string[]): string[] { return [...new Set(values.filter(Boolean))]; } @@ -684,20 +659,7 @@ class StateAdapterSchedulerStore implements SchedulerStore { /** Create the production scheduler store backed by Junior's state adapter. */ export function createStateSchedulerStore( - stateAdapter: StateAdapter = getSchedulerStateAdapter(), + stateAdapter: StateAdapter = getStateAdapter(), ): SchedulerStore { return new StateAdapterSchedulerStore(stateAdapter); } - -/** Disconnect the dedicated scheduler state adapter when one is configured. */ -export async function disconnectSchedulerStateAdapter(): Promise { - if (!schedulerStateAdapter) { - return; - } - - try { - await schedulerStateAdapter.disconnect(); - } finally { - schedulerStateAdapter = undefined; - } -} diff --git a/packages/junior/src/handlers/agent-dispatch.ts b/packages/junior/src/handlers/agent-dispatch.ts new file mode 100644 index 00000000..e5768874 --- /dev/null +++ b/packages/junior/src/handlers/agent-dispatch.ts @@ -0,0 +1,28 @@ +import { logException } from "@/chat/logging"; +import { runAgentDispatchSlice } from "@/chat/agent-dispatch/runner"; +import { verifyDispatchCallbackRequest } from "@/chat/agent-dispatch/signing"; +import type { WaitUntilFn } from "@/handlers/types"; + +/** Handle the authenticated internal agent-dispatch callback. */ +export async function POST( + request: Request, + waitUntil: WaitUntilFn, +): Promise { + const payload = await verifyDispatchCallbackRequest(request); + if (!payload) { + return new Response("Unauthorized", { status: 401 }); + } + + waitUntil(() => + runAgentDispatchSlice(payload).catch((error) => { + logException( + error, + "agent_dispatch_handler_failed", + {}, + { "app.dispatch.id": payload.id }, + "Agent dispatch handler failed", + ); + }), + ); + return new Response("Accepted", { status: 202 }); +} diff --git a/packages/junior/src/handlers/diagnostics-dashboard.ts b/packages/junior/src/handlers/diagnostics-dashboard.ts index c7eb6532..d20b3352 100644 --- a/packages/junior/src/handlers/diagnostics-dashboard.ts +++ b/packages/junior/src/handlers/diagnostics-dashboard.ts @@ -126,6 +126,8 @@ export async function GET(): Promise { { method: "GET", path: "/api/info" }, { method: "GET", path: "/api/oauth/callback/mcp/:provider" }, { method: "GET", path: "/api/oauth/callback/:provider" }, + { method: "POST", path: "/api/internal/agent-dispatch" }, + { method: "GET", path: "/api/internal/heartbeat" }, { method: "GET", path: "/api/internal/scheduler/tick" }, { method: "POST", path: "/api/webhooks/:platform" }, ]; diff --git a/packages/junior/src/handlers/heartbeat.ts b/packages/junior/src/handlers/heartbeat.ts new file mode 100644 index 00000000..16150ad5 --- /dev/null +++ b/packages/junior/src/handlers/heartbeat.ts @@ -0,0 +1,45 @@ +import { runHeartbeat } from "@/chat/agent-dispatch/heartbeat"; +import { logException } from "@/chat/logging"; +import type { WaitUntilFn } from "@/handlers/types"; + +function getHeartbeatSecret(): string | undefined { + return ( + process.env.JUNIOR_SCHEDULER_SECRET?.trim() || + process.env.CRON_SECRET?.trim() + ); +} + +function verifyHeartbeatRequest(request: Request): boolean { + const secret = getHeartbeatSecret(); + if (!secret) { + return false; + } + + const authorization = request.headers.get("authorization")?.trim(); + return authorization === `Bearer ${secret}`; +} + +/** Handle the authenticated internal heartbeat. */ +export async function GET( + request: Request, + waitUntil: WaitUntilFn, +): Promise { + if (!verifyHeartbeatRequest(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + const nowMs = Date.now(); + waitUntil(() => + runHeartbeat({ nowMs }).catch((error) => { + logException( + error, + "heartbeat_failed", + {}, + { "app.heartbeat.now_ms": nowMs }, + "Heartbeat failed", + ); + }), + ); + + return new Response("Accepted", { status: 202 }); +} diff --git a/packages/junior/src/vercel.ts b/packages/junior/src/vercel.ts index 5e31ccf6..8b3a6e7c 100644 --- a/packages/junior/src/vercel.ts +++ b/packages/junior/src/vercel.ts @@ -10,6 +10,10 @@ export function juniorVercelConfig(options: JuniorVercelConfigOptions = {}) { const config: Record = { framework: "nitro", crons: [ + { + path: "/api/internal/heartbeat", + schedule: "* * * * *", + }, { path: "/api/internal/scheduler/tick", schedule: "* * * * *", diff --git a/packages/junior/tests/integration/agent-dispatch-runner.test.ts b/packages/junior/tests/integration/agent-dispatch-runner.test.ts new file mode 100644 index 00000000..3f6b808d --- /dev/null +++ b/packages/junior/tests/integration/agent-dispatch-runner.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createOrGetDispatch, + getDispatchRecord, +} from "@/chat/agent-dispatch/store"; +import { runAgentDispatchSlice } from "@/chat/agent-dispatch/runner"; +import { getPersistedThreadState } from "@/chat/runtime/thread-state"; +import { RetryableTurnError } from "@/chat/runtime/turn"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import type { AssistantReply } from "@/chat/respond"; +import { chatPostMessageOk } from "../fixtures/slack/factories/api"; +import { + getCapturedSlackApiCalls, + queueSlackApiResponse, +} from "../msw/handlers/slack-api"; + +vi.hoisted(() => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; +}); + +function createReply(): AssistantReply { + return { + text: "Dispatch delivered.", + deliveryMode: "thread", + deliveryPlan: { + mode: "thread", + postThreadText: true, + attachFiles: "none", + }, + diagnostics: { + assistantMessageCount: 1, + durationMs: 1234, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + }; +} + +describe("agent dispatch runner", () => { + beforeEach(async () => { + await disconnectStateAdapter(); + }); + + afterEach(async () => { + await disconnectStateAdapter(); + }); + + it("runs a system dispatch and persists Slack delivery", async () => { + queueSlackApiResponse("chat.postMessage", { + body: chatPostMessageOk({ + channel: "C123", + ts: "1700000000.000001", + }), + }); + const created = await createOrGetDispatch({ + plugin: "scheduler", + nowMs: Date.parse("2026-05-26T12:00:00.000Z"), + options: { + idempotencyKey: "run-1", + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + }, + input: "Run the scheduled task.", + metadata: { runId: "run-1" }, + }, + }); + const generateAssistantReply = vi.fn(async (_input, context) => { + expect(context.requester).toBeUndefined(); + expect(context.authorizationFlowMode).toBe("disabled"); + expect(context.correlation).toMatchObject({ + conversationId: "slack:T123:C123", + channelId: "C123", + teamId: "T123", + actorType: "system", + actorId: "scheduler", + }); + return createReply(); + }); + + await runAgentDispatchSlice( + { + id: created.record.id, + expectedVersion: created.record.version, + }, + { generateAssistantReply }, + ); + + await expect(getDispatchRecord(created.record.id)).resolves.toMatchObject({ + status: "completed", + resultMessageTs: "1700000000.000001", + }); + expect(getCapturedSlackApiCalls("chat.postMessage")).toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + channel: "C123", + text: "Dispatch delivered.", + }), + }), + ]); + await expect( + getPersistedThreadState("slack:T123:C123"), + ).resolves.toMatchObject({ + conversation: { + messages: expect.arrayContaining([ + expect.objectContaining({ + id: `dispatch:${created.record.id}:user`, + author: expect.objectContaining({ + userName: "system:scheduler", + isBot: true, + }), + }), + expect.objectContaining({ + id: `dispatch:${created.record.id}:assistant`, + meta: expect.objectContaining({ + slackTs: "1700000000.000001", + replied: true, + }), + }), + ]), + }, + }); + }); + + it("persists timeout resume checkpoint state before scheduling the next slice", async () => { + const created = await createOrGetDispatch({ + plugin: "scheduler", + nowMs: Date.parse("2026-05-26T12:00:00.000Z"), + options: { + idempotencyKey: "run-timeout", + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + }, + input: "Run the scheduled task.", + }, + }); + const scheduleCallback = vi.fn(async () => undefined); + const generateAssistantReply = vi.fn(async () => { + throw new RetryableTurnError("turn_timeout_resume", "slice timed out", { + checkpointVersion: 7, + sliceId: 2, + }); + }); + + await runAgentDispatchSlice( + { + id: created.record.id, + expectedVersion: created.record.version, + }, + { generateAssistantReply, scheduleCallback }, + ); + + await expect(getDispatchRecord(created.record.id)).resolves.toMatchObject({ + status: "awaiting_resume", + resumeCheckpointVersion: 7, + }); + expect(scheduleCallback).toHaveBeenCalledWith({ + id: created.record.id, + expectedVersion: expect.any(Number), + }); + }); +}); diff --git a/packages/junior/tests/integration/heartbeat.test.ts b/packages/junior/tests/integration/heartbeat.test.ts new file mode 100644 index 00000000..b376d1a5 --- /dev/null +++ b/packages/junior/tests/integration/heartbeat.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; +import { createHeartbeatContext } from "@/chat/agent-dispatch/context"; +import { recoverStaleDispatches } from "@/chat/agent-dispatch/heartbeat"; +import { + createOrGetDispatch, + getDispatchRecord, + getDispatchStorageKey, + updateDispatchRecord, + withDispatchLock, +} from "@/chat/agent-dispatch/store"; +import type { DispatchRecord } from "@/chat/agent-dispatch/types"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { setAgentPlugins } from "@/chat/plugins/agent-hooks"; +import { GET as heartbeat } from "@/handlers/heartbeat"; +import type { WaitUntilFn } from "@/handlers/types"; + +vi.hoisted(() => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; +}); + +function collectWaitUntil(tasks: Promise[]): WaitUntilFn { + return (task) => { + tasks.push(typeof task === "function" ? task() : task); + }; +} + +describe("trusted plugin heartbeat", () => { + const originalFetch = global.fetch; + + beforeEach(async () => { + process.env.JUNIOR_SCHEDULER_SECRET = "heartbeat-secret"; + process.env.JUNIOR_BASE_URL = "https://junior.example.com"; + process.env.JUNIOR_SECRET = "dispatch-secret"; + setAgentPlugins([]); + await disconnectStateAdapter(); + }); + + afterEach(async () => { + global.fetch = originalFetch; + setAgentPlugins([]); + await disconnectStateAdapter(); + delete process.env.JUNIOR_SCHEDULER_SECRET; + delete process.env.CRON_SECRET; + delete process.env.JUNIOR_BASE_URL; + delete process.env.JUNIOR_SECRET; + vi.restoreAllMocks(); + }); + + it("rejects unauthenticated heartbeat requests", async () => { + const waitUntilTasks: Promise[] = []; + const response = await heartbeat( + new Request("https://example.invalid/api/internal/heartbeat"), + collectWaitUntil(waitUntilTasks), + ); + + expect(response.status).toBe(401); + expect(waitUntilTasks).toHaveLength(0); + }); + + it("runs trusted plugin heartbeat hooks", async () => { + const seen: number[] = []; + setAgentPlugins([ + defineJuniorPlugin({ + name: "scheduler", + hooks: { + heartbeat(ctx) { + seen.push(ctx.nowMs); + }, + }, + }), + ]); + const waitUntilTasks: Promise[] = []; + const response = await heartbeat( + new Request("https://example.invalid/api/internal/heartbeat", { + headers: { authorization: "Bearer heartbeat-secret" }, + }), + collectWaitUntil(waitUntilTasks), + ); + + expect(response.status).toBe(202); + await Promise.all(waitUntilTasks); + expect(seen).toHaveLength(1); + }); + + it("scopes dispatch lookup to the plugin that created it", async () => { + const fetchMock = vi.fn(async () => { + return new Response("Accepted", { status: 202 }); + }); + global.fetch = fetchMock as typeof fetch; + + const schedulerCtx = createHeartbeatContext({ + plugin: "scheduler", + nowMs: Date.parse("2026-05-26T12:00:00.000Z"), + }); + const result = await schedulerCtx.agent.dispatch({ + idempotencyKey: "run-1", + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + }, + input: "Run the scheduled task.", + metadata: { runId: "run-1" }, + }); + + await expect(schedulerCtx.agent.get(result.id)).resolves.toEqual({ + id: result.id, + status: "pending", + }); + await expect( + createHeartbeatContext({ + plugin: "other-plugin", + nowMs: Date.parse("2026-05-26T12:00:00.000Z"), + }).agent.get(result.id), + ).resolves.toBeUndefined(); + + await expect(getDispatchRecord(result.id)).resolves.toMatchObject({ + input: "Run the scheduled task.", + destination: { channelId: "C123" }, + metadata: { runId: "run-1" }, + }); + }); + + it("keeps plugin state isolated when plugin names and keys contain delimiters", async () => { + const first = createHeartbeatContext({ + plugin: "scheduler", + nowMs: Date.parse("2026-05-26T12:00:00.000Z"), + }); + const second = createHeartbeatContext({ + plugin: "scheduler:run", + nowMs: Date.parse("2026-05-26T12:00:00.000Z"), + }); + + await first.state.set("run:1", "first"); + await second.state.set("1", "second"); + + await expect(first.state.get("run:1")).resolves.toBe("first"); + await expect(second.state.get("1")).resolves.toBe("second"); + }); + + it("bounds dispatch fanout from one heartbeat context", async () => { + const fetchMock = vi.fn(async () => { + return new Response("Accepted", { status: 202 }); + }); + global.fetch = fetchMock as typeof fetch; + + const ctx = createHeartbeatContext({ + plugin: "scheduler", + nowMs: Date.parse("2026-05-26T12:00:00.000Z"), + }); + + for (let index = 0; index < 25; index += 1) { + await ctx.agent.dispatch({ + idempotencyKey: `run-${index}`, + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + }, + input: "Run the scheduled task.", + }); + } + + await expect( + ctx.agent.dispatch({ + idempotencyKey: "run-over-limit", + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + }, + input: "Run the scheduled task.", + }), + ).rejects.toThrow("Plugin heartbeat exceeded the dispatch limit"); + }); + + it("fails stale dispatches that exceed retry attempts", async () => { + const created = await createOrGetDispatch({ + plugin: "scheduler", + nowMs: Date.parse("2026-05-26T12:00:00.000Z"), + options: { + idempotencyKey: "run-exhausted", + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + }, + input: "Run the scheduled task.", + }, + }); + await withDispatchLock(created.record.id, async (state) => { + const record = await state.get( + getDispatchStorageKey(created.record.id), + ); + if (!record) { + throw new Error("Expected dispatch record to exist"); + } + await updateDispatchRecord(state, { + ...record, + attempt: record.maxAttempts, + lastCallbackAtMs: Date.parse("2026-05-26T12:00:00.000Z"), + }); + }); + + await expect( + recoverStaleDispatches({ + nowMs: Date.parse("2026-05-26T12:05:00.000Z"), + }), + ).resolves.toBe(0); + await expect(getDispatchRecord(created.record.id)).resolves.toMatchObject({ + status: "failed", + errorMessage: "Dispatch exceeded retry attempts.", + }); + }); +}); diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index 594e6177..8debf03f 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -1,9 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; -import { - createStateSchedulerStore, - disconnectSchedulerStateAdapter, -} from "@/chat/scheduler/store"; +import { createStateSchedulerStore } from "@/chat/scheduler/store"; import { createSlackScheduleCreateTaskTool, createSlackScheduleDeleteTaskTool, @@ -76,8 +73,6 @@ describe("Slack schedule tools", () => { afterEach(async () => { vi.useRealTimers(); delete process.env.JUNIOR_TIMEZONE; - delete process.env.JUNIOR_SCHEDULER_REDIS_URL; - await disconnectSchedulerStateAdapter(); await disconnectStateAdapter(); }); diff --git a/packages/junior/tests/unit/runtime/agent-dispatch-signing.test.ts b/packages/junior/tests/unit/runtime/agent-dispatch-signing.test.ts new file mode 100644 index 00000000..dca196b1 --- /dev/null +++ b/packages/junior/tests/unit/runtime/agent-dispatch-signing.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + scheduleDispatchCallback, + verifyDispatchCallbackRequest, +} from "@/chat/agent-dispatch/signing"; + +describe("agent dispatch callback signing", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + process.env.JUNIOR_BASE_URL = "https://junior.example.com"; + process.env.JUNIOR_SECRET = "dispatch-secret"; + }); + + afterEach(() => { + global.fetch = originalFetch; + delete process.env.JUNIOR_BASE_URL; + delete process.env.JUNIOR_SECRET; + vi.restoreAllMocks(); + }); + + it("signs dispatch callbacks so the handler can verify them", async () => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => { + return new Response("Accepted", { status: 202 }); + }); + global.fetch = fetchMock as typeof fetch; + + await scheduleDispatchCallback({ + id: "dispatch_123", + expectedVersion: 3, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://junior.example.com/api/internal/agent-dispatch"); + + const request = new Request(url, { + method: init.method, + headers: init.headers, + body: init.body, + }); + await expect(verifyDispatchCallbackRequest(request)).resolves.toEqual({ + id: "dispatch_123", + expectedVersion: 3, + }); + }); + + it("rejects callbacks whose signature does not match the body", async () => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => { + return new Response("Accepted", { status: 202 }); + }); + global.fetch = fetchMock as typeof fetch; + + await scheduleDispatchCallback({ + id: "dispatch_123", + expectedVersion: 3, + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = new Headers(init.headers); + headers.set("x-junior-dispatch-signature", "v1=deadbeef"); + const request = new Request(url, { + method: init.method, + headers, + body: init.body, + }); + + await expect( + verifyDispatchCallbackRequest(request), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/junior/tests/unit/runtime/agent-dispatch-validation.test.ts b/packages/junior/tests/unit/runtime/agent-dispatch-validation.test.ts new file mode 100644 index 00000000..c7af3f92 --- /dev/null +++ b/packages/junior/tests/unit/runtime/agent-dispatch-validation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { validateDispatchOptions } from "@/chat/agent-dispatch/validation"; + +const validOptions = { + idempotencyKey: "run-1", + destination: { + platform: "slack" as const, + teamId: "T123", + channelId: "C123", + }, + input: "Run the scheduled task.", +}; + +describe("agent dispatch validation", () => { + it("accepts a valid Slack channel dispatch", () => { + expect(() => validateDispatchOptions(validOptions)).not.toThrow(); + }); + + it("bounds durable idempotency and metadata keys", () => { + expect(() => + validateDispatchOptions({ + ...validOptions, + idempotencyKey: "x".repeat(513), + }), + ).toThrow("Dispatch idempotencyKey exceeds the maximum length"); + + expect(() => + validateDispatchOptions({ + ...validOptions, + metadata: { + ["x".repeat(129)]: "value", + }, + }), + ).toThrow("Dispatch metadata key exceeds the maximum length"); + }); +}); diff --git a/packages/junior/tests/unit/runtime/respond-error-path.test.ts b/packages/junior/tests/unit/runtime/respond-error-path.test.ts index a191153c..c74b6899 100644 --- a/packages/junior/tests/unit/runtime/respond-error-path.test.ts +++ b/packages/junior/tests/unit/runtime/respond-error-path.test.ts @@ -31,5 +31,5 @@ describe("generateAssistantReply error path", () => { expect(reply.diagnostics.outcome).toBe("provider_error"); expect(reply.diagnostics.modelId).toBe("openai/gpt-5.4"); expect(reply.diagnostics.thinkingLevel).toBeUndefined(); - }); + }, 10_000); }); diff --git a/packages/junior/tests/unit/vercel.test.ts b/packages/junior/tests/unit/vercel.test.ts index bbf02d9a..b2990a69 100644 --- a/packages/junior/tests/unit/vercel.test.ts +++ b/packages/junior/tests/unit/vercel.test.ts @@ -7,6 +7,16 @@ describe("juniorVercelConfig", () => { expect(config.framework).toBe("nitro"); expect(config.buildCommand).toBe("pnpm build"); + expect(config.crons).toEqual([ + { + path: "/api/internal/heartbeat", + schedule: "* * * * *", + }, + { + path: "/api/internal/scheduler/tick", + schedule: "* * * * *", + }, + ]); }); it("omits buildCommand when set to null", () => { diff --git a/policies/README.md b/policies/README.md index ae2fb7e0..f949a627 100644 --- a/policies/README.md +++ b/policies/README.md @@ -10,8 +10,10 @@ Good policy topics: - code comments and docstrings - testing expectations - naming conventions +- interface design - migration hygiene - automation safety boundaries +- serverless background work Keep policy docs small: diff --git a/policies/interface-design.md b/policies/interface-design.md new file mode 100644 index 00000000..cd9c3d78 --- /dev/null +++ b/policies/interface-design.md @@ -0,0 +1,21 @@ +# Interface Design + +## Intent + +Interfaces should expose the smallest useful capability while keeping ownership, lifecycle, and security boundaries obvious. + +## Policy + +- Prefer narrow capability methods over broad dependency bags or access to underlying services. +- Expose lifecycle-oriented operations, such as `dispatch` and `get`, instead of raw runners, clients, routes, or storage adapters. +- Return projections by default. Do not expose full internal records when callers only need status, ids, or summaries. +- Make ownership explicit in the API boundary. A caller should only read or mutate records it owns unless cross-owner access is the feature. +- Keep platform details inside the layer that owns the platform. Do not leak Slack clients, Vercel primitives, Redis clients, or model-runtime internals through feature interfaces. +- Require idempotency keys for APIs that create durable work from retryable contexts. +- Use short JavaScript-facing names for public types and methods. Avoid framework-style names that describe implementation mechanics instead of product intent. +- Add an interface only when it removes real coupling or represents a stable boundary. + +## Exceptions + +- Test fixtures may expose narrower construction seams when the production interface remains small. +- Low-level infrastructure modules may expose mechanism-specific APIs inside their own ownership boundary. diff --git a/policies/serverless-background-work.md b/policies/serverless-background-work.md new file mode 100644 index 00000000..82563020 --- /dev/null +++ b/policies/serverless-background-work.md @@ -0,0 +1,22 @@ +# Serverless Background Work + +## Intent + +Background work must survive serverless request boundaries, retries, and process loss without relying on memory or long-lived workers. + +## Policy + +- Persist durable work state before starting background execution. +- Internal callbacks should carry only small signed envelopes, such as ids and expected versions. Store full work payloads in durable state. +- Treat `waitUntil` as a per-request lifetime extension, not a job system. +- Make background work idempotent and retryable. +- Split long work into bounded slices with max attempts, max age, and max continuation depth. +- Define explicit recovery for stale non-terminal states such as `pending`, `running`, and `awaiting_resume`. +- Use durable leases or locks for ownership, and define lock ordering when work touches multiple state domains. +- Do not expose platform-specific background primitives directly to feature code or plugins unless that platform is the feature boundary. +- Stored user-authored instructions remain user content even when executed later by a system actor. + +## Exceptions + +- Purely best-effort telemetry or cache warming may skip durable state when losing the work has no product effect. +- Local development helpers may use in-memory execution when production code still follows the durable path. diff --git a/specs/index.md b/specs/index.md index c05871f5..fa1ea45b 100644 --- a/specs/index.md +++ b/specs/index.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-03 -- Last Edited: 2026-05-13 +- Last Edited: 2026-05-26 ## Changelog @@ -18,6 +18,7 @@ - 2026-05-06: Added draft advisor tool spec. - 2026-05-13: Added ownership map for chat, agent session, and Slack delivery specs. - 2026-05-18: Added draft scheduler spec for scheduled Junior tasks. +- 2026-05-26: Added draft trusted plugin heartbeat spec for scheduler packaging. ## Status @@ -84,6 +85,7 @@ For chat/agent/Slack turn behavior: - `specs/advisor-tool-spec.md` - `specs/scheduler-spec.md` +- `specs/trusted-plugin-heartbeat-spec.md` ## Archive Policy diff --git a/specs/scheduler-spec.md b/specs/scheduler-spec.md index 0f715919..34de9a2b 100644 --- a/specs/scheduler-spec.md +++ b/specs/scheduler-spec.md @@ -129,7 +129,6 @@ The initial implementation may use the Chat SDK state adapter and a global task - `junior:scheduler:claim:{task_id}:{scheduled_for_ms}` is the idempotency claim. A future Redis-native store may replace the scan index with a sorted due index without changing the runtime-facing scheduler store interface. -Deployments may set `JUNIOR_SCHEDULER_REDIS_URL` to move scheduler persistence onto a dedicated Redis backend. ### Run Idempotency diff --git a/specs/trusted-plugin-heartbeat-spec.md b/specs/trusted-plugin-heartbeat-spec.md new file mode 100644 index 00000000..e525509e --- /dev/null +++ b/specs/trusted-plugin-heartbeat-spec.md @@ -0,0 +1,652 @@ +# Trusted Plugin Heartbeat Spec + +## Metadata + +- Created: 2026-05-26 +- Last Edited: 2026-05-26 + +## Changelog + +- 2026-05-26: Clarified heartbeat recovery budgets, dispatch callback path, retention constant, lease semantics, destination shape, and lookup verification. +- 2026-05-26: Defined dispatch lookup retention and scheduler-owned terminal run history. +- 2026-05-26: Added dispatch recovery, result lookup, serverless slice, lock ordering, and system-actor security invariants. +- 2026-05-26: Specified dispatched agent request runner, continuation behavior, and cleaner JavaScript API names. +- 2026-05-26: Initial draft for trusted plugin heartbeat and agent dispatch. + +## Status + +Draft + +## Purpose + +Define the minimal trusted-plugin runtime surface needed to move scheduler behavior out of Junior core without exposing raw routes, platform internals, Slack clients, or agent execution internals to plugins. + +The motivating consumer is a scheduler plugin that lets users create scheduled tasks, then uses a core-owned serverless heartbeat and agent dispatch primitive to execute due work later. + +## Scope + +- Trusted plugin heartbeat hook. +- Core-owned internal heartbeat endpoint. +- Core-owned durable agent dispatch primitive. +- Serverless continuation model for plugin-claimed work. +- Scheduler-as-plugin migration boundary. + +## Non-Goals + +- Manifest-only scheduler plugins. +- Plugin-defined routes. +- Per-plugin heartbeat URLs. +- Plugin-owned Vercel or deployment adapter behavior. +- Generic durable queue infrastructure. +- Arbitrary cron schedules per plugin. +- Raw Slack Web API access from plugins. +- Raw agent runtime or `generateAssistantReply` access from plugins. +- Raw state adapter or Redis access from plugins. +- New interactive tool registration API. + +## Contracts + +### Trust Boundary + +Heartbeat and agent dispatch are trusted plugin capabilities. They are available only to plugins explicitly passed to `createApp({ plugins: [...] })` as trusted runtime plugins. + +Declarative `plugin.yaml` manifests must not register heartbeat handlers, internal routes, or agent dispatch behavior. + +Core owns: + +- route registration +- internal route authentication +- deployment cron configuration +- trusted plugin lookup +- plugin state namespaces +- serverless continuation callbacks +- agent execution +- Slack delivery +- auth mode enforcement +- logging and redaction + +Plugins own only their domain logic: tools, heartbeat work discovery, durable plugin state records, and the inputs they ask core to dispatch. + +### Interactive Tool Registration + +This spec does not introduce a new interactive tool registration hook. The +heartbeat/dispatch substrate is intentionally separate from how scheduler +management tools are exposed during an interactive turn. + +When scheduler management moves behind a plugin boundary, it must use a narrow +tool-registration surface that preserves the existing tool pipeline: schema +validation, tool guidance, tracing, and plugin `beforeToolExecute` hooks. That +surface is a separate contract from `heartbeat(ctx)` and `ctx.agent.dispatch`. + +### Core Heartbeat Endpoint + +Core exposes one internal heartbeat endpoint: + +```txt +GET /api/internal/heartbeat +``` + +The endpoint is core-owned and deployment-owned. Plugins must not register heartbeat routes, choose heartbeat URLs, or receive the raw `Request`. + +Core responsibilities: + +1. Verify the request with the configured internal heartbeat secret. +2. Re-drive stale core dispatches within a bounded core recovery budget. +3. Enumerate trusted plugin heartbeat handlers. +4. Invoke handlers with a bounded `HeartbeatContext`. +5. Enforce a small per-handler and total plugin heartbeat budget. +6. Log core recovery and per-plugin outcomes. +7. Return a generic response that does not expose installed plugin details unnecessarily. + +V1 uses one platform cron entry for this endpoint. The endpoint is a pulse, not a job runner. + +### Heartbeat Hook + +Trusted plugins may implement: + +```ts +interface TrustedPluginHooks { + heartbeat?(ctx: HeartbeatContext): Promise; +} +``` + +Heartbeat semantics: + +- Serverless-triggered. +- Best effort. +- May run late. +- May be skipped. +- May run concurrently with another heartbeat invocation. +- May run more than once for the same wall-clock minute. +- Must be idempotent. +- Must process bounded work. +- Must persist progress in durable state. +- Must not rely on memory, timers, or process lifetime. + +Core does not guarantee every heartbeat handler runs on every pulse. Durable state and idempotent claiming are the reliability boundary. + +### Heartbeat Context + +`HeartbeatContext` should stay minimal: + +```ts +interface HeartbeatContext { + nowMs: number; + state: NamespacedState; + agent: { + get(id: string): Promise; + dispatch(options: DispatchOptions): Promise; + }; + log: PluginLogger; +} +``` + +Do not expose `waitUntil` to trusted plugins in V1. Core may use platform lifetime extension internally, but plugin handlers should be written as bounded request handlers. + +### Agent Dispatch + +Trusted plugins may ask core to fire off an agent request: + +```ts +const result = await ctx.agent.dispatch({ + idempotencyKey: run.id, + destination: { + platform: "slack", + teamId: task.destination.teamId, + channelId: task.destination.channelId, + }, + input: buildScheduledTaskRunPrompt({ task, run, nowMs }), + metadata: { + taskId: task.id, + runId: run.id, + }, +}); +``` + +The argument shape is: + +```ts +type DispatchOptions = { + idempotencyKey: string; + destination: { + platform: "slack"; + teamId: string; + channelId: string; + }; + input: string; + metadata?: Record; +}; +``` + +The return value is: + +```ts +type DispatchResult = { + id: string; + status: "created" | "already_exists"; +}; +``` + +Plugins may read the current state of a dispatch they created: + +```ts +const dispatch = await ctx.agent.get(dispatchId); +``` + +The lookup return value is: + +```ts +type Dispatch = { + id: string; + status: + | "pending" + | "running" + | "awaiting_resume" + | "completed" + | "failed" + | "blocked"; + resultMessageTs?: string; + errorMessage?: string; +}; +``` + +This is the only plugin-facing agent execution API for V1. Plugins do not call `runSystemTurn`, `generateAssistantReply`, Slack runner helpers, thread-state helpers, or delivery helpers. + +If exported types are needed, prefer short JavaScript-facing names like `DispatchOptions`, `DispatchResult`, and `Dispatch`. + +Core derives and enforces: + +- system actor identity from the plugin name +- auth mode from the system actor +- no requester for system actors +- disabled interactive auth for system actors +- conversation state identity from destination +- delivery behavior from destination +- internal callback scheduling +- timeout continuation behavior +- sandbox state persistence +- tool availability policy +- tracing, logging, and redaction + +`idempotencyKey` is required. Calling `agent.dispatch` with the same plugin and idempotency key must not create two dispatch records. + +V1 dispatch constraints: + +- `destination.platform` must be `"slack"`. +- The destination must be a Slack public channel, private channel, or existing DM channel that the bot can post to. +- The destination must not be an existing Slack thread. +- The destination uses a Slack channel id; it must not use or accept a user id. +- The dispatch input is plain text. +- Metadata is for correlation only and must not affect authorization. +- Dispatch input is inserted as user-role synthetic conversation content. +- The core-owned system actor controls execution identity, audit, and auth policy; it does not make `input` privileged system or developer instructions. +- System dispatches have no requester, no user OAuth token access, and no interactive auth continuation. +- Schedule-management tools are unavailable during system dispatches. +- App or bot credential tools may run only when their existing policy allows system actor use. + +### Internal Agent Invocation + +`agent.dispatch` persists a core-owned dispatch record, then fires a signed internal serverless callback. The callback is the execution unit. + +Core exposes one internal dispatch callback endpoint: + +```txt +POST /api/internal/agent-dispatch +``` + +The endpoint is core-owned. Plugins must not register dispatch routes, choose dispatch callback URLs, or receive the raw callback `Request`. + +Core should use the same state/serverless paradigm as existing turn continuation: + +1. Persist dispatch metadata and expected version in durable state. +2. Sign an internal callback using the core internal secret. +3. POST the callback to a core-owned internal endpoint. +4. The endpoint verifies the signature and timestamp. +5. The endpoint loads the durable dispatch record. +6. The endpoint transitions the dispatch under the dispatch lock before running it. +7. The endpoint runs the dispatched agent request and persists the result. + +The callback body should contain only a small core envelope, such as dispatch id and expected version. The prompt, destination, actor, and metadata live in durable state. + +Heartbeat auth and dispatch callback auth are separate: + +- `/api/internal/heartbeat` uses bearer cron auth, using `JUNIOR_SCHEDULER_SECRET` or `CRON_SECRET`. +- Dispatch callbacks use HMAC body signing with timestamp skew checks and `JUNIOR_SECRET`, matching the existing timeout-resume callback model. + +### Dispatch State + +Core dispatch state is separate from plugin state. The scheduler plugin records that a run was dispatched; core records whether the dispatched agent request actually ran and delivered output. + +Plugin state is namespaced by core using collision-resistant internal keys. +Plugin-visible keys must be non-empty and bounded. Plugins do not receive raw +Redis keys, raw state adapter handles, or another plugin's namespace. + +Minimal dispatch record: + +```ts +type DispatchRecord = { + id: string; + plugin: string; + idempotencyKey: string; + status: + | "pending" + | "running" + | "awaiting_resume" + | "completed" + | "failed" + | "blocked"; + version: number; + actor: { + type: "system"; + id: string; + }; + destination: { + platform: "slack"; + teamId: string; + channelId: string; + }; + input: string; + metadata?: Record; + createdAtMs: number; + attempt: number; + maxAttempts: number; + leaseExpiresAtMs?: number; + resumeCheckpointVersion?: number; + lastCallbackAtMs?: number; + updatedAtMs: number; + resultMessageTs?: string; + errorMessage?: string; +}; +``` + +Plugin-visible `Dispatch` is a projection of this record, not the full stored value. + +The dispatch id should be deterministic from plugin name and idempotency key. Duplicate `dispatch(...)` calls return the existing dispatch id and may re-fire the internal callback only when the existing record is incomplete. + +`ctx.agent.get(id)` returns only dispatches owned by the calling trusted plugin. It does not expose prompt text, destination details, actor details, metadata, conversation state, tool calls, model messages, logs, or credentials. + +Dispatch records use `THREAD_STATE_TTL_MS`, the same retention window as thread/checkpoint state. `ctx.agent.get(id)` is a short-to-medium-term reconciliation API, not permanent run history. After the retention window expires, `ctx.agent.get(id)` returns `undefined`. + +The scheduler plugin owns durable task and run history in its namespaced state. When it observes a terminal dispatch through `ctx.agent.get(id)`, it copies the terminal status, result timestamp, and error summary onto the scheduler run record. The scheduler must not depend on core dispatch records remaining readable forever. + +### Dispatch Recovery + +Core owns recovery for incomplete dispatches. Plugins do not need to understand callback delivery or platform lifetime failures. + +The heartbeat endpoint performs two bounded phases: + +1. Re-drive stale core dispatches within a bounded core recovery budget. +2. Invoke trusted plugin `heartbeat(ctx)` handlers within a separate bounded plugin budget. + +Core recovery must not starve when plugin heartbeat handlers are slow or failing. Plugin heartbeat work must not starve because core recovery found a large backlog; unfinished recovery remains durable for a later heartbeat. + +Core may re-fire a signed dispatch callback when a dispatch is incomplete and stale: + +- `pending` with no recent callback attempt +- `running` with an expired lease +- `awaiting_resume` with an expired lease or missing callback attempt + +Core must not re-fire terminal dispatches: + +- `completed` +- `failed` +- `blocked` + +Recovery is bounded by attempt count, max dispatch age, max continuation slices, and the dispatch retention window. A dispatch that exceeds retry bounds is marked `failed`. A dispatch that ages out of retained core state is no longer recoverable by core. + +### Serverless Slice Model + +Each dispatch callback owns one bounded execution slice. + +Callback route behavior: + +1. Verify HMAC signature and timestamp. +2. Parse the small callback envelope. +3. Register the dispatch work with platform `waitUntil`. +4. Return `202 Accepted`. + +Slice behavior: + +1. Load and claim the dispatch. +2. Run one generation and delivery attempt. +3. If the agent times out at a resumable boundary, persist the checkpoint, mark the dispatch `awaiting_resume`, and schedule another signed dispatch callback. +4. If the dispatch reaches the slice cap, mark it `failed`. + +The route must not rely on process memory, timers, or a long-lived worker after the platform request lifetime ends. The only in-process lifetime extension is the platform `waitUntil` task for the current callback. + +### Locking And State Transitions + +Dispatch mutation uses locks available from the existing state adapter. The implementation must not require a general compare-and-set primitive. + +Lock classes: + +- `dispatch:` protects dispatch status, version, attempts, and leases. +- destination conversation lock protects conversation, artifact, sandbox, and delivery state. + +Lock order is always: + +1. dispatch lock +2. destination conversation lock + +Code must not acquire those locks in the reverse order. Stale recovery uses durable status, version, attempt, and lease fields rather than process memory. + +Dispatch leases are not renewed during a slice in V1. The lease duration must exceed the maximum callback slice budget plus platform scheduling slack. A retry may claim an expired lease only after verifying the dispatch is still non-terminal. + +### Dispatched Agent Runner + +The internal callback runs a core-owned dispatched agent runner. This runner is the durable execution boundary for `ctx.agent.dispatch`. + +The runner owns: + +- loading the dispatch record +- acquiring the destination conversation lock +- loading persisted conversation, artifact, sandbox, and channel configuration state +- creating or reusing the synthetic system-authored conversation message for the dispatch +- building conversation context +- calling `generateAssistantReply` +- delivering the reply to the destination +- committing conversation, artifact, sandbox, and dispatch state +- marking auth-required runs as blocked +- scheduling continuation when the agent times out at a resumable boundary + +Plugins never see this runner or its dependencies. + +The runner should generalize the current scheduled Slack runner behavior instead of exposing that runner as plugin API. It should keep the same delivery success rule: a dispatch is not complete until the visible destination post has been accepted and completion state has been persisted. + +### Delivery Idempotency + +Dispatch callbacks are at-least-once. Visible delivery should be best-effort exactly once. + +The runner must use stable synthetic message ids: + +- `dispatch:${dispatch.id}:user` +- `dispatch:${dispatch.id}:assistant` + +Before posting, the runner checks persisted conversation state for the assistant message id. If it already has `meta.replied === true` and `meta.slackTs`, the runner marks the dispatch `completed` with that Slack timestamp and does not post again. + +Slack post and state commit are not atomic. If Slack accepts the post but persisting completion state fails, the dispatch is marked failed when possible with a delivery-commit error. A retry must check persisted conversation state before posting again, but the system only guarantees best-effort duplicate suppression for this post-then-commit failure window. + +### Dispatch Continuation + +Dispatched agent requests must not use the existing Slack turn-resume route directly. The current turn-resume path reconstructs an interactive Slack thread turn and requires a persisted user-authored message. System dispatches have no requester and target a DM or channel, not an existing thread. + +Timeout continuation for dispatched requests uses the dispatch callback path: + +1. `generateAssistantReply` persists a resumable turn checkpoint for the dispatch conversation and turn id. +2. The runner catches `turn_timeout_resume`. +3. The runner marks the dispatch `awaiting_resume` with the next checkpoint version. +4. The runner signs and posts another dispatch callback for the same dispatch id. +5. The next callback verifies the dispatch is still `awaiting_resume` at the expected version. +6. The runner resumes `generateAssistantReply` with the same dispatch input, conversation id, turn id, actor, destination, and persisted Pi messages. +7. The final callback delivers once, commits final state, and marks the dispatch `completed`, `failed`, or `blocked`. + +This keeps scheduled invocations aligned with the existing serverless execution model without treating them as interactive Slack turns. + +Dispatch continuation invariants: + +1. A dispatch has one stable conversation id and one stable turn id. +2. The turn id is derived from the dispatch id. +3. Duplicate callbacks must not run the same dispatch concurrently. +4. Duplicate callbacks must not deliver the same assistant output twice. +5. Timeout continuation must preserve cumulative usage and duration through the existing turn checkpoint state. +6. Auth continuation is disabled for system actors; auth-required outcomes become blocked results. + +### Dispatch Limits + +Core enforces reliability limits even for trusted plugin code: + +- maximum dispatch calls per heartbeat context +- maximum dispatch input length +- maximum metadata keys and bytes +- maximum concurrent dispatches per destination +- maximum retry attempts +- maximum dispatch age +- maximum continuation slices + +### Scheduler Plugin Flow + +The scheduler plugin uses this trusted hook for background work: + +1. `heartbeat(ctx)` for due-run discovery and dispatch. + +Interactive schedule management tools are a separate migration concern and must +continue deriving their destination from the active conversation context. + +Heartbeat flow: + +1. Load due tasks from the scheduler plugin's namespaced state. +2. Reconcile previously dispatched runs with `ctx.agent.get(dispatchId)`. +3. Claim up to a small limit of due runs. +4. Mark each claimed run as pending dispatch. +5. Call `ctx.agent.dispatch(...)` once per claimed run. +6. Store the returned dispatch id on the run record. +7. Leave remaining due work for a future heartbeat. + +The scheduler heartbeat must not execute scheduled tasks inline. It only claims and dispatches bounded work. + +If `ctx.agent.get(dispatchId)` returns `undefined` for a non-terminal scheduler run, the scheduler treats the core dispatch record as expired or missing. The scheduler may mark the run failed with an expiration error, or reclaim and redispatch only when its own run policy says that is safe. The scheduler must eventually transition the run to a terminal state or create a new redispatch attempt; it must not leave the original run non-terminal forever after core dispatch state expires. + +Dispatch call for a scheduled run: + +```ts +await ctx.agent.dispatch({ + idempotencyKey: run.id, + destination: task.destination, + input: buildScheduledTaskRunPrompt({ task, run, nowMs }), + metadata: { + taskId: task.id, + runId: run.id, + }, +}); +``` + +### Scheduler Run State + +The scheduler plugin should make dispatch state explicit enough to recover from partial failures: + +- due task +- claimed run +- pending dispatch +- dispatched +- running +- completed +- failed +- blocked +- skipped + +Required invariants: + +1. Heartbeat claims a due run before dispatch. +2. Dispatch success records the core dispatch id. +3. Duplicate dispatch attempts use the same idempotency key. +4. Duplicate internal callbacks do not execute the same run twice. +5. Stale pending-dispatch records are reclaimable by a later heartbeat. +6. Stale running records are reclaimable according to scheduler policy. +7. Scheduler tools derive destination from the active conversation context. +8. Users cannot create scheduled DMs for other users. +9. Existing Slack threads are never stored as task destinations. + +### Core Capability Boundaries + +Core must not expose these to plugins: + +- raw Slack tokens +- Slack Web API clients +- raw HTTP requests for internal routes +- route registration +- Vercel config mutation +- raw Redis clients +- unrestricted state adapter access +- unrestricted agent runtime functions +- user OAuth tokens for system actor dispatches + +Core may expose narrow capabilities: + +- namespaced state +- plugin logger +- `agent.dispatch` +- `agent.get` + +## Failure Model + +### Heartbeat Missed Or Late + +No correctness failure. The next heartbeat can claim still-due work from durable state. + +### Duplicate Heartbeat + +Plugin state claiming and `agent.dispatch` idempotency suppress duplicate execution. + +### Heartbeat Budget Exhausted + +Core stops invoking additional handlers or the current handler times out. Plugins must leave unfinished work in durable state for a later heartbeat. + +### Dispatch Call Fails + +The plugin keeps the run in pending-dispatch or claimed state without a dispatch id. A later heartbeat may reclaim and retry dispatch after a stale timeout. + +### Dispatch Succeeds But Callback Does Not Complete + +The core dispatch record remains durable. A later heartbeat or future continuation mechanism may observe the incomplete dispatch and decide whether to retry according to core dispatch policy. + +### Dispatch Blocks For Auth + +System actor dispatches must not start interactive auth. Core returns or persists a blocked result. The scheduler plugin marks the scheduled run blocked and privately notifies the creator when possible through core-owned delivery behavior. + +### Plugin Throws + +Core logs the plugin heartbeat/tool error with plugin name and safe metadata. One plugin failure must not expose secrets or raw payloads, and must not grant that plugin broader capabilities. + +## Observability + +Core heartbeat logs should include: + +- heartbeat invocation id +- trusted plugin name +- handler kind +- duration +- outcome +- dispatch count, when reported +- error class/message, when safe + +Agent dispatch logs should include: + +- dispatch id +- plugin name +- idempotency key +- actor type and id +- destination platform and conversation id +- plugin metadata keys safe for logs +- outcome + +Dispatch recovery logs should include: + +- stale dispatch re-driven by heartbeat +- dispatch retry bound exceeded +- dispatch expired before completion +- `ctx.agent.get(id)` miss for missing or expired dispatch state + +Logs and spans must not include OAuth tokens, provider credentials, raw authorization URLs, Slack tokens, or private tool payloads. + +## Verification + +Use integration tests for: + +- heartbeat endpoint authentication +- trusted plugin heartbeat invocation +- heartbeat best-effort isolation when one plugin fails +- namespaced state access +- `agent.dispatch` idempotency +- `agent.get` returns the caller plugin's dispatch projection +- `agent.get` does not return another plugin's dispatch +- `agent.get` returns `undefined` after dispatch retention expiry +- `agent.get` omits prompt, destination, actor, metadata, conversation state, tool calls, model messages, logs, and credentials +- internal callback signature verification +- scheduler heartbeat claims due runs but does not execute inline +- scheduler heartbeat dispatches one request per claimed run +- duplicate heartbeat does not duplicate dispatch records +- stale pending-dispatch run is reclaimable +- stale core dispatch recovery is bounded separately from plugin heartbeat work +- expired or missing dispatch lookup forces scheduler terminal reconciliation or redispatch +- system actor dispatch does not use requester OAuth or interactive auth + +Use unit tests for: + +- scheduler due-run claim state transitions +- agent dispatch input validation +- plugin name/id validation +- internal callback signing and parsing + +Use evals for: + +- interactive schedule creation behavior +- confirmation-first schedule authoring +- scheduled-run prompt execution behavior + +## Related Specs + +- `./plugin-spec.md` +- `./scheduler-spec.md` +- `./agent-session-resumability-spec.md` +- `./chat-architecture-spec.md` +- `./slack-agent-delivery-spec.md` From 9847670650e5eda968b8fdb36dbfe154da43f0c7 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 26 May 2026 12:47:16 -0700 Subject: [PATCH 14/17] feat(scheduler): Move scheduler tools into trusted plugin Register Junior's scheduler as a built-in trusted plugin so schedule management tools are exposed through the plugin boundary instead of direct core tool registration. Wire the scheduler heartbeat to claim due runs, dispatch them through the core agent dispatch API, and reconcile terminal dispatch projections back into scheduler state. Co-Authored-By: GPT-5 Codex --- packages/junior-plugin-api/src/index.ts | 38 +++ packages/junior/src/app.ts | 8 +- .../junior/src/chat/agent-dispatch/context.ts | 44 +--- .../junior/src/chat/plugins/agent-hooks.ts | 43 ++++ packages/junior/src/chat/plugins/state.ts | 46 ++++ packages/junior/src/chat/scheduler/plugin.ts | 225 ++++++++++++++++++ packages/junior/src/chat/scheduler/store.ts | 44 ++++ packages/junior/src/chat/scheduler/types.ts | 1 + packages/junior/src/chat/tools/index.ts | 28 +-- .../tests/integration/heartbeat.test.ts | 93 ++++++++ packages/junior/tests/unit/app-config.test.ts | 17 +- .../tests/unit/plugins/agent-hooks.test.ts | 109 +++++++++ .../unit/slack/tool-registration.test.ts | 12 +- specs/trusted-plugin-heartbeat-spec.md | 44 ++-- 14 files changed, 669 insertions(+), 83 deletions(-) create mode 100644 packages/junior/src/chat/plugins/state.ts create mode 100644 packages/junior/src/chat/scheduler/plugin.ts diff --git a/packages/junior-plugin-api/src/index.ts b/packages/junior-plugin-api/src/index.ts index 30162c3b..ae7ce461 100644 --- a/packages/junior-plugin-api/src/index.ts +++ b/packages/junior-plugin-api/src/index.ts @@ -58,6 +58,41 @@ export interface BeforeToolExecuteHookContext { }; } +export type AgentPluginToolExecute = { + bivarianceHack( + input: TInput, + options: { experimental_context?: unknown }, + ): Promise | unknown; +}["bivarianceHack"]; + +export interface AgentPluginToolDefinition { + annotations?: unknown; + description: string; + executionMode?: unknown; + inputSchema: unknown; + prepareArguments?: (args: unknown) => unknown; + promptGuidelines?: string[]; + promptSnippet?: string; + execute?: AgentPluginToolExecute; +} + +export interface ToolRegistrationHookContext { + channelCapabilities?: { + canAddReactions: boolean; + canCreateCanvas: boolean; + canPostToChannel: boolean; + }; + channelId?: string; + disableScheduleTools?: boolean; + messageTs?: string; + plugin: AgentPluginMetadata; + requester?: AgentPluginRequester; + state: AgentPluginState; + teamId?: string; + threadTs?: string; + userText?: string; +} + export interface DispatchOptions { destination: { platform: "slack"; @@ -116,6 +151,9 @@ export interface HeartbeatResult { export interface AgentPluginHooks { sandboxPrepare?(ctx: SandboxPrepareHookContext): Promise | void; beforeToolExecute?(ctx: BeforeToolExecuteHookContext): Promise | void; + tools?( + ctx: ToolRegistrationHookContext, + ): Record; heartbeat?( ctx: HeartbeatHookContext, ): Promise | HeartbeatResult | void; diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 3d0d1fd3..858c807e 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -12,6 +12,7 @@ import { setAgentPlugins, validateAgentPlugins, } from "@/chat/plugins/agent-hooks"; +import { createSchedulerPlugin } from "@/chat/scheduler/plugin"; import type { PluginConfig } from "@/chat/plugins/types"; import type { JuniorPlugin } from "@sentry/junior-plugin-api"; import { GET as diagnosticsGET } from "@/handlers/diagnostics"; @@ -179,9 +180,10 @@ function pluginConfigFromAgentPlugins( /** Create a Hono app with all Junior routes. */ export async function createApp(options?: JuniorAppOptions): Promise { const configuredPlugins = options?.plugins; - const agentPlugins = isJuniorPluginArray(configuredPlugins) - ? configuredPlugins - : []; + const agentPlugins = [ + createSchedulerPlugin(), + ...(isJuniorPluginArray(configuredPlugins) ? configuredPlugins : []), + ]; const pluginConfig = isJuniorPluginArray(configuredPlugins) ? mergePluginConfig( await resolveVirtualPluginConfig(), diff --git a/packages/junior/src/chat/agent-dispatch/context.ts b/packages/junior/src/chat/agent-dispatch/context.ts index 0d6b7ff8..3fbb5308 100644 --- a/packages/junior/src/chat/agent-dispatch/context.ts +++ b/packages/junior/src/chat/agent-dispatch/context.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import type { AgentPluginLogger, AgentPluginState, @@ -7,7 +6,7 @@ import type { DispatchResult, } from "@sentry/junior-plugin-api"; import { logException, logInfo, logWarn } from "@/chat/logging"; -import { getStateAdapter } from "@/chat/state/adapter"; +import { createPluginState } from "@/chat/plugins/state"; import { createOrGetDispatch, getPluginDispatchProjection, @@ -17,49 +16,8 @@ import { scheduleDispatchCallback } from "./signing"; import type { DispatchRecord } from "./types"; import { validateDispatchOptions } from "./validation"; -const MAX_PLUGIN_STATE_KEY_LENGTH = 512; const MAX_DISPATCHES_PER_HEARTBEAT = 25; -function hashKeyPart(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, 32); -} - -function pluginStateKey(plugin: string, key: string): string { - return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`; -} - -function validatePluginStateKey(key: string): void { - if (!key.trim()) { - throw new Error("Plugin state key is required"); - } - if (key.length > MAX_PLUGIN_STATE_KEY_LENGTH) { - throw new Error("Plugin state key exceeds the maximum length"); - } -} - -function createPluginState(plugin: string): AgentPluginState { - return { - async delete(key) { - validatePluginStateKey(key); - const state = getStateAdapter(); - await state.connect(); - await state.delete(pluginStateKey(plugin, key)); - }, - async get(key) { - validatePluginStateKey(key); - const state = getStateAdapter(); - await state.connect(); - return (await state.get(pluginStateKey(plugin, key))) ?? undefined; - }, - async set(key, value, ttlMs) { - validatePluginStateKey(key); - const state = getStateAdapter(); - await state.connect(); - await state.set(pluginStateKey(plugin, key), value, ttlMs); - }, - }; -} - function createPluginLogger(plugin: string): AgentPluginLogger { return { info(message, metadata) { diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index ee16465c..1424f8c3 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -4,7 +4,10 @@ import type { JuniorPlugin, } from "@sentry/junior-plugin-api"; import { logInfo } from "@/chat/logging"; +import { createPluginState } from "@/chat/plugins/state"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; +import type { ToolDefinition } from "@/chat/tools/definition"; +import type { ToolRuntimeContext } from "@/chat/tools/types"; import type { SandboxCommandInput, SandboxInstance, @@ -35,6 +38,7 @@ export interface AgentPluginHookRunner { let agentPlugins: JuniorPlugin[] = []; const AGENT_PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/; +const AGENT_PLUGIN_TOOL_NAME_RE = /^[a-z][A-Za-z0-9]*$/; /** Validate trusted plugin identity before it can affect process-wide hooks. */ export function validateAgentPlugins(plugins: JuniorPlugin[]): void { @@ -67,6 +71,45 @@ export function getAgentPlugins(): JuniorPlugin[] { return [...agentPlugins]; } +/** Collect turn-scoped tools exposed by trusted plugins. */ +export function getAgentPluginTools( + context: ToolRuntimeContext, +): Record> { + const tools: Record> = {}; + for (const plugin of getAgentPlugins()) { + const hook = plugin.hooks?.tools; + if (!hook) { + continue; + } + const pluginTools = hook({ + plugin: { name: plugin.name }, + requester: context.requester, + channelCapabilities: context.channelCapabilities, + channelId: context.channelId, + disableScheduleTools: context.disableScheduleTools, + teamId: context.teamId, + messageTs: context.messageTs, + threadTs: context.threadTs, + userText: context.userText, + state: createPluginState(plugin.name), + }); + for (const [name, tool] of Object.entries(pluginTools)) { + if (!AGENT_PLUGIN_TOOL_NAME_RE.test(name)) { + throw new Error( + `Trusted plugin tool "${name}" from plugin "${plugin.name}" must be a camelCase identifier`, + ); + } + if (tools[name]) { + throw new Error( + `Duplicate trusted plugin tool "${name}" from plugin "${plugin.name}"`, + ); + } + tools[name] = tool as unknown as ToolDefinition; + } + } + return tools; +} + function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } diff --git a/packages/junior/src/chat/plugins/state.ts b/packages/junior/src/chat/plugins/state.ts new file mode 100644 index 00000000..f4c415f2 --- /dev/null +++ b/packages/junior/src/chat/plugins/state.ts @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto"; +import type { AgentPluginState } from "@sentry/junior-plugin-api"; +import { getStateAdapter } from "@/chat/state/adapter"; + +const MAX_PLUGIN_STATE_KEY_LENGTH = 512; + +function hashKeyPart(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 32); +} + +function pluginStateKey(plugin: string, key: string): string { + return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`; +} + +function validatePluginStateKey(key: string): void { + if (!key.trim()) { + throw new Error("Plugin state key is required"); + } + if (key.length > MAX_PLUGIN_STATE_KEY_LENGTH) { + throw new Error("Plugin state key exceeds the maximum length"); + } +} + +/** Create a durable state namespace scoped to one trusted plugin. */ +export function createPluginState(plugin: string): AgentPluginState { + return { + async delete(key) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + await state.delete(pluginStateKey(plugin, key)); + }, + async get(key) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + return (await state.get(pluginStateKey(plugin, key))) ?? undefined; + }, + async set(key, value, ttlMs) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + await state.set(pluginStateKey(plugin, key), value, ttlMs); + }, + }; +} diff --git a/packages/junior/src/chat/scheduler/plugin.ts b/packages/junior/src/chat/scheduler/plugin.ts new file mode 100644 index 00000000..6d143137 --- /dev/null +++ b/packages/junior/src/chat/scheduler/plugin.ts @@ -0,0 +1,225 @@ +import { + defineJuniorPlugin, + type Dispatch, + type ToolRegistrationHookContext, +} from "@sentry/junior-plugin-api"; +import { buildScheduledTaskRunPrompt } from "@/chat/scheduler/prompt"; +import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; +import { + createSlackScheduleCreateTaskTool, + createSlackScheduleDeleteTaskTool, + createSlackScheduleListTasksTool, + createSlackScheduleRunTaskNowTool, + createSlackScheduleUpdateTaskTool, +} from "@/chat/tools/slack/schedule-tools"; +import type { ToolDefinition } from "@/chat/tools/definition"; +import type { ToolRuntimeContext } from "@/chat/tools/types"; + +const SCHEDULER_HEARTBEAT_LIMIT = 10; + +function shouldSkipRun( + task: ScheduledTask, + run: ScheduledRun, +): string | undefined { + if (task.status === "deleted") { + return `Scheduled task ${task.id} was deleted before the run started.`; + } + if (task.status !== "active") { + return `Scheduled task ${task.id} was ${task.status} before the run started.`; + } + if ( + task.nextRunAtMs !== run.scheduledForMs && + task.runNowAtMs !== run.scheduledForMs + ) { + return `Scheduled task ${task.id} no longer targets ${new Date(run.scheduledForMs).toISOString()}.`; + } + return undefined; +} + +function createSchedulerToolContext( + ctx: ToolRegistrationHookContext, +): ToolRuntimeContext { + return { + channelCapabilities: ctx.channelCapabilities ?? { + canAddReactions: false, + canCreateCanvas: false, + canPostToChannel: false, + }, + channelId: ctx.channelId, + messageTs: ctx.messageTs, + requester: ctx.requester, + sandbox: {} as ToolRuntimeContext["sandbox"], + teamId: ctx.teamId, + threadTs: ctx.threadTs, + userText: ctx.userText, + }; +} + +async function applyDispatchResult(args: { + dispatch: Dispatch; + nowMs: number; + run: ScheduledRun; + store: ReturnType; +}): Promise { + if (args.dispatch.status === "completed") { + const completed = await args.store.markRunCompleted({ + completedAtMs: args.nowMs, + resultMessageTs: args.dispatch.resultMessageTs, + runId: args.run.id, + startedAtMs: args.run.startedAtMs!, + }); + if (!completed) { + return false; + } + await args.store.updateTaskAfterRun({ + nowMs: args.nowMs, + run: args.run, + status: "completed", + }); + return true; + } + + if (args.dispatch.status === "blocked") { + const blocked = await args.store.markRunBlocked({ + completedAtMs: args.nowMs, + errorMessage: args.dispatch.errorMessage ?? "Dispatch blocked.", + runId: args.run.id, + startedAtMs: args.run.startedAtMs!, + }); + if (!blocked) { + return false; + } + await args.store.updateTaskAfterRun({ + errorMessage: blocked.errorMessage, + nowMs: args.nowMs, + run: args.run, + status: "blocked", + }); + return true; + } + + if (args.dispatch.status === "failed") { + const failed = await args.store.markRunFailed({ + completedAtMs: args.nowMs, + errorMessage: args.dispatch.errorMessage ?? "Dispatch failed.", + runId: args.run.id, + startedAtMs: args.run.startedAtMs, + }); + if (!failed) { + return false; + } + await args.store.updateTaskAfterRun({ + errorMessage: failed.errorMessage, + nowMs: args.nowMs, + run: args.run, + status: "failed", + }); + return true; + } + + return false; +} + +/** Create Junior's built-in trusted scheduler plugin. */ +export function createSchedulerPlugin() { + return defineJuniorPlugin({ + name: "scheduler", + hooks: { + tools(ctx) { + if ( + ctx.disableScheduleTools || + !ctx.channelId || + !ctx.teamId || + !ctx.requester?.userId + ) { + return {} as Record>; + } + const context = createSchedulerToolContext(ctx); + return { + slackScheduleCreateTask: createSlackScheduleCreateTaskTool(context), + slackScheduleListTasks: createSlackScheduleListTasksTool(context), + slackScheduleUpdateTask: createSlackScheduleUpdateTaskTool(context), + slackScheduleDeleteTask: createSlackScheduleDeleteTaskTool(context), + slackScheduleRunTaskNow: createSlackScheduleRunTaskNowTool(context), + } satisfies Record>; + }, + async heartbeat(ctx) { + const store = createStateSchedulerStore(); + let dispatchCount = 0; + for (const run of await store.listIncompleteRuns()) { + if (!run.dispatchId) { + continue; + } + const dispatch = await ctx.agent.get(run.dispatchId); + if (!dispatch) { + continue; + } + if ( + await applyDispatchResult({ + dispatch, + nowMs: ctx.nowMs, + run, + store, + }) + ) { + dispatchCount += 1; + } + } + + for ( + let index = dispatchCount; + index < SCHEDULER_HEARTBEAT_LIMIT; + index += 1 + ) { + const run = await store.claimDueRun({ nowMs: ctx.nowMs }); + if (!run) { + break; + } + const task = await store.getTask(run.taskId); + if (!task) { + await store.markRunFailed({ + completedAtMs: ctx.nowMs, + errorMessage: `Scheduled task ${run.taskId} was not found`, + runId: run.id, + }); + continue; + } + const skippedReason = shouldSkipRun(task, run); + if (skippedReason) { + await store.markRunSkipped({ + completedAtMs: ctx.nowMs, + errorMessage: skippedReason, + runId: run.id, + }); + continue; + } + + const prompt = buildScheduledTaskRunPrompt({ + nowMs: ctx.nowMs, + run, + task, + }); + const dispatch = await ctx.agent.dispatch({ + idempotencyKey: run.id, + destination: task.destination, + input: prompt, + metadata: { + runId: run.id, + taskId: task.id, + }, + }); + await store.markRunDispatched({ + claimedAtMs: run.claimedAtMs, + dispatchId: dispatch.id, + nowMs: ctx.nowMs, + runId: run.id, + }); + dispatchCount += 1; + } + + return { dispatchCount }; + }, + }, + }); +} diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior/src/chat/scheduler/store.ts index d8fcff91..0f007ec0 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior/src/chat/scheduler/store.ts @@ -14,6 +14,7 @@ export interface SchedulerStore { claimDueRun(args: { nowMs: number }): Promise; getRun(runId: string): Promise; getTask(taskId: string): Promise; + listIncompleteRuns(): Promise; listTasksForTeam(teamId: string): Promise; markRunBlocked(args: { completedAtMs: number; @@ -43,6 +44,12 @@ export interface SchedulerStore { nowMs: number; runId: string; }): Promise; + markRunDispatched(args: { + claimedAtMs: number; + dispatchId: string; + nowMs: number; + runId: string; + }): Promise; saveTask(task: ScheduledTask): Promise; updateTaskAfterRun(args: { errorMessage?: string; @@ -441,6 +448,25 @@ class StateAdapterSchedulerStore implements SchedulerStore { return (await this.state.get(runKey(runId))) ?? undefined; } + async listIncompleteRuns(): Promise { + await this.state.connect(); + const ids = await getIndex(this.state, globalTaskIndexKey()); + const runs: ScheduledRun[] = []; + for (const taskId of ids) { + const active = await this.state.get<{ runId?: unknown }>( + activeRunKey(taskId), + ); + if (typeof active?.runId !== "string") { + continue; + } + const run = await this.getRun(active.runId); + if (run && !isFinishedRun(run)) { + runs.push(run); + } + } + return runs; + } + async markRunStarted(args: { claimedAtMs: number; nowMs: number; @@ -457,6 +483,24 @@ class StateAdapterSchedulerStore implements SchedulerStore { ); } + async markRunDispatched(args: { + claimedAtMs: number; + dispatchId: string; + nowMs: number; + runId: string; + }): Promise { + return await this.updateRun(args.runId, (run) => + run.status === "pending" && run.claimedAtMs === args.claimedAtMs + ? { + ...run, + dispatchId: args.dispatchId, + startedAtMs: args.nowMs, + status: "running", + } + : undefined, + ); + } + async markRunCompleted(args: { completedAtMs: number; resultMessageTs?: string; diff --git a/packages/junior/src/chat/scheduler/types.ts b/packages/junior/src/chat/scheduler/types.ts index f409d10c..9446b3ab 100644 --- a/packages/junior/src/chat/scheduler/types.ts +++ b/packages/junior/src/chat/scheduler/types.ts @@ -90,6 +90,7 @@ export interface ScheduledRun { attempt: number; claimedAtMs: number; completedAtMs?: number; + dispatchId?: string; errorMessage?: string; idempotencyKey: string; resultMessageTs?: string; diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index df0b8d76..25aea6c9 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -14,13 +14,6 @@ import { createReportProgressTool } from "@/chat/tools/runtime/report-progress"; import { createSlackChannelListMessagesTool } from "@/chat/tools/slack/channel-list-messages"; import { createSlackChannelPostMessageTool } from "@/chat/tools/slack/channel-post-message"; import { createSlackMessageAddReactionTool } from "@/chat/tools/slack/message-add-reaction"; -import { - createSlackScheduleCreateTaskTool, - createSlackScheduleDeleteTaskTool, - createSlackScheduleListTasksTool, - createSlackScheduleRunTaskNowTool, - createSlackScheduleUpdateTaskTool, -} from "@/chat/tools/slack/schedule-tools"; import { createSlackCanvasCreateTool, createSlackCanvasEditTool, @@ -43,6 +36,7 @@ import type { ToolRuntimeContext, ToolState, } from "@/chat/tools/types"; +import { getAgentPluginTools } from "@/chat/plugins/agent-hooks"; import { createWebFetchTool } from "@/chat/tools/web/fetch-tool"; import { createWebSearchTool } from "@/chat/tools/web/search"; import { createWriteFileTool } from "@/chat/tools/sandbox/write-file"; @@ -159,17 +153,15 @@ export function createTools( ); } - if ( - context.disableScheduleTools !== true && - context.channelId && - context.teamId && - context.requester?.userId - ) { - tools.slackScheduleCreateTask = createSlackScheduleCreateTaskTool(context); - tools.slackScheduleListTasks = createSlackScheduleListTasksTool(context); - tools.slackScheduleUpdateTask = createSlackScheduleUpdateTaskTool(context); - tools.slackScheduleDeleteTask = createSlackScheduleDeleteTaskTool(context); - tools.slackScheduleRunTaskNow = createSlackScheduleRunTaskNowTool(context); + for (const [name, pluginTool] of Object.entries( + getAgentPluginTools(context), + )) { + if (tools[name]) { + throw new Error( + `Trusted plugin tool "${name}" conflicts with a core tool`, + ); + } + tools[name] = pluginTool; } return tools; diff --git a/packages/junior/tests/integration/heartbeat.test.ts b/packages/junior/tests/integration/heartbeat.test.ts index b376d1a5..d6153c58 100644 --- a/packages/junior/tests/integration/heartbeat.test.ts +++ b/packages/junior/tests/integration/heartbeat.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; import { createHeartbeatContext } from "@/chat/agent-dispatch/context"; import { recoverStaleDispatches } from "@/chat/agent-dispatch/heartbeat"; +import { createSchedulerPlugin } from "@/chat/scheduler/plugin"; +import { createStateSchedulerStore } from "@/chat/scheduler/store"; +import type { ScheduledTask } from "@/chat/scheduler/types"; import { createOrGetDispatch, getDispatchRecord, @@ -25,6 +28,34 @@ function collectWaitUntil(tasks: Promise[]): WaitUntilFn { }; } +function createTask(): ScheduledTask { + const nextRunAtMs = Date.parse("2026-05-26T12:00:00.000Z"); + return { + id: "sched_plugin_1", + createdAtMs: nextRunAtMs, + createdBy: { slackUserId: "U123" }, + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + }, + nextRunAtMs, + schedule: { + description: "Once at noon", + kind: "one_off", + timezone: "UTC", + }, + status: "active", + task: { + title: "Digest", + objective: "Post a digest.", + instructions: ["Summarize the latest state."], + }, + updatedAtMs: nextRunAtMs, + version: 1, + }; +} + describe("trusted plugin heartbeat", () => { const originalFetch = global.fetch; @@ -213,4 +244,66 @@ describe("trusted plugin heartbeat", () => { errorMessage: "Dispatch exceeded retry attempts.", }); }); + + it("dispatches and reconciles scheduled runs from the scheduler plugin", async () => { + const fetchMock = vi.fn(async () => { + return new Response("Accepted", { status: 202 }); + }); + global.fetch = fetchMock as typeof fetch; + setAgentPlugins([createSchedulerPlugin()]); + const store = createStateSchedulerStore(); + await store.saveTask(createTask()); + + const firstWaitUntilTasks: Promise[] = []; + const firstResponse = await heartbeat( + new Request("https://example.invalid/api/internal/heartbeat", { + headers: { authorization: "Bearer heartbeat-secret" }, + }), + collectWaitUntil(firstWaitUntilTasks), + ); + expect(firstResponse.status).toBe(202); + await Promise.all(firstWaitUntilTasks); + + const running = await store.getRun( + `sched_plugin_1:${Date.parse("2026-05-26T12:00:00.000Z")}`, + ); + expect(running).toMatchObject({ + status: "running", + dispatchId: expect.any(String), + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await withDispatchLock(running!.dispatchId!, async (state) => { + const record = await state.get( + getDispatchStorageKey(running!.dispatchId!), + ); + if (!record) { + throw new Error("Expected dispatch record to exist"); + } + await updateDispatchRecord(state, { + ...record, + resultMessageTs: "1700000000.000001", + status: "completed", + }); + }); + + const secondWaitUntilTasks: Promise[] = []; + const secondResponse = await heartbeat( + new Request("https://example.invalid/api/internal/heartbeat", { + headers: { authorization: "Bearer heartbeat-secret" }, + }), + collectWaitUntil(secondWaitUntilTasks), + ); + expect(secondResponse.status).toBe(202); + await Promise.all(secondWaitUntilTasks); + + await expect(store.getRun(running!.id)).resolves.toMatchObject({ + status: "completed", + resultMessageTs: "1700000000.000001", + }); + await expect(store.getTask("sched_plugin_1")).resolves.toMatchObject({ + lastRunAtMs: Date.parse("2026-05-26T12:00:00.000Z"), + status: "paused", + }); + }); }); diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index 857b1168..b7a93be2 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -88,7 +88,9 @@ describe("createApp plugin config", () => { }); expect(getPluginProviders()).toEqual([]); - expect(getAgentPlugins()).toEqual([]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ + "scheduler", + ]); }); it("fails loudly when configured plugin package names are invalid", async () => { @@ -213,7 +215,10 @@ describe("createApp plugin config", () => { expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ "trusted", ]); - expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ + "scheduler", + "trusted", + ]); }); it("rejects duplicate trusted plugin names before mutating app config", async () => { @@ -230,7 +235,9 @@ describe("createApp plugin config", () => { }), ).rejects.toThrow('Duplicate trusted plugin name "dupe"'); - expect(getAgentPlugins()).toEqual([]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ + "scheduler", + ]); expect(getPluginProviders()).toEqual([]); }); @@ -247,7 +254,9 @@ describe("createApp plugin config", () => { 'Trusted plugin name "GitHub" must be a lowercase plugin identifier', ); - expect(getAgentPlugins()).toEqual([]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ + "scheduler", + ]); expect(getPluginProviders()).toEqual([]); }); }); diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index de1f96d5..efb662b1 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -2,8 +2,12 @@ import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; import { describe, expect, it } from "vitest"; import { createAgentPluginHookRunner, + getAgentPluginTools, setAgentPlugins, } from "@/chat/plugins/agent-hooks"; +import { createTools } from "@/chat/tools"; +import { tool } from "@/chat/tools/definition"; +import { Type } from "@sinclair/typebox"; import type { SandboxInstance } from "@/chat/sandbox/workspace"; function fakeSandbox( @@ -57,6 +61,111 @@ function fakeSandbox( } describe("agent plugin hooks", () => { + it("collects turn-scoped tools from configured plugins", () => { + const previous = setAgentPlugins([ + defineJuniorPlugin({ + name: "agent-demo", + hooks: { + tools(ctx) { + expect(ctx.requester?.userId).toBe("U123"); + return { + demoTool: tool({ + description: "Demo tool", + inputSchema: Type.Object({}), + execute: () => ({ ok: true }), + }), + }; + }, + }, + }), + ]); + try { + const tools = getAgentPluginTools({ + channelCapabilities: { + canAddReactions: false, + canCreateCanvas: false, + canPostToChannel: false, + }, + requester: { userId: "U123" }, + sandbox: {} as any, + }); + + expect(tools).toHaveProperty("demoTool"); + } finally { + setAgentPlugins(previous); + } + }); + + it("rejects plugin tools with invalid names", () => { + const previous = setAgentPlugins([ + defineJuniorPlugin({ + name: "agent-demo", + hooks: { + tools() { + return { + "not-valid": tool({ + description: "Demo tool", + inputSchema: Type.Object({}), + execute: () => ({ ok: true }), + }), + }; + }, + }, + }), + ]); + try { + expect(() => + getAgentPluginTools({ + channelCapabilities: { + canAddReactions: false, + canCreateCanvas: false, + canPostToChannel: false, + }, + sandbox: {} as any, + }), + ).toThrow("must be a camelCase identifier"); + } finally { + setAgentPlugins(previous); + } + }); + + it("rejects plugin tools that conflict with core tools", () => { + const previous = setAgentPlugins([ + defineJuniorPlugin({ + name: "agent-demo", + hooks: { + tools() { + return { + loadSkill: tool({ + description: "Demo tool", + inputSchema: Type.Object({}), + execute: () => ({ ok: true }), + }), + }; + }, + }, + }), + ]); + try { + expect(() => + createTools( + [], + {}, + { + channelCapabilities: { + canAddReactions: false, + canCreateCanvas: false, + canPostToChannel: false, + }, + sandbox: {} as any, + }, + ), + ).toThrow('Trusted plugin tool "loadSkill" conflicts with a core tool'); + } finally { + setAgentPlugins(previous); + } + }); + it("runs sandbox and tool lifecycle hooks from configured plugins", async () => { const writes: Array<{ content: string | Uint8Array; path: string }> = []; const previous = setAgentPlugins([ diff --git a/packages/junior/tests/unit/slack/tool-registration.test.ts b/packages/junior/tests/unit/slack/tool-registration.test.ts index 0f3c9798..7884d3a2 100644 --- a/packages/junior/tests/unit/slack/tool-registration.test.ts +++ b/packages/junior/tests/unit/slack/tool-registration.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTools } from "@/chat/tools"; +import { createSchedulerPlugin } from "@/chat/scheduler/plugin"; +import { setAgentPlugins } from "@/chat/plugins/agent-hooks"; import { resolveChannelCapabilities } from "@/chat/tools/channel-capabilities"; const noopSandbox = {} as any; @@ -13,6 +15,14 @@ function ctx(channelId?: string) { } describe("Slack tool registration", () => { + beforeEach(() => { + setAgentPlugins([createSchedulerPlugin()]); + }); + + afterEach(() => { + setAgentPlugins([]); + }); + it("does not register channel-scope tools in DM context", () => { const tools = createTools([], {}, ctx("D12345")); diff --git a/specs/trusted-plugin-heartbeat-spec.md b/specs/trusted-plugin-heartbeat-spec.md index e525509e..7ea29a67 100644 --- a/specs/trusted-plugin-heartbeat-spec.md +++ b/specs/trusted-plugin-heartbeat-spec.md @@ -26,6 +26,7 @@ The motivating consumer is a scheduler plugin that lets users create scheduled t ## Scope - Trusted plugin heartbeat hook. +- Trusted plugin tool registration hook. - Core-owned internal heartbeat endpoint. - Core-owned durable agent dispatch primitive. - Serverless continuation model for plugin-claimed work. @@ -42,13 +43,12 @@ The motivating consumer is a scheduler plugin that lets users create scheduled t - Raw Slack Web API access from plugins. - Raw agent runtime or `generateAssistantReply` access from plugins. - Raw state adapter or Redis access from plugins. -- New interactive tool registration API. ## Contracts ### Trust Boundary -Heartbeat and agent dispatch are trusted plugin capabilities. They are available only to plugins explicitly passed to `createApp({ plugins: [...] })` as trusted runtime plugins. +Heartbeat and agent dispatch are trusted plugin capabilities. They are available only to Junior-owned built-in trusted plugins and plugins explicitly passed to `createApp({ plugins: [...] })` as trusted runtime plugins. Declarative `plugin.yaml` manifests must not register heartbeat handlers, internal routes, or agent dispatch behavior. @@ -69,14 +69,31 @@ Plugins own only their domain logic: tools, heartbeat work discovery, durable pl ### Interactive Tool Registration -This spec does not introduce a new interactive tool registration hook. The -heartbeat/dispatch substrate is intentionally separate from how scheduler -management tools are exposed during an interactive turn. +Trusted plugins may register turn-scoped tools through a narrow hook: -When scheduler management moves behind a plugin boundary, it must use a narrow -tool-registration surface that preserves the existing tool pipeline: schema -validation, tool guidance, tracing, and plugin `beforeToolExecute` hooks. That -surface is a separate contract from `heartbeat(ctx)` and `ctx.agent.dispatch`. +```ts +interface TrustedPluginHooks { + tools?(ctx: ToolRegistrationContext): Record; +} +``` + +`ToolRegistrationContext` exposes only the current turn context needed to +decide whether tools are available: + +- active conversation destination, when present +- requester, when present +- channel/team identifiers, when present +- thread/message timestamps, when present +- namespaced plugin state +- current user text +- schedule-tool suppression for system dispatches + +Tools returned by this hook participate in the normal tool pipeline: schema +validation, tool guidance, tracing, and plugin `beforeToolExecute` hooks. + +The built-in scheduler plugin uses this hook to register create/list/update/ +delete/run-now tools only when the active Slack conversation has enough context +to manage scheduled tasks. ### Core Heartbeat Endpoint @@ -465,12 +482,10 @@ Core enforces reliability limits even for trusted plugin code: ### Scheduler Plugin Flow -The scheduler plugin uses this trusted hook for background work: - -1. `heartbeat(ctx)` for due-run discovery and dispatch. +The scheduler plugin uses two trusted hooks: -Interactive schedule management tools are a separate migration concern and must -continue deriving their destination from the active conversation context. +1. `tools(ctx)` for interactive schedule management. +2. `heartbeat(ctx)` for due-run discovery and dispatch. Heartbeat flow: @@ -544,6 +559,7 @@ Core may expose narrow capabilities: - namespaced state - plugin logger +- active turn context for tool registration - `agent.dispatch` - `agent.get` From 78057f4f1e6942e78fdfc96c55a946588be01056 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 26 May 2026 12:53:36 -0700 Subject: [PATCH 15/17] fix(scheduler): Keep schedule guidance on tools Move scheduler-specific agent guidance out of the core prompt and into the schedule tool descriptions and prompt guidance. This keeps scheduled-task behavior discoverable through the plugin-provided tool surface instead of growing global prompt policy. Co-Authored-By: GPT-5 Codex --- packages/junior/src/chat/prompt.ts | 1 - .../src/chat/tools/slack/schedule-tools.ts | 44 ++++++++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index a4e1d85f..562bd4ee 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -426,7 +426,6 @@ const SLACK_ACTION_RULES = [ "- Context-bound Slack tools use runtime-owned targets; do not invent channel, canvas, list, or message IDs.", "- Use first-class Slack tools for Slack side effects; do not use bash, curl, or provider APIs to bypass Slack tool targeting.", "- Use channel-post and emoji-reaction tools only when the user explicitly asks for that Slack side effect.", - "- Use Slack schedule tools only when the user explicitly asks to create, list, edit, pause, resume, remove, run now, or run future/recurring Junior work; scheduled task destinations are always the active Slack DM or channel, never an existing thread, and task creation needs an exact next-run ISO timestamp or supported relative next-run text. When no timezone is given, let the scheduler use its configured default timezone.", "- For explicit channel-post or emoji-reaction requests, skip a duplicate thread text reply when the tool result already satisfies the request.", "- Do not claim an attachment, canvas, channel post, list update, or reaction succeeded unless the tool returned success this turn; when it did, include any link the tool returned.", "- Do not use reactions as progress indicators.", diff --git a/packages/junior/src/chat/tools/slack/schedule-tools.ts b/packages/junior/src/chat/tools/slack/schedule-tools.ts index 110ac3bf..d234c1f8 100644 --- a/packages/junior/src/chat/tools/slack/schedule-tools.ts +++ b/packages/junior/src/chat/tools/slack/schedule-tools.ts @@ -22,6 +22,10 @@ import type { ToolRuntimeContext } from "@/chat/tools/types"; const TASK_ID_PREFIX = "sched"; const MAX_LISTED_TASKS = 50; const DEFAULT_SCHEDULE_TIMEZONE = "America/Los_Angeles"; +const ACTIVE_DESTINATION_GUIDELINE = + "Only manage tasks for the active Slack DM or channel; never target an existing thread, another channel, or another user's DM."; +const ACTIVE_TASK_ID_GUIDELINE = + "Use only task IDs returned from this active destination."; function requireActiveDestination( context: ToolRuntimeContext, @@ -303,7 +307,15 @@ function hasConflictingNextRunInputs(input: { export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { return tool({ description: - "Create a Junior scheduled task for the active Slack DM or channel. The destination is always the current Slack conversation, never an existing thread, and never an invented destination. Use only after the user has confirmed the normalized scheduled task contract. Provide either exact next_run_at_iso or supported relative next_run_at_text. For recurring work, provide a calendar recurrence_frequency.", + "Create a scheduled Junior task in the active Slack conversation.", + promptSnippet: "create future or recurring Junior work here", + promptGuidelines: [ + "Use only when the user explicitly asks Junior to do work later or on a recurring cadence.", + ACTIVE_DESTINATION_GUIDELINE, + "Before calling, show the normalized task, cadence, timezone, destination, and next run; call only after explicit user confirmation.", + "Provide exactly one of next_run_at_iso or next_run_at_text; omit timezone to use the configured default.", + "Use recurrence_frequency only for recurring schedules.", + ], inputSchema: Type.Object({ confirmed_by_user: Type.Boolean({ description: @@ -463,7 +475,12 @@ export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { export function createSlackScheduleListTasksTool(context: ToolRuntimeContext) { return tool({ description: - "List Junior scheduled tasks for the active Slack destination only. Use when the user asks what is scheduled here or wants task IDs before editing/removing schedules.", + "List scheduled Junior tasks for the active Slack conversation.", + promptSnippet: "list schedules for this Slack destination", + promptGuidelines: [ + "Use when the user asks what is scheduled here or needs task IDs before editing, deleting, or running schedules.", + ACTIVE_DESTINATION_GUIDELINE, + ], annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: Type.Object({}), execute: async () => { @@ -490,8 +507,15 @@ export function createSlackScheduleListTasksTool(context: ToolRuntimeContext) { /** Create a tool that edits a scheduled task in the active Slack destination. */ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { return tool({ - description: - "Edit a Junior scheduled task in the active Slack DM or channel. Use only for task IDs returned from the active destination. Do not move tasks across conversations.", + description: "Edit, pause, resume, or reschedule a Junior scheduled task.", + promptSnippet: "edit/pause/resume one schedule in this Slack destination", + promptGuidelines: [ + ACTIVE_TASK_ID_GUIDELINE, + ACTIVE_DESTINATION_GUIDELINE, + "Do not move scheduled tasks across conversations.", + "Provide exactly one of next_run_at_iso or next_run_at_text when changing the next run.", + "Set status to active, paused, or blocked when the user asks to resume, pause, or block a task.", + ], inputSchema: Type.Object({ task_id: Type.String({ minLength: 1 }), title: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })), @@ -653,7 +677,9 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { export function createSlackScheduleDeleteTaskTool(context: ToolRuntimeContext) { return tool({ description: - "Remove a Junior scheduled task from the active Slack destination. Use only for task IDs returned from this destination.", + "Delete a Junior scheduled task from the active Slack conversation.", + promptSnippet: "delete one schedule from this Slack destination", + promptGuidelines: [ACTIVE_TASK_ID_GUIDELINE, ACTIVE_DESTINATION_GUIDELINE], inputSchema: Type.Object({ task_id: Type.String({ minLength: 1 }), }), @@ -683,7 +709,13 @@ export function createSlackScheduleDeleteTaskTool(context: ToolRuntimeContext) { export function createSlackScheduleRunTaskNowTool(context: ToolRuntimeContext) { return tool({ description: - "Run an existing Junior scheduled task as soon as the scheduler tick processes it. Use only for task IDs returned from this destination.", + "Queue an active Junior scheduled task to run as soon as possible.", + promptSnippet: "run one active schedule now without changing its cadence", + promptGuidelines: [ + ACTIVE_TASK_ID_GUIDELINE, + ACTIVE_DESTINATION_GUIDELINE, + "Use when the user asks to run an existing scheduled task now; do not rewrite the stored calendar cadence.", + ], inputSchema: Type.Object({ task_id: Type.String({ minLength: 1 }), }), From 7e6ebf8059b29a4ef4bbb5e8791d019786868e64 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 26 May 2026 12:57:47 -0700 Subject: [PATCH 16/17] fix(prompt): Remove plugin provider knowledge Stop rendering installed plugin provider catalogs or plugin ownership metadata into prompt context. Plugin behavior should be surfaced through dynamic capabilities such as skills, tools, and tool guidance instead of core prompt knowledge. Co-Authored-By: GPT-5 Codex --- packages/junior/src/chat/prompt.ts | 40 ----------------------- packages/junior/tests/unit/prompt.test.ts | 21 ++++++++++++ 2 files changed, 21 insertions(+), 40 deletions(-) diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index 562bd4ee..e3f2bdc2 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -8,7 +8,6 @@ import { worldPathCandidates, } from "@/chat/discovery"; import { logInfo, logWarn } from "@/chat/logging"; -import { getPluginProviders } from "@/chat/plugins/registry"; import { slackOutputPolicy } from "@/chat/slack/output"; import { SANDBOX_DATA_ROOT, @@ -161,9 +160,6 @@ function formatSkillEntry(skill: SkillMetadata): string[] { lines.push(` ${escapeXml(skill.name)}`); lines.push(` ${escapeXml(skill.description)}`); lines.push(` ${escapeXml(skillLocation)}`); - if (skill.pluginProvider) { - lines.push(` ${escapeXml(skill.pluginProvider)}`); - } lines.push(" "); return lines; } @@ -235,37 +231,6 @@ function formatLoadedSkillsForPrompt(skills: Skill[]): string | null { return lines.join("\n"); } -function formatProviderCatalogForPrompt(): string | null { - const providers = getPluginProviders().map((plugin) => plugin.manifest); - if (providers.length === 0) { - return null; - } - - const lines = [ - "Config keys and default targets per provider; use after a skill is loaded. Run authenticated provider commands directly after resolving target defaults; let the runtime handle auth pauses/resumes.", - ]; - for (const provider of providers) { - lines.push(`- provider: ${escapeXml(provider.name)}`); - lines.push( - ` - config_keys: ${ - provider.configKeys.length > 0 - ? escapeXml(provider.configKeys.join(", ")) - : "none" - }`, - ); - lines.push( - ` - default_context: ${ - provider.target - ? escapeXml( - `${provider.target.type} via ${provider.target.configKey}`, - ) - : "none" - }`, - ); - } - return lines.join("\n"); -} - function formatActiveMcpCatalogsForPrompt( catalogs: ActiveMcpCatalogSummary[], ): string | null { @@ -602,11 +567,6 @@ function buildCapabilitiesSection(params: { blocks.push(renderTagBlock("tool-guidance", toolGuidance)); } - const providerCatalog = formatProviderCatalogForPrompt(); - if (providerCatalog) { - blocks.push(renderTagBlock("providers", providerCatalog)); - } - if (blocks.length === 0) { return null; } diff --git a/packages/junior/tests/unit/prompt.test.ts b/packages/junior/tests/unit/prompt.test.ts index d32e3655..d63d39d5 100644 --- a/packages/junior/tests/unit/prompt.test.ts +++ b/packages/junior/tests/unit/prompt.test.ts @@ -104,4 +104,25 @@ describe("prompt builders", () => { expect(turnContext).toContain("- exact edits"); expect(turnContext).toContain("- unique oldText"); }); + + it("does not expose plugin ownership as prompt knowledge", () => { + const turnContext = buildTurnContextPrompt({ + availableSkills: [ + { + name: "demo-skill", + description: "Demo workflow", + pluginProvider: "demo-provider", + skillPath: "/tmp/skills/demo-skill", + }, + ], + activeSkills: [], + activeMcpCatalogs: [], + invocation: null, + turnState: "fresh", + }); + + expect(turnContext).toContain("demo-skill"); + expect(turnContext).not.toContain("demo-provider"); + expect(turnContext).not.toContain(""); + }); }); From 035e13ef645d804baf7e8187af84ca269a40ede0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 26 May 2026 13:01:59 -0700 Subject: [PATCH 17/17] docs(prompt): Clarify plugin guidance boundary Document that core prompt assembly must not contain plugin-specific knowledge and that plugins should expose model-facing behavior through skills, tool descriptions, schemas, and tool guidance. Update the trusted plugin and provider catalog specs so they align with the scheduler plugin implementation. Co-Authored-By: GPT-5 Codex --- specs/agent-prompt-spec.md | 16 +++++++++++----- specs/plugin-spec.md | 6 +++++- specs/providers/catalog-spec.md | 8 ++++---- specs/trusted-plugin-heartbeat-spec.md | 9 +++++++++ 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/specs/agent-prompt-spec.md b/specs/agent-prompt-spec.md index 3bab1d92..bc0b1bde 100644 --- a/specs/agent-prompt-spec.md +++ b/specs/agent-prompt-spec.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-04-28 -- Last Edited: 2026-05-06 +- Last Edited: 2026-05-26 ## Changelog @@ -11,6 +11,7 @@ - 2026-04-30: Reworked the core prompt contract around fixed operating sections, source hierarchy, explicit completion gates, OpenClaw-style tool-call/safety boundaries, and stable-before-volatile ordering. - 2026-05-06: Required the initial system prompt to be byte-stable across conversations and turns, with volatile runtime context moved into per-turn user-message context. - 2026-05-06: Clarified that deployment-stable assistant identity belongs in the system prompt while requester identity remains per-turn context. +- 2026-05-26: Clarified that core prompt assembly must not contain plugin-specific knowledge; plugins express behavior through skills, tools, schemas, and tool guidance. ## Status @@ -32,7 +33,7 @@ Define the canonical contract for Junior's platform-owned agent prompt so prompt - Defining Pi agent loop mechanics or terminal output assembly; see `./harness-agent-spec.md`. - Defining Slack delivery transport behavior; see `./slack-agent-delivery-spec.md` and `./slack-outbound-contract-spec.md`. - Defining test-layer taxonomy; see `./testing/index.md`. -- Defining provider-specific prompt overlays unless this repository owns that overlay. +- Defining plugin-specific prompt overlays or provider workflows. Plugins own that guidance through their skills, tools, schemas, and tool guidance. ## Contracts @@ -41,6 +42,7 @@ Define the canonical contract for Junior's platform-owned agent prompt so prompt - The core prompt owns platform behavior: tool-use policy, execution bias, context boundaries, Slack output shape, and failure reporting expectations. - `SOUL.md` and other deployment-authored personality files are voice-only. Platform behavior must still work if those files are empty or heavily customized. - Skill files own domain-specific workflow mechanics. They must not duplicate generic harness behavior such as "use tools before answering" or "ask only when blocked." +- The core prompt must not name or describe specific installed plugins, plugin providers, plugin-owned config keys, plugin-owned default targets, plugin-owned tools, or plugin-specific workflows. That knowledge belongs to dynamic capabilities. ### Section boundaries @@ -48,6 +50,8 @@ Define the canonical contract for Junior's platform-owned agent prompt so prompt `buildTurnContextPrompt(...)` owns volatile prompt context. It is attached to the current user turn, including requester identity and resumed-turn context, and may vary by conversation or turn. Completed turns must strip this context before storing durable Pi message history so prior turns are not replayed with stale runtime facts. +Turn context may disclose dynamic capability surfaces that the model can act on, such as available skill names/descriptions, active MCP catalog summaries, and tool guidance attached to the current native tool set. It must not separately disclose plugin ownership or installed plugin/provider catalogs as prompt knowledge. If the model needs plugin-specific behavior, that behavior must arrive through the loaded skill body, tool description, tool schema, `promptSnippet`, or `promptGuidelines`. + The combined prompt surface must keep these concerns distinct: 1. Identity/personality. @@ -106,9 +110,10 @@ Mutable facts need live checks. Examples include files, repos, versions, issues, - Tool schemas remain the source of truth for tool parameters. The prompt may state when to use tools, not re-document every tool schema. - The model should load the best-matching skill when relevant and avoid preloading unrelated skills. -- After loading a plugin-backed skill, the prompt may describe the generic MCP lookup path, but provider-specific tool strategy belongs in the skill or plugin docs. +- After loading a plugin-backed skill, the prompt may describe the generic MCP lookup path, but provider-specific tool strategy belongs in the skill, tool description, tool schema, or tool guidance. - Skill selection should be explicit: scan available skills, load one clearly matching skill, choose the most specific skill when several match, and avoid loading any skill when none clearly applies. - Tool-call style belongs in its own section: call routine tools directly, narrate only when it helps, and prefer first-class tools over asking the user to perform equivalent manual work. +- Trusted plugin tools must carry concise descriptions and optional tool guidance that tell the agent when and how to use them. Do not compensate for weak plugin tool descriptions by adding plugin-specific bullets to the core prompt. ### Runtime and safety boundaries @@ -139,8 +144,9 @@ Prompt changes are rejected or revised when they introduce: 1. Duplicate rules across core prompt, skills, or personality files. 2. Multiple adjacent bullets that all express the same ask/act/verify policy. 3. Tool-schema restatement in prompt prose. -4. Skill instructions that override generic harness behavior without a domain-specific reason. -5. Static prompt tests that assert wording instead of behavior. +4. Core prompt or turn-context code that exposes specific installed plugins, plugin providers, plugin-owned config keys, plugin-owned default targets, or plugin-specific workflows outside the dynamic skill/tool surfaces. +5. Skill instructions that override generic harness behavior without a domain-specific reason. +6. Static prompt tests that assert wording instead of behavior. ## Observability diff --git a/specs/plugin-spec.md b/specs/plugin-spec.md index dc1edcf6..b186446a 100644 --- a/specs/plugin-spec.md +++ b/specs/plugin-spec.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-01 -- Last Edited: 2026-05-20 +- Last Edited: 2026-05-26 ## Changelog @@ -26,6 +26,7 @@ - 2026-05-12: Clarified that credentialed provider HTTP traffic is authenticated through the sandbox egress proxy. - 2026-05-20: Added `PluginConfig` manifests for install-level plugin configuration. - 2026-05-25: Added explicit trusted app plugin registration for deterministic agent behavior at Junior-owned lifecycle boundaries. +- 2026-05-26: Clarified that plugin-specific agent behavior must surface through skills, tools, schemas, and tool guidance, not core prompt/plugin catalog prose. ## Status @@ -65,6 +66,7 @@ Define a plugin model where provider integrations are self-contained manifests t 8. `loadSkill` activates the provider catalog and returns provider/count metadata once the MCP server is connected and `listTools` succeeds. If connection/listing needs MCP OAuth, `loadSkill` initiates the MCP auth pause and the resumed turn re-activates the catalog before the model continues. `searchMcpTools` returns focused descriptors, including input/output schema and annotations, for any available active-provider tool before `callMcpTool` executes it. 9. Runtime setup belongs to `plugin.yaml`: CLI packages, system packages, postinstall commands, MCP endpoints/tool allowlists, credential delivery, command env, OAuth, and provider config keys are manifest declarations, not skill instructions. 10. Skills consume the plugin-provided runtime surface. They must not instruct the agent to install packages, bootstrap CLIs, configure MCP servers, create credentials, or repair sandbox package installation as part of normal workflow. +11. The core prompt must not teach the agent about specific installed plugins, provider names, plugin config keys, default targets, or plugin workflows. Model-visible plugin behavior must arrive through dynamic capability surfaces: skill names/descriptions, loaded skill bodies, native tool descriptions, tool schemas, `promptSnippet`, `promptGuidelines`, and searched MCP tool descriptors. ## Plugin directory structure @@ -533,6 +535,8 @@ Plugin skills use the same `SKILL.md` format and frontmatter contract as existin ### Skill/runtime boundary +Plugin prompt behavior must be local to the capability that needs it. Plugin-backed skills may describe provider-specific workflows after the skill is loaded. Trusted plugin tools must have concise descriptions and, when needed, tool guidance that tells the agent when to use the tool, what not to target, and which user confirmation or context is required. The host must not add plugin-specific rescue rules to the core prompt to compensate for weak plugin descriptions. + Plugin-backed skills may tell the model how to use available commands, MCP tools, command env, config defaults, and provider-specific query syntax. They may include troubleshooting for unavailable runtime surfaces only as diagnosis and escalation, for example “report that the GitHub plugin runtime dependency is unavailable.” When the runtime loads a plugin-backed skill, it enforces the parent plugin before returning the skill: diff --git a/specs/providers/catalog-spec.md b/specs/providers/catalog-spec.md index 85f59538..c47aa3bd 100644 --- a/specs/providers/catalog-spec.md +++ b/specs/providers/catalog-spec.md @@ -3,13 +3,14 @@ ## Metadata - Created: 2026-02-27 -- Last Edited: 2026-05-06 +- Last Edited: 2026-05-26 ## Changelog - 2026-03-03: Standardized metadata headers and reconciled spec references/structure. - 2026-04-30: Added `github.org` to GitHub provider configKeys. - 2026-05-06: Clarified that provider catalog prompt disclosure belongs in per-turn context, not the static system prompt. +- 2026-05-26: Marked provider catalog prompt disclosure as superseded; core prompt context must not expose installed plugin/provider catalogs. ## Status @@ -22,7 +23,7 @@ Draft — largely superseded by `specs/plugin-spec.md` which now drives the prov ## Purpose -Define the canonical provider catalog model used by runtime, skill validation, and prompts. +Define the historical provider catalog model used by runtime and skill validation. Prompt disclosure rules in this draft are superseded by `../agent-prompt-spec.md` and `../plugin-spec.md`. This spec answers: @@ -103,8 +104,7 @@ target: ## Prompt Contracts -- Per-turn prompt context should include provider catalog summary so natural language requests can map to valid config/capability tokens without changing the static system prompt. -- Prompt guidance must remain generic and provider-extensible. +Superseded. Core prompt assembly must not expose installed provider/plugin catalogs, provider config keys, or default targets as standalone prompt knowledge. Provider-specific behavior should reach the model through dynamic skill/tool surfaces: available skill descriptions, loaded skill bodies, tool descriptions, schemas, tool guidance, and searched MCP descriptors. ## Observability diff --git a/specs/trusted-plugin-heartbeat-spec.md b/specs/trusted-plugin-heartbeat-spec.md index 7ea29a67..6c8f4d8e 100644 --- a/specs/trusted-plugin-heartbeat-spec.md +++ b/specs/trusted-plugin-heartbeat-spec.md @@ -7,6 +7,7 @@ ## Changelog +- 2026-05-26: Clarified that trusted plugin tools own their model-facing descriptions/guidance and must not require plugin-specific core prompt rules. - 2026-05-26: Clarified heartbeat recovery budgets, dispatch callback path, retention constant, lease semantics, destination shape, and lookup verification. - 2026-05-26: Defined dispatch lookup retention and scheduler-owned terminal run history. - 2026-05-26: Added dispatch recovery, result lookup, serverless slice, lock ordering, and system-actor security invariants. @@ -91,6 +92,14 @@ decide whether tools are available: Tools returned by this hook participate in the normal tool pipeline: schema validation, tool guidance, tracing, and plugin `beforeToolExecute` hooks. +Each returned tool must carry a concise model-facing description that explains +what the tool does and when it should be used. If correct use requires policy +that is specific to the plugin domain, such as destination scoping, confirmation +requirements, or recurrence semantics, that guidance belongs on the tool via +its description, schema descriptions, `promptSnippet`, or `promptGuidelines`. +Core prompt rules must stay plugin-agnostic and must not name scheduler tools or +any other specific plugin tool. + The built-in scheduler plugin uses this hook to register create/list/update/ delete/run-now tools only when the active Slack conversation has enough context to manage scheduled tasks.