Skip to content

Inline subagent prompt ends the turn for the hook and bell: budget spend under-counted #98

Description

@axisrow

Part of #94.

⚠️ Выводы не проверены человеком. Ишью заведено по аудиту, который сделал Claude Code. Перед исправлением достаточно один раз выполнить блок «Как проверить» и сравнить вывод с ожидаемым.

Что не так

Когда разговор сабагента записан прямо в основной файл (строки с isSidechain: true), его стартовый промпт — это user-строка. Так устроены фикстура test/fixtures/turn-accounting/session.jsonl и тест из #69 «a sidechain user line opens no turn». Граница хода у хука (isTurnBoundary) sidechain не исключает, поэтому хук и колокол на этом промпте обнуляют ход. Панель, где ChunkBuilder отбрасывает sidechain-строки, считает ход целиком.

перечитано за ход
хук (analyzeTurn) 6M
колокол (бакет хода) 6M
панель Re-read 12M

При бюджете 10M хук не запрещает вызовы, колокол молчит, а панель показывает 12M. Обещания, которые это нарушает:

Не проверено: пишет ли текущий Claude Code сабагентов в основной файл, а не в отдельные файлы. Фикстура проекта и тест #69 исходят из того, что пишет.

Как проверить (один раз, ~1 мин)

Из корня репозитория:

cat > /tmp/repro-sidechain.mts <<'EOF'
import { mkdtempSync, readFileSync, 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; };
// ход с промптом сабагента прямо в основном файле (isSidechain: true), бюджет хода 10M
const BUDGET = 10_000_000;
const lines = [
  user('fix the parser'),
  ai(tool('t1', 'Task', { description: 'find bug', prompt: 'find the parser bug' }), 6_000_000, 30, { rid: 'm1', mid: 'mm1' }),
  user('find the parser bug', { isSidechain: true }),
  ai(text('found it'), 1_000_000, 30, { rid: 's1', mid: 'ms1' }, { isSidechain: true }),
  result('t1'),
  ai(text('fixed'), 6_000_000, 30, { rid: 'm2', mid: 'mm2' }),
];
const f = write(lines);
const { analyzeTurn } = await load('scripts/turn-accounting.mjs');
const jsonl = await load('src/main/utils/jsonl.ts');
const { TurnBudgetDetector } = await load('src/main/utils/loopDetection.ts');
const { ChunkBuilder } = await load('src/main/services/analysis/ChunkBuilder.ts');
const { transformChunksToConversation } = await load('src/renderer/utils/groupTransformer.ts');
const { sumTurnReread } = await load('src/renderer/utils/contextTracker.ts');
const msgs = await jsonl.parseJsonlFile(f);
const det = new TurnBudgetDetector();
const alert = msgs.some((m: object) => det.feed('f', [m], BUDGET) !== null);
const groups = transformChunksToConversation(new ChunkBuilder().buildChunks(msgs, []), [], false).items.filter((i: { type: string }) => i.type === 'ai');
const row = {
  'хук (analyzeTurn)': analyzeTurn(readFileSync(f, 'utf8').trim().split('\n').reverse()).spent,
  'колокол (бакет хода)': det.currentTurnTotal('f'),
  'панель Re-read': sumTurnReread(groups[0].group.responses).tokens,
};
console.table(row);
console.log(`хук запретит вызовы: ${row['хук (analyzeTurn)'] >= BUDGET}; колокол позвонит: ${alert}; панель показывает ${row['панель Re-read'] / 1e6}M при бюджете ${BUDGET / 1e6}M`);
console.log(row['хук (analyzeTurn)'] !== row['панель Re-read'] ? 'ВОСПРОИЗВЕДЕНО: промпт сабагента обнуляет ход у хука и колокола' : 'OK');
EOF
pnpm exec tsx /tmp/repro-sidechain.mts

Правильно: три одинаковых числа (12M), хук запрещает вызовы, колокол звонит. Сейчас: 6M / 6M / 12M, хук запретит вызовы: false; колокол позвонит: false, строка ВОСПРОИЗВЕДЕНО.

Где в коде (гипотеза)

  • scripts/turn-accounting.mjs:208 — isTurnBoundary = isUserChunkLine || isCompactSummary, а isUserChunkLine не смотрит на isSidechain.
  • Колокол: src/main/utils/loopDetection.ts:342 обнуляет бакет по isTurnBoundary. Номер хода при этом считается без sidechain (строка 337) — то есть номер верный, а сумма обнулена.
  • Счётчик ходов в сайдбаре и чипы sidechain исключают (jsonl.ts:545).

Что сделать

  1. Исключить sidechain-строки из границы хода в каноне (isTurnBoundary), как это уже сделано для счётчика ходов и чипов.
  2. Убедиться, что существующий parity-тест остаётся зелёным.

Acceptance


  • Проверено человеком: воспроизводится / не воспроизводится

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions