# 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», «подготовленный файл потерян», «подмена не удалась: <ошибка>», «хаб прислал неполную публикацию», «журнал занят другим процессом», «обрыв связи с хабом», префикс ошибки хаба в `refresh` — «хаб: ». - Комментарии, логи, коммиты — по-русски; коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 `. ## 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 видит публикацию v…». Тест переименовать в `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 " ``` --- ### 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 " ``` --- ### 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) == "хаб: "`; ключ `"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 " ``` --- ### 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 " ``` --- ### 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 " ```