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

58 KiB
Raw Blame History

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 <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:

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 добавить:

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:

    def test_version_is_9(self):
        self.assertEqual(agent.AGENT_VERSION, 9)

tests/test_clients.py:217:

        self.assertIn("jira-agent v%d" % agent.AGENT_VERSION, out.getvalue())

tests/test_self_update_e2e.py — публикуется «следующая» версия:

        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<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
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 добавить:

    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:

    SHA_RE = re.compile(r"[0-9a-f]{64}")

В __init__ заменить self.hint_at = None # type: Optional[float] на

        self.hints = {}               # вид подсказки -> clock() последней записи в лог (не чаще HINT_SECONDS)

Новый метод:

    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 целиком:

    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" до конца метода:

        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:

    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
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:

    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:

    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):

EXIT_LOCKED = 73              # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)

cmd_run:

    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 "":

        text = body or ""                      # status и body проверены выше (M-5 #18)

После класса ConfigGap:

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:

        except HubError as e:
            out = self._out("failed", error=str(e))
            out["retryable"] = e.retryable
            out["peer"] = "хабом"
            return out

Executor — метод рядом с _out:

    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 — ветка будущего времени:

        if ago < 0:
            log("журнал: время последней записи в будущем (%s) — пауза перед первой командой полная" % at.isoformat())
            self.jira.last_call_at = time.monotonic()
            return                                 # вторая строка «не позже <будущее>» противоречила бы первой (M-2 #18)

Runner.execute — цикл повтора:

        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
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:

    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:

    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. Константа модуля:

MSK = timezone(timedelta(hours=3))

В __init__ после self.project = project:

        self.now = datetime.now(MSK)  # часы фейка: окно created >= -Nd считается от них

Метод:

    @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():

        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::

        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
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:
    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):
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)
''')
  • Тесты:
    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

Константы:

LAUNCHER_VERSION = 2
EXIT_UPDATE = 75                 # агент: «agent.py.new проверен, update.json = staged»
EXIT_LOCKED = 73                 # агент: «журнал занят другим процессом» — не сбой версии (design 6)

Функции модуля (заменить write_state, sha256_file, добавить remove):

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:

    # --- файлы версии ----------------------------------------------------------
    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 целиком:

    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
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>"