Skip to content

Commit 0d68985

Browse files
feat: schedule worktree isolation for running jobs in isolated worktrees
1 parent 1806f8d commit 0d68985

24 files changed

Lines changed: 446 additions & 135 deletions

backend/src/db/migrations/015-schedule-worktree-isolation.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ const migration: Migration = {
1717
if (!jobColumnNames.has('branch')) {
1818
db.run('ALTER TABLE schedule_jobs ADD COLUMN branch TEXT')
1919
}
20-
if (!jobColumnNames.has('isolation_mode')) {
21-
db.run("ALTER TABLE schedule_jobs ADD COLUMN isolation_mode TEXT NOT NULL DEFAULT 'worktree'")
22-
}
2320

2421
const runColumns = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[]
2522
const runColumnNames = new Set(runColumns.map((c) => c.name))
@@ -36,12 +33,11 @@ const migration: Migration = {
3633
},
3734

3835
down(db) {
39-
// Rebuild schedule_jobs without branch, isolation_mode
36+
// Rebuild schedule_jobs without branch
4037
const jobColumns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[]
4138
const hasBranch = jobColumns.some((c) => c.name === 'branch')
42-
const hasIsolationMode = jobColumns.some((c) => c.name === 'isolation_mode')
4339

44-
if (hasBranch || hasIsolationMode) {
40+
if (hasBranch) {
4541
db.run(`
4642
CREATE TABLE schedule_jobs_old (
4743
id INTEGER PRIMARY KEY AUTOINCREMENT,

backend/src/db/schedules.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
ScheduleJobSchema,
44
ScheduleRunSchema,
55
ScheduleSkillMetadataSchema,
6-
type ScheduleIsolationMode,
76
type ScheduleJob,
87
type ScheduleMode,
98
type ScheduleRun,
@@ -28,7 +27,6 @@ interface ScheduleJobRow {
2827
model: string | null
2928
skill_metadata: string | null
3029
branch: string | null
31-
isolation_mode: string | null
3230
created_at: number
3331
updated_at: number
3432
last_run_at: number | null
@@ -84,7 +82,6 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob {
8482
model: row.model,
8583
skillMetadata: parseSkillMetadata(row.skill_metadata),
8684
branch: row.branch,
87-
isolationMode: (row.isolation_mode as ScheduleIsolationMode) ?? 'worktree',
8885
createdAt: row.created_at,
8986
updatedAt: row.updated_at,
9087
lastRunAt: row.last_run_at,
@@ -150,10 +147,10 @@ export function createScheduleJob(db: Database, repoId: number, input: ScheduleJ
150147
const stmt = db.prepare(`
151148
INSERT INTO schedule_jobs (
152149
repo_id, name, description, enabled, schedule_mode, interval_minutes, cron_expression, timezone, agent_slug, prompt, model, skill_metadata,
153-
branch, isolation_mode,
150+
branch,
154151
created_at, updated_at, last_run_at, next_run_at
155152
)
156-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
153+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
157154
`)
158155

159156
const result = stmt.run(
@@ -170,7 +167,6 @@ export function createScheduleJob(db: Database, repoId: number, input: ScheduleJ
170167
input.model ?? null,
171168
serializeSkillMetadata(input.skillMetadata),
172169
input.branch,
173-
input.isolationMode,
174170
now,
175171
now,
176172
null,
@@ -195,7 +191,7 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i
195191
const stmt = db.prepare(`
196192
UPDATE schedule_jobs
197193
SET name = ?, description = ?, enabled = ?, schedule_mode = ?, interval_minutes = ?, cron_expression = ?, timezone = ?,
198-
agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, branch = ?, isolation_mode = ?, updated_at = ?, next_run_at = ?
194+
agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, branch = ?, updated_at = ?, next_run_at = ?
199195
WHERE repo_id = ? AND id = ?
200196
`)
201197

@@ -212,7 +208,6 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i
212208
input.model,
213209
serializeSkillMetadata(input.skillMetadata),
214210
input.branch,
215-
input.isolationMode,
216211
now,
217212
input.nextRunAt,
218213
repoId,
@@ -229,6 +224,41 @@ export function deleteScheduleJob(db: Database, repoId: number, jobId: number):
229224
return result.changes > 0
230225
}
231226

227+
export interface ScheduleRunArtifact {
228+
id: number
229+
status: ScheduleRunStatus
230+
runBranch: string | null
231+
worktreePath: string | null
232+
}
233+
234+
export function listScheduleRunArtifactsByJob(db: Database, repoId: number, jobId: number): ScheduleRunArtifact[] {
235+
const rows = db
236+
.prepare('SELECT id, status, run_branch, worktree_path FROM schedule_runs WHERE repo_id = ? AND job_id = ? ORDER BY id DESC')
237+
.all(repoId, jobId) as { id: number; status: string; run_branch: string | null; worktree_path: string | null }[]
238+
return rows.map((row) => ({
239+
id: row.id,
240+
status: row.status as ScheduleRunStatus,
241+
runBranch: row.run_branch,
242+
worktreePath: row.worktree_path,
243+
}))
244+
}
245+
246+
export function deleteScheduleRunById(db: Database, repoId: number, jobId: number, runId: number): boolean {
247+
const result = db
248+
.prepare('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?')
249+
.run(repoId, jobId, runId)
250+
return result.changes > 0
251+
}
252+
253+
export function deleteScheduleRunsByIds(db: Database, repoId: number, jobId: number, runIds: number[]): number {
254+
if (runIds.length === 0) return 0
255+
const placeholders = runIds.map(() => '?').join(', ')
256+
const result = db
257+
.prepare(`DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id IN (${placeholders})`)
258+
.run(repoId, jobId, ...runIds)
259+
return result.changes
260+
}
261+
232262
export function cleanupOrphanedSchedules(db: Database): { orphanedJobs: number; orphanedRuns: number } {
233263
const runStmt = db.prepare(`
234264
DELETE FROM schedule_runs

backend/src/routes/schedules.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,29 @@ export function createScheduleRoutes(scheduleService: ScheduleService) {
159159
}
160160
})
161161

162+
app.delete('/:jobId/runs', async (c) => {
163+
try {
164+
const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError)
165+
const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError)
166+
const result = await scheduleService.clearRunHistory(repoId, jobId)
167+
return c.json(result)
168+
} catch (error) {
169+
return handleServiceError(c, error, 'Failed to clear schedule run history', ScheduleServiceError)
170+
}
171+
})
172+
173+
app.delete('/:jobId/runs/:runId', async (c) => {
174+
try {
175+
const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError)
176+
const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError)
177+
const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError)
178+
await scheduleService.deleteRun(repoId, jobId, runId)
179+
return c.json({ success: true })
180+
} catch (error) {
181+
return handleServiceError(c, error, 'Failed to delete schedule run', ScheduleServiceError)
182+
}
183+
})
184+
162185
return app
163186
}
164187

backend/src/services/repo.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createRepo, getRepoByLocalPath, getRepoBySourcePath, getRepoById, updat
66
import type { Database } from 'bun:sqlite'
77
import type { Repo, CreateRepoInput } from '../types/repo'
88
import { logger } from '../utils/logger'
9-
import { getReposPath } from '@opencode-manager/shared/config/env'
9+
import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env'
1010
import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare } from '@opencode-manager/shared/utils'
1111
import type { GitAuthService } from './git-auth'
1212
import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth'
@@ -1170,6 +1170,7 @@ export async function getSiblingRepos(
11701170
const knownDirectories = new Set(repoSiblings.map((repo) => canonical(repo.fullPath)))
11711171
const targetDirectory = canonical(target.fullPath)
11721172
const reposRoot = canonical(getReposPath())
1173+
const scheduleWorktreeRoot = canonical(getScheduleWorktreesPath())
11731174

11741175
const candidates = workspaces.filter((workspace) => {
11751176
if (workspace.projectID !== targetProjectId) return false
@@ -1178,6 +1179,7 @@ export async function getSiblingRepos(
11781179
const workspaceDirectory = canonical(workspace.directory)
11791180
if (workspaceDirectory === targetDirectory) return false
11801181
if (workspaceDirectory === reposRoot) return false
1182+
if (workspaceDirectory.startsWith(`${scheduleWorktreeRoot}${path.sep}`)) return false
11811183
if (knownDirectories.has(workspaceDirectory)) return false
11821184

11831185
return true

backend/src/services/schedule-config.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Cron } from 'croner'
22
import type {
33
CreateScheduleJobRequest,
4-
ScheduleIsolationMode,
54
ScheduleJob,
65
ScheduleMode,
76
ScheduleSkillMetadata,
@@ -23,7 +22,6 @@ export interface ScheduleJobPersistenceInput {
2322
model: string | null
2423
skillMetadata: ScheduleSkillMetadata | null | undefined
2524
branch: string | null
26-
isolationMode: ScheduleIsolationMode
2725
nextRunAt: number | null
2826
}
2927

@@ -99,7 +97,6 @@ export function buildCreateSchedulePersistenceInput(input: CreateScheduleJobRequ
9997
model: input.model?.trim() || null,
10098
skillMetadata: input.skillMetadata,
10199
branch: input.branch?.trim() || null,
102-
isolationMode: input.isolationMode ?? 'worktree' as const,
103100
}
104101

105102
const scheduleConfig = input.scheduleMode === 'cron'
@@ -157,7 +154,6 @@ export function buildUpdatedSchedulePersistenceInput(
157154
model: input.model === undefined ? existing.model : (input.model?.trim() || null),
158155
skillMetadata: input.skillMetadata !== undefined ? input.skillMetadata : existing.skillMetadata,
159156
branch: input.branch === undefined ? existing.branch : (input.branch?.trim() || null),
160-
isolationMode: input.isolationMode ?? existing.isolationMode,
161157
nextRunAt,
162158
}
163159
}

backend/src/services/schedule-worktree.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync, mkdirSync } from 'node:fs'
22
import path from 'path'
33
import type { Database } from 'bun:sqlite'
4-
import { getReposPath } from '@opencode-manager/shared/config/env'
4+
import { getScheduleWorktreesPath } from '@opencode-manager/shared/config/env'
55
import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils'
66
import type { Repo } from '../types/repo'
77
import type { GitAuthService } from './git-auth'
@@ -39,10 +39,9 @@ export class ScheduleWorktreeManager {
3939

4040
async prepare(
4141
repo: Repo,
42-
job: { id: number; isolationMode: string; branch: string | null },
42+
job: { id: number; branch: string | null },
4343
runId: number,
4444
): Promise<ScheduleWorktreeContext | null> {
45-
if (job.isolationMode === 'inline') return null
4645
if (repo.id === ASSISTANT_REPO_ID) return null
4746

4847
try {
@@ -58,16 +57,13 @@ export class ScheduleWorktreeManager {
5857
}
5958

6059
try {
61-
const baseEnv = this.gitAuthService.getGitEnvironment(true)
62-
const sshEnv = sshSetup ? this.gitAuthService.getSSHEnvironment() : {}
63-
const identityEnv = await this.buildIdentityEnv()
64-
const env = { ...baseEnv, ...buildRepoEnvForRepo(repo), ...sshEnv, ...identityEnv }
60+
const env = await this.buildGitEnv(repo, sshSetup, true)
6561

66-
await executeCommand(['git', '-C', repo.fullPath, 'fetch', '--all', '--prune'], { env }).catch(() => {})
62+
await executeCommand(['git', '-C', repo.fullPath, 'fetch', '--prune', 'origin'], { env }).catch(() => {})
6763

6864
const base = job.branch?.trim() || (await resolveDefaultBranch(repo.fullPath, env))
6965
const runBranch = `schedule/${job.id}/run-${runId}`
70-
const worktreePath = path.join(getReposPath(), '.ocm-schedule-worktrees', `job-${job.id}-run-${runId}`)
66+
const worktreePath = path.join(getScheduleWorktreesPath(), `job-${job.id}-run-${runId}`)
7167

7268
mkdirSync(path.dirname(worktreePath), { recursive: true })
7369

@@ -98,21 +94,20 @@ export class ScheduleWorktreeManager {
9894

9995
let sshSetup = false
10096
let env: Record<string, string> | undefined
97+
let noChanges = false
10198

10299
try {
103100
if (repo.repoUrl && isSSHUrl(repo.repoUrl)) {
104101
await this.gitAuthService.setupSSHForRepoUrl(repo.repoUrl, this.db)
105102
sshSetup = true
106103
}
107104

108-
const baseEnv = this.gitAuthService.getGitEnvironment()
109-
const sshEnv = sshSetup ? this.gitAuthService.getSSHEnvironment() : {}
110-
const identityEnv = await this.buildIdentityEnv()
111-
env = { ...baseEnv, ...buildRepoEnvForRepo(repo), ...sshEnv, ...identityEnv }
105+
env = await this.buildGitEnv(repo, sshSetup, false)
112106

113107
const status = await executeCommand(['git', '-C', run.worktreePath, 'status', '--porcelain'], { env }).catch(() => '')
114108

115109
if (!status.trim()) {
110+
noChanges = true
116111
return { commitHash: null }
117112
}
118113

@@ -132,6 +127,9 @@ export class ScheduleWorktreeManager {
132127
} finally {
133128
try {
134129
await removeWorktree(repo.fullPath, run.worktreePath, env)
130+
if (noChanges && run.runBranch) {
131+
await executeCommand(['git', '-C', repo.fullPath, 'branch', '-D', run.runBranch], env ? { env } : undefined).catch(() => {})
132+
}
135133
} catch (error) {
136134
logger.error(`Failed to remove worktree ${run.worktreePath}:`, error)
137135
}
@@ -141,6 +139,36 @@ export class ScheduleWorktreeManager {
141139
}
142140
}
143141

142+
/**
143+
* Removes leftover worktrees and deletes the run branches for a set of
144+
* finished runs. Used when clearing run history. Branch and worktree removal
145+
* are local git operations, so no SSH setup is needed; failures are swallowed
146+
* per artifact so one bad entry does not block the rest.
147+
*/
148+
async pruneRunArtifacts(repo: Repo, artifacts: { runBranch: string | null; worktreePath: string | null }[]): Promise<void> {
149+
if (artifacts.length === 0) return
150+
151+
const env = await this.buildGitEnv(repo, false, true)
152+
153+
for (const artifact of artifacts) {
154+
if (artifact.worktreePath) {
155+
await removeWorktree(repo.fullPath, artifact.worktreePath, env)
156+
}
157+
}
158+
159+
const branches = artifacts.map((a) => a.runBranch).filter((b): b is string => b !== null && b.length > 0)
160+
if (branches.length > 0) {
161+
await executeCommand(['git', '-C', repo.fullPath, 'branch', '-D', ...branches], { env }).catch(() => {})
162+
}
163+
}
164+
165+
private async buildGitEnv(repo: Repo, sshSetup: boolean, silent: boolean): Promise<Record<string, string>> {
166+
const baseEnv = this.gitAuthService.getGitEnvironment(silent)
167+
const sshEnv = sshSetup ? this.gitAuthService.getSSHEnvironment() : {}
168+
const identityEnv = await this.buildIdentityEnv()
169+
return { ...baseEnv, ...buildRepoEnvForRepo(repo), ...sshEnv, ...identityEnv }
170+
}
171+
144172
private async buildIdentityEnv(): Promise<Record<string, string>> {
145173
const settings = this.settingsService.getSettings()
146174
const gitCredentials = this.credentialProvider.getGitCredentials()

backend/src/services/schedules.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ import {
1414
createScheduleJob,
1515
createScheduleRun,
1616
deleteScheduleJob,
17+
deleteScheduleRunById,
18+
deleteScheduleRunsByIds,
1719
getScheduleJobById,
1820
getRunningScheduleRunByJob,
1921
getScheduleRunById,
2022
listAllScheduleJobsWithRepos,
23+
listScheduleRunArtifactsByJob,
2124
listAllScheduleRuns,
2225
listEnabledScheduleJobs,
2326
listScheduleJobIdsByRepo,
@@ -505,6 +508,45 @@ export class ScheduleService {
505508
return run
506509
}
507510

511+
/**
512+
* Clears a job's run history: deletes every finished run's row plus its git
513+
* run branch and any leftover worktree. A run currently in progress is left
514+
* untouched (its row and live worktree are skipped).
515+
*/
516+
async clearRunHistory(repoId: number, jobId: number): Promise<{ cleared: number }> {
517+
const repo = this.assertRepo(repoId)
518+
this.assertJob(repoId, jobId)
519+
520+
const removable = listScheduleRunArtifactsByJob(this.db, repoId, jobId).filter((run) => run.status !== 'running')
521+
if (removable.length === 0) {
522+
return { cleared: 0 }
523+
}
524+
525+
await this.worktreeManager.pruneRunArtifacts(repo, removable)
526+
const cleared = deleteScheduleRunsByIds(this.db, repoId, jobId, removable.map((run) => run.id))
527+
return { cleared }
528+
}
529+
530+
/**
531+
* Deletes a single finished run plus its git run branch and any leftover
532+
* worktree. A run in progress must be cancelled first.
533+
*/
534+
async deleteRun(repoId: number, jobId: number, runId: number): Promise<void> {
535+
const repo = this.assertRepo(repoId)
536+
this.assertJob(repoId, jobId)
537+
const run = this.getRun(repoId, jobId, runId)
538+
539+
if (run.status === 'running') {
540+
throw new ScheduleServiceError('Cannot delete a run while it is in progress. Cancel it first.', 409)
541+
}
542+
543+
await this.worktreeManager.pruneRunArtifacts(repo, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }])
544+
const deleted = deleteScheduleRunById(this.db, repoId, jobId, runId)
545+
if (!deleted) {
546+
throw new ScheduleServiceError('Run not found', 404)
547+
}
548+
}
549+
508550
async runJob(repoId: number, jobId: number, triggerSource: ScheduleRunTriggerSource): Promise<ScheduleRun> {
509551
const repo = this.assertRepo(repoId)
510552
const job = this.assertJob(repoId, jobId)

0 commit comments

Comments
 (0)