From bfac438cea1cb42e2b1d8439d16ed32b100ad790 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 16:42:04 -0700 Subject: [PATCH] ref(scheduler): Trim scheduler SQL storage contract Keep scheduler task and run JSON records as the canonical durable state while limiting SQL columns to the projections used for lookup, due scans, and recovery. Remove scheduler-owned task versions, run task versions, and duplicate run idempotency keys from the active record shape. Document the executionActor fallback as retained-state tolerance and update scheduler tests for the slimmer pre-release schema. Co-Authored-By: GPT-5 Codex --- .../migrations/0001_scheduler.sql | 33 ++--- packages/junior-scheduler/src/db/schema.ts | 54 +++----- packages/junior-scheduler/src/prompt.ts | 3 +- .../junior-scheduler/src/schedule-tools.ts | 5 - packages/junior-scheduler/src/store.ts | 124 +++++------------- packages/junior-scheduler/src/types.ts | 3 - .../component/scheduler-sql-plugin.test.ts | 50 +------ .../tests/integration/heartbeat.test.ts | 1 - .../integration/slack-schedule-tools.test.ts | 8 -- specs/scheduler.md | 16 ++- 10 files changed, 71 insertions(+), 226 deletions(-) diff --git a/packages/junior-scheduler/migrations/0001_scheduler.sql b/packages/junior-scheduler/migrations/0001_scheduler.sql index 5ac100b15..14ba3b006 100644 --- a/packages/junior-scheduler/migrations/0001_scheduler.sql +++ b/packages/junior-scheduler/migrations/0001_scheduler.sql @@ -5,41 +5,26 @@ CREATE TABLE IF NOT EXISTS junior_scheduler_tasks ( next_run_at_ms BIGINT, run_now_at_ms BIGINT, created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - version INTEGER NOT NULL, - destination JSONB NOT NULL, - created_by JSONB NOT NULL, - conversation_access JSONB, - credential_subject JSONB, - execution_actor JSONB, - last_run_at_ms BIGINT, - original_request TEXT, - schedule JSONB NOT NULL, - status_reason TEXT, - task JSONB NOT NULL, record JSONB NOT NULL ); CREATE INDEX IF NOT EXISTS junior_scheduler_tasks_team_status_idx - ON junior_scheduler_tasks (team_id, status, created_at_ms); + ON junior_scheduler_tasks (team_id, created_at_ms, id) + WHERE status <> 'deleted'; -CREATE INDEX IF NOT EXISTS junior_scheduler_tasks_due_idx - ON junior_scheduler_tasks (status, run_now_at_ms, next_run_at_ms); +CREATE INDEX IF NOT EXISTS junior_scheduler_tasks_run_now_due_idx + ON junior_scheduler_tasks (run_now_at_ms, created_at_ms, id) + WHERE status = 'active' AND run_now_at_ms IS NOT NULL; + +CREATE INDEX IF NOT EXISTS junior_scheduler_tasks_next_run_due_idx + ON junior_scheduler_tasks (next_run_at_ms, created_at_ms, id) + WHERE status = 'active' AND next_run_at_ms IS NOT NULL; CREATE TABLE IF NOT EXISTS junior_scheduler_runs ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, status TEXT NOT NULL, - claimed_at_ms BIGINT NOT NULL, scheduled_for_ms BIGINT NOT NULL, - started_at_ms BIGINT, - completed_at_ms BIGINT, - dispatch_id TEXT, - error_message TEXT, - idempotency_key TEXT NOT NULL, - result_message_ts TEXT, - task_version INTEGER NOT NULL, - attempt INTEGER NOT NULL, record JSONB NOT NULL ); diff --git a/packages/junior-scheduler/src/db/schema.ts b/packages/junior-scheduler/src/db/schema.ts index e05e4d5a6..e2d5472be 100644 --- a/packages/junior-scheduler/src/db/schema.ts +++ b/packages/junior-scheduler/src/db/schema.ts @@ -1,11 +1,5 @@ -import { - bigint, - index, - integer, - jsonb, - pgTable, - text, -} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { bigint, index, jsonb, pgTable, text } from "drizzle-orm/pg-core"; import type { ScheduledRun, ScheduledTask } from "../types"; export const juniorSchedulerTasks = pgTable( @@ -17,31 +11,22 @@ export const juniorSchedulerTasks = pgTable( nextRunAtMs: bigint("next_run_at_ms", { mode: "number" }), runNowAtMs: bigint("run_now_at_ms", { mode: "number" }), createdAtMs: bigint("created_at_ms", { mode: "number" }).notNull(), - updatedAtMs: bigint("updated_at_ms", { mode: "number" }).notNull(), - version: integer("version").notNull(), - destination: jsonb("destination").notNull(), - createdBy: jsonb("created_by").notNull(), - conversationAccess: jsonb("conversation_access"), - credentialSubject: jsonb("credential_subject"), - executionActor: jsonb("execution_actor"), - lastRunAtMs: bigint("last_run_at_ms", { mode: "number" }), - originalRequest: text("original_request"), - schedule: jsonb("schedule").notNull(), - statusReason: text("status_reason"), - task: jsonb("task").notNull(), record: jsonb("record").$type().notNull(), }, (table) => [ - index("junior_scheduler_tasks_team_status_idx").on( - table.teamId, - table.status, - table.createdAtMs, - ), - index("junior_scheduler_tasks_due_idx").on( - table.status, - table.runNowAtMs, - table.nextRunAtMs, - ), + index("junior_scheduler_tasks_team_status_idx") + .on(table.teamId, table.createdAtMs, table.id) + .where(sql`${table.status} <> 'deleted'`), + index("junior_scheduler_tasks_run_now_due_idx") + .on(table.runNowAtMs, table.createdAtMs, table.id) + .where( + sql`${table.status} = 'active' AND ${table.runNowAtMs} IS NOT NULL`, + ), + index("junior_scheduler_tasks_next_run_due_idx") + .on(table.nextRunAtMs, table.createdAtMs, table.id) + .where( + sql`${table.status} = 'active' AND ${table.nextRunAtMs} IS NOT NULL`, + ), ], ); @@ -51,16 +36,7 @@ export const juniorSchedulerRuns = pgTable( id: text("id").primaryKey(), taskId: text("task_id").notNull(), status: text("status").notNull(), - claimedAtMs: bigint("claimed_at_ms", { mode: "number" }).notNull(), scheduledForMs: bigint("scheduled_for_ms", { mode: "number" }).notNull(), - startedAtMs: bigint("started_at_ms", { mode: "number" }), - completedAtMs: bigint("completed_at_ms", { mode: "number" }), - dispatchId: text("dispatch_id"), - errorMessage: text("error_message"), - idempotencyKey: text("idempotency_key").notNull(), - resultMessageTs: text("result_message_ts"), - taskVersion: integer("task_version").notNull(), - attempt: integer("attempt").notNull(), record: jsonb("record").$type().notNull(), }, (table) => [ diff --git a/packages/junior-scheduler/src/prompt.ts b/packages/junior-scheduler/src/prompt.ts index 75a782a93..e99279dfe 100644 --- a/packages/junior-scheduler/src/prompt.ts +++ b/packages/junior-scheduler/src/prompt.ts @@ -37,6 +37,8 @@ export function buildScheduledTaskRunPrompt(args: { const { run, task } = args; const destination = task.destination; const creator = sanitizeScheduledTaskPrincipal(task.createdBy); + // Older retained scheduler state predated executionActor; new tasks always + // store it explicitly as part of the task contract. const executionActor = task.executionActor ?? SCHEDULED_TASK_SYSTEM_ACTOR; if (!task.task.text?.trim()) { throw new Error("Scheduled task text is required"); @@ -55,7 +57,6 @@ export function buildScheduledTaskRunPrompt(args: { "", "", `- 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)}`, diff --git a/packages/junior-scheduler/src/schedule-tools.ts b/packages/junior-scheduler/src/schedule-tools.ts index 11b9a7fbf..0c5e0555a 100644 --- a/packages/junior-scheduler/src/schedule-tools.ts +++ b/packages/junior-scheduler/src/schedule-tools.ts @@ -213,7 +213,6 @@ function compactTask(task: ScheduledTask): Record { run_now_at: task.runNowAtMs ? new Date(task.runNowAtMs).toISOString() : null, - version: task.version, }; } @@ -425,7 +424,6 @@ export function createSlackScheduleCreateTaskTool( task: { text: input.task, }, - version: 1, }; await schedulerStore(context).saveTask(task); @@ -572,7 +570,6 @@ export function createSlackScheduleUpdateTaskTool( recurrence, }, task: input.task ? { text: input.task } : lookup.task, - version: lookup.version + 1, }; await schedulerStore(context).saveTask(next); @@ -608,7 +605,6 @@ export function createSlackScheduleDeleteTaskTool( status: "deleted", nextRunAtMs: undefined, runNowAtMs: undefined, - version: lookup.version + 1, }; await schedulerStore(context).saveTask(next); @@ -648,7 +644,6 @@ export function createSlackScheduleRunTaskNowTool( ...lookup, updatedAtMs: nowMs, runNowAtMs: nowMs, - version: lookup.version + 1, }; await schedulerStore(context).saveTask(next); diff --git a/packages/junior-scheduler/src/store.ts b/packages/junior-scheduler/src/store.ts index f44199897..63a9dfa1c 100644 --- a/packages/junior-scheduler/src/store.ts +++ b/packages/junior-scheduler/src/store.ts @@ -85,7 +85,7 @@ const taskRecordSchema = z statusReason: z.string().optional(), task: taskSpecSchema, updatedAtMs: z.number(), - version: z.number(), + version: z.number().optional(), }) .strict(); const runRecordSchema = z @@ -96,7 +96,7 @@ const runRecordSchema = z completedAtMs: z.number().optional(), dispatchId: z.string().optional(), errorMessage: z.string().optional(), - idempotencyKey: z.string(), + idempotencyKey: z.string().optional(), resultMessageTs: z.string().optional(), scheduledForMs: z.number(), startedAtMs: z.number().optional(), @@ -109,7 +109,7 @@ const runRecordSchema = z "skipped", ]), taskId: z.string(), - taskVersion: z.number(), + taskVersion: z.number().optional(), }) .strict(); @@ -372,16 +372,13 @@ function buildScheduledRun(args: { 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, }; } @@ -457,13 +454,34 @@ function canFinishRun( /** Decode retained scheduler task state, skipping invalid legacy records. */ function parseStoredTask(value: unknown): ScheduledTask | undefined { const parsed = taskRecordSchema.safeParse(parseJsonRecord(value)); - return parsed.success ? parsed.data : undefined; + return parsed.success ? stripLegacyTaskFields(parsed.data) : undefined; } /** Decode retained scheduler run state, skipping invalid legacy records. */ function parseStoredRun(value: unknown): ScheduledRun | undefined { const parsed = runRecordSchema.safeParse(parseJsonRecord(value)); - return parsed.success ? parsed.data : undefined; + return parsed.success ? stripLegacyRunFields(parsed.data) : undefined; +} + +function stripLegacyTaskFields( + task: ScheduledTask & { version?: number }, +): ScheduledTask { + const { version: _version, ...current } = task; + return current; +} + +function stripLegacyRunFields( + run: ScheduledRun & { + idempotencyKey?: string; + taskVersion?: number; + }, +): ScheduledRun { + const { + idempotencyKey: _idempotencyKey, + taskVersion: _taskVersion, + ...current + } = run; + return current; } function parseJsonRecord(value: unknown): T | undefined { @@ -751,7 +769,6 @@ class PluginStateSchedulerStore implements SchedulerStore { status: nextStatus, statusReason: nextStatus === "paused" ? errorMessage : undefined, updatedAtMs: args.nowMs, - version: current.version + 1, }, current, ); @@ -937,7 +954,6 @@ class PluginStateSchedulerStore implements SchedulerStore { statusReason: args.status === "blocked" ? args.errorMessage : undefined, updatedAtMs: args.nowMs, - version: current.version + 1, }, current, ); @@ -953,7 +969,6 @@ class PluginStateSchedulerStore implements SchedulerStore { ...current, lastRunAtMs: args.run.scheduledForMs, updatedAtMs: args.nowMs, - version: current.version + 1, }, current, ); @@ -979,7 +994,6 @@ class PluginStateSchedulerStore implements SchedulerStore { statusReason: args.status === "blocked" ? args.errorMessage : undefined, updatedAtMs: args.nowMs, - version: current.version + 1, }, current, ); @@ -1028,7 +1042,7 @@ type SchedulerRunRow = { /** Decode scheduler SQL task records and reject rows unsafe for scan paths. */ function parseSqlTaskRecord(value: unknown): ScheduledTask | undefined { const parsed = taskRecordSchema.safeParse(parseJsonRecord(value)); - return parsed.success ? parsed.data : undefined; + return parsed.success ? stripLegacyTaskFields(parsed.data) : undefined; } function parseSqlTaskRow(row: SchedulerTaskRow): ScheduledTask | undefined { @@ -1038,7 +1052,7 @@ function parseSqlTaskRow(row: SchedulerTaskRow): ScheduledTask | undefined { /** Decode scheduler SQL run records and reject rows unsafe for scan paths. */ function parseSqlRunRow(row: SchedulerRunRow): ScheduledRun | undefined { const parsed = runRecordSchema.safeParse(parseJsonRecord(row.record)); - return parsed.success ? parsed.data : undefined; + return parsed.success ? stripLegacyRunFields(parsed.data) : undefined; } function json(value: unknown): string { @@ -1066,23 +1080,9 @@ INSERT INTO junior_scheduler_tasks ( next_run_at_ms, run_now_at_ms, created_at_ms, - updated_at_ms, - version, - destination, - created_by, - conversation_access, - credential_subject, - execution_actor, - last_run_at_ms, - original_request, - schedule, - status_reason, - task, record ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, - $9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb, $13::jsonb, - $14, $15, $16::jsonb, $17, $18::jsonb, $19::jsonb + $1, $2, $3, $4, $5, $6, $7::jsonb ) ON CONFLICT (id) DO UPDATE SET team_id = EXCLUDED.team_id, @@ -1090,18 +1090,6 @@ ON CONFLICT (id) DO UPDATE SET next_run_at_ms = EXCLUDED.next_run_at_ms, run_now_at_ms = EXCLUDED.run_now_at_ms, created_at_ms = EXCLUDED.created_at_ms, - updated_at_ms = EXCLUDED.updated_at_ms, - version = EXCLUDED.version, - destination = EXCLUDED.destination, - created_by = EXCLUDED.created_by, - conversation_access = EXCLUDED.conversation_access, - credential_subject = EXCLUDED.credential_subject, - execution_actor = EXCLUDED.execution_actor, - last_run_at_ms = EXCLUDED.last_run_at_ms, - original_request = EXCLUDED.original_request, - schedule = EXCLUDED.schedule, - status_reason = EXCLUDED.status_reason, - task = EXCLUDED.task, record = EXCLUDED.record `, [ @@ -1111,18 +1099,6 @@ ON CONFLICT (id) DO UPDATE SET task.nextRunAtMs ?? null, task.runNowAtMs ?? null, task.createdAtMs, - task.updatedAtMs, - task.version, - json(task.destination), - json(task.createdBy), - task.conversationAccess ? json(task.conversationAccess) : null, - task.credentialSubject ? json(task.credentialSubject) : null, - task.executionActor ? json(task.executionActor) : null, - task.lastRunAtMs ?? null, - task.originalRequest ?? null, - json(task.schedule), - task.statusReason ?? null, - json(task.task), json(task), ], ); @@ -1135,52 +1111,18 @@ INSERT INTO junior_scheduler_runs ( id, task_id, status, - claimed_at_ms, scheduled_for_ms, - started_at_ms, - completed_at_ms, - dispatch_id, - error_message, - idempotency_key, - result_message_ts, - task_version, - attempt, record ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, - $9, $10, $11, $12, $13, $14::jsonb + $1, $2, $3, $4, $5::jsonb ) ON CONFLICT (id) DO UPDATE SET task_id = EXCLUDED.task_id, status = EXCLUDED.status, - claimed_at_ms = EXCLUDED.claimed_at_ms, scheduled_for_ms = EXCLUDED.scheduled_for_ms, - started_at_ms = EXCLUDED.started_at_ms, - completed_at_ms = EXCLUDED.completed_at_ms, - dispatch_id = EXCLUDED.dispatch_id, - error_message = EXCLUDED.error_message, - idempotency_key = EXCLUDED.idempotency_key, - result_message_ts = EXCLUDED.result_message_ts, - task_version = EXCLUDED.task_version, - attempt = EXCLUDED.attempt, record = EXCLUDED.record `, - [ - run.id, - run.taskId, - run.status, - run.claimedAtMs, - run.scheduledForMs, - run.startedAtMs ?? null, - run.completedAtMs ?? null, - run.dispatchId ?? null, - run.errorMessage ?? null, - run.idempotencyKey, - run.resultMessageTs ?? null, - run.taskVersion, - run.attempt, - json(run), - ], + [run.id, run.taskId, run.status, run.scheduledForMs, json(run)], ); } @@ -1439,7 +1381,6 @@ ORDER BY created_at_ms ASC, id ASC status: nextStatus, statusReason: nextStatus === "paused" ? errorMessage : undefined, updatedAtMs: args.nowMs, - version: current.version + 1, }, current, ); @@ -1612,7 +1553,6 @@ ORDER BY created_at_ms ASC, id ASC statusReason: args.status === "blocked" ? args.errorMessage : undefined, updatedAtMs: args.nowMs, - version: current.version + 1, }, current, ); @@ -1629,7 +1569,6 @@ ORDER BY created_at_ms ASC, id ASC ...current, lastRunAtMs: args.run.scheduledForMs, updatedAtMs: args.nowMs, - version: current.version + 1, }, current, ); @@ -1656,7 +1595,6 @@ ORDER BY created_at_ms ASC, id ASC statusReason: args.status === "blocked" ? args.errorMessage : undefined, updatedAtMs: args.nowMs, - version: current.version + 1, }, current, ); diff --git a/packages/junior-scheduler/src/types.ts b/packages/junior-scheduler/src/types.ts index 3b84cb42a..c4075f851 100644 --- a/packages/junior-scheduler/src/types.ts +++ b/packages/junior-scheduler/src/types.ts @@ -83,7 +83,6 @@ export interface ScheduledTask { statusReason?: string; task: ScheduledTaskSpec; updatedAtMs: number; - version: number; } export interface ScheduledRun { @@ -93,11 +92,9 @@ export interface ScheduledRun { completedAtMs?: number; dispatchId?: string; errorMessage?: string; - idempotencyKey: string; resultMessageTs?: string; scheduledForMs: number; startedAtMs?: number; status: ScheduledRunStatus; taskId: string; - taskVersion: number; } diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index b2e6d321e..ad86c007b 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -86,7 +86,6 @@ function createTask(overrides: Partial = {}): ScheduledTask { text: "Post a digest.", }, updatedAtMs: TEST_RUN_AT_MS, - version: 1, ...overrides, }; } @@ -286,7 +285,6 @@ describe("scheduler SQL plugin storage", () => { status: "active", statusReason: undefined, updatedAtMs: TEST_NOW_MS + 3, - version: task.version + 2, }); await expect( @@ -566,14 +564,8 @@ INSERT INTO junior_scheduler_tasks ( status, next_run_at_ms, created_at_ms, - updated_at_ms, - version, - destination, - created_by, - schedule, - task, record -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +) VALUES ($1, $2, $3, $4, $5, $6) `, [ "sched_bad_record", @@ -581,12 +573,6 @@ INSERT INTO junior_scheduler_tasks ( "active", TEST_RUN_AT_MS, TEST_RUN_AT_MS - 1, - TEST_RUN_AT_MS - 1, - 1, - JSON.stringify(task.destination), - JSON.stringify(task.createdBy), - JSON.stringify(task.schedule), - JSON.stringify(task.task), JSON.stringify({ id: "sched_bad_record" }), ], ); @@ -600,14 +586,8 @@ INSERT INTO junior_scheduler_tasks ( status, next_run_at_ms, created_at_ms, - updated_at_ms, - version, - destination, - created_by, - schedule, - task, record -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +) VALUES ($1, $2, $3, $4, $5, $6) `, [ "sched_bad_string_record", @@ -615,12 +595,6 @@ INSERT INTO junior_scheduler_tasks ( "active", TEST_RUN_AT_MS, TEST_RUN_AT_MS - 1, - TEST_RUN_AT_MS - 1, - 1, - JSON.stringify(task.destination), - JSON.stringify(task.createdBy), - JSON.stringify(task.schedule), - JSON.stringify(task.task), JSON.stringify("not-json"), ], ); @@ -633,23 +607,15 @@ INSERT INTO junior_scheduler_runs ( id, task_id, status, - claimed_at_ms, scheduled_for_ms, - idempotency_key, - task_version, - attempt, record -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +) VALUES ($1, $2, $3, $4, $5) `, [ "sched_bad_run", task.id, "pending", - TEST_NOW_MS - 120_000, TEST_RUN_AT_MS - 60_000, - "sched_bad_run", - 1, - 1, JSON.stringify({ id: "sched_bad_run" }), ], ); @@ -660,23 +626,15 @@ INSERT INTO junior_scheduler_runs ( id, task_id, status, - claimed_at_ms, scheduled_for_ms, - idempotency_key, - task_version, - attempt, record -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +) VALUES ($1, $2, $3, $4, $5) `, [ "sched_bad_string_run", task.id, "pending", - TEST_NOW_MS - 120_000, TEST_RUN_AT_MS - 60_000, - "sched_bad_string_run", - 1, - 1, JSON.stringify("not-json"), ], ); diff --git a/packages/junior/tests/integration/heartbeat.test.ts b/packages/junior/tests/integration/heartbeat.test.ts index a989a1d22..0d1975f7a 100644 --- a/packages/junior/tests/integration/heartbeat.test.ts +++ b/packages/junior/tests/integration/heartbeat.test.ts @@ -102,7 +102,6 @@ function createTask(overrides: Partial = {}): ScheduledTask { text: "Post a digest. Summarize the latest state.", }, updatedAtMs: nextRunAtMs, - version: 1, ...overrides, }; } diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index fb38981fe..c4eef0c65 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -582,7 +582,6 @@ describe("Slack schedule tools", () => { id: taskId, task: "Daily scheduler digest: Summarize open scheduler issues.", schedule: "Every day at 9am", - version: 2, }, }); @@ -628,7 +627,6 @@ describe("Slack schedule tools", () => { schedule: { description: "Every Monday at 9am", }, - version: 1, }); }); @@ -773,7 +771,6 @@ describe("Slack schedule tools", () => { task: { id: created.task.id, task: "Team-owned digest: Summarize open scheduler issues.", - version: 2, }, }); expect(deleted).toMatchObject({ @@ -794,7 +791,6 @@ describe("Slack schedule tools", () => { task: { text: "Team-owned digest: Summarize open scheduler issues.", }, - version: 3, }); }); @@ -946,7 +942,6 @@ describe("Slack schedule tools", () => { ...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( @@ -987,7 +982,6 @@ describe("Slack schedule tools", () => { status: "blocked", statusReason: "Missing GitHub credentials.", updatedAtMs: Date.parse("2026-05-25T16:01:00.000Z"), - version: task!.version + 1, }); const updated = await executeTool( @@ -1025,7 +1019,6 @@ describe("Slack schedule tools", () => { ...task!, nextRunAtMs: scheduledNextRunAtMs, updatedAtMs: Date.parse("2026-05-25T16:01:00.000Z"), - version: task!.version + 1, }); const beforeMs = Date.now(); @@ -1081,7 +1074,6 @@ describe("Slack schedule tools", () => { status: "paused", statusReason: "Paused by user.", updatedAtMs: Date.parse("2026-05-25T16:01:00.000Z"), - version: task!.version + 1, }); await expect( diff --git a/specs/scheduler.md b/specs/scheduler.md index f42ccff39..791dc4253 100644 --- a/specs/scheduler.md +++ b/specs/scheduler.md @@ -135,12 +135,16 @@ The scheduler is a trusted runtime plugin and requires plugin SQL storage. Its plugin package owns the scheduler migration files under `migrations/`, and `junior upgrade` applies those migrations before scheduler storage hooks run. -The SQL store keeps task and run records in scheduler-owned tables: - -- `junior_scheduler_tasks` stores current task state, destination fields, due - timestamps, schedule metadata, and the full task JSON record. -- `junior_scheduler_runs` stores run claims, dispatch ids, terminal status, - attempt metadata, and the full run JSON record. +The SQL store keeps task and run records in scheduler-owned tables. The JSON +`record` column is the canonical durable scheduler record; scalar columns are +minimal query projections, not duplicate source-of-truth fields. + +- `junior_scheduler_tasks` stores task identity, team/status/due-time + projections for lookup and heartbeat scans, and the full task JSON record. +- `junior_scheduler_runs` stores run identity, task/status/scheduled-time + projections for idempotency and recovery scans, and the full compact run JSON + record. Run records are execution-control state, not rich result or log + storage. The scheduler store interface remains the stable boundary for tools, heartbeat, and operational reporting. Runtime hook bodies use plugin SQL through `ctx.db`;