mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user