fix(harness): grandfather two upgrade paths the migration UPDATE can't reach

Migration 019 grandfathers rows that exist when it runs, but two paths
sidestep it and silently flipped pre-existing groups to the lean
defaults:

- A legacy group with no container_configs row at upgrade time gets its
  row from the startup backfill, which ran AFTER the migration and
  stamped '{}'. The backfill now stamps the same legacy all-on state —
  any group it touches by definition pre-dates the feature.
- During the update window (bind-mounted runner source already new, old
  host still running), container.json lacks the harnessCapabilities
  field and the runner defaulted it to {} = all-off. A missing field can
  only mean a pre-capability host (new hosts always emit the resolved
  map), so the runner now defaults it to legacy all-on.

Regression tests cover both: the migration→backfill boot sequence, and
the raw-config mapping (extracted as configFromRaw for testability).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-07-09 14:45:42 +03:00
parent 2b13dfadce
commit df6c393346
5 changed files with 72 additions and 15 deletions
+18
View File
@@ -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);
});
});
+25 -13
View File
@@ -18,14 +18,37 @@ export interface RunnerConfig {
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
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<string, string>;
}
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<string, string> = { '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<string, unknown>): 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<string, string>) ?? 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<string, string>) || {},
};
_config = configFromRaw(raw);
return _config;
}
+1 -1
View File
@@ -32,7 +32,7 @@ ncl groups restart --id <group-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 <g>` 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 <g> --harness-capabilities 'agent-teams=off,workflow=off'` then `ncl groups restart --id <g>`.
+6 -1
View File
@@ -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(),
};
@@ -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(