Тема
Operational Runbook — pm-engine
SSOT для pre-flight / deploy / smoke / rollback. Растворил файлы: vps-prod-runbook-2026-05-06.md, smoke-test-runbook.md, dashboard-smoke-runbook-2026-05-03.md, multi-wallet-smoke-runbook-2026-05-03.md, production-readiness-checklist.md.
Боевой бинарь — pm-engine; дашборд — pm-engine-dashboard (единственный дашборд, порт :31337). Wire-контракт SSE по-прежнему живёт в крейте pm-bot-events (имя крейта историческое, менять не нужно).
Monitoring / alerts — alerts-and-monitoring.md. Local-Bot Mode (Reset / rotate session) — local-dashboard-runbook.md. RTDS cutover (toxic-flow gates) — rtds-cutover-runbook.md. Probability-rank exit config/state migration — rank-exit-migration-runbook.md; это отдельный операторский шаг до соответствующего deploy.
Контекст
Per ADR-024 единственная активная стратегия — directional (Harvester). Остальные алгоритмы ARCHIVED/DEFERRED; этот runbook покрывает только Harvester-shaped операции. Исторические multi-strategy smoke процедуры — docs/history/.
VPS: deploy@poly.grot.app (Hetzner, Finland; A-запись → 204.168.229.194, IP годится как запасной вариант при проблемах с DNS), root /home/deploy/pm-backtest, engine-instance harvester-engine (контейнеры pm-engine-harvester-engine + pm-engine-dashboard-harvester-engine). Публичный дашборд — https://harvester-engine.grot.app (см. §Deploy). Прежний harvester-live.grot.app не существует: имя не резолвится, блока в tunnel/Caddyfile нет (проверено 2026-08-03).
Pre-flight checklist
Wallet + credentials
- [ ]
.envсуществует (или per-instance вinstances/harvester-engine/). Bootstrap:pm-cli init --chain-id 137. - [ ] Каждый
[REQUIRED]инфраструктурный env-key изenv/.env.exampleset — pm-engine fail-fast на startup. Strategy-knobs (directional.*) НЕ в env — они живут только вconfig.db(ADR-015); env-поверхность движка — это инфра+селекторы (PM_ENGINE_*,POLYGON_RPC_URL, ADR-015 селекторы,BOT_TG_*), не параметры стратегии. - [ ] CLOB credentials (
POLY_API_KEY,POLY_SECRET,POLY_PASSPHRASE) generated черезpm-cli init --private-key 0xYOUR_KEY. - [ ] V2 cutover wallet (post-2026-04-28): либо single-pass
pm-cli approve --with-wrap --wrap-all --private-key $KEY --rpc-url $RPC(USDC.e→pUSD wrap + CTF.setApprovalForAll + pUSD.approve для обоих V2 Exchanges), либо plainpm-cli approveесли pUSD уже wrapped, либо standalonepm-cli wrap --amount <X>. - [ ] On-chain sanity:
pUSD.balanceOf(wallet) > 0+ 4 V2 approvals (CTF.isApprovedForAll + pUSD.allowance MAX для Std + NegRisk V2 Exchanges). - [ ] Private key encrypted (никогда plaintext в
.env). ADR-015wallets.db(Argon2id + AES-256-GCM).
RTDS wire validation
Standalone RTDS wire-probe (legacy pm-bot one-shot harness / rtds-probe bin) удалён вместе с крейтом pm-bot — в pm-engine one-shot-инструмента нет. RTDS по-прежнему consum'ится движком (crates/pm-engine/src/adapters/rtds/), toxic-flow гейт теперь из config.db (directional.toxic_flow, поля log_only / active_cancel_enabled), НЕ из env. Pre-flight-проверка wire-контракта в pm-engine — включить ENGINE_RTDS_ENABLED=true и наблюдать rtds_* метрики соединения (см. ../integrations/rtds.md).
Strategy + budget (Harvester)
- [ ] Стратегия
directionalактивна (выбор строки config.db через активная строкаconfig.dbдля кошелька инстанса). Все knobs — вconfig.db(strategies.definitionJSON, ADR-015), НЕ в env. Harvester semantics: entry [0.95, 0.98] best-ask, exit GTC@0.99 on first fill, DCA $5/mkt, TTL 600s, stop-loss 0.80 / cooldown 5h / debounce 5s, redeem ~10min hybrid (project_harvester_semantics). - [ ] Per-market cap —
directional.budget_per_marketвconfig.db($5–$10, не выше$10для первого live). Env-overrideBOT_BUDGET_PER_MARKETdeprecated и в env жить не должен. - [ ] Continuous LIVE startup логирует обычный live mode, не probe. Ордер выше $5, но внутри router reservation, не должен давать
ProbeFuseViolation;SubmitAuthorizationViolationозначает локальный integrity defect и требует rollback, а не уменьшения корректного budget. - [ ] Max-hours-to-expiry knob — из
config.dbdirectional.*, не из env (feedback_max_hours_expiry). - [ ] Share-knobs at-rest unit (pm-engine).
min_size_shares/shares_per_market_max(иtrade_confirm_dust_shares) хранятся вconfig.dbв micro-shares (Shares = shares × 10^6, on-chain CTFbalanceOfprecision). Оператор авторит ЦЕЛЫЕ доли — UI scaling-seam (FieldKind 'shares') масштабирует whole → micro на submit и обратно на display, аналогично тому как поля*_micro_usdпоказывают $ при хранении μUSD. Публикуемый пример «min_size_shares = 5» означает at-rest5_000_000. Масштабирует whole → micro ТОЛЬКО UI (FieldKind 'shares'). CLIstrategy add --config <file.json>НЕ масштабирует — он принимает уже-at-rest micro nestedDirectionalConfigкак есть (домножение на 10^6 удвоило бы scale каждого export'а / фикстуры). Поэтому: авторь share-knob'ы через UI (вводишь целые доли), либо подавай--configJSON уже в micro. Ручное редактированиеconfig.dbцелыми долями (5 вместо 5_000_000) убивает min-size gate / max-cap на 6 порядков. - [ ] System Restart/deploy НЕ перезаписывает
config.db. Кнопка «Рестарт системы» всегда делает exit(0) + Docker restart и перечитывает config; cancel-all, exit-all и wipe engine.db выбираются отдельно. Wipe разрешён только при durable flat. Legacy/resetсохраняет старую последовательность cancel-all + wipe + restart.config.db(strategy knobs, включая share-knobs выше) ни одним путём не трогается. После деплоя/reset проверь, что share-knobs вconfig.dbостались в micro-at-rest (если config.db пересоздавался — пере-авторь через scaling UI/CLI, не вписывай целые доли руками). - [ ] Проверены latest / published / loaded версии config.db. Save draft не меняет следующий старт; publish не делает hot reload и не перезапускает engine.
published != loadedозначает ожидающий контролируемый restart. Перед restart применить обычный drain/flat барьер; после старта сверить version и SHA-256 в/api/local/seed. - [ ] Для первого v3 cutover выполнен отдельный backup/hash/rollback drill из strategy-config-v3-migration-runbook.md.
- [ ] Колонка
budget_limit_usdengine-строк = derived-проекция (engine-dashboard-budget-truthD1). Единственный источник бюджета движка —definition.total_budget_micro_usd; POST/PUT pm-engine-dashboard выводят колонку автоматически (= total_budget_micro_usd / 1e6), руками её НЕ редактировать. После schema v3 definition/wallet запрещено чинить прямым SQL или legacy update: создать полную типизированную версию и явно опубликовать её. Публикация атомарно пересоберёт derived-проекцию. Верификация: «Эффективный бюджет» в списке стратегий = карточка «Бюджет» на главной =Budget:в следующем TG-footer (одно число).
Safe defaults
- [ ] Toxic-flow гейт safe: в
config.dbdirectional.toxic_flowполяlog_only = trueиactive_cancel_enabled = false(дефолтная безопасная комбинация — только лог, без отмен). Это config.db, НЕ env: legacyRTDS_TOXIC_FLOW_LOG_ONLY/RTDS_TOXIC_FLOW_ACTIVE_CANCEL_ENABLEDв pm-engine не читаются. - [ ] Webhook-канал (ADR-003) удалён вместе с pm-bot — никаких
WEBHOOK_*в env, отдельного opt-in нет.
Tests (CI gate)
- [ ]
cargo-audit 0.22.2установлен черезcargo install cargo-audit --version 0.22.2 --locked. - [ ]
scripts/cargo-supply-chain-gate.shобновил RustSec и подтвердил корневойCargo.lock; недоступная база считается отказом, а не пропуском. Единственное исключение —RUSTSEC-2026-0235: до него скрипт обязан доказать пустой all-features/all-targets inverse tree дляrkyv@0.7.46; ошибка или непустой путь блокируют сборку до вызова аудитора. - [ ]
cargo build --locked --workspace --releasegreen. - [ ]
cargo test --locked --workspacegreen. - [ ]
cargo clippy --locked --workspace --all-targets -- -D warnings0 warnings.
Допущенные предупреждения RustSec и стоимость их закрытия (замер 2026-09-14)
cargo audit в воротах цепочки поставки и в deploy-vps.sh показывает три предупреждения категории informational. Ворота их не считают уязвимостями по замыслу: спека docs/history/openspec/changes/2026-08-31-harden-cargo-supply-chain-gate/specs/cargo-supply-chain-gate/spec.md прямо требует, чтобы informational-предупреждения оставались видимыми, но не переквалифицировались в уязвимости. Построчного списка исключений в scripts/cargo-supply-chain-gate.sh нет — единственный там --ignore (RUSTSEC-2026-0235 для rkyv) относится к отдельному случаю: исключение применяется только после того, как скрипт на каждом запуске заново доказывает пустой all-features/all-targets inverse tree для rkyv@0.7.46, а не выдано один раз навсегда. Этот подраздел — список причин, почему остальные три предупреждения не закрыты.
| Крейт / версия | RustSec ID | Категория | Итог |
|---|---|---|---|
derivative 2.2.0 | RUSTSEC-2024-0388 | unmaintained | путь в граф компиляции не существует — закрывать нечего |
paste 1.0.15 | RUSTSEC-2024-0436 | unmaintained | зависимость alloy-rs/core; замена отклонена авторами alloy |
lru 0.16.4 | RUSTSEC-2026-0253 | unsound (use-after-free при панике в pop()) | исправление есть только в alloy 2.x; уязвимый путь наш код не вызывает |
1. derivative 2.2.0 (RUSTSEC-2024-0388, unmaintained). Транзитивная зависимость по пути alloy-primitives → ruint 1.20.0 → ark-ff 0.3.0/0.4.2 → derivative, но только под опциональной фичей ark-ff у ruint, которую не включает ни alloy-primitives, ни nybbles (проверено cargo metadata). cargo tree -i derivative@2.2.0 даже с --all-features --target all не находит ни одного пути — пакет присутствует в Cargo.lock мёртвой записью и не компилируется ни в один бинарь. Исправленной версии нет, крейт архивирован. Закрывать нечего.
2. paste 1.0.15 (RUSTSEC-2024-0436, unmaintained). Транзитивная proc-macro зависимость alloy-primitives, alloy-json-abi и syn-solidity 1.7.x; свежие версии 1.7.3 по-прежнему требуют paste ^1.0 (манифесты crates.io). Upstream alloy-rs/core осознанно отказался от замены: issue #897 закрыт мейнтейнером с формулировкой, что крейт paste завершён и делает свою работу; PR #1086 «Replaced unmaintained paste crate with pastey» (2026-03-16) закрыт без слияния — с пометкой, что предупреждение RustSec по нему игнорируется, а другие зависимости проекта уже используют paste. Собственная прослойка ([patch.crates-io] на локальный крейт, реэкспортирующий макрос форка pastey) технически возможна, но не принята: не подтверждено, что cargo audit вообще перестанет видеть paste 1.0.15 (advisory матчится по имени и версии, а [patch] меняет источник пакета, а не его имя и версию); совместимость pastey подтверждена только README форка, CI upstream по PR #1086 не запускался; прослойка осталась бы на нашей поддержке; гейт цепочки поставки path-источники не различает. Решение — не делать.
3. lru 0.16.4 (RUSTSEC-2026-0253, unsound — use-after-free в LruCache::pop() при панике). Транзитивная зависимость по пути alloy 1.8.3 → alloy-provider / alloy-contract 1.8.3 → lru ^0.16. Наш код LruCache не вызывает (rg 'lru::|LruCache' crates/ пуст) — кэш живёт целиком внутри alloy-provider. Исправление (lru >= 0.18.2) есть только начиная с alloy-provider 2.4.0 (PR alloy-rs/alloy#4131, слит 2026-08-11); линия alloy 1.x закончилась на 1.8.3 (2026-03-27), бэкпорта в неё нет.
Стоимость перехода на alloy 2.x (проба 2026-09-14 в отдельном worktree на копии main: alloy = "2.4", cargo update -p alloy, cargo check --workspace --all-targets):
- резолвер поднял
alloy 1.8.3 → 2.4.2и 21 пакет семейства без конфликтов;lruпри этом стал0.18.4; - компиляция остановилась на
pm-sdkс двумя ошибками вcrates/pm-sdk/src/clob/mod.rs(строки 317–318): типажSignerне выполнен для нашегоPrivateKeySigner, методcredentialsуAuthenticationBuilderнедоступен; - причина — внешний
polymarket-client-sdk 0.4.4требуетalloy ^1.6.3, поэтому в графе одновременно оказываются две версии alloy (1.8.3 для клиента CLOB, 2.4.2 для остального кода), и подписант из alloy 2 не реализует типажSignerиз alloy 1, которым типизирован клиент CLOB. Это несовместимость двух мажоров, а не изменение публичного API нашего кода; - на crates.io у
polymarket-client-sdkпоследняя версия — 0.4.4 от 2026-03-17, все версии 0.4.x требуют alloy 1.x, версии на alloy 2 нет; - остальные крейты (
pm-engine,pm-cli,pm-engine-dashboard,pm-observer,pm-analytics) не проверялись — компиляция встала раньше, наpm-sdk; внутри самогоpm-sdkмодулиonchain/provider.rs(FallbackService, сигнатураnew(transports, active_transport_count)одинакова в 1.8.3 и 2.4.2;ProviderBuilder),eip712/,auth/ошибок не дали; - из объявленных breaking changes alloy 2.0.0 (разделение
TransactionBuilder, blob-транзакции,AnyTransactionReceiptкак структура,ChainConfignon-exhaustive,.network()вместо стека filler иGasFiller::default(), fail-fastAnyNetwork) наш код структурно не задевает ни один — единственныйChainConfigв коде наш собственный, адреса контрактов Polygon; открытый вопрос — дефолтный стек filler уProviderBuilder::new()и оценка газа, проверяется только живой транзакцией; - поправка к раннему разбору: утверждение, что
alloy-contractвыпадает из графаalloy-provider 2.x, неверно —alloy-contractзависит отalloy-provider, а не наоборот, и остаётся опциональной зависимостью фасадногоalloyво всех версиях через default features.
Решение (2026-09-14, оператор): переход на alloy 2.x сейчас не делаем. Обходные пути — адаптер между двумя версиями alloy для одного подписанта в клиенте CLOB (две копии alloy в одном бинаре), либо форк или замена polymarket-client-sdk собственной аутентификацией CLOB — это работа класса «карточка» на денежном пути с живой приёмкой approve/wrap/redeem ради снятия одного informational-предупреждения, чей уязвимый метод наш код не вызывает. Условия пересмотра: выход polymarket-client-sdk на alloy 2.x (проверять список версий на crates.io при каждом аудите цепочки поставки), либо переквалификация RustSec любого из трёх ID из informational в vulnerability — тогда ворота остановят выкат сами, как это случилось 2026-09-14 с rustls (RUSTSEC-2026-0285, закрыто обновлением до 0.23.45).
Sign-off (operator)
- [ ] Все pre-cutover items ticked.
- [ ] Smoke output saved/archived.
- [ ] Conservative budget set (
$5–10). - [ ] On-call мониторится первый час.
- [ ] Rollback rehearsed (§Rollback).
Deploy procedure
Топология VPS
deploy@poly.grot.app Debian 13
~/pm-backtest/
├── bin/{pm-engine, pm-engine-capture, pm-engine-dashboard, pm-cli} ← active binaries
├── .deploy/staging/<release-id>/ ← выкаты; подрезается до STAGING_KEEP=3
├── .deploy/manifest/ ← запись о том, ЧТО установлено; НЕ подрезается
├── .deploy/backups/ ← снимки config.db перед рискованными правками
├── spa-engine/dist/ ← SPA дашборда (единственная; Caddy раздаёт её как /spa-engine)
├── shared/.env.shared
├── instances/harvester-engine/ ← боевой инстанс
│ ├── config/{wallets.db, config.db} ← ADR-015 stores
│ ├── data/{captures/, engine.db}
│ ├── .env ← права 600; + CLOB_API_* для pm-cli
│ └── docker-compose.yml
├── instances/spread-mm/ ← остановлен, контейнеров нет
├── tunnel/{docker-compose.yml, Caddyfile, .env}
├── scripts/ ← миграции config.db + new-instance.sh
└── README.md ← короткий указатель на deploy/server/README.md
docker networks: pm-tunnel-net (Caddy↔dashboard↔tunnel), per-instance engine-net.Cron пользователя deploy (ровно одна задача):
*/10— дозапись RSS движка +structure_censusв~/census-trend.log(ручное наблюдение за памятью с 2026-07-24).
Лог ротирует logrotate (/etc/logrotate.d/pm-engine, эталон — deploy/server/logrotate/pm-engine): раз в сутки, 7 поколений, со сжатием.
Чего на VPS НЕТ (и не должно появляться): бинаря pm-analytics (EV-оракул считается локально через forensics.sh ev), spa/dist (принадлежал удалённому pm-dashboard), shared/grids и sweep.sh (replay/sweep — локальный pm-backtest — снят 2026-08-21), Prometheus/Alertmanager. Каталог analytics/ ЕСТЬ: это административные schema/migrations для ClickHouse, а не сервис pm-analytics.
Полный layout — deploy/server/README.md.
Deployment policy
Все prod-деплои — только через scripts/deploy-vps.sh. Никаких ручных scp, mv, ad-hoc *.prev backup'ов. Manual swaps уже приводили к binary backup sprawl и неоднозначности live artefact'а.
bash
scripts/deploy-vps.sh engine # coordinated pm-engine + dashboard + pm-cli + SPA cutover
scripts/deploy-vps.sh resume --release-id <id> # зафиксировать T0 и снять drain принятого release
scripts/deploy-vps.sh engine-bot # pm-engine binary only (restart pm-engine)
scripts/deploy-vps.sh engine-dashboard # pm-engine-dashboard binary + SPA (restart dashboard)
scripts/deploy-vps.sh engine-spa # engine SPA only (no restart)
scripts/deploy-vps.sh engine-capture # TCHS audit-capture sidecar only (see §Audit-capture)
scripts/deploy-vps.sh cleanup-bin # preview; --apply для applyЕсли среда запуска не должна читать системный или пользовательский OpenSSH config, передайте один абсолютный путь всему транспортному слою выката:
bash
PM_DEPLOY_SSH_CONFIG_FILE=/dev/null scripts/deploy-vps.sh engine-spaСкрипт передаёт этот путь в ssh, scp и локальный rsync; файлы настроек SSH он не изменяет. Относительный или нечитаемый путь отклоняется до сборки и сетевых вызовов. Без переменной сохраняется обычное поведение OpenSSH.
config-migrate намеренно ОТКАЗЫВАЕТ: production contract migration нельзя применять отдельно от совместимого набора readers/writers. Для миграционного релиза используется только engine; ручные pm-cli strategy contract-migrate, sqlite3, копирование compose/schema и HTTP resume не входят в release flow.
Скрипт форсит INSTANCE=harvester-engine для engine-таргетов, билдит локально, заливает в ~/pm-backtest/.deploy/staging/<release-id>/, верифицирует SHA-256 на VPS до install, ставит active binaries в ~/pm-backtest/bin/, рестартит выбранный service. Frontend — pnpm (не npm).
Режимы с Rust-сборкой и scripts/gate.sh используют один дочерний namespace protected-operations внутри базового Cargo target. Обычный ручной cargo пишет в базовый namespace и физически не может заменить доказуемый бинарь. Между собой защищённые операции удерживают общий репозиторный flock до завершения cargo build, теста, SHA-256 и загрузки, поэтому второй gate/deploy из другой worktree ждёт. Режимы без Rust-сборки (verify, health, engine-spa, engine-config и другие перечисленные в helper) не меняют target и lock не берут.
Полная цель engine сначала fail-closed сверяет текущий artifact manifest, собирает и staging'ит четыре совместимых артефакта, compose и ClickHouse DDL, затем проверяет копию config.db новым typed reader'ом и репетирует миграции копии engine.db; wallets.db проходит integrity-check. (Прежняя backtest-jobs.db снята 2026-08-21 вместе с заданиями бэктеста.) Только после этого проверяются persistent Drain, open_positions=0, open_orders=0 и unresolved_submits=0, и writers останавливаются.
В остановленном окне .deploy/backups/<release-id>/ получает SQLite-consistent копии всех присутствующих stores, явные маркеры отсутствующих stores, compose, старые binaries, SPA и artifact manifest. Затем скрипт применяет contract/state/ jobs миграции staged-бинариями, ClickHouse schema — admin-role скриптом, ставит артефакты и выполняет strict acceptance по runtime/config identity, SQLite, VictoriaMetrics и ClickHouse run/ingest gaps. Для SPA acceptance отдельно сверяет SHA-256 установленного index.html с /spa-engine/index.html внутри pm-caddy; при доказанном расхождении пересоздаёт только Caddy и принимает выпуск лишь после совпадения. Ошибка до принятия автоматически восстанавливает rollback set и оставляет persistent Drain; SPA восстанавливается внутри существующего dist, чтобы bind mount не остался на брошенном inode.
Принятый release записывается в .deploy/releases/releases.jsonl и current-release.json со state ready-to-resume; engine НЕ снимает drain. Ни deploy-скрипт, ни агент не уполномочены возобновлять торговлю. Только оператор после проверки evidence может взять release-id из финального вывода и запустить:
bash
scripts/deploy-vps.sh resume --release-id <точный-release-id>Команда повторно сверяет release/artifacts/config/run/epoch/flat, ДО HTTP resume синхронно дозаписывает measurement-cutoffs.jsonl с VM baselines, ClickHouse watermarks и authored strategy modes, а после подтверждения пишет единственную active-строку. Именно её cutoff_at — нижняя граница всех форвардных измерений. Подготовленная, но не подтверждённая строка не открывает эпоху; повторный успешный resume для одного release запрещён. engine и resume сериализованы одним VPS lock: конкурентный запуск отказывает до stop writers или HTTP resume. Release id принимается только в формате, который генерирует сам скрипт, и проверяется локально до SSH.
Rollback ClickHouse отличается от SQLite: уже записанный аналитический архив не откатывается. Поэтому автоматический релиз принимает только безопасные additive/проверяемые миграции; destructive engine/type change обязан fail-closed до нового writer (для допустимого пересоздания таблица машинно доказана пустой).
Какой код сейчас торгует
Ответ читается как ФАКТ из записанных величин. Выводить его из времени сборки бинаря, времени файла в ~/pm-backtest/bin/ или имени каталога подготовки (.deploy/staging/<release-id>/) НЕЛЬЗЯ — такой вывод молча врёт в трёх случаях:
- пересборка без правок даёт свежее время файла у СТАРОГО кода: время говорит о моменте сборки, а не о том, что собрано;
- выкат из изменённого рабочего дерева выглядит как чистый коммит: каталог подготовки называется по
HEAD, а незафиксированные правки в имени не отражаются; - восстановление бинаря из копии (в том числе автоматический откат координированного выпуска на
.deploy/backups/<release-id>/) ставит время момента копирования на код прежнего коммита.
Каталог подготовки к тому же недолговечен: STAGING_KEEP=3 подрезает старые, то есть через три выката эта косвенная улика исчезает вовсе.
Штатная проверка — одна команда. Ничего не собирает, не заливает и не перезапускает; из инструментов нужны только ssh и jq:
bash
scripts/deploy-vps.sh verifyОна всегда сверяет запись манифеста выката с контрольной суммой установленного файла. Для двух постоянно работающих контейнерных процессов добавляется третий факт — собственный отпечаток процесса: движок опрашивается по /api/local/seed, dashboard — по своему /health. Оба запроса выполняются через docker exec pm-caddy curl. Для SPA третьим фактом служит SHA-256 /spa-engine/index.html внутри pm-caddy: он обязан совпасть с установленным host-файлом, иначе verify возвращает MISMATCH и ничего не перезапускает. Только для остальных непроцессных артефактов (pm-cli) третий факт неприменим и явно называется NO RUNTIME COMPARISON, а не «процесс не ответил». Все Rust-бинарии всё равно несут fingerprint времени сборки и печатают его через --version.
Исходы по артефакту:
VERDICT: AGREES— запись, установленный файл и процесс сошлись; строка называет работающийcommitиdirty.VERDICT: MISMATCH— расхождение, и строка называет, ЧТО именно разошлось: запись (нечитаема либо неполна / в указателе лежит запись другого артефакта / записи нет вовсе, а движок работает и о себе сообщает), файл (отсутствует по записанному пути / его сумма не та) или процесс (файл установлен, но служба на него не перезапущена). При подменённом файле сверка дополнительно ищет его сумму в журнале выкатов и называет коммит, который этот файл НЕСЁТ.VERDICT: FINGERPRINT UNAVAILABLE— процесс существует, но его endpoint не ответил полным отпечатком (старый бинарь, недоступный endpoint или неполный ответ). Это отдельный исход, не совпадение и не расхождение.NO RUNTIME COMPARISON— у артефакта нет применимой process-поверхности; запись и установленный файл всё равно сверены. Это штатная граница для непроцессных инструментов, а не деградация D8.
Коды возврата: 0 — расхождений нет («отпечаток недоступен» расхождением не считается); 4 — расхождение найдено ЛИБО сверять нечего (записей ещё нет и процесс не ответил); 5 — сверка не выполнена вовсе (манифест не прочитан с хоста). При расхождении последняя строка называет расходящиеся артефакты поимённо.
Три поверхности отпечатка у работающего процесса. Все три берут одну константу, вкомпилированную в бинарь, и потому разойтись не могут:
bash
# 1. Метрика (рецепт проброса — alerts-and-monitoring.md §«Доступ к боевым метрикам»)
curl -s http://127.0.0.1:31350/metrics | grep pm_engine_build_info
# 2. Снимок состояния (проброс к контейнеру дашборда — CLAUDE.md §Browser access, рецепт A)
curl -s http://127.0.0.1:31337/api/local/seed | jq .build_info
# 3. Строка старта в журнале процесса
ssh deploy@poly.grot.app 'docker logs pm-engine-harvester-engine 2>&1 | grep "pm-engine starting" | tail -n1'Строка старта берётся ПОСЛЕДНЯЯ, и это не мелочь: журнал контейнера переживает его перезапуск, а docker compose stop/up контейнер переиспользует, поэтому после второго выката в журнале лежат строки старта ОБЕИХ сборок. Выбор первой строки вернул бы самую старую — метрика и снимок показывали бы сборку B, рецепт называл бы сборку A, и оператор получил бы мнимое расхождение трёх поверхностей там, где расхождения нет. Поведение рецепта проверяется офлайн на журнале с двумя разными стартами: scripts/test-deploy-vps-manifest.sh, сценарий 26.
Третья поверхность — вспомогательная: журнал ротируется, и строка старта из него со временем исчезает. Именно поэтому verify журнал не читает, а опрашивает живой процесс.
Dashboard сообщает собственный отпечаток через GET /health | jq .build_info. pm-cli и pm-trader-observer печатают свой отпечаток второй строкой --version/-V.
Что читать в отпечатке. Идентичность кода — ДВА поля:
commit— ПОЛНЫЙ идентификатор коммита либо явноеunknown(git был недоступен на сборке; таймстамп или правдоподобная константа вместо коммита не подставляются никогда);dirty—true(сборка из ИЗМЕНЁННОГО дерева),false(из чистого) илиunknown(git недоступен).dirty=trueозначает, что меткаcommitНЕ называет работавший код: рядом с этим коммитом были незафиксированные правки отслеживаемых файлов.
version и built_at — СПРАВКА, а не ответ. Момент сборки вкомпилирован в бинарь, поэтому копирование и восстановление из резервной копии его СОХРАНЯЮТ — и этим он отличается от времени ФАЙЛА, решение по которому и породило этот механизм. Ответом он всё равно не является: он меняется на каждой пересборке той же пары commit+dirty, то есть называет СБОРКУ, а не код. Два бинаря с одним commit и одним dirty — это один и тот же код, сколько бы разных built_at они ни несли.
Манифест выката — ~/pm-backtest/.deploy/manifest/:
deployments.jsonl— строка на каждый установленный артефакт (только дозапись);current-<artifact>.json— указатель на то, что установлено сейчас.
Поля записи: artifact, path, sha256 УСТАНОВЛЕННОГО файла (для каталога SPA — слепок дерева), commit, dirty, deployed_at, account. Подрезка STAGING_KEEP этого каталога НЕ касается — в этом весь смысл: материал установки одноразовый, запись о нём нет. Запись врезана в тот же удалённый шаг, что и установка, сразу после неё и ДО запуска службы, поэтому упавший рестарт не оставляет установленный артефакт незаписанным.
Выкат из изменённого дерева называется вслух. До установки скрипт печатает source: commit=… dirty=…, а при dirty=true — WARNING: deploying from a MODIFIED working tree …. После сборки и ДО первой заливки состояние источника пересвидетельствуется: если отслеживаемый файл или HEAD изменились во время сборки, выкат ОСТАНАВЛИВАЕТСЯ (FATAL: source tree changed during the build, код возврата 3, ничего не залито и не установлено) — иначе запись описывала бы не ту сборку, которая собралась.
Daily ops
Engine status:
bash
ssh deploy@poly.grot.app 'docker ps --format "{{.Names}}: {{.Status}}"'
ssh deploy@poly.grot.app 'docker logs --tail 50 pm-engine-harvester-engine'
ssh deploy@poly.grot.app 'docker logs --tail 50 pm-engine-dashboard-harvester-engine'Dashboard: https://harvester-engine.grot.app (Cloudflare Access). Агенту удобнее SSH-проброс порта — см. CLAUDE.md §Browser access.
Аварийный pm-cli на VPS (cancel-all / sell-all). Бинарь лежит в ~/pm-backtest/bin/pm-cli. L2-креды (CLOB_API_KEY / _SECRET / _PASSPHRASE) живут в shared/.env.shared — он подключается к контейнерам первым env_file, инстансовый .env идёт вторым и их не дублирует. Движок эти ключи видит, но НЕ читает: их потребитель только crates/pm-cli/src/cancel_sell.rs (сначала POLY_*, затем CLOB_API_*). ЛОВУШКА: путь BOT_WALLETS_DB_PATH контейнерный (/config/wallets.db), а на хосте каталога /config нет — перекрывать флагом --db-path (он имеет приоритет над env, crates/pm-cli/src/key_source.rs:46):
bash
cd ~/pm-backtest/instances/harvester-engine
set -a; . ../../shared/.env.shared; . ./.env; set +a # CLOB_API_* + WALLETS_MASTER_PASSPHRASE
~/pm-backtest/bin/pm-cli cancel-all \
--wallet-label live --db-path "$PWD/config/wallets.db"
# то же для sell-all — набор флагов идентиченКлюч подписи берётся из зашифрованной wallets.db по метке (--wallet-label), на командную строку не попадает; мастер-пароль — из WALLETS_MASTER_PASSPHRASE (иначе интерактивный запрос). BOT_CONFIG_DB_PATH этим командам не нужен.
Если L2-креды потеряны — они выводятся заново из ключа кошелька (pm-sdk derive/create-api-key), это не невосполнимый секрет.
Ручной выход при неизвестном либо опровергнутом шаге цены (openspec order-price-tick-grid-conformance). Движок держит такой рынок в КАРАНТИНЕ: он не строит по нему ни одной новой цены — ни входа, ни цели выхода, ни капитуляции стопа. Что из этого следует оператору:
- операторская продажа ОДНОЙ позиции через движок (
POST /api/local/positions/{token_id}/sell) для такого рынка НЕ работает: она идёт тем же швом выпуска и тем же карантином. Это не сбой ручки, а её проектное поведение — выпустить цену, которую биржа заведомо отвергнет, запрещено; - единственный кандидат —
pm-cli sell-all. Его источник шага ДРУГОЙ: инструмент спрашивает шаг у CLOB (GET /tick-size), а движок знает шаг из метаданных Gamma и WS-события смены. «Шаг неизвестен ДВИЖКУ» не означает «шаг неизвестен вообще», поэтому инструмент обязан ПОПЫТАТЬСЯ даже по рынку в карантине. Кэш у него холодный (клиент строится заново на каждый запуск), то есть первый запрос по токену — настоящий сетевой; - если биржа шаг НЕ вернула, инструмент пропускает позицию с явной причиной и заявку не выпускает: подстановка умолчального шага запрещена. В этом случае работающего ручного выхода для рынка НЕТ — это состояние предъявляется оператору как принимаемый риск, а не прячется в примечание.
Проверено ли, что биржа отвечает шагом там, где его не дала Gamma — НЕТ. На 2026-09-06 адресный запрос неприменим: повторная живая выборка обнаружения tag_id=104596 содержит 1100 открытых рынков, и у каждого объявлен orderPriceMinTickSize (0.001 — 647, 0.01 — 453). Поэтому нет token_id требуемого класса, на котором можно проверить GET /tick-size. Это не положительный результат CLOB для гипотетического рынка без метаданных Gamma. Если такой рынок появится, до фактического ответа CLOB аварийный путь считается неподтверждённым; отказ pm-cli получить шаг означает, что позиция пропущена и рабочего ручного выхода через этот инструмент нет.
Deploy pm-engine (graceful): scripts/deploy-vps.sh engine-bot. Движок ловит SIGTERM в crates/pm-engine/src/lifecycle/shutdown.rs (двойной SIGTERM в течение 1s ⇒ DoubleSignalImmediateExit, exit 14 — operator escape hatch). Reset/self-restart модель движка — local-dashboard-runbook.md §Reset.
Legacy pm-bot имел heartbeat-lock на bot_sessions (stale row блокировал рестарт на 5 min TTL). У pm-engine такого DB-lock нет: таблицы bot_sessions в engine.db не существует (0 совпадений в crates/pm-engine/src), идентичность сессии выводится из процесса. Не применять pm-bot UPDATE bot_sessions … SQL — он не относится к движку.
Deploy pm-engine-dashboard + SPA: scripts/deploy-vps.sh engine-dashboard. Backend (~/pm-backtest/bin/pm-engine-dashboard, axum /health, /api/*, wallet-explorer + Local-Bot SSE proxy) и SPA (~/pm-backtest/spa-engine/dist/, Caddy serves) — независимые artefacts. Backend container не serves Vue routes напрямую (404 на любом SPA-маршруте ожидаемо). Validate отдельно:
bash
ssh deploy@poly.grot.app \
'docker exec pm-caddy curl -fsS http://pm-engine-dashboard-harvester-engine:31337/health'
ssh deploy@poly.grot.app 'ls -1 ~/pm-backtest/spa-engine/dist/assets/*.js | head'Канонический engine-SPA путь — ~/pm-backtest/spa-engine/dist/ (не ~/pm-backtest/frontend/pm-engine-dashboard-web/dist/).
Push engine SPA (zero downtime): scripts/deploy-vps.sh engine-spa. После установки скрипт сравнивает host index.html с файлом внутри Caddy. Если старый контейнер держит прежний inode bind mount, скрипт пересоздаёт только Caddy и отказывает, если повторная сверка не сошлась. Ctrl+Shift+R подхватит bundle.
Clean binary clutter: scripts/deploy-vps.sh cleanup-bin (preview), --apply для apply. Удаляет stale *.old, *.prev*, *.bak, *.new. Active binaries не трогает.
Cloudflare tunnel
Стек живёт в ~/pm-backtest/tunnel/ (cloudflared + Caddy). Once-a-quarter ops (новый instance hostname, rotate tunnel token) — см. deploy/server/README.md §Cloudflare.
Фактическое состояние маршрутизации (проверено 2026-08-03): в tunnel/Caddyfile ОДИН host-блок — harvester-engine.grot.app:80 с import spa-engine-and-api pm-engine-dashboard-harvester-engine; блок spread-mm.grot.app удалён вместе с остановленным инстансом. SPA раздаётся только из /spa-engine (общий /spa принадлежал pm-dashboard и снят). Разграничения по заголовку Host фактически нет: при единственном сайте Caddy отвечает 200 на любой Host — защита периметра целиком на стороне Cloudflare Access, наружу порты не опубликованы. Изменение конфигурации применяется без пересоздания контейнера: docker exec pm-caddy caddy reload --config /etc/caddy/Caddyfile; пересоздание нужно только при смене монтирований.
bash
cd ~/pm-backtest/tunnel
docker compose ps # cloudflared + caddy
docker logs --tail 30 pm-tunnel # quic connections
docker logs --tail 30 pm-caddy # SSE + reverse-proxySmoke tests after deploy
Smoke после выката выполняется на фактическом live/probe процессе. Read-only проверки не разрешают запуск второго процесса с тем же кошельком.
Первым шагом — подтвердить, что в бою действительно НОВЫЙ код: scripts/deploy-vps.sh verify обязан назвать ожидаемый коммит (§Какой код сейчас торгует). Без этого дальнейшие пробы отвечают на вопрос «работает ли что-то», а не «работает ли выкаченное».
Basic pm-engine smoke
Использовать scripts/deploy-vps.sh verify, логи уже запущенного контейнера, /metrics и /api/local/seed. Не запускать локальный или параллельный процесс как замену боевой проверке: это другая сессия и другой источник фактов.
Состояние фоновой сверки холдингов кошелька — необязательное поле того же /api/local/seed:
bash
curl -sS http://127.0.0.1:31337/api/local/seed | jq -r .holdings_reconcileЧетыре значения: pending — фоновый прогон ещё не завершался с момента старта процесса (нормально в первые секунды после рестарта; не признак отказа); clean — последний завершённый прогон не нашёл расхождений; discrepancies — последний завершённый прогон нашёл расхождение; not_performed — сверщик не собрался при старте (reason="construction"), последний прогон уперся в предел 300 с (reason="deadline") либо не смог прочитать источник холдингов или реестр позиций (reason="holdings_source"/"registry") — сам по себе НЕ означает найденное расхождение и НЕ останавливает торговлю: см. §«Prometheus /metrics endpoint» в alerts-and-monitoring.md.
Поле фиксируется РАНЬШЕ рассылки тревоги в приёмники (Telegram/лог) — доставка идёт отдельным асинхронным шагом следом, а не в одной операции с записью состояния. Если приёмник недоступен, discrepancies/ not_performed здесь всё равно уже видны — этот эндпоинт не зависит от исхода доставки тревоги.
RTDS smoke
Standalone RTDS wire-probe в pm-engine отсутствует — см. §Pre-flight «RTDS wire validation» (проверка через ENGINE_RTDS_ENABLED + rtds_* метрики). RTDS toxic-flow гейт настраивается через config.dbdirectional.toxic_flow, не env.
Basic dashboard probes
bash
cargo build --locked --release -p pm-engine-dashboard
./target/release/pm-engine-dashboard > .tmp/dashboard.log 2>&1 &
DPID=$!
sleep 4
# Порт по умолчанию — 31350 (crates/pm-engine-dashboard/src/config.rs).
# :31337 — это ПРОДОВОЕ значение VPS-контейнера, локально его нет.
curl -sS -o /dev/null -w "GET / → %{http_code}\n" http://localhost:31350/
curl -sS -w "\n[%{http_code}]\n" http://localhost:31350/health
# Портфолио кошелька: пробуем ДАННЫЕ, а не страницу. HTML отдаёт SPA
# (маршрут /wallet/<address>), бэкенд его не рендерит с X1.4b: путь
# /<address> на бэкенде = 404, а за Caddy = index.html, который ничего
# не отрисует (в роутере такого маршрута нет). Как проба — бесполезен.
curl -sS -o /dev/null -w "GET /api/<wallet>/snapshot → %{http_code}\n" \
http://localhost:31350/api/0xba561b30f68530485491547fb75baf6f03d0b7f4/snapshot
kill $DPID 2>/dev/nullExpected: / → 200 ~1KB; /health → 200 {"status":"ok"}; /api/<wallet>/snapshot → 200 JSON десятки KB. Port conflict ⇒ PM_ENGINE_DASHBOARD_BIND_ADDR=0.0.0.0:31447 … (голого DASHBOARD_BIND_ADDR код не читает — команда с ним поднимет дашборд на 31350).
Dashboard widgets smoke (ADR-013 / ADR-014)
End-to-end probe pm-engine ↔ pm-engine-dashboard ↔ Vue SPA после изменений cancel UI / risk widgets.
Prereqs: workspace builds; pnpm install --filter pm-engine-dashboard-web; free :31337, :31342, :5174.
Phase 1 — источник фактических данных: использовать уже работающий live/probe engine через SSH-проброс. Не поднимать второй процесс с торговым кошельком ради smoke.
Phase 2 — pm-engine-dashboard: прокси /api/local/* бьёт в engine SSE через PM_ENGINE_SSE_URL (legacy-зеркало PM_BOT_LOCAL_API_URL, default http://127.0.0.1:31342); bearer — PM_ENGINE_SSE_TOKEN (legacy-зеркало BOT_LOCAL_API_TOKEN).
Порт 31337 здесь задан НАМЕРЕННО и НЕ противоречит правилу «локальный дефолт — 31350»: smoke специально повторяет продовую раскладку VPS, чтобы последовательность совпадала с тем, что потом проверяется в бою. Bind указан явной переменной, поэтому дефолт не участвует. Если 31337 занят — меняйте и bind, и все curl ниже согласованно.
bash
PM_ENGINE_DASHBOARD_BIND_ADDR=127.0.0.1:31337 \
PM_ENGINE_SSE_URL=http://127.0.0.1:31342 \
PM_ENGINE_SSE_TOKEN=<тот же токен, что у engine> \
cargo run --locked -p pm-engine-dashboard --release \
> .tmp/dashboard-smoke-server.log 2>&1 &
DASH_PID=$!
sleep 3
curl -sS -o /dev/null -w "GET /health → %{http_code}\n" http://127.0.0.1:31337/health
curl -sS -o /dev/null -w "GET /api/local/sessions → %{http_code}\n" \
http://127.0.0.1:31337/api/local/sessionsБез запущенного pm-engine /api/local/* → 503 (proxy намеренно surface'ит 503 вместо fail).
Phase 3 — Vite SPA:
bash
( cd frontend/pm-engine-dashboard-web && pnpm dev > ../../.tmp/dashboard-smoke-web.log 2>&1 ) &
WEB_PID=$!
sleep 4Open http://localhost:5174/.
Phase 4 — UI checklist (фокус на ADR-013/014 widgets):
| # | Probe | Expected |
|---|---|---|
| 1 | Cancel — single row (если orders): Cancel → ConfirmDialog → accept → toast Cancel sent + Undo (Ns) | row → cancelled (ADR-013) |
| 2 | Cancel — undo within 5s | reason chip исчезает |
| 3 | Cancel — bulk paranoid (≥6 rows): select → требуется typed CANCEL | button disabled до match |
| 4 | Cancel — bulk soft (≤5 rows): ConfirmDialog без typed gate | softer affordance |
| 5 | RiskBadge trio (Sharpe / Sortino / Max DD) | grey при <2 fills, иначе green/yellow/red (ADR-014 §D8) |
| 6 | Numeric grid: Annualisation + Median dt | ×1.00 & — пусто / ×N.NN & Ms/Ss |
| 7 | EquityCurve sparkline после ≥2 fills | 60-pt sparkline |
| 8 | DrawdownChart после ≥2 fills | SVG drawdown + equity line |
| 9 | Show risk-free rate setting toggle | InputNumber bound в localStorage |
| 10 | Reload → risk-free persists | localStorage[pmd:risk_free_rate] |
Базовые UI rows (top-bar, connection dot, session dropdown, balance chart, EventStream, open orders header, compare view, wallet view) — покрыты Basic dashboard probes выше.
Phase 5 — reconnect: разрыв прокси/туннеля должен показать offline banner; после восстановления соединения UI повторно загружает /seed. Боевой engine для этой проверки не останавливается.
Cleanup: kill $WEB_PID $DASH_PID 2>/dev/null.
Multi-wallet + strategy-config smoke (ADR-015)
End-to-end probe wallets.db + config.db + WebUI configurator. ≤10 min; safe против live wallet.
Prereqs: cargo check --locked --workspace green; pnpm install; free :31337/:31342/:5174; throwaway passphrase + PK.
bash
mkdir -p .tmp
export WALLETS_DB=.tmp/smoke-wallets.db
export CONFIG_DB=.tmp/smoke-config.db
export SMOKE_PASS='correct horse battery staple'
export SMOKE_PK='0xPLACEHOLDER_PRIVATE_KEY_HEX'Phase 1 — CLI wallet round-trip:
| # | Probe | Expected |
|---|---|---|
| 1 | pm-cli wallet add --label hot-1 --db-path $WALLETS_DB (stdin: pass×2 + PK) | inserted row label=hot-1 |
| 2 | pm-cli wallet list --db-path $WALLETS_DB | hot-1 + derived address |
| 3 | pm-cli wallet export --label hot-1 (paranoid label-typed) | 5-line .env block |
| 4 | pm-cli wallet rotate-master | rotated 1 wallet(s); старый pass не unlock'ает |
| 5 | pm-cli wallet remove --label hot-1 | row deleted |
Cleanup: re-add hot-1 с original pass.
Phase 2 — CLI strategy:
FE-5 (бюджетный предикат по балансу кошелька) снят целиком — openspec strategy-config-domain-unification, решение оператора 2026-08-31: баланс кошелька не ограничивает настройки лимитов стратегии. Шаги ниже проверяют оставшийся денежный инвариант activate — запрет смены активной строки при открытых позициях (не бюджет).
| # | Probe | Expected |
|---|---|---|
| 6 | pm-cli strategy add --label directional-smoke --type directional --config <directional.json> --wallet hot-1 --budget 50 --db-path $CONFIG_DB (JSON уже в micro at-rest) | inserted strategy id=1 enabled=false; audit op=add |
| 7 | pm-cli strategy list | enabled=0, wallet=hot-1, budget=50 |
| 8 | pm-cli strategy activate --engine-db /data/engine.db | enabled=1; audit op=activate |
| 9 | правка total_budget_micro_usd в JSON + pm-cli strategy versions create --label directional-smoke --config <edited.json> --wallet hot-1 --expected-latest 1 --expected-published 1 --mode publish --db-path $CONFIG_DB | новая опубликованная версия; budget_limit_usd пересчитан как производная от total_budget_micro_usd |
| 10 | pm-cli strategy export --label directional-smoke | definition JSON |
| 11 | pm-cli strategy deactivate --force | enabled=0; audit op=deactivate. Повторная активация — activate (Phase 3). |
Phase 3 — WebUI:
Boot triple больше не является допустимым smoke: runtime simulation удалён, а запуск live engine на временном кошельке — отдельная реальная торговая сессия. WebUI-конфигуратор проверяется через backend fixtures/E2E без запуска engine.
| # | WebUI action | Expected |
|---|---|---|
| 12 | localhost:5174/wallets | renders + hot-1 row |
| 13 | Add wallet hot-2 + throwaway PK + $SMOKE_PASS | form clears; CLI confirms 2 rows |
| 14 | localhost:5174/strategies | renders + directional-smoke |
| 15 | Add strategy → type directional | all directional.* config fields visible |
| 16 | Rotate master в Wallets view (typed label twice + old/new pass) | one-tx re-encrypt; toast Rotated <N>; старый pass не unlock'ает |
Phase 4 — pm-engine с selectors: kill старого pm-engine, restart с WALLETS_MASTER_PASSPHRASE=<new>, BOT_WALLETS_DB_PATH=$WALLETS_DB, BOT_CONFIG_DB_PATH=$CONFIG_DB, BOT_WALLET_LABEL=hot-1, активная строка directional-smoke в config.db. Verify:
| # | Probe | Expected |
|---|---|---|
| 17 | grep -E 'BOT_WALLET_LABEL|wallet hot-1 unlocked|strategy directional-smoke' .tmp/smoke-engine-selectors.log | all markers |
| 18 | grep 'cross-check: row.wallet_label' … | … matches BOT_WALLET_LABEL='hot-1' — OK |
| 19 | grep -c metrics_tick … после 10s | ≥8 ticks |
Phase 5 — INV-3 failure: point strategy wallet_label на hot-2, keep BOT_WALLET_LABEL=hot-1, restart. Expected: non-zero exit + log strategy directional-smoke has wallet_label='hot-2' but BOT_WALLET_LABEL='hot-1' — refusing to start to avoid signing with the wrong key. Repair: revert wallet_label обратно.
Phase 6 — rollback parity: unset все ADR-015 env, restart pm-engine с дефолтной config.db-строкой (PM_ENGINE_DB=.tmp/… без селекторов). Expected: grep -E 'wallets.db|config.db|BOT_WALLET_LABEL' … пусто. (BOT_STRATEGY_LABEL в системе больше нет — переменная удалена вместе с выбором строки по метке.)
Cleanup: kill процессы, rm -f .tmp/smoke-wallets.db .tmp/smoke-config.db .tmp/smoke-engine*.db.
Rollback procedure
Redeploy known-good local commit через тот же scripts/deploy-vps.sh. VPS — не source of truth для rollback artefacts.
bash
git log --oneline -20 # выбрать known-good
git checkout <good-sha>
scripts/deploy-vps.sh engine-bot # либо engine-dashboard/engine
git checkout mainПосле отката — в том числе после АВТОМАТИЧЕСКОГО восстановления rollback set координированным выпуском — торгующий коммит подтверждать scripts/deploy-vps.sh verify (§Какой код сейчас торгует). Время файла у восстановленного бинаря говорит о моменте копирования, а не о коде, который в нём лежит; отпечаток и запись манифеста называют коммит, который бинарь НЕСЁТ.
Rollback во время инцидента при взведённой автоматической остановке: движок вошёл в Drain с происхождением Automatic (накопленный убыток или серия стоп-аутов — circuit_breaker; расхождение учёта — сторож учёта). Оператор оценивает, оставить drain (защищает капитал) или снять его через POST /api/local/resume ПОСЛЕ разбора причины. Причина видна в баннере дашборда и в критическом алерте persistent_drain_entered в Telegram.
Отдельного «kill switch» с рестарт-онли снятием БОЛЬШЕ НЕТ: он был снят 2026-08-25 вместе с доунификационным путём, у которого не было боевого взводящего. Автоматический стоп по накопленному убытку и по расхождению учёта входит в тот же drain и снимается одним resume, как и ручной. Это НЕ единственная автоматическая остановка входов: деградация доступа к CLOB (ExchangeAccess) закрывает вход самостоятельно, но и снимается сама — после успешной пробы, без участия оператора.
Toxic-flow гейт (directional.toxic_flow) reversible правкой config.db
- Reset движка для подхвата свежей config (§Local-Bot Mode). Инфра env-флаги (
PM_ENGINE_*) reversible через restart pm-engine с обновлённым.env.
Этап наблюдения входа (directional.entry_observation) откатывается в ЖЁСТКОМ порядке: секция снимается конфигуратором ДО отката бинаря — §Этап наблюдения входа, шаг 4. Старый бинарь на строке с секцией не падает, а молча её игнорирует, поэтому нарушенный порядок даёт не отказ, а тихое состояние без журнала и без метрик.
Для rollback submit-authorization фикса сначала зафиксировать текущее значение directional.budget_per_market и сделать backup config.db. Если возвращается старый binary с общим probe fuse, ДО его рестарта вернуть per-market budget не выше $5; иначе старый hidden cap снова остановит все BUY выше $5.
Откат бинаря при сохранённом discovery.slug_filter — СНАЧАЛА снять ключ
Ключ discovery.slug_filter (openspec discovery-market-slug-filter) СУЖАЕТ вселенную обнаружения. Старый бинарь этого ключа не знает и, прочитав config.db, МОЛЧА его проигнорирует — то есть начнёт торговать ВЕСЬ тег боевыми деньгами при конфигурации, которая внешне выглядит суженной. Кодом нового бинаря эта дыра закрыта быть не может (запрет незнакомых ключей в новом бинаре на поведение старого не влияет), поэтому она закрывается ПОРЯДКОМ:
- опубликовать версию конфигурации БЕЗ ключа
discovery.slug_filter(конфигуратор дашборда, create_version + publish_version — прямые правки config.db запрещены); - убедиться, что действующее значение фильтра у работающего процесса пустое — ряд
pm_engine_discovery_slug_filter_active{value=""} 0в/metrics; - только после этого откатывать бинарь.
Обратный порядок недопустим: между откатом и снятием ключа старый бинарь торгует несуженной вселенной, и это НЕ видно ни по одной поверхности — у старого бинаря величин фильтра нет вовсе.
Цена ВЫКАТА, которую надо знать заранее. Новое поле меняет отпечаток контракта конфигурации, поэтому ранее сохранённые снимки версий станут непубликуемыми без copy-forward. Посчитать их число ДО выката, не после.
Откат миграции typed-целей выхода (config.db восстанавливается ПЕРВЫМ)
Относится к откату за коммит 969098ad (typed exit_target / stop_target) — к любому бинарю, собранному ДО него. Миграцию делает scripts/migrate-directional-relative-targets.sh; процедура ниже — обратная ей.
Историческая семантика ниже описывает бинарь той даты. Текущий
DirectionalConfigимеет strict root и отвергает любой неизвестный top-level ключ; для текущего execution-cutover использовать раздел выше.
Форма definition и бинарь связаны жёстко в ОБЕ стороны (сверено по коду до- и после-миграционной версии crates/pm-engine/src/strategies/directional/config.rs):
- Старый бинарь + мигрированная
config.db— падение на старте по двум независимым каналам. На верхнем уровнеDirectionalConfigтребуетexit_price_milli, который миграция удалила (умолчания у поля нет); при этомdeny_unknown_fieldsна самойDirectionalConfigНЕТ, поэтому новый ключexit_targetмолча игнорируется — ошибку даёт именно ОТСУТСТВИЕ старого ключа, а не присутствие нового. Внутри секцииstop_lossнаоборот:StopLossConfigнесётdeny_unknown_fields, поэтомуstop_targetотвергается как неизвестный ключ, аthreshold_milli— как отсутствующий обязательный. Оба канала выходят одной ошибкой загрузчикаDirectionalConfig deserialize failed for strategy <label>(crates/pm-engine/src/config/loader.rs). - Новый бинарь + восстановленная legacy-
config.db— тоже падение на старте:exit_targetобязателен и умолчания не имеет.
Ни одна из двух форм — ни чисто legacy, ни чисто typed — не поднимает оба бинаря. Единственное исключение узкое и к откату не относится: строка, несущая ОБЕ формы сразу (переходный билд писал их парой) и при этом БЕЗ секции stop_loss (stop_loss объявлен Option), загрузится под любым бинарём — лишний ключ верхнего уровня игнорируется в обе стороны. Как только секция stop_loss есть, обе формы отвергаются обоими бинарями (deny_unknown_fields внутри неё), а сам скрипт миграции такие строки схлопывает до одной формы при совпадении значений. Отсюда единственный допустимый порядок отката: остановить → восстановить config.db → поставить старый бинарь → запустить. Порядок наоборот (сначала бинарь) даёт движок, не переживший старт: ни стопов, ни выходов, открытые позиции без защиты до тех пор, пока оператор не заметит.
Дашборд откатывается вместе с движком. pm-engine-dashboard валидирует сохраняемую стратегию тем же типизированным конфигом движка (validate_pm_engine_directional на POST и PUT crates/pm-engine-dashboard/src/routes/api/strategies.rs), поэтому новый дашборд не примет legacy-форму, а старый — typed. Плюс ./config смонтирован в ОБА контейнера (docker-compose.yml инстанса), то есть дашборд — второй писатель в config.db. Пока откат не завершён, страницу «Стратегии» не трогать.
Где лежат копии. Скрипт кладёт копию РЯДОМ с исходной БД под именем <путь к config.db>.bak-relexit-<метка>, где метка — UTC-время в формате %Y%m%dT%H%M%S, дефис, наносекунды и буква Z (пример: config.db.bak-relexit-20260726T101530-123456789Z; это НЕ ISO-8601, руками имя не собирать — брать по маске). Существующую копию скрипт не перезаписывает (выход 73), поэтому копий столько, сколько было запусков. Живая БД инстанса — /home/deploy/pm-backtest/instances/harvester-engine/config/config.db, копии там же.
Как выбрать нужную. Годится копия, снятая запуском, который РЕАЛЬНО переводил БД из legacy-формы в typed. Скрипт идемпотентен, поэтому повторный запуск делает копию уже мигрированной БД — такая копия для отката бесполезна. Проверять содержимым, не датой:
bash
cd /home/deploy/pm-backtest/instances/harvester-engine
ls -lt config/config.db.bak-relexit-*
# ПУСТО ⇒ СТОП. Старый бинарь НЕ ставить: пригодного снимка до-миграционной
# формы на инстансе нет, откат в этом виде невозможен — эскалировать. То же
# при непустом списке, в котором ни одна копия не проходит проверку ниже.
# (Факт на 2026-07-27: запуск скрипта против живого инстанса нигде в репозитории
# не зафиксирован — существование копий надо проверять, а не предполагать.)
# Точная проверка (нужен sqlite3 на хосте — наличие НЕ проверено, см. ниже):
sqlite3 config/config.db.bak-relexit-<метка> "
SELECT label,
json_type(definition, '\$.exit_price_milli') AS legacy,
json_type(definition, '\$.exit_target') AS typed
FROM strategies WHERE strategy_type = 'directional' ORDER BY label;"
# Годная копия: legacy = integer/real, typed = NULL (пусто).
# Грубая проверка без sqlite3 (асимметричная):
grep -ac exit_price_milli config/config.db.bak-relexit-<метка>
# 0 ⇒ копия точно УЖЕ мигрирована, не годится. Ненулевое ⇒ строка в файле
# есть, но это может быть след освобождённой страницы — не доказательство.Восстановление. Выполняется на хосте VPS при остановленных контейнерах. Копию делает .backup, то есть это самостоятельный файл SQLite — достаточно cp, sqlite3 на хосте не требуется. config.db открывается в режиме WAL (crates/pm-common/src/strategy_config.rs), поэтому рядом живут -wal / -shm; их обязательно убрать вместе с файлом БД, иначе на восстановленную БД доигрывается журнал от прежней.
bash
cd /home/deploy/pm-backtest/instances/harvester-engine
docker compose stop pm-engine pm-engine-dashboard # оба монтируют ./config
ts=$(date -u +%Y%m%dT%H%M%SZ)
mv config/config.db "config/config.db.pre-rollback-$ts"
[ -e config/config.db-wal ] && mv config/config.db-wal "config/config.db-wal.pre-rollback-$ts"
[ -e config/config.db-shm ] && mv config/config.db-shm "config/config.db-shm.pre-rollback-$ts"
cp -p config/config.db.bak-relexit-<метка> config/config.db
# Владелец и режим обязаны совпасть с прежним файлом: `cp -p` сохраняет режим и
# метки времени, но НЕ владельца (кроме запуска от root). Копия, снятая изнутри
# контейнера, принадлежит root — восстановленный из неё файл окажется недоступен
# на запись дашборду, и это всплывёт часами позже, на первом сохранении.
ls -l config/config.db "config/config.db.pre-rollback-$ts"
# Расхождение владельца ⇒ привести к прежнему (`sudo chown <owner>:<group> config/config.db`).Альтернатива шагу cp при наличии sqlite3 на хосте (её же печатает сам скрипт в ветке неудавшейся верификации) — sqlite3 config/config.db ".restore 'config/config.db.bak-relexit-<метка>'". Предыдущие шаги (остановленные контейнеры, отодвинутые config.db и сайдкары -wal/-shm) обязательны и для неё: .restore пишет в файл, который после mv создаётся заново.
Затем — старый бинарь. С рабочей машины, НЕ на VPS:
bash
git log --oneline -20 # выбрать known-good ДО 969098ad
git checkout <good-sha>
scripts/deploy-vps.sh engine # движок + дашборд + pm-cli + SPA, оба сервиса поднимутся сами
git checkout maindeploy-vps.sh сам делает docker compose stop / up -d по своим сервисам, то есть отдельного шага запуска не нужно — но восстановление config.db обязано быть завершено ДО его вызова.
Как проверить, что откат удался.
docker compose ps pm-engine pm-engine-dashboard— обаUp, и движок остаётсяUpспустя 2 минуты. Ошибка конфигурации — fail-fast, контейнер выходит сразу, поэтому «поднялся и держится» — сам по себе сигнал.docker compose logs --since 10m pm-engine | grep 'DirectionalConfig deserialize failed'— пусто. Непустой вывод означает, что взята не та копия либоconfig.dbосталась мигрированной.- Дашборд отвечает и показывает строку стратегии на странице «Стратегии», по SSE идут тики. Страница берёт данные из той же
config.db, поэтому пустой список — признак, что подменён не тот файл.
Цена восстановления. Копия — снимок на момент миграции. Всё, что оператор менял в config.db ПОСЛЕ неё (бюджет, пороги — через страницу «Стратегии» или pm-cli strategy versions create/publish; вкл/выкл стратегий — через activate/deactivate), восстановлением откатывается. Перед подменой зафиксировать актуальные значения хотя бы глазами на странице «Стратегии»; файл config.db.pre-rollback-<ts> остаётся на диске и позволяет свериться позже.
Откат бинаря confirmed-coverage-forbids-unsellable-verdict — снять UNCOVERED_UNCONFIRMED ДО подмены бинаря
Относится к откату на любой бинарь, собранный ДО этой карточки. Новый бинарь пишет на строки positions состояние coverage_state = 'UNCOVERED_UNCONFIRMED' (первый неподтверждённый ноль измерения) — старый бинарь такого литерала не знает: CoverageState::from_wire у него отвечает ошибкой на КАЖДУЮ такую строку. Снять литерал нужно ДО остановки нового процесса и постановки старого, иначе первое же чтение строки старым бинарём падает.
Команда (выполняется на копии/живой БД инстанса, config в примере — для engine-state.db, не config.db):
bash
sqlite3 <путь к engine-state.db> "
UPDATE positions
SET coverage_state = 'NOT_CHECKED',
coverage_checked_at = NULL,
coverage_measured_shares = NULL,
coverage_booked_shares = NULL
WHERE coverage_state = 'UNCOVERED_UNCONFIRMED'"Почему обнуляются ВСЕ четыре поля, а не только coverage_state.NOT_CHECKED — литерал, который старый бинарь понимает как «измерение никогда не проводилось». Если оставить на такой строке измеренное количество или момент измерения от снятого неподтверждённого нуля, строка станет ЛЖИВОЙ ровно тем способом, которого правило «нет мёртвых заглушек» запрещает: перечисление утверждает «не проверено», а соседние колонки одновременно хранят конкретное измерение — это внутреннее противоречие строки, не просто устаревшее поле.
Почему coverage_seq эта команда НЕ трогает. Колонка — водяной знак уже ПОТРАЧЕННОГО номера, не текущее состояние строки: PositionRegistry:: hydrate (docs/target/storage.md §«Подтверждённое покрытие») засевает счётчик реестра максимумом coverage_seq по ВСЕЙ таблице при каждом запуске, включая старый бинарь — тот при чтении строки coverage_seq просто игнорирует (у него нет этого понятия), но само число на диске остаётся. Обнулить его значило бы позволить СЛЕДУЮЩЕМУ запуску нового бинаря (после отмены отката) выдать этот же номер повторно ДРУГОМУ измерению — а уникальность номера на схеме держит частичный индекс, который такую повторную выдачу примет как валидную (старая строка к этому моменту уже не несёт coverage_seq, раз он тоже был обнулён), тихо разрушив порядок между двумя измерениями, которые в реальности были РАЗНЫМИ событиями.
Порядок: остановить движок → выполнить команду на engine-state.db инстанса → поставить старый бинарь → запустить. Обратный порядок (сначала бинарь) даёт старому бинарю строки, которые он не может разобрать, — падение на первом же чтении затронутой позиции, а не на старте процесса целиком (миграция схемы идемпотентна и назад не откатывается — колонки coverage_booked_shares/coverage_seq при этом остаются в схеме, старый бинарь их просто не читает).
Проверено на копии. Тест hydrate_after_a_rollback_does_not_reissue_the_watermarked_seq (crates/pm-engine/src/adapters/storage/sqlite_tests.rs) прогоняет ровно эту команду против настоящей SQLite-строки, доведённой до UNCOVERED_UNCONFIRMED, и подтверждает оба свойства сразу: строка после команды читается без from_wire-ошибки и без внутреннего противоречия (NOT_CHECKED без измеренного количества/момента), а max_coverage_seq() — тот самый вход, который засевает hydrate — остаётся РАВНЫМ значению до отката, то есть следующая гидрация выдаст номер строго больше, а не переиспользует уже потраченный.
Переход на независимые execution-политики directional (координированный cutover)
Новый DirectionalConfig строгий на корне и не принимает глобальные post_only, max_taker_fee_bps, buy_order_ttl_seconds либо прежний тег WatchArmFak. Старый бинарь, в свою очередь, требует глобальные ключи и не читает execution. Поэтому совместимого промежуточного состояния нет: миграция config.db и бинарный набор откатываются только вместе.
- Выполнить обычный drain/flat gate. Не останавливать writers вручную: coordinated deploy сначала проверяет live drain/flat через dashboard, затем сам останавливает оба сервиса.
- Выполнить единственный штатный cutover:
scripts/deploy-vps.sh engine. После остановки writers скрипт снимает полный rollback set и вызывает stagedpm-cli strategy contract-migrate; версия, упорядоченная цепочка и совместимость получены из его machine-readablecontract-info, без env-флага и shell-версии. Для versioned config миграция создаёт и публикует immutable N+1 snapshot через typed pm-cli, поэтому projection, content hash и contract fingerprint согласованы. Любая ошибка восстанавливает прежние stores, compose, бинарии и SPA. Legacy GTD60s, который биржа отвергала даже с wire-buffer, канонический зарегистрированный transform нормализует до180sдо typed validation. - Проверить отсутствие трёх старых ключей, наличие пяти
execution-ролей и новыеlatest_config_version == published_config_version. - На старте обязана появиться строка
effective directional order executionс пятью role/policy labels. - В configurator открыть существующую стратегию, проверить create/edit round-trip и условные поля TTL/post-only/BUY fee cap. Не менять боевые policy без отдельного операторского решения.
Откат: остановить writers, восстановить timestamped backup config.db и предыдущий согласованный набор бинарей, затем запустить инстанс. Восстановление только БД или только бинаря намеренно fail-fast.
Историческое снятие четырёх мёртвых ключей конфигурации
Относится к снятию entry_price_band_upper_milli, pending_ttl_secs, market_tag_filter (2026-07-30) и max_wallet_pct_bp (2026-07-28). Порядок здесь ПРОТИВОПОЛОЖЕН откату typed-целей выше: там config.db идёт первой, здесь — бинарь.
Этот порядок описывает только cutover 2026-07-30 и больше не применим к текущему строгому корневому контракту. Перед текущим execution-cutover все четыре ключа уже должны быть сняты; новый бинарь отвергает их как unknown.
Порядок.
Скопировать оба скрипта и оба
.sqlна VPS. Боеваяconfig.dbлежит на сервере, а копии репозитория там НЕТ: в/home/deploy/pm-backtest/есть каталогscripts/, но каталогаdocs/нет вовсе (сверено 2026-07-30). Скрипты миграции не несут SQL внутри — они читают.sqlиз копии репозитория ЛИБО из каталога рядом с собой, поэтому копировать надо ЧЕТЫРЕ файла в ОДИН каталог:bashscp scripts/migrate-directional-drop-retired-knobs.sh \ scripts/migrate-directional-drop-wallet-pct-limit.sh \ docs/operations/config-migration-2026-07-30-drop-redundant-directional-knobs.sql \ docs/operations/config-migration-2026-07-28-remove-wallet-pct-risk-limit.sql \ deploy@poly.grot.app:/home/deploy/pm-backtest/scripts/Скопировать только
.shнедостаточно: скрипт завершится кодом66(«migration SQL not found») и НИЧЕГО не сделает. Третий путь поиска — переменнаяMIGRATION_SQL=<путь к .sql>, если файлы разнесены.Предполётная проверка (ДО выката бинаря). Снятие второй границы меняет допуск только там, где она СТРОГО НИЖЕ предельной цены покупки. Команда выполняется НА VPS (там же, где база):
bashssh deploy@poly.grot.app \ 'bash ~/pm-backtest/scripts/migrate-directional-drop-retired-knobs.sh --check-only \ /home/deploy/pm-backtest/instances/harvester-engine/config/config.db'Ненулевой код выхода ⇒ СТОП: на такой строке новый бинарь РАСШИРИТ коридор входа. Сначала опустить
max_entry_price_milliявным решением оператора. Замер 2026-07-30: наharvester-engine-v2обе величины равны 950 — проверка проходит, поведение не меняется.Почему одной базы достаточно (расширение коридора делает БИНАРЬ, а бинарь на VPS один файл — легко решить, что задеты все инстансы). Сверено 2026-07-30 по
docker inspect:deploy-vps.sh engineзаменяет ровноbin/pm-engineиbin/pm-engine-dashboard, а этот путь смонтирован только вpm-engine-harvester-engineиpm-engine-dashboard-harvester-engine, и оба читаютinstances/harvester-engine/config/config.db. Остальные контейнеры запускают другие файлы, напримерpm-engine-bench-tput→bin/pm-engine-bench-tput. Если топология изменится (общий бинарь для нескольких инстансов) — прогнать--check-onlyпо КАЖДОЙ базе. Для справки, замер тех же баз 2026-07-30:instances/bench-tput— те же 950/950 (нейтрально),instances/spread-mm— единственная строка типаpaired-merge, снимаемых ключей не несёт.Выкат ОБОИХ бинарей одной командой —
scripts/deploy-vps.sh engine. На момент этой исторической миграции движок поднимался на несмигрированной базе; текущий strict-root контракт так больше не работает. Тогдашнее поведение было закреплено удалённым впоследствии тестомretired_directional_keys_at_top_level_are_toleratedиpm-engine/tests/it/config_live_row_migration_e2e.rs.engine-botиengine-dashboardпо отдельности здесь ЗАПРЕЩЕНЫ.engine-botобновляет только движок и оставляет работать СТАРЫЙpm-engine-dashboard, а дашборд разбираетdefinitionв тот жеDirectionalConfig(validate_pm_engine_directional, три слоя: serde → sonic-rs →validate_strategy_config). После шага 3 старый дашборд отвечал бы400 … missing field 'entry_price_band_upper_milli'на любое сохранение строки стратегии. Страница «Стратегии» при этом продолжила бы открываться — список отдаётdefinitionсырым JSON и в тип не разбирает, поэтому отказ проявился бы только в момент сохранения.Миграция (после того, как движок подтверждён живым):
bashssh deploy@poly.grot.app \ 'bash ~/pm-backtest/scripts/migrate-directional-drop-retired-knobs.sh \ /home/deploy/pm-backtest/instances/harvester-engine/config/config.db' ssh deploy@poly.grot.app \ 'bash ~/pm-backtest/scripts/migrate-directional-drop-wallet-pct-limit.sh \ /home/deploy/pm-backtest/instances/harvester-engine/config/config.db'Оба скрипта делают копию рядом с базой (
config.db.bak-deadknobs-<метка>,config.db.bak-walletpct-<метка>, метка — та же маска, что уbak-relexit-*), печатают состояние до и после и проверяют результат. Идемпотентны.Reset движка через дашборд (или рестарт контейнера), чтобы он перечитал
config.db. Строго говоря не обязателен — снятые ключи движок и так не читает, — но оставляет рантайм и файл в одном состоянии.
Если миграцию применили РАНЬШЕ выката (нарушен порядок). Это отдельный отказ, не «неудобство отката», и выглядит он обманчиво тихо:
- Работающий СТАРЫЙ движок продолжает торговать. Конфигурацию он читает ОДИН раз при старте (
config::loader::load_runtime_config) иconfig.dbв рантайме не перечитывает — правка файла его не роняет. - Но любой перезапуск после этого — отказ старта с
missing field entry_price_band_upper_milli. Перезапуск наступает сам: у контейнераpm-engine-harvester-engineполитикаunless-stopped, а кнопка Reset на дашборде намеренно завершает процесс (exit(0)) ради перечитывания свежейconfig.db. То есть движок останется лежать при открытых позициях, и причина будет выглядеть как «Reset сломал движок». - Выход: либо немедленно выкатить новый бинарь, либо восстановить копию
config.db.bak-deadknobs-<метка>.
Откат ПОСЛЕ применения миграции требует вернуть два обязательных ключа, иначе старый бинарь не стартует. Основной путь — восстановить копию (sqlite3 '<config.db>' ".restore '<config.db>.bak-deadknobs-<метка>'"), ручной — блок ОТКАТ в docs/operations/config-migration-2026-07-30-drop-redundant-directional-knobs.sql. Откат max_wallet_pct_bp не нужен ни в каком порядке: это поле было Option, и старый бинарь грузит смигрированную строку.
Сохранение строки через форму миграцию НЕ выполняет и рассчитывать на это нельзя. Дашборд сохраняет definition послойным слиянием поверх исходной записи (deepMergeDefinition), поэтому ключи, которых форма не производит, остаются на месте: проверено прогоном настоящей цепочки flattenJsonToDottedKeys → expandDottedKeysToJson → deepMergeDefinition на боевой строке — потерянных ключей ноль, снятые четыре тоже уцелели. Снимает их только миграция.
Этап наблюдения входа — тень, приёмка, блокирующий режим, откат
Семантика настроек, предикатов, границ отмены и эпистемика тени — directional-harvester.md §Этап наблюдения входа. Здесь — только порядок действий оператора.
Состояние по умолчанию: этап выключен. Боевая строка секции
directional.entry_observationне несёт (снимокcrates/pm-engine/tests/fixtures/live-strategy-definition-2026-07-30.json), путь входа — сегодняшний Direct. Включение — только явным решением оператора.
1. Включение тени
Правка конфигурации — ТОЛЬКО конфигуратором или pm-cli strategy. Прямой SQL по config.db запрещён: снимок версии защищён контрольной суммой содержимого и отпечатком контракта, и правка мимо конфигуратора ломает целостность снимка. В форме дашборда есть переключатель «Наблюдать за кандидатом до заявки»; выключенное положение записывает "entry_observation": null, включённое — секцию целиком со стартовыми значениями.
Порядок:
Выкатить бинарь, который секцию понимает (
scripts/deploy-vps.sh engine-bot), с явного разрешения оператора. Выкат сам по себе ничего не включает: строка секции не несёт, путь входа не меняется.Что включённая секция стоит торговому пути — измерено офлайн на зафиксированной записи и приложено к воротам выката:
openspec/changes/entry-observation-stage/latency-measurement-2026-08-12.md(после архивации изменения путь станетopenspec/changes/archive/…/entry-observation-stage/latency-measurement-2026-08-12.md). Коротко: устойчивая прибавка есть у одной области — секундного тика (+4.47…+8.78 мкс к медиане, 9/9 пар обеих партий), суммарное время оценок растёт на ~1.4 %, поток заявок бит-идентичен по отпечатку значимых полей. Эти числа — НЕ верхняя граница боевой цены, и читать их так нельзя. Замер офлайновый и покрывает половину вопроса; три границы объявлены в самом отчёте: (1) дисковая сторона журнала не покрыта вовсе — писатель вне торгового пути и в офлайн-реплее отсутствует, его цену показывает уже живой прогон по счётчикамenqueued/committed/dropped(шаг 5 ниже); (2) прибавка к тику снята на карте из 71 кандидатуры против боевой вселенной ~1074 рынков и потолка 10 000, а тиковый проход идёт ПО карте — боевую прибавку измеренная сверху не ограничивает; (3) числа сняты не на боевой машине, и у замера есть собственный пол (1.4–7.4 мкс). Продолжительной нагрузочной проверки — p99 всего цикла живого процесса вместе с задачей-писателем — на момент выката нет вовсе. Повторить замер после любой правки этапа, удорожающей путь оценки, ПОВТОРИТЬ ЗАМЕР НЕЧЕМ: инструментscripts/bench/entry-observation-latency.shмерил цену секции через офлайн-реплей и снят вместе с ним 2026-08-21. Живой рядpm_engine_scanner_loop_lag_secondsего НЕ заменяет — он мерит интервал между итерациями цикла, а не длительность обработки кадра. Длительность обработки кадра несётpm_engine_hook_duration_seconds{hook="on_market_data"}; перцентиль по обоим считаетсяhistogram_quantile(...)по_bucket. Прежние суффиксные ряды одного перцентиля (…_p99_seconds) остаются запасной ветвью панелей до накопления histogram-истории и снимаются отдельным шагом.Включить секцию конфигуратором с
mode = shadow. Сохранение создаёт новую версию конфигурации; сама по себе публикация движок не перезапускает.Перезапустить движок —
config.dbподхватывается ТОЛЬКО стартом процесса. Штатный способ — кнопка Reset на дашборде (cancel-all + rotate + самостоятельный перезапуск процесса, local-dashboard-runbook.md §6.2). Этот Reset безопасен: журнала ещё нет. КАЖДЫЙ следующий Reset обрывает окно сбора — см. шаг 2 приёмки ниже.Проверить, что этап поднялся: в логе старта строка
entry-observation verdict journal ENABLEDс полямиmode,config_digest,retention_hours. Её отсутствие означает, что секции в загруженной версии нет (движок стартует и без неё — молча).Проверить операторские поверхности через ~минуту работы:
bash# Метрики движка — рецепт C из [CLAUDE.md](../../CLAUDE.md) # (проброс порта в туннельную сеть; наружу порт не публикуется). curl -s http://127.0.0.1:31350/metrics | grep pm_engine_entry_observationОжидается ненулевой
pm_engine_entry_observation_admitted_totalи ненулевой уровеньpm_engine_entry_observation_candidates; при этомpm_engine_entry_observation_journal_dropped_totalобязан оставаться нулём. Ряды полноты журнала (…_journal_enqueued_total,…_committed_total,…_write_failed_total,…_dropped_total,…_pruned_total,…_epoch_open_failed_total,…_flush_watermark_seq) публикуются КАЖДЫМ тиком насоса метрик, включая нули: при включённой секции они существуют с первого тика, ещё до первой записанной партии. Поэтому ОТСУТСТВИЕ любого из них читается как «журнала нет вовсе» (секция выключена) либо как поломка сбора — но никогда как «потерь не было». Там же обязан быть нулевымpm_engine_entry_observation_journal_epoch_open_failed_total: пока строка эпохи не открыта, её вердикты — СИРОТЫ, и оракул приёмки исключает их внутренним соединением. Ненулевое значение читается вместе с запросом грязных эпох (ENTRY_OBSERVATION_UNCLEAN_EPOCHS_SQL): он называет такие строки отдельной популяциейsource = 'orphan_verdicts'с числом выпавших строк. Писатель повторяет открытие перед каждым батчем, поэтому счётчик, выросший один раз и остановившийся, означает «хранилище моргнуло и эпоха открыта повтором», а растущий — что строки эпохи нет до сих пор. В теневом режиме воронка входа НЕ меняется — это признак исправности, а не поломки: ступени наблюдения атрибутируются только в блокирующем режиме.Когда нужен ответ «почему остановлен ИМЕННО ЭТОТ рынок ИМЕННО СЕЙЧАС», поднять трассу допуска: переменная
PM_ENGINE_ADMISSION_TRACE_CONDITION_IDсcondition_idодного рынка (по умолчанию трасса выключена, включается ровно на один рынок, перезапуск процесса обязателен), лог-цельadmission_trace. Стадии этапа —entry_observation,entry_fill_budget,entry_reprice_floor; префиксshadow:в исходе значит «измерено, поток заявок не тронут». Метрики и воронка агрегатны и на этот вопрос не отвечают. Полный перечень исходов — directional-harvester.md §Этап наблюдения входа, подраздел «Что этап оставляет оператору».
Что тень НЕ делает: ни одна заявка не задерживается и не подавляется. Расхождение потока заявок с прежним — дефект, а не настройка; при обнаружении выключать секцию, а не «подкручивать пороги».
2. Сбор данных и приёмка
До старта сбора зарегистрировать критерии приёмки (иначе пороги окажутся выбранными по уже увиденным данным): пороги цены терминальности, допуск превышения бюджета (интервал тика плюс задержка подтверждения отмены), требование dropped = 0, допустимое число эпох без чистого конца, минимальное чистое покрытие по каждому предикату.
Окно сбора объявляется «между операторскими сбросами». Reset — штатный способ подхватить свежую config.db, и он же помечает ВСЮ накопленную популяцию журнала признаком утраченной связи с orders (linkage_lost). Один Reset внутри окна обесценивает всю выборку: оракул приёмки честно вернёт linkage_lost вместо поимённого списка. Практическое следствие — окно начинается ПОСЛЕ последнего Reset, то есть после самого включения секции.
Хранение против окна. Строки старше verdict_retention_hours удаляет задача-писатель, просматривая по 64 самых старых строки за проход; удаление наблюдаемо: счётчик pm_engine_entry_observation_journal_pruned_total и водомер entry_observation_verdict_retention (newest_pruned_verdict_at_ms — граница, ниже которой выборка неполна по построению; отсутствие строки означает, что обрезка не срабатывала ни разу). Правило: окно приёмки обязано ЦЕЛИКОМ уместиться внутри срока хранения, либо срок поднимается ЗАРАНЕЕ, либо окно выгружается снимком форензики до обрезки. Стартовое значение — 168 ч (7 суток).
Расчёт приёмки — SQL ENTRY_OBSERVATION_BUDGET_ACCEPTANCE_SQL (crates/pm-engine/src/adapters/storage/sqlite.rs): соединение журнала с durable-таблицей orders и бухгалтерией позиций, параметр — проверяемое значение бюджета. Он считает только эпохи процесса с чистым концом; число эпох без чистого конца — отдельный критерий приёмки, а не повод их молча выбросить. Сверку филлов вести штатным инструментарием (scripts/analysis/forensics.sh, skill trade-forensics): строка позиции — не доказательство налива. Подъём запроса в отдельную подкоманду форензики — работа воротного шага, ad-hoc скрипты писать запрещено.
Знаменатели, которые нельзя брать наивно:
- чистый конец эпохи — условие НЕОБХОДИМОЕ, но не достаточное. Порядок завершения задачи-писателя и производителей вердиктов не установлен (обе задачи висят на одном токене отмены в одном наборе), поэтому строка, предложенная в момент завершения, может быть учтена принятой и никем не зафиксирована, а инкремент потери — лечь уже после снимка счётчиков эпохи. Следствие:
dropped = 0у эпохи с чистым концом не является доказательством нулевой потери, и эпоху, чьё завершение пришлось на активную работу движка, приёмка обязана рассматривать наравне с эпохой без чистого конца. Порядок завершения — открытый вопрос владельца завершения процесса, а не настройка журнала; - строки
order_terminalне покрывают отказ биржи по уже принятой заявке — популяция исходов заявок неполна (оракул приёмки ею не пользуется); - погоня, выпущенная при насыщенной карте кандидатур, строки
episodeне получает — «эпизодов на рынок» неполно за периоды насыщения (признак — ненулевойpm_engine_entry_observation_saturation_refused_total); - калибровка глубины, ранга и осушения — только по кандидатурам без признака собственной заявки; откат на загрязнённую выборку запрещён, нулевое чистое покрытие = предикат не калибруем.
3. Переход в блокирующий режим
Отдельные ворота после приёмки: боевые значения всех настроек и подписанный список отсечённых победителей. Переключение — mode = enforce конфигуратором плюс перезапуск процесса; двоичный файл тот же. Потолок осушения в блокирующем режиме запрещён загрузчиком в ЛЮБОМ заданном виде — отказ смотрит на присутствие значения, а не на его величину, поэтому 0.0 отвергается наравне с 0.9, и перед переключением max_ldi_during_observation обязан быть приведён к null («выключил нулём» не считается — движок откажется стартовать).
После включения смотреть: ступени воронки (удержания по предикату, отмены по причине, chase_budget_exhausted, chase_floor_blocked), метрики (…_cancelled_total{reason}, pm_engine_entry_fill_budget_exhausted_total, pm_engine_entry_reprice_floor_blocked_total), журнал вердиктов и фактическую задержку подтверждения отмены против предзарегистрированного допуска. Обратное переключение (mode = shadow) — тем же путём, конфигуратор плюс перезапуск; критерии отката записываются ДО включения.
4. Откат
Порядок жёсткий: СНАЧАЛА убрать секцию конфигуратором, ПОТОМ откатывать бинарь.
Причина относится к предыдущему бинарю, на который выполняется этот rollback: его корневой DirectionalConfig ещё игнорировал незнакомую секцию entry_observation, поэтому он стартует и молча не исполняет её. Текущий бинарь строгий на корне; это не разрешает менять порядок execution-cutover, описанный выше.
- этап не работает, журнал не пишется, метрик и ступеней воронки нет, а в
config.dbлежит настройка, которую никто не читает — отличить «тень выключена» от «этап сломан» на операторских поверхностях нечем; - обрезка журнала выполняется задачей-писателем, а её у старого бинаря нет: накопленные строки перестают удаляться и лежат на диске до следующего запуска бинаря с секцией;
- при возврате бинаря вперёд этап оживает с тем режимом, который лежит в строке. Откат из-под
enforceбез снятия секции вернёт блокировку входов автоматически, без отдельного решения оператора.
Порядок:
- Конфигуратором выключить переключатель наблюдения (запишется
"entry_observation": null) и опубликовать версию. - Перезапустить движок — убедиться, что строки
entry-observation verdict journal ENABLEDв логе старта больше нет. - Откатить бинарь штатным путём (§Rollback procedure).
Обратный порядок (сначала бинарь) не ломает торговлю, но оставляет систему в описанном тихом состоянии; если так уже вышло — снять секцию конфигуратором можно и после, отдельного восстановления не требуется.
Про дашборд. Схема конфигурации входит в отпечаток контракта версии, поэтому откат бинаря ДАШБОРДА к выпуску без секции делает уже созданные версии непубликуемыми (version_not_publishable, лечится copy-forward восстановлением). Это общее свойство любой смены схемы, а не особенность этой секции — но при полном откате (deploy-vps.sh engine) его надо ожидать.
5. Диск и WAL — что известно и что НЕ измерено
- Журнал пишется отдельной задачей через ограниченную очередь (4096 строк, запись пачками до 128 строк); торговый путь синхронной записи не делает, переполнение очереди — потеря строки со счётчиком, а не задержка заявки.
- Строка рождается на кандидатуру и на эпизод, а не на оценку, поэтому объём много ниже темпа оценок. Измеренная плотность на трёхминутной записи — 13 строк при боевых стартовых значениях и 71 при мгновенном созревании — это НИЖНЯЯ граница: на короткой ленте нет ни дребезга границы допуска, ни созреваний при
min_observation_seconds = 300. - Стоимость самой записи (fsync/WAL SQLite и соперничество за процессор в общем рантайме) воспроизводимым замером НЕ снята: замер стоимости секции выполнялся в реплее, где задачи-писателя нет. Абсолютные значения задержки снимались не на боевой машине.
- Практическое следствие: следить за
…_journal_dropped_total(обязан быть нулём), за…_journal_flush_watermark_seq(растёт — писатель жив) и за размеромengine.db— возврат свободных страниц системе делает периодическийdeploy/server/scripts/vacuum-engine-db.sh(incremental vacuum по расписанию). Числа дискового расхода на боевой плотности будут известны только по форвард-данным.
Смена активной стратегии — оператор
Рабочую строку выбирает НЕ окружение, а признак активации в config.db: предикат enabled = 1 AND wallet_label = <кошелёк инстанса>. Переменной BOT_STRATEGY_LABEL и аргумента --strategy-label больше нет.
Порядок смены:
- Привести инстанс в ПЛОСКОЕ состояние — открытых позиций быть не должно. Проверка:
scripts/analysis/forensics.sh exit-protection --engine --instance <inst>→open_rows=0. Если позиции есть — выйти из них штатным рабочим процессом перезапуска с опцией «Выйти из всех позиций». - Назначить новую активную строку ОДНОЙ операцией: конфигуратор дашборда либо
pm-cli strategy activate --label <label> --engine-db <путь к engine.db>. Пары «выключить/включить» больше нет: между двумя запросами база оказывалась бы в состоянии «активной нет», и перезапуск в этом окне ломает старт. - Перезапустить движок штатным рабочим процессом. До перезапуска панель показывает расхождение назначения и факта как ожидающий перезапуск.
Отказы, которые вы увидите вместо тихого старта не с той стратегией:
| Состояние | Что происходит |
|---|---|
| активной строки нет | движок не стартует; названы кошелёк и путь config.db |
| активны две и более | движок не стартует; перечислены все метки |
| открытые позиции при смене | писатель отклоняет активацию, называя обе метки и число позиций |
| метка прошлого запуска ≠ активной И есть позиции | движок не стартует: чужой инвентарь не обслуживается чужими порогами |
Снятие активации (pm-cli strategy deactivate) приводит базу в состояние «активной нет», при котором движок стартовать откажется, — это отдельное явное действие с подтверждением, а не половина смены.
Миграция схемы config.db v3 → v4 — точка отката
Первый запуск нового бинаря переводит схему на v4 и добавляет частичный уникальный индекс единственности активной строки. Перед записью создаётся резервная копия config.db.pre-v4.bak — это и есть точка отката: остановить процесс, вернуть файл на место, вернуть прежний бинарь.
Миграция ОТКАЗЫВАЕТ, если на кошельке активны две и более строк, и перечисляет их метки. Активацию она не снимает сама: какая строка рабочая — знает оператор. Приведите базу в порядок pm-cli strategy deactivate --label <лишняя> и повторите запуск.
Ограниченная тестовая итерация через System Restart
- Ввести persistent Drain и дождаться
open_positions == 0, если нужна выборка без старого инвентаря. Старые позиции разрешено сохранить, но они намеренно не входят в новый счётчик. - Открыть «Рестарт системы». При необходимости выбрать exit-all и/или «Сбросить статистику», затем указать положительное N в поле «Остановить после N новых позиций». Положительный N всегда включает cancel-all; exit/reset/forget не включаются автоматически.
- Подтвердить restart. Workflow сначала завершает выбранные действия, затем сохраняет pending iteration. Ошибка handoff оставляет процесс живым и показывает phase
failed; вручную рестартовать в обход ошибки нельзя. - После respawn проверить
/api/local/seed.position_iteration: новыйiteration_id, stateactive, ожидаемые limit/opened/reserved/remaining. Те же значения обновляются событиемposition_iteration_changedи gaugespm_engine_position_iteration_*без отдельного polling. - После N-й новой уникальной позиции ожидать persistent Drain с reason
position_iteration_limit, отмену resting BUY, продолжающиеся SELL/redeem и ровно один Telegram alertpersistent_drain_entered. DCA/order/fill не являются отдельной позицией и счётчик не увеличивают.
Rollback бинаря без limiter-support разрешён только после terminal state итерации: сначала persistent Drain, затем flat и проверка, что active row нет. Если итерация ещё active, старый binary не выкатывать: завершить её текущей версией (достижением limit либо принятым новым ограниченным restart, который атомарно помечает прежнюю replaced_by_operator_restart) и снова проверить Drain/flat. Затем применять rollback только через scripts/deploy-vps.sh engine; старый процесс не умеет восстановить active admission boundary.
Manual entry-halt (DRAIN MODE) — оператор
Применимо к pm-engine (instance
harvester-engine, deploy-таргетengine-bot). DRAIN MODE — обратимый operator-режим: движок перестаёт выставлять НОВЫЕ BUY (gate режет толькоSide::BuyвOrderRouter::allocate), но всё, что не является новой BUY-аллокацией, не затронуто — SELL-выходы (GTC sell-лесенки, stop-loss FAK, forced-exit), redeem и reconciliation продолжают работать, открытые позиции сливаются в flat. Отличается от emergency-stop (глушит обе стороны, необратим) и reset (стирает runtime-state + рестарт) — полная трёхсторонняя таблица:../target/risk-and-emergency.md§«DRAIN MODE — ЕДИНЫЙ стоп/возобновление (ручной + авто)».
Контроль — только через Local-API. Drain НЕ strategy-knob и НЕ env-override: нет directional.* ключа и нет env-переключателя самого drain. Живой контроль возможен исключительно через HTTP (POST /api/local/drain|resume) — кнопкой в дашборде или curl. pm-cli drain НЕ управляет (запись в БД применилась бы только на следующем bootstrap, не на лету).
Флоу оператора (обратимый цикл)
- Войти в drain. Дашборд
harvester-engine.grot.app→ кнопка «Drain» в top-bar (data-testid="drain-button") → подтвердить вDrainDialog. Движок отменит resting BUY, заблокирует новые входы; выходы продолжат идти. - Дождаться flat. Баннер drain держится, пока есть открытые позиции. Сигнал завершения —
open_positions == 0(то же значение, чтоmetrics.open_positionsв/seed). ПоллитьGET /api/local/drain(полеopen_positions) илиGET /api/local/seed(metrics.open_positions) до0. - Снять drain (когда нужно возобновить торговлю). Кнопка «Resume» (
data-testid="drain-button-active") → движок снимает entry-halt. BUY НЕ проходят сразу: снятие последней причины растит поколение открытия, и первый же вход взводит ПРОГРЕВ — торговля возобновляется по охвату книг, а не по нажатию кнопки. Ход прогрева виден ступеньюwarmupв воронке.resumeснимает ТОЛЬКО drain: остановка жизненного цикла (bootstrap/reset), деградация доступа к CLOB и per-market caps остаются в силе — гейты независимы и соединены по И.
После coordinated scripts/deploy-vps.sh engine этот общий оперативный рецепт НЕ используется: release остаётся ready-to-resume, а снять drain можно только scripts/deploy-vps.sh resume --release-id <id>, чтобы до первого нового BUY существовала durable T0-отсечка. UI/curl resume после релиза обошёл бы границу измерений и считается ошибкой оператора.
Drain персистентен: переживает рестарт процесса И дашбордовый Reset (строка entry_halt_state не стирается) — после respawn движок вернётся в drain, пока оператор не сделает resume.
Primary-путь — UI-кнопка. Кнопка + DrainDialog присутствуют в SPA (frontend/pm-engine-dashboard-web/src/App.vue, components/DrainDialog.vue, stores/drain.ts) — проверено по коду фронтенда. SPA бьёт в dashboard-proxy (/api/local/drain), который форвардит на upstream engine SSE (PM_ENGINE_SSE_URL, legacy-зеркало PM_BOT_LOCAL_API_URL; default http://127.0.0.1:31342 = дефолтный PM_ENGINE_SSE_BIND). На проде compose задаёт PM_ENGINE_SSE_URL=http://pm-engine:31342 — сверить конфиг дашборда на живом VPS перед тем, как полагаться на кнопку как на единственный путь. Оперативный факт: resume на engine-bot уже выполнялся вживую (2026-07-17).
Endpoints (code-verified контракт движка)
Прямой контракт pm-engine (сервер поднят при PM_ENGINE_SSE_ENABLED=true; bind PM_ENGINE_SSE_BIND, default 127.0.0.1:31342; bearer PM_ENGINE_SSE_TOKEN, обязателен при non-loopback bind):
POST /api/local/drain(тело опционально{"reason":"..."}, defaultoperator_drain) →200{draining, reason, since, open_positions, cancelled_buys, emergency_exit}. Идемпотентно (повтор →200, не409).POST /api/local/resume→200{draining:false, reason:null, since:null, open_positions, cancelled_buys:0, emergency_exit:false}.GET /api/local/drain→ тот же 6-полевойDrainStatusResponse(reason/since=nullкогда не draining).
Вторичный путь — SSH/curl (СВЕРИТЬ на живом VPS)
Если UI недоступен, drain снимается/ставится curl'ом напрямую в engine Local-API. Точная форма зависит от рантайма контейнера и НЕ проверена в этой сессии — перед применением сверить на живом VPS: имя engine-контейнера, что порт 31342 слушается на нужном интерфейсе, задан ли PM_ENGINE_SSE_TOKEN (тогда нужен заголовок Authorization: Bearer <token>), и присутствует ли curl в контейнере. Шаблон (не авторитетный, требует проверки подстановок):
bash
# СВЕРИТЬ: <engine-container>, порт/интерфейс, наличие токена и curl.
ssh deploy@poly.grot.app \
'IP=$(docker inspect -f "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{break}}{{end}}" <engine-container>); \
curl -s -m5 -X POST "http://$IP:31342/api/local/resume"' # либо /drain
# Поллинг до flat:
# curl -s "http://$IP:31342/api/local/drain" # поле open_positions → 0Если PM_ENGINE_SSE_TOKEN задан — добавить -H "Authorization: Bearer <token>"; без совпадающего токена запрос вернётся 401.
Деградация биржи / плановые работы — оператор
Применимо к pm-engine. Деградация CLOB и плановые работы биржи — самоснимающийся гейт входов, НЕ drain: операторской ручки у него нет и не нужно. Механика, классы улик и пороги — SSOT
../target/risk-and-emergency.md§«Доступ к бирже (EXCHANGE-HEALTH)»; алерты и метрики —alerts-and-monitoring.md.
Что движок делает сам
- Детектирует деградацию по классифицированным исходам собственных попыток: JSON
401/403, HTML/WAF block или подтверждённое биржей maintenance-событие ⇒ Degraded/Open мгновенно; ≥5 подозрительных/транспортных исходов за 60 с ⇒ Degraded/Open по порогу. Параллельно опрашиваетstatus.polymarket.com/summary.json(каждые 30 с): активные или анонсированные (≤5 мин до старта) плановые работы закрывают входы упреждающе. - Блокирует ТОЛЬКО новые BUY-входы. SELL-выходы, стопы, капитуляция, redeem и reconciliation работают без изменений — гейт стоит после SELL-обхода, структурно не может задеть выход.
- Снимается сам только по доказательству. Не чаще раза в 60 с движок допускает одну полную read-only authenticated пробу. Только успешная терминальная пагинация закрывает circuit; провал возвращает его в Open. Во время плановых работ статус-страница удерживает пробы, но сама никогда не открывает входы.
- Не переживает рестарт. Состояние только в памяти: после рестарта процесс стартует Healthy и, если биржа всё ещё деградирует, честно передетектирует Degraded за ≤1 окно из свежих исходов.
Что видит оператор
| Поверхность | Что показывает |
|---|---|
| Telegram | одна пара на эпизод: exchange_degraded_entered (Critical, разбивка классов улик + корроборация статус-страницы) и exchange_degraded_cleared (Warning, длительность); неудачные HalfOpen-пробы сообщений не добавляют; отдельно остаются status_page_transition и stop_deferred_restart_prolonged |
/metrics | pm_engine_clob_health_state (0/1/2), pm_engine_clob_error_events_total{class}, pm_engine_clob_circuit_transitions_total{from,to}, pm_engine_clob_probe_attempts_total, pm_engine_clob_probe_results_total{result}, pm_engine_clob_degraded_seconds_total, pm_engine_entry_blocked_exchange_health_total, pm_engine_status_page_state (0/1/2/3), pm_engine_status_poll_failures_total, pm_engine_status_page_component_probe_failures_total, pm_engine_status_page_foreign_maintenance_suppressions_total, pm_engine_stop_restart_defer_active, pm_engine_stop_restart_defer_seconds_total |
| Воронка входа на дашборде | стадия blocked_exchange_degraded (вторая в цепочке, сразу после drain) — под деградацией оценки честно отбрасываются на ней |
| Журнал | rule=exchange_health_degraded / exchange_health_recovering / exchange_health_recovered (переходы FSM), rule=status_page_maintenance_block (вход в блок работ: причина page_under_maintenance / maintenance_in_progress / announced_maintenance_imminent), rule=status_page_maintenance_release (работы ушли, удержание снято), rule=status_page_maintenance_foreign_only / ..._ended (работы идут, но неисправны ТОЛЬКО чужие компоненты — входы не закрывались), rule=status_page_component_probe_unavailable (покомпонентный статус недоступен — блок остаётся из осторожности), rule=status_page_transition, rule=status_page_poll_failsoft (страница недоступна — НЕ сбой биржи), rule=status_page_schema_anomaly (форма ответа или ростер компонентов дрейфанули) |
Когда вмешиваться
- Затяжная деградация при молчащей статус-странице. Алерт с корроборацией «статус-страница молчит» и долгий
pm_engine_clob_health_state = 2— возможна частичная деградация, не попавшая на страницу, ЛИБО проблема локальной сети VPS→Cloudflare. Смотреть разбивку классов: перевесtransportуказывает на сеть; проверить связность с VPS и другие upstream'ы (Gamma-счётчик, WS). stop_deferred_restart_prolonged. Позиция ≥5 отложек подряд или >10 мин без исполнимого стопа: matching engine держит post-only-окно дольше обычного. Проверить статус-страницу биржи и позицию вручную (книга, стакан, открытые ордера); движок продолжает ре-атаковать сам — алярм означает «посмотри», не «сделай за движок».- Расхождение со статус-страницей. Страница показывает инцидент (
HASISSUES), аpm_engine_clob_health_state = 0и входы идут — штатно: инциденты сами по себе входы НЕ закрывают (блокируют только работы), а телеметрия проблем не видит. Страница показывает работы (UNDERMAINTENANCE/INPROGRESS), а гейт не в Degraded — ШТАТНО ровно в одном случае: работы затронули только чужие компоненты (Perpetuals — их API движок не использует). Признак — строкаrule=status_page_maintenance_foreign_onlyв логе и ростpm_engine_status_page_foreign_maintenance_suppressions_total; в логе перечислены неисправные компоненты с пометкой[foreign]. Второй штатный случай — покомпонентный статус недоступен: строкаrule=status_page_component_probe_unavailableи ростpm_engine_status_page_component_probe_failures_total; тогда блок стоит ИМЕННО из осторожности, а не потому что задета наша поверхность, и искать аварию CLOB не нужно. Если НИ ОДНОГО из этих признаков нет, а работы идут — так быть не должно (поллер обязан записать definitive-улику и удержание): сверитьpm_engine_status_page_state— значение3(Unknown) означает ОДНО из двух: первые ~30 с после старта процесса поллер ещё не сделал ни одного опроса (штатно, дождаться первого тика), либо поллер потерял страницу (≥5 подряд неудач) и блок работ снят fail-soft'ом (тоже штатно, детекция продолжает работать от телеметрии); иначе смотреть логиstatus_page_poll_failsoft/status_page_maintenance_block.
Чего НЕ делать
- Не жать resume.
POST /api/local/resumeк этому гейту не относится — он снимает ТОЛЬКО персистентный drain. Гейт здоровья снимется сам; ручного снятия не существует. - Не перезапускать процесс ради «снятия» гейта. Рестарт теряет состояние эпизода и не является доказательством доступности authenticated CLOB.
- Не трактовать
Unknownстатус-страницы как сбой биржи. Недоступность стороннего статус-сервиса — fail-soft: торговля продолжается, детекция работает от собственной телеметрии. - Не входить в drain «на всякий случай» при плановых работах. Гейт уже закрыл входы и откроет их после успешной пробы; персистентный drain потребует ручного resume, о котором легко забыть.
Неразрешённая отправка — оператор
Применимо к pm-engine. Состояние возникает, когда отправка заявки состоялась, а ответа, доказывающего отсутствие ордера, движок не получил. Правило и порядок сверки —
../target/engine.md§«Три исхода отправки»; контракт биржи —../architecture/polymarket-clob-order-api.md.
Что это означает
Ордер МОЖЕТ стоять на бирже. Поэтому движок:
- удерживает резервацию — эти деньги вычтены из доступного бюджета;
- блокирует повторный выпуск намерения по тройке «кошелёк, исход, сторона» (переживает закрытие сигнала и перезапуск процесса);
- не переводит строку в отказ и не публикует событие отказа: факт провала не установлен.
Времени как основания разрешить не существует. Ни давность, ни перезапуск процесса резервацию не разжимают. Выход только по факту: пришедшее событие жизненного цикла, вердикт сверки или явное решение оператора.
Что видит оператор
| Поверхность | Что показывает |
|---|---|
| Дашборд, карточка «Неразрешённые отправки» (секция «P&L и кошелёк») | количество, удержанная сумма, возраст самой старой, признак «сверка без ответа». Карточки нет вовсе ⇒ движок этих полей не шлёт (старый бинарь); «0» ⇒ состояния нет |
| Полоса тревоги в шапке (рядом с аварийной остановкой / drain / RTDS) | появляется только при ненулевом количестве; поднимается до уровня ошибки, когда есть записи в стадии «сверка не дала ответа» |
/metrics | pm_engine_unresolved_submits, pm_engine_unresolved_submits_held_micro_usd, pm_engine_unresolved_submits_oldest_age_seconds |
| Telegram | сигнал unresolved_submits_held — только при старте процесса с неразрешёнными строками (reminder-семантика). Молчание Telegram НЕ означает отсутствия состояния: проверять метрику или карточку |
| Журнал | rule=ambiguous_submit (возникло), rule=unresolved_submit_blocked (намерение подавлено), rule=unresolved_submit_resolved (разрешено), rule=unresolved_submits_restored (пережило перезапуск), rule=unresolved_submit_probe_inconclusive (проход сверки не дал ответа — в поле reason названа причина), rule=bootstrap_recognised_own_unacked_order (при старте движок узнал СВОЮ неподтверждённую заявку в перечне открытых ордеров биржи и НЕ снял её как чужую) |
Флоу оператора
Дать сверке отработать. Положительный вывод («ордер в книге») приходит немедленным запросом, за один-два REST-вызова. Отрицательный («ордера не было») — не раньше тихого периода ЭТОГО рынка плюс ожидания ближайшего прохода работника (30 с). Тихий период = собственная задержка рынка (
seconds_delay) + 30 с запаса, то есть на рынке без задержки это 30–60 секунд после отправки, а на спортивном рынке с задержкой 33 с — 63–93 секунды.Отдельный исход, который надо узнавать в журнале:
reason = MarketDelayUnknownозначает, что движок не смог прочитать задержку рынка, и отрицательный вывод по такому рынку НЕ будет вынесен вообще никогда — ни через минуту, ни через сутки. Ждать бесполезно: через пять проходов запись сама встанет в стадию «сверка не дала ответа», и дальше факт устанавливает оператор (шаг 3). Если источник задержки восстановится, запись разрешится без человека — попытки не прекращаются.Посмотреть перечень.
GET /api/local/unresolved_submits— три величины и построчный список (order_uid, рынок, исход, сторона, удержано, возраст, стадия,signing_hash).signing_hash: null⇒ ключа сверки не существует и разрешить состояние выводом «ордер существовал» нечем.Установить факт самостоятельно, если стадия стала «сверка не дала ответа» (
inconclusive). Это штатное терминальное состояние, а не авария: строгие правила вывода сделаны намеренно. Чем устанавливать — открытые ордера биржи по рынку и исходу, лента сделок (scripts/analysis/forensics.sh rejected-sweep --engine), холдинг долей на цепочке.Разрешить явно. Подтверждающий токен нужен в ОБОИХ местах — заголовке и теле, как у
/reset:bashcurl -s -X POST "http://$IP:31342/api/local/unresolved_submits/<order_uid>/resolve" \ -H "Authorization: Bearer $PM_ENGINE_SSE_TOKEN" \ -H "X-Confirm-Session: RESOLVE" -H 'Content-Type: application/json' \ -d '{"confirmation":"RESOLVE","outcome":"no_order","operator":"<кто>","note":"<чем установлено>"}'
outcome | Что делает с деньгами и строкой |
|---|---|
no_order | строка → отказ, резервация освобождена, сигнал закрыт, намерение снова доступно к выпуску |
order_exists | строка → размещена с биржевым идентификатором, резервация ОСТАЁТСЯ под ордером, блокировка снята |
order_filled | то же, что order_exists, но в аудите зафиксировано, что ордер уже наливал; СКОЛЬКО он налил — вопрос фактов исполнения, а не объявления оператора |
Отсутствие подтверждающего токена или неизвестный outcome ⇒ 400 и никаких побочных действий. Нет записи ⇒ 404. Нет ни отпечатка, ни переданного exchange_order_id при order_exists/order_filled ⇒ 409 (no_exchange_id) — тогда либо передать идентификатор в теле ("exchange_order_id": "0x…"), либо разрешать как no_order.
Ловушки, о которых обязан знать оператор
«Принято» на ручной продаже не означает продажи.POST /api/local/positions/{token}/sell отвечает 200 {"queued":true} — это «взведено на СЛЕДУЮЩИЙ такт стратегии», а не «продано». Если по этой паре «исход, сторона SELL» держится неразрешённая отправка, продажа на следующем такте упрётся в барьер и не уйдёт, а одноразовая заявка оператора уже израсходована — на HTTP-канале об этом не сообщат. Признаки: нет события position_closed по SSE, в журнале rule=unresolved_submit_blocked, в Telegram — сигнал класса StopKill с текстом ошибки (душится до одного раза в 15 минут на позицию; приходит, только если контур стоп-тревог подключён). Порядок действий: сначала разрешить неразрешённую отправку, потом повторить продажу. Предпроверка ручной продажи ловит только выходной SELL ТОГО ЖЕ сигнала (409 in_flight); неопределённый выход по цели (directional-exit-…) и неопределённая продажа под ДРУГИМ сигналом на том же токене её проходят.
Заблокированная сторона SELL = позиция без защиты. Неразрешённая отправка на продаже блокирует по этой паре «исход, сторона» ВЕСЬ выходной путь, включая стоп-капитуляцию. Стоп при этом остаётся взведённым и переспрашивает каждый такт, но заявка не уходит, — то есть позиция стоит без работающего защитного выхода до разрешения состояния. Это самый дорогой из исходов удержания и первый по приоритету разбора: неразрешённые записи со стороной SELL разбирать раньше записей со стороной BUY (у тех цена — только замороженный бюджет).
Ордер, разрешённый СВЕРКОЙ, слышим сразу; разрешённый ВРУЧНУЮ — глух до перезапуска. Реестр корреляции боевого клиента наполняется на успешной отправке и гидрацией из таблицы orders при старте, а неопределённая отправка ни туда, ни туда не попадала. Поэтому доказав существование заявки, работник сверки заводит корреляцию задним числом и отдаёт кадры пользовательского канала, ждавшие в буфере, обычному пути — филл книжится как обычный филл (rule=lost_ack_manufactured, rule=lost_ack_correlation_created, а на проигранном исполнении ещё и rule=unresolved_submit_fill_replayed).
Два случая, когда ордер всё-таки остаётся глухим и перезапуск нужен:
- разрешение сделал ОПЕРАТОР через
/resolve, а не сверка: изготовление подтверждения висит на вердикте рубежей, операторский путь его не зовёт; - сверка вердикт вынесла, но изготовление не состоялось — тогда в журнале есть
rule=unresolved_submit_placed_without_correlation(снимок ордера пропал из маршрутизатора) либоrule=lost_ack_not_delivered(кадры вынуты из буфера, а канал событий их не принял — вердикт при этом СОЗНАТЕЛЬНО не применён, деньги остаются удержанными).
Отсутствие unresolved_submit_placed_without_correlation больше НЕ означает «ничего не проверяли»: с волны 14 это правило печатается только когда изготовление действительно не состоялось.
Названная неточность учёта: ставка тейкерской комиссии заявки в строке orders не хранится, поэтому проигранный кадр ТЕЙКЕРСКОГО исполнения книжит нулевую комиссию и занижает издержку (у покоящейся заявки филл мейкерский, и ноль там верен). Видно полем taker_fee_rate_unknown правила lost_ack_correlation_created. Та же граница, что у гидрации реестра после перезапуска.
Кадры пред-подтверждения живут 30 секунд. Филл, чей кадр истёк раньше первого ответа рубежей, проиграть нечем: рубеж знает ОБЪЁМ, но носителя-события больше нет, а книжить объём из работника сверки запрещено (писатель позиций один). Такая запись передаётся оператору (rule=unresolved_submit_fill_needs_operator) — сверить позицию вручную и разрешить через /resolve.
Сброс не сотрёт удержание молча. POST /api/local/reset отвечает 409 с перечнем и удержанной суммой, пока есть неразрешённые записи; решение принимается по самой таблице, а не по зеркалу в памяти. Не сумели прочитать таблицу ⇒ 500, а не «раз не видно, значит нет».
Маршруты живут у ДВИЖКА, дашборд их не проксирует. SPA показывает только агрегат (три величины). Перечень и разрешение доступны напрямую на локальном API движка (PM_ENGINE_SSE_BIND, по умолчанию 127.0.0.1:31342, bearer PM_ENGINE_SSE_TOKEN) — тем же путём, что и вторичный путь drain ниже.
Работник сверки заявок не размещает и не отменяет. Он только добывает факты; деньгами двигает OrderRouter. Сигнал накопления тоже ничего не разрешает и ни одной резервации не освобождает.
Причины отказа заявки — оператор
SSOT каталога кодов и правил тревоги — alerts-and-monitoring.md §«Каталог кодов причины отказа». Здесь — что ДЕЛАТЬ.
Тревога «отказ записан БЕЗ кода причины»
Сигнал означает регрессию НАБЛЮДАЕМОСТИ, а не потерю денег: строка отказа лежит в базе без кода, то есть путь записи обошёл таксономию. Разбор — в порядке убывания вероятности, ровно по четырём источникам, от которых правило и защищает:
- Откат бинаря. Проверить, какая версия движка запущена, и совпадает ли она с выкаченной: сигнал поднимается проходом на СТАРТЕ, и первый его повод — процесс версии без таксономии, писавший строки отказа.
- Ручной SQL. Искать следы правки боевой базы вне штатных инструментов.
- Подсунутая старая либо чужая база. Сверить путь
BOT_CONFIG_DB_PATH/ каталог состояния инстанса и происхождение файла. - Запись обобщённым патчем. Если ни одно из трёх выше — это дефект кода: появилась вторая точка записи отказа мимо проверенного писателя.
Сигнал НАЗЫВАЕТ order_uid и идентификатор строки-ПРЕДСТАВИТЕЛЯ и число провинившихся строк прохода — начинать с них:
bash
scripts/analysis/forensics.sh reject-reasons --engineКатегория (NO CODE — taxonomy regression) в разбивке и есть та популяция.
Сигнал — ОДИН на инцидент за TTL, а не по одному на строку. Если строк больше одной, остальные названы в логе движка (WARN на каждую) и сосчитаны в pm_engine_uncoded_reject_alerts_deduped_total; полный перечень берётся командой выше, а не из сигналов.
Сигнал повторяется после каждого перезапуска, пока строка в базе, и это задумано. Отметка прохода живёт в процессе; durable-флажка «о строке сообщено» нет намеренно — иначе путь наблюдаемости писал бы в терминальную денежную строку. Заглушить сообщение нельзя и не нужно: устраняется ПРИЧИНА. Автоматически дописать код такой строке ЗАПРЕЩЕНО — вывод кода из сохранённого текста воспроизвёл бы удалённый разбор подстрок и записал бы догадку как факт.
Снятие распределения серий отказов
Числа правила «серия отказов подряд на одном рынке» (длина N, окно, TTL, глобальный бюджет) подписываются ТОЛЬКО по форвард-замеру на данных НОВОЙ эпохи. Замер снимается TIMELINE-запросом, который видит и отказы, и УСПЕШНЫЕ РАЗМЕЩЕНИЯ:
bash
# ОКНО обязательно: онлайн-правило выселяет отказы старше окна ДО проверки
# порога. Прогонять несколько окон-кандидатов и смотреть, как меняется
# распределение длин.
scripts/analysis/forensics.sh reject-reasons --engine --window-secs 900
# раздел «серии отказов по (condition_id, side), с учётом успешных размещений»Выборка, видящая ТОЛЬКО отказы, для назначения чисел НЕПРИГОДНА: онлайн-правило сбрасывается успешным размещением, поэтому reject-only-вид склеит серии, которые движок разорвал бы, и завысит длины. То же и с ОКНОМ: без --window-secs запрос считает серии БЕЗ выселения и печатает «окно: БЕЗ ОКНА» — такие длины годятся только как верхняя оценка, подписывать по ним N нельзя. Эквивалентность запроса с окном и чистой функции решения правила закреплена тестом (crates/pm-engine/tests/it/reject_taxonomy_durable.rs::the_offline_reconstruction_agrees_with_the_pure_decision_function), поэтому подписанные по замеру числа означают онлайн ровно то же самое. До-фиксовые записи и backup-БД для назначения чисел использовать запрещено — они годны только для диагностики.
Telegram bot setup (pm-engine)
Применимо для pm-engine in-process AlertEngine и per-event notifier. Контракт env-переменных и alert rules — alerts-and-monitoring.md раздел «pm-engine observability».
Код pm-engine читает УНИФИЦИРОВАННЫЙ BOT_TG_* env (BOT_TG_EVENTS_ENABLED, BOT_TG_BOT_TOKEN, BOT_TG_REPORTS_CHAT_ID, BOT_TG_INSTANCE_LABEL, BOT_TG_EVENTS_BATCH_WINDOW_MS; crates/pm-engine/src/config/loader.rs). Отдельный digest-era TELEGRAM_* env и периодический hourly/daily digest (K4) из кода удалены; env/.env.example тоже несёт только BOT_TG_* (устаревших TELEGRAM_*/PM_ENGINE_DIGEST_* там нет — их удаление зафиксировано в шапке файла). Ниже — контракт по коду.
1. Создать bot
- В Telegram открыть
@BotFather, отправить/newbot, ответить на вопросы о имени и handle. - Сохранить выданный token формата
123456:ABC-DEF.... Это значениеBOT_TG_BOT_TOKEN. - Добавить bot'а в целевой chat / channel (или начать с ним private диалог). Дать boto'у права постить, если это channel.
2. Получить chat_id
- Private chat: написать боту произвольное сообщение, открыть
https://api.telegram.org/bot<TOKEN>/getUpdates, найтиresult[].message.chat.id(положительное число). - Group/channel: добавить
@userinfobot, прочитать chat_id (отрицательное число для group,-100…для channel).
3. Configure pm-engine
В .env-файле instance'а добавить (контракт по коду — BOT_TG_*):
bash
BOT_TG_EVENTS_ENABLED=true
BOT_TG_BOT_TOKEN=<bot_token>
BOT_TG_REPORTS_CHAT_ID=<chat_id>
BOT_TG_INSTANCE_LABEL=harvester-engine # опционально, префикс инстанса
BOT_TG_EVENTS_BATCH_WINDOW_MS=4000 # опционально, окно батча событийПериодический hourly/daily digest (PM_ENGINE_DIGEST_*, TelegramDigestScheduler) из кода движка удалён (0 совпадений) — вместо него per-event notifier с батч-окном (crates/pm-engine/src/observability/engine_event_notifier.rs). Полный список env — env/.env.example (несёт только BOT_TG_*).
4. Restart + verify
Перезапустить процесс pm-engine. В логах ожидаем Telegram sender initialized (и absence of Telegram sender disabled warning).
Sanity-check: спровоцировать тестовый halt (например fail-fast на заведомо невалидном config-knob) — Telegram должен получить pm-engine HALTED сообщение в течение секунд. Сам процесс при этом останется в Halted state, как и предписано якорем A6.
Per-event Telegram-нотификации приходят батчами (окно BOT_TG_EVENTS_BATCH_WINDOW_MS).
Health monitoring
pm-engine /metrics endpoint (loopback :31340) + in-process AlertEngine → Telegram — SSOT alerts-and-monitoring.md. Внешний Prometheus/Alertmanager стек не развёрнут (future).
После каждого deploy:
docker ps⇒ оба контейнераUp.docker logs --tail 50 pm-engine-harvester-engine⇒ нет panic.Из network namespace pm-engine
curl http://127.0.0.1:31340/metrics⇒ exporter доступен на pm-engine default, а counters тикают.scripts/vps-health.sh harvester-engine— послерелизный гейт. Отдельно запускать его послеengine,engine-botилиengine-dashboardне нужно: с 2026-08-27 эти режимы вызывают его сами, и его отказ БЛОКИРУЕТ выкат.Три исхода, а не два.
0— нарушений нет;1— обнаружена неисправность;2— ПРОВЕРКА НЕ СОСТОЯЛАСЬ (сбор сорван, срез недостоверен, поля-источника нет на проводе). Второй и третий блокируют одинаково, но лечатся по-разному: «мы не смогли проверить» — это не «всё хорошо».Пять действующих проверок из пятнадцати записей. Остальные десять —
retired, и причины РАЗНЫЕ: у восьми источник боевой производитель не заполняет, а две (scanner-idle,rtds-disabled) были уровня INFO — они ничего не проверяли, и их сведения переехали в блок снимка. Актуальный состав всегда даётscripts/vps-health.sh --list-checks(JSON:id, состояние, уровень, источник, условие применимости, порог, причина снятия) — этот вывод, а не текст здесь, является ответом на вопрос «что именно проверяется».Неприменимость видна отдельно от успеха. После
engineинстанс остаётся дренированным, поэтому торговые проверки печатаются в разделе «не применимо», а не как зелёные.Разбор без сети:
scripts/vps-health.sh --classify-from <файл>прогоняет сохранённый поток (формат<recv_ms>\tdata: <json>, как его видит сбор).Единственный обход —
HEALTH_GATE=bypass: гейт всё равно выполняется, его отказ и факт обхода печатаются. Любое другое непустое значение, включая прежнееstrict, останавливает выкат — причём ДО сборки, а не после неё. Прежней переменнойSTRICTвнутри гейта больше нет: она решала судьбу уровня WARN, а действующих записей такого уровня не осталось, и настройка ни на что не влияла бы.Быстрый
/healthbackend сам по себе не доказывает, что браузер не перегружен потоком.Dashboard
/health,/api/local/seedи/api/strategiesотвечают; production compose обязан задаватьDASHBOARD_JOURNAL_DB_PATH=/data/engine.db, иначе стратегия fail-loud, а не показывает ложный realized PnL = 0. Пишущих БД у дашборда не осталось: прежняяbacktest-jobs.dbснята 2026-08-21 вместе с заданиями бэктеста, журнал движка процесс только читает.scripts/dashboard-browser-smoke.sh⇒ живая страница «Обзор», connected SSE, модель и renderer каждого sparkline не короче 15 точек, отрицательный/backtestи снятые API. Нереализованный PnL и реальный тик Midnight проверяются на живых строках; пустая сессия возвращаетnot_observable_no_open_positions, а не ложный PASS. Скрипт работает через временный SSH-туннель и стоящий перед ним Host-прокси, который пропускает толькоGET/HEAD; Cloudflare Access,/etc/hostsи ручной Playwright config не требуются.Только после выката стоп-контура
lossless-stop-confirm-trade-window, ДО разрешения новых входов:scripts/stop-evidence-forward-check.sh harvester-engine --window-hours 6 --vm-gap-tolerance-secs 3⇒ вердикт по удержанию свидетельств, разрывам покрытия по источникам, неполным кандидатам, усечению окна свидетелей, исполнению стоп-ордеров и деградации запроса окна.--vm-gap-tolerance-secs— обязательный на полном прогоне флаг без встроенного умолчания (допуск разрыва между соседними образцамиup{}в VictoriaMetrics); 3 с — ориентир 3×интервал опроса. Живым замером 2026-09-03 подтверждена ТОЛЬКО связность и синтаксис запросов PromQL к VictoriaMetrics на боевом узле (86400 образцов за представительный час, максимальный разрыв между соседними — 1 с) — это не полный прогон самого скрипта, а отдельная проверка плюмбинга источника. Коды возврата:0норма,1тревога,2НЕ ИЗМЕРЕНО (тоже запрещает новые входы — отсутствие опровержения не есть подтверждение),3скрипт не смог работать. Перед выкатом тот же скрипт с--preflight-onlyловит пару «stop_loss.flow_sell_share_min_permilleзадан»- «
stop_loss.confirm_source = rtds_actor_buffer», при которой движок откажется стартовать (доля продаж считалась бы по чужой ленте относительно источника подтверждения). Офлайн-смоук самого инструмента —bash scripts/test-stop-evidence-forward-check.sh. Статус: инструмент готов и покрыт офлайн-смоуком; полный прогон скрипта на боевом узле ещё НЕ состоялся (состоялась только проверка плюмбинга VictoriaMetrics выше), поэтому какая из двух ступеней доступа к/metricsтам работает — покажет первый полный живой прогон (скрипт называет сработавшую ступень сам и печатает средство починки при неудаче). Требует бинаря pm-engine с нулевой материализацией счётчика коллизий ключа (в дереве с 2026-08-04): на более старом бинаре рядаpm_engine_trade_evidence_key_collision_totalв/metricsнет вовсе, раздел 7 честно объявляет НЕ ИЗМЕРЕНО, и полный прогон никогда не вернёт0. Если скрипт сообщает «экспортёр отдал неполный набор» — сперва выкатить актуальный бинарь, и только потом судить о вердикте.
- «
Troubleshooting
CLOB circuit-open diagnosis
При JSON 401/403, HTML/WAF block или длительной недоступности authenticated read процесс не завершается. Общий CLOB circuit breaker переходит в Degraded/Open; на bootstrap lifecycle остаётся Reconciling, а во время активной торговли блокирует только новые BUY. SELL-выходы, стопы, redeem и сверка продолжают работать.
Порядок разбора:
- Проверить
pm_engine_clob_health_state(2= Open,1= HalfOpen),pm_engine_clob_error_events_total{class="authenticated_access"}иpm_engine_clob_probe_results_total{result}. - В структурированном журнале сопоставить HTTP status, media type,
Retry-After,CF-Rayиerror_kind. Тела HTML и credentials в журнал не попадают. - JSON auth reject означает недоступность торговой поверхности для текущих credentials; HTML/WAF
403— для текущего egress. Ни один исход не даёт права автоматически ротировать credentials или менять egress. - Движок сам не чаще раза в 60 с выполняет полный read-only authenticated
GET /data/orders. Только успешная терминальная пагинация закрывает circuit. «Зелёная»status.polymarket.com, тишина и успех другой CLOB-операции не снимают блок. - Telegram получает одно Critical-сообщение при открытии эпизода и одно recovery-сообщение при закрытии. Провалы минутных проб не дублируют алерт.
Production-инстанс harvester-engine использует явно заданный BOOTSTRAP_RECONCILE_TIMEOUT_SECONDS=60. Решение оператора от 2026-08-23 основано на четырёх успешных bootstrap-сверках длительностью 108–122 мс и failure-бюджете: пять request timeout по 8 с плюс backoff 1+2+4+8 с занимают около 55 с и достигают порога пяти событий в 60-секундном окне. Это значение конкретного инстанса, не скрытый default для других развёртываний.
Halt-loop diagnosis
Признак: контейнер pm-engine с restart: unless-stopped многократно стартует и сразу завершается. Сигнал оператору сегодня — in-process AlertEngine → Telegram (LifecycleHalted → Critical) и live SSE-событие; lifecycle-события не пишутся в events, поэтому после рестарта durable источник причины — bootstrap_audit и docker inspect. Метрика pm_engine_lifecycle_halt_total экспортируется на /metrics (:31340); VictoriaMetrics опрашивает её раз в секунду, но отдельного vmalert/Alertmanager правила пока нет (см. alerts-and-monitoring.md).
По якорю A6 любой halt возобновляется только полным перезапуском процесса; CLI команды acknowledge нет. Operator workflow:
Остановить restart loop:
docker compose stop pm-engine(илиsystemctl stop pm-engine.serviceесли non-docker). Дальнейшая диагностика без активного процесса.Определить halt reason через exit code последней попытки (маппинг из
crates/pm-engine/src/lifecycle/states.rs::exit_code()):11→Halt(UnexplainedPositionDrift)— локальная позиция > CLOB позиции, расхождение не объясняется trades заbootstrap.lookback_hours.12→Halt(BulkAnomalySuspected)— bootstrap reconciler хотел применить болееbootstrap.auto_heal_action_capдействий за один проход.13→Halt(ReconcileTimeout)— единый deadlineBOOTSTRAP_RECONCILE_TIMEOUT_SECONDSистёк во время вычисления или healing. Недоступность authenticated read теперь открывает circuit и process exit не даёт.14→Halt(DoubleSignalImmediateExit)— два SIGTERM в течение 1s (operator escape hatch).15→Halt(ReconcileInconclusive)— судьба локальной заявки не доказана CLOB-источниками.
Команды:
bashdocker inspect --format='{{.State.ExitCode}}' pm-engine-harvester-engine journalctl -u pm-engine.service --no-pager | tail -50 # non-dockerИзучить аудит-trail — последние bootstrap'ы и их применённые/отвергнутые auto-heal actions лежат в таблицах
bootstrap_audit/bootstrap_actionsвengine-state.db. Backend их отдаёт (PersistencePort::fetch_bootstrap_history). Читать read-only черезpm-cli bot reconcile-history --db-path <engine-state.db> --limit N(join тех же таблиц;crates/pm-cli/src/main.rs). Engine-специфичная командаpm-cli engine reconcile-historyпока deferred (tech-debtPM-ENGINE-H-CLI-RECONCILE-HISTORY).Починить root cause до перезапуска — по halt reason:
UnexplainedPositionDrift: сверить CLOB positions черезpm-cli(или прямо в Polymarket UI) с локальнымengine.db. Если внешний actor открыл/закрыл позиции — синхронизировать руками (pm-cliинструменты position adjustment) или принять CLOB как авторитативный (увеличитьbootstrap.lookback_hoursесли расхождение объяснимо старыми trades за пределами текущего окна).BulkAnomalySuspected: проверить, не было ли catastrophe (массовое resolution / cancel-all извне). Если auto-heal действительно нужен в таком объёме — временно поднятьbootstrap.auto_heal_action_capв definition JSON и опубликовать новую версию (pm-cli strategy versions create ... --mode publishлибо конфигуратор дашборда), перезапустить, потом вернуть default тем же путём.ReconcileTimeout: проверитьbootstrap_auditи этап вычисления/healing; менятьBOOTSTRAP_RECONCILE_TIMEOUT_SECONDSможно только отдельным операторским решением по измеренному времени, у кода нет запасного значения.DoubleSignalImmediateExit: оператор сам сделал двойной SIGTERM — это escape hatch, не bug. Просто перезапустить.
Manual start процесса после починки:
docker compose start pm-engine/systemctl start pm-engine.service. На следующем boot reconciler пройдёт цикл заново; если root cause устранена — пройдёт вReady.
Никогда не использовать restart: always без understanding почему halt сработал — это превратит halt в silent loop без operator review.
Engine lock / startup
DB-lock / повторный старт на той же engine.db: kill previous instance или override PM_ENGINE_DB=.tmp/<unique>.db. Confirm только один контейнер ссылается на тот же engine.db. Heartbeat-TTL DB-lock (как у legacy pm-bot bot_sessions) у движка нет: таблицы bot_sessions в engine.db не существует. «Lock» здесь — только одновременный доступ к SQLite-файлу; снимается остановкой предыдущего процесса либо уникальным PM_ENGINE_DB.
failed to install rustls crypto provider: другой provider installed earlier в процессе. Редко для standalone smoke.
Missing required env vars / fail-fast на инфра-env: движок репортит все missing [REQUIRED] инфра-ключи одной паникой на startup. Append недостающие PM_ENGINE_* + re-run. Напоминание: strategy-knobs в env НЕ задаются — они в config.db (ADR-015).
Network / ports
Address already in use на :31337/:31342/:5174/:31340: ss -tlnp 'sport = :NNNN' для holder; stop или override (PM_ENGINE_DASHBOARD_BIND_ADDR=127.0.0.1:31447, PM_ENGINE_SSE_BIND=…:31449, pnpm dev --port 5175). Дефолт дашборда — 31350; 31337 встречается здесь потому, что smoke Phase 2 биндит его явно, и потому что так настроен VPS.
Geoblock 403 от Polymarket (Hetzner FI = EU = blocked для POST /order): legacy BOT_SKIP_GEOBLOCK в коде pm-engine отсутствует (0 совпадений geoblock/SKIP_GEO в crates/pm-engine/src) — geoblock-bypass knob'а у движка нет. Для live trading из geoblocked region обход не включать.
SSE flat / dashboard «limited mode»: engine SSE выключен (PM_ENGINE_SSE_ENABLED не true) или pm-engine:31342 unreachable из dashboard контейнера. Проверить docker network inspect соответствующей engine-net — оба endpoints attached.
Connection dot stays red: (a) curl http://127.0.0.1:<порт дашборда>/api/local/sessions (дефолт 31350) — 503 ⇒ pm-engine down/wrong port; (b) DevTools → Network → EventSource /api/local/stream должен быть 200 text/event-stream; (c) token mismatch — engine PM_ENGINE_SSE_TOKEN vs pm-engine-dashboard PM_ENGINE_SSE_TOKEN (legacy-зеркало BOT_LOCAL_API_TOKEN).
Dashboard widgets
Risk badges all grey: требует ≥2 fills с non-zero variance. Пустая новая live/probe сессия ожидаемо не имеет достаточных fill-данных. Same root cause: Annualisation ×1.00 (fewer than 2 fills ⇒ median dt undefined, falls back 1; ADR-014 §D7).
Cancel — bulk paranoid modal не появляется: cohort > PARANOID_BULK_THRESHOLD = 5 (ADR-013). На exactly 5 — soft ConfirmDialog; ≥6 — typed-CANCEL modal.
Multi-wallet
GET /api/wallets returns 503: BOT_WALLETS_DB_PATH не set на pm-engine-dashboard. Stop, set, restart. Не auto-discover'ит.
INV-3 fires unexpectedly: pm-cli strategy list показывает текущий wallet_label. Сравнить с BOT_WALLET_LABEL literally (case-sensitive). Pitfall: hot-1 vs Hot-1.
Vite не reach pm-engine-dashboard: vite.config.ts проксирует /api/* на :31337. Если dashboard bind'нул elsewhere — edit proxy или VITE_API_BASE=… pnpm dev.
Misc
MATIC fetcher returns null (dashboard показывает MATIC: —): POLYGON_RPC_URL unset / RPC down. Проверить grep POLYGON_RPC_URL ~/pm-backtest/instances/harvester-engine/.env (public Polygon endpoint, e.g. https://polygon.drpc.org).
rsync --delete на SPA без dist/ subdir: Caddy bind-mount'ит ~/pm-backtest/spa-engine/dist:/spa-engine:ro. Build output должен попасть внутрь dist/. Wrong: rsync … dist/ deploy@…:~/pm-backtest/spa-engine/ (overwrites flat). Right: rsync … dist/ deploy@…:~/pm-backtest/spa-engine/dist/. Recovery после broken: mkdir -p dist && mv index.html assets dist/
docker compose restart caddyвtunnel/.
Redeem failures (Phase J)
pm-engine ведёт учёт redemption в engine.db в той же positions таблице с идентичными колонками (position_uid, redeem_status, redeem_retry_count, redeem_last_error; статус 'FAILED' валиден — crates/pm-engine/src/adapters/storage/sqlite.rs). SQL ниже применим как есть.
REDEEM_FAILED status
Position перешла в Failed после max retries или terminal error. Требуется operator investigation.
Query failed redeems:
sql
SELECT position_uid, condition_id, redeem_retry_count, redeem_last_error
FROM positions
WHERE redeem_status = 'FAILED'
ORDER BY updated_at DESC;Типичные причины:
- Network / RPC: повторные RPC timeouts — investigate Polygon RPC provider health, gas pricing.
- Zero balance: position помечена в
Failed, но CTF tokens already zero. Проверить консистентностьPositionRegistryс on-chain state. - Invalid token id: metadata schema drift — check Gamma response shape для
neg_riskmarkets. - Owner address mismatch: wallet config issue — verify
rt_config.walletсоответствует tx signer'у.
Manual recovery:
Investigate underlying issue.
Если transient resolved → operator может вручную выполнить CAS
Failed → Pendingчерез SQL:sqlUPDATE positions SET redeem_status = 'PENDING', redeem_retry_count = 0, redeem_due_at = <now_unix_ms>, redeem_last_error = NULL WHERE position_uid = ?;Движок подхватывает на следующем tick через
load_duequery.
Stuck SUBMITTED status
Process crashed между mark_submitted и mark_confirmed. tx_hash может быть already submitted на chain — система НЕ auto-recovers Submitted rows, чтобы prevent double-submit.
Investigation:
Query:
sqlSELECT position_uid, condition_id, redeem_tx_hash, redeem_submitted_at FROM positions WHERE redeem_status = 'SUBMITTED';Check
tx_hashна Polygonscan —confirmed/pending/failed.Если confirmed: вручную
sqlUPDATE positions SET redeem_status = 'CONFIRMED', redeem_confirmed_at = <ts>, status = 'REDEEMED' WHERE position_uid = ?;Если failed: investigate error → вручную перевести в
FAILEDс заполненнымredeem_last_error.Если pending: дождаться confirmation. Re-submit только после явного подтверждения, что tx dropped из mempool (иначе duplicate tx risk).
Disaster recovery
Tunnel down (dashboard 522/530): движок продолжает; SSH независим. ssh deploy@… 'cd ~/pm-backtest/tunnel && docker compose restart cloudflared'.
Wallet decryption fail (forgot master passphrase): wallets.db = AES-256-GCM. Recovery нет — либо passphrase в password manager, либо private key re-import через pm-cli wallet add. Mitigation: всегда pm-cli wallet export в hardware-encrypted backup при добавлении.
Cross-references
alerts-and-monitoring.md— pm-engine/metrics(:31340) + in-process AlertEngine → Telegram + per-alert диагностика (внешний Prometheus/Alertmanager стек = future).local-dashboard-runbook.md— Local-Bot Mode, reset/rotate session.rtds-cutover-runbook.md— toxic-flow gates.tech-debt-backlog-2026-08-31.md— known limitations (archived snapshot).../history/multi-wallet-migration-runbook-2026-05-03.md— миграция в encryptedwallets.db.deploy/server/README.md— VPS layout.- ADR-024 directional-first; ADR-015 multi-wallet + strategy config; ADR-005 Local-Bot Mode; ADR-013/014 cancel UI / risk widgets; ADR-031 risk engine.
- Memory:
feedback_release_build_for_vps,feedback_no_release_build_pm_analytics,feedback_live_test_wallet,feedback_vps_access_available,project_harvester_semantics.