Глава 3: Состояние — Двухслойная архитектура

Глава 2 проследила bootstrap-пайплайн от старта процесса до первого рендера. К концу у системы было полностью настроенное окружение. Но настроенное что именно? Где живёт session ID? Текущая модель? История сообщений? Учёт стоимости? Режим разрешений? Где живёт состояние и почему оно живёт именно там?

Любое долго живущее приложение рано или поздно сталкивается с этим вопросом. Для простого CLI-утилита ответ тривиален — несколько переменных в main(). Но Агент — не простой CLI-утилита. Это React-приложение, рендеримое через Ink, с жизненным циклом процесса, длящимся часами, системой plugins, которая загружается в произвольные моменты, API-слоем, который должен собирать prompts из закэшированного контекста, трекером стоимости, переживающим перезапуски процесса, и десятками инфраструктурных модулей, которым нужно читать и писать общие данные, не импортируя друг друга.

Наивный подход — один глобальный store — ломается мгновенно. Если бы трекер стоимости обновлял тот же store, который вызывает React-перерисовки, каждый API-вызов запускал бы полную reconciliation дерева компонентов. Инфраструктурные модули (bootstrap, построение контекста, учёт стоимости, telemetry) не могут импортировать React. Они работают до монтирования React. Они работают после размонтирования React. Они работают в контекстах, где вообще нет дерева компонентов. Поместить всё в store, зависящий от React, означало бы создать циклические зависимости по всему графу импортов.

Агент решает это двухслойной архитектурой: изменяемый process singleton для инфраструктурного состояния и минимальный реактивный store для UI-состояния. Эта глава объясняет оба слоя, систему побочных эффектов, которая их связывает, и поддерживающие подсистемы, опирающиеся на этот фундамент. Каждая последующая глава предполагает, что вы понимаете, где живёт состояние и почему оно живёт именно там.


3.1 Состояние bootstrap — процессный singleton

Почему нужен изменяемый singleton

Модуль bootstrap state (bootstrap/state.ts) — это один изменяемый объект, создаваемый один раз при старте процесса:

const STATE: State = getInitialState()

Комментарий над этой строкой гласит: AND ESPECIALLY HERE. Две строки выше определения типа: DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE. Эти комментарии звучат как инженеры, которые на собственном опыте узнали цену неуправляемого глобального объекта.

Изменяемый singleton — правильный выбор по трём причинам. Во-первых, bootstrap-состояние должно быть доступно до инициализации любого фреймворка — до монтирования React, до создания store, до загрузки plugins. Инициализация на уровне модуля — единственный механизм, гарантирующий доступность в момент импорта. Во-вторых, данные по своей природе привязаны к процессу: session IDs, счётчики telemetry, аккумуляторы стоимости, закэшированные пути. Здесь нет осмысленного «предыдущего состояния», которое нужно сравнивать, нет подписчиков, которых надо уведомлять, нет истории отмены. В-третьих, модуль должен быть листом в графе импортов. Если бы он импортировал React, или store, или любой сервисный модуль, это создало бы циклы, ломающее bootstrap-последовательность из главы 2. Поскольку он зависит только от utility-типов и node:crypto, его можно импортировать откуда угодно.

Примерно 80 полей

Тип State содержит примерно 80 полей. Небольшая выборка показывает широту охвата:

Идентичность и путиoriginalCwd, projectRoot, cwd, sessionId, parentSessionId. originalCwd разрешается через realpathSync и NFC-нормализуется при старте процесса. Он никогда не меняется.

Стоимость и метрикиtotalCostUSD, totalAPIDuration, totalLinesAdded, totalLinesRemoved. Они монотонно накапливаются в течение сессии и сохраняются на диск при выходе.

Telemetrymeter, sessionCounter, costCounter, tokenCounter. Handles OpenTelemetry, все nullable (null, пока telemetry не инициализируется).

Конфигурация моделиmainLoopModelOverride, initialMainLoopModel. Override устанавливается, когда пользователь меняет модели в середине сессии.

Флаги сессииisInteractive, kairosActive, sessionTrustAccepted, hasExitedPlanMode. Булевы флаги, которые управляют поведением на протяжении сессии.

Оптимизация кэшаpromptCache1hAllowlist, promptCache1hEligible, systemPromptSectionCache, cachedClaudeMdContent. Они существуют, чтобы предотвращать лишние вычисления и лишние пробои prompt cache.

Паттерн getter/setter

Объект STATE никогда не экспортируется. Весь доступ идёт через примерно 100 отдельных функций getter и setter:

// Псевдокод — иллюстрирует паттерн
export function getProjectRoot(): string {
  return STATE.projectRoot
}

export function setProjectRoot(dir: string): void {
  STATE.projectRoot = dir.normalize('NFC')  // NFC-нормализация на каждом setter для пути
}

Этот паттерн обеспечивает инкапсуляцию, NFC-нормализацию на каждом setter для пути (что предотвращает Unicode-несовпадения на macOS), сужение типов и изоляцию bootstrap. Цена — многословность: сотня функций на восемьдесят полей. Но в кодовой базе, где одно случайное изменение может сорвать prompt cache на 50 000 токенов, победу берёт явность.

Паттерн signal

Bootstrap не может импортировать listeners (он лист DAG), поэтому использует минимальный pub/sub-примитив createSignal. Сигнал sessionSwitched имеет ровно одного потребителя: concurrentSessions.ts, который синхронизирует PID-файлы. Сигнал экспортируется как onSessionSwitch = sessionSwitched.subscribe, позволяя вызывающим сторонам регистрироваться, не заставляя bootstrap знать, кто они такие.

Пять липких latch’ей

Самые тонкие поля в bootstrap state — пять булевых latch’ей, следующих одному и тому же паттерну: как только feature впервые активируется в течение сессии, соответствующий флаг остаётся true до конца сессии. У всех одна причина существования: сохранение prompt cache.

Claude API поддерживает server-side prompt caching. Когда последовательные запросы разделяют один и тот же префикс системного prompt, сервер переиспользует закэшированные вычисления. Но ключ кэша включает HTTP-заголовки и поля тела запроса. Если beta header появляется в запросе N, но отсутствует в N+1, кэш пробивается — даже если содержимое prompt идентично. Для system prompt, превышающего 50 000 токенов, промах по кэшу дорог.

Пять latch’ей:

LatchЧто он предотвращает
afkModeHeaderLatchedПереключение auto mode через Shift+Tab включает и выключает AFK beta header
fastModeHeaderLatchedВход и выход из cooldown fast mode переключают fast mode header
cacheEditingHeaderLatchedИзменения удалённого feature flag пробивают кэш каждого активного пользователя
thinkingClearLatchedСрабатывает при подтверждённом промахе кэша (>1h idle). Не даёт повторному включению thinking blocks пробивать только что прогретый кэш
pendingPostCompactionОдноразовый флаг для telemetry: отличает промахи кэша, вызванные компакцией, от промахов из-за TTL

Все пять используют трёхсостояний тип: boolean | null. Начальное значение null означает «ещё не оценено». true означает «защёлкнуто». Они никогда не возвращаются к null или false после того, как установлены в true. Это и есть определяющее свойство latch.

Паттерн реализации:

function shouldSendBetaHeader(featureCurrentlyActive: boolean): boolean {
  const latched = getAfkModeHeaderLatched()
  if (latched === true) return true       // Уже защёлкнуто — всегда отправляем
  if (featureCurrentlyActive) {
    setAfkModeHeaderLatched(true)          // Первая активация — защёлкнуть
    return true
  }
  return false                             // Ни разу не активировалось — не отправляем
}

Почему бы просто не отправлять всегда все beta headers? Потому что заголовки — часть ключа кэша. Отправка нераспознанного заголовка создаёт другое пространство кэша. Latch гарантирует, что вы входите в пространство кэша только тогда, когда оно действительно нужно, а затем остаётесь в нём.


3.2 AppState — реактивный store

Реализация в 34 строки

Store UI-состояния живёт в state/store.ts:

Реализация store занимает примерно 30 строк: замыкание над переменной state, проверка равенства через Object.is для предотвращения ложных обновлений, синхронное уведомление listeners и callback onChange для побочных эффектов. Каркас выглядит так:

// Псевдокод — иллюстрирует паттерн
function makeStore(initial, onTransition) {
  let current = initial
  const subs = new Set()
  return {
    read:      () => current,
    update:    (fn) => { /* guard через Object.is, потом notify */ },
    subscribe: (cb) => { subs.add(cb); return () => subs.delete(cb) },
  }
}

Тридцать четыре строки. Никакого middleware, никаких devtools, никакого time-travel debugging, никаких action types. Только замыкание над изменяемой переменной, Set listeners и проверка Object.is. Это Zustand без библиотеки.

Решения, которые заслуживают внимания:

Паттерн updater-функции. Нет setState(newValue) — только setState((prev) => next). Каждое изменение получает текущее состояние и должно породить следующее, устраняя баги устаревшего состояния при конкурентных изменениях.

Проверка равенства Object.is. Если updater возвращает ту же ссылку, изменение — no-op. Никакие listeners не срабатывают. Никакие побочные эффекты не выполняются. Это критично для производительности — компоненты, которые делают spread и set без фактического изменения значения, не вызывают перерисовки.

onChange срабатывает до listeners. Опциональный callback onChange получает и старое, и новое состояние и срабатывает синхронно до уведомления любого подписчика. Это используется для побочных эффектов (раздел 3.4), которые должны завершиться до того, как UI перерисуется.

Никакого middleware, никаких devtools. Это не упущение. Когда store требует ровно три операции (get, set, subscribe), проверки Object.is и синхронного onChange, 34 строки своего кода лучше, чем зависимость. Вы контролируете точную семантику. Полную реализацию можно прочитать за тридцать секунд.

Тип AppState

Тип AppState (~452 строки) — это форма всего, что UI нужно для рендера. Он обёрнут в DeepImmutable<> для большинства полей, с явными исключениями для полей, содержащих типы функций:

export type AppState = DeepImmutable<{
  settings: SettingsJson
  verbose: boolean
  // ... ещё ~150 полей
}> & {
  tasks: { [taskId: string]: TaskState }  // Содержит abort controllers
  agentNameRegistry: Map<string, AgentId>
}

Тип пересечения позволяет большинству полей быть глубоко неизменяемыми, при этом исключая поля, которые хранят функции, Maps и mutable refs. Полная immutability — это значение по умолчанию, с хирургически точными люками там, где типовая система спорит с runtime-семантикой.

Интеграция с React

Store интегрируется с React через useSyncExternalStore:

// Стандартный React-паттерн — useSyncExternalStore с selector
export function useAppState<T>(selector: (state: AppState) => T): T {
  const store = useContext(AppStoreContext)
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
  )
}

Selector должен возвращать существующую подструктуру (а не заново созданный объект), чтобы сравнение Object.is предотвратило лишние перерисовки. Если вы напишете useAppState(s => ({ a: s.a, b: s.b })), каждый render будет создавать новую ссылку на объект, и компонент будет перерисовываться при каждом изменении состояния. Это то же ограничение, с которым сталкиваются пользователи Zustand — более дешёвые сравнения, но автор selector должен понимать идентичность ссылок.


3.3 Как связаны два слоя

Два слоя обмениваются данными через явные, узкие интерфейсы.

Bootstrap state течёт в AppState во время инициализации: getDefaultAppState() читает настройки с диска (который bootstrap помог найти), проверяет feature flags (которые bootstrap оценил) и устанавливает начальную модель (которую bootstrap разрешил из CLI-аргументов и настроек).

AppState возвращается в bootstrap state через побочные эффекты: когда пользователь меняет модель, onChangeAppState вызывает setMainLoopModelOverride() в bootstrap. Когда меняются настройки, caches credentials в bootstrap очищаются.

Но два слоя никогда не разделяют одну и ту же ссылку. Модуль, который импортирует bootstrap state, не должен знать о React. Компонент, читающий AppState, не должен знать о process singleton.

Конкретный пример проясняет поток данных. Когда пользователь вводит /model claude-sonnet-4:

  1. Обработчик команды вызывает store.setState(prev => ({ ...prev, mainLoopModel: 'claude-sonnet-4' }))
  2. Проверка Object.is у store обнаруживает изменение
  3. Срабатывает onChangeAppState, обнаруживает смену модели, вызывает setMainLoopModelOverride() (обновляет bootstrap) и updateSettingsForSource() (сохраняет на диск)
  4. Срабатывают все подписчики store — React-компоненты перерисовываются и показывают новое имя модели
  5. Следующий API-вызов читает модель из getMainLoopModelOverride() в bootstrap state

Шаги 1–4 синхронны. API client на шаге 5 может запуститься через секунды. Но он читает из bootstrap state (обновлённого на шаге 3), а не из AppState. Это и есть двухслойная передача: UI-store — источник истины для того, что выбрал пользователь, а bootstrap state — источник истины для того, что использует API client.

Свойство DAG — bootstrap зависит ни от чего, AppState зависит от bootstrap для init, React зависит от AppState — обеспечивается правилом ESLint, которое запрещает bootstrap/state.ts импортировать модули вне разрешённого набора.


3.4 Побочные эффекты: onChangeAppState

Callback onChange — это место, где два слоя синхронизируются. Каждый вызов setState запускает onChangeAppState, который получает и предыдущее, и новое состояние и решает, какие внешние эффекты нужно запустить.

Синхронизация режима разрешений — основной сценарий. До появления этого централизованного обработчика режим разрешений синхронизировался с удалённой сессией (CCR) только по 2 из 8+ путей изменения. Остальные шесть — циклическое переключение через Shift+Tab, опции диалогов, slash-команды, rewind, bridge callbacks — все меняли AppState, не уведомляя CCR. Внешние метаданные рассинхронизировались.

Исправление: перестать распылять уведомления по точкам изменения и вместо этого вычислять diff в одном месте. Комментарий в исходнике перечисляет все сломанные пути изменения и отмечает, что «разбросанные callsites выше не требуют никаких изменений». В этом и состоит архитектурное преимущество централизованных побочных эффектов — покрытие структурное, а не ручное.

Изменения модели удерживают bootstrap state в синхроне с тем, что рисует UI. Изменения настроек очищают caches credentials и повторно применяют переменные окружения. Тумблер verbose и expanded view сохраняются в глобальную конфигурацию.

Паттерн — централизованные побочные эффекты на diffable-переходе состояния — по сути является Observer pattern, применённым к гранулярности diff состояния, а не отдельных событий. Он масштабируется лучше, чем разбросанные эмиссии событий, потому что число побочных эффектов растёт намного медленнее, чем число точек изменения.


3.5 Построение контекста

Три мемоизированные async-функции в context.ts строят контекст системного prompt, который добавляется к каждому разговору. Каждая вычисляется один раз за сессию, а не на каждый ход.

getGitStatus запускает пять git-команд параллельно (Promise.all), формируя блок с текущей веткой, веткой по умолчанию, недавними коммитами и состоянием working tree. Флаг --no-optional-locks не даёт git брать write locks, которые могли бы мешать параллельным git-операциям в другом терминале.

getUserContext загружает содержимое CLAUDE.md и кэширует его в bootstrap state через setCachedClaudeMdContent. Этот кэш разрывает циклическую зависимость: классификатор auto-mode нуждается в содержимом CLAUDE.md, но загрузка CLAUDE.md проходит через файловую систему, затем через permissions, а затем вызывает классификатор. Кэширование в bootstrap state (лист DAG) разрывает цикл.

Все три функции контекста используют Lodash memoize (вычислить один раз, кэшировать навсегда), а не TTL-based кэширование. Логика такая: если бы git status пересчитывался каждые 5 минут, изменение пробило бы server-side prompt cache. В самом системном prompt модель даже получает инструкцию: «Это git status в начале разговора. Обратите внимание, что это состояние — снимок во времени».


3.6 Учёт стоимости

Каждый API-ответ проходит через addToTotalSessionCost, который накапливает использование по моделям, обновляет bootstrap state, сообщает в OpenTelemetry и рекурсивно обрабатывает использование advisor tools (вложенные вызовы модели внутри ответа).

Состояние стоимости переживает перезапуски процесса через сохранение и восстановление в файл конфигурации проекта. Session ID используется как защита — costs восстанавливаются только если сохранённый session ID совпадает с сессией, которую возобновляют.

Гистограммы используют reservoir sampling (алгоритм R), чтобы держать память ограниченной и при этом точно представлять распределения. Reservoir из 1 024 элементов даёт p50, p95 и p99 процентиля. Почему не простое скользящее среднее? Потому что среднее скрывает форму распределения. Сессия, где 95% API-вызовов занимают 200 мс, а 5% — 10 секунд, имеет тот же average, что и сессия, где все вызовы занимают 690 мс, но пользовательский опыт радикально разный.


3.7 Что мы узнали

Кодовая база выросла из простого CLI в систему с ~450 строками определений state type, ~80 полями process state, системой побочных эффектов, несколькими границами сохранения и latch’ами оптимизации кэша. Ничто из этого не проектировалось заранее. Липкие latch’и были добавлены, когда пробой кэша стал измеримой проблемой стоимости. Обработчик onChange был централизован, когда выяснилось, что 6 из 8 путей синхронизации permissions сломаны. Кэш CLAUDE.md появился, когда возникла циклическая зависимость.

Это естественный путь роста состояния в сложном приложении. Двухслойная архитектура даёт достаточно структуры, чтобы удержать этот рост — новые поля bootstrap не влияют на React-рендеринг, новые поля AppState не создают циклы импортов — и при этом остаётся достаточно гибкой, чтобы поддерживать паттерны, которые не были предусмотрены в исходном дизайне.


3.8 Сводка по архитектуре состояния

СвойствоBootstrap StateAppState
РасположениеSingleton на уровне модуляReact context
ИзменяемостьMutable через settersНеизменяемые снимки через updater
ПодписчикиSignal (pub/sub) для конкретных событийuseSyncExternalStore для React
ДоступностьВо время импорта (до React)После монтирования provider
СохранениеОбработчики выхода процессаЧерез onChange на диск
РавенствоN/A (императивные чтения)Проверка ссылок Object.is
ЗависимостиЛист DAG (ничего не импортирует)Импортирует типы со всей кодовой базы
Сброс в тестахresetStateForTests()Создание нового экземпляра store
Основные потребителиAPI client, трекер стоимости, сборщик контекстаReact-компоненты, побочные эффекты

Примените это

Разделяйте состояние по способу доступа, а не по домену. Session ID принадлежит singleton не потому, что это «инфраструктура» в абстрактном смысле, а потому, что его нужно читать до монтирования React и писать без уведомления подписчиков. Режим разрешений принадлежит реактивному store, потому что его изменение должно вызывать перерисовки и побочные эффекты. Позвольте способу доступа определять слой — и архитектура сложится естественно.

Паттерн липкого latch. Любая система, взаимодействующая с кэшем (prompt cache, CDN, query cache), сталкивается с одной и той же проблемой: переключатели feature, которые меняют ключ кэша в середине сессии, вызывают инвалидацию. После активации feature её вклад в ключ кэша остаётся активным на всю сессию. Трёхсостояний тип (boolean | null, означающий «не оценено / включено / больше никогда не выключается») делает намерение самодокументируемым. Особенно полезно, когда кэш не находится под вашим контролем.

Централизуйте побочные эффекты на diff состояния. Когда несколько кодовых путей могут менять одно и то же состояние, не распыляйте уведомления по местам изменения. Подвесьте onChange callback store и определяйте, какие поля изменились. Покрытие становится структурным (любое изменение запускает эффект), а не ручным (каждое место изменения должно помнить об уведомлении).

Предпочитайте 34 строки собственного кода библиотеке, которой не управляете. Когда ваши требования — это ровно get, set, subscribe и callback на изменение, минимальная реализация даёт полный контроль над семантикой. В системе, где баги управления состоянием могут стоить реальных денег, такая прозрачность имеет ценность. Ключевая идея — понять, когда библиотека вам не нужна.

Используйте выход процесса как границу сохранения осознанно. Несколько подсистем сохраняют состояние при завершении процесса. Компромисс здесь явный: аварийное завершение без graceful shutdown (SIGKILL, OOM) теряет накопленные данные. Это приемлемо, потому что данные диагностические, а не транзакционные, и запись на диск при каждом изменении состояния была бы слишком дорогой для счётчиков, которые увеличиваются сотни раз за сессию.


Двухслойная архитектура, установленная в этой главе — bootstrap singleton для инфраструктуры, реактивный store для UI, и побочные эффекты, связывающие их, — это фундамент, на котором строится каждая последующая глава. Цикл разговора (глава 4) читает контекст из memoized builders. Система инструментов (глава 5) проверяет permissions из AppState. Система агентов (глава 8) создаёт записи задач в AppState, одновременно отслеживая стоимость в bootstrap state. Понимание того, где живёт состояние и почему, — необходимое условие для понимания того, как работают все эти системы.

Некоторые поля пересекают границу. Основная модель цикла существует в обоих слоях: mainLoopModel в AppState (для рендеринга UI) и mainLoopModelOverride в bootstrap state (для потребления API client). Обработчик onChangeAppState синхронизирует их. Это дублирование — цена двухслойного разделения. Но альтернатива — заставить API client импортировать React store или заставить React-компоненты читать из process singleton — нарушила бы направление зависимостей, которое удерживает архитектуру в рабочем состоянии. Небольшое количество контролируемого дублирования, связанное централизованной точкой синхронизации, лучше, чем запутанный граф зависимостей.