kin: auto-commit after pipeline
This commit is contained in:
parent
0ccd451b4b
commit
04cbbc563b
7 changed files with 324 additions and 7 deletions
|
|
@ -52,8 +52,8 @@ You receive:
|
||||||
|
|
||||||
Set `completion_mode` based on the following rules (in priority order):
|
Set `completion_mode` based on the following rules (in priority order):
|
||||||
|
|
||||||
1. If `project.execution_mode` is set — use it as the default.
|
1. If `project.execution_mode` is set — use it. Do NOT override with `route_type`.
|
||||||
2. Override by `route_type`:
|
2. If `project.execution_mode` is NOT set, use `route_type` as heuristic:
|
||||||
- `debug`, `hotfix`, `feature` → `"auto_complete"` (only if the last pipeline step is `tester` or `reviewer`)
|
- `debug`, `hotfix`, `feature` → `"auto_complete"` (only if the last pipeline step is `tester` or `reviewer`)
|
||||||
- `research`, `new_project`, `security_audit` → `"review"`
|
- `research`, `new_project`, `security_audit` → `"review"`
|
||||||
3. Fallback: `"review"`
|
3. Fallback: `"review"`
|
||||||
|
|
|
||||||
|
|
@ -1472,7 +1472,7 @@ def run_pipeline(
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Review mode: wait for manual approval
|
# Review mode: wait for manual approval
|
||||||
models.update_task(conn, task_id, status="review", execution_mode="review")
|
models.update_task(conn, task_id, status="review")
|
||||||
|
|
||||||
# Run post-pipeline hooks (failures don't affect pipeline status)
|
# Run post-pipeline hooks (failures don't affect pipeline status)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -649,10 +649,12 @@ def run_task(ctx, task_id, dry_run, allow_write):
|
||||||
pipeline_steps = output["pipeline"]
|
pipeline_steps = output["pipeline"]
|
||||||
analysis = output.get("analysis", "")
|
analysis = output.get("analysis", "")
|
||||||
|
|
||||||
# Save completion_mode from PM output to task (only if not already set by user)
|
# Save completion_mode from PM output to task (only if neither task nor project has explicit mode)
|
||||||
task_current = models.get_task(conn, task_id)
|
task_current = models.get_task(conn, task_id)
|
||||||
update_fields = {}
|
update_fields = {}
|
||||||
if not task_current.get("execution_mode"):
|
project = models.get_project(conn, project_id)
|
||||||
|
project_mode = project.get("execution_mode") if project else None
|
||||||
|
if not task_current.get("execution_mode") and not project_mode:
|
||||||
pm_completion_mode = models.validate_completion_mode(
|
pm_completion_mode = models.validate_completion_mode(
|
||||||
output.get("completion_mode", "review")
|
output.get("completion_mode", "review")
|
||||||
)
|
)
|
||||||
|
|
|
||||||
179
tests/test_kin_097_regression.py
Normal file
179
tests/test_kin_097_regression.py
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""
|
||||||
|
Regression tests for KIN-097:
|
||||||
|
Tasks should start based on the review/auto toggle state, not independently.
|
||||||
|
|
||||||
|
Root causes fixed:
|
||||||
|
(1) load() now calls loadMode() after reload — toggle syncs with DB
|
||||||
|
(2) runTask() now patches execution_mode before running — task always gets
|
||||||
|
the current toggle state, not a stale value from DB
|
||||||
|
|
||||||
|
Backend regression:
|
||||||
|
- task.execution_mode=auto_complete → pipeline auto-approves (status=done)
|
||||||
|
- task.execution_mode=review → pipeline does NOT auto-approve (status=review),
|
||||||
|
even if project.execution_mode=auto_complete
|
||||||
|
- get_effective_mode uses task-level execution_mode with higher priority than project
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from core.db import init_db
|
||||||
|
from core import models
|
||||||
|
from agents.runner import run_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures & helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn():
|
||||||
|
c = init_db(":memory:")
|
||||||
|
models.create_project(c, "p1", "P1", "/tmp/p1", tech_stack=["python"])
|
||||||
|
models.create_task(c, "P1-001", "p1", "Fix bug",
|
||||||
|
brief={"route_type": "debug"})
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_success(output="done"):
|
||||||
|
m = MagicMock()
|
||||||
|
m.stdout = json.dumps({"result": output})
|
||||||
|
m.stderr = ""
|
||||||
|
m.returncode = 0
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_effective_mode: task-level priority regression
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetEffectiveMode:
|
||||||
|
"""Regression: task.execution_mode has higher priority than project.execution_mode."""
|
||||||
|
|
||||||
|
def test_task_review_overrides_project_auto_complete(self, conn):
|
||||||
|
"""KIN-097: task=review + project=auto_complete → effective mode is 'review'."""
|
||||||
|
models.update_project(conn, "p1", execution_mode="auto_complete")
|
||||||
|
models.update_task(conn, "P1-001", execution_mode="review")
|
||||||
|
mode = models.get_effective_mode(conn, "p1", "P1-001")
|
||||||
|
assert mode == "review", (
|
||||||
|
"task-level review должен override project-level auto_complete"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_task_auto_complete_overrides_project_review(self, conn):
|
||||||
|
"""KIN-097: task=auto_complete + project=review → effective mode is 'auto_complete'."""
|
||||||
|
models.update_project(conn, "p1", execution_mode="review")
|
||||||
|
models.update_task(conn, "P1-001", execution_mode="auto_complete")
|
||||||
|
mode = models.get_effective_mode(conn, "p1", "P1-001")
|
||||||
|
assert mode == "auto_complete", (
|
||||||
|
"task-level auto_complete должен override project-level review"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_task_none_falls_back_to_project_auto_complete(self, conn):
|
||||||
|
"""Если task.execution_mode=None, берётся project.execution_mode=auto_complete."""
|
||||||
|
models.update_project(conn, "p1", execution_mode="auto_complete")
|
||||||
|
# task остаётся без execution_mode
|
||||||
|
mode = models.get_effective_mode(conn, "p1", "P1-001")
|
||||||
|
assert mode == "auto_complete"
|
||||||
|
|
||||||
|
def test_task_none_project_none_defaults_to_review(self, conn):
|
||||||
|
"""Если оба None → fallback 'review' (безопасный режим)."""
|
||||||
|
# Проект без execution_mode (default NULL)
|
||||||
|
mode = models.get_effective_mode(conn, "p1", "P1-001")
|
||||||
|
assert mode == "review"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run_pipeline: autopilot only triggers in auto_complete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRunPipelineCompletionMode:
|
||||||
|
"""KIN-097 acceptance criteria: pipeline outcome depends on execution_mode."""
|
||||||
|
|
||||||
|
@patch("core.followup.generate_followups")
|
||||||
|
@patch("agents.runner.run_hooks")
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_task_review_mode_does_not_auto_approve_when_project_is_auto(
|
||||||
|
self, mock_run, mock_hooks, mock_followup, conn
|
||||||
|
):
|
||||||
|
"""KIN-097 regression: project=auto_complete но task=review → status=review (не done)."""
|
||||||
|
mock_run.return_value = _mock_success()
|
||||||
|
mock_hooks.return_value = []
|
||||||
|
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||||
|
|
||||||
|
models.update_project(conn, "p1", execution_mode="auto_complete")
|
||||||
|
# Frontend патчит task с текущим состоянием тоггла перед run
|
||||||
|
models.update_task(conn, "P1-001", execution_mode="review")
|
||||||
|
|
||||||
|
steps = [{"role": "debugger", "brief": "find bug"},
|
||||||
|
{"role": "tester", "brief": "verify"}]
|
||||||
|
result = run_pipeline(conn, "P1-001", steps)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
task = models.get_task(conn, "P1-001")
|
||||||
|
assert task["status"] == "review", (
|
||||||
|
"При execution_mode=review задача должна ждать ручного approve, "
|
||||||
|
"а НЕ auto-approve несмотря на project.execution_mode=auto_complete"
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("core.followup.generate_followups")
|
||||||
|
@patch("agents.runner.run_hooks")
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_task_auto_complete_auto_approves_when_project_is_review(
|
||||||
|
self, mock_run, mock_hooks, mock_followup, conn
|
||||||
|
):
|
||||||
|
"""KIN-097: project=review но task=auto_complete → status=done (автопилот активен)."""
|
||||||
|
mock_run.return_value = _mock_success()
|
||||||
|
mock_hooks.return_value = []
|
||||||
|
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||||
|
|
||||||
|
# Проект в review-режиме
|
||||||
|
# Frontend патчит task с текущим состоянием тоггла перед run
|
||||||
|
models.update_task(conn, "P1-001", execution_mode="auto_complete")
|
||||||
|
|
||||||
|
steps = [{"role": "debugger", "brief": "find bug"},
|
||||||
|
{"role": "tester", "brief": "verify"}]
|
||||||
|
result = run_pipeline(conn, "P1-001", steps)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
task = models.get_task(conn, "P1-001")
|
||||||
|
assert task["status"] == "done", (
|
||||||
|
"task.execution_mode=auto_complete должен auto-approve (status=done) "
|
||||||
|
"даже если project.execution_mode=review"
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("core.followup.generate_followups")
|
||||||
|
@patch("agents.runner.run_hooks")
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_task_auto_complete_mode_returned_in_result(
|
||||||
|
self, mock_run, mock_hooks, mock_followup, conn
|
||||||
|
):
|
||||||
|
"""run_pipeline включает поле mode=auto_complete в результат."""
|
||||||
|
mock_run.return_value = _mock_success()
|
||||||
|
mock_hooks.return_value = []
|
||||||
|
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||||
|
|
||||||
|
models.update_task(conn, "P1-001", execution_mode="auto_complete")
|
||||||
|
steps = [{"role": "debugger", "brief": "find"},
|
||||||
|
{"role": "tester", "brief": "test"}]
|
||||||
|
result = run_pipeline(conn, "P1-001", steps)
|
||||||
|
|
||||||
|
assert result.get("mode") == "auto_complete"
|
||||||
|
|
||||||
|
@patch("core.followup.generate_followups")
|
||||||
|
@patch("agents.runner.run_hooks")
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_task_review_mode_returned_in_result(
|
||||||
|
self, mock_run, mock_hooks, mock_followup, conn
|
||||||
|
):
|
||||||
|
"""run_pipeline включает поле mode=review в результат при review-задаче."""
|
||||||
|
mock_run.return_value = _mock_success()
|
||||||
|
mock_hooks.return_value = []
|
||||||
|
mock_followup.return_value = {"created": [], "pending_actions": []}
|
||||||
|
|
||||||
|
models.update_task(conn, "P1-001", execution_mode="review")
|
||||||
|
steps = [{"role": "debugger", "brief": "find"}]
|
||||||
|
result = run_pipeline(conn, "P1-001", steps)
|
||||||
|
|
||||||
|
assert result.get("mode") == "review"
|
||||||
|
|
@ -21,6 +21,8 @@ vi.mock('../api', () => ({
|
||||||
taskFull: vi.fn(),
|
taskFull: vi.fn(),
|
||||||
patchTask: vi.fn(),
|
patchTask: vi.fn(),
|
||||||
patchProject: vi.fn(),
|
patchProject: vi.fn(),
|
||||||
|
runTask: vi.fn(),
|
||||||
|
getPhases: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -70,7 +72,12 @@ function makeRouter() {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorageMock.clear()
|
localStorageMock.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
|
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
|
||||||
|
vi.mocked(api.patchTask).mockResolvedValue({ execution_mode: 'review' } as any)
|
||||||
|
vi.mocked(api.patchProject).mockResolvedValue({ execution_mode: 'review' } as any)
|
||||||
|
vi.mocked(api.runTask).mockResolvedValue(undefined as any)
|
||||||
|
vi.mocked(api.getPhases).mockResolvedValue([] as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('KIN-FIX-002: execution_mode унификация на "auto_complete"', () => {
|
describe('KIN-FIX-002: execution_mode унификация на "auto_complete"', () => {
|
||||||
|
|
@ -447,3 +454,124 @@ describe('KIN-077: кнопка Review/Auto — regression (400 Bad Request fix)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('KIN-097: runTask синхронизирует execution_mode с тогглом перед запуском', () => {
|
||||||
|
const TASK_PENDING = {
|
||||||
|
id: 'KIN-001',
|
||||||
|
project_id: 'KIN',
|
||||||
|
title: 'Test Task',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 5,
|
||||||
|
assigned_role: null,
|
||||||
|
parent_task_id: null,
|
||||||
|
brief: null,
|
||||||
|
spec: null,
|
||||||
|
execution_mode: null,
|
||||||
|
blocked_reason: null,
|
||||||
|
category: null,
|
||||||
|
created_at: '2024-01-01',
|
||||||
|
updated_at: '2024-01-01',
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProjectWith(tasks: typeof TASK_PENDING[], execution_mode: string | null = null) {
|
||||||
|
return { ...MOCK_PROJECT, execution_mode, tasks }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('runTask передаёт execution_mode=auto_complete когда тоггл в Auto', async () => {
|
||||||
|
const project = makeProjectWith([TASK_PENDING], 'auto_complete')
|
||||||
|
vi.mocked(api.project).mockResolvedValue(project as any)
|
||||||
|
vi.mocked(api.patchTask).mockResolvedValue({ ...TASK_PENDING, execution_mode: 'auto_complete' } as any)
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const runBtn = wrapper.find('button[title="Run pipeline"]')
|
||||||
|
expect(runBtn.exists(), 'кнопка ▶ должна быть видна для pending задачи').toBe(true)
|
||||||
|
|
||||||
|
await runBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Проверяем что patchTask вызван с execution_mode=auto_complete
|
||||||
|
expect(vi.mocked(api.patchTask)).toHaveBeenCalledWith('KIN-001', {
|
||||||
|
execution_mode: 'auto_complete',
|
||||||
|
})
|
||||||
|
// Проверяем что runTask вызван после patchTask
|
||||||
|
expect(vi.mocked(api.runTask)).toHaveBeenCalledWith('KIN-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runTask передаёт execution_mode=review когда тоггл в Review', async () => {
|
||||||
|
const project = makeProjectWith([TASK_PENDING], 'review')
|
||||||
|
vi.mocked(api.project).mockResolvedValue(project as any)
|
||||||
|
vi.mocked(api.patchTask).mockResolvedValue({ ...TASK_PENDING, execution_mode: 'review' } as any)
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const runBtn = wrapper.find('button[title="Run pipeline"]')
|
||||||
|
expect(runBtn.exists()).toBe(true)
|
||||||
|
|
||||||
|
await runBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(vi.mocked(api.patchTask)).toHaveBeenCalledWith('KIN-001', {
|
||||||
|
execution_mode: 'review',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('autoMode обновляется после load() — синхронизируется с project.execution_mode из DB', async () => {
|
||||||
|
// Первый load возвращает auto_complete
|
||||||
|
vi.mocked(api.project).mockResolvedValue(
|
||||||
|
makeProjectWith([], 'auto_complete') as any
|
||||||
|
)
|
||||||
|
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Тоггл должен показывать Auto
|
||||||
|
const toggleBtn = wrapper.findAll('button').find(b =>
|
||||||
|
b.text().includes('Auto') || b.text().includes('Review')
|
||||||
|
)
|
||||||
|
expect(toggleBtn!.text()).toContain('Auto')
|
||||||
|
|
||||||
|
// DB переключается на review (например, другой клиент изменил режим)
|
||||||
|
vi.mocked(api.project).mockResolvedValue(
|
||||||
|
makeProjectWith([], 'review') as any
|
||||||
|
)
|
||||||
|
|
||||||
|
// После load() тоггл должен обновиться на Review
|
||||||
|
// Имитируем внешний load (например, после создания задачи)
|
||||||
|
vi.mocked(api.patchProject).mockResolvedValue({ execution_mode: 'review' } as any)
|
||||||
|
// Триггерим reload через toggleAutocommit (который вызывает patchProject, но не load)
|
||||||
|
// Вместо этого напрямую проверим что при новом mount с review — кнопка Review
|
||||||
|
const wrapper2 = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const toggleBtn2 = wrapper2.findAll('button').find(b =>
|
||||||
|
b.text().includes('Auto') || b.text().includes('Review')
|
||||||
|
)
|
||||||
|
expect(toggleBtn2!.text()).toContain('Review')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,8 @@ async function load() {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
project.value = await api.project(props.id)
|
project.value = await api.project(props.id)
|
||||||
|
loadMode()
|
||||||
|
loadAutocommit()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -407,8 +409,6 @@ watch(() => props.id, () => {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await load()
|
await load()
|
||||||
loadMode()
|
|
||||||
loadAutocommit()
|
|
||||||
await loadPhases()
|
await loadPhases()
|
||||||
await loadEnvironments()
|
await loadEnvironments()
|
||||||
})
|
})
|
||||||
|
|
@ -531,6 +531,8 @@ async function runTask(taskId: string, event: Event) {
|
||||||
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
||||||
runningTaskId.value = taskId
|
runningTaskId.value = taskId
|
||||||
try {
|
try {
|
||||||
|
// Sync task execution_mode with current project toggle state before running
|
||||||
|
await api.patchTask(taskId, { execution_mode: autoMode.value ? 'auto_complete' : 'review' })
|
||||||
await api.runTask(taskId)
|
await api.runTask(taskId)
|
||||||
await load()
|
await load()
|
||||||
if (activeTab.value === 'kanban') checkAndPollKanban()
|
if (activeTab.value === 'kanban') checkAndPollKanban()
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,12 @@ async function runPipeline() {
|
||||||
claudeLoginError.value = false
|
claudeLoginError.value = false
|
||||||
pipelineStarting.value = true
|
pipelineStarting.value = true
|
||||||
try {
|
try {
|
||||||
|
// Sync task execution_mode with current toggle state before running
|
||||||
|
const targetMode = autoMode.value ? 'auto_complete' : 'review'
|
||||||
|
if (task.value && task.value.execution_mode !== targetMode) {
|
||||||
|
const updated = await api.patchTask(props.id, { execution_mode: targetMode })
|
||||||
|
task.value = { ...task.value, ...updated }
|
||||||
|
}
|
||||||
await api.runTask(props.id)
|
await api.runTask(props.id)
|
||||||
startPolling()
|
startPolling()
|
||||||
await load()
|
await load()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue