kin: auto-commit after pipeline
This commit is contained in:
parent
76a88714e4
commit
4bc421e117
5 changed files with 195 additions and 6 deletions
|
|
@ -71,11 +71,6 @@ def _build_claude_env() -> dict:
|
||||||
socks = glob.glob("/private/tmp/com.apple.launchd.*/Listeners")
|
socks = glob.glob("/private/tmp/com.apple.launchd.*/Listeners")
|
||||||
if socks:
|
if socks:
|
||||||
env["SSH_AUTH_SOCK"] = socks[0]
|
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
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1423,6 +1423,75 @@ class TestClaudePath:
|
||||||
"KIN-057: _resolve_claude_cmd должен возвращать строку даже при пустом os PATH"
|
"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
|
# KIN-063: TestCompletionMode — auto_complete + last-step role check
|
||||||
|
|
|
||||||
|
|
@ -357,3 +357,44 @@ def test_check_dead_pipelines_permission_error_ignored(tmp_path):
|
||||||
|
|
||||||
assert task["status"] != "blocked"
|
assert task["status"] != "blocked"
|
||||||
assert pipeline_row["status"] == "running"
|
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 "")
|
||||||
|
|
|
||||||
|
|
@ -212,3 +212,81 @@ describe('KIN-099: toast auto-dismiss через 8 секунд', () => {
|
||||||
expect(wrapper.find('.border-red-700').exists()).toBe(true)
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,10 @@ function loadDismissedWatchdog(): Set<string> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WATCHDOG_MAX_STORED = 100
|
||||||
|
|
||||||
function saveDismissedWatchdog(ids: Set<string>) {
|
function saveDismissedWatchdog(ids: Set<string>) {
|
||||||
localStorage.setItem(WATCHDOG_TOAST_KEY, JSON.stringify([...ids]))
|
localStorage.setItem(WATCHDOG_TOAST_KEY, JSON.stringify([...ids].slice(-WATCHDOG_MAX_STORED)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const dismissedWatchdog = ref<Set<string>>(loadDismissedWatchdog())
|
const dismissedWatchdog = ref<Set<string>>(loadDismissedWatchdog())
|
||||||
|
|
@ -117,6 +119,10 @@ onMounted(async () => {
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue