jira-agent/docs/superpowers/plans/2026-10-14-agent-v9-hardening-agent.md
2026-10-14 23:58:13 +04:00

1056 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# agent-v9-hardening — агент v9 и запускатель v2 — план
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** закрыть отложенные находки агента (#22, #18, #4 M-10): запускатель не откатывает здоровую версию и не теряет
пробный период при падении посреди подмены, нехватка места не роняет агента и запускатель, тексты ошибок называют
источник, фейки повторяют наблюдённое поведение Jira и хаба; выпуск — агент v9 и `run.py` v2.
**Architecture:** `src/agent.py` (один файл) — версия 9, код выхода 73, устойчивый `Updater`, источник ошибки хаба в
`refresh`; `src/run.py` — сверка `update.json` с sha256 `agent.py` при старте и перед каждым запуском, повторная
проверка `ok`, код 73, `OSError` при подмене. Фейки `tests/fake_jira.py`, `tests/fake_hub.py` — `project`/`created` в
поиске и долгий `claim`.
**Tech Stack:** Python 3.9.6, только стандартная библиотека; `unittest`.
**Spec:** `../jira-hub/openspec/changes/agent-v9-hardening/` (proposal.md, design.md — решения 1–18,
specs/agent/spec.md); решения агента — `openspec/changes/agent-v9-hardening/design.md` (worktree
`/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/agent-v9` для спеки).
**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9`, ветка `feat/23-agent-v9`. Все команды —
из корня worktree; git — `git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 …`.
## Global Constraints
- Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+ запрещён (`tests/test_py39.py`).
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь набор.
- `AGENT_VERSION = 9`, `LAUNCHER_VERSION = 2`, `EXIT_UPDATE = 75`, `EXIT_LOCKED = 73`.
- Пробный период — 600 с, SIGKILL через 60 с после SIGTERM, пауза перезапуска 5 → 300 с (без изменений).
- Повтор подготовки после временного сбоя — не раньше 300 с; подсказки в лог — не чаще 3600 с.
- `agent_release`: `version` — `int` (не `bool`), `sha256` — строка ровно из 64 символов `[0-9a-f]`.
- Тексты (дословно): «агент уже запущен…» (прежний), «пробный период прерван: agent.py не v<to>», «подготовленный файл
потерян», «подмена не удалась: <ошибка>», «хаб прислал неполную публикацию», «журнал занят другим процессом»,
«обрыв связи с хабом», префикс ошибки хаба в `refresh` — «хаб: ».
- Комментарии, логи, коммиты — по-русски; коммиты заканчиваются строкой
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. `update.json` без поля `sha256` или с `sha256 = null` при `state = trial` — запускатель не должен считать это
совпадением (sha256 файла никогда не `None`) и не должен падать; ожидается `rolled_back` «пробный период прерван». Тест —
Task 5, `test_trial_without_sha_is_stale`.
2. `agent.py` не читается (`OSError` в `sha256_file`) — запускатель не падает, пробного периода нет. Тест — Task 5,
`test_sha256_file_unreadable_is_none`.
3. Ответ хаба 4xx в `refresh` (неверный токен) — не повторяется как обрыв, текст «хаб: HTTP 401…». Тест — Task 3,
`test_hub_4xx_in_refresh_not_retryable`.
4. Подсказки разного рода (неполная публикация и «запускайте через run.py») не глушат друг друга общим таймером. Тест —
Task 2, `test_hints_have_separate_timers`.
5. Выход агента 73 вне пробного периода — пауза с нарастанием, а не перезапуск в тугом цикле. Тест — Task 5,
`test_locked_outside_trial_backs_off`.
---
### Task 1: Версия 9 и тесты без жёстких номеров версий
**Files:**
- Modify: `src/agent.py:36` (`AGENT_VERSION`)
- Modify: `tests/test_update.py`, `tests/test_runner.py:54-100`, `tests/test_folder.py:160-161`, `tests/test_clients.py:217`,
`tests/test_self_update_e2e.py:27,42-44,66,72-74`
**Interfaces:**
- Produces: `agent.AGENT_VERSION == 9`; в `tests/test_update.py` — константы модуля `CUR = agent.AGENT_VERSION`,
`NEXT = CUR + 1` (их используют Task 2).
- [ ] **Step 1: Поднять версию**
`src/agent.py:36`:
```python
AGENT_VERSION = 9
```
- [ ] **Step 2: Прогнать тесты — увидеть падения от жёстких номеров**
Run: `/usr/bin/python3 -m unittest discover -s tests -t . 2>&1 | tail -5`
Expected: FAIL (test_version_is_5, test_update.* с `make_release(6)`, test_runner `agent_version == 5`, selftest «v5», e2e).
- [ ] **Step 3: `tests/test_update.py` — номера относительно текущей версии**
После `from fake_hub import FakeHub` добавить:
```python
CUR = agent.AGENT_VERSION
NEXT = CUR + 1
```
Замены в классе `UpdaterCheckTest` (строки 54–211), `UpdateStateTest` не трогать:
- `make_release(6` → `make_release(NEXT` везде;
- `make_release(7)` → `make_release(NEXT + 1)`; в `test_syntax_error_and_wrong_version_rejected`
`wrong = dict(wrong, version=8)` → `wrong = dict(wrong, version=NEXT + 2)`;
- `make_release(5)` → `make_release(CUR)`;
- в `test_good_version_is_staged`: `("staged", 5, 6, rel["sha256"])` → `("staged", CUR, NEXT, rel["sha256"])`;
- в `test_sha_mismatch_rejected_once`: `("rejected", 6, "sha256 не сходится")` → `("rejected", NEXT, "sha256 не сходится")`;
- в `test_rolled_back_same_sha_not_retried`: `"rolled_back", 5, 6, rel["sha256"]` → `"rolled_back", CUR, NEXT, rel["sha256"]`;
- в `test_without_launcher_only_hint`: `"доступна v6; для самообновления запускайте через run.py"` →
`"доступна v%d; для самообновления запускайте через run.py" % NEXT`.
- [ ] **Step 4: Остальные тесты**
`tests/test_runner.py`:
- `test_claim_sends_update_state_and_launcher`: `self.assertEqual(self.hub.claim_bodies[0]["agent_version"], 5)` →
`self.assertEqual(self.hub.claim_bodies[0]["agent_version"], agent.AGENT_VERSION)`;
- в `test_idle_with_release_exits_for_update`, `test_stop_during_check_exits_0`, `test_command_first_then_update` версию
`6` в `{"version": 6, ...}` и в лямбде заменить на `agent.AGENT_VERSION + 1`.
`tests/test_folder.py:160`:
```python
def test_version_is_9(self):
self.assertEqual(agent.AGENT_VERSION, 9)
```
`tests/test_clients.py:217`:
```python
self.assertIn("jira-agent v%d" % agent.AGENT_VERSION, out.getvalue())
```
`tests/test_self_update_e2e.py` — публикуется «следующая» версия:
```python
cur = "AGENT_VERSION = %d\n" % self.cur
content = src.replace(cur, "AGENT_VERSION = %d\n" % (self.cur + 1), 1)
self.assertNotEqual(content, src)
self.hub.release = {"version": self.cur + 1, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
"content": content}
```
где перед этим в `setUp` (до чтения `src`): `self.cur = int(re.search(r"^AGENT_VERSION = (\d+)", src, re.M).group(1))`
— для этого `src` прочитать раньше и добавить `import re`. Docstring класса: «v<N> видит публикацию v<N+1>…». Тест
переименовать в `test_update_to_next_and_stop_by_ctrl_c`; в нём `== 6` → `== self.cur + 1` (два места).
- [ ] **Step 5: Прогнать весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK.
- [ ] **Step 6: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 add src/agent.py tests/
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 commit -m "feat(agent): версия 9; тесты без жёстких номеров версий (#23)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: `Updater` — `OSError` как временный сбой, проверка формата публикации
**Files:**
- Modify: `src/agent.py` — класс `Updater` (строки ~951–1075)
- Test: `tests/test_update.py`
**Interfaces:**
- Consumes: `CUR`, `NEXT` из Task 1.
- Produces: `Updater.SHA_RE` (`re.compile(r"[0-9a-f]{64}")`, проверка через `fullmatch`); `Updater.hints: dict`
(вид подсказки → время последней), `Updater._hint(kind: str, msg: str) -> None`. Атрибут `hint_at` удаляется.
- [ ] **Step 1: Тесты**
В `UpdaterCheckTest` добавить:
```python
def capture_logs(self):
logs = []
orig = agent.log
agent.log = logs.append
self.addCleanup(setattr, agent, "log", orig)
return logs
def test_incomplete_release_ignored_with_hourly_hint(self):
logs = self.capture_logs()
for ref in ({"version": NEXT, "sha256": None}, {"version": str(NEXT), "sha256": "a" * 64},
{"version": True, "sha256": "a" * 64}, {"version": NEXT, "sha256": "A" * 64},
{"version": NEXT, "sha256": "a" * 63}, {"version": NEXT, "sha256": "a" * 64 + "\n"}):
self.assertFalse(self.u.check(ref))
self.now[0] += 3600
self.assertFalse(self.u.check({"version": NEXT, "sha256": None}))
self.assertEqual(sum(1 for l in logs if l.startswith("хаб прислал неполную публикацию")), 2)
self.assertEqual(self.hub.release_requests, 0)
self.assertIsNone(self.u.state())
def test_hints_have_separate_timers(self):
logs = self.capture_logs()
self.u.launcher_version = None
self.hub.release = make_release(NEXT)
self.assertFalse(self.u.check({"version": NEXT, "sha256": None}))
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertEqual(sum(1 for l in logs if l.startswith("хаб прислал неполную публикацию")), 1)
self.assertEqual(logs.count("доступна v%d; для самообновления запускайте через run.py" % NEXT), 1)
def test_write_error_is_transient(self):
self.hub.release = make_release(NEXT)
ref = self.ref(self.hub.release)
self.u.new_path = os.path.join(self.dir.name, "нет-каталога", "agent.py.new") # open() → FileNotFoundError
self.assertFalse(self.u.check(ref))
self.assertIsNone(self.u.state())
self.assertEqual(self.selftests, [])
self.now[0] += 299
self.assertFalse(self.u.check(ref))
self.assertEqual(self.hub.release_requests, 1) # раньше 5 минут не повторяет
def test_selftest_os_error_is_transient(self):
self.hub.release = make_release(NEXT)
def boom(path):
raise OSError(28, "No space left on device")
self.u.run_selftest = boom
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertIsNone(self.u.state())
self.assertFalse(os.path.exists(self.u.new_path))
def test_staged_write_error_is_transient(self):
self.hub.release = make_release(NEXT)
self.u.state_path = os.path.join(self.dir.name, "нет-каталога", "update.json")
self.assertFalse(self.u.check(self.ref(self.hub.release))) # без staged выходить с 75 нельзя
self.assertFalse(os.path.exists(self.u.new_path))
def test_reject_write_error_only_logged(self):
rel = make_release(NEXT)
self.hub.release = dict(rel, content=rel["content"] + "# подмена\n")
self.u.state_path = os.path.join(self.dir.name, "нет-каталога", "update.json")
logs = self.capture_logs()
self.assertFalse(self.u.check(self.ref(rel))) # не бросает
self.assertTrue(any(l.startswith("не удалось записать update.json") for l in logs), logs)
```
- [ ] **Step 2: Прогнать — падают**
Run: `/usr/bin/python3 -m unittest tests.test_update -v 2>&1 | tail -15`
Expected: FAIL/ERROR у шести новых тестов (`hub.release_requests` > 0, `FileNotFoundError`, `OSError`).
- [ ] **Step 3: Реализация**
В `Updater`:
```python
SHA_RE = re.compile(r"[0-9a-f]{64}")
```
В `__init__` заменить `self.hint_at = None # type: Optional[float]` на
```python
self.hints = {} # вид подсказки -> clock() последней записи в лог (не чаще HINT_SECONDS)
```
Новый метод:
```python
def _hint(self, kind: str, msg: str) -> None:
now = self.clock()
at = self.hints.get(kind)
if at is None or now - at >= self.HINT_SECONDS:
self.hints[kind] = now
log(msg)
```
`check` целиком:
```python
def check(self, release) -> bool:
"""Вызывается после claim без команды. True — agent.py.new проверен, update.json = staged: run выходит с 75."""
if not self.enabled or not isinstance(release, dict):
return False
version, sha = release.get("version"), release.get("sha256")
if type(version) is not int or not isinstance(sha, str) or not self.SHA_RE.fullmatch(sha):
self._hint("format", "хаб прислал неполную публикацию: version=%r, sha256=%r" % (version, sha)) # design 10
return False
if version <= AGENT_VERSION:
return False
st = self.state()
if st and st.get("state") in ("rejected", "rolled_back") and st.get("to_version") == version \
and st.get("sha256") == sha:
return False
if self.launcher_version is None:
self._hint("launcher", "доступна v%d; для самообновления запускайте через run.py" % version)
return False
until = self.retry_after.get((version, sha))
if until is not None and self.clock() < until:
return False
return self.prepare(version, sha)
```
В `prepare` заменить блок от `tmp = self.new_path + ".tmp"` до конца метода:
```python
tmp = self.new_path + ".tmp"
try: # диск полон, нет прав — временный сбой, не трейсбек (design 9)
with open(tmp, "wb") as f:
f.write(data)
try:
os.chmod(tmp, os.stat(self.agent_path).st_mode & 0o777)
except OSError:
pass
os.replace(tmp, self.new_path)
except OSError as e:
return self._transient(version, sha, "файлы: %s" % e)
if self.should_stop():
self._drop_new()
return False
try:
code, out = self.run_selftest(self.new_path)
except OSError as e:
return self._transient(version, sha, "selftest: %s" % e)
if self.should_stop(): # Ctrl-C уронил и selftest — это не отказ версии
self._drop_new()
return False
if code == 0:
try:
write_update_state(self.state_path, "staged", AGENT_VERSION, version, sha)
except OSError as e:
return self._transient(version, sha, "update.json: %s" % e)
log("v%d готова, перезапуск через run.py" % version)
return True
if code is None:
return self._transient(version, sha, "selftest: таймаут %d с" % self.SELFTEST_TIMEOUT)
if code == 3:
return self._transient(version, sha, "selftest: хаб или Jira недоступны")
return self._reject(version, sha, "selftest: код %s: %s" % (code, out[-500:]))
```
`_drop_new`: `except FileNotFoundError:` → `except OSError:`.
`_reject`:
```python
def _reject(self, version, sha, reason) -> bool:
self._drop_new()
try:
write_update_state(self.state_path, "rejected", AGENT_VERSION, version, sha, reason)
except OSError as e:
log("не удалось записать update.json: %s" % e)
log("v%d отвергнута: %s" % (version, reason))
return False
```
- [ ] **Step 4: Прогнать весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK.
- [ ] **Step 5: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 add src/agent.py tests/test_update.py
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 commit -m "fix(agent): OSError при подготовке версии — временный сбой; формат agent_release (M-6, M-10 #22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Код 73, одна строка темпа, источник ошибки хаба в `refresh`, чистка `_take`
**Files:**
- Modify: `src/agent.py` — константы (строка ~37), `FolderTransport._take` (~286), `ConfigGap` (~501, рядом добавить
`HubError`), `Executor.run` (~512–530), `Executor.do_refresh` (~895–945), `Runner.restore_pace` (~1109–1125),
`Runner.execute` (~1188–1203), `cmd_run` (~1319–1327)
- Test: `tests/test_runner.py`, `tests/test_refresh.py`
**Interfaces:**
- Produces: `agent.EXIT_LOCKED = 73`; `agent.HubError(cause)` со свойствами `retryable: bool`, `str(e) == "хаб: <cause>"`;
ключ `"peer"` в результате `Executor.run` (`"хабом"` для ошибок хаба; отсутствует — значит `"Jira"`).
Task 5 (`run.py`) опирается на код 73.
- [ ] **Step 1: Тесты**
`tests/test_runner.py` — изменить `test_second_process_refused`: `self.assertEqual(agent.cmd_run(cfg, self.journal.path), 1)` →
`self.assertEqual(agent.cmd_run(cfg, self.journal.path), agent.EXIT_LOCKED)` и добавить рядом
`self.assertEqual(agent.EXIT_LOCKED, 73)`.
Добавить в `RunnerTest`:
```python
def test_future_journal_time_single_log_line(self):
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
logs = []
orig = agent.log
agent.log = logs.append
try:
self.runner.restore_pace()
finally:
agent.log = orig
self.assertEqual(len(logs), 1, logs) # M-2 #18: без «не позже <будущее>»
self.assertIn("в будущем", logs[0])
self.assertIsNotNone(self.runner.jira.last_call_at)
def test_future_journal_time_then_stop_in_pause(self):
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30)
self.runner.sleep = lambda s: setattr(self.runner, "stop", True) # Ctrl-C в первую секунду паузы
self.runner.loop()
self.assertEqual([r["kind"] for r in self.journal.records() if r["command_id"] == 1], ["claimed"])
self.assertEqual(self.fake.requests, []) # в Jira ни одного вызова (M-3 #18)
self.assertEqual(self.hub.commands[1]["status"], "claimed")
def test_retry_exhausted_on_hub_names_hub(self):
self.runner.load_settings()
logs = []
orig = agent.log
agent.log = logs.append
self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "хаб: не ответил",
"jira_calls": 0, "rate_limited": 0, "retryable": True, "peer": "хабом"}
self.hub.enqueue("refresh", {"keys": ["AURORA-1"]})
try:
self.runner.loop()
finally:
agent.log = orig
r = self.hub.results[1]
self.assertEqual(r["error"], "результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил")
self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs)
self.assertNotIn("peer", r)
```
и в `test_retry_exhausted_gives_failed_unknown` добавить
`self.assertIn("(обрыв связи с Jira)", r["error"])`.
`tests/test_refresh.py` — в `RefreshTest`:
```python
def test_hub_unreachable_in_refresh_names_hub(self):
self.fake.add("AURORA-1")
self.ex.hub = agent.HubClient({"hub_url": "http://127.0.0.1:1", "hub_token": "t", "timeout": 2})
out = self.ex.run({"id": 1, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
self.assertEqual(out["status"], "failed")
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
self.assertTrue(out["retryable"])
self.assertEqual(out["peer"], "хабом")
def test_hub_4xx_in_refresh_not_retryable(self):
self.fake.add("AURORA-1")
self.ex.hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": "неверный", "timeout": 5})
out = self.ex.run({"id": 1, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
self.assertEqual(out["status"], "failed")
self.assertTrue(out["error"].startswith("хаб: HTTP 401"), out["error"])
self.assertFalse(out["retryable"])
def test_jira_error_has_no_peer(self):
self.fake.fail_next.append((500, {}))
out = self.ex.run({"id": 1, "type": "refresh", "params": {"jql": "project = AURORA"}})
self.assertTrue(out["retryable"])
self.assertNotIn("peer", out)
```
- [ ] **Step 2: Прогнать — падают**
Run: `/usr/bin/python3 -m unittest tests.test_runner tests.test_refresh 2>&1 | tail -15`
Expected: FAIL/ERROR: `EXIT_LOCKED` нет, две строки лога, `error` без «хаб: », нет `peer`.
- [ ] **Step 3: Реализация**
Константа рядом с `EXIT_UPDATE` (строка ~37):
```python
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
```
`cmd_run`:
```python
if lock is None:
log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path)
return EXIT_LOCKED
```
`FolderTransport._take` — вместо `status, text = int(resp.get("status") or 0), resp.get("body") or ""`:
```python
text = body or "" # status и body проверены выше (M-5 #18)
```
После класса `ConfigGap`:
```python
class HubError(Exception):
"""Ошибка хаба внутри команды (refresh: known_keys, state): источник — хаб, не Jira (M-6 #18, design 12).
Повтор — только при недоступности хаба и 5xx; refresh в Jira не пишет, повтор безопасен."""
def __init__(self, cause: Exception):
super().__init__("хаб: %s" % cause)
self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500)
```
`Executor.run` — первым `except` после `ConfigGap`:
```python
except HubError as e:
out = self._out("failed", error=str(e))
out["retryable"] = e.retryable
out["peer"] = "хабом"
return out
```
`Executor` — метод рядом с `_out`:
```python
def _hub(self, fn, *args):
"""Вызов хаба из команды: его сбой — HubError, чтобы не выдать его за обрыв связи с Jira."""
try:
return fn(*args)
except (ConnectionError, HttpError) as e:
raise HubError(e) from None
```
`do_refresh`: `known = set(self.hub.known_keys()) if self.hub else set()` →
`known = set(self._hub(self.hub.known_keys)) if self.hub else set()`; `self.hub.state({...})` →
`self._hub(self.hub.state, {...})` (тот же словарь).
`Runner.restore_pace` — ветка будущего времени:
```python
if ago < 0:
log("журнал: время последней записи в будущем (%s) — пауза перед первой командой полная" % at.isoformat())
self.jira.last_call_at = time.monotonic()
return # вторая строка «не позже <будущее>» противоречила бы первой (M-2 #18)
```
`Runner.execute` — цикл повтора:
```python
while True:
out = self.run_once(cmd)
calls += out["jira_calls"]
limited += out["rate_limited"]
peer = out.pop("peer", "Jira") # с кем оборвалась связь: Jira или хаб (refresh)
if not out.pop("retryable", False):
break
if attempt >= self.RETRY_ATTEMPTS:
out = {"status": "failed", "result": None,
"error": "результат неизвестен (обрыв связи с %s), повтор безопасен: %s" % (peer, out.get("error"))}
break
attempt += 1
log(" ! обрыв связи с %s, повтор %d/%d через проверку: %s" % (peer, attempt, self.RETRY_ATTEMPTS, out.get("error")))
```
(остальное тело цикла без изменений).
- [ ] **Step 4: Прогнать весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK.
- [ ] **Step 5: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 add src/agent.py tests/test_runner.py tests/test_refresh.py
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 commit -m "fix(agent): код 73 при занятом журнале, одна строка темпа, источник ошибки хаба в refresh (M-7 #22; M-2, M-3, M-5, M-6 #18)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: Фейки — `project`/`created` в поиске дубля, долгий `claim`
**Files:**
- Modify: `tests/fake_jira.py` (импорты, `__init__`, `add`, `search_keys`, `POST /issue`)
- Modify: `tests/fake_hub.py` (импорт `time`, `__init__`, `handle`)
- Test: `tests/test_executor_fields.py`, `tests/test_runner.py`
**Interfaces:**
- Produces: `FakeJira.now: datetime` (aware, +03:00), `FakeJira.ts(dt) -> str` (формат Jira `"%Y-%m-%dT%H:%M:%S.000+0300"`);
`FakeHub.claim_wait: float` (по умолчанию 0).
- [ ] **Step 1: Тесты**
`tests/test_executor_fields.py` — импорт `from datetime import timedelta`; в `ExecutorFieldsTest`:
```python
def test_ensure_issue_old_or_foreign_is_not_duplicate(self):
self.fake.add("AURORA-500", reporter="me", summary="Нужная задача",
created=self.fake.ts(self.fake.now - timedelta(days=8))) # за окном created >= -7d
self.fake.add("OTHER-1", reporter="me", summary="Нужная задача") # чужой проект
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача")
self.assertTrue(out["result"]["created"])
self.assertNotIn(out["result"]["key"], ("AURORA-500", "OTHER-1"))
def test_ensure_issue_within_window_is_duplicate(self):
self.fake.add("AURORA-500", reporter="me", summary="Нужная задача",
created=self.fake.ts(self.fake.now - timedelta(days=6)))
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача")
self.assertEqual(out["result"], {"key": "AURORA-500", "existed": True})
```
`tests/test_runner.py` — импорт `import threading`; в `RunnerTest`:
```python
def test_stop_during_long_claim(self):
self.hub.claim_wait = 1.0 # хаб держит claim при пустой очереди (как claim_wait = 30)
self.runner.idle_limit = None
threading.Timer(0.3, lambda: setattr(self.runner, "stop", True)).start()
start = time.monotonic()
self.runner.loop()
self.assertGreaterEqual(time.monotonic() - start, 0.9) # дождался ответа claim
self.assertEqual(self.hub.claims, 1)
self.assertEqual(self.journal.records(), [])
def test_command_arrives_during_long_claim(self):
self.hub.claim_wait = 5.0
def arrive():
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.hub.claim_wait = 0 # следующий claim (после отчёта) — без ожидания
threading.Timer(0.3, arrive).start()
start = time.monotonic()
self.runner.loop()
self.assertLess(time.monotonic() - start, 4) # ответ пришёл с командой, а не по таймауту
self.assertEqual(self.hub.results[1]["status"], "done")
```
- [ ] **Step 2: Прогнать — падают**
Run: `/usr/bin/python3 -m unittest tests.test_executor_fields tests.test_runner 2>&1 | tail -10`
Expected: ERROR `AttributeError: 'FakeJira' object has no attribute 'ts'`; `test_stop_during_long_claim` FAIL (< 0.9 с).
- [ ] **Step 3: `fake_jira.py`**
Импорт: `from datetime import datetime, timedelta, timezone`. Константа модуля:
```python
MSK = timezone(timedelta(hours=3))
```
В `__init__` после `self.project = project`:
```python
self.now = datetime.now(MSK) # часы фейка: окно created >= -Nd считается от них
```
Метод:
```python
@staticmethod
def ts(dt):
return dt.astimezone(MSK).strftime("%Y-%m-%dT%H:%M:%S.000+0300")
```
В `add` — `"created": "2026-10-01T10:00:00.000+0300"` → `"created": self.ts(self.now - timedelta(hours=1))`
(`updated` не менять — на него опирается `test_to_state_and_links`).
В `POST /rest/api/2/issue`: `created="2026-10-11T12:00:00.000+0300"` → `created=self.ts(self.now)`.
`search_keys` — ветка `reporter = currentUser()`:
```python
if "reporter = currentUser()" in (jql or ""):
keys = [k for k, f in self.issues.items() if f["reporter"] == "me"]
m = re.search(r"project = (\w+)", jql)
if m: # как Jira: только задачи проекта
keys = [k for k in keys if k.startswith(m.group(1) + "-")]
m = re.search(r"created >= -(\d+)d", jql)
if m: # как Jira: только созданные за последние N дней
since = self.now - timedelta(days=int(m.group(1)))
keys = [k for k in keys
if datetime.strptime(self.issues[k]["created"], "%Y-%m-%dT%H:%M:%S.%f%z") >= since]
return keys[::-1] if "ORDER BY created DESC" in jql else keys
```
- [ ] **Step 4: `fake_hub.py`**
Импорт `import time`. В `__init__`: `self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб`.
В `handle` после проверки токена, до `with self.lock:`:
```python
path = h.path.split("?")[0]
if path == "/agent/claim" and self.claim_wait:
end = time.monotonic() + self.claim_wait
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
with self.lock:
if self.queue:
break
time.sleep(0.05)
with self.lock:
code, payload = self.route(method, path, body)
```
- [ ] **Step 5: Прогнать весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK. Если какой-то прежний тест ищет дубль с задачей, у которой `created` задан явно и старше 7 дней, — это
дефект теста (живая Jira такую не нашла бы): поправить `created` в тесте и записать рулинг в
`openspec/changes/agent-v9-hardening/design.md`.
- [ ] **Step 6: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 add tests/
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 commit -m "test(agent): фейки — project и created в поиске дубля, долгий claim хаба (M-10 #4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: `run.py` v2 — сверка с файлом, повторная проверка `ok`, код 73, `OSError`; документация
**Files:**
- Modify: `src/run.py` (весь файл, кроме `log`, `read_state`, `acquire_lock`, `main`)
- Test: `tests/test_launcher.py`, `tests/test_self_update_e2e.py:72-74`
- Docs: `README.md`, `ARCHITECTURE.md`, `CLAUDE.md`, `openspec/changes/agent-v9-hardening/design.md`
**Interfaces:**
- Consumes: код выхода агента `73` (Task 3), `75` (без изменений).
- Produces: `run.LAUNCHER_VERSION = 2`, `run.EXIT_LOCKED = 73`; `run.write_state(...) -> bool`; `run.sha256_file(path) ->
Optional[str]`; `run.remove(path) -> None`; `Launcher.reconcile()`, `Launcher.in_trial() -> bool`,
`Launcher.drop_stale_trial(st)`, `Launcher.fail_update(st, err)`. `Launcher.recover_files` удаляется.
- [ ] **Step 1: Тесты**
`tests/test_launcher.py`:
- `test_env_and_args_passed`: `"1 run --journal j.jsonl"` → `"2 run --journal j.jsonl"`.
- Хелпер в `LauncherTest`:
```python
def sha(self, text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def path(self, name):
return os.path.join(self.dir, name)
```
- Поддельные агенты (на уровне модуля, после `V6_HANG`):
```python
V6_SEES_STATE = fake("v6", '''
mark("seen:" + (state() or {}).get("state", "-"))
write(**dict(state(), state="ok"))
human_stop()
''')
V6_OK_ON_TERM = fake("v6", '''
import signal, time
if len(open(os.path.join(HERE, "runs.log")).read().split()) > 2:
human_stop()
def term(*a):
write(**dict(state(), state="ok")) # ответ claim пришёл в последнюю секунду пробного периода
sys.exit(0)
signal.signal(signal.SIGTERM, term)
time.sleep(60)
''')
```
- Тесты:
```python
def test_staged_already_replaced_resumes_trial(self):
"""M-5: запускатель упал между os.replace(new, agent) и записью trial."""
self.stage(V6_SEES_STATE)
os.replace(self.path("agent.py.new"), self.path("agent.py"))
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6", "seen:trial"]) # пробный период, а не staged навсегда
self.assertEqual(self.state()["state"], "ok")
def test_staged_without_file_rejected(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": 6, "sha256": "a" * 64}))
self.assertEqual(self.l.run(), 0)
st = self.state()
self.assertEqual((st["state"], st["error"]), ("rejected", "подготовленный файл потерян"))
self.assertEqual(self.runs(), ["v5"])
def test_stale_trial_marked_rolled_back(self):
"""M-3: откат прерван — agent.py уже прежняя версия, update.json всё ещё trial."""
self.l.trial_seconds = 1
self.put("agent.py", fake("v8", "import time\ntime.sleep(2)\nhuman_stop()\n")) # дольше пробного периода
self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9, "sha256": "a" * 64}))
self.assertEqual(self.l.run(), 0)
st = self.state()
self.assertEqual((st["state"], st["to_version"], st["error"]),
("rolled_back", 9, "пробный период прерван: agent.py не v9"))
self.assertEqual(self.runs(), ["v8"]) # не остановлен через пробный период
def test_trial_without_sha_is_stale(self):
self.put("agent.py", fake("v8", "human_stop()\n"))
self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9}))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.state()["state"], "rolled_back")
def test_trial_survives_launcher_restart(self):
"""M-10 #22: trial при перезапуске run.py — пробный период продолжается, падение даёт откат."""
self.put("agent.py", V6_CRASH)
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(V6_CRASH)}))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6", "v5"])
self.assertEqual(self.state()["state"], "rolled_back")
def test_ok_at_last_moment_no_rollback(self):
"""M-4: пробный период истёк, но агент записал ok, получив SIGTERM."""
self.l.trial_seconds = 1
self.l.kill_grace = 5
self.put("agent.py", V5_UPDATES)
self.next_version(V6_OK_ON_TERM)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V6_OK_ON_TERM)
self.assertEqual(self.state()["state"], "ok")
self.assertEqual(self.runs(), ["v5", "v6", "v6"])
self.assertEqual(self.pauses, []) # перезапуск без паузы
def test_locked_in_trial_no_rollback(self):
"""M-7: журнал держит осиротевший агент — код 73 не откатывает новую версию."""
v6 = fake("v6", '''
n = len(open(os.path.join(HERE, "runs.log")).read().split())
human_stop() if n > 1 else sys.exit(73)
''')
self.put("agent.py", v6)
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(v6)}))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6", "v6"])
self.assertEqual(self.pauses, [5])
self.assertEqual(self.read("agent.py"), v6)
self.assertEqual(self.state()["state"], "trial")
def test_locked_outside_trial_backs_off(self):
self.put("agent.py", fake("v6", '''
n = len(open(os.path.join(HERE, "runs.log")).read().split())
human_stop() if n > 3 else sys.exit(73)
'''))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.pauses, [5, 10, 20])
def test_os_error_before_replace_rejected(self):
"""M-6: копия в agent.py.prev.tmp не удалась (здесь — на месте файла каталог)."""
self.put("agent.py", V5_UPDATES)
self.next_version(V6_OK_THEN_STOP)
os.mkdir(self.path("agent.py.prev.tmp"))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES)
st = self.state()
self.assertEqual(st["state"], "rejected")
self.assertTrue(st["error"].startswith("подмена не удалась: "), st["error"])
self.assertFalse(os.path.exists(self.path("agent.py.new")))
self.assertEqual(self.runs(), ["v5", "v5"])
def test_trial_not_written_reverts(self):
orig = run.write_state
def flaky(path, state, base, error=None):
return False if state == "trial" else orig(path, state, base, error)
run.write_state = flaky
self.addCleanup(setattr, run, "write_state", orig)
self.put("agent.py", V5_UPDATES)
self.next_version(V6_OK_THEN_STOP)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES) # agent.py.prev вернулся на место
st = self.state()
self.assertEqual((st["state"], st["error"]), ("rejected", "подмена не удалась: update.json не записан"))
self.assertEqual(self.runs(), ["v5", "v5"])
def test_write_state_error_does_not_raise(self):
self.assertFalse(run.write_state(self.path("нет/update.json"), "ok", {}))
def test_sha256_file_unreadable_is_none(self):
self.assertIsNone(run.sha256_file(self.path("нет-файла")))
def test_tmp_files_removed_at_start(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"):
self.put(name, "остаток")
self.assertEqual(self.l.run(), 0)
for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"):
self.assertFalse(os.path.exists(self.path(name)), name)
def test_sigterm_forwarded_and_no_restart(self):
"""M-10 #22: on_sigterm — пересылка агенту, нового запуска нет."""
import threading
self.put("agent.py", fake("v5", '''
import signal, time
def term(*a):
mark("term")
sys.exit(0)
signal.signal(signal.SIGTERM, term)
open(os.path.join(HERE, "ready"), "w").close()
time.sleep(30)
'''))
def send():
for _ in range(300):
if os.path.exists(self.path("ready")):
break
time.sleep(0.05)
self.l.on_sigterm(signal.SIGTERM, None)
t = threading.Thread(target=send)
t.start()
self.assertEqual(self.l.run(), 0)
t.join()
self.assertEqual(self.runs(), ["v5", "term"])
```
(в импорты модуля добавить `import time`).
`tests/test_self_update_e2e.py`: `self.assertEqual(last["launcher_version"], 1)` → `self.assertEqual(last["launcher_version"], 2)`.
- [ ] **Step 2: Прогнать — падают**
Run: `/usr/bin/python3 -m unittest tests.test_launcher 2>&1 | tail -15`
Expected: FAIL/ERROR у новых тестов (`staged` навсегда, откат при 73, трейсбек `IsADirectoryError`, `write_state` бросает).
- [ ] **Step 3: Реализация `run.py`**
Константы:
```python
LAUNCHER_VERSION = 2
EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged»
EXIT_LOCKED = 73 # агент: «журнал занят другим процессом» — не сбой версии (design 6)
```
Функции модуля (заменить `write_state`, `sha256_file`, добавить `remove`):
```python
def write_state(path: str, state: str, base: Optional[dict], error=None) -> bool:
"""Атомарная запись update.json. False — не записано (диск полон): запускатель не падает (design 5)."""
base = base or {}
data = {"state": state, "from_version": base.get("from_version"), "to_version": base.get("to_version"),
"sha256": base.get("sha256"), "error": error,
"at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path)
except OSError as e:
log("не удалось записать update.json: %s" % e)
return False
return True
def sha256_file(path: str) -> Optional[str]:
try:
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
except OSError:
return None
def remove(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
```
В `Launcher` заменить `recover_files` и `apply_update`, добавить `in_trial`, `drop_stale_trial`, `fail_update`:
```python
# --- файлы версии ----------------------------------------------------------
def reconcile(self) -> None:
"""Сверка update.json с файлами при старте (design 1): запускатель мог упасть посреди подмены или отката."""
for tmp in (self.new + ".tmp", self.prev + ".tmp", self.state_path + ".tmp"):
remove(tmp)
if not os.path.exists(self.agent) and os.path.exists(self.prev):
os.replace(self.prev, self.agent)
log("agent.py не найден — восстановлен из agent.py.prev")
st = read_state(self.state_path)
state, sha = (st or {}).get("state"), (st or {}).get("sha256")
if state == "staged":
if sha and sha256_file(self.agent) == sha: # подмена сделана, trial не записан (M-5)
remove(self.new)
write_state(self.state_path, "trial", st)
log("v%s уже на месте — пробный запуск" % st.get("to_version"))
elif sha and os.path.exists(self.new) and sha256_file(self.new) == sha:
log("найдена подготовленная версия — ставлю")
self.apply_update()
else:
remove(self.new)
write_state(self.state_path, "rejected", st, "подготовленный файл потерян")
log("подготовленная v%s потеряна — отвергнута" % st.get("to_version"))
return
remove(self.new)
if state == "trial" and sha256_file(self.agent) != sha:
self.drop_stale_trial(st)
def in_trial(self) -> bool:
"""Пробный период для этого запуска: trial и agent.py — именно эта версия (design 2)."""
st = read_state(self.state_path)
if not (st and st.get("state") == "trial"):
return False
if sha256_file(self.agent) != st.get("sha256"):
self.drop_stale_trial(st)
return False
return True
def drop_stale_trial(self, st: dict) -> None:
"""agent.py не та версия, что в trial (прерван откат, ручная подмена): пробного периода нет, версию не ставить (M-3)."""
reason = "пробный период прерван: agent.py не v%s" % st.get("to_version")
write_state(self.state_path, "rolled_back", st, reason)
log(reason)
def apply_update(self) -> None:
st = read_state(self.state_path)
if not (st and st.get("state") == "staged" and os.path.exists(self.new) and sha256_file(self.new) == st.get("sha256")):
remove(self.new)
write_state(self.state_path, "rejected", st, "файл изменился после проверки")
log("обновление отвергнуто: файл изменился после проверки")
return
tmp = self.prev + ".tmp"
try:
with open(self.agent, "rb") as src, open(tmp, "wb") as dst:
dst.write(src.read())
os.replace(tmp, self.prev)
os.replace(self.new, self.agent) # agent.py существует на каждом шаге
except OSError as e:
self.fail_update(st, e)
return
if not write_state(self.state_path, "trial", st):
try:
os.replace(self.prev, self.agent) # без trial новая версия осталась бы без пробного периода
except OSError as e:
log("не удалось вернуть agent.py.prev: %s" % e)
self.fail_update(st, "update.json не записан")
return
log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version")))
def fail_update(self, st: dict, err) -> None:
"""Подмена не удалась (диск полон, права): текущая версия остаётся, эта — rejected навсегда (design 4)."""
remove(self.prev + ".tmp")
remove(self.new)
write_state(self.state_path, "rejected", st, "подмена не удалась: %s" % err)
log("подмена v%s не удалась: %s" % (st.get("to_version"), err))
```
`rollback` без изменений (`write_state` больше не бросает).
`run` целиком:
```python
def run(self) -> int:
self.reconcile()
code = 0
while True:
if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2)
return code
trial = self.in_trial()
started = time.monotonic()
self.interrupted = False # Ctrl-C относится к этому запуску агента
self.child = self.spawn()
if self.stopping: # SIGTERM между Popen и присваиванием child до агента не дошёл
self.child.send_signal(signal.SIGTERM)
code, timed_out = self.wait_child(self.child, trial)
self.child = None
uptime = time.monotonic() - started
st = read_state(self.state_path)
ok = bool(st and st.get("state") == "ok")
reason = "агент упал (код %s)" % code
if timed_out and ok: # ответ claim пришёл в последнюю секунду (M-4, design 3)
log("подтверждение пришло в последний момент — отката нет")
self.backoff = BACKOFF_START
continue
if timed_out:
if self.rollback(st, "не вышла на связь за 10 мин"):
continue
elif self.stopping or (self.interrupted and code in STOP_CODES):
return code
elif code == EXIT_UPDATE:
self.apply_update()
self.backoff = BACKOFF_START
if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте
return 0
continue
elif code == EXIT_LOCKED: # журнал держит другой процесс — версия ни при чём (M-7, design 6)
reason = "журнал занят другим процессом (агент уже запущен)"
elif trial and not ok:
if self.rollback(st, "код %s через %d с" % (code, uptime)):
continue
if self.stopping:
return code
wait = self.next_backoff(uptime)
log("%s, перезапуск через %d с" % (reason, wait))
if not self.pause(wait):
return code
```
- [ ] **Step 4: Прогнать весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK (в том числе e2e: v9 → v10 через настоящий `run.py`, `launcher_version = 2`).
- [ ] **Step 5: Документация**
`README.md`:
- раздел «Запуск», абзац «Остановка…»: после «…и перезапускается.» добавить: «Если журнал занят другим процессом
(осталась прежняя копия агента), `agent.py run` выходит с кодом 73, а `run.py` повторяет запуск с паузой и не считает
это сбоем версии.»
- раздел «Самообновление»: после абзаца про откат добавить абзац:
«Запускатель v2 при старте и перед каждым запуском сверяет `update.json` с sha256 файла `agent.py`: если `agent.py` уже
новая версия, а `update.json` = `staged` (запускатель упал посреди подмены), пробный период продолжается; если
`update.json` = `trial`, а `agent.py` другая версия (прерван откат или файл заменён вручную), пишется `rolled_back`
«пробный период прерван…» и эта версия больше сама не ставится — вернуть её можно публикацией с номером выше. Если
подмена не удалась (`OSError`, например диск полон), текущая версия остаётся, а новая получает `rejected` «подмена не
удалась»: освободить место и опубликовать следующую версию. Временные файлы `*.tmp` удаляются при старте.»
- пункт «Версия» в «Что делает агент с командами»: `(сейчас 5)` → `(сейчас 9)`; в конец пункта: «Версия 9 — код 73 при
занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
`refresh` (номера 6–8 заняты живой проверкой самообновления).»
- раздел «Обновление агента», фраза «не стартует («агент уже запущен…», код 1)» → «не стартует («агент уже запущен…», код 73)»;
добавить абзац: «Переход на `run.py` v2: сначала агент должен обновиться до v9 (MCP `agent_status` — «обновление: ok
v8 → v9»); затем `launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist`, скопировать `run.py` в
`~/jira-agent-bundle`, `launchctl load …`; `agent_status` покажет «запускатель: v2».»
`ARCHITECTURE.md` — в описании `run.py` заменить упоминание `recover_files` на: «`reconcile` при старте и `in_trial`
перед каждым запуском сверяют `update.json` с sha256 `agent.py` (таблица — design 1 change'а agent-v9-hardening);
коды агента: 75 — подмена, 73 — журнал занят (не сбой версии); `OSError` при подмене — `rejected`, запускатель не падает».
Если в ARCHITECTURE.md нет `recover_files` — добавить этот абзац в раздел про `run.py`.
`CLAUDE.md` — абзац «Первая подсистема»: «Последний change — `../jira-hub/openspec/changes/agent-v9-hardening/` (решения
агента — `openspec/changes/agent-v9-hardening/design.md`), план — `docs/superpowers/plans/2026-10-14-agent-v9-hardening-agent.md`.»
(заменить ссылки на agent-self-update).
`openspec/changes/agent-v9-hardening/design.md` — дописать в таблицу «Decisions made without discussion» рулинги,
принятые при исполнении (маркер `R-6` и дальше; R-1…R-5 заняты дизайном хаба). Если рулингов не было — строка не
добавляется.
- [ ] **Step 6: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 add src/run.py tests/ README.md ARCHITECTURE.md CLAUDE.md openspec/
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v9 commit -m "feat(agent): run.py v2 — сверка update.json с файлом, ok в последний момент, код 73, OSError при подмене (M-3…M-7, M-10 #22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```