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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

> Append-only session log. Read at session start. Update at session end.

## 2026-07-15 — Scheduled Tasks review follow-up

Addressed the Codex reviews on AXI-92. Manual Run now treats a lost atomic
claim as a conflict instead of reporting a false success and advances an overdue
occurrence so the worker cannot immediately duplicate the manual run. Execution
claims recheck workspace archival; edits reject concurrent running, pause, or
resume changes; and stale resume calls cannot overwrite an active task's due
occurrence. These guards use the same atomic database predicates as their
mutations. The schedule form now exposes the runtime's full IANA timezone set
with a compatibility fallback. The worker's global due-task sweep has an
`enabled, nextRunAt` index matching its filter and ordering. Added focused
regression coverage for the manual-run, archival, timezone, and lifecycle
guards.

## 2026-07-16 — Project-declared branch topology

Reconciled the shared delivery policy after clarifying Forge's trunk-based flow
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export default defineConfig({
{
text: "Automation Surfaces",
items: [
{ text: "Scheduled tasks", link: "/automation/scheduled-tasks.html" },
{ text: "Webhooks", link: "/automation/webhooks.html" },
{ text: "Plugins", link: "/automation/plugins.html" },
{ text: "API Keys", link: "/automation/api-keys.html" },
Expand Down
45 changes: 45 additions & 0 deletions docs/automation/scheduled-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Scheduled tasks

Scheduled tasks are Forge's first-class recurring automation surface. They are
separate from legacy recurring issues: existing recurring issue definitions and
their behavior are unchanged.

Open **Automation → Scheduled tasks** in a workspace to create and monitor a
task. Owners and admins can manage tasks; every workspace member can inspect
their schedule, current status, error state, and recent runs.

## Create-issue action

The initial supported action creates a real Forge issue through the same
canonical service used by the rest of the product. Configure:

- a task name and issue title;
- a prompt, which becomes the issue description;
- issue priority;
- delivery to the workspace inbox or an active project; and
- an interval, daily, or weekly schedule.

Daily and weekly schedules use an IANA timezone and retain their local
wall-clock time across daylight-saving changes. Interval schedules are elapsed
time based and run at least five minutes apart.

## Runs and failures

Every attempt gets a durable run row before the action begins. Successful runs
link to the issue they created. Failed runs retain a useful error message on
both the task and run history.

For scheduled attempts, Forge calculates and saves the next future occurrence
before executing the action. A failed action therefore remains visibly active
for its next run. Pausing is the only lifecycle action that clears `nextRunAt`;
resuming computes a new future occurrence.

**Run now** executes immediately without moving the regular future occurrence.
Deleting a task requires typing its exact name and removes its run history, but
keeps issues created by earlier runs.

## Worker operation

The BullMQ maintenance worker scans due tasks every minute. Run `pnpm worker`
alongside the web process in deployments that do not enable in-process workers.
The legacy `RecurringIssue` ticker continues independently.
89 changes: 89 additions & 0 deletions prisma/migrations/0107_scheduled_tasks/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
-- First-class recurring automation tasks. Legacy RecurringIssue rows remain
-- untouched and continue to use their existing ticker.

CREATE TYPE "ScheduledTaskAction" AS ENUM ('CREATE_ISSUE');
CREATE TYPE "ScheduledTaskScheduleType" AS ENUM ('INTERVAL', 'DAILY', 'WEEKLY');
CREATE TYPE "ScheduledTaskDeliveryType" AS ENUM ('INBOX', 'PROJECT');
CREATE TYPE "ScheduledTaskStatus" AS ENUM ('ACTIVE', 'RUNNING', 'SUCCEEDED', 'FAILED', 'PAUSED');
CREATE TYPE "ScheduledTaskRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
CREATE TYPE "ScheduledTaskRunTrigger" AS ENUM ('SCHEDULE', 'MANUAL');

ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_CREATED';
ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_UPDATED';
ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_DELETED';
ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_RUN_STARTED';
ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_RUN_SUCCEEDED';
ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_RUN_FAILED';

CREATE TABLE "ScheduledTask" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"projectId" TEXT,
"name" TEXT NOT NULL,
"action" "ScheduledTaskAction" NOT NULL DEFAULT 'CREATE_ISSUE',
"prompt" TEXT NOT NULL,
"issueTitle" TEXT NOT NULL,
"issuePriority" "Priority" NOT NULL DEFAULT 'NONE',
"deliveryType" "ScheduledTaskDeliveryType" NOT NULL DEFAULT 'INBOX',
"scheduleType" "ScheduledTaskScheduleType" NOT NULL,
"intervalMinutes" INTEGER,
"timeOfDayMinutes" INTEGER,
"dayOfWeek" INTEGER,
"timezone" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"status" "ScheduledTaskStatus" NOT NULL DEFAULT 'ACTIVE',
"nextRunAt" TIMESTAMP(3),
"lastRunAt" TIMESTAMP(3),
"lastSucceededAt" TIMESTAMP(3),
"lastError" TEXT,
"consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ScheduledTask_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "ScheduledTaskRun" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"scheduledTaskId" TEXT NOT NULL,
"outputIssueId" TEXT,
"status" "ScheduledTaskRunStatus" NOT NULL DEFAULT 'RUNNING',
"trigger" "ScheduledTaskRunTrigger" NOT NULL,
"scheduledForAt" TIMESTAMP(3),
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ScheduledTaskRun_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "ScheduledTask_workspaceId_enabled_nextRunAt_idx"
ON "ScheduledTask"("workspaceId", "enabled", "nextRunAt");
Comment on lines +61 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a global due-task index

The worker sweep is global across workspaces (enabled/nextRunAt plus ORDER BY nextRunAt in sweepScheduledTasks), but this index leads with workspaceId, which is not constrained by that query. As scheduled tasks accumulate across tenants, the once-per-minute worker cannot read due rows in next-run order from this index and will have to scan/sort; add a worker-oriented index starting with enabled, nextRunAt (or an equivalent partial index).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e930b0d. Added Prisma index [enabled, nextRunAt] plus migration 0107; the migration applied cleanly and the live dev schema reports ScheduledTask_enabled_nextRunAt_idx with the expected btree columns.

CREATE INDEX "ScheduledTask_workspaceId_status_idx"
ON "ScheduledTask"("workspaceId", "status");
CREATE INDEX "ScheduledTask_projectId_idx" ON "ScheduledTask"("projectId");
CREATE INDEX "ScheduledTaskRun_scheduledTaskId_createdAt_idx"
ON "ScheduledTaskRun"("scheduledTaskId", "createdAt");
CREATE INDEX "ScheduledTaskRun_workspaceId_createdAt_idx"
ON "ScheduledTaskRun"("workspaceId", "createdAt");
CREATE INDEX "ScheduledTaskRun_outputIssueId_idx" ON "ScheduledTaskRun"("outputIssueId");

ALTER TABLE "ScheduledTask"
ADD CONSTRAINT "ScheduledTask_workspaceId_fkey"
FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ScheduledTask"
ADD CONSTRAINT "ScheduledTask_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ScheduledTask"
ADD CONSTRAINT "ScheduledTask_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ScheduledTaskRun"
ADD CONSTRAINT "ScheduledTaskRun_workspaceId_fkey"
FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ScheduledTaskRun"
ADD CONSTRAINT "ScheduledTaskRun_scheduledTaskId_fkey"
FOREIGN KEY ("scheduledTaskId") REFERENCES "ScheduledTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ScheduledTaskRun"
ADD CONSTRAINT "ScheduledTaskRun_outputIssueId_fkey"
FOREIGN KEY ("outputIssueId") REFERENCES "Issue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
3 changes: 3 additions & 0 deletions prisma/migrations/0108_scheduled_task_due_index/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Support the global worker sweep, which filters enabled tasks and orders by nextRunAt.
CREATE INDEX "ScheduledTask_enabled_nextRunAt_idx"
ON "ScheduledTask"("enabled", "nextRunAt");
108 changes: 108 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ enum EventKind {
PLAN_BUDGET_EXCEEDED
/// A RUNNING plan has no valid path to make progress without operator action.
PLAN_STALLED
SCHEDULED_TASK_CREATED
SCHEDULED_TASK_UPDATED
SCHEDULED_TASK_DELETED
SCHEDULED_TASK_RUN_STARTED
SCHEDULED_TASK_RUN_SUCCEEDED
SCHEDULED_TASK_RUN_FAILED
}

/// Operator-requested control on an in-flight AgentRun. NONE is the
Expand Down Expand Up @@ -211,6 +217,40 @@ enum CompletionAutomation {
AUTO_WHEN_SAFE
}

enum ScheduledTaskAction {
CREATE_ISSUE
}

enum ScheduledTaskScheduleType {
INTERVAL
DAILY
WEEKLY
}

enum ScheduledTaskDeliveryType {
INBOX
PROJECT
}

enum ScheduledTaskStatus {
ACTIVE
RUNNING
SUCCEEDED
FAILED
PAUSED
}

enum ScheduledTaskRunStatus {
RUNNING
SUCCEEDED
FAILED
}

enum ScheduledTaskRunTrigger {
SCHEDULE
MANUAL
}

/// Canonical lifecycle for an isolated code worktree/branch attached to an issue.
/// GitHub remains authoritative for PR/check/review facts; Forge owns coordination.
enum WorkSessionStatus {
Expand Down Expand Up @@ -643,6 +683,7 @@ model User {
claimedIssues Issue[] @relation("IssueClaimedBy")
defaultIssueAssigneeForWorkspaces Workspace[] @relation("WorkspaceDefaultIssueAssignee")
recurringCreated RecurringIssue[] @relation("RecurringCreator")
scheduledTasksCreated ScheduledTask[] @relation("ScheduledTaskCreator")
savedViews SavedView[]
issueSavedViews IssueSavedView[]
timeEntries TimeEntry[]
Expand Down Expand Up @@ -982,6 +1023,8 @@ model Workspace {
templates IssueTemplate[]
projectTemplates ProjectTemplate[]
recurring RecurringIssue[]
scheduledTasks ScheduledTask[]
scheduledTaskRuns ScheduledTaskRun[]
savedViews SavedView[]
issueSavedViews IssueSavedView[]
cycles Cycle[]
Expand Down Expand Up @@ -1098,6 +1141,7 @@ model Project {
issues Issue[]
templates IssueTemplate[]
recurring RecurringIssue[]
scheduledTasks ScheduledTask[]
dispatchRules DispatchRule[]
artifacts Artifact[]
executionPlans ExecutionPlan[]
Expand Down Expand Up @@ -1250,6 +1294,7 @@ model Issue {
actionRequests ActionRequest[]
externalLinks ExternalResourceLink[]
workSessions WorkSession[]
scheduledTaskRuns ScheduledTaskRun[]

@@unique([workspaceId, number])
@@index([workspaceId, statusId])
Expand Down Expand Up @@ -1330,6 +1375,69 @@ model RecurringIssue {
@@index([workspaceId])
}

/// Recurring automation independent from the legacy RecurringIssue feature.
/// INTERVAL uses intervalMinutes, DAILY uses timeOfDayMinutes, and WEEKLY
/// uses timeOfDayMinutes + dayOfWeek (0=Sunday ... 6=Saturday).
model ScheduledTask {
id String @id @default(cuid())
workspaceId String
createdById String
projectId String?
name String
action ScheduledTaskAction @default(CREATE_ISSUE)
prompt String @db.Text
issueTitle String
issuePriority Priority @default(NONE)
deliveryType ScheduledTaskDeliveryType @default(INBOX)
scheduleType ScheduledTaskScheduleType
intervalMinutes Int?
timeOfDayMinutes Int?
dayOfWeek Int?
timezone String
enabled Boolean @default(true)
status ScheduledTaskStatus @default(ACTIVE)
nextRunAt DateTime?
lastRunAt DateTime?
lastSucceededAt DateTime?
lastError String? @db.Text
consecutiveFailures Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("ScheduledTaskCreator", fields: [createdById], references: [id])
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
runs ScheduledTaskRun[]

@@index([enabled, nextRunAt])
@@index([workspaceId, enabled, nextRunAt])
@@index([workspaceId, status])
@@index([projectId])
}

/// Durable execution attempt, created before the configured action runs.
model ScheduledTaskRun {
id String @id @default(cuid())
workspaceId String
scheduledTaskId String
outputIssueId String?
status ScheduledTaskRunStatus @default(RUNNING)
trigger ScheduledTaskRunTrigger
scheduledForAt DateTime?
startedAt DateTime @default(now())
completedAt DateTime?
error String? @db.Text
createdAt DateTime @default(now())

workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
scheduledTask ScheduledTask @relation(fields: [scheduledTaskId], references: [id], onDelete: Cascade)
outputIssue Issue? @relation(fields: [outputIssueId], references: [id], onDelete: SetNull)

@@index([scheduledTaskId, createdAt])
@@index([workspaceId, createdAt])
@@index([outputIssueId])
}

/// Bookmarked filter preset. userId null = shared workspace view.
model SavedView {
id String @id @default(cuid())
Expand Down
Loading
Loading