From 4cc4de4269160b85c42c6500859ab22da7d51431 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 8 Jul 2026 17:21:22 +0300 Subject: [PATCH] =?UTF-8?q?OpenSpec:=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=82=D1=80=D1=91=D1=85=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20changes=20+=20=D1=81=D0=B8=D0=BD=D0=BA=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master): - ingest-dedup-integrity (F1, F6) → спека ingest - retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation - linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout и state-reconciliation Дельты влиты в openspec/specs, changes перенесены в openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ingest/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/file-layout/spec.md | 0 .../specs/state-reconciliation/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-07-08-retry-stall-basis}/design.md | 0 .../2026-07-08-retry-stall-basis}/proposal.md | 0 .../specs/state-reconciliation/spec.md | 0 .../2026-07-08-retry-stall-basis}/tasks.md | 0 openspec/specs/file-layout/spec.md | 33 ++++++ openspec/specs/ingest/spec.md | 66 ++++++++++-- openspec/specs/state-reconciliation/spec.md | 101 ++++++++++++++++-- 19 files changed, 178 insertions(+), 22 deletions(-) rename openspec/changes/{ingest-dedup-integrity => archive/2026-07-08-ingest-dedup-integrity}/.openspec.yaml (100%) rename openspec/changes/{ingest-dedup-integrity => archive/2026-07-08-ingest-dedup-integrity}/design.md (100%) rename openspec/changes/{ingest-dedup-integrity => archive/2026-07-08-ingest-dedup-integrity}/proposal.md (100%) rename openspec/changes/{ingest-dedup-integrity => archive/2026-07-08-ingest-dedup-integrity}/specs/ingest/spec.md (100%) rename openspec/changes/{ingest-dedup-integrity => archive/2026-07-08-ingest-dedup-integrity}/tasks.md (100%) rename openspec/changes/{linking-transition-robustness => archive/2026-07-08-linking-transition-robustness}/.openspec.yaml (100%) rename openspec/changes/{linking-transition-robustness => archive/2026-07-08-linking-transition-robustness}/design.md (100%) rename openspec/changes/{linking-transition-robustness => archive/2026-07-08-linking-transition-robustness}/proposal.md (100%) rename openspec/changes/{linking-transition-robustness => archive/2026-07-08-linking-transition-robustness}/specs/file-layout/spec.md (100%) rename openspec/changes/{linking-transition-robustness => archive/2026-07-08-linking-transition-robustness}/specs/state-reconciliation/spec.md (100%) rename openspec/changes/{linking-transition-robustness => archive/2026-07-08-linking-transition-robustness}/tasks.md (100%) rename openspec/changes/{retry-stall-basis => archive/2026-07-08-retry-stall-basis}/.openspec.yaml (100%) rename openspec/changes/{retry-stall-basis => archive/2026-07-08-retry-stall-basis}/design.md (100%) rename openspec/changes/{retry-stall-basis => archive/2026-07-08-retry-stall-basis}/proposal.md (100%) rename openspec/changes/{retry-stall-basis => archive/2026-07-08-retry-stall-basis}/specs/state-reconciliation/spec.md (100%) rename openspec/changes/{retry-stall-basis => archive/2026-07-08-retry-stall-basis}/tasks.md (100%) diff --git a/openspec/changes/ingest-dedup-integrity/.openspec.yaml b/openspec/changes/archive/2026-07-08-ingest-dedup-integrity/.openspec.yaml similarity index 100% rename from openspec/changes/ingest-dedup-integrity/.openspec.yaml rename to openspec/changes/archive/2026-07-08-ingest-dedup-integrity/.openspec.yaml diff --git a/openspec/changes/ingest-dedup-integrity/design.md b/openspec/changes/archive/2026-07-08-ingest-dedup-integrity/design.md similarity index 100% rename from openspec/changes/ingest-dedup-integrity/design.md rename to openspec/changes/archive/2026-07-08-ingest-dedup-integrity/design.md diff --git a/openspec/changes/ingest-dedup-integrity/proposal.md b/openspec/changes/archive/2026-07-08-ingest-dedup-integrity/proposal.md similarity index 100% rename from openspec/changes/ingest-dedup-integrity/proposal.md rename to openspec/changes/archive/2026-07-08-ingest-dedup-integrity/proposal.md diff --git a/openspec/changes/ingest-dedup-integrity/specs/ingest/spec.md b/openspec/changes/archive/2026-07-08-ingest-dedup-integrity/specs/ingest/spec.md similarity index 100% rename from openspec/changes/ingest-dedup-integrity/specs/ingest/spec.md rename to openspec/changes/archive/2026-07-08-ingest-dedup-integrity/specs/ingest/spec.md diff --git a/openspec/changes/ingest-dedup-integrity/tasks.md b/openspec/changes/archive/2026-07-08-ingest-dedup-integrity/tasks.md similarity index 100% rename from openspec/changes/ingest-dedup-integrity/tasks.md rename to openspec/changes/archive/2026-07-08-ingest-dedup-integrity/tasks.md diff --git a/openspec/changes/linking-transition-robustness/.openspec.yaml b/openspec/changes/archive/2026-07-08-linking-transition-robustness/.openspec.yaml similarity index 100% rename from openspec/changes/linking-transition-robustness/.openspec.yaml rename to openspec/changes/archive/2026-07-08-linking-transition-robustness/.openspec.yaml diff --git a/openspec/changes/linking-transition-robustness/design.md b/openspec/changes/archive/2026-07-08-linking-transition-robustness/design.md similarity index 100% rename from openspec/changes/linking-transition-robustness/design.md rename to openspec/changes/archive/2026-07-08-linking-transition-robustness/design.md diff --git a/openspec/changes/linking-transition-robustness/proposal.md b/openspec/changes/archive/2026-07-08-linking-transition-robustness/proposal.md similarity index 100% rename from openspec/changes/linking-transition-robustness/proposal.md rename to openspec/changes/archive/2026-07-08-linking-transition-robustness/proposal.md diff --git a/openspec/changes/linking-transition-robustness/specs/file-layout/spec.md b/openspec/changes/archive/2026-07-08-linking-transition-robustness/specs/file-layout/spec.md similarity index 100% rename from openspec/changes/linking-transition-robustness/specs/file-layout/spec.md rename to openspec/changes/archive/2026-07-08-linking-transition-robustness/specs/file-layout/spec.md diff --git a/openspec/changes/linking-transition-robustness/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-07-08-linking-transition-robustness/specs/state-reconciliation/spec.md similarity index 100% rename from openspec/changes/linking-transition-robustness/specs/state-reconciliation/spec.md rename to openspec/changes/archive/2026-07-08-linking-transition-robustness/specs/state-reconciliation/spec.md diff --git a/openspec/changes/linking-transition-robustness/tasks.md b/openspec/changes/archive/2026-07-08-linking-transition-robustness/tasks.md similarity index 100% rename from openspec/changes/linking-transition-robustness/tasks.md rename to openspec/changes/archive/2026-07-08-linking-transition-robustness/tasks.md diff --git a/openspec/changes/retry-stall-basis/.openspec.yaml b/openspec/changes/archive/2026-07-08-retry-stall-basis/.openspec.yaml similarity index 100% rename from openspec/changes/retry-stall-basis/.openspec.yaml rename to openspec/changes/archive/2026-07-08-retry-stall-basis/.openspec.yaml diff --git a/openspec/changes/retry-stall-basis/design.md b/openspec/changes/archive/2026-07-08-retry-stall-basis/design.md similarity index 100% rename from openspec/changes/retry-stall-basis/design.md rename to openspec/changes/archive/2026-07-08-retry-stall-basis/design.md diff --git a/openspec/changes/retry-stall-basis/proposal.md b/openspec/changes/archive/2026-07-08-retry-stall-basis/proposal.md similarity index 100% rename from openspec/changes/retry-stall-basis/proposal.md rename to openspec/changes/archive/2026-07-08-retry-stall-basis/proposal.md diff --git a/openspec/changes/retry-stall-basis/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-07-08-retry-stall-basis/specs/state-reconciliation/spec.md similarity index 100% rename from openspec/changes/retry-stall-basis/specs/state-reconciliation/spec.md rename to openspec/changes/archive/2026-07-08-retry-stall-basis/specs/state-reconciliation/spec.md diff --git a/openspec/changes/retry-stall-basis/tasks.md b/openspec/changes/archive/2026-07-08-retry-stall-basis/tasks.md similarity index 100% rename from openspec/changes/retry-stall-basis/tasks.md rename to openspec/changes/archive/2026-07-08-retry-stall-basis/tasks.md diff --git a/openspec/specs/file-layout/spec.md b/openspec/specs/file-layout/spec.md index a9385a8..944fcf7 100644 --- a/openspec/specs/file-layout/spec.md +++ b/openspec/specs/file-layout/spec.md @@ -111,3 +111,36 @@ NOT влиять на инвариант неприкосновенности и - **THEN** суммарный размер разложенных файлов доступен как размер раздачи для показа в карточке +### Requirement: Claim раскладки коммитится до хардлинков и устойчив к сбою учёта + +Раскладка — «claim-then-side-effect»: система SHALL сперва зафиксировать переход +задачи в `linking` (claim шага раскладки), и только затем создавать хардлинки. +Если запись claim перехода в `linking` провалилась, система НЕ SHALL создавать +хардлинки и SHALL прервать раскладку, оставив задачу в исходном состоянии +(`review`/`deferred` при ручном применении; `recognizing` при авто-раскладке) — +чтобы у шага сохранился владелец, а хардлинки не легли при незакоммиченном claim +(иначе финальный переход `linking → done` из фактического состояния был бы +отклонён графом, и задача застряла бы со stale-планом). + +Если хардлинки уже созданы, но запись их учёта (`file_link`) провалилась +(транзиентная ошибка хранилища), задача НЕ SHALL оставаться в `linking`: система +SHALL перевести её в `review` с причиной. Повторное применение SHALL быть +идемпотентным — уже созданные хардлинки распознаются как существующие +(`StatusExists`), а их учёт дописывается. + +#### Scenario: Провал claim не создаёт хардлинков + +- **GIVEN** задача в `review` с готовым источником и валидным планом +- **WHEN** запись перехода в `linking` проваливается +- **THEN** хардлинки не создаются, учёт `file_link` не пишется +- **AND** задача остаётся в `review`, а команда отказывает с ошибкой + +#### Scenario: Провал учёта уводит в review, не оставляя в linking + +- **GIVEN** хардлинки по плану уже созданы на файловой системе +- **WHEN** запись строк `file_link` проваливается транзиентной ошибкой +- **THEN** задача переходит в `review` с причиной (код `persist`), а не остаётся + в `linking` +- **AND** созданные хардлинки остаются на диске +- **AND** повторное «Применить» идемпотентно дописывает учёт и доводит до `done` + diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index 26aaef2..95d1631 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -207,10 +207,13 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40 (повторного добавления торрента в qBittorrent). Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие -гибридного торрента): хеш, которым владеет другая активная загрузка, -дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное -состояние в обход этой проверки SHALL отклоняться хранилищем (механический -бэкстоп вместо удалённого unique-индекса). +гибридного торрента) на ВСЕХ путях дозаписи, включая дедуп-дозапись при +приёме: хеш, которым владеет другая активная загрузка, дописан быть SHALL +NOT — ни отдельным методом дозаписи, ни дедуп-веткой атомарного заведения, +которая доносит недостающие хеши найденной активной задаче. Прямой перевод +терминальной загрузки в активное состояние в обход этой проверки SHALL +отклоняться хранилищем (механический бэкстоп вместо удалённого +unique-индекса). #### Scenario: Retry при занятом хеше @@ -220,6 +223,16 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40 - **THEN** переход отклоняется с пояснением, #1 остаётся в `failed` - **AND** активной по `h` остаётся #2 +#### Scenario: Дедуп-дозапись не крадёт чужой хеш + +- **GIVEN** активная загрузка A владеет хешем `v1`, активная загрузка B + владеет хешем `v2` того же гибридного торрента +- **WHEN** принимается источник с обоими хешами `{v1, v2}` и дедупится на B +- **THEN** B получает только незанятые хеши, а `v1` (в собственности A) B не + дописывается +- **AND** инвариант «не более одной активной загрузки на infohash» + сохраняется (по `v1` активна только A) + ### Requirement: Синтез контекста распознавания из полей magnet При приёме система SHALL извлекать из полей magnet-ссылки дополнительный @@ -314,7 +327,6 @@ recognition (LLM-промпт) и веб-UI (страница загрузки). - **THEN** `download.Context` остаётся пустым - **AND** приём проходит штатно (пустой контекст допустим) - ### Requirement: Приём источника из .torrent-файла Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с @@ -342,9 +354,21 @@ v2-хеш (для v2/гибридного файла, BEP52) SHALL извлек чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet): раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и -заведение загрузки; при дедупликации (новая загрузка не создана) байты -сохраняться SHALL NOT. Размер принимаемого `.torrent` система SHALL ограничивать -на границе транспорта (защита от разбухания хранилища). +заведение загрузки; при дедупликации (новая загрузка не создана) байты в общем +случае сохраняться SHALL NOT. + +**Исключение — апгрейд пойманной magnet-задачи до torrent.** Если входящий +источник — байты `.torrent`, а дедуп попал на активную загрузку с +`source_type = magnet`, ещё НЕ отданную в qBittorrent (состояние `catched`), +система SHALL в одной write-транзакции сохранить байты `.torrent`, +привязав их к этой загрузке, и сменить её `source_type` на `torrent`. Тем +самым воркер добавит раздачу файлом, а не magnet-хешем (иначе на закрытом +трекере без DHT метаданные не докачаются, а magnet застрянет в metaDL → +failed). Апгрейд SHALL применяться ТОЛЬКО пока загрузка в `catched` (воркер +источник ещё не добавил); для уже добавленной (`downloading` и далее) +загрузки смена `source_type` при дедупе выполняться SHALL NOT — её судьба +решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть best-effort: +его неуспех приём не прерывает. Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий) @@ -380,12 +404,31 @@ NOT. qBittorrent - **AND** сопоставление раздачи работает по нему -#### Scenario: Дубль .torrent по активной задаче +#### Scenario: Дубль .torrent по активной torrent-задаче -- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash +- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash и + `source_type = torrent` - **WHEN** принимается `.torrent` с тем же инфохэшем - **THEN** новая загрузка не создаётся, возвращается существующая -- **AND** байты торрента не сохраняются (дубль) +- **AND** байты торрента повторно не сохраняются (дубль) + +#### Scenario: Апгрейд catched-magnet до torrent + +- **GIVEN** активная загрузка в `catched` с `source_type = magnet` и хешем `h` + (magnet-задача ещё не отдана в qBittorrent) +- **WHEN** принимается `.torrent` с тем же инфохэшем `h` +- **THEN** новая загрузка не создаётся, возвращается существующая +- **AND** байты `.torrent` сохраняются привязанными к ней, а её `source_type` + становится `torrent` — в одной транзакции +- **AND** воркер добавит раздачу файлом (не по magnet) + +#### Scenario: Magnet-задача уже добавлена — апгрейда нет + +- **GIVEN** активная загрузка с `source_type = magnet` уже в `downloading` + (отдана в qBittorrent) +- **WHEN** принимается `.torrent` с тем же инфохэшем +- **THEN** возвращается существующая загрузка, её `source_type` остаётся + `magnet`, байты `.torrent` не сохраняются #### Scenario: Контекст из полей файла @@ -398,3 +441,4 @@ NOT. - **WHEN** принимаемый `.torrent`-файл превышает ограничение размера - **THEN** приём отклоняется с ошибкой, загрузка не создаётся + diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md index 496dc63..9902cf1 100644 --- a/openspec/specs/state-reconciliation/spec.md +++ b/openspec/specs/state-reconciliation/spec.md @@ -79,10 +79,17 @@ SHALL NOT воскрешать упавшую задачу и SHALL остави сохраняя инвариант «не более одной активной задачи на infohash». `magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не -рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом -прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck` -из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим -метаданным в `docs/specs/workflow.md`). +рабочим механизмом. Две страховочные меры при этом РАЗНЫЕ: `magnet_timeout` +SHALL мериться по **возрасту** торрента (время от добавления в qBittorrent, +`added_on`, с фолбэком на `created_at` задачи), а `stuck_after` — по +**длительности простоя** (время от `last_activity` qBittorrent — момента +последнего движения данных), а НЕ по возрасту. Пока торрент в +`metaDL`/`forcedMetaDL` или иным образом прогрессирует в пределах +страховочного таймаута, задача в `failed`/`stuck` из-за него оказаться +SHALL NOT; в частности, торрент со свежим `last_activity` в `stuck` система +пометить SHALL NOT, даже если его общий возраст превышает `stuck_after` (см. +требование о терпеливости к долгим метаданным и меры таймаутов в +`docs/specs/workflow.md`). #### Scenario: Метаданные пришли после magnet_timeout @@ -116,18 +123,40 @@ SHALL NOT воскрешать упавшую задачу и SHALL остави - **THEN** #1 остаётся в `failed` (восстановление не выполняется) - **AND** активной по этому infohash остаётся #2 +#### Scenario: Долго качавшийся торрент на миг зашёл в stalledDL + +- **GIVEN** торрент качался часами и двигал данные только что (свежий + `last_activity`), но на текущем тике qBittorrent показывает его `stalledDL` +- **WHEN** `worker` проверяет таймаут зависания +- **THEN** задача остаётся в `downloading` (простой меньше `stuck_after`), + несмотря на большой возраст торрента +- **AND** ложного `stuck` со «stalled for <возраст>» и уведомления о падении + не возникает + ### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов Система SHALL предоставлять пользователю команду повторной попытки (retry) для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API). Retry SHALL переводить задачу обратно в `downloading`, не вызывая её -немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL -сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого -`created_at`). +немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL +сбрасываться. -Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к -существующему торренту, а не добавлять источник повторно вслепую; повторный -`Add` выполняется, только когда раздачи в qBittorrent нет. +Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи +(`retried_at`, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер +(`magnet_timeout` по возрасту и `stuck_after` по простою): отсчёт ведётся от +`max(базис, retried_at)`. `retried_at` SHALL храниться в задаче (не в памяти +процесса), чтобы сброс базиса пережил интервал поллинга и рестарт процесса. +Благодаря этому даже живой, но давно добавленный либо давно простаивающий +торрент после retry SHALL получать свежее окно и на ближайшем тике сверки +падать снова SHALL NOT. + +Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться +к существующему торренту, а не добавлять источник повторно вслепую. Если же +живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry +перецепляться к нему SHALL NOT (перецепка к сломанному торренту тут же вернула +бы задачу в `failed` по сверке) и SHALL повторно отдать источник, как при +отсутствии раздачи. Повторный `Add` выполняется, только когда раздачи в +qBittorrent нет ЛИБО она сломана. Повторный `Add` при retry система SHALL выполнять **по типу источника** (`source_type`), как и добавление пойманной загрузки (см. `download-tracking` @@ -138,11 +167,20 @@ Retry SHALL переводить задачу обратно в `downloading`, #### Scenario: Retry упавшей magnet-загрузки из веб-UI -- **GIVEN** задача в `failed`, её торрент жив в qBittorrent +- **GIVEN** задача в `failed`, её торрент жив и здоров в qBittorrent - **WHEN** пользователь нажимает retry в веб-UI - **THEN** задача возвращается в `downloading` без повторного `Add` - **AND** не падает снова на ближайшем тике сверки по таймауту +#### Scenario: Retry живого, но давно простаивающего торрента не падает снова + +- **GIVEN** задача в `stuck`/`stalled`, её торрент жив в qBittorrent, но + добавлен давно и данные не двигались дольше `stuck_after` +- **WHEN** пользователь нажимает retry +- **THEN** задача возвращается в `downloading` без повторного `Add` +- **AND** на ближайшем тике сверки НЕ падает снова в `stuck` (базис сброшен + через `retried_at`) + #### Scenario: Retry доступен в Telegram - **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в @@ -157,6 +195,15 @@ Retry SHALL переводить задачу обратно в `downloading`, torrent сохранёнными байтами файлом - **AND** задача переходит в `downloading` +#### Scenario: Retry сломанного живого торрента повторно отдаёт источник + +- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в + состоянии ошибки (`error`/`missingFiles`) +- **WHEN** пользователь инициирует retry +- **THEN** источник отдаётся заново (перецепка к сломанному торренту не + выполняется) +- **AND** задача переходит в `downloading` + #### Scenario: Retry torrent-загрузки без живого источника - **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent @@ -364,3 +411,35 @@ SHALL отклоняться сразу с пояснением, что исто `nlink > 1` - **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым +### Requirement: Восстановление задачи, застрявшей в linking + +Система SHALL на каждом тике поллинга и при старте выявлять задачи в состоянии +`linking` и возвращать их в `review` с причиной «прерванная раскладка» (код +`interrupted`), откуда человек повторит применение (повтор идемпотентен). +`linking` — нетерминальное активное состояние, и у него, как у каждого +нетерминального состояния, ДОЛЖЕН быть владелец, продвигающий задачу; иначе +краш процесса между переходом в `linking` и финальным переходом оставил бы +задачу без владельца — её не листит ни один штатный шаг (ни поллинг активных, +ни распознавание, ни матрица сверки, ни восстановление `failed`/`stuck`). + +Выявление SHALL выполняться под той же блокировкой переходов, что и раскладка: +активная раскладка удерживает блокировку весь свой срок и завершает переход из +`linking` до её отпускания, поэтому любая `linking`-задача, наблюдаемая под +блокировкой, по построению устарела (осталась после краха) — восстановление НЕ +SHALL задевать раскладку в полёте. + +#### Scenario: Осиротевший linking возвращается в review + +- **GIVEN** задача осталась в `linking` после краха между claim и финальным + переходом +- **WHEN** выполняется тик поллинга (или старт сервиса) +- **THEN** задача переходит в `review` с причиной «прерванная раскладка» + (код `interrupted`) +- **AND** её можно повторно применить из ревью + +#### Scenario: Прочие состояния sweep не задевает + +- **GIVEN** задачи в состояниях `done` и `review` +- **WHEN** выполняется тик поллинга +- **THEN** восстановление `linking` их состояние не меняет +