Compare commits

..
Author SHA1 Message Date
SinabinaandClaude Fable 5 39f750e831 fix(migrations): scope v0.32.2 dirty-check to targeted sources; surface failed phase detail
- phaseBFenceFacts now queries legacy rows FIRST and dirty-checks only
  the source_ids it will actually write into. Zero fenceable rows (or
  rows scoped to clean sources) no longer fail on an unrelated dirty
  source. Targeted-dirty-source refusal unchanged. Fixes #927.
- apply-migrations now prints each failed phase's name + detail to
  stderr alongside 'reported status=failed', instead of burying the
  actionable message in the ledger. Fixes #921.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:24:42 -07:00
7 changed files with 88 additions and 75 deletions
+7
View File
@@ -438,6 +438,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
const result = await m.orchestrator(orchestratorOptsFrom(cli));
if (result.status === 'failed') {
console.error(`Migration v${m.version} reported status=failed.`);
// Surface each failed phase's detail — the ledger records it, but
// the operator needs it on stderr to act (#921).
for (const p of result.phases) {
if (p.status === 'failed') {
console.error(` phase ${p.name}: ${p.detail ?? '(no detail)'}`);
}
}
// Record the attempt as 'partial' (not 'complete') so the cap counts
// it. Don't let a failed orchestrator look like it never ran.
try {
+15 -11
View File
@@ -186,17 +186,6 @@ async function phaseBFenceFacts(
const localPathById = new Map<string, string | null>();
for (const s of sources) localPathById.set(s.id, s.local_path);
// Dirty-tree refusal: check every source's local_path before writing.
for (const [id, localPath] of localPathById) {
if (localPath && isLocalPathDirty(localPath)) {
return {
name: 'fence_facts',
status: 'failed',
detail: `source "${id}" has uncommitted changes in ${localPath}. Commit or stash, then re-run.`,
};
}
}
// Walk legacy rows in (source_id, entity_slug) groups for per-page
// atomic writes.
const legacy = await engine.executeRaw<LegacyFactRow>(
@@ -235,6 +224,21 @@ async function phaseBFenceFacts(
groups.set(key, list);
}
// Dirty-tree refusal: check ONLY the sources we are about to write
// into. A dirty tree in an unrelated source (or zero fenceable rows
// at all) must not block a no-op or a targeted backfill (#927).
const targetSourceIds = new Set([...groups.keys()].map(k => k.split('\0')[0]));
for (const id of targetSourceIds) {
const localPath = localPathById.get(id);
if (localPath && isLocalPathDirty(localPath)) {
return {
name: 'fence_facts',
status: 'failed',
detail: `source "${id}" has uncommitted changes in ${localPath}. Commit or stash, then re-run.`,
};
}
}
for (const [key, group] of groups) {
const [sourceId, entitySlug] = key.split('\0');
const localPath = localPathById.get(sourceId)!;
+1 -5
View File
@@ -22,7 +22,6 @@
*/
import { resolveRecipe } from './model-resolver.ts';
import { listRecipes } from './recipes/index.ts';
import { AIConfigError } from './errors.ts';
export interface ProviderCapabilities {
@@ -78,10 +77,7 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti
if (!chat) {
throw new AIConfigError(
`Provider "${recipe.id}" does not offer a chat touchpoint.`,
// Computed from the registry so the hint can't drift into listing
// chat-less providers (the pre-fix list falsely included embedding-only
// recipes, sending users in circles — #1157).
`Known providers with chat: ${listRecipes().filter(r => r.touchpoints.chat).map(r => r.id).join(', ')}. Pick one for models.tier.subagent.`,
`Known providers with chat: openai, anthropic, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai, dashscope, minimax, zhipu, ollama, llama-server. Pick one for models.tier.subagent.`,
);
}
+4 -19
View File
@@ -1,10 +1,9 @@
import type { Recipe } from '../types.ts';
/**
* Zhipu AI (智谱AI) BigModel Open Platform. OpenAI-compatible /embeddings and
* /chat/completions endpoints at open.bigmodel.cn. Hosts embedding-2 (1024d),
* embedding-3 (Matryoshka up to 2048d), and the GLM chat family (glm-5.1 etc.)
* with native tool calling — usable for models.tier.subagent (#1157).
* Zhipu AI (智谱AI) BigModel Open Platform. OpenAI-compatible /embeddings
* endpoint at open.bigmodel.cn. Hosts embedding-2 (1024d) and embedding-3
* (Matryoshka up to 2048d).
*
* embedding-3 at 2048 dims exceeds pgvector's HNSW cap of 2000 — those
* brains fall back to exact vector scans (see
@@ -26,20 +25,6 @@ export const zhipu: Recipe = {
setup_url: 'https://open.bigmodel.cn/',
},
touchpoints: {
chat: {
// Informational list (openai-compat tier: assertTouchpoint doesn't
// enforce it), so newer GLM ids pass without a recipe edit.
models: ['glm-5.1', 'glm-4.6', 'glm-4.5'],
supports_tools: true,
// gbrain-side stable tool ids (v0.38 D11) decoupled the loop from
// Anthropic response formats; GLM tool calling is stable through the
// OpenAI-compat path, same as deepseek/groq.
supports_subagent_loop: true,
// Anthropic-style cache_control markers are not honored on the
// OpenAI-compat path — the loop runs hot (degraded:no_caching warn).
supports_prompt_cache: false,
max_context_tokens: 128000,
},
embedding: {
models: ['embedding-3', 'embedding-2'],
default_dims: 1024,
@@ -51,5 +36,5 @@ export const zhipu: Recipe = {
},
},
setup_hint:
'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`. Chat/subagent: use `zhipu:glm-5.1`.',
'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`',
};
-39
View File
@@ -69,45 +69,6 @@ describe('recipe: zhipu', () => {
expect(sql.toLowerCase()).toContain('hnsw');
});
test('chat touchpoint declares GLM models with tool + subagent-loop support (#1157)', () => {
const r = getRecipe('zhipu')!;
expect(r.touchpoints.chat).toBeDefined();
expect(r.touchpoints.chat!.models).toContain('glm-5.1');
expect(r.touchpoints.chat!.supports_tools).toBe(true);
expect(r.touchpoints.chat!.supports_subagent_loop).toBe(true);
expect(r.touchpoints.chat!.supports_prompt_cache).toBe(false);
});
test('zhipu:glm-5.1 passes the subagent capability gate (degraded:no_caching, not refused)', async () => {
// Pre-fix: getProviderCapabilities threw "does not offer a chat touchpoint"
// and classifyCapabilities returned 'unknown' → subagent submit refused.
const { getProviderCapabilities, classifyCapabilities } =
await import('../../src/core/ai/capabilities.ts');
const caps = getProviderCapabilities('zhipu:glm-5.1');
expect(caps.supportsToolCalling).toBe(true);
expect(classifyCapabilities('zhipu:glm-5.1')).toBe('degraded:no_caching');
});
test('no-chat-touchpoint error hint lists only providers that actually have chat', async () => {
// The hint is computed from the registry; every provider it names must
// really carry a chat touchpoint (pre-fix it hardcoded zhipu/dashscope/
// minimax, all embedding-only at the time).
const { getProviderCapabilities } = await import('../../src/core/ai/capabilities.ts');
const { listRecipes } = await import('../../src/core/ai/recipes/index.ts');
let hint = '';
try {
getProviderCapabilities('voyage:voyage-3');
throw new Error('expected AIConfigError for embedding-only provider');
} catch (e) {
hint = (e as { fix?: string }).fix ?? String(e);
}
const listed = hint.match(/chat: ([^.]+)\./)?.[1]?.split(', ') ?? [];
expect(listed.length).toBeGreaterThan(0);
const withChat = new Set(listRecipes().filter(r => r.touchpoints.chat).map(r => r.id));
for (const id of listed) expect(withChat.has(id)).toBe(true);
expect(listed).toContain('zhipu');
});
test('dimsProviderOptions threads dimensions for embedding-3 (Matryoshka)', async () => {
// Codex finding #1: Zhipu embedding-3 is Matryoshka 256-2048. Without
// `dimensions` on the wire, user-selected non-default dims are
+13
View File
@@ -180,3 +180,16 @@ describe('runApplyMigrations exit codes (v0.36.1.x #1062)', () => {
expect(src).toMatch(/All migrations up to date[\s\S]{0,80}process\.exit\(0\)/);
});
});
// #921: a failed orchestrator must print each failed phase's detail to
// stderr — not just "reported status=failed" — so the operator can act
// without digging through the ledger.
describe('failed migration prints phase detail (#921)', () => {
test('runner loops result.phases and console.errors failed phase details', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/commands/apply-migrations.ts', 'utf8');
expect(src).toMatch(
/reported status=failed[\s\S]{0,400}for \(const p of result\.phases\)[\s\S]{0,200}p\.status === 'failed'[\s\S]{0,200}console\.error\([\s\S]{0,80}p\.name[\s\S]{0,80}p\.detail/,
);
});
});
+48 -1
View File
@@ -10,10 +10,11 @@
* __setTestEngineOverride so we don't need a configured brain.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { v0_32_2, __setTestEngineOverride, __testing } from '../src/commands/migrations/v0_32_2.ts';
@@ -238,6 +239,52 @@ describe('phaseBFenceFacts — happy path backfill', () => {
});
});
describe('phaseBFenceFacts — dirty-tree refusal scoping (#927)', () => {
let dirtyDir: string;
beforeEach(async () => {
// A second source whose local_path is a git repo with uncommitted changes.
dirtyDir = mkdtempSync(join(tmpdir(), 'mig-v0_32_2-dirty-'));
execFileSync('git', ['-C', dirtyDir, 'init', '-q']);
writeFileSync(join(dirtyDir, 'uncommitted.md'), 'dirty', 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES ('other', 'other', $1)`,
[dirtyDir],
);
});
afterEach(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(`DELETE FROM sources WHERE id = 'other'`);
rmSync(dirtyDir, { recursive: true, force: true });
});
test('no legacy facts at all → complete, dirty unrelated source ignored', async () => {
const r = await __testing.phaseBFenceFacts(engine, OPTS);
expect(r.status).toBe('complete');
expect(r.detail).toContain('scanned=0');
});
test('facts scoped to a clean source fence despite dirty unrelated source', async () => {
await seedLegacyFact({ entity_slug: 'people/alice', fact: 'Founded Acme' });
const r = await __testing.phaseBFenceFacts(engine, OPTS);
expect(r.status).toBe('complete');
expect(r.detail).toContain('fenced=1');
expect(existsSync(join(brainDir, 'people/alice.md'))).toBe(true);
});
test('still refuses when the TARGETED source is dirty', async () => {
await seedLegacyFact({ entity_slug: 'people/alice', fact: 'F1', source_id: 'other' });
const r = await __testing.phaseBFenceFacts(engine, OPTS);
expect(r.status).toBe('failed');
expect(r.detail).toContain('"other"');
expect(r.detail).toContain('uncommitted changes');
});
});
describe('phaseCVerify', () => {
test('returns complete when fence + DB row counts match', async () => {
await seedLegacyFact({ entity_slug: 'people/alice', fact: 'F1' });