diff --git a/docs/code/worker.md b/docs/code/worker.md index f5a9761..a32715f 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -203,6 +203,22 @@ Transient push problems recover on their own: a rejection caused by concurrent f This applies only to the agent's own PRs (the same watch list as review polling). Each base/head SHA pair is a durable event in `.devintern-code/queue.db`; new commits on the PR branch open a fresh event, so an exhausted attempt is retried after the next push. Failures retry up to `WEBHOOK_MAX_RETRIES` (default 3), including across worker restarts, waiting out an exponential backoff between attempts (30s after the first failure, doubling up to 10 minutes) so persistent failures do not hammer the API on every poll tick. Before acting, the worker requires the PR head SHA to remain unchanged for `WORKER_BASE_SYNC_QUIET_SECONDS` (default 30) and then re-fetches both SHAs. Recent or concurrent pushes defer the run without consuming an attempt; if a run defers several times in a row, the event is given up until the head or base moves again. GitHub's PR API can report an outdated `base.sha` for a while, so the resolver always merges the actual fetched tip of the base branch rather than trusting that field. Each resolve run is bounded by `WORKER_RESOLVE_TIMEOUT_SECONDS` (default 1800; `0` disables) — a hung resolver subprocess is killed and counted as a failed attempt, and runs left `in_progress` by a crashed or killed worker are marked failed at the next startup. The same merge logic is available manually for any PR via `devintern resolve-conflicts `; manual runs exit non-zero with a clear message when the fix could not be published. +#### Scheduled conflict resolution + +By default resolution runs as soon as a conflict is detected. Because each resolution is an agent run (and therefore token spend), workspaces on metered AI plans can batch it off-peak instead with `[workspace].conflict_resolution` in `workspace.toml`: + +```toml +[workspace] +conflict_resolution = "scheduled" +conflict_resolution_cron = "0 3 * * *" # or conflict_resolution_interval = "1d" +``` + +In scheduled mode the poller still detects every conflict on the first tick it appears and queues it durably (a pending base-sync event), but the agent is not invoked. When the scheduled window arrives — cron uses the worker host timezone, intervals are relative — the worker resolves all queued conflicts in one pass and logs the active mode and next window at startup. The window stays open for a grace period (`WORKER_RESOLVE_WINDOW_GRACE_MINUTES`, default 60) so quiet-period waits and retry backoffs inside the pass can still complete; anything unresolved when it closes waits for the next window. A window that arrives while the worker is down (missed nightly run) catches up on the first tick after restart. Stale resolutions cannot happen: before invoking the agent the worker re-fetches the PR, and closed/merged PRs or a conflict that resolved itself are dropped from the queue without spending tokens. The manual `devintern resolve-conflicts ` command always works on demand, and once GitHub reports a PR conflict-free its queued event never triggers an agent run. + +The setting applies to the whole workspace and takes effect on worker restart, like the rest of `workspace.toml`. Between windows a conflicted PR cannot be merged, so teams that rely on instant rebases should keep `auto`. See [Workspaces → Automatic conflict resolution](./workspaces.md#automatic-conflict-resolution-auto-vs-scheduled-vs-disabled) for the config reference and tradeoffs. + +To turn automatic conflict resolution off entirely — no detection, no queuing, no agent runs — set `conflict_resolution = "disabled"`: conflicted PRs stay conflicted until resolved by hand or via `devintern resolve-conflicts `. + ## Mention the bot on any PR The worker also reacts to mentions on pull requests it did not create. When a teammate writes a comment like `@devintern address the review feedback` on any PR in the repository, the worker picks it up on the next poll and handles it through the same pipeline. Detection is a repository-wide sweep of new comments (two requests per interval, regardless of how many PRs are open), so mentions work without any webhook setup. diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index 87cbcb3..19ce97f 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -29,6 +29,11 @@ Workspace mode runs under the same automation license as the rest of the worker: worktrees_ttl_days = 7 dashboard = true # dashboard_port = 4400 +# Batch automatic conflict resolution off-peak instead of instant (default "auto"): +# conflict_resolution = "scheduled" +# conflict_resolution_cron = "0 3 * * *" # worker host timezone +# conflict_resolution_interval = "1d" # exactly one of cron / interval +# Or turn it off entirely: conflict_resolution = "disabled" [defaults] tracker = "jira" @@ -81,6 +86,26 @@ prompt = "Review the frontend and clean up one source of recurring noise." - Rule criteria combine with AND; list values (`components`, `labels`) match when the task carries any of them. Comparisons are case-insensitive. `project` matches the task key prefix for `PROJ-123` style keys (Jira, Linear); trackers with numeric or opaque ids route via labels or components. - `[[automations]]` uses the same schema as single-repo `.devintern-code/automations.toml`. An entry must name `repo` when the workspace has more than one repository. See [Worker Daemon → Recurring automations](./worker.md#recurring-automations) for prompt-writing guidance and schedule semantics. +### Automatic conflict resolution: `auto` vs `scheduled` vs `disabled` + +When a watched PR conflicts with its base branch, the worker normally resolves it right away (`conflict_resolution = "auto"`, the default — no behavior change on upgrade). Every resolution hands the conflicted files to the AI agent, which consumes tokens — even at 3am when nobody is reviewing the PR anyway. + +Set `conflict_resolution = "scheduled"` to batch those resolutions into an off-peak window. Polling still detects every conflict immediately and queues it (the PR stays conflicted until then, and the worker logs which mode is active at startup); the agent only runs inside the window: + +```toml +[workspace] +conflict_resolution = "scheduled" +conflict_resolution_cron = "0 3 * * *" # or conflict_resolution_interval = "1d" +``` + +The schedule uses the same format as `[[automations]]`: a five-field cron expression (worker host timezone) or a positive `15m`/`6h`/`1d` interval — exactly one of the two. Exactly one window pass runs per occurrence; if the worker is down when the window arrives (a missed nightly run), the queued conflicts resolve on the first tick after restart. Inside a window the usual safety rules still apply: failed attempts wait out their retry backoff, PRs whose head is still moving wait out the quiet period, and anything not finished before the window closes (60 minutes by default, `WORKER_RESOLVE_WINDOW_GRACE_MINUTES`) waits for the next one. PRs merged upstream before the window opens are skipped — the worker re-checks GitHub's mergeability before invoking the agent. + +Two things are never delayed by scheduled mode: review feedback on the agent's PRs is addressed immediately as usual, and you can always run `devintern resolve-conflicts ` by hand to fix one PR without waiting for the window — once GitHub reports the PR conflict-free, the queued event never triggers an agent run. + +The setting is workspace-wide (per-repo overrides are not supported in v1) and, like the rest of `workspace.toml`, requires a worker restart to take effect. The tradeoff to keep in mind: between windows a conflicted PR cannot be merged, so on fast-moving branches where an instant rebase unblocks a waiting reviewer, `auto` stays the better choice. See [Worker Daemon → Merge conflicts on the agent's PRs](./worker.md#merge-conflicts-on-the-agents-prs) for how resolution itself works. + +Set `conflict_resolution = "disabled"` to turn automatic conflict resolution off entirely: the worker stops watching for conflicts on the agent's PRs altogether — no detection, no queuing, no agent runs. A PR that conflicts with its base simply stays conflicted until someone resolves it (by hand, or on demand via `devintern resolve-conflicts `). Review feedback and @mention handling are unaffected. This is a valid choice when the team prefers to rebase manually, or when the agent is not trusted to resolve conflicts in a sensitive repository. + ### How workspace automations differ from single-repo ones The scheduling is identical; only where the work runs changes: diff --git a/packages/code/src/lib/automation-acquirer.ts b/packages/code/src/lib/automation-acquirer.ts index b475eef..7a08f1f 100644 --- a/packages/code/src/lib/automation-acquirer.ts +++ b/packages/code/src/lib/automation-acquirer.ts @@ -4,11 +4,11 @@ import { spawn } from "child_process"; import type { ChildProcess } from "child_process"; import { join } from "path"; -import { CronExpressionParser } from "cron-parser"; import { resolveConfigDir } from "@devintern/utils"; import type { Acquirer } from "../worker"; import type { AutomationConfig } from "./automation-config"; +import { nextScheduleOccurrence } from "./automation-config"; import { AutomationStateStore } from "./automation-state"; import { workerTaskArgs } from "./task-polling-acquirer"; import { RUN_ORIGIN_ENV } from "./analytics"; @@ -63,11 +63,10 @@ interface ActiveAutomationRun { /** Calculate the first future occurrence after `afterMs` (cron uses host timezone). */ export function nextAutomationDue(automation: AutomationConfig, afterMs: number): number { - if (automation.intervalMs) return afterMs + automation.intervalMs; - if (!automation.cron) throw new Error(`Automation "${automation.id}" has no schedule`); - return CronExpressionParser.parse(automation.cron, { currentDate: new Date(afterMs) }) - .next() - .getTime(); + if (!automation.intervalMs && !automation.cron) { + throw new Error(`Automation "${automation.id}" has no schedule`); + } + return nextScheduleOccurrence(automation, afterMs); } /** One-timer scheduler with durable UTC cursors and per-automation leases. */ diff --git a/packages/code/src/lib/automation-config.ts b/packages/code/src/lib/automation-config.ts index 26ff287..e411ec2 100644 --- a/packages/code/src/lib/automation-config.ts +++ b/packages/code/src/lib/automation-config.ts @@ -15,6 +15,13 @@ export interface AutomationConfig { repo?: string; } +/** A cron-or-interval schedule using the `[[automations]]` format. */ +export interface CronOrIntervalSchedule { + cron?: string; + interval?: string; + intervalMs?: number; +} + export const SINGLE_REPO_AUTOMATIONS_PATH = ".devintern-code/automations.toml"; const AUTOMATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const DURATION_PATTERN = /^(\d+)([mhd])$/; @@ -35,6 +42,78 @@ export function parseAutomationInterval(value: string, nowMs = Date.now()): numb return intervalMs; } +/** + * Validate a cron-or-interval schedule pair using the `[[automations]]` + * rules: exactly one of the two keys must be set, cron must be a five-field + * expression that parses, and interval must be a positive `15m`/`6h`/`1d` + * duration. Every problem found is collected in `errors`. + * + * @param table - Raw key/value table holding the schedule keys + * @param options - `label` for error messages; `cronKey` / `intervalKey` + * rename the keys (messages always use the actual key names) + * @returns The normalized schedule, or undefined when the pair is absent or + * invalid. + */ +export function parseCronOrIntervalSchedule( + table: Record, + options: { label: string; cronKey?: string; intervalKey?: string }, + errors: string[], +): CronOrIntervalSchedule | undefined { + const cronKey = options.cronKey ?? "cron"; + const intervalKey = options.intervalKey ?? "interval"; + const { label } = options; + const readScheduleString = (key: string): string | undefined => { + const item = table[key]; + if (item === undefined || item === null) return undefined; + if (typeof item !== "string" || !item.trim()) { + errors.push(`${label}.${key} must be a non-empty string.`); + return undefined; + } + return item.trim(); + }; + + const cron = readScheduleString(cronKey); + const interval = readScheduleString(intervalKey); + const pairInvalid = Boolean(cron) === Boolean(interval); + if (pairInvalid) { + errors.push(`${label} must set exactly one of ${cronKey} or ${intervalKey}.`); + } + + let cronValid = true; + if (cron) { + if (cron.split(/\s+/).length !== 5) { + errors.push(`${label}.${cronKey} must be a five-field cron expression.`); + cronValid = false; + } else { + try { + CronExpressionParser.parse(cron); + } catch (error) { + errors.push(`${label}.${cronKey} is invalid: ${(error as Error).message}`); + cronValid = false; + } + } + } + + const intervalMs = interval ? parseAutomationInterval(interval) : undefined; + let intervalValid = true; + if (interval && intervalMs === null) { + errors.push(`${label}.${intervalKey} must use a positive duration such as 15m, 6h, or 1d.`); + intervalValid = false; + } + + if (pairInvalid || !cronValid || !intervalValid) return undefined; + return { cron, interval, intervalMs: intervalMs ?? undefined }; +} + +/** First occurrence of a schedule strictly after `afterMs` (cron uses host timezone). */ +export function nextScheduleOccurrence(schedule: CronOrIntervalSchedule, afterMs: number): number { + if (schedule.intervalMs) return afterMs + schedule.intervalMs; + if (!schedule.cron) throw new Error("Schedule has no cron expression"); + return CronExpressionParser.parse(schedule.cron, { currentDate: new Date(afterMs) }) + .next() + .getTime(); +} + /** Validate and normalize `[[automations]]` tables, collecting every error. */ export function parseAutomationEntries( value: unknown, @@ -77,46 +156,21 @@ export function parseAutomationEntries( const prompt = stringValue("prompt"); if (!prompt) errors.push(`${label}.prompt is required.`); - const cron = stringValue("cron"); - const interval = stringValue("interval"); - if (Boolean(cron) === Boolean(interval)) { - errors.push(`${label} must set exactly one of cron or interval.`); - } - if (cron) { - if (cron.split(/\s+/).length !== 5) { - errors.push(`${label}.cron must be a five-field cron expression.`); - } else { - try { - CronExpressionParser.parse(cron); - } catch (error) { - errors.push(`${label}.cron is invalid: ${(error as Error).message}`); - } - } - } - const intervalMs = interval ? parseAutomationInterval(interval) : undefined; - if (interval && intervalMs === null) { - errors.push(`${label}.interval must use a positive duration such as 15m, 6h, or 1d.`); - } + const schedule = parseCronOrIntervalSchedule(table, { label }, errors); const repo = stringValue("repo"); if (repo && options.repoNames && !options.repoNames.has(repo)) { errors.push(`${label}.repo "${repo}" does not match any [[repos]] name.`); } - if ( - id && - typeof enabledValue === "boolean" && - prompt && - Boolean(cron) !== Boolean(interval) && - (!interval || intervalMs !== null) - ) { + if (id && typeof enabledValue === "boolean" && prompt && schedule) { automations.push({ id, enabled: enabledValue, prompt, - cron, - interval, - intervalMs: intervalMs ?? undefined, + cron: schedule.cron, + interval: schedule.interval, + intervalMs: schedule.intervalMs, repo, }); } diff --git a/packages/code/src/lib/review-polling-acquirer.ts b/packages/code/src/lib/review-polling-acquirer.ts index 2f89689..e2d9690 100644 --- a/packages/code/src/lib/review-polling-acquirer.ts +++ b/packages/code/src/lib/review-polling-acquirer.ts @@ -25,10 +25,13 @@ import { spawn } from "child_process"; +import { nextScheduleOccurrence } from "./automation-config"; +import type { CronOrIntervalSchedule } from "./automation-config"; import { parseEnvInteger } from "./env-integer"; import type { RunStore } from "./run-recorder"; import type { WebhookQueue } from "./webhook-queue"; import type { WorkerState } from "./worker-state"; +import type { ConflictResolutionMode } from "./workspace/config"; import type { Acquirer } from "../worker"; export interface PolledReview { @@ -107,13 +110,49 @@ export interface ReviewPollingAcquirerOptions { */ allowedRepos?: string[]; verbose?: boolean; + /** + * Schedule gating automatic conflict resolution (workspace scheduled + * mode, `[workspace].conflict_resolution = "scheduled"`). When set, + * conflicting PRs are still detected and queued on every tick, but the + * agent is invoked only inside the scheduled window. Omit for `auto` + * mode: resolve as soon as a conflict is detected (default). + */ + conflictSchedule?: CronOrIntervalSchedule; + /** + * Workspace conflict-resolution mode (`[workspace].conflict_resolution`), + * surfaced in the startup log. `"disabled"` means the caller omits + * `resolveConflicts`, so base-sync detection never runs and conflicts + * stay for manual resolution. Defaults to `"scheduled"` when + * `conflictSchedule` is set, else `"auto"`. + */ + conflictResolution?: ConflictResolutionMode; + /** + * How long a scheduled window stays open for resolution attempts once it + * arrives (covers quiet-period waits, retry backoff, and a serial pass + * over several PRs). Defaults to `WORKER_RESOLVE_WINDOW_GRACE_MINUTES` + * or 60 minutes. + */ + conflictWindowGraceMs?: number; } /** Dedupe source for review/comment ids. */ const SOURCE = "github:reviews"; const BASE_SYNC_SOURCE = "github:base-sync"; +/** Durable cursor source for the scheduled conflict-resolution window. */ +const CONFLICT_WINDOW_SOURCE = "worker:conflict-window"; const prRunTails = new Map>(); +/** Default minutes a scheduled conflict-resolution window stays open. */ +export const DEFAULT_CONFLICT_WINDOW_GRACE_MINUTES = 60; + +/** Durable state for scheduled conflict resolution (persisted per worker). */ +interface ConflictWindowState { + /** Next scheduled occurrence (ms epoch). */ + nextWindowAt: number; + /** Resolution attempts are allowed until this instant (0 = none opened yet). */ + windowOpenUntil: number; +} + /** * Consecutive `deferred` resolver outcomes tolerated for one base-sync event * before it is exhausted. Defers do not consume attempts (they model benign @@ -376,11 +415,17 @@ export class ReviewPollingAcquirer implements Acquirer { private prCache = new Map(); /** Consecutive `deferred` resolver outcomes per base-sync event. */ private deferCounts = new Map(); + /** Cached scheduled-window state; `undefined` = not loaded from the cursor yet. */ + private conflictWindowState: ConflictWindowState | null | undefined; constructor(options: ReviewPollingAcquirerOptions) { this.options = options; } + private now(): number { + return (this.options.now ?? Date.now)(); + } + /** Start polling: immediate first tick, then on the configured interval. */ async start(): Promise { // Drop stale registry rows for repos this worker no longer manages @@ -397,6 +442,8 @@ export class ReviewPollingAcquirer implements Acquirer { `🔎 Polling reviews on agent PRs every ${this.options.intervalSeconds}s ` + `(watching ${this.options.workerState.listOpenAgentPrs().length} open PR(s))`, ); + this.syncConflictWindow(); + this.logConflictMode(); await this.tick(); this.timer = setInterval(() => void this.tick(), this.options.intervalSeconds * 1000); } @@ -417,6 +464,7 @@ export class ReviewPollingAcquirer implements Acquirer { this.busy = true; try { + this.syncConflictWindow(); const allowedRepos = this.options.allowedRepos; const watchedPrs = this.options.workerState.listOpenAgentPrs(); const watchedKeys = new Set(watchedPrs.map((pr) => this.prKey(pr.repo, pr.prNumber))); @@ -588,6 +636,13 @@ export class ReviewPollingAcquirer implements Acquirer { const backoffMs = baseSyncRetryBackoffMs(event.attempts); if (backoffMs > 0 && now - event.updatedAt < backoffMs) return; + // Scheduled mode, outside the window: the conflict stays queued (the + // event above is durable) and the agent is not invoked. A manual + // `devintern resolve-conflicts ` still runs on demand; once the + // PR is no longer conflicting, the mergeability check above keeps the + // queued event out of execution without spending tokens. + if (this.options.conflictSchedule && !this.conflictWindowOpen(now)) return; + const quietMs = (this.options.quietPeriodSeconds ?? 30) * 1000; if (now - event.headObservedAt < quietMs) return; @@ -695,6 +750,113 @@ export class ReviewPollingAcquirer implements Acquirer { return `${repo.toLowerCase()}#${prNumber}`; } + /** + * Advance the durable scheduled-window state for this tick. Auto mode + * (no schedule) is a no-op. The first tick after enabling scheduled mode + * only schedules the next occurrence — conflicts detected from now on + * queue for it instead of running. When an occurrence arrives, a grace + * window opens so pending conflicts resolve on the following ticks. + */ + private syncConflictWindow(): void { + const schedule = this.options.conflictSchedule; + if (!schedule) return; + const now = this.now(); + if (this.conflictWindowState === undefined) { + this.conflictWindowState = null; + const raw = this.options.workerState.getCursor(CONFLICT_WINDOW_SOURCE)?.cursorValue; + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.nextWindowAt === "number" && + typeof parsed.windowOpenUntil === "number" + ) { + this.conflictWindowState = { + nextWindowAt: parsed.nextWindowAt, + windowOpenUntil: parsed.windowOpenUntil, + }; + } + } catch { + // Corrupt state re-initializes below. + } + } + } + + let state = this.conflictWindowState; + if (!state) { + state = { nextWindowAt: nextScheduleOccurrence(schedule, now), windowOpenUntil: 0 }; + this.saveConflictWindowState(state); + return; + } + if (now >= state.nextWindowAt) { + // Fresh window: each window gets its own defer budget, and anything + // still pending (including from a missed window while the worker was + // down) resolves now. + this.deferCounts.clear(); + state = { + nextWindowAt: nextScheduleOccurrence(schedule, now), + windowOpenUntil: now + this.conflictWindowGraceMs(), + }; + this.saveConflictWindowState(state); + console.log( + `⏰ [${this.name}] conflict-resolution window open until ${new Date( + state.windowOpenUntil, + ).toISOString()}; queued conflicts will resolve now`, + ); + } + } + + /** Scheduled mode only: are resolution attempts currently allowed? */ + private conflictWindowOpen(now: number): boolean { + const state = this.conflictWindowState; + return state != null && now <= state.windowOpenUntil; + } + + private saveConflictWindowState(state: ConflictWindowState): void { + this.conflictWindowState = state; + this.options.workerState.setCursor(CONFLICT_WINDOW_SOURCE, JSON.stringify(state)); + } + + private conflictWindowGraceMs(): number { + return ( + this.options.conflictWindowGraceMs ?? + parseEnvInteger( + "WORKER_RESOLVE_WINDOW_GRACE_MINUTES", + DEFAULT_CONFLICT_WINDOW_GRACE_MINUTES, + { min: 0 }, + ) * 60_000 + ); + } + + private scheduleLabel(): string { + const schedule = this.options.conflictSchedule; + if (!schedule) return "auto"; + return schedule.cron ? `cron "${schedule.cron}"` : `interval ${schedule.interval}`; + } + + /** Surface the active conflict-resolution mode on worker startup. */ + private logConflictMode(): void { + const mode = + this.options.conflictResolution ?? (this.options.conflictSchedule ? "scheduled" : "auto"); + if (mode === "disabled") { + console.log( + `⏸️ [${this.name}] conflict resolution: disabled (conflicts stay for manual resolution)`, + ); + return; + } + if (!this.options.conflictSchedule) { + console.log( + `🔀 [${this.name}] conflict resolution: auto (immediately when a conflict is detected)`, + ); + return; + } + const nextWindowAt = this.conflictWindowState?.nextWindowAt; + console.log( + `⏰ [${this.name}] conflict resolution: scheduled (${this.scheduleLabel()})` + + (nextWindowAt ? `; next window ${new Date(nextWindowAt).toISOString()}` : ""), + ); + } + private baseSyncExternalId(repo: string, prNumber: number, baseSha: string, headSha: string) { return `base-sync:${repo}#${prNumber}:${baseSha}:${headSha}`; } diff --git a/packages/code/src/lib/workspace/config.ts b/packages/code/src/lib/workspace/config.ts index 49a2f60..a250f93 100644 --- a/packages/code/src/lib/workspace/config.ts +++ b/packages/code/src/lib/workspace/config.ts @@ -1,10 +1,13 @@ import { readFileSync } from "fs"; import { supportsPolling, trackersSupportingPolling } from "../tracker-capabilities"; -import { parseAutomationEntries } from "../automation-config"; -import type { AutomationConfig } from "../automation-config"; +import { parseAutomationEntries, parseCronOrIntervalSchedule } from "../automation-config"; +import type { AutomationConfig, CronOrIntervalSchedule } from "../automation-config"; import { parseToml } from "./toml"; +/** When automatic conflict resolution on the agent's PRs runs. */ +export type ConflictResolutionMode = "auto" | "scheduled" | "disabled"; + /** Workspace-wide settings from the `[workspace]` table. */ export interface WorkspaceSettings { /** Days before a leftover (failed-run) task worktree is swept. */ @@ -13,6 +16,20 @@ export interface WorkspaceSettings { dashboard: boolean; /** Dashboard listen port; unset follows DASHBOARD_PORT / 4400. */ dashboardPort?: number; + /** + * `auto` (default) resolves merge conflicts on the agent's PRs as soon as + * they are detected; `scheduled` queues them during polling and resolves + * them in the configured window (see {@link WorkspaceSettings.conflictSchedule}); + * `disabled` turns automatic conflict resolution off entirely — conflicts + * stay for manual resolution (`devintern resolve-conflicts `). + */ + conflictResolution: ConflictResolutionMode; + /** + * Cron-or-interval window for scheduled conflict resolution (same format + * as `[[automations]]` schedules). Set only when `conflictResolution` is + * `"scheduled"`. + */ + conflictSchedule?: CronOrIntervalSchedule; } /** Fleet-wide defaults from the `[defaults]` table. */ @@ -71,6 +88,7 @@ export interface WorkspaceConfig { export const DEFAULT_WORKTREES_TTL_DAYS = 7; export const DEFAULT_POLL_INTERVAL_SECONDS = 60; export const DEFAULT_DASHBOARD = true; +export const DEFAULT_CONFLICT_RESOLUTION: ConflictResolutionMode = "auto"; /** Repo names double as directory names; keep them filesystem-safe. */ const REPO_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; @@ -240,6 +258,38 @@ export function parseWorkspaceConfig( }, ); + const conflictResolutionRaw = readString( + workspaceTable, + "conflict_resolution", + "[workspace]", + errors, + ); + let conflictResolution = DEFAULT_CONFLICT_RESOLUTION; + let conflictSchedule: CronOrIntervalSchedule | undefined; + if (conflictResolutionRaw === "scheduled") { + conflictResolution = "scheduled"; + conflictSchedule = parseCronOrIntervalSchedule( + workspaceTable, + { + label: "[workspace]", + cronKey: "conflict_resolution_cron", + intervalKey: "conflict_resolution_interval", + }, + errors, + ); + } else if (conflictResolutionRaw === "disabled") { + conflictResolution = "disabled"; + } else if (conflictResolutionRaw && conflictResolutionRaw !== "auto") { + errors.push(`[workspace].conflict_resolution must be "auto", "scheduled", or "disabled".`); + } + if (conflictResolutionRaw !== "scheduled") { + for (const key of ["conflict_resolution_cron", "conflict_resolution_interval"] as const) { + if (workspaceTable[key] !== undefined) { + errors.push(`[workspace].${key} is only used when conflict_resolution = "scheduled".`); + } + } + } + const defaultsTable = asTable(document.defaults, "[defaults]", errors); const tracker = readString(defaultsTable, "tracker", "[defaults]", errors); if (tracker && !supportsPolling(tracker)) { @@ -342,7 +392,7 @@ export function parseWorkspaceConfig( } return { - workspace: { worktreesTtlDays, dashboard, dashboardPort }, + workspace: { worktreesTtlDays, dashboard, dashboardPort, conflictResolution, conflictSchedule }, defaults, repos, routing, diff --git a/packages/code/src/lib/workspace/init.ts b/packages/code/src/lib/workspace/init.ts index 1ac9ea4..80289e2 100644 --- a/packages/code/src/lib/workspace/init.ts +++ b/packages/code/src/lib/workspace/init.ts @@ -29,6 +29,15 @@ worktrees_ttl_days = 7 # Local observability dashboard (http://localhost:4400). Set false to disable. dashboard = true # dashboard_port = 4400 +# When automatic merge-conflict resolution runs on the agent's PRs. +# "auto" (default) resolves as soon as a conflict is detected; "scheduled" +# queues conflicts during polling and resolves them in one off-peak window +# to cut AI token spend. Requires exactly one schedule below, and a worker +# restart to take effect. "disabled" turns it off entirely — conflicts +# stay for manual resolution (devintern resolve-conflicts ). +# conflict_resolution = "scheduled" +# conflict_resolution_cron = "0 3 * * *" # worker host timezone +# conflict_resolution_interval = "1d" [defaults] # Tracker the fleet query runs against: jira, linear, github, azure-devops, diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index 0f4fd94..109a0f1 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -656,7 +656,10 @@ export async function buildFleetEventAcquirers(options: { }, }, addressPr: fleetAddressPr, - resolveConflicts, + resolveConflicts: + config.workspace.conflictResolution === "disabled" ? undefined : resolveConflicts, + conflictSchedule: config.workspace.conflictSchedule, + conflictResolution: config.workspace.conflictResolution, quietPeriodSeconds: parseEnvInteger("WORKER_BASE_SYNC_QUIET_SECONDS", 30, { min: 0 }), runStore, allowedRepos: slugs, diff --git a/packages/code/tests/automation-config.test.ts b/packages/code/tests/automation-config.test.ts index dc3de79..29922d1 100644 --- a/packages/code/tests/automation-config.test.ts +++ b/packages/code/tests/automation-config.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { parseAutomationConfig, parseAutomationInterval } from "../src/lib/automation-config"; +import { + nextScheduleOccurrence, + parseAutomationConfig, + parseAutomationInterval, + parseCronOrIntervalSchedule, +} from "../src/lib/automation-config"; import { parseWorkspaceConfig } from "../src/lib/workspace/config"; describe("automation configuration", () => { @@ -86,3 +91,63 @@ repo = "web" ).toThrow(/does not match any \[\[repos\]\] name/); }); }); + +describe("parseCronOrIntervalSchedule", () => { + test("normalizes a valid cron schedule", () => { + const errors: string[] = []; + const schedule = parseCronOrIntervalSchedule({ cron: " 0 3 * * * " }, { label: "[x]" }, errors); + expect(errors).toEqual([]); + expect(schedule).toEqual({ cron: "0 3 * * *", interval: undefined, intervalMs: undefined }); + }); + + test("honors renamed keys in error messages", () => { + const errors: string[] = []; + parseCronOrIntervalSchedule( + {}, + { + label: "[workspace]", + cronKey: "conflict_resolution_cron", + intervalKey: "conflict_resolution_interval", + }, + errors, + ); + expect(errors).toEqual([ + "[workspace] must set exactly one of conflict_resolution_cron or conflict_resolution_interval.", + ]); + }); + + test("collects every schedule problem", () => { + const errors: string[] = []; + const schedule = parseCronOrIntervalSchedule( + { cron: "bad", interval: "2w" }, + { label: "[[automations]][0]" }, + errors, + ); + expect(schedule).toBeUndefined(); + expect(errors).toEqual([ + "[[automations]][0] must set exactly one of cron or interval.", + "[[automations]][0].cron must be a five-field cron expression.", + "[[automations]][0].interval must use a positive duration such as 15m, 6h, or 1d.", + ]); + }); +}); + +describe("nextScheduleOccurrence", () => { + test("interval schedules are relative to the given time", () => { + const start = 1_750_000_000_000; + expect(nextScheduleOccurrence({ interval: "6h", intervalMs: 6 * 3_600_000 }, start)).toBe( + start + 6 * 3_600_000, + ); + }); + + test("cron occurrences are strictly after the given time", () => { + const start = Date.UTC(2026, 0, 1, 0, 0, 0); + const next = nextScheduleOccurrence({ cron: "0 3 * * *" }, start); + expect(next).toBeGreaterThan(start); + expect(next - start).toBeLessThanOrEqual(29 * 3_600_000); + }); + + test("throws without any schedule", () => { + expect(() => nextScheduleOccurrence({}, 0)).toThrow(/no cron expression/); + }); +}); diff --git a/packages/code/tests/review-polling-acquirer.test.ts b/packages/code/tests/review-polling-acquirer.test.ts index 222b9e6..2018d6e 100644 --- a/packages/code/tests/review-polling-acquirer.test.ts +++ b/packages/code/tests/review-polling-acquirer.test.ts @@ -13,9 +13,11 @@ import type { PolledPr, PolledReview, } from "../src/lib/review-polling-acquirer"; +import type { CronOrIntervalSchedule } from "../src/lib/automation-config"; import { WebhookQueue } from "../src/lib/webhook-queue"; import { RunStore } from "../src/lib/run-recorder"; import { WorkerState } from "../src/lib/worker-state"; +import type { ConflictResolutionMode } from "../src/lib/workspace/config"; describe("ReviewPollingAcquirer", () => { let dbPath: string; @@ -62,6 +64,10 @@ describe("ReviewPollingAcquirer", () => { quietPeriodSeconds?: number; now?: () => number; allowedRepos?: string[]; + conflictSchedule?: CronOrIntervalSchedule; + conflictWindowGraceMs?: number; + conflictResolution?: ConflictResolutionMode; + disableResolveConflicts?: boolean; } = {}, ) { const addressed: string[] = []; @@ -105,14 +111,19 @@ describe("ReviewPollingAcquirer", () => { addressed.push(`${repo}#${n}`); return true; }, - resolveConflicts: async (repo, n) => { - resolved.push(`${repo}#${n}`); - return options.resolveResults?.shift() ?? { outcome: "clean", message: "merged" }; - }, + resolveConflicts: options.disableResolveConflicts + ? undefined + : async (repo, n) => { + resolved.push(`${repo}#${n}`); + return options.resolveResults?.shift() ?? { outcome: "clean", message: "merged" }; + }, quietPeriodSeconds: options.quietPeriodSeconds ?? 0, runStore: options.runStore, now: options.now, allowedRepos: options.allowedRepos, + conflictSchedule: options.conflictSchedule, + conflictWindowGraceMs: options.conflictWindowGraceMs, + conflictResolution: options.conflictResolution, }); return { acquirer, addressed, resolved }; } @@ -732,6 +743,177 @@ describe("ReviewPollingAcquirer", () => { runStore.close(); }); + const DAY_MS = 86_400_000; + const dailySchedule: CronOrIntervalSchedule = { interval: "1d", intervalMs: DAY_MS }; + + function conflictGh(): FakeGitHubState { + return { + prState: "open", + mergeableState: "dirty", + headSha: "head1", + baseSha: "base1", + reviews: [], + comments: [], + }; + } + + function windowState(): { nextWindowAt: number; windowOpenUntil: number } { + const raw = workerState.getCursor("worker:conflict-window")?.cursorValue; + expect(raw).toBeTruthy(); + return JSON.parse(raw!); + } + + test("scheduled mode queues conflicts without invoking the resolver before the window", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + const start = 1_750_000_000_000; + let now = start; + const made = makeAcquirer(conflictGh(), { + conflictSchedule: dailySchedule, + conflictWindowGraceMs: 60 * 60_000, + now: () => now, + }); + + await made.acquirer.tick(); + expect(made.resolved).toEqual([]); + expect(queue.getBaseSyncEvent("base-sync:acme/widgets#42:base1:head1")?.status).toBe("pending"); + + // Enabling scheduled mode only schedules the first window; nothing runs now. + const state = windowState(); + expect(state.nextWindowAt).toBe(start + DAY_MS); + expect(state.windowOpenUntil).toBe(0); + }); + + test("queued conflicts resolve when the window arrives and queue again after it closes", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + let now = 1_750_000_000_000; + const gh = conflictGh(); + const made = makeAcquirer(gh, { + conflictSchedule: dailySchedule, + conflictWindowGraceMs: 60 * 60_000, + now: () => now, + }); + const eventKey = "base-sync:acme/widgets#42:base1:head1"; + + await made.acquirer.tick(); + expect(made.resolved).toEqual([]); + + // The occurrence arrives: the queued conflict resolves in this tick. + now += DAY_MS + 1; + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#42"]); + expect(queue.getBaseSyncEvent(eventKey)?.status).toBe("completed"); + expect(windowState().nextWindowAt).toBe(now + DAY_MS); + + // A new conflict while the window's grace period is open resolves too. + gh.baseSha = "base2"; + await made.acquirer.tick(); + expect(made.resolved).toEqual(["acme/widgets#42", "acme/widgets#42"]); + + // After the grace period closes, conflicts queue for the next window. + now += 61 * 60_000; + gh.baseSha = "base3"; + await made.acquirer.tick(); + expect(made.resolved).toHaveLength(2); + expect(queue.getBaseSyncEvent("base-sync:acme/widgets#42:base3:head1")?.status).toBe("pending"); + }); + + test("a window missed while the worker was down catches up on the first tick after restart", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + let now = 1_750_000_000_000; + const gh = conflictGh(); + const made = makeAcquirer(gh, { + conflictSchedule: dailySchedule, + conflictWindowGraceMs: 60 * 60_000, + now: () => now, + }); + await made.acquirer.tick(); + expect(made.resolved).toEqual([]); + + // Model a restart: a fresh acquirer over the same durable state, much later. + now += 2 * DAY_MS; + const restarted = makeAcquirer(gh, { + conflictSchedule: dailySchedule, + conflictWindowGraceMs: 60 * 60_000, + now: () => now, + }); + await restarted.acquirer.tick(); + expect(restarted.resolved).toEqual(["acme/widgets#42"]); + expect(queue.getBaseSyncEvent("base-sync:acme/widgets#42:base1:head1")?.status).toBe( + "completed", + ); + }); + + test("a PR resolved manually never triggers the queued window run", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + let now = 1_750_000_000_000; + const gh = conflictGh(); + const made = makeAcquirer(gh, { + conflictSchedule: dailySchedule, + conflictWindowGraceMs: 60 * 60_000, + now: () => now, + }); + await made.acquirer.tick(); + expect(made.resolved).toEqual([]); + + // Someone (e.g. `devintern resolve-conflicts `) fixed it out of band. + gh.mergeableState = "clean"; + now += DAY_MS + 1; + await made.acquirer.tick(); + expect(made.resolved).toEqual([]); + }); + + test("disabled mode never detects or resolves conflicts", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + const made = makeAcquirer(conflictGh(), { + disableResolveConflicts: true, + conflictResolution: "disabled", + }); + + await made.acquirer.tick(); + expect(made.resolved).toEqual([]); + expect(queue.getBaseSyncEvent("base-sync:acme/widgets#42:base1:head1")).toBeNull(); + }); + + test("start() surfaces the active conflict-resolution mode", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + const logs: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + logs.push(args.join(" ")); + }; + try { + const scheduled = makeAcquirer( + { prState: "open", reviews: [], comments: [] }, + { + conflictSchedule: { cron: "0 3 * * *" }, + }, + ); + await scheduled.acquirer.start(); + scheduled.acquirer.stop(); + + const auto = makeAcquirer({ prState: "open", reviews: [], comments: [] }); + await auto.acquirer.start(); + auto.acquirer.stop(); + + const disabled = makeAcquirer( + { prState: "open", reviews: [], comments: [] }, + { + disableResolveConflicts: true, + conflictResolution: "disabled", + }, + ); + await disabled.acquirer.start(); + disabled.acquirer.stop(); + } finally { + console.log = original; + } + expect( + logs.some((line) => line.includes('conflict resolution: scheduled (cron "0 3 * * *")')), + ).toBe(true); + expect(logs.some((line) => line.includes("conflict resolution: auto"))).toBe(true); + expect(logs.some((line) => line.includes("conflict resolution: disabled"))).toBe(true); + }); + test("start() unwatches open rows for repos outside allowedRepos", async () => { workerState.recordAgentPr({ repo: "getdevintern/devintern", prNumber: 45 }); workerState.recordAgentPr({ repo: "danii1/devintern", prNumber: 199 }); diff --git a/packages/code/tests/workspace-config.test.ts b/packages/code/tests/workspace-config.test.ts index 035ac4d..6c2a33a 100644 --- a/packages/code/tests/workspace-config.test.ts +++ b/packages/code/tests/workspace-config.test.ts @@ -149,6 +149,139 @@ poll_interval = 0 ).toThrow(/poll_interval must be a positive integer/); }); + test("defaults conflict resolution to auto without a schedule", () => { + const config = parseWorkspaceConfig(` +[defaults] +tracker = "markdown" +`); + expect(config.workspace.conflictResolution).toBe("auto"); + expect(config.workspace.conflictSchedule).toBeUndefined(); + }); + + test("parses scheduled conflict resolution with cron", () => { + const config = parseWorkspaceConfig(` +[workspace] +conflict_resolution = "scheduled" +conflict_resolution_cron = "0 3 * * *" + +[defaults] +tracker = "markdown" +`); + expect(config.workspace.conflictResolution).toBe("scheduled"); + expect(config.workspace.conflictSchedule).toEqual({ cron: "0 3 * * *" }); + }); + + test("parses scheduled conflict resolution with an interval", () => { + const config = parseWorkspaceConfig(` +[workspace] +conflict_resolution = "scheduled" +conflict_resolution_interval = "1d" + +[defaults] +tracker = "markdown" +`); + expect(config.workspace.conflictResolution).toBe("scheduled"); + expect(config.workspace.conflictSchedule).toEqual({ interval: "1d", intervalMs: 86_400_000 }); + }); + + test("scheduled conflict resolution requires exactly one schedule key", () => { + expect(() => + parseWorkspaceConfig(` +[workspace] +conflict_resolution = "scheduled" + +[defaults] +tracker = "markdown" +`), + ).toThrow(/must set exactly one of conflict_resolution_cron or conflict_resolution_interval/); + + expect(() => + parseWorkspaceConfig(` +[workspace] +conflict_resolution = "scheduled" +conflict_resolution_cron = "0 3 * * *" +conflict_resolution_interval = "1d" + +[defaults] +tracker = "markdown" +`), + ).toThrow(/must set exactly one of conflict_resolution_cron or conflict_resolution_interval/); + }); + + test("rejects invalid scheduled conflict resolution schedules", () => { + expect(() => + parseWorkspaceConfig(` +[workspace] +conflict_resolution = "scheduled" +conflict_resolution_cron = "99 bad" + +[defaults] +tracker = "markdown" +`), + ).toThrow(/five-field cron expression/); + + expect(() => + parseWorkspaceConfig(` +[workspace] +conflict_resolution = "scheduled" +conflict_resolution_interval = "30s" + +[defaults] +tracker = "markdown" +`), + ).toThrow(/positive duration such as 15m, 6h, or 1d/); + }); + + test("rejects unknown conflict resolution modes", () => { + expect(() => + parseWorkspaceConfig(` +[workspace] +conflict_resolution = "nightly" + +[defaults] +tracker = "markdown" +`), + ).toThrow(/must be "auto", "scheduled", or "disabled"/); + }); + + test("parses disabled conflict resolution", () => { + const config = parseWorkspaceConfig(` +[workspace] +conflict_resolution = "disabled" + +[defaults] +tracker = "markdown" +`); + expect(config.workspace.conflictResolution).toBe("disabled"); + expect(config.workspace.conflictSchedule).toBeUndefined(); + }); + + test("rejects schedule keys with disabled conflict resolution", () => { + expect(() => + parseWorkspaceConfig(` +[workspace] +conflict_resolution = "disabled" +conflict_resolution_cron = "0 3 * * *" + +[defaults] +tracker = "markdown" +`), + ).toThrow(/only used when conflict_resolution = "scheduled"/); + }); + + test("rejects schedule keys without scheduled mode", () => { + expect(() => + parseWorkspaceConfig(` +[workspace] +conflict_resolution = "auto" +conflict_resolution_cron = "0 3 * * *" + +[defaults] +tracker = "markdown" +`), + ).toThrow(/only used when conflict_resolution = "scheduled"/); + }); + test("rejects invalid pr_labels values", () => { expect(() => parseWorkspaceConfig(`