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 6e791db5..cb80558c 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, 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 @@ -153,6 +153,44 @@ 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 +# Smaller model on the workspace primary harness (here claude-code); 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 +interval = "1d" +harness = "codex" +model = "gpt-5.2" +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 `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. +- 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 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. @@ -191,6 +229,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. Entries without `team` use the workspace tracker, which must support estimation (Jira, Linear, Azure DevOps, Asana via a custom field; GitHub and GitLab post comment-only estimates); 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 51ac224e..9aa8591c 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 model for conflict resolution (primary harness) # Optional bounded host concurrency (see warning below): # [workspace.execution] @@ -108,6 +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. 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 @@ -280,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 (`auto` and `scheduled`) and GitLab (`auto`): + +```toml +[workspace] +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 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. ### How workspace automations differ from single-repo ones 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(); }); 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 new file mode 100644 index 00000000..c7d776c4 --- /dev/null +++ b/packages/code/src/lib/agent/selection.ts @@ -0,0 +1,307 @@ +/** + * Per-job agent selection for workspace `[[automations]]`, `[[estimations]]`, + * and automatic conflict resolution (`[workspace].conflict_resolution_*`). + * + * 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 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 { + getHarness, + HARNESS_ALIASES, + isHarnessInstalled, + listHarnesses, + parseAgentEffort, + DEFAULT_HARNESS_NAME, +} 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). */ + 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, 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; keyPrefix?: string }, + errors: string[], +): AgentSelection { + const { label } = options; + const prefix = options.keyPrefix ?? ""; + const entrySuffix = options.id ? ` (entry "${options.id}")` : ""; + const selection: AgentSelection = {}; + + 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()) { + 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}.${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}.${prefix}harness "${harness}" is not a known agent harness${entrySuffix}. ` + + `Available harnesses: ${available}.`, + ); + } 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}`); + } + } + } + + 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}.${prefix}effort "${effort}" must be one of low, medium, or high${entrySuffix}.`, + ); + } + } + + 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( + `⚠️ 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.`, + ); + } +} + +/** + * 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 a job's agent selection onto a composed environment. + * + * Only fields the selection set are written, so omitted fields keep the value + * from the environment layers (team/repo/workspace) or the harness default. + * + * 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. + * + * `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. + */ +export function applyAgentSelectionEnv( + env: Record, + selection: AgentSelection | undefined, +): Record { + if (!selection) return env; + const envPrimary = primaryHarnessName(env.AGENT_HARNESS); + const target = selection.harness ?? envPrimary; + if (selection.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 && 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 d47f3986..fdd911cf 100644 --- a/packages/code/src/lib/automation/acquirer.ts +++ b/packages/code/src/lib/automation/acquirer.ts @@ -10,6 +10,11 @@ import type { AutomationConfig } from "./config"; import { automationTaskArgs, nextScheduleOccurrence } from "./config"; import { AutomationStateStore } from "./state"; import { workerTaskArgs } from "../acquirers/task-polling"; +import { + applyAgentSelectionEnv, + pickAgentSelection, + pinnedHarnessChildEnv, +} from "../agent/selection"; import { resolveProjectConfigDir } from "../config/config-dir"; import { RUN_ORIGIN_ENV } from "../observability/analytics"; import { getWorkerFailover, HARNESS_UNAVAILABLE_COOLDOWN_MS } from "../worker/failover"; @@ -619,7 +624,7 @@ export function automationRunEnv( originValue: string = "scheduled", ): Record { return { - ...base, + ...applyAgentSelectionEnv({ ...base }, pickAgentSelection(automation)), [AUTOMATION_ORIGIN_ENV]: originValue, [AUTOMATION_ID_ENV]: automation.id, }; @@ -639,6 +644,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, }, ); @@ -675,6 +683,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 { @@ -747,7 +762,9 @@ export function spawnAutomationProcess( } if (code === USAGE_LIMIT_EXIT_CODE || code === HARNESS_UNAVAILABLE_EXIT_CODE) { const failover = getWorkerFailover(); - if (failover) { + // 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) { const outcome = code === USAGE_LIMIT_EXIT_CODE ? failover.reportFromHint(readUsageLimitHint(env[USAGE_LIMIT_FILE_ENV])) @@ -756,7 +773,7 @@ export function spawnAutomationProcess( ); if (outcome.kind !== "exhausted") { cleanupHint(); - const nextEnv = pinAutomationEnv(options.env); + const nextEnv = pinAutomationEnv(options.env, options.harnessPin); hintDir = nextEnv.hintDir; start(nextEnv.env); return; @@ -780,7 +797,7 @@ export function spawnAutomationProcess( }); }; - const initial = pinAutomationEnv(options.env); + const initial = pinAutomationEnv(options.env, options.harnessPin); hintDir = initial.hintDir; start(initial.env); @@ -790,10 +807,19 @@ export function spawnAutomationProcess( }; } -function pinAutomationEnv(base: Record): { +function pinAutomationEnv( + base: Record, + harnessPin?: string, +): { env: Record; hintDir?: string; } { + if (harnessPin) { + // 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) { return { env: base }; diff --git a/packages/code/src/lib/automation/config.ts b/packages/code/src/lib/automation/config.ts index 90655d03..1e454155 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; @@ -187,6 +189,8 @@ export function parseAutomationEntries( errors.push(`${label}.repo "${repo}" does not match any [[repos]] name.`); } + const selection = parseAgentSelection(table, { label, id }, errors); + const allowNoChangesValue = table.allow_no_changes; const allowNoChanges = typeof allowNoChangesValue === "boolean" ? allowNoChangesValue : false; if ( @@ -208,6 +212,7 @@ export function parseAutomationEntries( repo, openPr, allowNoChanges, + ...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 3be023e7..a81fa85b 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`). */ @@ -100,6 +102,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" && @@ -115,6 +119,7 @@ export function parseEstimationEntries(value: unknown): { cron, interval, intervalMs: intervalMs ?? undefined, + ...selection, }); } } 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 a46f3a2f..023b643a 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"; @@ -108,7 +108,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/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/env.ts b/packages/code/src/lib/workspace/env.ts index c56d0575..e38cca28 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; } @@ -344,10 +364,13 @@ export function buildWorkspaceContextEnv( * * @param team - Team whose tracker and credentials the run uses. * @param workspaceDir - Workspace home (defaults to `~/.devintern`). + * @param options.agentSelection - Per-entry harness/model/effort overrides + * applied above the team layers. */ export function buildTeamWorkspaceContextEnv( team: TeamConfig, workspaceDir: string = resolveWorkspaceDir(), + options: { agentSelection?: AgentSelection } = {}, ): Record { const env: Record = { ...process.env, @@ -355,6 +378,7 @@ export function buildTeamWorkspaceContextEnv( }; pinWorkerRuntimeEnv(env, workspaceDir); delete env[WORKSPACE_REPO_ENV]; + applyAgentSelectionEnv(env, options.agentSelection); env.TASK_TRACKER = team.tracker; env[WORKSPACE_TEAM_ENV] = team.name; return env; 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 80c99be7..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,7 +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 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"; @@ -567,92 +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 { - if (!estimation.team) { - const env = buildWorkspaceContextEnv(workspaceDir); - 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), - 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); - const repo = automation.repo - ? findRepo(config, automation.repo) - : config.repos.length === 1 - ? config.repos[0] - : undefined; - if (!repo) - return { - cwd: workspaceDir, - env: buildWorkspaceContextEnv(workspaceDir), - 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, - }), - repo: repo.name, - taskFileDir, - release() {}, - }; -} +export { resolveWorkspaceAutomationContext, resolveWorkspaceEstimationContext }; const PUSH_PERMISSION_HINT = "Pushes use the ambient git credential chain — when GITHUB_TOKEN is exported, " + @@ -866,6 +777,7 @@ export function validateReloadedWorkspaceConfig( ): void { const fleet = resolveFleetAutomations(next); if (fleet.problems.length > 0) throw new Error(fleet.problems.join("\n- ")); + warnUninstalledHarnessPins(harnessPinnedJobs(next)); if (next.defaults.tracker !== current.defaults.tracker) { throw new Error("[defaults].tracker is startup-only; restart the worker to change it."); } @@ -1408,6 +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); + // 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 new file mode 100644 index 00000000..aa47025c --- /dev/null +++ b/packages/code/tests/agent-selection.test.ts @@ -0,0 +1,636 @@ +import { afterEach, describe, expect, test } from "bun:test"; +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 { 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"; +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("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( + { 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", model: "gpt-5-codex", effort: "medium" }, + ); + 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 + // 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("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" }, + ); + 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" }, + { 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", () => { + 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 drops the inherited model when the pin changes the harness", () => { + const automation: AutomationConfig = { + id: "daily", + enabled: true, + prompt: "p", + interval: "1h", + intervalMs: 3_600_000, + harness: "codex", + }; + const env = automationRunEnv(automation, { + AGENT_HARNESS: "claude-code", + AGENT_MODEL: "claude-sonnet", + }); + expect(env.AGENT_HARNESS).toBe("codex"); + expect(env.AGENT_MODEL).toBeUndefined(); + }); + + 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"); + // 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; + 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"); + }); +}); + +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([]); + }); + }); +}); + +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 d15ce804..36c9e25c 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,10 +16,15 @@ 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 { HARNESS_UNAVAILABLE_EXIT_CODE } from "../src/lib/worker/usage-limit-protocol"; +import { + HARNESS_UNAVAILABLE_EXIT_CODE, + USAGE_LIMIT_FILE_ENV, + WORKER_CHILD_ENV, +} from "../src/lib/worker/usage-limit-protocol"; describe("AutomationAcquirer", () => { describe("resolveAutomationRunArgs", () => { @@ -1018,3 +1023,277 @@ 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, + codexModel: process.env.CODEX_MODEL ?? null, + grokModel: process.env.GROK_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(); + await 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(); + await 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(); + await 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(); + await 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(); + await 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(); + await 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(); + await 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(); + await 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 scopes its model to the primary and keeps failover", async () => { + const { probePath, probeLog } = fixture(); + await 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); + // 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/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 () => { 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()); diff --git a/packages/code/tests/workspace-worker.test.ts b/packages/code/tests/workspace-worker.test.ts index 38393453..163c22a7 100644 --- a/packages/code/tests/workspace-worker.test.ts +++ b/packages/code/tests/workspace-worker.test.ts @@ -1515,6 +1515,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", () => {