Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ebe3bbb
feat: add automation scheduler service
Astro-Han May 29, 2026
27fae00
feat: enforce scheduled automation no-overlap
Astro-Han May 29, 2026
3527751
feat: wire automation scheduler to mutations
Astro-Han May 29, 2026
de5d2b4
fix: address automation scheduler review feedback
Astro-Han May 29, 2026
5d9ef63
fix: keep recurring scheduler alive after overlap
Astro-Han May 29, 2026
03a284d
refactor: supervise automation scheduler tasks
Astro-Han May 29, 2026
5f0da9a
fix: anchor recurring scheduler after manual completion
Astro-Han May 29, 2026
a7b443c
fix: cover scheduler run lifecycle and stop rules
Astro-Han May 29, 2026
867bbfa
fix: count all completed automation runs
Astro-Han May 29, 2026
6a82ead
fix: schedule automation definition events
Astro-Han May 29, 2026
4427058
fix: enforce recurring count before firing
Astro-Han May 29, 2026
e2ae6e3
fix: stop scheduler runs before instance disposal
Astro-Han May 29, 2026
2634c31
fix: preserve timers after manual writer skips
Astro-Han May 29, 2026
e77bed1
fix: handle missed scheduler edge cases
Astro-Han May 29, 2026
bb67bc0
fix: start scheduler before definition events
Astro-Han May 29, 2026
b743101
test: cover route scheduler lazy start
Astro-Han May 29, 2026
2ae0f78
fix: relax missed schedule grace
Astro-Han May 30, 2026
65bf291
fix: keep instance disposal best effort
Astro-Han May 30, 2026
ecb2f90
fix: preserve automation scheduler timers
Astro-Han May 30, 2026
cdab055
fix: defer recurring timers during scheduled runs
Astro-Han May 30, 2026
0a11324
Merge remote-tracking branch 'origin/dev' into codex/i950-automation-…
Astro-Han May 30, 2026
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
65 changes: 56 additions & 9 deletions packages/opencode/src/automation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export namespace Automation {
definitions: Map<string, Definition>
runs: Map<string, Run[]>
activeWriters: Set<string>
activeRuns: Map<string, { writerKey: string; controller: AbortController }>
activeRuns: Map<string, { writerKey: string; controller: AbortController; runID: string }>
}
const state = Instance.state<State>(() => ({
definitions: new Map(),
Expand Down Expand Up @@ -503,11 +503,15 @@ export namespace Automation {
return replaceRun(Run.parse(next))
}

function stopRun(run: Run, stopReason: Extract<Run, { state: "stopped" }>["stopReason"]): Run {
function stopRun(
run: Run,
stopReason: Extract<Run, { state: "stopped" }>["stopReason"],
options?: { now?: number },
): Run {
if (run.state === "stopped" || run.state === "succeeded" || run.state === "failed") return run
return reviseRun(run, {
state: "stopped",
completedAt: Date.now(),
completedAt: options?.now ?? Date.now(),
result: null,
error: null,
stopReason,
Expand All @@ -518,12 +522,26 @@ export namespace Automation {
const active = state().activeRuns.get(automationID)
if (!active) return undefined
active.controller.abort()
const current = state().runs.get(automationID)?.find((run) => (
run.state === "scheduled" || run.state === "running" || run.state === "awaiting_input"
))
const current = state().runs.get(automationID)?.find((run) => run.id === active.runID)
return current ? stopRun(current, "cancelled") : undefined
}

export function stopRunByID(
runID: string,
stopReason: Extract<Run, { state: "stopped" }>["stopReason"],
options?: { now?: number },
): Run | undefined {
for (const runs of state().runs.values()) {
const run = runs.find((item) => item.id === runID)
if (!run) continue
const active = state().activeRuns.get(run.automationID)
if (active?.runID === runID) active.controller.abort()
const stopped = stopRun(run, stopReason, options)
return stopped === run ? undefined : stopped
}
return undefined
}

export function markRunStarted(run: Run, sessionID: SessionID, options?: { now?: number }): Run {
return reviseRun(run, {
state: "running",
Expand Down Expand Up @@ -584,6 +602,32 @@ export namespace Automation {
return run
}

export function hasActiveRun(automationID: string): boolean {
if (state().activeRuns.has(automationID)) return true
return (state().runs.get(automationID) ?? []).some(
(run) => run.state === "scheduled" || run.state === "running" || run.state === "awaiting_input",
)
}

export function hasRunTriggeredAtOrAfter(automationID: string, triggeredAt: number): boolean {
return (state().runs.get(automationID) ?? []).some((run) => run.triggeredAt >= triggeredAt)
}

export function completedRunCount(automationID: string): number {
get(automationID)
const runs = state().runs.get(automationID) ?? []
return runs.filter((run) => run.state === "succeeded" || run.state === "failed").length
}

export function recordStoppedRun(
automationID: string,
stopReason: Extract<Run, { state: "stopped" }>["stopReason"],
options?: { now?: number; triggeredAt?: number },
): Run {
const run = runNow(automationID, { now: options?.triggeredAt ?? options?.now })
return stopRun(run, stopReason, options)
}

export function runNowExecuting(
id: string,
options: { executor: RunExecutor; attendance?: AutomationRunAttendance; now?: number },
Expand All @@ -608,7 +652,7 @@ export namespace Automation {
}
data.activeWriters.add(writerKey)
const controller = new AbortController()
data.activeRuns.set(initial.automationID, { writerKey, controller })
data.activeRuns.set(initial.automationID, { writerKey, controller, runID: initial.id })
let current = initial
try {
const prepared = await executor({ definition, run: initial, attendance, signal: controller.signal })
Expand Down Expand Up @@ -663,8 +707,11 @@ export namespace Automation {
})
await publishRunUpdated(failed)
} finally {
data.activeRuns.delete(initial.automationID)
data.activeWriters.delete(writerKey)
const active = data.activeRuns.get(initial.automationID)
if (active?.runID === initial.id) {
data.activeRuns.delete(initial.automationID)
data.activeWriters.delete(writerKey)
}
}
}

Expand Down
Loading