1364 lines
69 KiB
Markdown
1364 lines
69 KiB
Markdown
# agent-self-update (агент) Implementation Plan
|
||
|
||
> **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:** агент v5 сам обновляется с хаба в паузе между командами, а запускатель `run.py` подменяет файл, проверяет новую версию пробным периодом и откатывает плохую.
|
||
|
||
**Architecture:** в `agent.py` — функции `update.json` (`read_update_state`/`write_update_state`), класс `Updater` (проверка публикации, скачивание, sha256, компиляция, `selftest`, состояние `staged`) и точка в `Runner.loop` после `claim` без команды; `run` выходит с кодом 75. Новый файл `run.py` (без импорта `agent.py`: тот меняется) запускает `agent.py run` дочерним процессом, по коду 75 подменяет файл, ведёт пробный период и откат, перезапускает после падения с паузой.
|
||
|
||
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`.
|
||
|
||
**Spec:** `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update/openspec/changes/agent-self-update/` (design.md решения 7–24, specs/agent/spec.md — «Самообновление агента», «Запускатель run.py»). Решения агента при исполнении — `openspec/changes/agent-self-update/design.md` этого репозитория. План хаба — `jira-hub/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md`; агенту хаб в тестах не нужен (FakeHub).
|
||
|
||
**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update`, ветка `feat/19-agent-self-update`. Команды тестов — из этого каталога; git — `git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update …`.
|
||
|
||
## Global Constraints
|
||
|
||
- Python 3.9, только стандартная библиотека; `tests/test_py39.py` проверяет `agent.py` и `run.py`. Без `X | Y` в аннотациях, без `match`.
|
||
- `AGENT_VERSION = 5`; `LAUNCHER_VERSION = 1`; код выхода «обновление готово» — `75`.
|
||
- Файлы — в каталоге `agent.py`/`run.py`: `agent.py.new`, `agent.py.prev`, `update.json`, `run.py.lock`. Общая папка для кода не используется.
|
||
- `update.json` = `{state, from_version, to_version, sha256, error, at}`; состояния `staged`, `trial`, `ok`, `rolled_back`, `rejected`; запись — временный файл + `os.replace`.
|
||
- `selftest`: печатает `jira-agent v<N>`, затем как `check`; код 0 — всё доступно, 3 — что-то недоступно; блокировку журнала не берёт.
|
||
- Подготовка: таймаут `selftest` 120 с; повтор после временного сбоя — не раньше 300 с; подсказка без запускателя — не чаще 3600 с; ошибка `rejected` от `selftest` — последние 500 символов вывода.
|
||
- Запускатель: пробный период 600 с; SIGTERM, через 60 с SIGKILL; пауза после падения 5 → ×2 → не больше 300 с, сброс после 600 с работы.
|
||
- Тексты лога: «доступна v<N>; для самообновления запускайте через run.py», «v<N> готова, перезапуск через run.py», «обновление до v<N> подтверждено», «run: откат v<to> → v<from>: <причина>», «run: агент упал (код <N>), перезапуск через <S> с», «запускатель уже работает».
|
||
- Причины отката: «код <N> через <S> с», «не вышла на связь за 10 мин»; отказ при подмене: «файл изменился после проверки»; sha: «sha256 не сходится».
|
||
- Язык сообщений, комментариев, коммитов — русский. Коммит заканчивается строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||
- Весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь набор.
|
||
|
||
## Review Focus
|
||
|
||
- Ctrl-C во время `selftest` новой версии: SIGINT получает и дочерний `selftest` (общая группа), он падает с кодом 1 — это НЕ должно стать `rejected`; агент удаляет `.new` и выходит как обычно (тест в Task 2: `test_stop_during_selftest_is_not_reject`).
|
||
- Ответ `GET /agent/release` с другой версией/sha, чем в `claim` (публикация сменилась между запросами) — временный сбой, а не `rejected` (тест в Task 2: `test_release_changed_between_requests_is_transient`).
|
||
- Мусор в `update.json` (обрезан, не объект) — агент и запускатель не падают, считают «состояния нет» (тесты в Task 1: `test_read_update_state_garbage`, в Task 3: `test_garbage_update_json_is_no_state`).
|
||
- Падение запускателя между выходом агента с 75 и подменой — при следующем старте подмена доделывается (тест в Task 3: `test_staged_at_start_is_applied`).
|
||
- Ctrl-C в паузе перед перезапуском после падения — запускатель выходит, а не висит (тест в Task 3: `test_sigint_without_child_stops`).
|
||
|
||
---
|
||
|
||
### Task 1: Состояние обновления, `selftest`, поля `claim`, версия 5
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` (константы, `read_update_state`, `write_update_state`, `HubClient.claim`/`release`, класс `Updater` — только `state`/`confirm`, `Runner` — передача полей в `claim` и подтверждение, `cmd_selftest`, `main`)
|
||
- Modify: `tests/fake_hub.py` (поле `release`, маршрут `/agent/release`, `agent_release` в ответе `claim`)
|
||
- Modify: `tests/test_py39.py` (разрешённые импорты `hashlib`, `re`, `subprocess`)
|
||
- Create: `tests/test_update.py`
|
||
- Test: `tests/test_runner.py`, `tests/test_clients.py`
|
||
|
||
**Interfaces:**
|
||
- Produces (используют Task 2 и Task 4):
|
||
- `agent.AGENT_VERSION = 5`, `agent.EXIT_UPDATE = 75`
|
||
- `agent.read_update_state(path: str) -> Optional[dict]`
|
||
- `agent.write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict`
|
||
- `agent.HubClient.claim(agent_version, settings_version, last_jira_call_at, update_state=None, launcher_version=None) -> dict`
|
||
- `agent.HubClient.release() -> dict`
|
||
- `agent.Updater(hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True, config_path: Optional[str] = None)`; атрибуты `state_path`, `new_path`, `launcher_version`; методы `state() -> Optional[dict]`, `confirm() -> None`
|
||
- `agent.Runner.updater` (по умолчанию `None`), `agent.Runner.exit_code` (по умолчанию 0)
|
||
- `agent.cmd_selftest(cfg) -> int` (0 или 3); `main([... "selftest"])`
|
||
- `FakeHub.release` — `None` или `{"version", "sha256", "content"}`; `FakeHub.release_requests: int`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
`tests/test_update.py`:
|
||
|
||
```python
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import unittest
|
||
|
||
from _common import agent
|
||
|
||
|
||
class UpdateStateTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.TemporaryDirectory()
|
||
self.path = os.path.join(self.dir.name, "update.json")
|
||
|
||
def tearDown(self):
|
||
self.dir.cleanup()
|
||
|
||
def test_write_and_read(self):
|
||
d = agent.write_update_state(self.path, "staged", 5, 6, "abc")
|
||
self.assertEqual(agent.read_update_state(self.path), d)
|
||
self.assertEqual((d["state"], d["from_version"], d["to_version"], d["sha256"], d["error"]), ("staged", 5, 6, "abc", None))
|
||
self.assertTrue(d["at"])
|
||
self.assertEqual(os.listdir(self.dir.name), ["update.json"]) # временный файл не остался
|
||
|
||
def test_read_missing(self):
|
||
self.assertIsNone(agent.read_update_state(self.path))
|
||
|
||
def test_read_update_state_garbage(self):
|
||
for text in ('{"state": "tri', "[1, 2]", ""):
|
||
with open(self.path, "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
self.assertIsNone(agent.read_update_state(self.path))
|
||
|
||
def test_confirm_trial_of_this_version(self):
|
||
u = agent.Updater(None, self.dir.name, 1)
|
||
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s")
|
||
u.confirm()
|
||
st = agent.read_update_state(u.state_path)
|
||
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 4, agent.AGENT_VERSION, "s"))
|
||
|
||
def test_confirm_ignores_other_states(self):
|
||
u = agent.Updater(None, self.dir.name, 1)
|
||
u.confirm()
|
||
self.assertIsNone(u.state()) # нет файла — не создаётся
|
||
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION + 1, "s")
|
||
u.confirm()
|
||
self.assertEqual(u.state()["state"], "trial") # пробная другая версия — не наша
|
||
agent.write_update_state(u.state_path, "rolled_back", 5, 6, "s", "код 1 через 3 с")
|
||
u.confirm()
|
||
self.assertEqual(u.state()["state"], "rolled_back")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
`tests/test_runner.py`, в `RunnerTest`:
|
||
|
||
```python
|
||
def test_claim_sends_update_state_and_launcher(self):
|
||
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
|
||
agent.write_update_state(self.runner.updater.state_path, "trial", 4, agent.AGENT_VERSION, "s")
|
||
self.runner.loop()
|
||
body = self.hub.claim_bodies[0]
|
||
self.assertEqual(body["launcher_version"], 1)
|
||
self.assertEqual(body["update_state"]["state"], "trial")
|
||
self.assertEqual(agent.read_update_state(self.runner.updater.state_path)["state"], "ok") # первый успешный claim
|
||
self.assertEqual(self.hub.claim_bodies[0]["agent_version"], 5)
|
||
|
||
def test_claim_without_updater_sends_nulls(self):
|
||
self.runner.loop()
|
||
self.assertIsNone(self.hub.claim_bodies[0]["update_state"])
|
||
self.assertIsNone(self.hub.claim_bodies[0]["launcher_version"])
|
||
```
|
||
|
||
`tests/test_clients.py` — после `test_check_command` (в том же классе, где `self.fake` — FakeJira):
|
||
|
||
```python
|
||
def test_selftest_command(self):
|
||
import contextlib
|
||
import io
|
||
import json
|
||
import os
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as d:
|
||
path = os.path.join(d, "agent.json")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(jira_cfg(self.fake), f)
|
||
out = io.StringIO()
|
||
with contextlib.redirect_stdout(out):
|
||
# хаб недоступен (порт 1), Jira доступен — selftest печатает версию и возвращает 3, а не 1
|
||
self.assertEqual(agent.main(["--config", path, "selftest"]), 3)
|
||
self.assertIn("jira-agent v5", out.getvalue())
|
||
self.assertIn("Jira: доступен", out.getvalue())
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_update tests.test_runner tests.test_clients -v 2>&1 | tail -20`
|
||
Expected: FAIL/ERROR — `AttributeError: module 'agent' has no attribute 'write_update_state'`, `KeyError: 'update_state'`, `invalid choice: 'selftest'`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`tests/test_py39.py`: в `allowed` добавить `"hashlib", "re", "subprocess"`.
|
||
|
||
`src/agent.py`:
|
||
|
||
1. Docstring модуля — строки команд дополнить:
|
||
|
||
```
|
||
python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3
|
||
```
|
||
|
||
и абзац: «Самообновление — через запускатель run.py (спека agent, «Самообновление агента»).»
|
||
|
||
2. Импорты: добавить `hashlib`, `re`, `subprocess` (по алфавиту в существующем списке).
|
||
|
||
3. Константы:
|
||
|
||
```python
|
||
AGENT_VERSION = 5
|
||
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
|
||
```
|
||
|
||
4. После `load_config` — функции состояния обновления:
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# Самообновление: update.json рядом с agent.py (спека agent «Самообновление агента», design 7)
|
||
# ---------------------------------------------------------------------------
|
||
def read_update_state(path: str) -> Optional[dict]:
|
||
"""Состояние обновления; нет файла или мусор — None."""
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except FileNotFoundError:
|
||
return None
|
||
except (OSError, ValueError):
|
||
log("update.json не читается — считаю, что состояния нет")
|
||
return None
|
||
return data if isinstance(data, dict) else None
|
||
|
||
|
||
def write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict:
|
||
data = {"state": state, "from_version": from_version, "to_version": to_version, "sha256": sha256,
|
||
"error": error, "at": now_iso()}
|
||
tmp = path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False)
|
||
os.replace(tmp, path)
|
||
return data
|
||
```
|
||
|
||
5. `HubClient`:
|
||
|
||
```python
|
||
def claim(self, agent_version, settings_version, last_jira_call_at, update_state=None, launcher_version=None) -> dict:
|
||
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
|
||
"last_jira_call_at": last_jira_call_at, "update_state": update_state,
|
||
"launcher_version": launcher_version}, timeout=max(self.timeout, 45))
|
||
|
||
def release(self) -> dict:
|
||
return self.call("GET", "/agent/release")
|
||
```
|
||
|
||
6. Класс `Updater` (перед `class Runner`), пока только состояние и подтверждение — `check`/`prepare` добавит Task 2:
|
||
|
||
```python
|
||
class Updater:
|
||
"""Самообновление агента (спека agent «Самообновление агента», design 7–12). Работает только под run.py."""
|
||
|
||
def __init__(self, hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True,
|
||
config_path: Optional[str] = None):
|
||
self.hub, self.dir, self.launcher_version, self.enabled = hub, base_dir, launcher_version, enabled
|
||
self.config_path = config_path or os.path.join(base_dir, "agent.json")
|
||
self.state_path = os.path.join(base_dir, "update.json")
|
||
self.new_path = os.path.join(base_dir, "agent.py.new")
|
||
self.agent_path = os.path.join(base_dir, "agent.py")
|
||
|
||
def state(self) -> Optional[dict]:
|
||
return read_update_state(self.state_path)
|
||
|
||
def confirm(self) -> None:
|
||
"""Первый успешный claim новой версии: trial этой версии → ok (запускатель перестаёт ждать отката)."""
|
||
st = self.state()
|
||
if st and st.get("state") == "trial" and st.get("to_version") == AGENT_VERSION:
|
||
write_update_state(self.state_path, "ok", st.get("from_version"), AGENT_VERSION, st.get("sha256"))
|
||
log("обновление до v%d подтверждено" % AGENT_VERSION)
|
||
```
|
||
|
||
7. `Runner.__init__`: добавить
|
||
|
||
```python
|
||
self.updater = None # type: Optional[Updater]
|
||
self.exit_code = 0 # EXIT_UPDATE — выход для подмены версии
|
||
```
|
||
|
||
8. `Runner.loop` — вызов `claim` и подтверждение:
|
||
|
||
```python
|
||
try:
|
||
upd = self.updater
|
||
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso,
|
||
upd.state() if upd else None, upd.launcher_version if upd else None)
|
||
except (ConnectionError, HttpError) as e:
|
||
self.sleep(self.hub_wait(e, max(self.poll, 1)))
|
||
continue
|
||
if self.updater:
|
||
self.updater.confirm()
|
||
```
|
||
|
||
9. `cmd_selftest` после `cmd_check`:
|
||
|
||
```python
|
||
def cmd_selftest(cfg: dict) -> int:
|
||
"""Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться:
|
||
0 — хаб и Jira доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10)."""
|
||
log("jira-agent v%d" % AGENT_VERSION)
|
||
return 0 if cmd_check(cfg) == 0 else 3
|
||
```
|
||
|
||
`main`: `choices=["run", "check", "selftest", "prune"]` и ветка `if args.cmd == "selftest": return cmd_selftest(cfg)`.
|
||
|
||
`tests/fake_hub.py`: в `__init__` — `self.release = None` и `self.release_requests = 0`; в `route` для `/agent/claim` оба ответа `{"command": …, "settings_version": …}` дополнить `"agent_release": self._release_ref()`; метод и маршрут:
|
||
|
||
```python
|
||
def _release_ref(self):
|
||
r = self.release
|
||
return {"version": r["version"], "sha256": r["sha256"]} if r else None
|
||
```
|
||
|
||
```python
|
||
if path == "/agent/release":
|
||
self.release_requests += 1
|
||
return (200, self.release) if self.release else (404, {"error": "no_release"})
|
||
```
|
||
|
||
(маршрут — до разбора `/agent/commands/...`).
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . 2>&1 | tail -3`
|
||
Expected: `OK`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update add src/agent.py tests/
|
||
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update commit -m "feat(agent): update.json, selftest, update_state и launcher_version в claim, версия 5 (#19)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Обновление в паузе — `Updater.check`/`prepare`, выход 75
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` (`Updater.check`, `prepare`, `_run_selftest`, `_reject`, `_transient`, `_drop_new`; `Runner.loop`; `cmd_run`/`_run`; `main`)
|
||
- Test: `tests/test_update.py`, `tests/test_runner.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1 (`Updater`, `read_update_state`, `write_update_state`, `HubClient.release`, `FakeHub.release`).
|
||
- Produces:
|
||
- `Updater.check(release: Optional[dict]) -> bool` — True: `agent.py.new` готов, `update.json` = `staged`, `run` должен выйти с 75
|
||
- `Updater.run_selftest: Callable[[str], Tuple[Optional[int], str]]` — подменяется в тестах; `None` — таймаут
|
||
- `Updater.clock` (по умолчанию `time.monotonic`), `Updater.should_stop: Callable[[], bool]`
|
||
- `Updater.RETRY_SECONDS = 300`, `HINT_SECONDS = 3600`, `SELFTEST_TIMEOUT = 120`
|
||
- `cmd_run(cfg, journal_path, config_path=None) -> int` — 0 или `EXIT_UPDATE`
|
||
|
||
- [ ] **Step 1: Write the failing tests** — `tests/test_update.py`, новый класс:
|
||
|
||
```python
|
||
import hashlib
|
||
|
||
from fake_hub import FakeHub
|
||
from _common import SETTINGS
|
||
|
||
|
||
def make_release(version, body="print('ok')\n"):
|
||
content = "AGENT_VERSION = %d\n%s" % (version, body)
|
||
return {"version": version, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content}
|
||
|
||
|
||
class UpdaterCheckTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.TemporaryDirectory()
|
||
self.hub = FakeHub(SETTINGS)
|
||
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
|
||
self.u = agent.Updater(agent.HubClient(cfg), self.dir.name, 1)
|
||
self.selftests = []
|
||
self.code = 0
|
||
self.u.run_selftest = lambda path: (self.selftests.append(path), (self.code, "вывод selftest"))[1]
|
||
self.now = [1000.0]
|
||
self.u.clock = lambda: self.now[0]
|
||
|
||
def tearDown(self):
|
||
self.hub.stop()
|
||
self.dir.cleanup()
|
||
|
||
def ref(self, rel):
|
||
return {"version": rel["version"], "sha256": rel["sha256"]}
|
||
|
||
def test_good_version_is_staged(self):
|
||
rel = make_release(6)
|
||
self.hub.release = rel
|
||
self.assertTrue(self.u.check(self.ref(rel)))
|
||
with open(self.u.new_path, encoding="utf-8") as f:
|
||
self.assertEqual(f.read(), rel["content"])
|
||
st = self.u.state()
|
||
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("staged", 5, 6, rel["sha256"]))
|
||
self.assertEqual(self.selftests, [self.u.new_path])
|
||
|
||
def test_not_newer_or_disabled_or_no_release(self):
|
||
self.hub.release = make_release(5)
|
||
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||
self.assertFalse(self.u.check(None))
|
||
self.hub.release = make_release(6)
|
||
self.u.enabled = False
|
||
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||
self.assertEqual(self.hub.release_requests, 0)
|
||
|
||
def test_sha_mismatch_rejected_once(self):
|
||
rel = make_release(6)
|
||
self.hub.release = dict(rel, content=rel["content"] + "# подмена\n")
|
||
ref = self.ref(rel)
|
||
self.hub.release["sha256"] = rel["sha256"]
|
||
self.assertFalse(self.u.check(ref))
|
||
st = self.u.state()
|
||
self.assertEqual((st["state"], st["to_version"], st["error"]), ("rejected", 6, "sha256 не сходится"))
|
||
self.assertFalse(os.path.exists(self.u.new_path))
|
||
self.assertFalse(self.u.check(ref))
|
||
self.assertEqual(self.hub.release_requests, 1) # отвергнутую версию больше не качает
|
||
|
||
def test_syntax_error_and_wrong_version_rejected(self):
|
||
bad = make_release(6, body="def (:\n")
|
||
self.hub.release = bad
|
||
self.assertFalse(self.u.check(self.ref(bad)))
|
||
self.assertIn("не компилируется", self.u.state()["error"])
|
||
wrong = make_release(7)
|
||
wrong = dict(wrong, version=8)
|
||
self.hub.release = wrong
|
||
self.assertFalse(self.u.check(self.ref(wrong)))
|
||
self.assertIn("AGENT_VERSION", self.u.state()["error"])
|
||
self.assertEqual(self.selftests, [])
|
||
|
||
def test_selftest_bad_code_rejected(self):
|
||
self.hub.release = make_release(6)
|
||
self.code = 1
|
||
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||
st = self.u.state()
|
||
self.assertEqual(st["state"], "rejected")
|
||
self.assertTrue(st["error"].startswith("selftest: код 1"))
|
||
self.assertIn("вывод selftest", st["error"])
|
||
self.assertFalse(os.path.exists(self.u.new_path))
|
||
|
||
def test_selftest_unavailable_is_transient(self):
|
||
self.hub.release = make_release(6)
|
||
self.code = 3
|
||
ref = self.ref(self.hub.release)
|
||
self.assertFalse(self.u.check(ref))
|
||
self.assertIsNone(self.u.state()) # update.json не тронут
|
||
self.assertFalse(os.path.exists(self.u.new_path))
|
||
self.now[0] += 299
|
||
self.assertFalse(self.u.check(ref))
|
||
self.assertEqual(len(self.selftests), 1) # раньше 5 минут не повторяет
|
||
self.now[0] += 2
|
||
self.code = 0
|
||
self.assertTrue(self.u.check(ref))
|
||
|
||
def test_selftest_timeout_is_transient(self):
|
||
self.hub.release = make_release(6)
|
||
self.code = None
|
||
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||
self.assertIsNone(self.u.state())
|
||
|
||
def test_download_failure_is_transient(self):
|
||
rel = make_release(6) # в claim есть, а /agent/release — 404
|
||
self.assertFalse(self.u.check(self.ref(rel)))
|
||
self.assertIsNone(self.u.state())
|
||
|
||
def test_release_changed_between_requests_is_transient(self):
|
||
old, new = make_release(6), make_release(7)
|
||
self.hub.release = new
|
||
self.assertFalse(self.u.check(self.ref(old)))
|
||
self.assertIsNone(self.u.state())
|
||
self.assertEqual(self.selftests, [])
|
||
|
||
def test_rolled_back_same_sha_not_retried(self):
|
||
rel = make_release(6)
|
||
self.hub.release = rel
|
||
agent.write_update_state(self.u.state_path, "rolled_back", 5, 6, rel["sha256"], "код 1 через 3 с")
|
||
self.assertFalse(self.u.check(self.ref(rel)))
|
||
fixed = make_release(6, body="print('fixed')\n")
|
||
self.hub.release = fixed
|
||
self.assertTrue(self.u.check(self.ref(fixed))) # тот же номер, другой sha — пробуем
|
||
|
||
def test_without_launcher_only_hint(self):
|
||
self.u.launcher_version = None
|
||
self.hub.release = make_release(6)
|
||
logs = []
|
||
orig = agent.log
|
||
agent.log = logs.append
|
||
try:
|
||
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||
self.now[0] += 3600
|
||
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||
finally:
|
||
agent.log = orig
|
||
self.assertEqual(logs.count("доступна v6; для самообновления запускайте через run.py"), 2)
|
||
self.assertEqual(self.hub.release_requests, 0)
|
||
|
||
def test_stop_during_selftest_is_not_reject(self):
|
||
self.hub.release = make_release(6)
|
||
stop = [False]
|
||
self.u.should_stop = lambda: stop[0]
|
||
|
||
def selftest(path):
|
||
stop[0] = True # Ctrl-C пришёл и selftest упал с 1
|
||
return 1, "KeyboardInterrupt"
|
||
self.u.run_selftest = selftest
|
||
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_real_selftest_runs_new_file(self):
|
||
u = agent.Updater(None, self.dir.name, 1, config_path=os.path.join(self.dir.name, "agent.json"))
|
||
path = os.path.join(self.dir.name, "agent.py.new")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write("import sys\nprint('args', sys.argv[1:])\nsys.exit(3)\n")
|
||
code, out = u._run_selftest(path)
|
||
self.assertEqual(code, 3)
|
||
self.assertIn("'--config', '%s', 'selftest'" % os.path.join(self.dir.name, "agent.json"), out)
|
||
```
|
||
|
||
`tests/test_runner.py`, в `RunnerTest`:
|
||
|
||
```python
|
||
def test_idle_with_release_exits_for_update(self):
|
||
rel = {"version": 6, "sha256": "s", "content": "x"}
|
||
self.hub.release = rel
|
||
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
|
||
self.runner.updater.check = lambda r: r == {"version": 6, "sha256": "s"}
|
||
self.runner.idle_limit = 5
|
||
self.runner.loop()
|
||
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
|
||
self.assertEqual(self.hub.claims, 1)
|
||
|
||
def test_command_first_then_update(self):
|
||
self.hub.release = {"version": 6, "sha256": "s", "content": "x"}
|
||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
|
||
seen = []
|
||
self.runner.updater.check = lambda r: (seen.append(r), True)[1]
|
||
self.runner.loop()
|
||
self.assertEqual(self.hub.results[1]["status"], "done") # команда выполнена и подтверждена
|
||
self.assertEqual(len(seen), 1) # проверка — только после claim без команды
|
||
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_update tests.test_runner -v 2>&1 | tail -20`
|
||
Expected: ERROR — `AttributeError: 'Updater' object has no attribute 'check'`.
|
||
|
||
- [ ] **Step 3: Implement** — в `class Updater`:
|
||
|
||
```python
|
||
RETRY_SECONDS = 300 # временный сбой подготовки — повтор не раньше (R-8)
|
||
HINT_SECONDS = 3600 # подсказка «запускайте через run.py» — не чаще (R-10)
|
||
SELFTEST_TIMEOUT = 120
|
||
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)
|
||
```
|
||
|
||
в `__init__` дописать:
|
||
|
||
```python
|
||
self.retry_after = {} # (version, sha256) -> clock(), раньше которого не повторять
|
||
self.hint_at = None # type: Optional[float]
|
||
self.clock = time.monotonic
|
||
self.should_stop = lambda: False
|
||
self.run_selftest = self._run_selftest
|
||
```
|
||
|
||
методы:
|
||
|
||
```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
|
||
try:
|
||
version, sha = int(release.get("version")), str(release.get("sha256"))
|
||
except (TypeError, ValueError):
|
||
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:
|
||
now = self.clock()
|
||
if self.hint_at is None or now - self.hint_at >= self.HINT_SECONDS:
|
||
self.hint_at = now
|
||
log("доступна 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)
|
||
|
||
def prepare(self, version: int, sha: str) -> bool:
|
||
log("доступна v%d — готовлю обновление" % version)
|
||
try:
|
||
rel = self.hub.release()
|
||
except (ConnectionError, HttpError) as e:
|
||
return self._transient(version, sha, "скачивание: %s" % e)
|
||
content = rel.get("content") if isinstance(rel, dict) else None
|
||
if not isinstance(content, str) or rel.get("version") != version or rel.get("sha256") != sha:
|
||
return self._transient(version, sha, "публикация сменилась между запросами")
|
||
data = content.encode("utf-8")
|
||
if hashlib.sha256(data).hexdigest() != sha:
|
||
return self._reject(version, sha, "sha256 не сходится")
|
||
try:
|
||
compile(content, "agent.py.new", "exec")
|
||
except (SyntaxError, ValueError) as e:
|
||
return self._reject(version, sha, "не компилируется: %s" % e)
|
||
if self.VERSION_RE.findall(content) != [str(version)]:
|
||
return self._reject(version, sha, "AGENT_VERSION в файле не %d" % version)
|
||
tmp = self.new_path + ".tmp"
|
||
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)
|
||
if self.should_stop():
|
||
self._drop_new()
|
||
return False
|
||
code, out = self.run_selftest(self.new_path)
|
||
if self.should_stop(): # Ctrl-C уронил и selftest — это не отказ версии
|
||
self._drop_new()
|
||
return False
|
||
if code == 0:
|
||
write_update_state(self.state_path, "staged", AGENT_VERSION, version, sha)
|
||
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:]))
|
||
|
||
def _run_selftest(self, path: str):
|
||
try:
|
||
p = subprocess.run([sys.executable, path, "--config", self.config_path, "selftest"], cwd=self.dir,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.SELFTEST_TIMEOUT)
|
||
except subprocess.TimeoutExpired:
|
||
return None, ""
|
||
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||
|
||
def _drop_new(self) -> None:
|
||
for path in (self.new_path, self.new_path + ".tmp"):
|
||
try:
|
||
os.remove(path)
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
def _transient(self, version, sha, reason) -> bool:
|
||
self._drop_new()
|
||
self.retry_after[(version, sha)] = self.clock() + self.RETRY_SECONDS
|
||
log("обновление до v%d отложено на %d мин: %s" % (version, self.RETRY_SECONDS // 60, reason))
|
||
return False
|
||
|
||
def _reject(self, version, sha, reason) -> bool:
|
||
self._drop_new()
|
||
write_update_state(self.state_path, "rejected", AGENT_VERSION, version, sha, reason)
|
||
log("v%d отвергнута: %s" % (version, reason))
|
||
return False
|
||
```
|
||
|
||
`Runner.loop` — после `if cmd: … continue`, до `idle += 1`:
|
||
|
||
```python
|
||
if self.updater and not self.stop and self.updater.check(resp.get("agent_release")):
|
||
self.exit_code = EXIT_UPDATE
|
||
return
|
||
```
|
||
|
||
`cmd_run`/`_run` — передать путь настроек и собрать `Updater`:
|
||
|
||
```python
|
||
def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int:
|
||
...
|
||
return _run(cfg, journal_path, config_path)
|
||
```
|
||
|
||
```python
|
||
def launcher_version() -> Optional[int]:
|
||
"""Версия запускателя из окружения (run.py ставит JIRA_AGENT_LAUNCHER); без запускателя — None."""
|
||
try:
|
||
return int(os.environ["JIRA_AGENT_LAUNCHER"])
|
||
except (KeyError, ValueError):
|
||
return None
|
||
```
|
||
|
||
в `_run` после создания `runner`:
|
||
|
||
```python
|
||
hub = runner.hub
|
||
runner.updater = Updater(hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path)
|
||
runner.updater.should_stop = lambda: runner.stop
|
||
```
|
||
|
||
конец `_run`:
|
||
|
||
```python
|
||
runner.loop()
|
||
log("выход для обновления (код %d)" % EXIT_UPDATE if runner.exit_code == EXIT_UPDATE else "остановлен")
|
||
return runner.exit_code
|
||
```
|
||
|
||
`main`: `return cmd_run(cfg, args.journal, args.config)`.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . 2>&1 | tail -3`
|
||
Expected: `OK`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update add src/agent.py tests/
|
||
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update commit -m "feat(agent): самообновление в паузе — скачивание, sha256, selftest, выход 75 (#19)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Запускатель `run.py`
|
||
|
||
**Files:**
|
||
- Create: `src/run.py`
|
||
- Create: `tests/test_launcher.py`
|
||
- Modify: `tests/test_py39.py` (проверка `run.py`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: контракт агента — код 75 + `update.json` = `staged` + `agent.py.new`; `JIRA_AGENT_LAUNCHER`; агент пишет `ok` (Task 1–2). `run.py` НЕ импортирует `agent.py`.
|
||
- Produces:
|
||
- `run.LAUNCHER_VERSION = 1`, `run.EXIT_UPDATE = 75`
|
||
- `run.Launcher(base_dir: str, args: List[str])`; атрибуты `trial_seconds` (600), `kill_grace` (60), `pause` (метод `pause(seconds) -> bool`, подменяется в тестах), `stopping`, `child`; методы `run() -> int`, `on_sigint(signum, frame)`, `on_sigterm(signum, frame)`
|
||
- `run.acquire_lock(base_dir) -> Optional[file]`; `run.main(argv=None) -> int`
|
||
|
||
- [ ] **Step 1: Write the failing tests** — `tests/test_launcher.py`:
|
||
|
||
```python
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import textwrap
|
||
import unittest
|
||
|
||
from _common import SRC
|
||
|
||
if SRC not in sys.path:
|
||
sys.path.insert(0, SRC)
|
||
import run # noqa: E402
|
||
|
||
# Поддельные агенты: маленькие скрипты, которые ведут себя как agent.py по контракту (коды выхода, update.json).
|
||
STATE_HELPERS = textwrap.dedent('''
|
||
import json, os, sys
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
STATE = os.path.join(HERE, "update.json")
|
||
def state():
|
||
try:
|
||
return json.load(open(STATE, encoding="utf-8"))
|
||
except Exception:
|
||
return None
|
||
def write(**kw):
|
||
json.dump(kw, open(STATE, "w", encoding="utf-8"))
|
||
def mark(name):
|
||
open(os.path.join(HERE, "runs.log"), "a").write(name + "\\n")
|
||
''')
|
||
|
||
|
||
def fake(name, body):
|
||
return STATE_HELPERS + "mark(%r)\n" % name + textwrap.dedent(body)
|
||
|
||
|
||
# v5 «нашёл» новую версию: кладёт agent.py.new из next.py, пишет staged и выходит с 75; после отката/отказа — выход 0.
|
||
V5_UPDATES = fake("v5", '''
|
||
import hashlib
|
||
st = state()
|
||
if st is None:
|
||
data = open(os.path.join(HERE, "next.py"), "rb").read()
|
||
open(os.path.join(HERE, "agent.py.new"), "wb").write(data)
|
||
write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest())
|
||
sys.exit(75)
|
||
sys.exit(0)
|
||
''')
|
||
# v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new.
|
||
V5_BAD_STAGE = fake("v5", '''
|
||
st = state()
|
||
if st is None:
|
||
open(os.path.join(HERE, "agent.py.new"), "w").write("мусор")
|
||
write(state="staged", from_version=5, to_version=6, sha256="чужой")
|
||
sys.exit(75)
|
||
sys.exit(0)
|
||
''')
|
||
V6_OK_THEN_STOP = fake("v6", '''
|
||
st = state()
|
||
write(**dict(st, state="ok"))
|
||
sys.exit(0)
|
||
''')
|
||
V6_CRASH = fake("v6", 'sys.exit(1)\n')
|
||
V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n')
|
||
|
||
|
||
class LauncherTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.dir = self.tmp.name
|
||
self.l = run.Launcher(self.dir, ["--journal", "j.jsonl"])
|
||
self.pauses = []
|
||
self.l.pause = lambda s: (self.pauses.append(s), True)[1]
|
||
|
||
def tearDown(self):
|
||
self.tmp.cleanup()
|
||
|
||
def put(self, name, text):
|
||
with open(os.path.join(self.dir, name), "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
|
||
def read(self, name):
|
||
with open(os.path.join(self.dir, name), encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
def state(self):
|
||
return json.loads(self.read("update.json"))
|
||
|
||
def runs(self):
|
||
return self.read("runs.log").split()
|
||
|
||
def next_version(self, text):
|
||
self.put("next.py", text)
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
def stage(self, new_text, to_version=6):
|
||
self.put("agent.py.new", new_text)
|
||
sha = hashlib.sha256(new_text.encode("utf-8")).hexdigest()
|
||
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": to_version, "sha256": sha}))
|
||
return sha
|
||
|
||
def test_update_applied_and_confirmed(self):
|
||
self.put("agent.py", V5_UPDATES)
|
||
sha = self.next_version(V6_OK_THEN_STOP)
|
||
self.assertEqual(self.l.run(), 0)
|
||
self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP)
|
||
self.assertEqual(self.read("agent.py.prev"), V5_UPDATES)
|
||
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
|
||
st = self.state()
|
||
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 5, 6, sha))
|
||
self.assertEqual(self.runs(), ["v5", "v6"])
|
||
|
||
def test_crash_in_trial_rolls_back(self):
|
||
self.put("agent.py", V5_UPDATES)
|
||
sha = self.next_version(V6_CRASH)
|
||
self.assertEqual(self.l.run(), 0)
|
||
self.assertEqual(self.read("agent.py"), V5_UPDATES)
|
||
st = self.state()
|
||
self.assertEqual((st["state"], st["to_version"], st["sha256"]), ("rolled_back", 6, sha))
|
||
self.assertRegex(st["error"], r"^код 1 через \d+ с$")
|
||
self.assertEqual(self.runs(), ["v5", "v6", "v5"])
|
||
self.assertEqual(self.pauses, []) # откат без паузы
|
||
|
||
def test_hang_in_trial_rolls_back(self):
|
||
self.l.trial_seconds = 1
|
||
self.l.kill_grace = 5
|
||
self.put("agent.py", V5_UPDATES)
|
||
self.next_version(V6_HANG)
|
||
self.assertEqual(self.l.run(), 0)
|
||
self.assertEqual(self.read("agent.py"), V5_UPDATES)
|
||
self.assertEqual(self.state()["error"], "не вышла на связь за 10 мин")
|
||
|
||
def test_crash_after_ok_restarts_with_backoff(self):
|
||
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
|
||
self.put("agent.py", fake("v6", '''
|
||
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||
sys.exit(1 if n <= 2 else 0)
|
||
'''))
|
||
self.assertEqual(self.l.run(), 0)
|
||
self.assertEqual(self.pauses, [5, 10])
|
||
self.assertEqual(self.state()["state"], "ok") # отката нет
|
||
|
||
def test_backoff_caps_and_resets(self):
|
||
self.assertEqual([self.l.next_backoff(10) for _ in range(8)], [5, 10, 20, 40, 80, 160, 300, 300])
|
||
self.assertEqual(self.l.next_backoff(600), 5) # проработал 10 минут — с начала
|
||
|
||
def test_75_without_staged_rejected(self):
|
||
self.put("agent.py", V5_BAD_STAGE)
|
||
self.assertEqual(self.l.run(), 0)
|
||
self.assertEqual(self.read("agent.py"), V5_BAD_STAGE)
|
||
self.assertEqual(self.runs(), ["v5", "v5"])
|
||
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
|
||
st = self.state()
|
||
self.assertEqual((st["state"], st["error"]), ("rejected", "файл изменился после проверки"))
|
||
|
||
def test_staged_at_start_is_applied(self):
|
||
self.put("agent.py", fake("v5", "sys.exit(0)\n"))
|
||
self.stage(V6_OK_THEN_STOP)
|
||
self.assertEqual(self.l.run(), 0)
|
||
self.assertEqual(self.runs(), ["v6"])
|
||
self.assertEqual(self.state()["state"], "ok")
|
||
|
||
def test_stray_new_removed_at_start(self):
|
||
self.put("agent.py", fake("v5", "sys.exit(0)\n"))
|
||
self.put("agent.py.new", "остаток")
|
||
self.assertEqual(self.l.run(), 0)
|
||
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
|
||
|
||
def test_missing_agent_restored_from_prev(self):
|
||
self.put("agent.py.prev", fake("v5", "sys.exit(0)\n"))
|
||
self.assertEqual(self.l.run(), 0)
|
||
self.assertEqual(self.runs(), ["v5"])
|
||
|
||
def test_garbage_update_json_is_no_state(self):
|
||
self.put("update.json", '{"state": "tri')
|
||
self.put("agent.py", fake("v5", "sys.exit(0)\n"))
|
||
self.assertEqual(self.l.run(), 0)
|
||
|
||
def test_env_and_args_passed(self):
|
||
self.put("agent.py", fake("v5", '''
|
||
open(os.path.join(HERE, "env.txt"), "w").write(os.environ.get("JIRA_AGENT_LAUNCHER", "") + " " + " ".join(sys.argv[1:]))
|
||
sys.exit(130)
|
||
'''))
|
||
self.assertEqual(self.l.run(), 130)
|
||
self.assertEqual(self.read("env.txt"), "1 run --journal j.jsonl")
|
||
|
||
def test_sigint_without_child_stops(self):
|
||
self.l.on_sigint(signal.SIGINT, None)
|
||
self.assertTrue(self.l.stopping)
|
||
|
||
def test_sigint_with_child_ignored(self):
|
||
self.l.child = object()
|
||
self.l.on_sigint(signal.SIGINT, None)
|
||
self.assertFalse(self.l.stopping)
|
||
|
||
def test_second_launcher_refused(self):
|
||
with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f:
|
||
self.put("run.py", f.read())
|
||
self.put("agent.py", fake("v5", "sys.exit(0)\n"))
|
||
lock = run.acquire_lock(self.dir)
|
||
try:
|
||
p = subprocess.run([sys.executable, os.path.join(self.dir, "run.py")], stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT, timeout=30)
|
||
finally:
|
||
lock.close()
|
||
self.assertEqual(p.returncode, 1)
|
||
self.assertIn("запускатель уже работает", p.stdout.decode("utf-8"))
|
||
self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log")))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
`tests/test_py39.py` — оба теста по циклу `for name in ("agent.py", "run.py")` (через `self.subTest(name)`), `allowed` для `run.py` тот же набор плюс `subprocess`, `hashlib` уже есть с Task 1.
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_launcher -v 2>&1 | tail -5`
|
||
Expected: ERROR — `ModuleNotFoundError: No module named 'run'`.
|
||
|
||
- [ ] **Step 3: Implement** — `src/run.py`:
|
||
|
||
```python
|
||
#!/usr/bin/env python3
|
||
"""run.py — запускатель jira-agent: запускает agent.py дочерним процессом, ставит новую версию после самообновления
|
||
агента, откатывает её, если она упала или не вышла на связь, и поднимает агента после падения.
|
||
Только стандартная библиотека, Python 3.9. agent.py не импортирует: тот меняется, запускатель — нет.
|
||
Спека: jira-hub/openspec/specs/agent/spec.md, «Запускатель run.py».
|
||
|
||
python3 run.py [--config agent.json] [--journal journal.jsonl] # аргументы уходят в agent.py run
|
||
|
||
Обновляется вручную (редко): остановить, заменить файл, запустить.
|
||
"""
|
||
import fcntl
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from typing import List, Optional
|
||
|
||
LAUNCHER_VERSION = 1
|
||
EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged»
|
||
TRIAL_SECONDS = 600 # новая версия должна записать ok за это время
|
||
KILL_GRACE = 60 # после SIGTERM до SIGKILL
|
||
BACKOFF_START, BACKOFF_MAX = 5, 300
|
||
HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала
|
||
|
||
|
||
def log(msg: str) -> None:
|
||
print("[%s] run: %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True)
|
||
|
||
|
||
def read_state(path: str) -> Optional[dict]:
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except (OSError, ValueError):
|
||
return None
|
||
return data if isinstance(data, dict) else None
|
||
|
||
|
||
def write_state(path: str, state: str, base: Optional[dict], error=None) -> None:
|
||
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"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False)
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def sha256_file(path: str) -> str:
|
||
with open(path, "rb") as f:
|
||
return hashlib.sha256(f.read()).hexdigest()
|
||
|
||
|
||
def acquire_lock(base_dir: str):
|
||
"""Один запускатель на каталог. None — уже занято."""
|
||
f = open(os.path.join(base_dir, "run.py.lock"), "a")
|
||
try:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except OSError:
|
||
f.close()
|
||
return None
|
||
return f
|
||
|
||
|
||
class Launcher:
|
||
def __init__(self, base_dir: str, args: List[str]):
|
||
self.dir, self.args = base_dir, list(args)
|
||
self.agent = os.path.join(base_dir, "agent.py")
|
||
self.new = os.path.join(base_dir, "agent.py.new")
|
||
self.prev = os.path.join(base_dir, "agent.py.prev")
|
||
self.state_path = os.path.join(base_dir, "update.json")
|
||
self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE
|
||
self.backoff = BACKOFF_START
|
||
self.stopping = False
|
||
self.child = None
|
||
|
||
# --- сигналы ---------------------------------------------------------------
|
||
def on_sigint(self, signum, frame):
|
||
"""Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он. Без агента (пауза) — выход."""
|
||
if self.child is None:
|
||
self.stopping = True
|
||
|
||
def on_sigterm(self, signum, frame):
|
||
self.stopping = True
|
||
child = self.child
|
||
if child is not None and hasattr(child, "send_signal"):
|
||
child.send_signal(signal.SIGTERM)
|
||
|
||
# --- файлы версии ----------------------------------------------------------
|
||
def recover_files(self) -> None:
|
||
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")
|
||
if os.path.exists(self.new):
|
||
st = read_state(self.state_path)
|
||
if st and st.get("state") == "staged":
|
||
log("найдена подготовленная версия — ставлю")
|
||
self.apply_update()
|
||
else:
|
||
os.remove(self.new)
|
||
|
||
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")):
|
||
if os.path.exists(self.new):
|
||
os.remove(self.new)
|
||
write_state(self.state_path, "rejected", st, "файл изменился после проверки")
|
||
log("обновление отвергнуто: файл изменился после проверки")
|
||
return
|
||
tmp = self.prev + ".tmp"
|
||
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 существует на каждом шаге
|
||
write_state(self.state_path, "trial", st)
|
||
log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version")))
|
||
|
||
def rollback(self, st: Optional[dict], reason: str) -> bool:
|
||
if not os.path.exists(self.prev):
|
||
log("откат невозможен: нет agent.py.prev")
|
||
return False
|
||
os.replace(self.prev, self.agent)
|
||
write_state(self.state_path, "rolled_back", st, reason)
|
||
log("откат v%s → v%s: %s" % ((st or {}).get("to_version"), (st or {}).get("from_version"), reason))
|
||
return True
|
||
|
||
# --- процесс агента --------------------------------------------------------
|
||
def spawn(self):
|
||
env = dict(os.environ, JIRA_AGENT_LAUNCHER=str(LAUNCHER_VERSION))
|
||
return subprocess.Popen([sys.executable, "-u", self.agent, "run"] + self.args, env=env, cwd=self.dir)
|
||
|
||
def terminate(self, child) -> None:
|
||
child.send_signal(signal.SIGTERM)
|
||
try:
|
||
child.wait(self.kill_grace)
|
||
except subprocess.TimeoutExpired:
|
||
child.kill()
|
||
child.wait()
|
||
|
||
def wait_child(self, child, trial: bool):
|
||
"""Код выхода и признак «пробный период истёк». В пробный период ждём ok не дольше trial_seconds."""
|
||
start = time.monotonic()
|
||
while True:
|
||
try:
|
||
return child.wait(1), False
|
||
except subprocess.TimeoutExpired:
|
||
pass
|
||
if trial:
|
||
st = read_state(self.state_path)
|
||
if st and st.get("state") == "ok":
|
||
trial = False
|
||
elif time.monotonic() - start >= self.trial_seconds:
|
||
self.terminate(child)
|
||
return child.returncode, True
|
||
|
||
def pause(self, seconds: float) -> bool:
|
||
"""Пауза перед перезапуском; False — остановлены во время паузы."""
|
||
end = time.monotonic() + seconds
|
||
while not self.stopping and time.monotonic() < end:
|
||
time.sleep(min(1.0, end - time.monotonic()))
|
||
return not self.stopping
|
||
|
||
def next_backoff(self, uptime: float) -> int:
|
||
if uptime >= HEALTHY_UPTIME:
|
||
self.backoff = BACKOFF_START
|
||
wait = self.backoff
|
||
self.backoff = min(self.backoff * 2, BACKOFF_MAX)
|
||
return wait
|
||
|
||
def run(self) -> int:
|
||
self.recover_files()
|
||
while True:
|
||
st = read_state(self.state_path)
|
||
trial = bool(st and st.get("state") == "trial")
|
||
started = time.monotonic()
|
||
self.child = self.spawn()
|
||
code, timed_out = self.wait_child(self.child, trial)
|
||
self.child = None
|
||
uptime = time.monotonic() - started
|
||
st = read_state(self.state_path)
|
||
if timed_out:
|
||
if self.rollback(st, "не вышла на связь за 10 мин"):
|
||
continue
|
||
elif code in (0, 130) or self.stopping:
|
||
return code
|
||
elif code == EXIT_UPDATE:
|
||
self.apply_update()
|
||
self.backoff = BACKOFF_START
|
||
continue
|
||
elif trial and not (st and st.get("state") == "ok"):
|
||
if self.rollback(st, "код %s через %d с" % (code, uptime)):
|
||
continue
|
||
if self.stopping:
|
||
return code
|
||
wait = self.next_backoff(uptime)
|
||
log("агент упал (код %s), перезапуск через %d с" % (code, wait))
|
||
if not self.pause(wait):
|
||
return code
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
base = os.path.dirname(os.path.abspath(__file__))
|
||
lock = acquire_lock(base)
|
||
if lock is None:
|
||
log("запускатель уже работает в %s — второй не стартует" % base)
|
||
return 1
|
||
try:
|
||
launcher = Launcher(base, sys.argv[1:] if argv is None else argv)
|
||
signal.signal(signal.SIGINT, launcher.on_sigint)
|
||
signal.signal(signal.SIGTERM, launcher.on_sigterm)
|
||
log("запускатель v%d: агент %s" % (LAUNCHER_VERSION, launcher.agent))
|
||
return launcher.run()
|
||
finally:
|
||
lock.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|
||
```
|
||
|
||
Примечание к `test_hang_in_trial_rolls_back`: таймаут пробного периода в тесте 1 с, а причина в `update.json` — всегда «не вышла на связь за 10 мин» (текст спеки, не зависит от `trial_seconds`). Поддельный v6 без обработчика SIGTERM умирает с кодом `-15` — это не 0/130, но `timed_out` проверяется первым.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . 2>&1 | tail -3`
|
||
Expected: `OK`. Если `ResourceWarning` на `Popen` — в `run()` после `wait_child` объект `Popen` уже дождан; на `open(...)` в поддельных агентах предупреждения в дочернем процессе не ловятся.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update add src/run.py tests/test_launcher.py tests/test_py39.py
|
||
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update commit -m "feat(agent): запускатель run.py — подмена версии, пробный период, откат, перезапуск (#19)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Сквозной тест `run.py` + настоящий `agent.py` и документация
|
||
|
||
**Files:**
|
||
- Create: `tests/test_self_update_e2e.py`
|
||
- Modify: `src/agent.example.json` (`"auto_update": true`)
|
||
- Modify: `README.md` («Доставка на виртуалку», «Запуск», «Автозапуск через launchd», «Обновление агента», раздел «Самообновление»)
|
||
- Modify: `ARCHITECTURE.md` (раздел `Updater`, `run.py`, границы)
|
||
- Modify: `CLAUDE.md` (архитектура, команды, текущий change, пункт публикации в закрытии)
|
||
|
||
**Interfaces:**
|
||
- Consumes: всё из Task 1–3; `FakeHub.release`, `FakeJira` (`/rest/api/2/myself`).
|
||
|
||
- [ ] **Step 1: Write the failing test** — `tests/test_self_update_e2e.py`:
|
||
|
||
```python
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import shutil
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import unittest
|
||
|
||
from _common import SETTINGS, SRC
|
||
from fake_hub import FakeHub
|
||
from fake_jira import FakeJira
|
||
|
||
|
||
def wait_for(pred, timeout=30):
|
||
end = time.monotonic() + timeout
|
||
while time.monotonic() < end:
|
||
if pred():
|
||
return True
|
||
time.sleep(0.1)
|
||
return False
|
||
|
||
|
||
class SelfUpdateE2E(unittest.TestCase):
|
||
"""run.py + настоящий agent.py: v5 видит публикацию v6, ставит её, v6 подтверждает; Ctrl-C группе — выход 0."""
|
||
|
||
def setUp(self):
|
||
self.jira = FakeJira()
|
||
self.hub = FakeHub(SETTINGS)
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
d = self.tmp.name
|
||
shutil.copy(os.path.join(SRC, "run.py"), d)
|
||
shutil.copy(os.path.join(SRC, "agent.py"), d)
|
||
with open(os.path.join(d, "agent.json"), "w", encoding="utf-8") as f:
|
||
json.dump({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5, "poll_seconds": 0.2,
|
||
"jira": {"base_url": self.jira.base_url,
|
||
"auth": {"type": "basic", "user": "me", "password": "secret"}}}, f)
|
||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||
src = f.read()
|
||
content = src.replace("AGENT_VERSION = 5\n", "AGENT_VERSION = 6\n", 1)
|
||
self.assertNotEqual(content, src)
|
||
self.hub.release = {"version": 6, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content}
|
||
self.content = content
|
||
self.log = open(os.path.join(d, "out.log"), "wb")
|
||
self.proc = subprocess.Popen([sys.executable, os.path.join(d, "run.py")], stdout=self.log,
|
||
stderr=subprocess.STDOUT, start_new_session=True)
|
||
|
||
def tearDown(self):
|
||
if self.proc.poll() is None:
|
||
os.killpg(self.proc.pid, signal.SIGKILL)
|
||
self.proc.wait()
|
||
self.log.close()
|
||
self.hub.stop()
|
||
self.jira.stop()
|
||
self.tmp.cleanup()
|
||
|
||
def state(self):
|
||
try:
|
||
with open(os.path.join(self.tmp.name, "update.json"), encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except (OSError, ValueError):
|
||
return None
|
||
|
||
def test_update_to_v6_and_stop_by_ctrl_c(self):
|
||
ok = wait_for(lambda: (self.state() or {}).get("state") == "ok", 60)
|
||
with open(os.path.join(self.tmp.name, "out.log"), encoding="utf-8", errors="replace") as f:
|
||
out = f.read()
|
||
self.assertTrue(ok, out)
|
||
with open(os.path.join(self.tmp.name, "agent.py"), encoding="utf-8") as f:
|
||
self.assertEqual(f.read(), self.content)
|
||
self.assertTrue(os.path.exists(os.path.join(self.tmp.name, "agent.py.prev")))
|
||
self.assertTrue(wait_for(lambda: any(b.get("agent_version") == 6 for b in self.hub.claim_bodies)))
|
||
last = [b for b in self.hub.claim_bodies if b.get("agent_version") == 6][-1]
|
||
self.assertEqual(last["launcher_version"], 1)
|
||
os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе
|
||
self.assertEqual(self.proc.wait(30), 0)
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails or passes**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_self_update_e2e -v 2>&1 | tail -15`
|
||
Expected: PASS, если Task 1–3 выполнены верно (это интеграционный тест, а не TDD новой логики). Если FAIL — разбирать вывод `out.log` из сообщения теста (superpowers:systematic-debugging), чинить в `agent.py`/`run.py` с отдельным тестом на найденный случай в `test_update.py`/`test_launcher.py`.
|
||
|
||
- [ ] **Step 3: Documentation**
|
||
|
||
`src/agent.example.json` — после `"poll_seconds": 5` строка `"auto_update": true`.
|
||
|
||
`README.md`:
|
||
- первая строка описания: «Один файл `src/agent.py` и запускатель `src/run.py`, только стандартная библиотека, Python 3.9.»
|
||
- «Доставка на виртуалку», шаг 1: копировать `src/agent.py`, `src/run.py` и `src/agent.example.json` в локальный каталог виртуалки (`~/jira-agent-bundle`), **не запускать из общей папки**; в список настроек — `auto_update` (по умолчанию `true`; `false` — не обновляться самому).
|
||
- «Проверка»: добавить абзац про `python3 agent.py selftest` («то же, что `check`, плюс строка `jira-agent v<N>`; код 3 — что-то недоступно; её вызывает предыдущая версия перед обновлением»).
|
||
- «Запуск»: `python3 run.py` в терминале, `nohup python3 -u run.py > agent.log 2>&1 &` в фоне; Ctrl-C — как раньше (первый — доделать команду, второй — выйти сразу); запуск `python3 agent.py` без запускателя возможен, но тогда агент себя не обновляет и раз в час пишет «доступна v<N>…».
|
||
- «Автозапуск через launchd»: в `ProgramArguments` — `/Users/USER/jira-agent/run.py`.
|
||
- новый раздел «Самообновление» (после «Запуск»):
|
||
|
||
```markdown
|
||
## Самообновление
|
||
|
||
С версии 5 агент обновляется сам. После `jira-hub agent publish` на Mac mini хаб сообщает новую версию в ответе на
|
||
`claim`. Агент в паузе между командами (очередь пуста, отчёты подтверждены) скачивает файл по `GET /agent/release`,
|
||
сверяет sha256, компилирует, проверяет `AGENT_VERSION`, кладёт рядом `agent.py.new`, запускает его `selftest` и выходит
|
||
с кодом 75. `run.py` копирует текущий `agent.py` в `agent.py.prev`, ставит новый и запускает его в пробном режиме:
|
||
версия подтверждена, когда она получила первый ответ хаба (`update.json` → `ok`). Если новая версия упала или не вышла
|
||
на связь за 10 минут — `run.py` возвращает `agent.py.prev` и пишет `rolled_back`; эту версию агент больше не ставит,
|
||
хаб показывает откат в `agent_status`. Код в общую папку не попадает: файлы пишутся только в каталог `run.py`.
|
||
|
||
Файлы рядом с `agent.py`: `update.json` (состояние: `staged`, `trial`, `ok`, `rolled_back`, `rejected`),
|
||
`agent.py.prev` (прежняя версия), `run.py.lock`. Не удалять во время работы.
|
||
|
||
Выключить: `"auto_update": false` в `agent.json`. `run.py` обновляется вручную (меняется редко).
|
||
```
|
||
|
||
- «Обновление агента» переписать: «С версии 5 — само (раздел «Самообновление»). Вручную — только `run.py` или переход с v4: остановить старый процесс, скопировать `agent.py` и `run.py` в каталог агента, `python3 agent.py check`, запустить `python3 run.py`. `agent.json`, `journal.jsonl`, `update.json` не трогать.» (абзац о двух процессах и блокировке журнала сохранить).
|
||
- «Версия» в «Правила работы»: «сейчас 5 — самообновление через run.py».
|
||
|
||
`ARCHITECTURE.md`:
|
||
- первая строка: агент — `src/agent.py` и запускатель `src/run.py`.
|
||
- строка таблицы перед «Цикл»:
|
||
|
||
```markdown
|
||
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
||
```
|
||
|
||
- у «Цикл» — «после `claim` без команды спрашивает `Updater`; готова версия — выход с `EXIT_UPDATE` (75)»; у CLI — `selftest`.
|
||
- новый абзац «## Запускатель `src/run.py`»: не импортирует `agent.py`; `Launcher` — дочерний `agent.py run`, коды 0/130 → выход, 75 → `apply_update` (`.prev`, `os.replace`, `trial`), падение в `trial` или 10 мин без `ok` → `rollback`, иначе пауза 5…300 с; блокировка `run.py.lock`; SIGINT при живом агенте игнорирует, SIGTERM пересылает.
|
||
- «Границы»: «Код новой версии приходит только через API хаба и пишется в каталог агента; общая папка для кода не используется.»
|
||
|
||
`CLAUDE.md`:
|
||
- «Архитектура»: `src/run.py` — запускатель (не импортирует `agent.py`); раздел `Updater` между `Runner` и CLI; CLI — `run|check|selftest|prune`.
|
||
- «Команды»: `/usr/bin/python3 src/run.py` — запуск через запускатель (самообновление); `/usr/bin/python3 src/agent.py selftest`.
|
||
- «Тесты»: `test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах), `test_self_update_e2e.py` (run.py + настоящий agent.py + фейки).
|
||
- «Первая подсистема», последний абзац: текущий change — `../jira-hub/openspec/changes/agent-self-update/`, план — `docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`.
|
||
- новый раздел:
|
||
|
||
```markdown
|
||
## Выкатка новой версии
|
||
Самообновление (с v5): после merge PR агента в main — поднять `AGENT_VERSION` (если не поднят в PR) и опубликовать:
|
||
`uv run --project ../jira-hub jira-hub agent publish src/agent.py` (из основного checkout `jira-agent` на main).
|
||
Через несколько минут MCP `agent_status` должен показать «обновление: ok v<старая> → v<новая>»; откат — «откат с v<N>…»
|
||
(причина — в логе агента на виртуалке). Это пункт чеклиста «закрывай» для PR агента. `run.py` публикацией не
|
||
обновляется — только вручную через общую папку.
|
||
```
|
||
|
||
- [ ] **Step 4: Run all tests**
|
||
|
||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . 2>&1 | tail -3`
|
||
Expected: `OK`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update add tests/test_self_update_e2e.py src/agent.example.json README.md ARCHITECTURE.md CLAUDE.md
|
||
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update commit -m "test(agent): сквозной тест самообновления; README, ARCHITECTURE, CLAUDE.md — run.py и публикация (#19)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|