recording-picker: structured OD_MOCKS_POOL + hard-fail no-match

Siri-Ray review: \`OD_MOCKS_POOL=outcome:failed\` was documented as a
supported selection knob, but the matcher only checked tags and
\`meta.agent\` — so the negative-path pool found 0 candidates and
silently fell through to global random, validating against any
recording instead of a failed trace.

Fix:
- Parse \`<dim>:<value>\` shape and route each dim to the right meta
  field: \`outcome\` → \`meta.outcome\`, \`agent\` → \`meta.agent\`,
  \`skill\` → \`tags[]\`. Bare values still fall back to tag substring.
- If the env was set and matched nothing, throw with the failing
  value and a jq one-liner for inspection. Same loud-fail policy as
  OD_MOCKS_TRACE — silent fallback was the original bug.

Verified locally: outcome:failed, agent:codex, skill:agent-browser
all route correctly; outcome:nonsense throws the explicit error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lefarcen 2026-05-29 14:46:20 +08:00
parent df8ee162a3
commit e576074ad9

View file

@ -73,26 +73,44 @@ export async function pickRecording({ prompt } = {}) {
if (picked) return { traceId: picked, path: join(dir, `${picked}.jsonl`), method: 'hash' };
}
// 3. pool by tag
// 3. pool by tag — supports structured `<dimension>:<value>` shortcuts
// documented in README (agent:claude, skill:agent-browser,
// outcome:failed). The dimension routes to the right meta field;
// bare values fall back to tag substring match. Mirrors the
// OD_MOCKS_TRACE policy: if the env is set and matches nothing,
// refuse to fall through to global random — surface the typo.
const pool = process.env.OD_MOCKS_POOL;
if (pool) {
const colonIdx = pool.indexOf(':');
const dim = colonIdx >= 0 ? pool.slice(0, colonIdx) : null;
const value = colonIdx >= 0 ? pool.slice(colonIdx + 1) : null;
const candidates = [];
for (const id of all) {
const meta = await readMeta(dir, id);
if (!meta) continue;
const tags = meta.tags ?? [];
if (
tags.includes(pool) ||
meta.agent === pool ||
tags.some(t => typeof t === 'string' && t.includes(pool))
) {
candidates.push(id);
}
let match = false;
if (dim === 'outcome' && meta.outcome === value) match = true;
else if (dim === 'agent' && meta.agent === value) match = true;
else if (dim === 'skill' && tags.some(t => t === `skill:${value}`)) match = true;
else if (tags.includes(pool)) match = true;
else if (meta.agent === pool) match = true;
else if (tags.some(t => typeof t === 'string' && t.includes(pool))) match = true;
if (match) candidates.push(id);
}
if (candidates.length > 0) {
const picked = pickRandom(candidates, process.env.OD_MOCKS_SEED);
if (picked) return { traceId: picked, path: join(dir, `${picked}.jsonl`), method: 'pool', pool };
if (candidates.length === 0) {
throw new Error(
`OD_MOCKS_POOL="${pool}" matched no recordings in ${dir}. ` +
`Supported shapes: agent:<name>, skill:<name>, outcome:<succeeded|failed|errored>, ` +
`or any tag substring. Check candidates with ` +
`\`jq '[.entries[] | {agent, outcome, skills}] | unique' mocks/manifest.json\`.`,
);
}
const picked = pickRandom(candidates, process.env.OD_MOCKS_SEED);
if (picked) return { traceId: picked, path: join(dir, `${picked}.jsonl`), method: 'pool', pool };
}
// 4. random