From 7b0325235205518982ada86d7e9d40388d0a2cd4 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 28 Aug 2026 15:23:23 +0700 Subject: [PATCH 1/2] feat: implement DEV-105 - Make automatic conflict resolution in workspace mode configurable: run immediately or on a schedule (e.g. nightly) to reduce AI token spend --- docs/code/worker.md | 14 ++ docs/code/workspaces.md | 22 +++ packages/code/src/lib/automation-acquirer.ts | 11 +- packages/code/src/lib/automation-config.ts | 114 +++++++++---- .../code/src/lib/review-polling-acquirer.ts | 145 +++++++++++++++++ packages/code/src/lib/workspace/config.ts | 52 +++++- packages/code/src/lib/workspace/init.ts | 8 + .../src/lib/workspace/workspace-worker.ts | 1 + packages/code/tests/automation-config.test.ts | 67 +++++++- .../tests/review-polling-acquirer.test.ts | 153 ++++++++++++++++++ packages/code/tests/workspace-config.test.ts | 108 +++++++++++++ 11 files changed, 655 insertions(+), 40 deletions(-) diff --git a/docs/code/worker.md b/docs/code/worker.md index f5a9761..a0750b1 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -203,6 +203,20 @@ 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) for the config reference and tradeoffs. + ## 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 ced8e84..7d679f2 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -29,6 +29,10 @@ 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 [defaults] tracker = "jira" @@ -78,6 +82,24 @@ 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` + +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. + ### 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..1fd7c52 100644 --- a/packages/code/src/lib/review-polling-acquirer.ts +++ b/packages/code/src/lib/review-polling-acquirer.ts @@ -25,6 +25,8 @@ 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"; @@ -107,13 +109,41 @@ 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; + /** + * 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 +406,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 +433,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 +455,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 +627,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 +741,105 @@ 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 { + 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 2f9118d..5f7c3ac 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"; + /** Workspace-wide settings from the `[workspace]` table. */ export interface WorkspaceSettings { /** Days before a leftover (failed-run) task worktree is swept. */ @@ -13,6 +16,18 @@ 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}). + */ + 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. */ @@ -67,6 +82,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._-]*$/; @@ -236,6 +252,36 @@ 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 && conflictResolutionRaw !== "auto") { + errors.push(`[workspace].conflict_resolution must be "auto" or "scheduled".`); + } + 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)) { @@ -330,7 +376,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 0614e8c..8965694 100644 --- a/packages/code/src/lib/workspace/init.ts +++ b/packages/code/src/lib/workspace/init.ts @@ -29,6 +29,14 @@ 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. +# 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..c0a25c0 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -657,6 +657,7 @@ export async function buildFleetEventAcquirers(options: { }, addressPr: fleetAddressPr, resolveConflicts, + conflictSchedule: config.workspace.conflictSchedule, 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..d76360b 100644 --- a/packages/code/tests/review-polling-acquirer.test.ts +++ b/packages/code/tests/review-polling-acquirer.test.ts @@ -13,6 +13,7 @@ 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"; @@ -62,6 +63,8 @@ describe("ReviewPollingAcquirer", () => { quietPeriodSeconds?: number; now?: () => number; allowedRepos?: string[]; + conflictSchedule?: CronOrIntervalSchedule; + conflictWindowGraceMs?: number; } = {}, ) { const addressed: string[] = []; @@ -113,6 +116,8 @@ describe("ReviewPollingAcquirer", () => { runStore: options.runStore, now: options.now, allowedRepos: options.allowedRepos, + conflictSchedule: options.conflictSchedule, + conflictWindowGraceMs: options.conflictWindowGraceMs, }); return { acquirer, addressed, resolved }; } @@ -732,6 +737,154 @@ 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("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(); + } 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); + }); + 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 b628d66..94d286a 100644 --- a/packages/code/tests/workspace-config.test.ts +++ b/packages/code/tests/workspace-config.test.ts @@ -143,6 +143,114 @@ 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" or "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 TOML", () => { expect(() => parseWorkspaceConfig("[defaults\ntracker=")).toThrow(/Failed to parse/); }); From f210c8e5e27ef2df0cd903367af20661bb41a8ba Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 28 Aug 2026 15:54:13 +0700 Subject: [PATCH 2/2] feat: add disabled mode to conflict_resolution to turn automatic conflict resolution off entirely [workspace].conflict_resolution = "disabled" now opts a workspace out of automatic base-sync on the agent's PRs: no detection, no queuing, no agent runs. Conflicted PRs stay conflicted until resolved by hand or via `devintern resolve-conflicts `. Review feedback and @mentions are unaffected, and the worker logs the disabled mode at startup. --- docs/code/worker.md | 4 +- docs/code/workspaces.md | 5 ++- .../code/src/lib/review-polling-acquirer.ts | 17 +++++++++ packages/code/src/lib/workspace/config.ts | 10 +++-- packages/code/src/lib/workspace/init.ts | 3 +- .../src/lib/workspace/workspace-worker.ts | 4 +- .../tests/review-polling-acquirer.test.ts | 37 +++++++++++++++++-- packages/code/tests/workspace-config.test.ts | 27 +++++++++++++- 8 files changed, 95 insertions(+), 12 deletions(-) diff --git a/docs/code/worker.md b/docs/code/worker.md index a0750b1..a32715f 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -215,7 +215,9 @@ 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) for the config reference and tradeoffs. +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 diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index 7d679f2..3443b5f 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -33,6 +33,7 @@ dashboard = true # 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" @@ -82,7 +83,7 @@ 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` +### 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. @@ -100,6 +101,8 @@ Two things are never delayed by scheduled mode: review feedback on the agent's P 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/review-polling-acquirer.ts b/packages/code/src/lib/review-polling-acquirer.ts index 1fd7c52..e2d9690 100644 --- a/packages/code/src/lib/review-polling-acquirer.ts +++ b/packages/code/src/lib/review-polling-acquirer.ts @@ -31,6 +31,7 @@ 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 { @@ -117,6 +118,14 @@ export interface ReviewPollingAcquirerOptions { * 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 @@ -827,6 +836,14 @@ export class ReviewPollingAcquirer implements Acquirer { /** 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)`, diff --git a/packages/code/src/lib/workspace/config.ts b/packages/code/src/lib/workspace/config.ts index 5f7c3ac..2e8bba8 100644 --- a/packages/code/src/lib/workspace/config.ts +++ b/packages/code/src/lib/workspace/config.ts @@ -6,7 +6,7 @@ import type { AutomationConfig, CronOrIntervalSchedule } from "../automation-con import { parseToml } from "./toml"; /** When automatic conflict resolution on the agent's PRs runs. */ -export type ConflictResolutionMode = "auto" | "scheduled"; +export type ConflictResolutionMode = "auto" | "scheduled" | "disabled"; /** Workspace-wide settings from the `[workspace]` table. */ export interface WorkspaceSettings { @@ -19,7 +19,9 @@ export interface WorkspaceSettings { /** * `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}). + * 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; /** @@ -271,8 +273,10 @@ export function parseWorkspaceConfig( }, errors, ); + } else if (conflictResolutionRaw === "disabled") { + conflictResolution = "disabled"; } else if (conflictResolutionRaw && conflictResolutionRaw !== "auto") { - errors.push(`[workspace].conflict_resolution must be "auto" or "scheduled".`); + 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) { diff --git a/packages/code/src/lib/workspace/init.ts b/packages/code/src/lib/workspace/init.ts index 8965694..557a056 100644 --- a/packages/code/src/lib/workspace/init.ts +++ b/packages/code/src/lib/workspace/init.ts @@ -33,7 +33,8 @@ dashboard = true # "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. +# 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" diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index c0a25c0..109a0f1 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -656,8 +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/review-polling-acquirer.test.ts b/packages/code/tests/review-polling-acquirer.test.ts index d76360b..2018d6e 100644 --- a/packages/code/tests/review-polling-acquirer.test.ts +++ b/packages/code/tests/review-polling-acquirer.test.ts @@ -17,6 +17,7 @@ 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; @@ -65,6 +66,8 @@ describe("ReviewPollingAcquirer", () => { allowedRepos?: string[]; conflictSchedule?: CronOrIntervalSchedule; conflictWindowGraceMs?: number; + conflictResolution?: ConflictResolutionMode; + disableResolveConflicts?: boolean; } = {}, ) { const addressed: string[] = []; @@ -108,16 +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 }; } @@ -856,6 +862,18 @@ describe("ReviewPollingAcquirer", () => { 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[] = []; @@ -876,6 +894,16 @@ describe("ReviewPollingAcquirer", () => { 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; } @@ -883,6 +911,7 @@ describe("ReviewPollingAcquirer", () => { 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 () => { diff --git a/packages/code/tests/workspace-config.test.ts b/packages/code/tests/workspace-config.test.ts index 94d286a..20195b6 100644 --- a/packages/code/tests/workspace-config.test.ts +++ b/packages/code/tests/workspace-config.test.ts @@ -235,7 +235,32 @@ conflict_resolution = "nightly" [defaults] tracker = "markdown" `), - ).toThrow(/must be "auto" or "scheduled"/); + ).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", () => {