From c547fd19474f345109ff4c6aaec6a5c34bfe6931 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 16 Oct 2026 13:54:03 +0700 Subject: [PATCH 1/6] feat: implement DEV-184 - Per-automation and per-estimation agent harness, model, and effort selection --- docs/code/worker.md | 34 +- docs/code/workspaces.md | 1 + packages/code/src/lib/agent/selection.ts | 130 ++++++++ packages/code/src/lib/automation/acquirer.ts | 32 +- packages/code/src/lib/automation/config.ts | 7 +- .../src/lib/automation/estimation-acquirer.ts | 4 +- .../src/lib/automation/estimation-config.ts | 7 +- packages/code/src/lib/workspace/env.ts | 24 +- .../src/lib/workspace/workspace-worker.ts | 11 +- packages/code/tests/agent-selection.test.ts | 305 ++++++++++++++++++ packages/code/tests/workspace-worker.test.ts | 29 ++ 11 files changed, 571 insertions(+), 13 deletions(-) create mode 100644 packages/code/src/lib/agent/selection.ts create mode 100644 packages/code/tests/agent-selection.test.ts diff --git a/docs/code/worker.md b/docs/code/worker.md index 7d2a341e..07620cff 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -39,7 +39,7 @@ devintern webhook serve ## Agent failover -Set `AGENT_HARNESS=codex,grok` (comma-separated, priority first) in the workspace `.env` so the worker keeps going when one agent hits a usage limit. Failover applies to every worker job: tracker tasks, PR review addressing, `@mention` runs, conflict resolution, scheduled automations, estimations, dashboard retries, and relay-driven work. Details: [Failover across multiple harnesses](./configuration.md#failover-across-multiple-harnesses). +Set `AGENT_HARNESS=codex,grok` (comma-separated, priority first) in the workspace `.env` so the worker keeps going when one agent hits a usage limit. Failover applies to every worker job: tracker tasks, PR review addressing, `@mention` runs, conflict resolution, scheduled automations, estimations, dashboard retries, and relay-driven work — except automations or estimations that pin their own `harness` (see [Choosing the agent per schedule](#choosing-the-agent-per-schedule)). Details: [Failover across multiple harnesses](./configuration.md#failover-across-multiple-harnesses). ## Error-monitor auto-fixes @@ -131,6 +131,36 @@ The prompt replaces the ticket description the agent would normally read, so tre When `open_pr = true`, occurrences receive the same flags as polled tasks: `[defaults].worker_task_args` in `workspace.toml` (default `--create-pr`). For example, set `worker_task_args = "--create-pr --auto-review"` to have every automated PR go through the review loop too, and add `--auto-review-iterations 3` to the same value (or set the `AUTO_REVIEW_ITERATIONS` env var) to raise the shared review–fix cycle cap (default: 2). This setting applies to polled tracker tasks as well. Automations with `open_pr` off (the default) are never affected by these flags — PR and review flags are stripped from them and `--no-git` is applied instead. +### Choosing the agent per schedule + +By default every run uses the workspace `AGENT_HARNESS` / `AGENT_MODEL` / `AGENT_EFFORT` from the [environment layers](./configuration.md). An individual `[[automations]]` or `[[estimations]]` entry can override the agent for its own runs, so a cheap model can handle a daily triage job while a frontier model handles release work: + +```toml +[[automations]] +id = "log-triage" +enabled = true +interval = "1d" +harness = "codex" +model = "gpt-5-codex" +effort = "low" +prompt = "Summarize new errors and file at most one issue." + +[[automations]] +id = "release-manager" +enabled = true +cron = "0 9 * * 1" +harness = "claude-code" +effort = "high" +open_pr = true +prompt = "Cut the next release and update the changelog." +``` + +- `harness` names **one** registered agent (aliases such as `agy` resolve to their canonical name). An unknown name fails validation, naming the entry id and the `harness` field. +- `model` is a harness-specific string (see that agent's CLI docs) and is never validated cross-harness. Omit it to use the chosen harness's default model (or the inherited `AGENT_MODEL`, if one is set). +- `effort` accepts only `low`, `medium`, or `high`; anything else fails validation. It is applied only for harnesses that support reasoning effort — on any other harness it is ignored with a warning, and the run proceeds. +- Precedence for the entry's runs is **entry field > team/repo env > workspace env > harness default**. Fields you omit fall through to the existing workspace/repo/team values, and entries that set none of the three fields behave exactly as before. +- An entry that pins `harness` uses exactly that agent and opts out of the workspace `AGENT_HARNESS` failover chain for its runs; entries without `harness` keep normal failover. The selection applies to subsequent occurrences after a live reload; a run already in flight keeps the agent it started with. + ### Schedule semantics Schedule cursors and claims live in `queue.db`. Missed occurrences coalesce to at most one immediate run after startup. The occurrence cursor advances atomically when claimed, so a crash does not replay a possibly completed run. Active claims receive heartbeats; after two minutes without a heartbeat a later due occurrence may recover the stale claim. If the same automation is still active at its next occurrence, that occurrence is logged and skipped without creating a run record. If the repository lock is held by another task, the occurrence is also skipped. This is an at-most-once policy: skipped occurrences are not replayed. A dashboard-triggered manual run holds the same lease while it is active, so a scheduled occurrence coming due mid-validation is skipped rather than run concurrently. @@ -169,6 +199,8 @@ query = "sprint in openSprints() AND \"Story Points\" is EMPTY" Each entry needs a unique `id`, boolean `enabled`, non-empty `query`, and exactly one of `cron` or `interval`. Omitting the table (or leaving every entry disabled) changes nothing: estimation is simply off, and `[defaults].task_query` is never estimated as a side effect. The workspace tracker must support estimation (Jira, Linear, Azure DevOps, Asana, GitHub comment-only); Trello/markdown workspaces fail at startup with a clear error. `worker init` does not ask about estimations — add tables by hand. +Estimation entries accept the same optional `harness`, `model`, and `effort` overrides as `[[automations]]`, so a scheduled sweep can run on a cheap model — see [Choosing the agent per schedule](#choosing-the-agent-per-schedule). Because estimation never touches a repository, these fields do not require (and are unaffected by) a `repo`. + Estimation entries live-reload with `workspace.toml`: added and re-enabled entries schedule their next future occurrence, changed schedules reset their cursor, query edits apply to the next sweep, and removed entries stop scheduling without interrupting a sweep already in progress. When an entry comes due the worker runs one-shot `devintern --estimate --query ""` from the workspace home. That path keeps all of the interactive behavior: tickets younger than 24 hours are skipped, already-estimated tickets are skipped unless the ticket changed since the estimate, changed tickets are re-estimated with the estimate comment updated in place, points are written to the tracker field, and usage-limit aborts exit cleanly so the next occurrence retries. Each sweep is recorded with its own `estimate` origin (plus the schedule id) in the [dashboard](./dashboard.md) — it never shows up as a scheduled implement run. diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index 761529b5..d67cb99f 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -108,6 +108,7 @@ prompt = "Review the frontend and clean up one source of recurring noise." - `[worker].auto_update` keeps a globally installed CLI current while the worker is idle (checked at most daily, installed without interrupting work, restart on success). See [Running the Worker Unattended: Keeping the worker up to date](./worker.md#keeping-the-worker-up-to-date). - `[[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. `open_pr` (boolean, default `false`) decides whether occurrences open a pull request: opt-in per automation, and workspace-level `worker_task_args` never overrides it. See [Worker Daemon → Recurring automations](./worker.md#recurring-automations) for prompt-writing guidance and schedule semantics. - `[[estimations]]` schedules unattended story-point sweeps (tracker query + cron/interval, no `prompt`, no `repo`). The workspace tracker must support estimation. See [Worker Daemon → Scheduled story-point estimation](./worker.md#scheduled-story-point-estimation). +- `[[automations]]` and `[[estimations]]` entries may each set `harness` (one agent name), `model`, and `effort` (`"low"`, `"medium"`, or `"high"`) to run that schedule on a different agent than the workspace default. Precedence for an entry's runs is **entry field > team/repo env > workspace env > harness default**; omitted fields keep the existing workspace/repo/team values, and `model` is harness-specific (never validated cross-harness). An unknown `harness` or an `effort` outside the three levels fails validation naming the entry id and field. `effort` on a harness that does not support reasoning effort is ignored with a warning instead of failing the run. These overrides apply on live reload to subsequent occurrences; in-flight runs finish under the selection they started with. See [Worker Daemon → Choosing the agent per schedule](./worker.md#choosing-the-agent-per-schedule). - `[[error_monitors]]` maps each Sentry project to one repo and an optional team, with per-source credential layers for multi-project setups. See [Sentry Auto-fixes](./sentry-integration.md). ### Bounded host concurrency diff --git a/packages/code/src/lib/agent/selection.ts b/packages/code/src/lib/agent/selection.ts new file mode 100644 index 00000000..5cd30fd8 --- /dev/null +++ b/packages/code/src/lib/agent/selection.ts @@ -0,0 +1,130 @@ +/** + * Per-entry agent selection for workspace `[[automations]]` / `[[estimations]]`. + * + * A scheduled entry may name the agent (`harness`) and `model` / reasoning + * `effort` for its own runs, overriding the workspace-wide `AGENT_HARNESS` / + * `AGENT_MODEL` / `AGENT_EFFORT` resolved from the environment layers. The + * selection is validated here so a bad value fails `workspace.toml` parsing + * with an actionable message instead of silently falling back. + * + * Precedence for an entry's runs: entry field > team/repo env > workspace + * env > harness default. `model` is harness-specific and is never validated + * cross-harness; omitting it leaves the model to the harness default (or the + * inherited env value when one is set). Disallowed effort on a harness that + * does not support it is ignored at run time by the harness runners, which + * warn instead of failing. + */ + +import { getHarness, listHarnesses, parseAgentEffort } from "@devintern/agent-harness"; +import type { AgentEffort } from "@devintern/agent-harness"; + +/** Optional per-entry agent overrides parsed from an automation/estimation. */ +export interface AgentSelection { + /** Canonical harness name for this entry's runs (validated on parse). */ + harness?: string; + /** Harness-specific model string; undefined keeps the harness default. */ + model?: string; + /** Reasoning effort for harnesses that support it. */ + effort?: AgentEffort; +} + +/** Copy only the selection fields actually set on a source object. */ +export function pickAgentSelection(source: AgentSelection): AgentSelection { + return { + ...(source.harness ? { harness: source.harness } : {}), + ...(source.model ? { model: source.model } : {}), + ...(source.effort ? { effort: source.effort } : {}), + }; +} + +/** + * Validate the optional `harness`, `model`, and `effort` fields of one + * automation/estimation table. + * + * Collects every problem in `errors` (the caller's shared list) and returns + * the selection with the fields that validated. `harness` must name a single + * registered harness (aliases resolve to their canonical name) so the entry's + * run uses exactly the agent it names; `effort` reuses the shared + * `parseAgentEffort` validator. + * + * @param table - Raw entry table. + * @param options - `label` used in messages and the owning `id`, when known. + * @param errors - Shared error accumulator. + */ +export function parseAgentSelection( + table: Record, + options: { label: string; id?: string }, + errors: string[], +): AgentSelection { + const { label } = options; + const entrySuffix = options.id ? ` (entry "${options.id}")` : ""; + const selection: AgentSelection = {}; + + const readNonEmptyString = (key: string): string | undefined => { + const value = table[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || !value.trim()) { + errors.push(`${label}.${key} must be a non-empty string${entrySuffix}.`); + return undefined; + } + return value.trim(); + }; + + const harness = readNonEmptyString("harness"); + if (harness !== undefined) { + const names = harness + .split(",") + .map((name) => name.trim()) + .filter(Boolean); + const canonical = names.map((name) => getHarness(name)).filter((h) => h !== undefined); + if (names.length !== 1) { + errors.push( + `${label}.harness must name a single agent harness, got "${harness}"${entrySuffix}.`, + ); + } else if (canonical.length !== 1) { + const available = listHarnesses() + .map((h) => `"${h.name}"`) + .join(", "); + errors.push( + `${label}.harness "${harness}" is not a known agent harness${entrySuffix}. ` + + `Available harnesses: ${available}.`, + ); + } else { + selection.harness = canonical[0]!.name; + } + } + + const model = readNonEmptyString("model"); + if (model !== undefined) { + selection.model = model; + } + + const effort = readNonEmptyString("effort"); + if (effort !== undefined) { + try { + selection.effort = parseAgentEffort(effort); + } catch { + errors.push(`${label}.effort "${effort}" must be one of low, medium, or high${entrySuffix}.`); + } + } + + return selection; +} + +/** + * Overlay an entry's agent selection onto a composed environment. + * + * Only fields the entry set are written, so omitted fields keep the value + * from the environment layers (team/repo/workspace) or the harness default. + * Mutates and returns `env` for chaining. + */ +export function applyAgentSelectionEnv( + env: Record, + selection: AgentSelection | undefined, +): Record { + if (!selection) return env; + if (selection.harness) env.AGENT_HARNESS = selection.harness; + if (selection.model) env.AGENT_MODEL = selection.model; + if (selection.effort) env.AGENT_EFFORT = selection.effort; + return env; +} diff --git a/packages/code/src/lib/automation/acquirer.ts b/packages/code/src/lib/automation/acquirer.ts index 27e9fe78..54b6ea33 100644 --- a/packages/code/src/lib/automation/acquirer.ts +++ b/packages/code/src/lib/automation/acquirer.ts @@ -10,6 +10,7 @@ import type { AutomationConfig } from "./config"; import { automationTaskArgs, nextScheduleOccurrence } from "./config"; import { AutomationStateStore } from "./state"; import { workerTaskArgs } from "../acquirers/task-polling"; +import { applyAgentSelectionEnv, pickAgentSelection } from "../agent/selection"; import { resolveProjectConfigDir } from "../config/config-dir"; import { RUN_ORIGIN_ENV } from "../observability/analytics"; import { getWorkerFailover } from "../worker/failover"; @@ -618,7 +619,7 @@ export function automationRunEnv( originValue: string = "scheduled", ): Record { return { - ...base, + ...applyAgentSelectionEnv({ ...base }, pickAgentSelection(automation)), [AUTOMATION_ORIGIN_ENV]: originValue, [AUTOMATION_ID_ENV]: automation.id, }; @@ -638,6 +639,9 @@ function defaultSpawnRun( { cwd: context.cwd, env: automationRunEnv(automation, context.env, originValue), + // A pinned harness must survive the failover child-env pin (see + // `pinAutomationEnv`); entries without one keep workspace failover. + harnessPin: automation.harness, terminationGraceMs, }, ); @@ -674,6 +678,13 @@ export function spawnAutomationProcess( options: { cwd: string; env: Record; + /** + * Explicit harness pinned by the scheduled entry (`harness` field). When + * set, the child is pinned to it and the workspace failover overlay is + * skipped so the entry always uses the agent it named. Entries without a + * `harness` keep normal workspace failover. + */ + harnessPin?: string; terminationGraceMs?: number; }, ): SpawnedAutomationRun { @@ -746,11 +757,13 @@ export function spawnAutomationProcess( } if (code === USAGE_LIMIT_EXIT_CODE) { const failover = getWorkerFailover(); - if (failover && !failover.allLimited()) { + // A harness pinned by the entry opts out of workspace failover, so an + // explicit selection never silently runs a different agent. + if (!options.harnessPin && failover && !failover.allLimited()) { const outcome = failover.reportFromHint(readUsageLimitHint(env[USAGE_LIMIT_FILE_ENV])); if (outcome.kind !== "exhausted") { cleanupHint(); - const nextEnv = pinAutomationEnv(options.env); + const nextEnv = pinAutomationEnv(options.env, options.harnessPin); hintDir = nextEnv.hintDir; start(nextEnv.env); return; @@ -773,7 +786,7 @@ export function spawnAutomationProcess( }); }; - const initial = pinAutomationEnv(options.env); + const initial = pinAutomationEnv(options.env, options.harnessPin); hintDir = initial.hintDir; start(initial.env); @@ -783,10 +796,19 @@ export function spawnAutomationProcess( }; } -function pinAutomationEnv(base: Record): { +function pinAutomationEnv( + base: Record, + harnessPin?: string, +): { env: Record; hintDir?: string; } { + if (harnessPin) { + // Pin the entry's own harness and skip the failover overlay entirely so + // neither the workspace active harness nor the usage-limit protocol can + // re-route an entry that named its agent. + return { env: { ...base, AGENT_HARNESS: harnessPin } }; + } const failover = getWorkerFailover(); if (!failover) { return { env: base }; diff --git a/packages/code/src/lib/automation/config.ts b/packages/code/src/lib/automation/config.ts index a073d6f8..6b96d6fa 100644 --- a/packages/code/src/lib/automation/config.ts +++ b/packages/code/src/lib/automation/config.ts @@ -4,8 +4,10 @@ import { join } from "path"; import { CronExpressionParser } from "cron-parser"; import { parseToml } from "../workspace/toml"; +import { parseAgentSelection } from "../agent/selection"; +import type { AgentSelection } from "../agent/selection"; -export interface AutomationConfig { +export interface AutomationConfig extends AgentSelection { id: string; enabled: boolean; prompt: string; @@ -181,6 +183,8 @@ export function parseAutomationEntries( errors.push(`${label}.repo "${repo}" does not match any [[repos]] name.`); } + const selection = parseAgentSelection(table, { label, id }, errors); + if (id && typeof enabledValue === "boolean" && prompt && schedule) { automations.push({ id, @@ -191,6 +195,7 @@ export function parseAutomationEntries( intervalMs: schedule.intervalMs, repo, openPr, + ...selection, }); } } diff --git a/packages/code/src/lib/automation/estimation-acquirer.ts b/packages/code/src/lib/automation/estimation-acquirer.ts index 037c8ecc..1ee014a9 100644 --- a/packages/code/src/lib/automation/estimation-acquirer.ts +++ b/packages/code/src/lib/automation/estimation-acquirer.ts @@ -6,6 +6,7 @@ import { } from "./acquirer"; import type { AutomationRunContext, SpawnedAutomationRun } from "./acquirer"; import type { AutomationConfig } from "./config"; +import { applyAgentSelectionEnv, pickAgentSelection } from "../agent/selection"; import type { EstimationConfig } from "./estimation-config"; /** Value of the run-origin marker that attributes CLI runs to scheduled estimation. */ @@ -49,7 +50,7 @@ export function estimationRunEnv( base?: Record, ): Record { return { - ...base, + ...applyAgentSelectionEnv({ ...base }, pickAgentSelection(estimation)), [AUTOMATION_ORIGIN_ENV]: ESTIMATION_ORIGIN_ENV_VALUE, [AUTOMATION_ID_ENV]: estimation.id, }; @@ -73,6 +74,7 @@ export function spawnEstimationSweep( { cwd: context.cwd, env: estimationRunEnv(estimation, context.env), + harnessPin: estimation.harness, terminationGraceMs, }, ); diff --git a/packages/code/src/lib/automation/estimation-config.ts b/packages/code/src/lib/automation/estimation-config.ts index 206d62da..5c7fadf6 100644 --- a/packages/code/src/lib/automation/estimation-config.ts +++ b/packages/code/src/lib/automation/estimation-config.ts @@ -1,6 +1,8 @@ import { CronExpressionParser } from "cron-parser"; import { parseAutomationInterval } from "./config"; +import { parseAgentSelection } from "../agent/selection"; +import type { AgentSelection } from "../agent/selection"; /** * One workspace `[[estimations]]` entry: a scheduled story-point sweep. @@ -9,7 +11,7 @@ import { parseAutomationInterval } from "./config"; * `query` — the automation prompt pipeline is never used, and there is no * `repo` because estimation never touches a repository. */ -export interface EstimationConfig { +export interface EstimationConfig extends AgentSelection { id: string; enabled: boolean; /** Tracker query selecting the tickets to estimate (same language as `--query`). */ @@ -90,6 +92,8 @@ export function parseEstimationEntries(value: unknown): { errors.push(`${label}.interval must use a positive duration such as 15m, 6h, or 1d.`); } + const selection = parseAgentSelection(table, { label, id }, errors); + if ( id && typeof enabledValue === "boolean" && @@ -104,6 +108,7 @@ export function parseEstimationEntries(value: unknown): { cron, interval, intervalMs: intervalMs ?? undefined, + ...selection, }); } } diff --git a/packages/code/src/lib/workspace/env.ts b/packages/code/src/lib/workspace/env.ts index 63bd9e1b..5cf8b7ec 100644 --- a/packages/code/src/lib/workspace/env.ts +++ b/packages/code/src/lib/workspace/env.ts @@ -13,6 +13,8 @@ import { isAbsolute, join } from "path"; import type { ErrorMonitorConfig, RepoConfig, TeamConfig } from "./config"; import { resolveWorkspaceDir, workspaceDbPath, workspaceEnvPath } from "./paths"; +import { applyAgentSelectionEnv } from "../agent/selection"; +import type { AgentSelection } from "../agent/selection"; import { ANALYTICS_CONFIG_DIR_ENV } from "../observability/analytics"; import { WORKER_SUBPROCESS_ENV } from "../config/config-dir"; @@ -123,12 +125,18 @@ export function gitHubSlugFromRemote(remote: string): string | null { * {@link ACTIONED_SOURCE_ENV}). * @param options.pipelineDefaultFile - Absolute path of the workspace default * pipeline file, if one is configured. + * @param options.agentSelection - Per-entry harness/model/effort overrides + * applied last (above every env layer). * @returns Environment record to pass to `runTaskViaCli` / `runAddressReviewViaCli`. */ export function buildRepoEnv( repo: RepoConfig, workspaceDir: string = resolveWorkspaceDir(), - options: { actionedSource?: string; pipelineDefaultFile?: string } = {}, + options: { + actionedSource?: string; + pipelineDefaultFile?: string; + agentSelection?: AgentSelection; + } = {}, ): Record { const workspaceEnv = parseEnvFile(workspaceEnvPath(workspaceDir)); const repoFileEnv = repo.envFile @@ -166,6 +174,8 @@ export function buildRepoEnv( env.PR_LABELS = repo.prLabels.join(","); } + applyAgentSelectionEnv(env, options.agentSelection); + return env; } @@ -277,7 +287,11 @@ export function buildTeamTaskEnv( repo: RepoConfig, team: TeamConfig, workspaceDir: string = resolveWorkspaceDir(), - options: { actionedSource?: string; pipelineDefaultFile?: string } = {}, + options: { + actionedSource?: string; + pipelineDefaultFile?: string; + agentSelection?: AgentSelection; + } = {}, ): Record { const env = buildRepoEnv(repo, workspaceDir, options); Object.assign(env, teamLayerEnv(team, workspaceDir)); @@ -286,6 +300,8 @@ export function buildTeamTaskEnv( // so re-pin it here: a team `env_file` or inline value must not re-route the // pipeline or resurrect a stale reference. pinPipelineEnv(env, repo, workspaceDir, options); + // Re-apply the entry selection so a team layer cannot re-route the agent. + applyAgentSelectionEnv(env, options.agentSelection); env.TASK_TRACKER = team.tracker; env[WORKSPACE_TEAM_ENV] = team.name; return env; @@ -319,12 +335,16 @@ export function buildErrorMonitorEnv( * paths pinned, so the child resolves auth and license state in the workspace. * * @param workspaceDir - Workspace home (defaults to `~/.devintern`). + * @param options.agentSelection - Per-entry harness/model/effort overrides + * applied last (above the process env). */ export function buildWorkspaceContextEnv( workspaceDir: string = resolveWorkspaceDir(), + options: { agentSelection?: AgentSelection } = {}, ): Record { const env: Record = { ...process.env }; pinWorkerRuntimeEnv(env, workspaceDir); + applyAgentSelectionEnv(env, options.agentSelection); return env; } diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index 2936b88f..bc2ab4be 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -53,6 +53,7 @@ import type { AutomationConfig } from "../automation/config"; import { automationTaskArgs } from "../automation/config"; import { AutomationStateStore } from "../automation/state"; import { EstimationAcquirer } from "../automation/estimation-acquirer"; +import { pickAgentSelection } from "../agent/selection"; import { createTaskSupervisor, JobNotStartedError } from "../worker/supervisor"; import type { TaskSupervisor } from "../worker/supervisor"; import type { AutomationRunContext } from "../automation/acquirer"; @@ -413,6 +414,9 @@ export async function resolveWorkspaceAutomationContext( // Keep occurrence task files in the workspace home (next to repos/, // worktrees/, and the central DB) instead of inside a repo worktree. const taskFileDir = join(workspaceDir, "automations"); + // The entry's own harness/model/effort override every env layer for this + // occurrence only; other runs keep the workspace/repo defaults. + const agentSelection = pickAgentSelection(automation); const repo = automation.repo ? findRepo(config, automation.repo) : config.repos.length === 1 @@ -421,7 +425,7 @@ export async function resolveWorkspaceAutomationContext( if (!repo) return { cwd: workspaceDir, - env: buildWorkspaceContextEnv(workspaceDir), + env: buildWorkspaceContextEnv(workspaceDir, { agentSelection }), taskFileDir, release() {}, }; @@ -435,6 +439,7 @@ export async function resolveWorkspaceAutomationContext( pipelineDefaultFile: config.defaults.pipelineFile ? resolveWorkspacePipelinePath(workspaceDir, config.defaults.pipelineFile) : undefined, + agentSelection, }), repo: repo.name, taskFileDir, @@ -868,7 +873,9 @@ export async function runWorkspaceWorker(options: RunWorkspaceWorkerOptions): Pr withSupervisorSlot( async () => ({ cwd: workspaceDir, - env: buildWorkspaceContextEnv(workspaceDir), + env: buildWorkspaceContextEnv(workspaceDir, { + agentSelection: pickAgentSelection(estimation), + }), release() {}, }), supervisor, diff --git a/packages/code/tests/agent-selection.test.ts b/packages/code/tests/agent-selection.test.ts new file mode 100644 index 00000000..1ca76fa3 --- /dev/null +++ b/packages/code/tests/agent-selection.test.ts @@ -0,0 +1,305 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +import { + applyAgentSelectionEnv, + parseAgentSelection, + pickAgentSelection, +} from "../src/lib/agent/selection"; +import { parseWorkspaceConfig } from "../src/lib/workspace/config"; +import { buildRepoEnv, buildWorkspaceContextEnv } from "../src/lib/workspace/env"; +import { automationRunEnv } from "../src/lib/automation/acquirer"; +import { estimationRunEnv } from "../src/lib/automation/estimation-acquirer"; +import type { AutomationConfig } from "../src/lib/automation/config"; +import type { EstimationConfig } from "../src/lib/automation/estimation-config"; + +const temporaryDirs: string[] = []; +afterEach(() => { + for (const dir of temporaryDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function newWorkspaceDir(env = ""): string { + const dir = mkdtempSync(join(tmpdir(), "devintern-agent-selection-")); + temporaryDirs.push(dir); + if (env) writeFileSync(join(dir, ".env"), env); + return dir; +} + +const BASE_CONFIG = ` +[defaults] +tracker = "jira" +`; + +describe("parseAgentSelection", () => { + test("accepts harness, model, and effort and canonicalizes aliases", () => { + const errors: string[] = []; + const selection = parseAgentSelection( + { harness: "agy", model: "gemini-2.5-pro", effort: "high" }, + { label: "[[automations]][0]", id: "daily" }, + errors, + ); + expect(errors).toEqual([]); + expect(selection).toEqual({ + harness: "antigravity", + model: "gemini-2.5-pro", + effort: "high", + }); + }); + + test("omits unset fields entirely", () => { + const errors: string[] = []; + expect(parseAgentSelection({}, { label: "[x]" }, errors)).toEqual({}); + expect(errors).toEqual([]); + }); + + test("rejects an unknown harness with the field and entry id", () => { + const errors: string[] = []; + parseAgentSelection( + { harness: "not-real" }, + { label: "[[automations]][0]", id: "daily" }, + errors, + ); + expect(errors.join("\n")).toContain('[[automations]][0].harness "not-real"'); + expect(errors.join("\n")).toContain('entry "daily"'); + }); + + test("rejects a harness chain — an entry names one agent", () => { + const errors: string[] = []; + parseAgentSelection( + { harness: "codex,grok" }, + { label: "[[estimations]][1]", id: "groom" }, + errors, + ); + expect(errors.join("\n")).toContain("must name a single agent harness"); + expect(errors.join("\n")).toContain('entry "groom"'); + }); + + test("rejects an invalid effort with the field and entry id", () => { + const errors: string[] = []; + parseAgentSelection( + { effort: "ultra" }, + { label: "[[automations]][2]", id: "release" }, + errors, + ); + expect(errors.join("\n")).toContain('[[automations]][2].effort "ultra"'); + expect(errors.join("\n")).toContain('entry "release"'); + }); + + test("accepts effort on a harness that ignores it (validated at spawn, not parse)", () => { + const errors: string[] = []; + const selection = parseAgentSelection( + { harness: "cursor", effort: "low" }, + { label: "[x]" }, + errors, + ); + expect(errors).toEqual([]); + expect(selection).toEqual({ harness: "cursor", effort: "low" }); + }); +}); + +describe("applyAgentSelectionEnv", () => { + test("overlays only the selected fields over the layered env", () => { + const env = applyAgentSelectionEnv( + { AGENT_HARNESS: "claude-code", AGENT_MODEL: "workspace-model", KEEP: "1" }, + { harness: "codex", effort: "medium" }, + ); + expect(env).toEqual({ + AGENT_HARNESS: "codex", + AGENT_MODEL: "workspace-model", + AGENT_EFFORT: "medium", + KEEP: "1", + }); + }); + + test("is a no-op for an empty selection", () => { + const env = { AGENT_MODEL: "workspace-model" }; + applyAgentSelectionEnv(env, {}); + expect(env).toEqual({ AGENT_MODEL: "workspace-model" }); + }); +}); + +describe("pickAgentSelection", () => { + test("copies only set fields", () => { + expect(pickAgentSelection({ model: "m" })).toEqual({ model: "m" }); + expect(pickAgentSelection({})).toEqual({}); + }); +}); + +describe("workspace config entry selection", () => { + test("parses automation harness/model/effort", () => { + const config = parseWorkspaceConfig(`${BASE_CONFIG} +[[automations]] +id = "daily" +enabled = true +interval = "15m" +prompt = "triage" +harness = "codex" +model = "gpt-5-codex" +effort = "low" +`); + expect(config.automations[0]).toMatchObject({ + id: "daily", + harness: "codex", + model: "gpt-5-codex", + effort: "low", + }); + }); + + test("parses estimation harness/model/effort (no repo required)", () => { + const config = parseWorkspaceConfig(`${BASE_CONFIG} +[[estimations]] +id = "groom" +enabled = true +cron = "0 9 * * 1-5" +query = "status = 'To Do'" +harness = "claude-code" +effort = "high" +`); + expect(config.estimations[0]).toMatchObject({ + id: "groom", + harness: "claude-code", + effort: "high", + }); + expect(config.estimations[0]?.model).toBeUndefined(); + }); + + test("entries with no selection fields load unchanged", () => { + const config = parseWorkspaceConfig(`${BASE_CONFIG} +[[automations]] +id = "daily" +enabled = true +interval = "1h" +prompt = "work" + +[[estimations]] +id = "groom" +enabled = true +interval = "6h" +query = "q" +`); + expect(config.automations[0]?.harness).toBeUndefined(); + expect(config.automations[0]?.model).toBeUndefined(); + expect(config.automations[0]?.effort).toBeUndefined(); + expect(config.estimations[0]?.harness).toBeUndefined(); + }); + + test("unknown automation harness fails validation naming id and field", () => { + expect(() => + parseWorkspaceConfig(`${BASE_CONFIG} +[[automations]] +id = "release" +enabled = true +interval = "1h" +prompt = "work" +harness = "bogus" +`), + ).toThrow(/release[\s\S]*harness|harness[\s\S]*release/); + }); + + test("invalid estimation effort fails validation naming id and field", () => { + expect(() => + parseWorkspaceConfig(`${BASE_CONFIG} +[[estimations]] +id = "groom" +enabled = true +interval = "1h" +query = "q" +effort = "extreme" +`), + ).toThrow(/groom[\s\S]*effort|effort[\s\S]*groom/); + }); +}); + +describe("entry selection precedence in run env", () => { + test("automationRunEnv overrides workspace env values", () => { + const automation: AutomationConfig = { + id: "daily", + enabled: true, + prompt: "p", + interval: "1h", + intervalMs: 3_600_000, + harness: "codex", + model: "gpt-5-codex", + effort: "high", + }; + const env = automationRunEnv(automation, { + AGENT_HARNESS: "claude-code", + AGENT_MODEL: "workspace-model", + AGENT_EFFORT: "low", + }); + expect(env.AGENT_HARNESS).toBe("codex"); + expect(env.AGENT_MODEL).toBe("gpt-5-codex"); + expect(env.AGENT_EFFORT).toBe("high"); + }); + + test("automationRunEnv keeps inherited model when the entry omits it", () => { + const automation: AutomationConfig = { + id: "daily", + enabled: true, + prompt: "p", + interval: "1h", + intervalMs: 3_600_000, + harness: "codex", + }; + const env = automationRunEnv(automation, { AGENT_MODEL: "workspace-model" }); + expect(env.AGENT_HARNESS).toBe("codex"); + expect(env.AGENT_MODEL).toBe("workspace-model"); + }); + + test("estimationRunEnv overrides workspace env values", () => { + const estimation: EstimationConfig = { + id: "groom", + enabled: true, + query: "q", + interval: "6h", + intervalMs: 21_600_000, + harness: "claude-code", + effort: "medium", + }; + const env = estimationRunEnv(estimation, { AGENT_HARNESS: "codex" }); + expect(env.AGENT_HARNESS).toBe("claude-code"); + expect(env.AGENT_EFFORT).toBe("medium"); + }); +}); + +describe("env builder selection precedence", () => { + test("workspace-context selection overrides the workspace process env", () => { + const workspaceDir = newWorkspaceDir(); + const previousHarness = process.env.AGENT_HARNESS; + const previousModel = process.env.AGENT_MODEL; + process.env.AGENT_HARNESS = "claude-code"; + process.env.AGENT_MODEL = "workspace-model"; + try { + const env = buildWorkspaceContextEnv(workspaceDir, { + agentSelection: { harness: "codex", effort: "high" }, + }); + expect(env.AGENT_HARNESS).toBe("codex"); + expect(env.AGENT_MODEL).toBe("workspace-model"); + expect(env.AGENT_EFFORT).toBe("high"); + } finally { + if (previousHarness === undefined) delete process.env.AGENT_HARNESS; + else process.env.AGENT_HARNESS = previousHarness; + if (previousModel === undefined) delete process.env.AGENT_MODEL; + else process.env.AGENT_MODEL = previousModel; + } + }); + + test("repo selection overrides workspace .env and inline repo env", () => { + const workspaceDir = newWorkspaceDir( + "AGENT_HARNESS=claude-code\nAGENT_MODEL=workspace-model\n", + ); + const env = buildRepoEnv( + { + name: "api", + remote: "https://github.com/acme/api.git", + env: { AGENT_HARNESS: "grok", AGENT_MODEL: "repo-model" }, + }, + workspaceDir, + { agentSelection: { harness: "codex", model: "entry-model" } }, + ); + expect(env.AGENT_HARNESS).toBe("codex"); + expect(env.AGENT_MODEL).toBe("entry-model"); + }); +}); diff --git a/packages/code/tests/workspace-worker.test.ts b/packages/code/tests/workspace-worker.test.ts index 53a4b279..a565422e 100644 --- a/packages/code/tests/workspace-worker.test.ts +++ b/packages/code/tests/workspace-worker.test.ts @@ -1212,6 +1212,35 @@ describe("resolveWorkspaceAutomationContext", () => { expect(context?.cwd).toContain(join("worktrees", "backend", "base")); rmSync(workspaceDir, { recursive: true, force: true }); }); + + test("applies the entry's harness/model/effort to the occurrence env only", async () => { + const workspaceDir = join( + tmpdir(), + `ws-automation-selection-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const repoManager = new FakeRepoManager(workspaceDir); + const context = await resolveWorkspaceAutomationContext( + { + id: "scheduled", + enabled: true, + prompt: "work", + interval: "1h", + intervalMs: 3_600_000, + repo: "backend", + harness: "codex", + model: "gpt-5-codex", + effort: "high", + }, + CONFIG, + workspaceDir, + repoManager, + ); + + expect(context?.env.AGENT_HARNESS).toBe("codex"); + expect(context?.env.AGENT_MODEL).toBe("gpt-5-codex"); + expect(context?.env.AGENT_EFFORT).toBe("high"); + rmSync(workspaceDir, { recursive: true, force: true }); + }); }); describe("worktree sweeping", () => { From 8a437820f79ceb985bddcc65066ee764c45f6d8d Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 16 Oct 2026 14:24:08 +0700 Subject: [PATCH 2/6] fix: address PR review feedback 1. [MEDIUM] bug in automation/acquirer.ts: a pinned harness skipped the whole failover overlay, which was also the only thing setting DEVINTERN_WORKER_CHILD=1. The pinned CLI child therefore stopped being a worker child, so a usage-limit abort wrote no hint and exited 0 (falling through to one-shot success handling) instead of exiting 75. Keep the worker-child marker on pinned children and drop any inherited hint path; the existing !options.harnessPin guard already prevents re-spawning on another harness. 2. [MEDIUM] testing: add spawnAutomationProcess coverage for pinned runs (stub child exits 75 with a started failover) asserting the pinned AGENT_HARNESS, the worker-child marker, no leaked hint path, and no failover, plus the estimation variant and an unpinned failover control. Iteration 2 fixes: 3. [MEDIUM] bug in agent/selection.ts: an entry that set model/effort without a harness kept workspace failover, and the failover overlay only rewrote AGENT_HARNESS, so a harness-specific model leaked onto whichever fallback harness ran. applyAgentSelectionEnv now writes model/effort only when the entry also pins a harness, so a model-only entry leaves the inherited value untouched and can never reach a different harness. 4. [MEDIUM] bug in automation/acquirer.ts: a pinned harness left the inherited AGENT_CLI_PATH in place, so the child resolved the workspace primary's binary for the pinned harness. applyAgentSelectionEnv now drops AGENT_CLI_PATH when the pin differs from the env's primary harness (keeping it for a primary pin), and pinAutomationEnv applies the same guard for direct callers. Shared via a new primaryHarnessName helper. 5. [testing] add unit and spawnAutomationProcess integration coverage for model-only entries (no leak to primary or fallback) and for AGENT_CLI_PATH being dropped on a non-primary pin but kept on a primary pin; update the worker/workspaces docs to state the model/effort-require-harness contract. Iteration 3 fixes: 6. [HIGH] bug in agent/selection.ts and automation/acquirer.ts: dropping only AGENT_CLI_PATH on a non-primary pin left the resolver's CLAUDE_CLI_PATH backward-compat fallback in place. resolveEnvHarnessPath (resolver.ts) uses CLAUDE_CLI_PATH for ANY harness, so a codex pin still resolved to the claude binary. applyAgentSelectionEnv and pinAutomationEnv now share a dropForeignHarnessCliPaths helper that also drops CLAUDE_CLI_PATH for a non-claude pin (kept when pinning claude-code). 7. [MEDIUM] bug in agent/selection.ts: an entry that pinned a different harness but omitted model/effort inherited the env primary's harness-specific AGENT_MODEL/AGENT_EFFORT. When the pin changes the harness those inherited values are now dropped unless the entry sets its own, mirroring the failover-case reasoning (same pin keeps them). 8. [testing] regression coverage: unit tests that a non-claude pin resolves its own binary rather than the inherited CLAUDE_CLI_PATH and that inherited model/effort are dropped/kept by whether the pin changes harness, plus spawnAutomationProcess integration tests for CLAUDE_CLI_PATH dropped on a non-claude pin, kept on a claude pin, and the inherited model dropped on a harness-changing pin. Docs updated. Iteration 4 fixes: 9. [MEDIUM] bug in automation/manual.ts: the standalone dashboard's 'Run now' composed context.env from process.env without pinning DEVINTERN_WORKER_SUBPROCESS, so the spawned CLI child ran loadEnvironment() and dotenv re-populated the AGENT_CLI_PATH/AGENT_MODEL that the entry's harness pin had deliberately dropped (the cross-harness leak this PR fixes on the scheduled/estimation paths). Pin the worker-subprocess marker in the manual context so the child skips the checkout's .env; end-to-end test composes the real automationRunEnv child env, runs the real loadEnvironment in a subprocess, and asserts the dropped keys stay dropped (and would be restored without the marker). 10. [MEDIUM] bug in agent/selection.ts: parseAgentSelection validated a pinned harness only against the registry, so an entry pinned to a registered-but-uninstalled harness (or a wrong _CLI_PATH) failed every occurrence silently with no failover. Add warnUninstalledHarnessPins and call it at worker startup (applyWorkspaceEnv) and on config reload (validateReloadedWorkspaceConfig), mirroring the AGENT_HARNESS chain's installability warnings; unit tests cover the missing-CLI warning and the AGENT_CLI_PATH rules for primary vs non-primary pins. Docs updated. Iteration 5 fixes: 11. [MEDIUM] bug in automation/manual.ts: iteration 4 pinned the broad DEVINTERN_WORKER_SUBPROCESS marker on the standalone dashboard manual spawn purely to skip dotenv, but isWorkerSubprocess() also switches resolveRuntimeStateDir() from the project .devintern-code to the workspace state dir (~/.devintern/state/code). An automated (systemd/CI) dashboard-spawned child then read the wrong .auth-session.json in loadSupabaseConfig() (index.ts) and could fail the automation-license gate. Add a narrow DEVINTERN_SKIP_PROJECT_ENV marker and a shouldSkipProjectEnv() helper that loadEnvironment() consults; manual.ts sets the narrow marker instead, so the child skips the checkout .env while its runtime state and auth session still resolve to the project config dir. Scheduled runs keep the worker marker (and workspace state). 12. [testing] automation-manual.test.ts: assert the narrow marker is set and add a regression test that runs the real resolveRuntimeStateDir / loadSupabaseConfig in a subprocess and asserts the manual child stays project-scoped, with the worker marker control showing the workspace-dir relocation the fix avoids. Make the dotenv-leak control hermetic against an inherited worker marker. --- docs/code/worker.md | 8 +- docs/code/workspaces.md | 2 +- packages/code/src/lib/agent/selection.ts | 148 +++++++++- packages/code/src/lib/automation/acquirer.ts | 34 ++- packages/code/src/lib/automation/manual.ts | 11 + packages/code/src/lib/cli/bootstrap.ts | 4 +- packages/code/src/lib/config/config-dir.ts | 13 + .../src/lib/workspace/workspace-worker.ts | 6 +- packages/code/tests/agent-selection.test.ts | 196 ++++++++++++- .../code/tests/automation-acquirer.test.ts | 275 +++++++++++++++++- packages/code/tests/automation-manual.test.ts | 146 ++++++++++ 11 files changed, 814 insertions(+), 29 deletions(-) diff --git a/docs/code/worker.md b/docs/code/worker.md index 07620cff..22e3e7fa 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -155,11 +155,11 @@ open_pr = true prompt = "Cut the next release and update the changelog." ``` -- `harness` names **one** registered agent (aliases such as `agy` resolve to their canonical name). An unknown name fails validation, naming the entry id and the `harness` field. -- `model` is a harness-specific string (see that agent's CLI docs) and is never validated cross-harness. Omit it to use the chosen harness's default model (or the inherited `AGENT_MODEL`, if one is set). -- `effort` accepts only `low`, `medium`, or `high`; anything else fails validation. It is applied only for harnesses that support reasoning effort — on any other harness it is ignored with a warning, and the run proceeds. +- `harness` names **one** registered agent (aliases such as `agy` resolve to their canonical name). An unknown name fails validation, naming the entry id and the `harness` field. Because a pin opts out of failover, the worker warns at startup (and on config reload) when an entry's `harness` is registered but its CLI is not installed or its `_CLI_PATH` points at a missing executable, so the problem is visible before the first occurrence. +- `model` is a harness-specific string (see that agent's CLI docs) and is never validated cross-harness. Because it only makes sense for one agent, it is applied only when the entry also sets `harness`; without `harness` the inherited `AGENT_MODEL` (if any) is kept. When `harness` names a different agent than the workspace primary, an inherited `AGENT_MODEL` chosen for that primary is dropped unless the entry sets `model`. +- `effort` accepts only `low`, `medium`, or `high`; anything else fails validation. Like `model`, it is applied only when the entry also sets `harness`, and an inherited `AGENT_EFFORT` is dropped when the pin changes the harness unless the entry sets `effort`. It is applied only for harnesses that support reasoning effort — on any other harness it is ignored with a warning, and the run proceeds. - Precedence for the entry's runs is **entry field > team/repo env > workspace env > harness default**. Fields you omit fall through to the existing workspace/repo/team values, and entries that set none of the three fields behave exactly as before. -- An entry that pins `harness` uses exactly that agent and opts out of the workspace `AGENT_HARNESS` failover chain for its runs; entries without `harness` keep normal failover. The selection applies to subsequent occurrences after a live reload; a run already in flight keeps the agent it started with. +- An entry that pins `harness` uses exactly that agent and opts out of the workspace `AGENT_HARNESS` failover chain for its runs; entries without `harness` keep normal failover and never receive a harness-specific `model`/`effort` the entry did not pair with a `harness`. A pinned harness also drops the inherited global `AGENT_CLI_PATH` (which names the workspace primary's binary) and, for a non-`claude-code` pin, the resolver's `CLAUDE_CLI_PATH` fallback, so it resolves its own `_CLI_PATH` or default. The selection applies to subsequent occurrences after a live reload; a run already in flight keeps the agent it started with. ### Schedule semantics diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index d67cb99f..2f2e981c 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -108,7 +108,7 @@ prompt = "Review the frontend and clean up one source of recurring noise." - `[worker].auto_update` keeps a globally installed CLI current while the worker is idle (checked at most daily, installed without interrupting work, restart on success). See [Running the Worker Unattended: Keeping the worker up to date](./worker.md#keeping-the-worker-up-to-date). - `[[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. `open_pr` (boolean, default `false`) decides whether occurrences open a pull request: opt-in per automation, and workspace-level `worker_task_args` never overrides it. See [Worker Daemon → Recurring automations](./worker.md#recurring-automations) for prompt-writing guidance and schedule semantics. - `[[estimations]]` schedules unattended story-point sweeps (tracker query + cron/interval, no `prompt`, no `repo`). The workspace tracker must support estimation. See [Worker Daemon → Scheduled story-point estimation](./worker.md#scheduled-story-point-estimation). -- `[[automations]]` and `[[estimations]]` entries may each set `harness` (one agent name), `model`, and `effort` (`"low"`, `"medium"`, or `"high"`) to run that schedule on a different agent than the workspace default. Precedence for an entry's runs is **entry field > team/repo env > workspace env > harness default**; omitted fields keep the existing workspace/repo/team values, and `model` is harness-specific (never validated cross-harness). An unknown `harness` or an `effort` outside the three levels fails validation naming the entry id and field. `effort` on a harness that does not support reasoning effort is ignored with a warning instead of failing the run. These overrides apply on live reload to subsequent occurrences; in-flight runs finish under the selection they started with. See [Worker Daemon → Choosing the agent per schedule](./worker.md#choosing-the-agent-per-schedule). +- `[[automations]]` and `[[estimations]]` entries may each set `harness` (one agent name), `model`, and `effort` (`"low"`, `"medium"`, or `"high"`) to run that schedule on a different agent than the workspace default. Precedence for an entry's runs is **entry field > team/repo env > workspace env > harness default**; omitted fields keep the existing workspace/repo/team values, and `model`/`effort` are harness-specific so they apply only when the entry also sets `harness` (an entry without `harness` keeps normal failover and never sends a harness-specific model to a fallback). When the pin names a different agent than the workspace primary, an inherited `model`/`effort` and the primary's `AGENT_CLI_PATH` (plus the `CLAUDE_CLI_PATH` fallback for a non-`claude-code` pin) are dropped so the pinned agent uses its own binary and defaults. An unknown `harness` or an `effort` outside the three levels fails validation naming the entry id and field. A pinned `harness` whose CLI is not installed is warned about at worker startup and on reload (the pin opts out of failover), naming the entry. `effort` on a harness that does not support reasoning effort is ignored with a warning instead of failing the run. These overrides apply on live reload to subsequent occurrences; in-flight runs finish under the selection they started with. See [Worker Daemon → Choosing the agent per schedule](./worker.md#choosing-the-agent-per-schedule). - `[[error_monitors]]` maps each Sentry project to one repo and an optional team, with per-source credential layers for multi-project setups. See [Sentry Auto-fixes](./sentry-integration.md). ### Bounded host concurrency diff --git a/packages/code/src/lib/agent/selection.ts b/packages/code/src/lib/agent/selection.ts index 5cd30fd8..7f5bbe0b 100644 --- a/packages/code/src/lib/agent/selection.ts +++ b/packages/code/src/lib/agent/selection.ts @@ -8,14 +8,24 @@ * with an actionable message instead of silently falling back. * * Precedence for an entry's runs: entry field > team/repo env > workspace - * env > harness default. `model` is harness-specific and is never validated - * cross-harness; omitting it leaves the model to the harness default (or the - * inherited env value when one is set). Disallowed effort on a harness that - * does not support it is ignored at run time by the harness runners, which - * warn instead of failing. + * env > harness default. `model`/`effort` are harness-specific, so they apply + * only when the entry also names a `harness`; a model- or effort-only entry + * leaves the inherited env value untouched rather than sending a string chosen + * for one harness to whichever harness a failover chain later selects. When the + * entry pins a harness other than the env's primary, an inherited + * `AGENT_MODEL`/`AGENT_EFFORT` was chosen for that primary and is dropped too, + * unless the entry sets its own. Disallowed effort on a harness that does not + * support it is ignored at run time by the harness runners, which warn instead + * of failing. */ -import { getHarness, listHarnesses, parseAgentEffort } from "@devintern/agent-harness"; +import { + getHarness, + isHarnessInstalled, + listHarnesses, + parseAgentEffort, + DEFAULT_HARNESS_NAME, +} from "@devintern/agent-harness"; import type { AgentEffort } from "@devintern/agent-harness"; /** Optional per-entry agent overrides parsed from an automation/estimation. */ @@ -111,11 +121,118 @@ export function parseAgentSelection( return selection; } +/** + * Canonical name of the first (priority) harness named by an `AGENT_HARNESS` + * value, or the default harness when the value is unset/empty. + * + * Mirrors the chain's own notion of "primary" (see `resolveHarnessChain`) so a + * caller can tell whether a pinned harness is the one a global path override + * was written for. + */ +export function primaryHarnessName(chain: string | undefined): string { + const first = chain?.split(",")[0]?.trim(); + if (!first) return DEFAULT_HARNESS_NAME; + return getHarness(first)?.name ?? first; +} + +/** A scheduled entry whose pinned harness can be checked for installability. */ +export interface HarnessPinnedEntry { + /** Entry id, used in the warning. */ + id: string; + /** Canonical harness name pinned by the entry, when set. */ + harness?: string; +} + +/** + * Warn about scheduled entries pinned to a harness whose CLI is not installed. + * + * A pinned `harness` opts the entry out of the workspace `AGENT_HARNESS` + * failover chain (see `spawnAutomationProcess`), so a pin to a + * registered-but-uninstalled harness — or one whose `_CLI_PATH` points + * at a missing executable — makes every occurrence fail with no failover. + * `parseAgentSelection` validates only against the registry, so without this + * check the misconfiguration stays silent until the first run; call this at + * worker startup and on config reload to surface it early, mirroring the + * installability warnings `resolveHarnessChain` already emits for the chain. + * + * The probe mirrors the pinned child's own resolution: `AGENT_CLI_PATH` is + * honoured only when the pin is the env's primary harness (the path the child + * keeps); a pin to any other harness resolves its own `_CLI_PATH` or + * default. + * + * @param entries - Automations and estimations with their parsed selection. + * @param log - Warning sink (defaults to `console.warn`). + */ +export function warnUninstalledHarnessPins( + entries: readonly HarnessPinnedEntry[], + log: (message: string) => void = (message) => console.warn(message), +): void { + const envPrimary = primaryHarnessName(process.env.AGENT_HARNESS); + const installed = new Map(); + for (const entry of entries) { + if (!entry.harness) continue; + const harness = getHarness(entry.harness); + if (!harness) continue; // parseAgentSelection already rejects unknown names + let ok = installed.get(harness.name); + if (ok === undefined) { + ok = isHarnessInstalled(harness, { includeGlobalCliPath: harness.name === envPrimary }); + installed.set(harness.name, ok); + } + if (ok) continue; + const envKey = harness.name.toUpperCase().replace(/-/g, "_"); + log( + `⚠️ Schedule "${entry.id}" is pinned to harness "${harness.name}", which is not installed. ` + + `It opts out of failover, so it will fail until the CLI is installed or ${envKey}_CLI_PATH is set.`, + ); + } +} + +/** + * Drop inherited CLI-path overrides that point at another harness's binary. + * + * `AGENT_CLI_PATH` names the env's primary harness binary. `CLAUDE_CLI_PATH` is + * the resolver's backward-compatibility fallback for *any* harness + * (`resolveEnvHarnessPath`: `env.CLAUDE_CLI_PATH // backward compatibility`), so + * leaving it set would redirect a non-claude pin to the claude executable. Both + * are dropped when they do not belong to `harnessName`; `CLAUDE_CLI_PATH` is + * kept only when pinning claude-code, where it is the harness-specific path. + * + * @param env - Composed environment to mutate. + * @param harnessName - Canonical harness the run is being pinned to. + * @param envPrimaryHarness - Canonical name of the env's primary harness. + */ +export function dropForeignHarnessCliPaths( + env: Record, + harnessName: string, + envPrimaryHarness: string, +): void { + if (envPrimaryHarness !== harnessName) { + delete env.AGENT_CLI_PATH; + } + if (harnessName !== DEFAULT_HARNESS_NAME) { + delete env.CLAUDE_CLI_PATH; + } +} + /** * Overlay an entry's agent selection onto a composed environment. * * Only fields the entry set are written, so omitted fields keep the value * from the environment layers (team/repo/workspace) or the harness default. + * + * `model` and `effort` are harness-specific, so they are written only when the + * entry also pins a `harness`. Without a `harness` the entry keeps the + * workspace failover chain, and a model chosen for one harness must not be + * handed to whichever fallback harness the chain selects. + * + * When the entry pins a harness other than the env's primary, the global + * `AGENT_CLI_PATH` and the resolver's `CLAUDE_CLI_PATH` fallback (which name + * the primary/claude binary) are dropped so the child resolves the pinned + * harness's own `_CLI_PATH` or default instead of spawning another + * harness's executable. For the same reason an inherited `AGENT_MODEL` / + * `AGENT_EFFORT` (chosen for the primary) is dropped unless the entry sets its + * own value. + * * Mutates and returns `env` for chaining. */ export function applyAgentSelectionEnv( @@ -123,8 +240,21 @@ export function applyAgentSelectionEnv( selection: AgentSelection | undefined, ): Record { if (!selection) return env; - if (selection.harness) env.AGENT_HARNESS = selection.harness; - if (selection.model) env.AGENT_MODEL = selection.model; - if (selection.effort) env.AGENT_EFFORT = selection.effort; + if (selection.harness) { + const envPrimary = primaryHarnessName(env.AGENT_HARNESS); + const changedHarness = envPrimary !== selection.harness; + dropForeignHarnessCliPaths(env, selection.harness, envPrimary); + env.AGENT_HARNESS = selection.harness; + if (selection.model) { + env.AGENT_MODEL = selection.model; + } else if (changedHarness) { + delete env.AGENT_MODEL; + } + if (selection.effort) { + env.AGENT_EFFORT = selection.effort; + } else if (changedHarness) { + delete env.AGENT_EFFORT; + } + } return env; } diff --git a/packages/code/src/lib/automation/acquirer.ts b/packages/code/src/lib/automation/acquirer.ts index 54b6ea33..2a04d906 100644 --- a/packages/code/src/lib/automation/acquirer.ts +++ b/packages/code/src/lib/automation/acquirer.ts @@ -10,7 +10,12 @@ import type { AutomationConfig } from "./config"; import { automationTaskArgs, nextScheduleOccurrence } from "./config"; import { AutomationStateStore } from "./state"; import { workerTaskArgs } from "../acquirers/task-polling"; -import { applyAgentSelectionEnv, pickAgentSelection } from "../agent/selection"; +import { + applyAgentSelectionEnv, + dropForeignHarnessCliPaths, + pickAgentSelection, + primaryHarnessName, +} from "../agent/selection"; import { resolveProjectConfigDir } from "../config/config-dir"; import { RUN_ORIGIN_ENV } from "../observability/analytics"; import { getWorkerFailover } from "../worker/failover"; @@ -18,6 +23,7 @@ import { readUsageLimitHint, USAGE_LIMIT_EXIT_CODE, USAGE_LIMIT_FILE_ENV, + WORKER_CHILD_ENV, } from "../worker/usage-limit-protocol"; /** Environment markers the task pipeline reads to attribute scheduled runs. */ @@ -804,10 +810,28 @@ function pinAutomationEnv( hintDir?: string; } { if (harnessPin) { - // Pin the entry's own harness and skip the failover overlay entirely so - // neither the workspace active harness nor the usage-limit protocol can - // re-route an entry that named its agent. - return { env: { ...base, AGENT_HARNESS: harnessPin } }; + // Pin the entry's own harness and skip the workspace failover overlay so + // neither the active harness nor a usage-limit failover can re-route an + // entry that named its agent. Keep the worker-child marker — and drop any + // hint path inherited from an ancestor worker — so a usage-limit abort + // still signals the parent by exiting 75 instead of falling through to + // one-shot success handling. `spawnAutomationProcess`'s + // `!options.harnessPin` guard ignores that exit rather than re-spawning on + // another harness. + const env: Record = { + ...base, + AGENT_HARNESS: harnessPin, + [WORKER_CHILD_ENV]: "1", + }; + delete env[USAGE_LIMIT_FILE_ENV]; + // The global AGENT_CLI_PATH names the env's primary harness binary, and the + // resolver's CLAUDE_CLI_PATH fallback applies to ANY harness; a pin to a + // non-claude harness must resolve its own `_CLI_PATH` or default + // rather than spawn another harness's executable. `applyAgentSelectionEnv` + // already drops them when the pin flows through the entry selection; this + // guards direct callers that pin without pre-composing that selection. + dropForeignHarnessCliPaths(env, harnessPin, primaryHarnessName(base.AGENT_HARNESS)); + return { env }; } const failover = getWorkerFailover(); if (!failover) { diff --git a/packages/code/src/lib/automation/manual.ts b/packages/code/src/lib/automation/manual.ts index eef0e48c..dc10b26e 100644 --- a/packages/code/src/lib/automation/manual.ts +++ b/packages/code/src/lib/automation/manual.ts @@ -27,6 +27,7 @@ import type { } from "./acquirer"; import { RUN_ORIGIN_ENV } from "../observability/analytics"; import { workerTaskArgs } from "../acquirers/task-polling"; +import { SKIP_PROJECT_ENV_ENV } from "../config/config-dir"; /** Test-only spawn override so manual-run tests never launch a real CLI. */ export interface StandaloneAutomationOptions { @@ -80,6 +81,16 @@ export function loadStandaloneAutomationActions( ...process.env, [RUN_ORIGIN_ENV]: MANUAL_ORIGIN_ENV_VALUE, [AUTOMATION_ID_ENV]: automation.id, + // The spawn applies the entry's harness/model selection and drops the + // workspace primary's AGENT_CLI_PATH/AGENT_MODEL, exactly like a + // scheduled run. Mark the child so its `loadEnvironment()` skips + // `.devintern-code/.env` — otherwise dotenv re-populates those dropped + // keys and the child spawns the primary harness's binary under the + // pinned harness. This is the narrow skip marker, not the worker + // marker: a single-repo manual run must keep resolving its runtime + // state and license/auth session to the project config dir rather than + // the workspace state dir the worker marker would select. + [SKIP_PROJECT_ENV_ENV]: "1", }, release() {}, }; diff --git a/packages/code/src/lib/cli/bootstrap.ts b/packages/code/src/lib/cli/bootstrap.ts index abab24a9..8d660848 100644 --- a/packages/code/src/lib/cli/bootstrap.ts +++ b/packages/code/src/lib/cli/bootstrap.ts @@ -7,7 +7,7 @@ import { LicenseCheckError, requireLicense } from "@devintern/license-check"; import type { LicenseCheckResult } from "@devintern/license-check"; import { findEnvFile, maybeOfferCliUpdate } from "@devintern/utils"; import { isAutomatedEnvironment } from "../config/env-detector"; -import { isWorkerSubprocess, resolveRuntimeStateDir } from "../config/config-dir"; +import { resolveRuntimeStateDir, shouldSkipProjectEnv } from "../config/config-dir"; import { isInteractive } from "../init/wizard"; import { flushAnalytics } from "../observability/analytics"; import { initSentryOnce } from "../observability/sentry-init"; @@ -69,7 +69,7 @@ function loadEnvironmentInner(envFile: string | undefined, skipProjectEnv: boole // A workspace task already receives its composed credentials from the // worker. Do not fill missing keys from the checkout's local config. - if (isWorkerSubprocess() || skipProjectEnv) return null; + if (shouldSkipProjectEnv() || skipProjectEnv) return null; // Otherwise, search upward from cwd for the nearest .env file const envPath = findEnvFile({ configDirName: ".devintern-code" }); diff --git a/packages/code/src/lib/config/config-dir.ts b/packages/code/src/lib/config/config-dir.ts index 10888cb1..694e4331 100644 --- a/packages/code/src/lib/config/config-dir.ts +++ b/packages/code/src/lib/config/config-dir.ts @@ -3,12 +3,25 @@ import { resolveWorkspaceDir, workspaceCodeStateDir } from "../workspace/paths"; /** Marks a task subprocess launched by the workspace supervisor. */ export const WORKER_SUBPROCESS_ENV = "DEVINTERN_WORKER_SUBPROCESS"; +/** + * Narrow marker for a subprocess that must skip the checkout's + * `.devintern-code/.env` without being treated as a worker subprocess (so its + * runtime state and license/auth session keep resolving to the project config + * dir). Used by the standalone dashboard's manual automation spawn, whose + * composed entry selection must not be re-populated by dotenv. + */ +export const SKIP_PROJECT_ENV_ENV = "DEVINTERN_SKIP_PROJECT_ENV"; let manualWorkspaceStateDir: string | null = null; export function isWorkerSubprocess(): boolean { return process.env[WORKER_SUBPROCESS_ENV] === "1"; } +/** Whether this process must not load the checkout's local `.env`. */ +export function shouldSkipProjectEnv(): boolean { + return isWorkerSubprocess() || process.env[SKIP_PROJECT_ENV_ENV] === "1"; +} + /** Resolve the normal project config directory, independent of worker state. */ export function resolveProjectConfigDir(startDir: string = process.cwd()): string { return resolveConfigDir({ configDirName: ".devintern-code", startDir }); diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index bc2ab4be..c61febb2 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -53,7 +53,7 @@ import type { AutomationConfig } from "../automation/config"; import { automationTaskArgs } from "../automation/config"; import { AutomationStateStore } from "../automation/state"; import { EstimationAcquirer } from "../automation/estimation-acquirer"; -import { pickAgentSelection } from "../agent/selection"; +import { pickAgentSelection, warnUninstalledHarnessPins } from "../agent/selection"; import { createTaskSupervisor, JobNotStartedError } from "../worker/supervisor"; import type { TaskSupervisor } from "../worker/supervisor"; import type { AutomationRunContext } from "../automation/acquirer"; @@ -659,6 +659,7 @@ export function validateReloadedWorkspaceConfig( ): void { const fleet = resolveFleetAutomations(next); if (fleet.problems.length > 0) throw new Error(fleet.problems.join("\n- ")); + warnUninstalledHarnessPins([...next.automations, ...next.estimations]); if (next.defaults.tracker !== current.defaults.tracker) { throw new Error("[defaults].tracker is startup-only; restart the worker to change it."); } @@ -1211,6 +1212,9 @@ function applyWorkspaceEnv( if (config.defaults.tracker) process.env.TASK_TRACKER = config.defaults.tracker; // In-process consumers (dashboard, run records) follow the fleet DB. process.env.WEBHOOK_QUEUE_DB = workspaceDbPath(workspaceDir); + // A pinned harness opts an entry out of failover; surface a missing CLI here, + // where the workspace `.env` is already applied, so it is not silent. + warnUninstalledHarnessPins([...config.automations, ...config.estimations]); return { multiTeam, initialQuery: config.defaults.taskQuery, diff --git a/packages/code/tests/agent-selection.test.ts b/packages/code/tests/agent-selection.test.ts index 1ca76fa3..586f2c73 100644 --- a/packages/code/tests/agent-selection.test.ts +++ b/packages/code/tests/agent-selection.test.ts @@ -3,10 +3,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { resolveHarness } from "@devintern/agent-harness"; import { applyAgentSelectionEnv, parseAgentSelection, pickAgentSelection, + warnUninstalledHarnessPins, } from "../src/lib/agent/selection"; import { parseWorkspaceConfig } from "../src/lib/workspace/config"; import { buildRepoEnv, buildWorkspaceContextEnv } from "../src/lib/workspace/env"; @@ -103,21 +105,119 @@ describe("applyAgentSelectionEnv", () => { test("overlays only the selected fields over the layered env", () => { const env = applyAgentSelectionEnv( { AGENT_HARNESS: "claude-code", AGENT_MODEL: "workspace-model", KEEP: "1" }, - { harness: "codex", effort: "medium" }, + { harness: "codex", model: "gpt-5-codex", effort: "medium" }, ); expect(env).toEqual({ AGENT_HARNESS: "codex", - AGENT_MODEL: "workspace-model", + AGENT_MODEL: "gpt-5-codex", AGENT_EFFORT: "medium", KEEP: "1", }); }); + test("drops an inherited model/effort when the pin changes the harness", () => { + // The inherited model/effort were chosen for the env's primary harness, so + // sending them to a different pinned harness is the cross-harness leak this + // module deliberately prevents. + const env = applyAgentSelectionEnv( + { + AGENT_HARNESS: "claude-code", + AGENT_MODEL: "claude-sonnet", + AGENT_EFFORT: "low", + KEEP: "1", + }, + { harness: "codex" }, + ); + expect(env).toEqual({ AGENT_HARNESS: "codex", KEEP: "1" }); + }); + + test("keeps an inherited model/effort when the pin keeps the primary harness", () => { + const env = applyAgentSelectionEnv( + { AGENT_HARNESS: "claude-code", AGENT_MODEL: "claude-sonnet", AGENT_EFFORT: "low" }, + { harness: "claude-code" }, + ); + expect(env).toEqual({ + AGENT_HARNESS: "claude-code", + AGENT_MODEL: "claude-sonnet", + AGENT_EFFORT: "low", + }); + }); + test("is a no-op for an empty selection", () => { const env = { AGENT_MODEL: "workspace-model" }; applyAgentSelectionEnv(env, {}); expect(env).toEqual({ AGENT_MODEL: "workspace-model" }); }); + + test("ignores model and effort when the entry does not pin a harness", () => { + // A model-only entry keeps the workspace failover chain, so writing a + // harness-specific model would leak it onto whichever fallback runs. + const env = applyAgentSelectionEnv( + { AGENT_HARNESS: "codex,grok", AGENT_MODEL: "workspace-model", KEEP: "1" }, + { model: "gpt-5-codex", effort: "high" }, + ); + expect(env).toEqual({ + AGENT_HARNESS: "codex,grok", + AGENT_MODEL: "workspace-model", + KEEP: "1", + }); + }); + + test("drops a stale AGENT_CLI_PATH when pinning a non-primary harness", () => { + const env = applyAgentSelectionEnv( + { AGENT_HARNESS: "claude-code", AGENT_CLI_PATH: "/opt/custom/claude" }, + { harness: "codex" }, + ); + expect(env.AGENT_HARNESS).toBe("codex"); + expect(env.AGENT_CLI_PATH).toBeUndefined(); + }); + + test("keeps AGENT_CLI_PATH when the pin is the env's primary harness", () => { + const env = applyAgentSelectionEnv( + { AGENT_HARNESS: "claude-code", AGENT_CLI_PATH: "/opt/custom/claude" }, + { harness: "claude-code" }, + ); + expect(env.AGENT_HARNESS).toBe("claude-code"); + expect(env.AGENT_CLI_PATH).toBe("/opt/custom/claude"); + }); + + test("a non-claude pin does not resolve to the inherited CLAUDE_CLI_PATH", () => { + // `CLAUDE_CLI_PATH` is the resolver's backward-compat fallback for ANY + // harness, so a pin to codex must drop it or the child spawns the claude + // binary for the pinned harness. + const composed = applyAgentSelectionEnv( + { + AGENT_HARNESS: "claude-code", + AGENT_CLI_PATH: "/opt/custom/claude", + CLAUDE_CLI_PATH: "/opt/custom/claude", + }, + { harness: "codex" }, + ); + expect(composed.CLAUDE_CLI_PATH).toBeUndefined(); + expect(composed.AGENT_CLI_PATH).toBeUndefined(); + + const previous = { ...process.env }; + try { + for (const key of Object.keys(process.env)) { + if (key.startsWith("AGENT_") || key === "CLAUDE_CLI_PATH") delete process.env[key]; + } + Object.assign(process.env, composed); + const resolved = resolveHarness(); + expect(resolved.harness.name).toBe("codex"); + expect(resolved.path).not.toBe("/opt/custom/claude"); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, previous); + } + }); + + test("pinning claude-code keeps the inherited CLAUDE_CLI_PATH", () => { + const env = applyAgentSelectionEnv( + { AGENT_HARNESS: "claude-code", CLAUDE_CLI_PATH: "/opt/custom/claude" }, + { harness: "claude-code" }, + ); + expect(env.CLAUDE_CLI_PATH).toBe("/opt/custom/claude"); + }); }); describe("pickAgentSelection", () => { @@ -234,7 +334,7 @@ describe("entry selection precedence in run env", () => { expect(env.AGENT_EFFORT).toBe("high"); }); - test("automationRunEnv keeps inherited model when the entry omits it", () => { + test("automationRunEnv drops the inherited model when the pin changes the harness", () => { const automation: AutomationConfig = { id: "daily", enabled: true, @@ -243,9 +343,12 @@ describe("entry selection precedence in run env", () => { intervalMs: 3_600_000, harness: "codex", }; - const env = automationRunEnv(automation, { AGENT_MODEL: "workspace-model" }); + const env = automationRunEnv(automation, { + AGENT_HARNESS: "claude-code", + AGENT_MODEL: "claude-sonnet", + }); expect(env.AGENT_HARNESS).toBe("codex"); - expect(env.AGENT_MODEL).toBe("workspace-model"); + expect(env.AGENT_MODEL).toBeUndefined(); }); test("estimationRunEnv overrides workspace env values", () => { @@ -276,7 +379,9 @@ describe("env builder selection precedence", () => { agentSelection: { harness: "codex", effort: "high" }, }); expect(env.AGENT_HARNESS).toBe("codex"); - expect(env.AGENT_MODEL).toBe("workspace-model"); + // The workspace model was chosen for the claude-code primary, so it must + // not leak onto the codex pin the entry selected. + expect(env.AGENT_MODEL).toBeUndefined(); expect(env.AGENT_EFFORT).toBe("high"); } finally { if (previousHarness === undefined) delete process.env.AGENT_HARNESS; @@ -303,3 +408,82 @@ describe("env builder selection precedence", () => { expect(env.AGENT_MODEL).toBe("entry-model"); }); }); + +describe("warnUninstalledHarnessPins", () => { + const KEYS = ["AGENT_HARNESS", "AGENT_CLI_PATH", "CODEX_CLI_PATH", "CLAUDE_CLI_PATH"] as const; + + /** Run `fn` with only the listed AGENT_* keys set, restoring on exit. */ + function withEnv(values: Partial>, fn: () => void): void { + const previous = Object.fromEntries(KEYS.map((key) => [key, process.env[key]])); + for (const key of KEYS) delete process.env[key]; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) process.env[key] = value; + } + try { + fn(); + } finally { + for (const key of KEYS) { + const value = previous[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + + test("warns when a pinned non-primary harness CLI is missing", () => { + withEnv({ AGENT_HARNESS: "claude-code", CODEX_CLI_PATH: "/nonexistent/codex" }, () => { + const warnings: string[] = []; + warnUninstalledHarnessPins([{ id: "dep", harness: "codex" }], (m) => warnings.push(m)); + expect(warnings.some((w) => w.includes('"codex"') && w.includes('"dep"'))).toBe(true); + expect(warnings.some((w) => w.includes("CODEX_CLI_PATH"))).toBe(true); + }); + }); + + test("stays silent when the pinned harness resolves to an executable", () => { + withEnv({ AGENT_HARNESS: "claude-code", CODEX_CLI_PATH: process.execPath }, () => { + const warnings: string[] = []; + warnUninstalledHarnessPins([{ id: "dep", harness: "codex" }], (m) => warnings.push(m)); + expect(warnings).toEqual([]); + }); + }); + + test("honours AGENT_CLI_PATH only when the pin is the env primary", () => { + withEnv( + { + AGENT_HARNESS: "claude-code", + AGENT_CLI_PATH: process.execPath, + CLAUDE_CLI_PATH: "/nonexistent/claude", + }, + () => { + const warnings: string[] = []; + warnUninstalledHarnessPins([{ id: "dep", harness: "claude-code" }], (m) => + warnings.push(m), + ); + expect(warnings).toEqual([]); + }, + ); + + withEnv( + { + AGENT_HARNESS: "codex", + AGENT_CLI_PATH: process.execPath, + CLAUDE_CLI_PATH: "/nonexistent/claude", + }, + () => { + const warnings: string[] = []; + warnUninstalledHarnessPins([{ id: "dep", harness: "claude-code" }], (m) => + warnings.push(m), + ); + expect(warnings.some((w) => w.includes('"claude-code"'))).toBe(true); + }, + ); + }); + + test("ignores entries without a pinned harness", () => { + withEnv({ AGENT_HARNESS: "claude-code" }, () => { + const warnings: string[] = []; + warnUninstalledHarnessPins([{ id: "dep" }], (m) => warnings.push(m)); + expect(warnings).toEqual([]); + }); + }); +}); diff --git a/packages/code/tests/automation-acquirer.test.ts b/packages/code/tests/automation-acquirer.test.ts index ca23c29f..37879153 100644 --- a/packages/code/tests/automation-acquirer.test.ts +++ b/packages/code/tests/automation-acquirer.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, rmSync } from "fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -16,8 +16,11 @@ import { writeAutomationTaskFile, } from "../src/lib/automation/acquirer"; import { automationTaskArgs } from "../src/lib/automation/config"; +import { spawnEstimationSweep } from "../src/lib/automation/estimation-acquirer"; import { AutomationStateStore } from "../src/lib/automation/state"; import type { AutomationConfig } from "../src/lib/automation/config"; +import { resetWorkerFailover, startWorkerFailover } from "../src/lib/worker/failover"; +import { USAGE_LIMIT_FILE_ENV, WORKER_CHILD_ENV } from "../src/lib/worker/usage-limit-protocol"; describe("AutomationAcquirer", () => { describe("resolveAutomationRunArgs", () => { @@ -988,3 +991,273 @@ describe("AutomationAcquirer triggerManual (dashboard Run now)", () => { await acquirer.stop(); }); }); + +describe("spawnAutomationProcess harness pin", () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + resetWorkerFailover(); + }); + + // A stub CLI that records the env it was spawned with, then exits 75 + // (EX_TEMPFAIL) so the parent's usage-limit handling is exercised. + const PROBE_SOURCE = `const fs = require("node:fs"); +fs.appendFileSync( + process.env.PROBE_LOG, + JSON.stringify({ + harness: process.env.AGENT_HARNESS ?? null, + workerChild: process.env[${JSON.stringify(WORKER_CHILD_ENV)}] ?? null, + usageLimitFile: process.env[${JSON.stringify(USAGE_LIMIT_FILE_ENV)}] ?? null, + cliPath: process.env.AGENT_CLI_PATH ?? null, + claudeCliPath: process.env.CLAUDE_CLI_PATH ?? null, + model: process.env.AGENT_MODEL ?? null, + effort: process.env.AGENT_EFFORT ?? null, + argv: process.argv.slice(2), + }) + "\\n", +); +process.exit(75); +`; + + function fixture(): { probePath: string; probeLog: string } { + const dir = mkdtempSync(join(tmpdir(), "acquirer-pin-")); + dirs.push(dir); + const probePath = join(dir, "probe.cjs"); + writeFileSync(probePath, PROBE_SOURCE); + return { probePath, probeLog: join(dir, "spawns.jsonl") }; + } + + function readSpawns(path: string): Array> { + const raw = readFileSync(path, "utf8").trim(); + return raw ? raw.split("\n").map((line) => JSON.parse(line)) : []; + } + + // Strip ambient worker-child markers so the assertions reflect what + // `pinAutomationEnv` adds, not whatever the test shell was spawned with. + function cleanBase(extra: Record) { + const env: Record = { ...process.env, ...extra }; + delete env[WORKER_CHILD_ENV]; + delete env[USAGE_LIMIT_FILE_ENV]; + return env; + } + + test("pins the entry harness, keeps the worker-child marker, and never fails over", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + const inheritedHint = join(tmpdir(), `acquirer-inherited-${Date.now()}.json`); + + const run = spawnAutomationProcess(process.execPath, [probePath], { + cwd: process.cwd(), + // An ancestor worker's hint path must not leak into a pinned child. + env: { ...cleanBase({ PROBE_LOG: probeLog }), [USAGE_LIMIT_FILE_ENV]: inheritedHint }, + harnessPin: "codex", + }); + expect(await run.completion).toBe(false); + + const records = readSpawns(probeLog); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + harness: "codex", + workerChild: "1", + usageLimitFile: null, + }); + }); + + test("fails over to the next harness when the entry did not pin one", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + + const run = spawnAutomationProcess(process.execPath, [probePath], { + cwd: process.cwd(), + env: cleanBase({ PROBE_LOG: probeLog }), + }); + expect(await run.completion).toBe(false); + + const records = readSpawns(probeLog); + expect(records.length).toBeGreaterThanOrEqual(2); + expect(records[0]).toMatchObject({ harness: "codex", workerChild: "1" }); + expect(records[0]?.usageLimitFile).not.toBeNull(); + expect(records[1]).toMatchObject({ harness: "grok", workerChild: "1" }); + }); + + test("an estimation sweep pins the entry harness and never fails over", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + + const previousEntry = process.argv[1]; + process.argv[1] = probePath; + try { + const run = spawnEstimationSweep( + { + id: "groom", + enabled: true, + query: "q", + interval: "6h", + intervalMs: 21_600_000, + harness: "claude-code", + }, + { cwd: process.cwd(), env: cleanBase({ PROBE_LOG: probeLog }), release() {} }, + ); + expect(await run.completion).toBe(false); + } finally { + process.argv[1] = previousEntry; + } + + const records = readSpawns(probeLog); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + harness: "claude-code", + workerChild: "1", + usageLimitFile: null, + }); + expect(records[0]?.argv).toContain("--estimate"); + }); + + test("a pin to a non-primary harness drops the inherited AGENT_CLI_PATH", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); + + const run = spawnAutomationProcess(process.execPath, [probePath], { + cwd: process.cwd(), + env: cleanBase({ + PROBE_LOG: probeLog, + AGENT_HARNESS: "claude-code", + AGENT_CLI_PATH: "/opt/custom/claude", + }), + harnessPin: "codex", + }); + expect(await run.completion).toBe(false); + + const records = readSpawns(probeLog); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ harness: "codex", cliPath: null }); + }); + + test("a pin to the primary harness keeps AGENT_CLI_PATH", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); + + const run = spawnAutomationProcess(process.execPath, [probePath], { + cwd: process.cwd(), + env: cleanBase({ + PROBE_LOG: probeLog, + AGENT_HARNESS: "claude-code", + AGENT_CLI_PATH: "/opt/custom/claude", + }), + harnessPin: "claude-code", + }); + expect(await run.completion).toBe(false); + + const records = readSpawns(probeLog); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + harness: "claude-code", + cliPath: "/opt/custom/claude", + }); + }); + + test("a pin to a non-claude harness drops the inherited CLAUDE_CLI_PATH fallback", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); + + const run = spawnAutomationProcess(process.execPath, [probePath], { + cwd: process.cwd(), + env: cleanBase({ + PROBE_LOG: probeLog, + AGENT_HARNESS: "claude-code", + CLAUDE_CLI_PATH: "/opt/custom/claude", + }), + harnessPin: "codex", + }); + expect(await run.completion).toBe(false); + + const records = readSpawns(probeLog); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ harness: "codex", claudeCliPath: null }); + }); + + test("a pin to claude-code keeps the inherited CLAUDE_CLI_PATH", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "codex,claude-code", checkInstalled: false, log: () => {} }); + + const run = spawnAutomationProcess(process.execPath, [probePath], { + cwd: process.cwd(), + env: cleanBase({ + PROBE_LOG: probeLog, + AGENT_HARNESS: "codex", + CLAUDE_CLI_PATH: "/opt/custom/claude", + }), + harnessPin: "claude-code", + }); + expect(await run.completion).toBe(false); + + const records = readSpawns(probeLog); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + harness: "claude-code", + claudeCliPath: "/opt/custom/claude", + }); + }); + + test("a pin that changes the harness drops the inherited model", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); + + const env = automationRunEnv( + { + id: "triage", + enabled: true, + prompt: "p", + interval: "1h", + intervalMs: 3_600_000, + harness: "codex", + }, + cleanBase({ + PROBE_LOG: probeLog, + AGENT_HARNESS: "claude-code", + AGENT_MODEL: "claude-sonnet", + }), + ); + + const run = spawnAutomationProcess(process.execPath, [probePath], { + cwd: process.cwd(), + env, + harnessPin: "codex", + }); + expect(await run.completion).toBe(false); + + const records = readSpawns(probeLog); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ harness: "codex", model: null }); + }); + + test("a model-only entry cannot leak its model onto a failover harness", async () => { + const { probePath, probeLog } = fixture(); + startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + + const base = cleanBase({ PROBE_LOG: probeLog, AGENT_HARNESS: "codex,grok" }); + delete base.AGENT_MODEL; + delete base.AGENT_EFFORT; + const env = automationRunEnv( + { + id: "triage", + enabled: true, + prompt: "p", + interval: "1h", + intervalMs: 3_600_000, + model: "gpt-5-codex", + }, + base, + ); + + const run = spawnAutomationProcess(process.execPath, [probePath], { + cwd: process.cwd(), + env, + }); + expect(await run.completion).toBe(false); + + const records = readSpawns(probeLog); + expect(records.length).toBeGreaterThanOrEqual(2); + expect(records[0]).toMatchObject({ harness: "codex", model: null }); + expect(records[1]).toMatchObject({ harness: "grok", model: null }); + }); +}); diff --git a/packages/code/tests/automation-manual.test.ts b/packages/code/tests/automation-manual.test.ts index 2bbc4b76..7eb105d1 100644 --- a/packages/code/tests/automation-manual.test.ts +++ b/packages/code/tests/automation-manual.test.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "child_process"; import { mkdirSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { loadStandaloneAutomationActions } from "../src/lib/automation/manual"; +import { automationRunEnv } from "../src/lib/automation/acquirer"; import { workerTaskArgs } from "../src/lib/acquirers/task-polling"; +import { SKIP_PROJECT_ENV_ENV, WORKER_SUBPROCESS_ENV } from "../src/lib/config/config-dir"; describe("standalone dashboard automation actions", () => { const dirs: string[] = []; @@ -73,6 +76,149 @@ prompt = "Inspect dependency health." expect(spawned[0].cwd).toBe(dir); expect(spawned[0].env.DEVINTERN_RUN_ORIGIN).toBe("manual"); expect(spawned[0].env.DEVINTERN_AUTOMATION_ID).toBe("dependency-health"); + // The child must skip the checkout's local `.env` so the composed + // entry selection cannot be overridden on reload — via the narrow skip + // marker, NOT the worker-subprocess marker, which would relocate the + // child's runtime state/license session to the workspace state dir. + expect(spawned[0].env[SKIP_PROJECT_ENV_ENV]).toBe("1"); + }); + + test("the child cannot reload dropped CLI-path/model over a pinned harness", async () => { + const dir = projectWith(` +[[automations]] +id = "dependency-health" +enabled = true +interval = "1d" +prompt = "Inspect dependency health." +harness = "codex" +`); + writeFileSync( + join(dir, ".devintern-code", ".env"), + "AGENT_HARNESS=claude-code\nAGENT_CLI_PATH=/custom/claude\nAGENT_MODEL=workspace-model\n", + ); + + const previous = { + AGENT_HARNESS: process.env.AGENT_HARNESS, + AGENT_CLI_PATH: process.env.AGENT_CLI_PATH, + AGENT_MODEL: process.env.AGENT_MODEL, + }; + // The dashboard loads the project `.env` before triggering, so its own + // process env carries the primary harness's CLI path and model. + process.env.AGENT_HARNESS = "claude-code"; + process.env.AGENT_CLI_PATH = "/custom/claude"; + process.env.AGENT_MODEL = "workspace-model"; + + let contextEnv: Record = {}; + const actions = loadStandaloneAutomationActions(dir, { + spawnRun: (_automation, context) => { + contextEnv = context.env; + return { completion: Promise.resolve(true), terminate() {} }; + }, + }); + try { + expect(await actions.trigger("dependency-health")).toEqual({ ok: true }); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + const automation = { + id: "dependency-health", + enabled: true, + prompt: "Inspect dependency health.", + interval: "1d", + intervalMs: 86_400_000, + harness: "codex", + }; + // The real spawn composes the child env via automationRunEnv: the pin + // changes the harness, so the primary's AGENT_CLI_PATH/AGENT_MODEL drop. + const childEnv = automationRunEnv(automation, contextEnv, "manual"); + expect(childEnv.AGENT_HARNESS).toBe("codex"); + expect(childEnv.AGENT_CLI_PATH).toBeUndefined(); + expect(childEnv.AGENT_MODEL).toBeUndefined(); + + const bootstrapPath = join(__dirname, "..", "src", "lib", "cli", "bootstrap.ts"); + const script = `import { loadEnvironment } from ${JSON.stringify(bootstrapPath)}; loadEnvironment(); console.log(JSON.stringify({ cliPath: process.env.AGENT_CLI_PATH ?? null, model: process.env.AGENT_MODEL ?? null, harness: process.env.AGENT_HARNESS ?? null }));`; + + // With the skip marker, loadEnvironment() is skipped and the dropped keys + // stay dropped. + const pinned = spawnSync("bun", ["-e", script], { + cwd: dir, + env: { ...childEnv, SENTRY_DISABLED: "1" }, + encoding: "utf8", + }); + expect(pinned.status).toBe(0); + expect(JSON.parse(pinned.stdout.trim())).toEqual({ + cliPath: null, + model: null, + harness: "codex", + }); + + // Control: without either skip marker the checkout `.env` repopulates both + // keys — the cross-harness leak this fix closes. (Both markers must be + // removed because the test process itself may inherit the worker marker.) + const leakyEnv = { ...childEnv }; + delete leakyEnv[SKIP_PROJECT_ENV_ENV]; + delete leakyEnv[WORKER_SUBPROCESS_ENV]; + const leaky = spawnSync("bun", ["-e", script], { + cwd: dir, + env: { ...leakyEnv, SENTRY_DISABLED: "1" }, + encoding: "utf8", + }); + expect(leaky.status).toBe(0); + expect(JSON.parse(leaky.stdout.trim())).toEqual({ + cliPath: "/custom/claude", + model: "workspace-model", + harness: "codex", + }); + }); + + test("the skip marker keeps runtime state and the auth session in the project config dir", () => { + const dir = projectWith(` +[[automations]] +id = "dependency-health" +enabled = true +interval = "1d" +prompt = "Inspect dependency health." +`); + const workspaceDir = join(dir, "workspace-home"); + const bootstrapPath = join(__dirname, "..", "src", "lib", "cli", "bootstrap.ts"); + const configDirPath = join(__dirname, "..", "src", "lib", "config", "config-dir.ts"); + const script = `import { resolveRuntimeStateDir } from ${JSON.stringify(configDirPath)}; import { loadSupabaseConfig } from ${JSON.stringify(bootstrapPath)}; console.log(JSON.stringify({ runtime: resolveRuntimeStateDir(), auth: loadSupabaseConfig().sessionFilePath }));`; + + const skipEnv: NodeJS.ProcessEnv = { + ...process.env, + DEVINTERN_WORKSPACE_DIR: workspaceDir, + [SKIP_PROJECT_ENV_ENV]: "1", + SENTRY_DISABLED: "1", + }; + delete skipEnv[WORKER_SUBPROCESS_ENV]; + + // The manual spawn's narrow marker: state and auth stay in the project. + const skip = spawnSync("bun", ["-e", script], { cwd: dir, env: skipEnv, encoding: "utf8" }); + expect(skip.status).toBe(0); + expect(JSON.parse(skip.stdout.trim())).toEqual({ + runtime: join(dir, ".devintern-code"), + auth: join(dir, ".devintern-code", ".auth-session.json"), + }); + + // Control: the broad worker marker relocates both to the workspace state + // dir — the blast radius this fix avoids by not overloading it. + const workerEnv: NodeJS.ProcessEnv = { ...skipEnv }; + delete workerEnv[SKIP_PROJECT_ENV_ENV]; + workerEnv[WORKER_SUBPROCESS_ENV] = "1"; + const worker = spawnSync("bun", ["-e", script], { + cwd: dir, + env: workerEnv, + encoding: "utf8", + }); + expect(worker.status).toBe(0); + expect(JSON.parse(worker.stdout.trim())).toEqual({ + runtime: join(workspaceDir, "state", "code"), + auth: join(workspaceDir, "state", "code", ".auth-session.json"), + }); }); test("trigger re-validates the config and refuses unknown or disabled automations", async () => { From 95ec16ad04cd023952119d0d71a89acad373687e Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 13 Nov 2026 20:38:23 +0700 Subject: [PATCH 3/6] feat(agent): model-only overrides keep failover; per-agent conflict resolution - A `model` without `harness` on an automation/estimation is no longer silently ignored: it is written to the primary harness's own `_MODEL`, so `model = "sonnet"` works while keeping failover and a fallback harness keeps its default model. A pinned `model` also outranks an inherited `_MODEL`. `effort` applies without a pin. - Add `[workspace].conflict_resolution_harness/_model/_effort`, applied to GitHub and GitLab automatic conflict resolution. `runWithFailover` gains a `harnessPin` option: a pinned run spawns once on that agent and defers on a usage limit instead of failing over. Startup/reload warns when the pin's CLI is missing. - Team-scoped estimations (added on main) apply the entry selection too. - Fix PR tests for main's async `startWorkerFailover`. - Move scheduled-job context resolvers to `workspace/schedule-context.ts` to keep `workspace-worker.ts` under the max-lines limit. Co-Authored-By: Claude Opus 5.5 --- docs/code/worker.md | 16 ++- docs/code/workspaces.md | 14 +- .../code/src/lib/acquirers/review-polling.ts | 22 +++- packages/code/src/lib/agent/selection.ts | 120 ++++++++++++------ packages/code/src/lib/automation/acquirer.ts | 30 +---- packages/code/src/lib/worker/failover.ts | 15 +++ packages/code/src/lib/workspace/config.ts | 27 ++++ .../code/src/lib/workspace/fleet-events.ts | 5 +- .../src/lib/workspace/gitlab-fleet-events.ts | 4 +- .../src/lib/workspace/schedule-context.ts | 111 ++++++++++++++++ .../src/lib/workspace/workspace-worker.ts | 118 ++--------------- packages/code/tests/agent-selection.test.ts | 110 +++++++++++++++- .../code/tests/automation-acquirer.test.ts | 28 ++-- packages/code/tests/fleet-events.test.ts | 27 ++++ 14 files changed, 447 insertions(+), 200 deletions(-) create mode 100644 packages/code/src/lib/workspace/schedule-context.ts diff --git a/docs/code/worker.md b/docs/code/worker.md index 0b8f41e8..d1a514cf 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -39,7 +39,7 @@ devintern webhook serve ## Agent failover -Set `AGENT_HARNESS=codex,grok` (comma-separated, priority first) in the workspace `.env` so the worker keeps going when one agent hits a usage limit. Failover applies to every worker job: tracker tasks, PR review addressing, `@mention` runs, conflict resolution, scheduled automations, estimations, dashboard retries, and relay-driven work — except automations or estimations that pin their own `harness` (see [Choosing the agent per schedule](#choosing-the-agent-per-schedule)). Details: [Failover across multiple harnesses](./configuration.md#failover-across-multiple-harnesses). +Set `AGENT_HARNESS=codex,grok` (comma-separated, priority first) in the workspace `.env` so the worker keeps going when one agent hits a usage limit. Failover applies to every worker job: tracker tasks, PR review addressing, `@mention` runs, conflict resolution, scheduled automations, estimations, dashboard retries, and relay-driven work — except automations, estimations, or conflict resolution that pin their own `harness` (see [Choosing the agent per schedule](#choosing-the-agent-per-schedule)). Details: [Failover across multiple harnesses](./configuration.md#failover-across-multiple-harnesses). ## Error-monitor auto-fixes @@ -158,6 +158,14 @@ When `open_pr = true`, occurrences receive the same flags as polled tasks: `[def By default every run uses the workspace `AGENT_HARNESS` / `AGENT_MODEL` / `AGENT_EFFORT` from the [environment layers](./configuration.md). An individual `[[automations]]` or `[[estimations]]` entry can override the agent for its own runs, so a cheap model can handle a daily triage job while a frontier model handles release work: ```toml +# Same harness as the workspace, smaller model; failover still applies. +[[automations]] +id = "dependency-bump" +enabled = true +interval = "1d" +model = "sonnet" +prompt = "Upgrade one outdated patch-level dependency and run the tests." + [[automations]] id = "log-triage" enabled = true @@ -178,10 +186,10 @@ prompt = "Cut the next release and update the changelog." ``` - `harness` names **one** registered agent (aliases such as `agy` resolve to their canonical name). An unknown name fails validation, naming the entry id and the `harness` field. Because a pin opts out of failover, the worker warns at startup (and on config reload) when an entry's `harness` is registered but its CLI is not installed or its `_CLI_PATH` points at a missing executable, so the problem is visible before the first occurrence. -- `model` is a harness-specific string (see that agent's CLI docs) and is never validated cross-harness. Because it only makes sense for one agent, it is applied only when the entry also sets `harness`; without `harness` the inherited `AGENT_MODEL` (if any) is kept. When `harness` names a different agent than the workspace primary, an inherited `AGENT_MODEL` chosen for that primary is dropped unless the entry sets `model`. -- `effort` accepts only `low`, `medium`, or `high`; anything else fails validation. Like `model`, it is applied only when the entry also sets `harness`, and an inherited `AGENT_EFFORT` is dropped when the pin changes the harness unless the entry sets `effort`. It is applied only for harnesses that support reasoning effort — on any other harness it is ignored with a warning, and the run proceeds. +- `model` is a harness-specific string (see that agent's CLI docs) and is never validated cross-harness. Without `harness`, it applies to the workspace's primary harness only (it is passed as that harness's `_MODEL`, e.g. `CLAUDE_CODE_MODEL`), so the entry keeps normal failover and a fallback harness runs on its own default model. With `harness`, it applies to the pinned agent; when `harness` names a different agent than the workspace primary, an inherited `AGENT_MODEL` chosen for that primary is dropped unless the entry sets `model`. +- `effort` accepts only `low`, `medium`, or `high`; anything else fails validation. It applies to whichever harness runs the entry (effort levels are harness-neutral); when a pin changes the harness, an inherited `AGENT_EFFORT` is dropped unless the entry sets `effort`. It is applied only for harnesses that support reasoning effort — on any other harness it is ignored with a warning, and the run proceeds. - Precedence for the entry's runs is **entry field > team/repo env > workspace env > harness default**. Fields you omit fall through to the existing workspace/repo/team values, and entries that set none of the three fields behave exactly as before. -- An entry that pins `harness` uses exactly that agent and opts out of the workspace `AGENT_HARNESS` failover chain for its runs; entries without `harness` keep normal failover and never receive a harness-specific `model`/`effort` the entry did not pair with a `harness`. A pinned harness also drops the inherited global `AGENT_CLI_PATH` (which names the workspace primary's binary) and, for a non-`claude-code` pin, the resolver's `CLAUDE_CLI_PATH` fallback, so it resolves its own `_CLI_PATH` or default. The selection applies to subsequent occurrences after a live reload; a run already in flight keeps the agent it started with. +- An entry that pins `harness` uses exactly that agent and opts out of the workspace `AGENT_HARNESS` failover chain for its runs; entries without `harness` keep normal failover, and their `model` never reaches a fallback harness. A pinned harness also drops the inherited global `AGENT_CLI_PATH` (which names the workspace primary's binary) and, for a non-`claude-code` pin, the resolver's `CLAUDE_CLI_PATH` fallback, so it resolves its own `_CLI_PATH` or default. The selection applies to subsequent occurrences after a live reload; a run already in flight keeps the agent it started with. ### Schedule semantics diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index 66317978..402a073c 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -36,6 +36,7 @@ dashboard = true # 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" +# conflict_resolution_model = "sonnet" # cheaper agent for conflict resolution # Optional bounded host concurrency (see warning below): # [workspace.execution] @@ -108,7 +109,7 @@ prompt = "Review the frontend and clean up one source of recurring noise." - `[worker].auto_update` keeps a globally installed CLI current while the worker is idle (checked at most daily, installed without interrupting work, restart on success). See [Running the Worker Unattended: Keeping the worker up to date](./worker.md#keeping-the-worker-up-to-date). - `[[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. `open_pr` (boolean, default `false`) decides whether occurrences open a pull request: opt-in per automation, and workspace-level `worker_task_args` never overrides it. `allow_no_changes` (boolean, default `false`) records an occurrence that changes nothing as a successful no-op instead of a failed run — use it for jobs whose prompt says to make no changes when nothing needs doing. See [Worker Daemon → Recurring automations](./worker.md#recurring-automations) for prompt-writing guidance and schedule semantics. - `[[estimations]]` schedules unattended story-point sweeps (tracker query + cron/interval, no `prompt`, no `repo`). An entry runs against `[defaults].tracker`, or against one team's tracker and credentials when it sets `team = ""`. That tracker must support estimation. See [Worker Daemon → Scheduled story-point estimation](./worker.md#scheduled-story-point-estimation). -- `[[automations]]` and `[[estimations]]` entries may each set `harness` (one agent name), `model`, and `effort` (`"low"`, `"medium"`, or `"high"`) to run that schedule on a different agent than the workspace default. Precedence for an entry's runs is **entry field > team/repo env > workspace env > harness default**; omitted fields keep the existing workspace/repo/team values, and `model`/`effort` are harness-specific so they apply only when the entry also sets `harness` (an entry without `harness` keeps normal failover and never sends a harness-specific model to a fallback). When the pin names a different agent than the workspace primary, an inherited `model`/`effort` and the primary's `AGENT_CLI_PATH` (plus the `CLAUDE_CLI_PATH` fallback for a non-`claude-code` pin) are dropped so the pinned agent uses its own binary and defaults. An unknown `harness` or an `effort` outside the three levels fails validation naming the entry id and field. A pinned `harness` whose CLI is not installed is warned about at worker startup and on reload (the pin opts out of failover), naming the entry. `effort` on a harness that does not support reasoning effort is ignored with a warning instead of failing the run. These overrides apply on live reload to subsequent occurrences; in-flight runs finish under the selection they started with. See [Worker Daemon → Choosing the agent per schedule](./worker.md#choosing-the-agent-per-schedule). +- `[[automations]]` and `[[estimations]]` entries may each set `harness` (one agent name), `model`, and `effort` (`"low"`, `"medium"`, or `"high"`) to run that schedule on a different agent than the workspace default. Precedence for an entry's runs is **entry field > team/repo env > workspace env > harness default**; omitted fields keep the existing workspace/repo/team values. A `model` without `harness` applies to the workspace's primary harness only (as its `_MODEL`), so `model = "sonnet"` alone keeps failover and a fallback harness runs on its own default model. A pinned `harness` opts the entry out of failover; when it names a different agent than the workspace primary, an inherited `model`/`effort` and the primary's `AGENT_CLI_PATH` (plus the `CLAUDE_CLI_PATH` fallback for a non-`claude-code` pin) are dropped so the pinned agent uses its own binary and defaults. An unknown `harness` or an `effort` outside the three levels fails validation naming the entry id and field. A pinned `harness` whose CLI is not installed is warned about at worker startup and on reload, naming the entry. `effort` on a harness that does not support reasoning effort is ignored with a warning instead of failing the run. These overrides apply on live reload to subsequent occurrences; in-flight runs finish under the selection they started with. See [Worker Daemon → Choosing the agent per schedule](./worker.md#choosing-the-agent-per-schedule). - `[[error_monitors]]` maps each Sentry project to one repo and an optional team, with per-source credential layers for multi-project setups. See [Sentry Auto-fixes](./sentry-integration.md). ### Bounded host concurrency @@ -281,6 +282,17 @@ 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 live-reloads with the rest of the runtime configuration. 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. +**Choosing the agent.** Conflict resolution is usually mechanical, so it can run on a cheaper model than feature work. Set any of `conflict_resolution_harness`, `conflict_resolution_model`, and `conflict_resolution_effort` under `[workspace]`; they behave exactly like an automation's `harness` / `model` / `effort` (see [Worker Daemon → Choosing the agent per schedule](./worker.md#choosing-the-agent-per-schedule)) and apply to automatic resolution on GitHub and GitLab in every mode: + +```toml +[workspace] +conflict_resolution_model = "sonnet" # primary harness only; failover still applies +# conflict_resolution_harness = "codex" # pin one agent (opts out of failover) +# conflict_resolution_effort = "low" +``` + +A model-only setting applies to the workspace's primary harness and keeps failover. A pinned harness that hits a usage limit defers the resolution to a later poll instead of failing over. Changes live-reload; a manual `devintern resolve-conflicts ` uses the normal environment. + 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 diff --git a/packages/code/src/lib/acquirers/review-polling.ts b/packages/code/src/lib/acquirers/review-polling.ts index 2a73d4e5..30f63ca9 100644 --- a/packages/code/src/lib/acquirers/review-polling.ts +++ b/packages/code/src/lib/acquirers/review-polling.ts @@ -348,6 +348,8 @@ export function runResolveConflictsViaCli( outputStdio?: "inherit" | "ignore"; webUrl?: string; signal?: AbortSignal; + /** Harness pinned by `[workspace].conflict_resolution_harness` (skips failover). */ + harnessPin?: string; } = {}, ): Promise { const args = [ @@ -368,6 +370,8 @@ export function runResolveConflictsUrlViaCli( expectedBaseSha?: string; timeoutMs?: number; signal?: AbortSignal; + /** Harness pinned by `[workspace].conflict_resolution_harness` (skips failover). */ + harnessPin?: string; entrypoint?: string; outputStdio?: "inherit" | "ignore"; } = {}, @@ -387,6 +391,8 @@ function runResolveSubcommand( outputStdio?: "inherit" | "ignore"; webUrl?: string; signal?: AbortSignal; + /** Harness pinned by `[workspace].conflict_resolution_harness` (skips failover). */ + harnessPin?: string; }, ): Promise { return runResolveWithFailover(repo, prNumber, extraArgs, opts); @@ -404,14 +410,20 @@ async function runResolveWithFailover( outputStdio?: "inherit" | "ignore"; webUrl?: string; signal?: AbortSignal; + /** Harness pinned by `[workspace].conflict_resolution_harness` (skips failover). */ + harnessPin?: string; }, ): Promise { let last: AutomaticResolveResult | null = null; - const status = await runWithFailover(async (env) => { - const spawned = await spawnResolveOnce(repo, prNumber, extraArgs, { ...opts, env }); - last = spawned.result; - return spawned.code; - }, opts.env ?? process.env); + const status = await runWithFailover( + async (env) => { + const spawned = await spawnResolveOnce(repo, prNumber, extraArgs, { ...opts, env }); + last = spawned.result; + return spawned.code; + }, + opts.env ?? process.env, + { harnessPin: opts.harnessPin }, + ); if (status === "deferred") { return { outcome: "deferred", diff --git a/packages/code/src/lib/agent/selection.ts b/packages/code/src/lib/agent/selection.ts index 7f5bbe0b..2ed0bf1c 100644 --- a/packages/code/src/lib/agent/selection.ts +++ b/packages/code/src/lib/agent/selection.ts @@ -1,22 +1,25 @@ /** - * Per-entry agent selection for workspace `[[automations]]` / `[[estimations]]`. + * Per-job agent selection for workspace `[[automations]]`, `[[estimations]]`, + * and automatic conflict resolution (`[workspace].conflict_resolution_*`). * - * A scheduled entry may name the agent (`harness`) and `model` / reasoning - * `effort` for its own runs, overriding the workspace-wide `AGENT_HARNESS` / - * `AGENT_MODEL` / `AGENT_EFFORT` resolved from the environment layers. The - * selection is validated here so a bad value fails `workspace.toml` parsing - * with an actionable message instead of silently falling back. + * A job may name the agent (`harness`) and `model` / reasoning `effort` for its + * own runs, overriding the workspace-wide `AGENT_HARNESS` / `AGENT_MODEL` / + * `AGENT_EFFORT` resolved from the environment layers. The selection is + * validated here so a bad value fails `workspace.toml` parsing with an + * actionable message instead of silently falling back. * - * Precedence for an entry's runs: entry field > team/repo env > workspace - * env > harness default. `model`/`effort` are harness-specific, so they apply - * only when the entry also names a `harness`; a model- or effort-only entry - * leaves the inherited env value untouched rather than sending a string chosen - * for one harness to whichever harness a failover chain later selects. When the - * entry pins a harness other than the env's primary, an inherited - * `AGENT_MODEL`/`AGENT_EFFORT` was chosen for that primary and is dropped too, - * unless the entry sets its own. Disallowed effort on a harness that does not - * support it is ignored at run time by the harness runners, which warn instead - * of failing. + * Precedence for the job's runs: selection field > team/repo env > workspace + * env > harness default. + * + * - With `harness`: the run is pinned to that one agent and opts out of the + * workspace failover chain. When the pin differs from the env's primary, an + * inherited `AGENT_MODEL`/`AGENT_EFFORT` (chosen for that primary) is dropped + * unless the selection sets its own. + * - Without `harness`: the workspace chain (and failover) is kept. `model` is + * written to the primary harness's own `_MODEL`, so it applies to + * the primary only and a fallback harness never receives a model id from a + * different provider. `effort` is harness-neutral and written to + * `AGENT_EFFORT`; a harness without effort support ignores it with a warning. */ import { @@ -28,6 +31,9 @@ import { } from "@devintern/agent-harness"; import type { AgentEffort } from "@devintern/agent-harness"; +import { harnessModelEnvKey } from "./model"; +import { USAGE_LIMIT_FILE_ENV, WORKER_CHILD_ENV } from "../worker/usage-limit-protocol"; + /** Optional per-entry agent overrides parsed from an automation/estimation. */ export interface AgentSelection { /** Canonical harness name for this entry's runs (validated on parse). */ @@ -58,19 +64,23 @@ export function pickAgentSelection(source: AgentSelection): AgentSelection { * `parseAgentEffort` validator. * * @param table - Raw entry table. - * @param options - `label` used in messages and the owning `id`, when known. + * @param options - `label` used in messages, the owning `id` when known, and + * an optional `keyPrefix` (e.g. `conflict_resolution_`) prepended to the + * `harness` / `model` / `effort` key names. * @param errors - Shared error accumulator. */ export function parseAgentSelection( table: Record, - options: { label: string; id?: string }, + options: { label: string; id?: string; keyPrefix?: string }, errors: string[], ): AgentSelection { const { label } = options; + const prefix = options.keyPrefix ?? ""; const entrySuffix = options.id ? ` (entry "${options.id}")` : ""; const selection: AgentSelection = {}; - const readNonEmptyString = (key: string): string | undefined => { + const readNonEmptyString = (field: string): string | undefined => { + const key = `${prefix}${field}`; const value = table[key]; if (value === undefined || value === null) return undefined; if (typeof value !== "string" || !value.trim()) { @@ -89,14 +99,14 @@ export function parseAgentSelection( const canonical = names.map((name) => getHarness(name)).filter((h) => h !== undefined); if (names.length !== 1) { errors.push( - `${label}.harness must name a single agent harness, got "${harness}"${entrySuffix}.`, + `${label}.${prefix}harness must name a single agent harness, got "${harness}"${entrySuffix}.`, ); } else if (canonical.length !== 1) { const available = listHarnesses() .map((h) => `"${h.name}"`) .join(", "); errors.push( - `${label}.harness "${harness}" is not a known agent harness${entrySuffix}. ` + + `${label}.${prefix}harness "${harness}" is not a known agent harness${entrySuffix}. ` + `Available harnesses: ${available}.`, ); } else { @@ -114,7 +124,9 @@ export function parseAgentSelection( try { selection.effort = parseAgentEffort(effort); } catch { - errors.push(`${label}.effort "${effort}" must be one of low, medium, or high${entrySuffix}.`); + errors.push( + `${label}.${prefix}effort "${effort}" must be one of low, medium, or high${entrySuffix}.`, + ); } } @@ -181,7 +193,7 @@ export function warnUninstalledHarnessPins( if (ok) continue; const envKey = harness.name.toUpperCase().replace(/-/g, "_"); log( - `⚠️ Schedule "${entry.id}" is pinned to harness "${harness.name}", which is not installed. ` + + `⚠️ Job "${entry.id}" is pinned to harness "${harness.name}", which is not installed. ` + `It opts out of failover, so it will fail until the CLI is installed or ${envKey}_CLI_PATH is set.`, ); } @@ -215,23 +227,22 @@ export function dropForeignHarnessCliPaths( } /** - * Overlay an entry's agent selection onto a composed environment. + * Overlay a job's agent selection onto a composed environment. * - * Only fields the entry set are written, so omitted fields keep the value + * Only fields the selection set are written, so omitted fields keep the value * from the environment layers (team/repo/workspace) or the harness default. * - * `model` and `effort` are harness-specific, so they are written only when the - * entry also pins a `harness`. Without a `harness` the entry keeps the - * workspace failover chain, and a model chosen for one harness must not be - * handed to whichever fallback harness the chain selects. + * With `harness`, the env is pinned to that agent. When the pin differs from + * the env's primary, the global `AGENT_CLI_PATH` and the resolver's + * `CLAUDE_CLI_PATH` fallback (which name the primary/claude binary) are + * dropped so the child resolves the pinned harness's own `_CLI_PATH` + * or default, and an inherited `AGENT_MODEL` / `AGENT_EFFORT` (chosen for the + * primary) is dropped unless the selection sets its own value. * - * When the entry pins a harness other than the env's primary, the global - * `AGENT_CLI_PATH` and the resolver's `CLAUDE_CLI_PATH` fallback (which name - * the primary/claude binary) are dropped so the child resolves the pinned - * harness's own `_CLI_PATH` or default instead of spawning another - * harness's executable. For the same reason an inherited `AGENT_MODEL` / - * `AGENT_EFFORT` (chosen for the primary) is dropped unless the entry sets its - * own value. + * `model` is always written to the target harness's own `_MODEL` + * (the pin, or the env's primary when no harness is set), which outranks + * `AGENT_MODEL` and applies only to that harness. Without a pin, a failover + * fallback therefore keeps its own default model. * * Mutates and returns `env` for chaining. */ @@ -240,8 +251,9 @@ export function applyAgentSelectionEnv( selection: AgentSelection | undefined, ): Record { if (!selection) return env; + const envPrimary = primaryHarnessName(env.AGENT_HARNESS); + const target = selection.harness ?? envPrimary; if (selection.harness) { - const envPrimary = primaryHarnessName(env.AGENT_HARNESS); const changedHarness = envPrimary !== selection.harness; dropForeignHarnessCliPaths(env, selection.harness, envPrimary); env.AGENT_HARNESS = selection.harness; @@ -250,11 +262,39 @@ export function applyAgentSelectionEnv( } else if (changedHarness) { delete env.AGENT_MODEL; } - if (selection.effort) { - env.AGENT_EFFORT = selection.effort; - } else if (changedHarness) { + if (!selection.effort && changedHarness) { delete env.AGENT_EFFORT; } } + if (selection.model) env[harnessModelEnvKey(target)] = selection.model; + if (selection.effort) env.AGENT_EFFORT = selection.effort; + return env; +} + +/** + * Child env for a run pinned to one harness, bypassing worker failover. + * + * Neither the active failover entry nor a usage-limit failover can re-route a + * job that named its agent. The worker-child marker is kept — and any hint + * path inherited from an ancestor worker dropped — so a usage-limit abort + * still exits 75 to the parent instead of falling through to one-shot success + * handling; the caller decides what that exit means (it never re-spawns on + * another harness). + * + * @param base - Composed environment (already carrying the selection). + * @param harnessPin - Canonical harness the run is pinned to. + */ +export function pinnedHarnessChildEnv( + base: Record, + harnessPin: string, +): Record { + const env: Record = { + ...base, + AGENT_HARNESS: harnessPin, + [WORKER_CHILD_ENV]: "1", + }; + delete env[USAGE_LIMIT_FILE_ENV]; + // Guards direct callers that pin without pre-composing the selection. + dropForeignHarnessCliPaths(env, harnessPin, primaryHarnessName(base.AGENT_HARNESS)); return env; } diff --git a/packages/code/src/lib/automation/acquirer.ts b/packages/code/src/lib/automation/acquirer.ts index 3b46427e..fdd911cf 100644 --- a/packages/code/src/lib/automation/acquirer.ts +++ b/packages/code/src/lib/automation/acquirer.ts @@ -12,9 +12,8 @@ import { AutomationStateStore } from "./state"; import { workerTaskArgs } from "../acquirers/task-polling"; import { applyAgentSelectionEnv, - dropForeignHarnessCliPaths, pickAgentSelection, - primaryHarnessName, + pinnedHarnessChildEnv, } from "../agent/selection"; import { resolveProjectConfigDir } from "../config/config-dir"; import { RUN_ORIGIN_ENV } from "../observability/analytics"; @@ -24,7 +23,6 @@ import { readUsageLimitHint, USAGE_LIMIT_EXIT_CODE, USAGE_LIMIT_FILE_ENV, - WORKER_CHILD_ENV, } from "../worker/usage-limit-protocol"; /** Environment markers the task pipeline reads to attribute scheduled runs. */ @@ -817,28 +815,10 @@ function pinAutomationEnv( hintDir?: string; } { if (harnessPin) { - // Pin the entry's own harness and skip the workspace failover overlay so - // neither the active harness nor a usage-limit failover can re-route an - // entry that named its agent. Keep the worker-child marker — and drop any - // hint path inherited from an ancestor worker — so a usage-limit abort - // still signals the parent by exiting 75 instead of falling through to - // one-shot success handling. `spawnAutomationProcess`'s - // `!options.harnessPin` guard ignores that exit rather than re-spawning on - // another harness. - const env: Record = { - ...base, - AGENT_HARNESS: harnessPin, - [WORKER_CHILD_ENV]: "1", - }; - delete env[USAGE_LIMIT_FILE_ENV]; - // The global AGENT_CLI_PATH names the env's primary harness binary, and the - // resolver's CLAUDE_CLI_PATH fallback applies to ANY harness; a pin to a - // non-claude harness must resolve its own `_CLI_PATH` or default - // rather than spawn another harness's executable. `applyAgentSelectionEnv` - // already drops them when the pin flows through the entry selection; this - // guards direct callers that pin without pre-composing that selection. - dropForeignHarnessCliPaths(env, harnessPin, primaryHarnessName(base.AGENT_HARNESS)); - return { env }; + // The entry named its agent: pin it and skip the workspace failover + // overlay. `spawnAutomationProcess`'s `!options.harnessPin` guard ignores + // a usage-limit exit rather than re-spawning on another harness. + return { env: pinnedHarnessChildEnv(base, harnessPin) }; } const failover = getWorkerFailover(); if (!failover) { diff --git a/packages/code/src/lib/worker/failover.ts b/packages/code/src/lib/worker/failover.ts index de812246..b374f981 100644 --- a/packages/code/src/lib/worker/failover.ts +++ b/packages/code/src/lib/worker/failover.ts @@ -32,6 +32,7 @@ import type { import { HarnessFailover } from "../agent/harness-failover"; import type { FailoverOutcome } from "../agent/harness-failover"; import { HARNESS_CHAIN_ENV, resolveAgentModel } from "../agent/model"; +import { pinnedHarnessChildEnv } from "../agent/selection"; import type { WebhookQueue } from "../state/webhook-queue"; import { AUTOMATION_ACCESS_EXIT_CODE, @@ -403,14 +404,28 @@ export function resetWorkerFailover(): void { * another harness would make the same decision. When failover has not been * started, the spawn runs once with the caller's env (one-shot / tests). * + * With `options.harnessPin` the job named its own agent: the child runs once + * pinned to that harness, outside the failover chain, and a usage-limit or + * harness-startup exit is reported as `"deferred"` instead of failing over. + * * @param spawnOnce - Spawn the child with the given env; return its exit code * @param baseEnv - Environment to pin the active harness onto + * @param options.harnessPin - Harness the job pinned (skips failover) */ export async function runWithFailover( spawnOnce: (env: Record) => Promise, baseEnv: Record = { ...process.env }, + options: { harnessPin?: string } = {}, ): Promise { const failover = getWorkerFailover(); + if (options.harnessPin) { + const code = await spawnOnce(pinnedHarnessChildEnv(baseEnv, options.harnessPin)); + if (code === USAGE_LIMIT_EXIT_CODE) return "deferred"; + if (code === AUTOMATION_ACCESS_EXIT_CODE) return "deferred"; + if (code === HARNESS_UNAVAILABLE_EXIT_CODE) return "deferred"; + if (code === TASK_SKIP_EXIT_CODE) return "skipped"; + return code === 0 ? "ok" : "failed"; + } if (!failover) { const code = await spawnOnce(baseEnv); if (code === AUTOMATION_ACCESS_EXIT_CODE) return "deferred"; diff --git a/packages/code/src/lib/workspace/config.ts b/packages/code/src/lib/workspace/config.ts index bbd4ddad..10251bbc 100644 --- a/packages/code/src/lib/workspace/config.ts +++ b/packages/code/src/lib/workspace/config.ts @@ -14,6 +14,8 @@ import type { WorkerScheduleConfig } from "../worker/schedule"; import { parseEstimationEntries } from "../automation/estimation-config"; import type { EstimationConfig } from "../automation/estimation-config"; import { parseToml } from "./toml"; +import { parseAgentSelection } from "../agent/selection"; +import type { AgentSelection, HarnessPinnedEntry } from "../agent/selection"; /** When automatic conflict resolution on the agent's PRs runs. */ export type ConflictResolutionMode = "auto" | "scheduled" | "disabled"; @@ -57,6 +59,13 @@ export interface WorkspaceSettings { * `"scheduled"`. */ conflictSchedule?: CronOrIntervalSchedule; + /** + * Agent for automatic conflict resolution, from the optional + * `conflict_resolution_harness` / `_model` / `_effort` keys. Same semantics + * as an automation's `harness` / `model` / `effort` (see + * `agent/selection.ts`). Omitted when none of the keys is set. + */ + conflictAgent?: AgentSelection; } /** Fleet-wide defaults from the `[defaults]` table. */ @@ -492,6 +501,11 @@ function parseWorkspaceSettings( readOptionalBoolean(workspaceTable, "ci_failure_fix", "[workspace]", errors) ?? DEFAULT_CI_FAILURE_FIX; const { conflictResolution, conflictSchedule } = parseConflictPolicy(workspaceTable, errors); + const conflictAgent = parseAgentSelection( + workspaceTable, + { label: "[workspace]", keyPrefix: "conflict_resolution_" }, + errors, + ); return { execution: { isolation, maxConcurrency, maxConcurrencyPerRepo }, @@ -501,6 +515,7 @@ function parseWorkspaceSettings( ciFailureFix, conflictResolution, conflictSchedule, + ...(Object.keys(conflictAgent).length > 0 ? { conflictAgent } : {}), }; } @@ -938,3 +953,15 @@ export function findRepo(config: WorkspaceConfig, name: string): RepoConfig | un export function findTeam(config: WorkspaceConfig, name: string): TeamConfig | undefined { return config.teams.find((team) => team.name === name); } + +/** + * Every workspace job that may pin its own harness: automations, + * estimations, and automatic conflict resolution. + */ +export function harnessPinnedJobs(config: WorkspaceConfig): HarnessPinnedEntry[] { + return [ + ...config.automations, + ...config.estimations, + { id: "conflict resolution", harness: config.workspace.conflictAgent?.harness }, + ]; +} diff --git a/packages/code/src/lib/workspace/fleet-events.ts b/packages/code/src/lib/workspace/fleet-events.ts index 0372b3e5..1e7b0f6d 100644 --- a/packages/code/src/lib/workspace/fleet-events.ts +++ b/packages/code/src/lib/workspace/fleet-events.ts @@ -201,9 +201,12 @@ export function createFleetResolveConflicts( await repoManager.ensureBareClone(repo); await repoManager.fetch(repo.name); const base = await repoManager.ensureBaseWorktree(repo); + // Read at run time so a live reload of the conflict agent applies. + const agentSelection = config.workspace.conflictAgent; return runResolve(slug, prNumber, { cwd: base, - env: buildRepoEnv(repo, workspaceDir), + env: buildRepoEnv(repo, workspaceDir, { agentSelection }), + harnessPin: agentSelection?.harness, expectedHeadSha: expected.headSha, expectedBaseSha: expected.baseSha, signal, diff --git a/packages/code/src/lib/workspace/gitlab-fleet-events.ts b/packages/code/src/lib/workspace/gitlab-fleet-events.ts index 48bad1f1..a71ac7e6 100644 --- a/packages/code/src/lib/workspace/gitlab-fleet-events.ts +++ b/packages/code/src/lib/workspace/gitlab-fleet-events.ts @@ -141,13 +141,15 @@ export async function buildGitLabFleetAcquirers(options: { const repo = resolveGitLabRepo(mr); if (!repo) return { outcome: "skipped", message: "repository is not configured" }; const invoke = async (base: string, signal?: AbortSignal) => { + const agentSelection = config.workspace.conflictAgent; return runResolveConflictsUrlViaCli( mr.webUrl, `${mr.instanceUrl}:${mr.projectPath}!${mr.changeNumber}`, { cwd: base, signal, - env: buildRepoEnv(repo, workspaceDir), + env: buildRepoEnv(repo, workspaceDir, { agentSelection }), + harnessPin: agentSelection?.harness, expectedHeadSha: expected.headSha, expectedBaseSha: expected.baseSha, }, diff --git a/packages/code/src/lib/workspace/schedule-context.ts b/packages/code/src/lib/workspace/schedule-context.ts new file mode 100644 index 00000000..ce15b90d --- /dev/null +++ b/packages/code/src/lib/workspace/schedule-context.ts @@ -0,0 +1,111 @@ +/** + * Run contexts (cwd + composed env) for scheduled workspace jobs: + * `[[automations]]` occurrences and `[[estimations]]` sweeps. Each applies the + * entry's own agent selection above every env layer. + */ + +import { pickAgentSelection } from "../agent/selection"; +import type { AutomationRunContext } from "../automation/acquirer"; +import type { AutomationConfig } from "../automation/config"; +import type { EstimationConfig } from "../automation/estimation-config"; +import { findRepo, findTeam, resolveWorkspacePipelinePath } from "./config"; +import type { WorkspaceConfig } from "./config"; +import { + buildRepoEnv, + buildTeamWorkspaceContextEnv, + buildWorkspaceContextEnv, + WORKSPACE_TEAM_ENV, +} from "./env"; +import type { RepoManagerLike } from "./fleet-executor"; +import { automationsDir } from "./paths"; + +/** + * Resolve where/how one scheduled `[[estimations]]` sweep runs. + * + * Always the workspace home: estimation is `--no-git` and tracker-only, so it + * never clones, checks out, or locks a repository. + * + * - Without `team`: unchanged shared workspace env, with `[defaults].tracker` + * inherited from the worker process. A team marker leaked from the shell is + * cleared so the sweep is never attributed to a team. + * - With `team`: that team's tracker and credential layers (see + * {@link buildTeamWorkspaceContextEnv}), attributed to the team via + * `DEVINTERN_WORKSPACE_TEAM` like the team's task runs. + * + * A team's fixed `repo` is deliberately ignored: it only picks the + * destination of implement runs, while tracker coordinates (including + * `GITHUB_REPO` for GitHub Issues) come from the team's own env, which the + * worker already requires at startup to build that team's tracker client. + * + * @param estimation - The current (possibly live-reloaded) estimation entry. + * @param config - The live workspace config (mutated in place by reloads). + * @param workspaceDir - Workspace home. + * @returns The run context, or null to skip the occurrence when the team is + * no longer configured. + */ +export function resolveWorkspaceEstimationContext( + estimation: EstimationConfig, + config: WorkspaceConfig, + workspaceDir: string, +): AutomationRunContext | null { + const agentSelection = pickAgentSelection(estimation); + if (!estimation.team) { + const env = buildWorkspaceContextEnv(workspaceDir, { agentSelection }); + delete env[WORKSPACE_TEAM_ENV]; + return { cwd: workspaceDir, env, release() {} }; + } + const team = findTeam(config, estimation.team); + if (!team) { + console.error( + `❌ [estimation:${estimation.id}] team "${estimation.team}" is not configured; skipping this occurrence.`, + ); + return null; + } + return { + cwd: workspaceDir, + env: buildTeamWorkspaceContextEnv(team, workspaceDir, { agentSelection }), + release() {}, + }; +} + +/** Resolve a scheduled run context while holding the repo lock during preparation. */ +export async function resolveWorkspaceAutomationContext( + automation: AutomationConfig, + config: WorkspaceConfig, + workspaceDir: string, + repoManager: RepoManagerLike, +) { + // Keep occurrence task files in the workspace home (next to repos/, + // worktrees/, and the central DB) instead of inside a repo worktree. + const taskFileDir = automationsDir(workspaceDir); + // The entry's harness/model/effort override every env layer for its runs. + const agentSelection = pickAgentSelection(automation); + const repo = automation.repo + ? findRepo(config, automation.repo) + : config.repos.length === 1 + ? config.repos[0] + : undefined; + if (!repo) + return { + cwd: workspaceDir, + env: buildWorkspaceContextEnv(workspaceDir, { agentSelection }), + taskFileDir, + release() {}, + }; + + await repoManager.ensureBareClone(repo); + await repoManager.fetch(repo.name); + const cwd = await repoManager.ensureBaseWorktree(repo); + return { + cwd, + env: buildRepoEnv(repo, workspaceDir, { + pipelineDefaultFile: config.defaults.pipelineFile + ? resolveWorkspacePipelinePath(workspaceDir, config.defaults.pipelineFile) + : undefined, + agentSelection, + }), + repo: repo.name, + taskFileDir, + release() {}, + }; +} diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index 2fc20363..0cfacae9 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -28,17 +28,13 @@ import { ScheduledRetryStore } from "../state/run-retry"; import { InjectedTaskStore } from "../state/injected-tasks"; import { createTaskActionedGate } from "../task/actioned-state"; import type { TaskTrackerClient } from "../trackers/client"; -import { findRepo, findTeam, loadWorkspaceConfig, resolveWorkspacePipelinePath } from "./config"; -import type { RepoConfig, WorkspaceConfig } from "./config"; +import { findRepo, findTeam, harnessPinnedJobs, loadWorkspaceConfig } from "./config"; import { - applyWorkspaceProcessEnv, - buildErrorMonitorEnv, - buildRepoEnv, - buildTeamEnv, - buildTeamWorkspaceContextEnv, - buildWorkspaceContextEnv, - WORKSPACE_TEAM_ENV, -} from "./env"; + resolveWorkspaceAutomationContext, + resolveWorkspaceEstimationContext, +} from "./schedule-context"; +import type { RepoConfig, WorkspaceConfig } from "./config"; +import { applyWorkspaceProcessEnv, buildErrorMonitorEnv, buildTeamEnv } from "./env"; import { automationsDir, injectedTasksDir, @@ -59,8 +55,7 @@ import type { AutomationConfig } from "../automation/config"; import { automationTaskArgs } from "../automation/config"; import { AutomationStateStore } from "../automation/state"; import { EstimationAcquirer } from "../automation/estimation-acquirer"; -import { pickAgentSelection, warnUninstalledHarnessPins } from "../agent/selection"; -import type { EstimationConfig } from "../automation/estimation-config"; +import { warnUninstalledHarnessPins } from "../agent/selection"; import { createTaskSupervisor, JobNotStartedError } from "../worker/supervisor"; import type { TaskSupervisor } from "../worker/supervisor"; import type { AutomationRunContext } from "../automation/acquirer"; @@ -568,97 +563,7 @@ export function buildRetryQueueAcquirer( }); } -/** - * Resolve where/how one scheduled `[[estimations]]` sweep runs. - * - * Always the workspace home: estimation is `--no-git` and tracker-only, so it - * never clones, checks out, or locks a repository. - * - * - Without `team`: unchanged shared workspace env, with `[defaults].tracker` - * inherited from the worker process. A team marker leaked from the shell is - * cleared so the sweep is never attributed to a team. - * - With `team`: that team's tracker and credential layers (see - * {@link buildTeamWorkspaceContextEnv}), attributed to the team via - * `DEVINTERN_WORKSPACE_TEAM` like the team's task runs. - * - * A team's fixed `repo` is deliberately ignored: it only picks the - * destination of implement runs, while tracker coordinates (including - * `GITHUB_REPO` for GitHub Issues) come from the team's own env, which the - * worker already requires at startup to build that team's tracker client. - * - * @param estimation - The current (possibly live-reloaded) estimation entry. - * @param config - The live workspace config (mutated in place by reloads). - * @param workspaceDir - Workspace home. - * @returns The run context, or null to skip the occurrence when the team is - * no longer configured. - */ -export function resolveWorkspaceEstimationContext( - estimation: EstimationConfig, - config: WorkspaceConfig, - workspaceDir: string, -): AutomationRunContext | null { - const agentSelection = pickAgentSelection(estimation); - if (!estimation.team) { - const env = buildWorkspaceContextEnv(workspaceDir, { agentSelection }); - delete env[WORKSPACE_TEAM_ENV]; - return { cwd: workspaceDir, env, release() {} }; - } - const team = findTeam(config, estimation.team); - if (!team) { - console.error( - `❌ [estimation:${estimation.id}] team "${estimation.team}" is not configured; skipping this occurrence.`, - ); - return null; - } - return { - cwd: workspaceDir, - env: buildTeamWorkspaceContextEnv(team, workspaceDir, { agentSelection }), - release() {}, - }; -} - -/** Resolve a scheduled run context while holding the repo lock during preparation. */ -export async function resolveWorkspaceAutomationContext( - automation: AutomationConfig, - config: WorkspaceConfig, - workspaceDir: string, - repoManager: RepoManagerLike, -) { - // Keep occurrence task files in the workspace home (next to repos/, - // worktrees/, and the central DB) instead of inside a repo worktree. - const taskFileDir = automationsDir(workspaceDir); - // The entry's own harness/model/effort override every env layer for this - // occurrence only; other runs keep the workspace/repo defaults. - const agentSelection = pickAgentSelection(automation); - const repo = automation.repo - ? findRepo(config, automation.repo) - : config.repos.length === 1 - ? config.repos[0] - : undefined; - if (!repo) - return { - cwd: workspaceDir, - env: buildWorkspaceContextEnv(workspaceDir, { agentSelection }), - taskFileDir, - release() {}, - }; - - await repoManager.ensureBareClone(repo); - await repoManager.fetch(repo.name); - const cwd = await repoManager.ensureBaseWorktree(repo); - return { - cwd, - env: buildRepoEnv(repo, workspaceDir, { - pipelineDefaultFile: config.defaults.pipelineFile - ? resolveWorkspacePipelinePath(workspaceDir, config.defaults.pipelineFile) - : undefined, - agentSelection, - }), - repo: repo.name, - taskFileDir, - release() {}, - }; -} +export { resolveWorkspaceAutomationContext, resolveWorkspaceEstimationContext }; const PUSH_PERMISSION_HINT = "Pushes use the ambient git credential chain — when GITHUB_TOKEN is exported, " + @@ -872,7 +777,7 @@ export function validateReloadedWorkspaceConfig( ): void { const fleet = resolveFleetAutomations(next); if (fleet.problems.length > 0) throw new Error(fleet.problems.join("\n- ")); - warnUninstalledHarnessPins([...next.automations, ...next.estimations]); + warnUninstalledHarnessPins(harnessPinnedJobs(next)); if (next.defaults.tracker !== current.defaults.tracker) { throw new Error("[defaults].tracker is startup-only; restart the worker to change it."); } @@ -1415,9 +1320,8 @@ function applyWorkspaceEnv( if (config.defaults.tracker) process.env.TASK_TRACKER = config.defaults.tracker; // In-process consumers (dashboard, run records) follow the fleet DB. process.env.WEBHOOK_QUEUE_DB = workspaceDbPath(workspaceDir); - // A pinned harness opts an entry out of failover; surface a missing CLI here, - // where the workspace `.env` is already applied, so it is not silent. - warnUninstalledHarnessPins([...config.automations, ...config.estimations]); + // Pins opt out of failover; warn here, after the workspace `.env` applies. + warnUninstalledHarnessPins(harnessPinnedJobs(config)); return { multiTeam, initialQuery: config.defaults.taskQuery, diff --git a/packages/code/tests/agent-selection.test.ts b/packages/code/tests/agent-selection.test.ts index 586f2c73..6c5074d9 100644 --- a/packages/code/tests/agent-selection.test.ts +++ b/packages/code/tests/agent-selection.test.ts @@ -10,7 +10,14 @@ import { pickAgentSelection, warnUninstalledHarnessPins, } from "../src/lib/agent/selection"; -import { parseWorkspaceConfig } from "../src/lib/workspace/config"; +import { harnessPinnedJobs, parseWorkspaceConfig } from "../src/lib/workspace/config"; +import { HARNESS_CHAIN_ENV, resolveAgentModel } from "../src/lib/agent/model"; +import { + resetWorkerFailover, + runWithFailover, + startWorkerFailover, +} from "../src/lib/worker/failover"; +import { USAGE_LIMIT_EXIT_CODE, WORKER_CHILD_ENV } from "../src/lib/worker/usage-limit-protocol"; import { buildRepoEnv, buildWorkspaceContextEnv } from "../src/lib/workspace/env"; import { automationRunEnv } from "../src/lib/automation/acquirer"; import { estimationRunEnv } from "../src/lib/automation/estimation-acquirer"; @@ -110,11 +117,21 @@ describe("applyAgentSelectionEnv", () => { expect(env).toEqual({ AGENT_HARNESS: "codex", AGENT_MODEL: "gpt-5-codex", + CODEX_MODEL: "gpt-5-codex", AGENT_EFFORT: "medium", KEEP: "1", }); }); + test("a pinned model outranks an inherited _MODEL for that harness", () => { + const env = applyAgentSelectionEnv( + { AGENT_HARNESS: "codex", CODEX_MODEL: "workspace-codex-model" }, + { harness: "codex", model: "gpt-5-codex-mini" }, + ); + expect(env.CODEX_MODEL).toBe("gpt-5-codex-mini"); + expect(env.AGENT_MODEL).toBe("gpt-5-codex-mini"); + }); + test("drops an inherited model/effort when the pin changes the harness", () => { // The inherited model/effort were chosen for the env's primary harness, so // sending them to a different pinned harness is the cross-harness leak this @@ -149,9 +166,9 @@ describe("applyAgentSelectionEnv", () => { expect(env).toEqual({ AGENT_MODEL: "workspace-model" }); }); - test("ignores model and effort when the entry does not pin a harness", () => { - // A model-only entry keeps the workspace failover chain, so writing a - // harness-specific model would leak it onto whichever fallback runs. + test("scopes a model-only selection to the primary harness and keeps the chain", () => { + // A model-only entry keeps the workspace failover chain. The model goes to + // the primary's own _MODEL, which a fallback never reads. const env = applyAgentSelectionEnv( { AGENT_HARNESS: "codex,grok", AGENT_MODEL: "workspace-model", KEEP: "1" }, { model: "gpt-5-codex", effort: "high" }, @@ -159,10 +176,33 @@ describe("applyAgentSelectionEnv", () => { expect(env).toEqual({ AGENT_HARNESS: "codex,grok", AGENT_MODEL: "workspace-model", + CODEX_MODEL: "gpt-5-codex", + AGENT_EFFORT: "high", KEEP: "1", }); }); + test("a model-only selection resolves on the primary but not on a fallback", () => { + const env = applyAgentSelectionEnv({ AGENT_HARNESS: "codex,grok" }, { model: "gpt-5-codex" }); + const saved = { ...process.env }; + try { + for (const key of ["AGENT_MODEL", "CODEX_MODEL", "GROK_MODEL", HARNESS_CHAIN_ENV]) { + delete process.env[key]; + } + Object.assign(process.env, env); + expect(resolveAgentModel("codex")).toBe("gpt-5-codex"); + expect(resolveAgentModel("grok")).toBeUndefined(); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, saved); + } + }); + + test("a model-only selection targets the default harness when none is configured", () => { + const env = applyAgentSelectionEnv({}, { model: "sonnet" }); + expect(env).toEqual({ CLAUDE_CODE_MODEL: "sonnet" }); + }); + test("drops a stale AGENT_CLI_PATH when pinning a non-primary harness", () => { const env = applyAgentSelectionEnv( { AGENT_HARNESS: "claude-code", AGENT_CLI_PATH: "/opt/custom/claude" }, @@ -487,3 +527,65 @@ describe("warnUninstalledHarnessPins", () => { }); }); }); + +describe("conflict resolution agent", () => { + afterEach(() => resetWorkerFailover()); + + test("parses [workspace].conflict_resolution_harness/model/effort", () => { + const config = parseWorkspaceConfig(`${BASE_CONFIG} +[workspace] +conflict_resolution_harness = "claude-code" +conflict_resolution_model = "sonnet" +conflict_resolution_effort = "low" +`); + expect(config.workspace.conflictAgent).toEqual({ + harness: "claude-code", + model: "sonnet", + effort: "low", + }); + expect(harnessPinnedJobs(config)).toContainEqual({ + id: "conflict resolution", + harness: "claude-code", + }); + }); + + test("omits the conflict agent when no key is set", () => { + expect(parseWorkspaceConfig(BASE_CONFIG).workspace.conflictAgent).toBeUndefined(); + }); + + test("rejects an unknown conflict_resolution_harness naming the key", () => { + expect(() => + parseWorkspaceConfig(`${BASE_CONFIG} +[workspace] +conflict_resolution_harness = "nope" +`), + ).toThrow(/\[workspace\]\.conflict_resolution_harness "nope" is not a known agent harness/); + }); + + test("a pinned run spawns once on the pin and defers on a usage limit", async () => { + await startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + const seen: Array> = []; + const status = await runWithFailover( + async (env) => { + seen.push(env); + return USAGE_LIMIT_EXIT_CODE; + }, + { AGENT_HARNESS: "claude-code" }, + { harnessPin: "claude-code" }, + ); + expect(status).toBe("deferred"); + expect(seen).toHaveLength(1); + expect(seen[0]?.AGENT_HARNESS).toBe("claude-code"); + expect(seen[0]?.[WORKER_CHILD_ENV]).toBe("1"); + }); + + test("an unpinned run still fails over across the chain", async () => { + await startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + const harnesses: Array = []; + await runWithFailover(async (env) => { + harnesses.push(env.AGENT_HARNESS); + return USAGE_LIMIT_EXIT_CODE; + }, {}); + expect(harnesses).toEqual(["codex", "grok"]); + }); +}); diff --git a/packages/code/tests/automation-acquirer.test.ts b/packages/code/tests/automation-acquirer.test.ts index d85ae6ae..36c9e25c 100644 --- a/packages/code/tests/automation-acquirer.test.ts +++ b/packages/code/tests/automation-acquirer.test.ts @@ -1043,6 +1043,8 @@ fs.appendFileSync( cliPath: process.env.AGENT_CLI_PATH ?? null, claudeCliPath: process.env.CLAUDE_CLI_PATH ?? null, model: process.env.AGENT_MODEL ?? null, + codexModel: process.env.CODEX_MODEL ?? null, + grokModel: process.env.GROK_MODEL ?? null, effort: process.env.AGENT_EFFORT ?? null, argv: process.argv.slice(2), }) + "\\n", @@ -1074,7 +1076,7 @@ process.exit(75); test("pins the entry harness, keeps the worker-child marker, and never fails over", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); const inheritedHint = join(tmpdir(), `acquirer-inherited-${Date.now()}.json`); const run = spawnAutomationProcess(process.execPath, [probePath], { @@ -1096,7 +1098,7 @@ process.exit(75); test("fails over to the next harness when the entry did not pin one", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); const run = spawnAutomationProcess(process.execPath, [probePath], { cwd: process.cwd(), @@ -1113,7 +1115,7 @@ process.exit(75); test("an estimation sweep pins the entry harness and never fails over", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); const previousEntry = process.argv[1]; process.argv[1] = probePath; @@ -1146,7 +1148,7 @@ process.exit(75); test("a pin to a non-primary harness drops the inherited AGENT_CLI_PATH", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); const run = spawnAutomationProcess(process.execPath, [probePath], { cwd: process.cwd(), @@ -1166,7 +1168,7 @@ process.exit(75); test("a pin to the primary harness keeps AGENT_CLI_PATH", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); const run = spawnAutomationProcess(process.execPath, [probePath], { cwd: process.cwd(), @@ -1189,7 +1191,7 @@ process.exit(75); test("a pin to a non-claude harness drops the inherited CLAUDE_CLI_PATH fallback", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); const run = spawnAutomationProcess(process.execPath, [probePath], { cwd: process.cwd(), @@ -1209,7 +1211,7 @@ process.exit(75); test("a pin to claude-code keeps the inherited CLAUDE_CLI_PATH", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "codex,claude-code", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "codex,claude-code", checkInstalled: false, log: () => {} }); const run = spawnAutomationProcess(process.execPath, [probePath], { cwd: process.cwd(), @@ -1232,7 +1234,7 @@ process.exit(75); test("a pin that changes the harness drops the inherited model", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "claude-code,codex", checkInstalled: false, log: () => {} }); const env = automationRunEnv( { @@ -1262,9 +1264,9 @@ process.exit(75); expect(records[0]).toMatchObject({ harness: "codex", model: null }); }); - test("a model-only entry cannot leak its model onto a failover harness", async () => { + test("a model-only entry scopes its model to the primary and keeps failover", async () => { const { probePath, probeLog } = fixture(); - startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); + await startWorkerFailover({ raw: "codex,grok", checkInstalled: false, log: () => {} }); const base = cleanBase({ PROBE_LOG: probeLog, AGENT_HARNESS: "codex,grok" }); delete base.AGENT_MODEL; @@ -1289,7 +1291,9 @@ process.exit(75); const records = readSpawns(probeLog); expect(records.length).toBeGreaterThanOrEqual(2); - expect(records[0]).toMatchObject({ harness: "codex", model: null }); - expect(records[1]).toMatchObject({ harness: "grok", model: null }); + // The model rides on the primary's own CODEX_MODEL, which the grok + // fallback never reads, so failover still runs with grok's default model. + expect(records[0]).toMatchObject({ harness: "codex", model: null, codexModel: "gpt-5-codex" }); + expect(records[1]).toMatchObject({ harness: "grok", model: null, grokModel: null }); }); }); diff --git a/packages/code/tests/fleet-events.test.ts b/packages/code/tests/fleet-events.test.ts index 9c75d848..059a64ed 100644 --- a/packages/code/tests/fleet-events.test.ts +++ b/packages/code/tests/fleet-events.test.ts @@ -113,6 +113,7 @@ describe("fleet event handlers", () => { expectedHeadSha?: string; expectedBaseSha?: string; }> = []; + const resolveAgents: Array> = []; const deps = () => ({ config: CONFIG, @@ -149,6 +150,7 @@ describe("fleet event handlers", () => { env?: Record; expectedHeadSha?: string; expectedBaseSha?: string; + harnessPin?: string; } = {}, ) => { resolutions.push({ @@ -158,6 +160,12 @@ describe("fleet event handlers", () => { expectedHeadSha: opts.expectedHeadSha, expectedBaseSha: opts.expectedBaseSha, }); + resolveAgents.push({ + harnessPin: opts.harnessPin, + harness: opts.env?.AGENT_HARNESS, + model: opts.env?.AGENT_MODEL, + effort: opts.env?.AGENT_EFFORT, + }); return { outcome: "clean" as const, message: "merged" }; }, }); @@ -169,6 +177,7 @@ describe("fleet event handlers", () => { reviews = []; pushAccess = true; resolutions.length = 0; + resolveAgents.length = 0; ciFixes.length = 0; }); @@ -301,6 +310,24 @@ describe("fleet event handlers", () => { expect(repoManager.calls).toContain("fetch:backend"); }); + test("base sync runs on the configured conflict-resolution agent", async () => { + const previous = CONFIG.workspace.conflictAgent; + // Mutated in place like a live reload; read at run time. + CONFIG.workspace.conflictAgent = { harness: "claude-code", model: "sonnet", effort: "low" }; + try { + const resolve = createFleetResolveConflicts(deps()); + await resolve("acme/backend", 42, { headSha: "head1", baseSha: "base1" }); + } finally { + CONFIG.workspace.conflictAgent = previous; + } + expect(resolveAgents.at(-1)).toEqual({ + harnessPin: "claude-code", + harness: "claude-code", + model: "sonnet", + effort: "low", + }); + }); + test("mention handler gates on push access and fails closed", async () => { const handle = createFleetMentionHandler(deps()); From c42bcc78cdf4f483800dbe60e3abd6f61e80b9a2 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 13 Nov 2026 20:52:54 +0700 Subject: [PATCH 4/6] fix(agent): warn when a job pins the deprecated gemini harness alias The selection canonicalizes aliases before spawning, so the child never saw "gemini" and the resolver's deprecation warning was lost. Emit it at parse time, naming the entry and key. Adds alias tests (agy, gemini, alias vs canonical primary). Co-Authored-By: Claude Opus 5.5 --- packages/code/src/lib/agent/selection.ts | 7 ++++ packages/code/tests/agent-selection.test.ts | 45 +++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/code/src/lib/agent/selection.ts b/packages/code/src/lib/agent/selection.ts index 2ed0bf1c..c7d776c4 100644 --- a/packages/code/src/lib/agent/selection.ts +++ b/packages/code/src/lib/agent/selection.ts @@ -24,6 +24,7 @@ import { getHarness, + HARNESS_ALIASES, isHarnessInstalled, listHarnesses, parseAgentEffort, @@ -111,6 +112,12 @@ export function parseAgentSelection( ); } else { selection.harness = canonical[0]!.name; + // The child only ever sees the canonical name, so the resolver's + // deprecated-alias warning would never fire; surface it here instead. + const alias = HARNESS_ALIASES[names[0]!]; + if (alias?.deprecated && alias.warning) { + console.warn(`⚠️ ${label}.${prefix}harness${entrySuffix}: ${alias.warning}`); + } } } diff --git a/packages/code/tests/agent-selection.test.ts b/packages/code/tests/agent-selection.test.ts index 6c5074d9..aa47025c 100644 --- a/packages/code/tests/agent-selection.test.ts +++ b/packages/code/tests/agent-selection.test.ts @@ -42,6 +42,51 @@ tracker = "jira" `; describe("parseAgentSelection", () => { + test("warns about a deprecated alias while canonicalizing it", () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (message: string) => warnings.push(message); + try { + const errors: string[] = []; + const selection = parseAgentSelection( + { harness: "gemini" }, + { label: "[[automations]][0]", id: "daily" }, + errors, + ); + expect(errors).toEqual([]); + expect(selection.harness).toBe("antigravity"); + } finally { + console.warn = original; + } + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('[[automations]][0].harness (entry "daily")'); + expect(warnings[0]).toContain("deprecated"); + }); + + test("does not warn for a non-deprecated alias", () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (message: string) => warnings.push(message); + try { + expect(parseAgentSelection({ harness: "agy" }, { label: "x" }, []).harness).toBe( + "antigravity", + ); + } finally { + console.warn = original; + } + expect(warnings).toEqual([]); + }); + + test("matches the env chain's alias handling for the primary", () => { + // AGENT_HARNESS=agy and a pin of "antigravity" are the same agent, so the + // inherited model is kept rather than dropped as a cross-harness leak. + const env = applyAgentSelectionEnv( + { AGENT_HARNESS: "agy", AGENT_MODEL: "gemini-3-pro" }, + { harness: "antigravity" }, + ); + expect(env.AGENT_MODEL).toBe("gemini-3-pro"); + }); + test("accepts harness, model, and effort and canonicalizes aliases", () => { const errors: string[] = []; const selection = parseAgentSelection( From 78fb1cc5ea3f889db0ad9e3ab7c498bf05d68cf2 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 13 Nov 2026 20:54:29 +0700 Subject: [PATCH 5/6] docs: correct per-job agent examples - Model-only examples name the primary harness they assume (claude-code). - Use gpt-5.2 for the Codex example, matching the rest of the docs. - Conflict agent applies to GitHub auto/scheduled and GitLab auto only. - Describe the real deferral behavior for a pinned harness on a usage limit (three consecutive deferrals, then wait for head/base to move). Co-Authored-By: Claude Opus 5.5 --- docs/code/worker.md | 4 ++-- docs/code/workspaces.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/code/worker.md b/docs/code/worker.md index d1a514cf..d55d2a99 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -158,7 +158,7 @@ When `open_pr = true`, occurrences receive the same flags as polled tasks: `[def By default every run uses the workspace `AGENT_HARNESS` / `AGENT_MODEL` / `AGENT_EFFORT` from the [environment layers](./configuration.md). An individual `[[automations]]` or `[[estimations]]` entry can override the agent for its own runs, so a cheap model can handle a daily triage job while a frontier model handles release work: ```toml -# Same harness as the workspace, smaller model; failover still applies. +# Smaller model on the workspace primary harness (here claude-code); failover still applies. [[automations]] id = "dependency-bump" enabled = true @@ -171,7 +171,7 @@ id = "log-triage" enabled = true interval = "1d" harness = "codex" -model = "gpt-5-codex" +model = "gpt-5.2" effort = "low" prompt = "Summarize new errors and file at most one issue." diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index 402a073c..9aa8591c 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -36,7 +36,7 @@ dashboard = true # 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" -# conflict_resolution_model = "sonnet" # cheaper agent for conflict resolution +# conflict_resolution_model = "sonnet" # cheaper model for conflict resolution (primary harness) # Optional bounded host concurrency (see warning below): # [workspace.execution] @@ -282,16 +282,16 @@ 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 live-reloads with the rest of the runtime configuration. 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. -**Choosing the agent.** Conflict resolution is usually mechanical, so it can run on a cheaper model than feature work. Set any of `conflict_resolution_harness`, `conflict_resolution_model`, and `conflict_resolution_effort` under `[workspace]`; they behave exactly like an automation's `harness` / `model` / `effort` (see [Worker Daemon → Choosing the agent per schedule](./worker.md#choosing-the-agent-per-schedule)) and apply to automatic resolution on GitHub and GitLab in every mode: +**Choosing the agent.** Conflict resolution is usually mechanical, so it can run on a cheaper model than feature work. Set any of `conflict_resolution_harness`, `conflict_resolution_model`, and `conflict_resolution_effort` under `[workspace]`; they behave exactly like an automation's `harness` / `model` / `effort` (see [Worker Daemon → Choosing the agent per schedule](./worker.md#choosing-the-agent-per-schedule)) and apply to automatic resolution on GitHub (`auto` and `scheduled`) and GitLab (`auto`): ```toml [workspace] -conflict_resolution_model = "sonnet" # primary harness only; failover still applies +conflict_resolution_model = "sonnet" # a model id for your primary harness (here claude-code); failover still applies # conflict_resolution_harness = "codex" # pin one agent (opts out of failover) # conflict_resolution_effort = "low" ``` -A model-only setting applies to the workspace's primary harness and keeps failover. A pinned harness that hits a usage limit defers the resolution to a later poll instead of failing over. Changes live-reload; a manual `devintern resolve-conflicts ` uses the normal environment. +A model-only setting applies to the workspace's primary harness and keeps failover. A pinned harness that hits a usage limit does not fail over: the resolution is deferred and retried on later polls, and after three consecutive deferrals the PR waits until its head or base moves again (or until you run `devintern resolve-conflicts `). Changes live-reload; a manual `devintern resolve-conflicts ` uses the normal environment. 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. From 809cf4b584d74712c797fcda99d8379e52c717ea Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 13 Nov 2026 21:01:53 +0700 Subject: [PATCH 6/6] feat(agent-harness): accept claude as an alias for claude-code Users write the binary name in AGENT_HARNESS chains (claude,codex,grok) and in per-job harness settings; it now resolves like agy -> antigravity. Docs list the alias. Co-Authored-By: Claude Opus 5.5 --- docs/code/configuration.md | 2 +- docs/code/worker.md | 2 +- docs/pm/configuration.md | 2 +- packages/agent-harness/src/registry.ts | 4 +++- packages/agent-harness/tests/registry.test.ts | 10 ++++++++++ 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/code/configuration.md b/docs/code/configuration.md index 3b426b17..5d0b93fe 100644 --- a/docs/code/configuration.md +++ b/docs/code/configuration.md @@ -538,7 +538,7 @@ AGENT_HARNESS=claude-code You usually only need `AGENT_HARNESS`. By default devintern uses the harness's standard command (for example `claude` for `claude-code`) and finds it on your `PATH` automatically, so `AGENT_CLI_PATH` can be left unset. -Common `AGENT_HARNESS` values include `claude-code`, `opencode`, `codex`, `cursor`, `grok`, `deepseek`, `antigravity`, `cline`, `goose`, `kilo-code`, `kimi`, `muse`, and `qwen`. If you do need to set a path explicitly, run `which` for the harness binary (`claude`, `opencode`, `codex`, `cursor-agent`, `grok`, `reasonix`, `agy`, `cline`, `goose`, `kilo`, `kimi`, `muse`, or `qwen`). +Common `AGENT_HARNESS` values include `claude-code` (alias `claude`), `opencode`, `codex`, `cursor`, `grok`, `deepseek`, `antigravity`, `cline`, `goose`, `kilo-code`, `kimi`, `muse`, and `qwen`. If you do need to set a path explicitly, run `which` for the harness binary (`claude`, `opencode`, `codex`, `cursor-agent`, `grok`, `reasonix`, `agy`, `cline`, `goose`, `kilo`, `kimi`, `muse`, or `qwen`). **Cursor note:** The Cursor harness uses Cursor's headless `cursor-agent` CLI (not a command named `cursor`). Cursor also installs an `agent` alias, but devintern looks for `cursor-agent` because other tools use the `agent` name too. Install Cursor and enable the CLI from Cursor's settings, then set `AGENT_HARNESS=cursor`. The `--max-turns` option has no effect with this harness; Cursor runs until the task is complete. diff --git a/docs/code/worker.md b/docs/code/worker.md index d55d2a99..cb80558c 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -185,7 +185,7 @@ open_pr = true prompt = "Cut the next release and update the changelog." ``` -- `harness` names **one** registered agent (aliases such as `agy` resolve to their canonical name). An unknown name fails validation, naming the entry id and the `harness` field. Because a pin opts out of failover, the worker warns at startup (and on config reload) when an entry's `harness` is registered but its CLI is not installed or its `_CLI_PATH` points at a missing executable, so the problem is visible before the first occurrence. +- `harness` names **one** registered agent (aliases such as `claude` or `agy` resolve to their canonical name). An unknown name fails validation, naming the entry id and the `harness` field. Because a pin opts out of failover, the worker warns at startup (and on config reload) when an entry's `harness` is registered but its CLI is not installed or its `_CLI_PATH` points at a missing executable, so the problem is visible before the first occurrence. - `model` is a harness-specific string (see that agent's CLI docs) and is never validated cross-harness. Without `harness`, it applies to the workspace's primary harness only (it is passed as that harness's `_MODEL`, e.g. `CLAUDE_CODE_MODEL`), so the entry keeps normal failover and a fallback harness runs on its own default model. With `harness`, it applies to the pinned agent; when `harness` names a different agent than the workspace primary, an inherited `AGENT_MODEL` chosen for that primary is dropped unless the entry sets `model`. - `effort` accepts only `low`, `medium`, or `high`; anything else fails validation. It applies to whichever harness runs the entry (effort levels are harness-neutral); when a pin changes the harness, an inherited `AGENT_EFFORT` is dropped unless the entry sets `effort`. It is applied only for harnesses that support reasoning effort — on any other harness it is ignored with a warning, and the run proceeds. - Precedence for the entry's runs is **entry field > team/repo env > workspace env > harness default**. Fields you omit fall through to the existing workspace/repo/team values, and entries that set none of the three fields behave exactly as before. diff --git a/docs/pm/configuration.md b/docs/pm/configuration.md index a3b7db41..11f2bad4 100644 --- a/docs/pm/configuration.md +++ b/docs/pm/configuration.md @@ -148,7 +148,7 @@ You can also override the harness for a single run with `--harness ` (CLI 2. Harness-specific env var (e.g. `OPENCODE_CLI_PATH` when `AGENT_HARNESS=opencode`) 3. Harness default command, located on your `PATH` (e.g. `claude`) -Common `AGENT_HARNESS` values include `claude-code`, `opencode`, `codex`, `cursor`, `grok`, `deepseek`, `antigravity`, `cline`, `goose`, `kilo-code`, `kimi`, `muse`, and `qwen`. If you do need to set a path explicitly, run `which` for the harness binary (`claude`, `opencode`, `codex`, `cursor-agent`, `grok`, `reasonix`, `agy`, `cline`, `goose`, `kilo`, `kimi`, `muse`, or `qwen`). +Common `AGENT_HARNESS` values include `claude-code` (alias `claude`), `opencode`, `codex`, `cursor`, `grok`, `deepseek`, `antigravity`, `cline`, `goose`, `kilo-code`, `kimi`, `muse`, and `qwen`. If you do need to set a path explicitly, run `which` for the harness binary (`claude`, `opencode`, `codex`, `cursor-agent`, `grok`, `reasonix`, `agy`, `cline`, `goose`, `kilo`, `kimi`, `muse`, or `qwen`). **Cursor note:** The Cursor harness uses Cursor's headless `cursor-agent` CLI (not a command named `cursor`). Cursor also installs an `agent` alias, but devpm looks for `cursor-agent` because other tools use the `agent` name too. Install Cursor and enable the CLI from Cursor's settings, then set `AGENT_HARNESS=cursor`. diff --git a/packages/agent-harness/src/registry.ts b/packages/agent-harness/src/registry.ts index 7a18b3e8..7c2df8c8 100644 --- a/packages/agent-harness/src/registry.ts +++ b/packages/agent-harness/src/registry.ts @@ -37,6 +37,8 @@ export const HARNESS_ALIASES: Readonly< > = { /** Binary name users often type as the harness id. */ agy: { target: "antigravity" }, + /** Binary name (and the short form users type, e.g. `claude,codex,grok`). */ + claude: { target: "claude-code" }, /** Product name users may type instead of the binary name. */ "muse-code": { target: "muse" }, /** @@ -67,7 +69,7 @@ export function registerHarness(harness: AgentHarness): void { /** * Look up a registered harness by its machine-readable name. * - * Resolves {@link HARNESS_ALIASES} (e.g. `agy` / `gemini` → `antigravity`) + * Resolves {@link HARNESS_ALIASES} (e.g. `claude` → `claude-code`, `agy` / `gemini` → `antigravity`) * without emitting deprecation warnings; callers that need warnings should use * {@link resolveHarness}. * diff --git a/packages/agent-harness/tests/registry.test.ts b/packages/agent-harness/tests/registry.test.ts index 35259c5f..82558c7e 100644 --- a/packages/agent-harness/tests/registry.test.ts +++ b/packages/agent-harness/tests/registry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { getHarness, listHarnesses, registerHarness } from "../src/registry.js"; +import { parseHarnessList } from "../src/harness-chain.js"; import { ClaudeCodeHarness } from "../src/harnesses/claude-code.js"; describe("registry", () => { @@ -46,6 +47,15 @@ describe("registry", () => { expect(getHarness("muse-code")?.defaultPath).toBe("muse"); }); + test("getHarness resolves the claude binary-name alias", () => { + expect(getHarness("claude")).toBe(getHarness("claude-code")); + }); + + test("parseHarnessList canonicalizes claude in a failover chain", () => { + expect(parseHarnessList("claude,codex,grok")).toEqual(["claude-code", "codex", "grok"]); + expect(parseHarnessList("claude,claude-code")).toEqual(["claude-code"]); + }); + test("getHarness returns undefined for unknown name", () => { expect(getHarness("nonexistent")).toBeUndefined(); });