Repository navigation
Loop thresholds differ per surface: panel flags the 2nd identical call, CLI the 3rd, bell the 4th #96
Copy link
Copy link
Closed
Labels
bugSomething isn't workingSomething isn't working
Description
Part of #94.
⚠️ Выводы не проверены человеком. Ишью заведено по аудиту, который сделал Claude Code. Перед исправлением достаточно один раз выполнить блок «Как проверить» и сравнить вывод с ожидаемым.
Что не так
Одинаковые вызовы подряд (ключ bashStem(normalizeCallKey) — один на всех) считаются циклом при разном числе повторов:
| повторов подряд | панель (красная категория Loop) | analyze:session (loop_streak) |
analyze:sessions (cycles) |
колокол |
|---|---|---|---|---|
| 2 | да | нет | нет | нет |
| 3 | да | да | да | нет |
| 4 | да | да | да | да |
История:
- feat(context): Loop + Wait-loop burn categories in Visible Context #26 ввёл категорию Loop «с 4-го вызова серии, как порог колокола; ранние вызовы серии легитимны, обычный Edit → fix → Edit не попадёт в красное».
- feat: session spend, burn rounds and correct wait-loop accounting #27 заменил
repeat = state.streak >= LOOP_MIN_STREAKнаrepeat = trueи переписал тест на «buckets the 2nd identical call». В описании feat: session spend, burn rounds and correct wait-loop accounting #27 об этом ни слова. - Follow-ups from PR #27 review: calibration boundary, body-highlight leak, wait-loop gate divergence #28/fix(burn): share turn-boundary predicate and wait-loop gate (#28) #33 вернули такой же порог для Wait-loop (принцип: «панель не показывает потерь, которых не показал бы CLI»), а для Loop — нет.
На реальном транскрипте: три Read одного файла подряд — панель показывает Loop, колокол молчит.
Как проверить (один раз, ~1 мин)
Из корня репозитория:
cat > /tmp/repro-loop.mts <<'EOF'
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const R = process.cwd(); // запускать из корня репозитория
const load = (p: string) => import(`${R}/${p}`);
let t = Date.parse('2026-10-01T07:00:00Z'), n = 0;
const at = (ms = 20_000) => new Date((t += ms)).toISOString();
const base = (x = {}) => ({ uuid: `u${++n}`, parentUuid: null, sessionId: 's', cwd: '/p', isSidechain: false, ...x });
const user = (content: unknown, x = {}, gap?: number) => ({ ...base(x), type: 'user', timestamp: at(gap), message: { role: 'user', content } });
const ai = (block: object, ctx: number, out: number, ids: { rid?: string; mid: string }, x = {}, gap?: number) => ({ ...base(x), type: 'assistant', timestamp: at(gap), ...(ids.rid ? { requestId: ids.rid } : {}), message: { id: ids.mid, role: 'assistant', model: 'claude-x', content: [block], usage: { input_tokens: 0, cache_read_input_tokens: ctx, cache_creation_input_tokens: 0, output_tokens: out } } });
const result = (id: string, gap?: number) => ({ ...base(), type: 'user', timestamp: at(gap), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'ok' }] } });
const text = (s: string) => ({ type: 'text', text: s });
const tool = (id: string, name: string, input: object) => ({ type: 'tool_use', id, name, input });
const write = (lines: object[]) => { const f = join(mkdtempSync(join(tmpdir(), 'repro-')), 's.jsonl'); writeFileSync(f, lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); return f; };
// k одинаковых Read подряд (каждый — отдельный запрос), затем ответ
const jsonl = await load('src/main/utils/jsonl.ts');
const { ChunkBuilder } = await load('src/main/services/analysis/ChunkBuilder.ts');
const { transformChunksToConversation } = await load('src/renderer/utils/groupTransformer.ts');
const { processSessionContextWithPhases } = await load('src/renderer/utils/contextTracker.ts');
const cli = await load('src/cli/analyzeSession.ts');
const inv = await load('src/cli/sessionInventory.ts');
const { LoopDetector } = await load('src/main/utils/loopDetection.ts');
const rows = [];
for (const k of [2, 3, 4]) {
const lines: object[] = [user('go')];
for (let i = 0; i < k; i++) lines.push(ai(tool(`r${i}`, 'Read', { file_path: '/x/f.ts' }), 600_000, 40, { rid: `q${i}`, mid: `m${i}` }), result(`r${i}`));
lines.push(ai(text('done'), 600_000, 40, { rid: 'qe', mid: 'me' }));
const f = write(lines);
const msgs = await jsonl.parseJsonlFile(f);
const conv = transformChunksToConversation(new ChunkBuilder().buildChunks(msgs, []), [], false);
let panel = false;
for (const s of processSessionContextWithPhases(conv.items, '/p', {}, new Map(), new Map()).statsMap.values())
if (s.newInjections.some((i: { category: string }) => i.category === 'loop')) panel = true;
const det = new LoopDetector();
const bell = msgs.some((m: object) => det.feed('f', [m], 4) !== null); // 4 = cycleThreshold по умолчанию
rows.push({
'повторов подряд': k,
'панель (Loop)': panel,
'analyze:session (loop_streak)': cli.computeFindings(msgs, cli.buildLedger(msgs)).some((x: { type: string }) => x.type === 'loop_streak'),
'analyze:sessions (cycles)': (await inv.scanSessionFile(f)).cycles.length > 0,
'колокол': bell,
});
}
console.table(rows);
const split = rows.filter((r) => new Set(Object.values(r).slice(1)).size > 1).map((r) => r['повторов подряд']);
console.log(split.length ? `ВОСПРОИЗВЕДЕНО: поверхности расходятся при ${split.join(' и ')} повторах` : 'OK: пороги совпадают');
EOF
pnpm exec tsx /tmp/repro-loop.mtsПравильно: в каждой строке таблицы все четыре поверхности согласны. Сейчас: ВОСПРОИЗВЕДЕНО: поверхности расходятся при 2 и 3 повторах.
Где в коде (гипотеза)
- Панель:
src/renderer/utils/contextTracker.ts:272—repeat = trueсо 2-го вызова. - CLI и инвентарь:
src/cli/analyzeSession.ts:91—loopStreakMin: 3. - Колокол:
notifications.loopDetection.cycleThreshold, по умолчанию 4 (ConfigManager.ts:250).
Что сделать
- Один порог Loop в
@shared/constants/loopPolicy(по образцуWAIT_LOOP_MIN_TICKS). Его используют панель,loop_streak, cycles инвентаря; он же — значениеcycleThresholdпо умолчанию. - Явно выбрать значение (4, как в feat(context): Loop + Wait-loop burn categories in Visible Context #26, или 3) и записать причину рядом с константой.
Связано: #90 — ключ без содержимого, поэтому серия разных правок одного файла тоже считается повтором.
Acceptance
- Тест на 2/3/4 повтора: все поверхности согласны
- Строка добавлена в тест паритета (см. эпик Epic: one number on every surface — cross-surface parity (audit follow-ups) #94)
- Проверено человеком: воспроизводится / не воспроизводится
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't working
Activity
fix(loop): one LOOP_MIN_STREAK gate for panel, CLI and bell (#96) (#113)
fix(test): loop-streak fixture needs 4 repeats (LOOP_MIN_STREAK on main)
feat(cli): regression corpus with numeric snapshots (#79) (#123)
test(parity): one edge-case fixture through every consumer (#103) (#132)