diff --git a/container/agent-runner/src/config.test.ts b/container/agent-runner/src/config.test.ts new file mode 100644 index 000000000..e5eeeda58 --- /dev/null +++ b/container/agent-runner/src/config.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'bun:test'; + +import { configFromRaw } from './config.js'; + +describe('configFromRaw harnessCapabilities', () => { + it('treats a missing field as a pre-capability host — legacy all-on, not all-off', () => { + // Update-window skew: the bind-mounted runner source is already + // capability-aware while the still-running old host wrote container.json + // without the field. Those groups ran with teams + Workflow on; defaulting + // to {} here would regress them to all-off until the host restarts. + expect(configFromRaw({}).harnessCapabilities).toEqual({ 'agent-teams': 'on', workflow: 'on' }); + }); + + it('passes an explicit host-resolved map through untouched', () => { + const caps = { 'agent-teams': 'off', workflow: 'off' }; + expect(configFromRaw({ harnessCapabilities: caps }).harnessCapabilities).toEqual(caps); + }); +}); diff --git a/container/agent-runner/src/config.ts b/container/agent-runner/src/config.ts index 6f7c4c159..2673fa017 100644 --- a/container/agent-runner/src/config.ts +++ b/container/agent-runner/src/config.ts @@ -18,14 +18,37 @@ export interface RunnerConfig { mcpServers: Record }>; model?: string; effort?: string; - /** Resolved harness-capability map (host-resolved). Missing → {} = all defaults (fail closed). */ + /** Resolved harness-capability map (host-resolved). Missing → legacy all-on (pre-capability host). */ harnessCapabilities: Record; } const DEFAULT_MAX_MESSAGES = 10; +// Pre-capability behavior. A container.json without `harnessCapabilities` was +// written by an older host (capability-aware hosts always emit the full +// resolved map, never omit it) — and under that host every group ran with +// teams + Workflow on. Defaulting to {} here would regress those groups to +// all-off during the update window where the bind-mounted runner source is +// already new but the still-running old host wrote the config file. +const LEGACY_HARNESS_CAPABILITIES: Record = { 'agent-teams': 'on', workflow: 'on' }; + let _config: RunnerConfig | null = null; +/** Map raw container.json fields to a RunnerConfig, applying per-field defaults. */ +export function configFromRaw(raw: Record): RunnerConfig { + return { + provider: (raw.provider as string) || 'claude', + assistantName: (raw.assistantName as string) || '', + groupName: (raw.groupName as string) || '', + agentGroupId: (raw.agentGroupId as string) || '', + maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES, + mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {}, + model: (raw.model as string) || undefined, + effort: (raw.effort as string) || undefined, + harnessCapabilities: (raw.harnessCapabilities as Record) ?? LEGACY_HARNESS_CAPABILITIES, + }; +} + /** * Load config from container.json. Called once at startup. * Falls back to sensible defaults for any missing field. @@ -40,18 +63,7 @@ export function loadConfig(): RunnerConfig { console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`); } - _config = { - provider: (raw.provider as string) || 'claude', - assistantName: (raw.assistantName as string) || '', - groupName: (raw.groupName as string) || '', - agentGroupId: (raw.agentGroupId as string) || '', - maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES, - mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {}, - model: (raw.model as string) || undefined, - effort: (raw.effort as string) || undefined, - harnessCapabilities: (raw.harnessCapabilities as Record) || {}, - }; - + _config = configFromRaw(raw); return _config; } diff --git a/docs/harness-capabilities.md b/docs/harness-capabilities.md index c7570f2b9..bb5469de2 100644 --- a/docs/harness-capabilities.md +++ b/docs/harness-capabilities.md @@ -32,7 +32,7 @@ ncl groups restart --id # apply ## Upgrade behavior — non-breaking (grandfathered) -Before this feature every group ran with agent teams on and Workflow available. Migration 019 **grandfathers every existing group** to that prior state — it stamps `{"agent-teams":"on","workflow":"on"}` onto each row that exists at upgrade time — so **upgrading changes nothing for your current agents**. Only newly-created groups (and every group on a fresh install) get the lean defaults via the column default `{}`. +Before this feature every group ran with agent teams on and Workflow available. Migration 019 **grandfathers every existing group** to that prior state — it stamps `{"agent-teams":"on","workflow":"on"}` onto each row that exists at upgrade time, and the startup backfill stamps the same state onto legacy groups whose config row is only created at boot — so **upgrading changes nothing for your current agents**. Only newly-created groups (and every group on a fresh install) get the lean defaults via the column default `{}`. The runner applies the same rule to a `container.json` missing the capability field (written by a pre-upgrade host mid-update): legacy all-on, so nothing flips off before the host restarts. - **Verify after upgrade**: `ncl groups config get --id ` shows `agent-teams`/`workflow` as `on (override)` for pre-existing groups; their `settings.json` keeps the teams env key and has no `disableWorkflows`. - **Opt an existing group into the lean defaults** (to get the ~20%/turn saving): `ncl groups config update --id --harness-capabilities 'agent-teams=off,workflow=off'` then `ncl groups restart --id `. diff --git a/src/backfill-container-configs.ts b/src/backfill-container-configs.ts index a375e7a68..10d1f6b59 100644 --- a/src/backfill-container-configs.ts +++ b/src/backfill-container-configs.ts @@ -65,7 +65,12 @@ export function backfillContainerConfigs(): void { packages_npm: JSON.stringify(legacy.packages?.npm ?? []), additional_mounts: JSON.stringify(legacy.additionalMounts ?? []), cli_scope: 'group', - harness_capabilities: '{}', + // Grandfather, same rule as migration 019: a group reaching this backfill + // has no config row, i.e. it pre-dates the capability feature and ran + // with teams + Workflow on. Migration 019's UPDATE can't cover it (the + // row doesn't exist yet when migrations run), so stamp the legacy state + // here — '{}' would silently flip such a group to the lean defaults. + harness_capabilities: '{"agent-teams":"on","workflow":"on"}', updated_at: new Date().toISOString(), }; diff --git a/src/db/migrations/019-harness-capabilities.test.ts b/src/db/migrations/019-harness-capabilities.test.ts index 368d42b53..a34c72b5f 100644 --- a/src/db/migrations/019-harness-capabilities.test.ts +++ b/src/db/migrations/019-harness-capabilities.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { backfillContainerConfigs } from '../../backfill-container-configs.js'; import { createAgentGroup } from '../agent-groups.js'; import { closeDb, getDb, initTestDb } from '../connection.js'; import { getContainerConfig } from '../container-configs.js'; @@ -52,6 +53,27 @@ describe('migration 019 (harness-capabilities) upgrade safety', () => { expect(JSON.parse(getContainerConfig('ag-new')!.harness_capabilities)).toEqual({}); }); + it('grandfathers a legacy group whose config row is created by the post-migration backfill', () => { + const db = getDb(); + // Startup sequence on a pre-container-configs-era install: the group + // exists but has NO container_configs row when migrations run, so 019's + // grandfather UPDATE has nothing to touch. The row is created moments + // later by backfillContainerConfigs() (index.ts boot order: migrations → + // backfill) — that path must apply the same grandfather rule. + group('ag-backfill'); + + runMigrations( + db, + migrations.filter((m) => m.name === 'harness-capabilities'), + ); + backfillContainerConfigs(); + + expect(JSON.parse(getContainerConfig('ag-backfill')!.harness_capabilities)).toEqual({ + 'agent-teams': 'on', + workflow: 'on', + }); + }); + it('is a no-op on a fresh install (no existing rows) — new groups start lean', () => { const db = getDb(); runMigrations(