Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/code/worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr-url>`; 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 <pr-url>` 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 <pr-url>`.

## 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.
Expand Down
25 changes: 25 additions & 0 deletions docs/code/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <pr-url>` 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 <pr-url>`). 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:
Expand Down
11 changes: 5 additions & 6 deletions packages/code/src/lib/automation-acquirer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down
114 changes: 84 additions & 30 deletions packages/code/src/lib/automation-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])$/;
Expand All @@ -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<string, unknown>,
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,
Expand Down Expand Up @@ -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,
});
}
Expand Down
Loading
Loading