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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
CREATE TABLE `automation_run_next` (
`id` text PRIMARY KEY,
`automation_id` text NOT NULL,
`project_id` text NOT NULL,
`owner_directory` text NOT NULL,
`triggered_at` integer NOT NULL,
`data` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `fk_automation_run_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
INSERT INTO `automation_run_next` (
`id`,
`automation_id`,
`project_id`,
`owner_directory`,
`triggered_at`,
`data`,
`time_created`,
`time_updated`
)
SELECT
`id`,
`automation_id`,
`project_id`,
`owner_directory`,
`triggered_at`,
`data`,
`time_created`,
`time_updated`
FROM `automation_run`;
--> statement-breakpoint
DROP TABLE `automation_run`;
--> statement-breakpoint
ALTER TABLE `automation_run_next` RENAME TO `automation_run`;
--> statement-breakpoint
CREATE INDEX `automation_run_automation_triggered_idx` ON `automation_run` (`automation_id`,`triggered_at`,`id`);
--> statement-breakpoint
CREATE INDEX `automation_run_project_owner_idx` ON `automation_run` (`project_id`,`owner_directory`);
7 changes: 3 additions & 4 deletions packages/opencode/src/automation/__test_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ import type { Automation } from "./index"
/**
* @internal Test-only injection points for the automation module.
*
* Production code MUST NOT import or write to this module — the only reader
* is `recordRunOutcome` in `./index`, which checks `beforeReplaceDefinition`
* to support a deterministic ConflictError retry test. By living outside the
* `Automation` namespace, this seam is invisible to anyone consuming the
* Production code MUST NOT import or write to this module. By living outside
* the `Automation` namespace, these seams are invisible to anyone consuming the
* public `Automation.*` API surface.
*
* Tests assign hooks here and MUST clear them in a `finally` block so a
* failing test cannot leak state to a sibling test.
*/
export const internalTestHooks: {
beforeReplaceDefinition?: (previous: Automation.Definition) => void
beforeExecuteRun?: (run: Automation.Run) => void | Promise<void>
} = {}
4 changes: 1 addition & 3 deletions packages/opencode/src/automation/automation.sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ export const AutomationRunTable = sqliteTable(
"automation_run",
{
id: text().primaryKey().$type<Automation.Run["id"]>(),
automation_id: text()
.notNull()
.references(() => AutomationDefinitionTable.id, { onDelete: "cascade" }),
automation_id: text().notNull(),
project_id: text()
.$type<ProjectID>()
.notNull()
Expand Down
103 changes: 35 additions & 68 deletions packages/opencode/src/automation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,6 @@ export namespace Automation {
.object({ error: z.literal("automation_conflict"), message: z.string() })
.strict()
.meta({ ref: "AutomationConflictError" })
export const ActiveRunStillRunningErrorResponse = z
.object({ error: z.literal("active_run_still_running"), runID: RunID })
.strict()
.meta({ ref: "AutomationActiveRunStillRunningError" })
// Stop accepts all three kinds at the schema layer so create/update can
// return a structured `unsupported_stop_condition` error for `kind: "condition"`
// (rejected by validateCreateInput / validateUpdateInput). The agent-facing
Expand Down Expand Up @@ -800,13 +796,17 @@ export namespace Automation {
return true
}

export async function remove(id: string): Promise<{ tombstone: Tombstone; stoppedRun?: Run }> {
export async function remove(id: string): Promise<{ tombstone: Tombstone }> {
const previous = get(id)
const stoppedRun = stopActiveRun(id)
const liveRun = await getLiveActiveRun(id)
if (liveRun) throw new ActiveRunStillRunningError(liveRun.id)
Database.use((db) => db.delete(AutomationDefinitionTable).where(eq(AutomationDefinitionTable.id, id)).run())
return { tombstone: { id: previous.id, deleted: true, revision: previous.revision + 1 }, stoppedRun }
return { tombstone: { id: previous.id, deleted: true, revision: previous.revision + 1 } }
}

async function stopLiveRunForSourceDelete(id: string) {
const active = state().activeRuns.get(id)
if (!active) return
const stopped = stopRunByID(active.runID, "cancelled")
if (stopped) await publishRunUpdated(stopped)
}

// A continue automation lives inside the conversation it was created in
Expand All @@ -819,11 +819,11 @@ export namespace Automation {
for (const definition of list()) {
if (definition.context !== "continue" || definition.sourceSessionID !== sessionID) continue
try {
await stopLiveRunForSourceDelete(definition.id)
const removed = await remove(definition.id)
await Bus.publish(Event.DefinitionDeleted, removed.tombstone)
if (removed.stoppedRun) await publishRunUpdated(removed.stoppedRun)
} catch (error) {
if (NotFoundError.isInstance(error) || error instanceof ActiveRunStillRunningError) continue
if (NotFoundError.isInstance(error)) continue
throw error
}
}
Expand Down Expand Up @@ -892,39 +892,6 @@ export namespace Automation {
)
}

function stopActiveRun(automationID: string) {
const active = state().activeRuns.get(automationID)
if (!active) return undefined
active.controller.abort()
const current = getRun(active.runID)
return current ? stopRun(current, "cancelled") : undefined
}

async function getLiveActiveRun(automationID: string) {
get(automationID)
const projectID = Instance.project.id
const ownerDirectory = Instance.directory
const rows = Database.use((db) =>
db
.select()
.from(AutomationRunTable)
.where(
and(
eq(AutomationRunTable.automation_id, automationID),
eq(AutomationRunTable.project_id, projectID),
eq(AutomationRunTable.owner_directory, ownerDirectory),
sql`json_extract(${AutomationRunTable.data}, '$.state') in ('scheduled', 'running', 'awaiting_input')`,
),
)
.all(),
)
for (const row of rows) {
const run = Run.parse(row.data)
if (!isActiveRun(run)) continue
if (await hasLiveRunLease(run.id)) return run
}
}

export function stopRunByID(
runID: string,
stopReason: Extract<Run, { state: "stopped" }>["stopReason"],
Expand Down Expand Up @@ -1021,10 +988,9 @@ export namespace Automation {
return false
}

function hasDurableActiveWriter(run: Run, writerKey: string) {
const definition = get(run.automationID)
const projectID = definition.where.projectID
const ownerDirectory = Instance.directory
function hasDurableActiveWriter(run: Run, writerKey: string, scope: Scope) {
const projectID = scope.projectID
const ownerDirectory = scope.ownerDirectory
return Database.transaction(
(db) => {
const rows = db
Expand Down Expand Up @@ -1062,7 +1028,10 @@ export namespace Automation {
if (row.id === run.id) return false
const item = Run.parse(row.data)
if (!isActiveRun(item)) return false
return writerKeys.get(item.automationID) === writerKey
const rowWriterKey = writerKeys.get(item.automationID)
// A deleted definition leaves no writer key; while its run is active,
// keep the writer guard conservative within this project scope.
return rowWriterKey === undefined || rowWriterKey === writerKey
})
},
{ behavior: "immediate" },
Expand Down Expand Up @@ -1180,25 +1149,34 @@ export namespace Automation {
const runID = AutomationID.Run.ascending()
const lease = await Flock.acquire(runLeaseKey(Instance.directory, runID))
try {
const initial = runNow(id, { now: options.now, runID })
queueMicrotask(() => void executeRun(initial, options.executor, options.attendance ?? "attended", lease))
const scope = currentScope()
const definition = get(id, scope)
const initial = runNow(id, { now: options.now, runID, scope })
queueMicrotask(() => void executeRun(initial, definition, scope, options.executor, options.attendance ?? "attended", lease))
return initial
} catch (error) {
await lease.release().catch(() => undefined)
throw error
}
}

async function executeRun(initial: Run, executor: RunExecutor, attendance: AutomationRunAttendance, lease: Flock.Lease) {
async function executeRun(
initial: Run,
definition: Definition,
scope: Scope,
executor: RunExecutor,
attendance: AutomationRunAttendance,
lease: Flock.Lease,
) {
const data = state()
const controller = new AbortController()
let writerKey: string | undefined
let current = initial
try {
const definition = get(initial.automationID)
await internalTestHooks.beforeExecuteRun?.(initial)
writerKey = getWriterKey(definition)
for (const run of await reconcileInterruptedRuns()) await publishRunUpdated(run)
if (data.activeWriters.has(writerKey) || hasDurableActiveWriter(initial, writerKey)) {
for (const run of await reconcileInterruptedRuns({ scope })) await publishRunUpdated(run)
if (data.activeWriters.has(writerKey) || hasDurableActiveWriter(initial, writerKey, scope)) {
const stopped = reviseRun(initial, {
state: "stopped",
completedAt: Date.now(),
Expand Down Expand Up @@ -1327,9 +1305,7 @@ export namespace Automation {
patch: UpdateInput,
options?: { now?: number },
) => Effect.Effect<Definition, ValidationError | ConflictError>
readonly remove: (
id: string,
) => Effect.Effect<{ tombstone: Tombstone; stoppedRun?: Run }, ActiveRunStillRunningError>
readonly remove: (id: string) => Effect.Effect<{ tombstone: Tombstone }>
readonly runNowExecuting: (
id: string,
options: { executor: RunExecutor; attendance?: AutomationRunAttendance; now?: number },
Expand Down Expand Up @@ -1376,9 +1352,7 @@ export namespace Automation {
}),
remove: (id) =>
Effect.tryPromise({ try: () => remove(id), catch: (error) => error }).pipe(
Effect.catch((error) =>
error instanceof ActiveRunStillRunningError ? Effect.fail(error) : Effect.die(error),
),
Effect.catch((error) => Effect.die(error)),
),
runNowExecuting: (id, options) => Effect.promise(() => runNowExecuting(id, options)),
runs: (input) => Effect.sync(() => runs(input)),
Expand Down Expand Up @@ -1409,10 +1383,3 @@ export class ConflictError extends Error {
this.name = "AutomationConflictError"
}
}

export class ActiveRunStillRunningError extends Error {
constructor(readonly runID: string) {
super(`Automation run is still running: ${runID}`)
this.name = "AutomationActiveRunStillRunningError"
}
}
17 changes: 2 additions & 15 deletions packages/opencode/src/server/instance/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { Cause, Effect, Exit } from "effect"
import z from "zod"
import { ActiveRunStillRunningError, Automation, AutomationID, ConflictError, ValidationError } from "@/automation"
import { Automation, AutomationID, ConflictError, ValidationError } from "@/automation"
import { sessionPromptExecutor } from "@/automation/runner"
import { AutomationScheduler } from "@/automation/scheduler"
import { validateModelAndVariant } from "@/automation/validation"
Expand All @@ -19,13 +19,6 @@ function conflictError(error: ConflictError) {
return Automation.ConflictErrorResponse.parse({ error: "automation_conflict", message: error.message })
}

function activeRunStillRunningError(error: ActiveRunStillRunningError) {
return Automation.ActiveRunStillRunningErrorResponse.parse({
error: "active_run_still_running",
runID: error.runID,
})
}

const AutomationIDParam = z.object({ automationID: AutomationID.Definition.zod })
const AutomationRunsQuery = z.object({
limit: z.coerce.number().int().positive().max(100).optional(),
Expand Down Expand Up @@ -55,7 +48,6 @@ function runRoute(c: Context, effect: Effect.Effect<Response, unknown, AppServic
const error = Cause.squash(exit.cause)
if (error instanceof ValidationError) return c.json(validationError(error), 422)
if (error instanceof ConflictError) return c.json(conflictError(error), 409)
if (error instanceof ActiveRunStillRunningError) return c.json(activeRunStillRunningError(error), 409)
return Promise.reject(error)
})
}
Expand Down Expand Up @@ -197,7 +189,6 @@ const deleteAutomation = Effect.fn("AutomationRoutes.delete")(function* (
yield* settleAutomationScheduler(scheduler)
const removed = yield* automation.remove(automationID)
yield* Effect.sync(() => scheduler.cancel(removed.tombstone.id))
if (removed.stoppedRun) yield* automation.publishRunUpdated(removed.stoppedRun)
yield* automation.publishDefinitionDeleted(removed.tombstone)
return c.json(removed.tombstone)
})
Expand Down Expand Up @@ -353,17 +344,13 @@ export const AutomationRoutes = (): Hono =>
describeRoute({
summary: "Delete automation",
description:
"Delete an automation definition and return a tombstone. If a run is active in this process, stop it and publish the stopped run before publishing the tombstone. If a live run is owned by another process, return 409 without deleting.",
"Delete an automation definition, cancel future scheduling, and return a tombstone. Already-started runs continue to completion.",
operationId: "automation.delete",
responses: {
200: {
description: "Automation deletion tombstone",
content: { "application/json": { schema: resolver(Automation.Tombstone) } },
},
409: {
description: "Automation has a live run owned by another process",
content: { "application/json": { schema: resolver(Automation.ActiveRunStillRunningErrorResponse) } },
},
...errors(404),
},
}),
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/session/prompt/pawwork.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ If the user has already specified a path, execute it directly without re-asking.

# Scheduling, reminders, and recurring work

When the user asks to do something later, be reminded, send something at a specific time, or repeat work on a schedule, create a PawWork Automation with the `automate` tool for one-time and recurring tasks alike. Automations appear in the Automations panel and run with the session's project context, model, and credentials.
When the user asks to do something later, be reminded, send or check something at a specific time, repeat work on a schedule, or monitor, poll, or watch something over time (check periodically, every N minutes/hours, until a status changes, or tell them when something happens), create a PawWork Automation with the `automate` tool for one-time and recurring tasks alike. Automations appear in the Automations panel and run with the session's project context, model, and credentials.

When the user asks to list, pause, resume, delete, remove, or cancel an existing PawWork Automation, activate `automate_manage` via `tool_info` and manage it there. Do not send the user away to the Automations panel unless they explicitly want to use the UI.

Never install OS-level schedulers for these requests with any tool: no `at`, `cron`, `crontab`, `launchd` or LaunchAgents plists, systemd timers, `schtasks`, background scripts, or sleep loops — neither by running commands nor by writing files. Use other tools only to gather information the scheduled prompt will need. OS schedulers are acceptable only when the user explicitly asks for a system-level scheduler outside PawWork.
Use short bounded waits only inside the current turn: fixed sleeps and retry loops must be capped at 60 seconds total and only wait for immediate readiness, such as a local server booting, a page loading, or a command producing first output. For minute-scale polling, repeated checks, or background monitoring, use `automate`.

Never install OS-level schedulers for these requests with any tool: no `at`, `cron`, `crontab`, `launchd` or LaunchAgents plists, systemd timers, `schtasks`, background scripts, or sleep loops; neither by running commands nor by writing files. Use other tools only to gather information the scheduled prompt will need. OS schedulers are acceptable only when the user explicitly asks for a system-level scheduler outside PawWork.

# Browsing and operating websites

Expand Down
12 changes: 2 additions & 10 deletions packages/opencode/src/tool/automate-manage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Cause, Effect, Schema } from "effect"
import { ActiveRunStillRunningError, Automation } from "@/automation"
import { Automation } from "@/automation"
import { NotFoundError } from "@/storage/db"
import * as Tool from "./tool"

Expand All @@ -21,7 +21,6 @@ type Metadata = {
automationDefinitions?: Automation.Definition[]
automationDefinition?: Automation.Definition
automationTombstone?: Automation.Tombstone
stoppedRun?: Automation.Run
}

function schedule(definition: Automation.Definition) {
Expand Down Expand Up @@ -52,12 +51,6 @@ function readableAutomationError(error: unknown, id: string) {
if (NotFoundError.isInstance(error)) {
return new Error(`Automation not found: ${id}. Run automate_manage list to get a current id.`, { cause: error })
}
if (error instanceof ActiveRunStillRunningError) {
return new Error(
`Cannot delete automation ${id}: active_run_still_running (${error.runID}). Try again after the active run finishes.`,
{ cause: error },
)
}
return error
}

Expand Down Expand Up @@ -116,11 +109,10 @@ export function createAutomateManageDefinition(
metadata: { action: "delete", id, title: previous.title },
})
const removed = yield* readableAutomationEffect(automation.remove(id), id)
if (removed.stoppedRun) yield* automation.publishRunUpdated(removed.stoppedRun)
yield* automation.publishDefinitionDeleted(removed.tombstone)
return {
title: "Automation deleted",
metadata: { automationTombstone: removed.tombstone, stoppedRun: removed.stoppedRun },
metadata: { automationTombstone: removed.tombstone },
output: JSON.stringify(removed.tombstone, null, 2),
}
}),
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/automate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function createAutomateDefinition(
// me", "later", "every weekday") against the first sentences, so triggers
// lead and behavioral detail lives in the field descriptions above.
description: [
"Create a PawWork Automation: a scheduled task, reminder, or recurring job that PawWork runs for the user. Use this whenever the user asks to do something later, at a specific time or date, one time, daily, weekly, on weekdays, or on any other schedule — including scheduled messages and reminders. Never set up OS schedulers (at, cron, launchd, schtasks) for these requests unless the user explicitly asks for an OS-level scheduler outside PawWork.",
"Create a PawWork Automation: a scheduled task, reminder, recurring job, or background monitor that PawWork runs for the user. Use this whenever the user asks to do something later, at a specific time or date, one time, daily, weekly, on weekdays, or on any other schedule. Also use it to monitor, poll, watch, or check periodically over time, such as every 5 minutes, until a status changes, or when the user says to tell them when something happens. Never set up OS schedulers (at, cron, launchd, schtasks) for these requests unless the user explicitly asks for an OS-level scheduler outside PawWork.",
"Automations appear in PawWork's Automations panel, where the user can pause or delete them, and each run uses this session's project context, model, and credentials. Creating the definition schedules the future run; it does not run the prompt now. After creating one, tell the user when it will fire (the result includes the schedule).",
].join("\n\n"),
parameters: AutomateParameters,
Expand Down
Loading