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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import Migration0077 from "./Migrations/077_RepairBooleanSettledOverride.ts";
import Migration0078 from "./Migrations/078_DropOrchestrationEventCorrelationIndex.ts";
import Migration0079 from "./Migrations/079_DelegatedRecoveryIndexes.ts";
import Migration0080 from "./Migrations/080_ScheduledTaskThreadMode.ts";
import Migration0081 from "./Migrations/081_ScheduledTaskImportance.ts";

const migrationEntries = [
[1, "OrchestrationEvents", Migration0001],
Expand Down Expand Up @@ -166,6 +167,7 @@ const migrationEntries = [
[78, "DropOrchestrationEventCorrelationIndex", Migration0078],
[79, "DelegatedRecoveryIndexes", Migration0079],
[80, "ScheduledTaskThreadMode", Migration0080],
[81, "ScheduledTaskImportance", Migration0081],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

// NULL classifies as routine: existing tasks keep the missed-run skip they had.
yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN importance TEXT`;
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe("V2 preview upgrade", () => {
[78, "DropOrchestrationEventCorrelationIndex"],
[79, "DelegatedRecoveryIndexes"],
[80, "ScheduledTaskThreadMode"],
[81, "ScheduledTaskImportance"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
Expand Down Expand Up @@ -163,6 +164,7 @@ describe("V2 preview upgrade", () => {
[78, "DropOrchestrationEventCorrelationIndex"],
[79, "DelegatedRecoveryIndexes"],
[80, "ScheduledTaskThreadMode"],
[81, "ScheduledTaskImportance"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
Expand Down
40 changes: 39 additions & 1 deletion apps/server/src/scheduledTasks/Schedule.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";

import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
import type { ScheduledTaskSchedule } from "@t3tools/contracts";

import { isSameSchedule, nextScheduledRunAt, skipsMissedRun } from "./Schedule.ts";

describe("scheduled task schedule calculation", () => {
it("skips to the next matching fixed-time weekday", () => {
Expand Down Expand Up @@ -83,3 +85,39 @@ describe("scheduled task schedule calculation", () => {
).toBe(false);
});
});

describe("missed fixed-time runs", () => {
const dueAt = DateTime.makeZonedUnsafe(
{ year: 2026, month: 11, day: 10, hour: 7, minute: 30, second: 0, millisecond: 0 },
{ timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true },
);
const withinGrace = DateTime.add(dueAt, { minutes: 5 });
const twoHoursLate = DateTime.add(dueAt, { hours: 2 });
const fixed: ScheduledTaskSchedule = { type: "fixed_time", timeOfDay: "07:30" };

it("skips a routine fixed-time run more than the grace late", () => {
expect(skipsMissedRun({ schedule: fixed }, dueAt, withinGrace)).toBe(false);
expect(skipsMissedRun({ schedule: fixed }, dueAt, twoHoursLate)).toBe(true);
expect(skipsMissedRun({ schedule: fixed, importance: "routine" }, dueAt, twoHoursLate)).toBe(
true,
);
});

it("issues an important or time-sensitive run regardless of how late its slot was", () => {
expect(skipsMissedRun({ schedule: fixed, importance: "important" }, dueAt, twoHoursLate)).toBe(
false,
);
expect(
skipsMissedRun({ schedule: fixed, importance: "time_sensitive" }, dueAt, twoHoursLate),
).toBe(false);
// The classification only reads on fixed-time schedules; other kinds were never skipped.
const interval: ScheduledTaskSchedule = { type: "interval", everyMs: 60_000 };
expect(
skipsMissedRun({ schedule: interval, importance: "important" }, dueAt, twoHoursLate),
).toBe(false);
const once: ScheduledTaskSchedule = { type: "once", runAt: "2026-11-10T07:30:00.000Z" };
expect(
skipsMissedRun({ schedule: once, importance: "time_sensitive" }, dueAt, twoHoursLate),
).toBe(false);
});
});
29 changes: 28 additions & 1 deletion apps/server/src/scheduledTasks/Schedule.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts";
import {
MIN_SCHEDULED_TASK_INTERVAL_MS,
type ScheduledTaskImportance,
type ScheduledTaskSchedule,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";

Expand Down Expand Up @@ -80,3 +84,26 @@ export function isMissedFixedTimeRun(
if (schedule.type !== "fixed_time") return false;
return DateTime.toEpochMillis(now) - DateTime.toEpochMillis(dueAt) > MISSED_FIXED_TIME_GRACE_MS;
}

/** Classifications whose runs are issued regardless of how late their slot came due. */
const ISSUED_REGARDLESS: ReadonlySet<ScheduledTaskImportance> = new Set([
"important",
"time_sensitive",
]);

/**
* Whether a due run is dropped as missed instead of issued. A fixed-time slot more than the grace
* late is skipped, unless the task is classified `important` or `time_sensitive`: that run is
* issued regardless — the classification for tasks whose timing matters more than the skip.
*/
export function skipsMissedRun(
task: {
readonly schedule: ScheduledTaskSchedule;
readonly importance?: ScheduledTaskImportance | undefined;
},
dueAt: DateTime.DateTime,
now: DateTime.DateTime,
): boolean {
if (task.importance !== undefined && ISSUED_REGARDLESS.has(task.importance)) return false;
return isMissedFixedTimeRun(task.schedule, dueAt, now);
}
104 changes: 104 additions & 0 deletions apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/sql/SqlClient";
import * as TestClock from "effect/testing/TestClock";

import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
Expand Down Expand Up @@ -234,3 +236,105 @@ it.effect(
}).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
}).pipe(Effect.provide(SqlitePersistence.layerMemory)),
);

it.effect(
"issues a missed fixed-time run for tasks classified important or time-sensitive, not for routine ones",
() =>
Effect.gen(function* () {
const dueAt = DateTime.makeZonedUnsafe(
{ year: 2026, month: 11, day: 10, hour: 9, minute: 0, second: 0, millisecond: 0 },
{ timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true },
);
// One minute before the slot, as if the environment was about to sleep through it.
yield* TestClock.setTime(DateTime.toEpochMillis(dueAt) - 60_000);
const sql = yield* SqlClient.SqlClient;
const launched = yield* Ref.make<string[]>([]);
// The scheduler is held: its ticks are captured and run by hand after the clock
// jumps, so no intermediate poll can see the slot while the "machine" sleeps.
const ticks = yield* Ref.make<ReadonlyArray<Effect.Effect<void>>>([]);
const layerDependencies = Layer.mergeAll(
NodeCrypto.layer,
ManagedPolicy.layerTest(),
Layer.mock(Scheduler.Scheduler)({
register: ((_name: string, runDueWork: Effect.Effect<void>) =>
Ref.update(ticks, (all) => [...all, runDueWork])) as never,
}),
Layer.mock(ThreadLaunchService.ThreadLaunchService)({
launch: (input) =>
Ref.update(launched, (titles) => [...titles, input.title]).pipe(
Effect.as({ threadId: "thread:missed-run-classification", resumed: false } as never),
),
}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
namedProjectsRoot: "/projects",
isScratchProject: () => Effect.succeed(false),
}),
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
);
yield* Effect.gen(function* () {
const service = yield* ScheduledTaskService.ScheduledTaskService;
const input = (title: string, importance?: "important" | "time_sensitive") => ({
id: `scheduled-task:missed-${title.toLowerCase()}`,
title,
prompt: `${title} prompt`,
enabled: true,
schedule: { type: "fixed_time", timeOfDay: "09:00" },
...(importance === undefined ? {} : { importance }),
projectId: "project-missed-run-classification",
workspaceStrategy: { type: "root" },
modelSelection: { instanceId: "codex", model: "gpt-5.4" },
runtimeMode: "full-access",
interactionMode: "default",
});
const routine = yield* service.upsert(yield* decodeUpsertInput(input("Routine")));
const important = yield* service.upsert(
yield* decodeUpsertInput(input("Important", "important")),
);
const sensitive = yield* service.upsert(
yield* decodeUpsertInput(input("Sensitive", "time_sensitive")),
);
const slot = DateTime.formatIso(DateTime.toUtc(dueAt));
expect(routine.task.nextRunAt).toBe(slot);
expect(important.task.nextRunAt).toBe(slot);
expect(sensitive.task.nextRunAt).toBe(slot);
expect(important.task.importance).toBe("important");
expect(sensitive.task.importance).toBe("time_sensitive");
expect(routine.task.importance).toBeUndefined();

// Sleep two hours past the slot: past the 10-minute missed-run grace.
yield* TestClock.adjust(
DateTime.toEpochMillis(DateTime.add(dueAt, { hours: 2 })) -
(DateTime.toEpochMillis(dueAt) - 60_000),
);
for (const tick of yield* Ref.get(ticks)) yield* tick;

expect(yield* Ref.get(launched)).toEqual(["Important", "Sensitive"]);
const rows = yield* sql<{
task_id: string;
last_run_status: string;
run_count: number;
next_run_at: string | null;
}>`SELECT task_id, last_run_status, run_count, next_run_at FROM scheduled_tasks ORDER BY task_id`;
const byId = new Map(rows.map((row) => [row.task_id, row]));
expect(byId.get("scheduled-task:missed-routine")).toMatchObject({
last_run_status: "never",
run_count: 0,
});
expect(byId.get("scheduled-task:missed-important")).toMatchObject({
last_run_status: "succeeded",
run_count: 1,
});
expect(byId.get("scheduled-task:missed-sensitive")).toMatchObject({
last_run_status: "succeeded",
run_count: 1,
});
// All three point at the next slot: the skipped one was rescheduled, not left due.
const nextRuns = new Set(rows.map((row) => row.next_run_at));
expect(nextRuns.size).toBe(1);
expect([...nextRuns][0]).toBe(
DateTime.formatIso(DateTime.toUtc(DateTime.add(dueAt, { days: 1 }))),
);
}).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
}).pipe(Effect.provide(SqlitePersistence.layerMemory)),
);
18 changes: 16 additions & 2 deletions apps/server/src/scheduledTasks/ScheduledTaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
import * as DueHint from "../scheduling/DueHint.ts";
import * as Scheduler from "../scheduling/Scheduler.ts";
import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
import { isSameSchedule, nextScheduledRunAt, skipsMissedRun } from "./Schedule.ts";
import {
redactHeaders,
redactQuery,
Expand Down Expand Up @@ -212,6 +212,7 @@ interface ScheduledTaskRow {
readonly prompt: string;
readonly enabled: number;
readonly schedule_json: string;
readonly importance: string | null;
readonly project_id: string;
readonly thread_id: string | null;
readonly thread_mode: string;
Expand Down Expand Up @@ -386,6 +387,10 @@ const decodeRow = Effect.fnUntraced(
prompt: row.prompt,
enabled: row.enabled === 1,
schedule,
// An unknown stored value reads as routine instead of making the row undecodable.
...(row.importance === "important" || row.importance === "time_sensitive"
? { importance: row.importance }
: {}),
projectId: row.project_id,
threadId: row.thread_id,
threadMode: row.thread_mode,
Expand Down Expand Up @@ -509,6 +514,7 @@ export const layer = Layer.effect(
prompt,
enabled,
schedule_json,
importance,
project_id,
thread_id,
thread_mode,
Expand Down Expand Up @@ -551,6 +557,7 @@ export const layer = Layer.effect(
prompt,
enabled,
schedule_json,
importance,
project_id,
thread_id,
thread_mode,
Expand Down Expand Up @@ -629,6 +636,7 @@ export const layer = Layer.effect(
prompt,
enabled,
schedule_json,
importance,
project_id,
thread_id,
thread_mode,
Expand All @@ -654,6 +662,7 @@ export const layer = Layer.effect(
${task.prompt},
${task.enabled ? 1 : 0},
${JSON.stringify(task.schedule)},
${task.importance ?? null},
${task.projectId},
${task.threadId},
${scheduledTaskThreadMode(task)},
Expand All @@ -680,6 +689,7 @@ export const layer = Layer.effect(
prompt = excluded.prompt,
enabled = excluded.enabled,
schedule_json = excluded.schedule_json,
importance = excluded.importance,
project_id = excluded.project_id,
-- A first run may bind the thread while this save is in flight; only a
-- switch to new threads unbinds.
Expand Down Expand Up @@ -1168,7 +1178,7 @@ export const layer = Layer.effect(
due,
({ task, dueAt }) =>
Effect.suspend(() =>
isMissedFixedTimeRun(task.schedule, dueAt, now)
skipsMissedRun(task, dueAt, now)
? rescheduleMissedRun(task, now)
: runTask(task, "scheduled"),
).pipe(
Expand Down Expand Up @@ -1331,6 +1341,9 @@ export const layer = Layer.effect(
// Keep the existing next_run_at when the schedule itself is untouched:
// editing a title or prompt must not postpone (or resurrect) a due
// run — only schedule/enabled changes restart the clock.

// Omitted keeps the stored classification, as webhook filters do; a new task starts routine.
const importance = input.importance ?? existingTask?.importance;
const schedule: ScheduledTask["schedule"] =
input.schedule.type === "webhook"
? {
Expand Down Expand Up @@ -1415,6 +1428,7 @@ export const layer = Layer.effect(
prompt: input.prompt,
enabled: input.enabled,
schedule,
...(importance === undefined ? {} : { importance }),
projectId: input.projectId,
threadId,
threadMode,
Expand Down
Loading
Loading