diff --git a/agents/runner.py b/agents/runner.py index f099a6f..4df79a4 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -71,11 +71,6 @@ def _build_claude_env() -> dict: socks = glob.glob("/private/tmp/com.apple.launchd.*/Listeners") if socks: env["SSH_AUTH_SOCK"] = socks[0] - if "SSH_AGENT_PID" not in env: - pid = os.environ.get("SSH_AGENT_PID") - if pid: - env["SSH_AGENT_PID"] = pid - return env diff --git a/tests/test_runner.py b/tests/test_runner.py index 264813f..22f4452 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1423,6 +1423,75 @@ class TestClaudePath: "KIN-057: _resolve_claude_cmd должен возвращать строку даже при пустом os PATH" ) + # --------------------------------------------------------------------------- + # KIN-FIX-013: регрессия — удалён мёртвый код SSH_AGENT_PID + # --------------------------------------------------------------------------- + + def test_build_claude_env_no_ssh_agent_pid_injection(self): + """Регрессия KIN-FIX-013: SSH_AGENT_PID не должен искусственно добавляться. + + Если SSH_AGENT_PID отсутствует в os.environ — его не должно быть в результате. + Ветка `if 'SSH_AGENT_PID' not in env` была always-no-op (env — копия os.environ, + поэтому условие всегда False при наличии ключа). Удалена как мёртвый код. + """ + env_without_pid = {k: v for k, v in __import__("os").environ.items() + if k != "SSH_AGENT_PID"} + with patch.dict("os.environ", env_without_pid, clear=True): + env = _build_claude_env() + assert "SSH_AGENT_PID" not in env, ( + "Регрессия KIN-FIX-013: SSH_AGENT_PID не должен появляться в env " + "если его нет в os.environ — мёртвый код был удалён" + ) + + def test_build_claude_env_ssh_agent_pid_preserved_when_set(self): + """SSH_AGENT_PID из os.environ наследуется через os.environ.copy() штатно.""" + with patch.dict("os.environ", {"SSH_AGENT_PID": "12345"}): + env = _build_claude_env() + assert env.get("SSH_AGENT_PID") == "12345", ( + "SSH_AGENT_PID из os.environ должен наследоваться через copy()" + ) + + def test_build_claude_env_no_dead_ssh_agent_pid_code(self): + """Регрессия KIN-FIX-013: исходный код _build_claude_env не содержит SSH_AGENT_PID. + + Любое возвращение мёртвого кода будет поймано здесь. + """ + import inspect + src = inspect.getsource(_build_claude_env) + assert "SSH_AGENT_PID" not in src, ( + "Регрессия KIN-FIX-013: мёртвый код SSH_AGENT_PID был возвращён в _build_claude_env" + ) + + def test_build_claude_env_ssh_auth_sock_forwarded_from_environ(self): + """SSH_AUTH_SOCK из os.environ должен пробрасываться в результирующий env.""" + with patch.dict("os.environ", {"SSH_AUTH_SOCK": "/tmp/ssh-agent.sock"}): + env = _build_claude_env() + assert env.get("SSH_AUTH_SOCK") == "/tmp/ssh-agent.sock", ( + "SSH_AUTH_SOCK из os.environ должен наследоваться через copy()" + ) + + def test_build_claude_env_ssh_auth_sock_fallback_when_absent(self): + """Если SSH_AUTH_SOCK не в os.environ, _build_claude_env пробует macOS-сокет.""" + env_without_sock = {k: v for k, v in __import__("os").environ.items() + if k != "SSH_AUTH_SOCK"} + fake_sock = "/private/tmp/com.apple.launchd.FAKE12345/Listeners" + with patch.dict("os.environ", env_without_sock, clear=True): + with patch("glob.glob", return_value=[fake_sock]): + env = _build_claude_env() + # Если glob нашёл сокет — он должен быть установлен + assert env.get("SSH_AUTH_SOCK") == fake_sock, ( + "SSH_AUTH_SOCK должен быть установлен из macOS launchd-сокета при его наличии" + ) + + def test_build_claude_env_ssh_auth_sock_absent_when_no_fallback(self): + """Если SSH_AUTH_SOCK нет и macOS-сокет не найден — ключ не добавляется.""" + env_without_sock = {k: v for k, v in __import__("os").environ.items() + if k != "SSH_AUTH_SOCK"} + with patch.dict("os.environ", env_without_sock, clear=True): + with patch("glob.glob", return_value=[]): + env = _build_claude_env() + assert "SSH_AUTH_SOCK" not in env + # --------------------------------------------------------------------------- # KIN-063: TestCompletionMode — auto_complete + last-step role check diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py index d83c4de..da5b2b9 100644 --- a/tests/test_watchdog.py +++ b/tests/test_watchdog.py @@ -357,3 +357,44 @@ def test_check_dead_pipelines_permission_error_ignored(tmp_path): assert task["status"] != "blocked" assert pipeline_row["status"] == "running" + + +# --------------------------------------------------------------------------- +# _check_parent_alive: EACCES и ProcessLookupError (KIN-099, решения #341/#358) +# --------------------------------------------------------------------------- + +def test_check_parent_alive_eacces_does_not_abort(conn): + """EACCES при os.kill(ppid, 0) — процесс жив (нет прав) → pipeline продолжается (решение #358).""" + import errno as _errno + from agents.runner import _check_parent_alive + + pipeline = models.create_pipeline(conn, "VDOL-001", "vdol", "custom", []) + eacces_ppid = 99990 + + with patch("agents.runner.os.getppid", return_value=eacces_ppid), \ + patch("agents.runner.os.kill", side_effect=OSError(_errno.EACCES, "Operation not permitted")): + result = _check_parent_alive(conn, pipeline, "VDOL-001", "vdol") + + assert result is False + + task = models.get_task(conn, "VDOL-001") + assert task["status"] != "blocked" + + +def test_check_parent_alive_process_lookup_error_aborts(conn): + """ProcessLookupError (errno=ESRCH) при os.kill → pipeline прерывается (решение #341).""" + import errno as _errno + from agents.runner import _check_parent_alive + + pipeline = models.create_pipeline(conn, "VDOL-001", "vdol", "custom", []) + dead_ppid = 99991 + + with patch("agents.runner.os.getppid", return_value=dead_ppid), \ + patch("agents.runner.os.kill", side_effect=ProcessLookupError(_errno.ESRCH, "No such process")): + result = _check_parent_alive(conn, pipeline, "VDOL-001", "vdol") + + assert result is True + + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "blocked" + assert str(dead_ppid) in (task.get("blocked_reason") or "") diff --git a/web/frontend/src/__tests__/watchdog-toast.test.ts b/web/frontend/src/__tests__/watchdog-toast.test.ts index c47d6f6..41b9eee 100644 --- a/web/frontend/src/__tests__/watchdog-toast.test.ts +++ b/web/frontend/src/__tests__/watchdog-toast.test.ts @@ -212,3 +212,81 @@ describe('KIN-099: toast auto-dismiss через 8 секунд', () => { expect(wrapper.find('.border-red-700').exists()).toBe(true) }) }) + +// ───────────────────────────────────────────────────────────── +// Критерий 5 (KIN-099 issue severity=medium): +// onUnmounted очищает setTimeout таймеры watchdog-тостов +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: onUnmounted очищает setTimeout таймеры watchdog-тостов', () => { + it('clearTimeout вызывается для активного таймера при unmount компонента', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-099', 'Process died unexpectedly (PID 12345)'), + ]) + + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') + + const wrapper = mount(EscalationBanner) + await flushPromises() + + // Toast must be visible with an active auto-dismiss timer + expect(wrapper.find('.border-red-700').exists()).toBe(true) + + wrapper.unmount() + + // onUnmounted should have called clearTimeout for the toast timer + expect(clearTimeoutSpy).toHaveBeenCalled() + }) + + it('После unmount таймер авто-dismiss не срабатывает (нет ошибок)', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-050', 'Process died unexpectedly (PID 11111)'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(true) + + // Unmount before 8 second timer fires — timer should be cancelled + wrapper.unmount() + + // Advance past 8 seconds — should not throw even though component is gone + vi.advanceTimersByTime(10000) + await flushPromises() + // Pass = no errors thrown after unmount + }) +}) + +// ───────────────────────────────────────────────────────────── +// Критерий 6 (KIN-099 issue severity=low): +// localStorage kin_dismissed_watchdog_toasts — сохранение работает +// Лимит реализован в KIN-OBS-017 (WATCHDOG_MAX_STORED = 100) +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: localStorage dismissed watchdog — сохранение (лимит: KIN-OBS-017)', () => { + it('Несколько dismissed task_id корректно сохраняются в localStorage', async () => { + const notifications = ['KIN-051', 'KIN-052', 'KIN-053'].map(id => + makeNotification(id, 'Process died unexpectedly (PID 1234)') + ) + vi.mocked(api.notifications).mockResolvedValue(notifications) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + // Dismiss all toasts one by one + for (let i = 0; i < 3; i++) { + const btn = wrapper.find('.border-red-700 button') + if (btn.exists()) await btn.trigger('click') + await flushPromises() + } + + const stored = localStorageMock.getItem('kin_dismissed_watchdog_toasts') + expect(stored).toBeTruthy() + const parsed = JSON.parse(stored!) + expect(parsed).toContain('KIN-051') + expect(parsed).toContain('KIN-052') + expect(parsed).toContain('KIN-053') + // Size limit is capped at WATCHDOG_MAX_STORED (100) — only last 100 IDs stored + }) +}) diff --git a/web/frontend/src/components/EscalationBanner.vue b/web/frontend/src/components/EscalationBanner.vue index 33f0400..a00e169 100644 --- a/web/frontend/src/components/EscalationBanner.vue +++ b/web/frontend/src/components/EscalationBanner.vue @@ -46,8 +46,10 @@ function loadDismissedWatchdog(): Set { } } +const WATCHDOG_MAX_STORED = 100 + function saveDismissedWatchdog(ids: Set) { - localStorage.setItem(WATCHDOG_TOAST_KEY, JSON.stringify([...ids])) + localStorage.setItem(WATCHDOG_TOAST_KEY, JSON.stringify([...ids].slice(-WATCHDOG_MAX_STORED))) } const dismissedWatchdog = ref>(loadDismissedWatchdog()) @@ -117,6 +119,10 @@ onMounted(async () => { onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) + // KIN-099: clear watchdog toast auto-dismiss timers to prevent memory leaks + for (const toast of watchdogToasts.value) { + if (toast.timerId) clearTimeout(toast.timerId) + } })