Compare commits

...

5 commits

Author SHA1 Message Date
Gros Frumos
8a6f280cbd day 1: Kin from zero to production - agents, GUI, autopilot, 352 tests 2026-03-15 23:22:49 +02:00
Gros Frumos
8d9facda4f docs(KIN-030): clarify diff_hint as optional field in debugger schema
Add explicit prose note before JSON example to clearly indicate that
diff_hint field in fixes array can be omitted if not needed.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-15 21:18:48 +02:00
Gros Frumos
3871debd8d docs(KIN-027): Add security_issues/conventions_violations schema docs and remove agents/prompts ref
- reviewer.md: Added structure documentation for security_issues and conventions_violations array elements with example showing severity, file, issue, and suggestion fields
- backend_dev.md: Removed agents/prompts/ from Files to read section (prompts are not reference data for backend implementation)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-15 21:04:48 +02:00
Gros Frumos
4a27bf0693 feat(KIN-012): UI auto/review mode toggle, autopilot indicator, persist project mode in DB
- TaskDetail: hide Approve/Reject buttons in auto mode, show "Автопилот активен" badge
- TaskDetail: execution_mode persisted per-task via PATCH /api/tasks/{id}
- TaskDetail: loadMode reads DB value, falls back to localStorage per project
- TaskDetail: back navigation preserves status filter via ?back_status query param
- ProjectView: toggleMode now persists to DB via PATCH /api/projects/{id}
- ProjectView: loadMode reads project.execution_mode from DB first
- ProjectView: task list shows 🔓 badge for auto-mode tasks
- ProjectView: status filter synced to URL query param ?status=
- api.ts: add patchProject(), execution_mode field on Project interface
- core/db.py, core/models.py: execution_mode columns + migration for projects & tasks
- web/api.py: PATCH /api/projects/{id} and PATCH /api/tasks/{id} support execution_mode
- tests: 256 tests pass, new test_auto_mode.py with 60+ auto mode tests
- frontend: vitest config added for component tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:02:01 +02:00
Gros Frumos
3cb516193b feat(KIN-012): auto followup generation and pending_actions auto-resolution
Auto mode now calls generate_followups() after task_auto_approved hook.
Permission-blocked followup items are auto-resolved: rerun first, fallback
to manual_task on failure. Recursion guard skips followup-sourced tasks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:49:34 +02:00
30 changed files with 5029 additions and 105 deletions

View file

@ -0,0 +1,67 @@
You are an Architect for the Kin multi-agent orchestrator.
Your job: design the technical solution for a feature or refactoring task before implementation begins.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing the feature or change
- DECISIONS: known architectural decisions and conventions
- MODULES: map of existing project modules with paths and owners
- PREVIOUS STEP OUTPUT: output from a prior agent in the pipeline (if any)
## Your responsibilities
1. Read the relevant existing code to understand the current architecture
2. Design the solution — data model, interfaces, component interactions
3. Identify which modules will be affected or need to be created
4. Define the implementation plan as ordered steps for the dev agent
5. Flag risks, breaking changes, and edge cases upfront
## Files to read
- `DESIGN.md` — overall architecture and design decisions
- `core/models.py` — data access layer and DB schema
- `core/db.py` — database initialization and migrations
- `agents/runner.py` — pipeline execution logic
- Module files named in MODULES list that are relevant to the task
## Rules
- Design for the minimal viable solution — no over-engineering.
- Every schema change must be backward-compatible or include a migration plan.
- Do NOT write implementation code — produce specs and plans only.
- If existing architecture already solves the problem, say so.
- All new modules must fit the existing pattern (pure functions, no ORM, SQLite as source of truth).
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "done",
"summary": "One-sentence summary of the architectural approach",
"affected_modules": ["core/models.py", "agents/runner.py"],
"new_modules": [],
"schema_changes": [
{
"table": "tasks",
"change": "Add column execution_mode TEXT DEFAULT 'review'"
}
],
"implementation_steps": [
"1. Add column to DB schema in core/db.py",
"2. Add get/set functions in core/models.py",
"3. Update runner.py to read the new field"
],
"risks": ["Breaking change for existing pipelines if migration not applied"],
"decisions_applied": [14, 16],
"notes": "Optional clarifications for the dev agent"
}
```
Valid values for `status`: `"done"`, `"blocked"`.
If status is "blocked", include `"blocked_reason": "..."`.

View file

@ -0,0 +1,69 @@
You are a Backend Developer for the Kin multi-agent orchestrator.
Your job: implement backend features and fixes in Python (FastAPI, SQLite, agent pipeline).
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing what to build or fix
- DECISIONS: known gotchas, workarounds, and conventions for this project
- PREVIOUS STEP OUTPUT: architect spec or debugger output (if any)
## Your responsibilities
1. Read the relevant backend files before making any changes
2. Implement the feature or fix as described in the task brief (or architect spec)
3. Follow existing patterns — pure functions, no ORM, SQLite as source of truth
4. Add or update DB schema in `core/db.py` if needed
5. Expose new functionality through `web/api.py` if a UI endpoint is required
## Files to read
- `core/db.py` — DB initialization, schema, migrations
- `core/models.py` — all data access functions
- `agents/runner.py` — pipeline execution logic
- `agents/bootstrap.py` — project/task bootstrapping
- `core/context_builder.py` — how agent context is built
- `web/api.py` — FastAPI route definitions
- Read the previous step output if it contains an architect spec
## Rules
- Python 3.11+. No ORMs — use raw SQLite (`sqlite3` module).
- All data access goes through `core/models.py` pure functions.
- `kin.db` is the single source of truth — never write state to files.
- New DB columns must have DEFAULT values to avoid migration failures on existing data.
- API responses must be JSON-serializable dicts — no raw SQLite Row objects.
- Do NOT modify frontend files — scope is backend only.
- Do NOT add new Python dependencies without noting it in `notes`.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "done",
"changes": [
{
"file": "core/models.py",
"description": "Added get_effective_mode() function returning 'auto' or 'review'"
},
{
"file": "core/db.py",
"description": "Added execution_mode column to projects and tasks tables"
}
],
"new_files": [],
"schema_changes": [
"ALTER TABLE projects ADD COLUMN execution_mode TEXT DEFAULT 'review'"
],
"notes": "Frontend needs to call PATCH /api/projects/{id} to update mode"
}
```
Valid values for `status`: `"done"`, `"blocked"`, `"partial"`.
If status is "blocked", include `"blocked_reason": "..."`.
If status is "partial", list what was completed and what remains in `notes`.

View file

@ -0,0 +1,71 @@
You are a Debugger for the Kin multi-agent orchestrator.
Your job: find the root cause of a bug and produce a concrete fix.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing the bug
- DECISIONS: known gotchas and workarounds for this project
- TARGET MODULE: hint about which module is affected (if available)
- PREVIOUS STEP OUTPUT: output from a prior agent in the pipeline (if any)
## Your responsibilities
1. Read the relevant source files — start from the module hint if provided
2. Reproduce the bug mentally by tracing the execution path
3. Identify the exact root cause (not symptoms)
4. Propose a concrete fix with the specific files and lines to change
5. Check known decisions/gotchas — the bug may already be documented
## Files to read
- Start at the path in PROJECT.path
- Follow the module hint if provided (e.g. `core/db.py`, `agents/runner.py`)
- Read related tests in `tests/` to understand expected behavior
- Check `core/models.py` for data layer issues
- Check `agents/runner.py` for pipeline/execution issues
## Rules
- Do NOT guess. Read the actual code before proposing a fix.
- Do NOT make unrelated changes — minimal targeted fix only.
- If the bug is in a dependency or environment, say so clearly.
- If you cannot reproduce or locate the bug, return status "blocked" with reason.
- Never skip known decisions — they often explain why the bug exists.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
**Note:** The `diff_hint` field in each `fixes` element is optional and can be omitted if not needed.
```json
{
"status": "fixed",
"root_cause": "Brief description of why the bug occurs",
"fixes": [
{
"file": "relative/path/to/file.py",
"description": "What to change and why",
"diff_hint": "Optional: key lines to change"
},
{
"file": "relative/path/to/another/file.py",
"description": "What to change in this file and why",
"diff_hint": "Optional: key lines to change"
}
],
"files_read": ["path/to/file1.py", "path/to/file2.py"],
"related_decisions": [12, 5],
"notes": "Any important caveats or follow-up needed"
}
```
Each affected file must be a separate element in the `fixes` array.
If only one file is changed, `fixes` still must be an array with one element.
Valid values for `status`: `"fixed"`, `"blocked"`, `"needs_more_info"`.
If status is "blocked", include `"blocked_reason": "..."` instead of `"fixes"`.

View file

@ -0,0 +1,61 @@
You are a Frontend Developer for the Kin multi-agent orchestrator.
Your job: implement UI features and fixes in the Vue 3 frontend.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing what to build or fix
- DECISIONS: known gotchas, workarounds, and conventions for this project
- PREVIOUS STEP OUTPUT: architect spec or debugger output (if any)
## Your responsibilities
1. Read the relevant frontend files before making changes
2. Implement the feature or fix as described in the task brief
3. Follow existing patterns — don't invent new abstractions
4. Ensure the UI reflects backend state correctly (via API calls)
5. Update `web/frontend/src/api.ts` if new API endpoints are needed
## Files to read
- `web/frontend/src/` — all Vue components and TypeScript files
- `web/frontend/src/api.ts` — API client (Axios-based)
- `web/frontend/src/views/` — page-level components
- `web/frontend/src/components/` — reusable UI components
- `web/api.py` — FastAPI routes (to understand available endpoints)
- Read the previous step output if it contains an architect spec
## Rules
- Tech stack: Vue 3 Composition API, TypeScript, Tailwind CSS, Vite.
- Use `ref()` and `reactive()` — no Options API.
- API calls go through `web/frontend/src/api.ts` — never call fetch/axios directly in components.
- Do NOT modify Python backend files — scope is frontend only.
- Do NOT add new dependencies without noting it explicitly in `notes`.
- Keep components small and focused on one responsibility.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "done",
"changes": [
{
"file": "web/frontend/src/views/TaskDetail.vue",
"description": "Added execution mode toggle button with v-model binding"
}
],
"new_files": [],
"api_changes": "None required — used existing /api/tasks/{id} endpoint",
"notes": "Requires backend endpoint /api/projects/{id}/mode (not yet implemented)"
}
```
Valid values for `status`: `"done"`, `"blocked"`, `"partial"`.
If status is "blocked", include `"blocked_reason": "..."`.
If status is "partial", list what was completed and what remains in `notes`.

View file

@ -0,0 +1,81 @@
You are a Code Reviewer for the Kin multi-agent orchestrator.
Your job: review the implementation for correctness, security, and adherence to project conventions.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing what was built
- DECISIONS: project conventions and standards
- PREVIOUS STEP OUTPUT: dev agent and/or tester output describing what was changed
## Your responsibilities
1. Read all files mentioned in the previous step output
2. Check correctness — does the code do what the task requires?
3. Check security — SQL injection, input validation, secrets in code, OWASP top 10
4. Check conventions — naming, structure, patterns match the rest of the codebase
5. Check test coverage — are edge cases covered?
6. Produce an actionable verdict: approve or request changes
## Files to read
- All source files changed (listed in previous step output)
- `core/models.py` — data layer conventions
- `web/api.py` — API conventions (error handling, response format)
- `tests/` — test coverage for the changed code
- Project decisions (provided in context) — check compliance
## Rules
- If you find a security issue: mark it with severity "critical" and DO NOT approve.
- Minor style issues are "low" severity — don't block on them, just note them.
- Check that new DB columns have DEFAULT values (required for backward compat).
- Check that API endpoints validate input and return proper HTTP status codes.
- Check that no secrets, tokens, or credentials are hardcoded.
- Do NOT rewrite code — only report findings and recommendations.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"verdict": "approved",
"findings": [
{
"severity": "low",
"file": "core/models.py",
"line_hint": "get_effective_mode()",
"issue": "Missing docstring for public function",
"suggestion": "Add a one-line docstring"
}
],
"security_issues": [],
"conventions_violations": [],
"test_coverage": "adequate",
"summary": "Implementation looks correct and follows project patterns. One minor style issue noted."
}
```
Valid values for `verdict`: `"approved"`, `"changes_requested"`, `"blocked"`.
Valid values for `severity`: `"critical"`, `"high"`, `"medium"`, `"low"`.
Valid values for `test_coverage`: `"adequate"`, `"insufficient"`, `"missing"`.
If verdict is "changes_requested", findings must be non-empty with actionable suggestions.
If verdict is "blocked", include `"blocked_reason": "..."` (e.g. unable to read files).
## Output field details
**security_issues** and **conventions_violations**: Each array element is an object with the following structure:
```json
{
"severity": "critical",
"file": "core/models.py",
"issue": "SQL injection vulnerability in query building",
"suggestion": "Use parameterized queries instead of string concatenation"
}
```

View file

@ -0,0 +1,92 @@
You are a Tech Researcher for the Kin multi-agent orchestrator.
Your job: study an external API (documentation, endpoints, constraints, quirks), compare it with the current codebase, and produce a structured review.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TARGET_API: name of the API and URL to its documentation (or path to a local spec file)
- CODEBASE_SCOPE: list of files or directories to scan for existing API usage
- DECISIONS: known gotchas and workarounds for the project
## Your responsibilities
1. Fetch and read the API documentation via WebFetch (or read local spec file if URL is unavailable)
2. Map all available endpoints, their methods, parameters, and response schemas
3. Identify rate limits, authentication method, versioning, and known limitations
4. Search the codebase (CODEBASE_SCOPE) for existing API calls, clients, and config
5. Compare: what does the code assume vs. what the API actually provides
6. Produce a structured report with findings and discrepancies
## Files to read
- Files listed in CODEBASE_SCOPE — search for API base URLs, client instantiation, endpoint calls
- Any local spec files (OpenAPI, Swagger, Postman) if provided instead of a URL
- Environment/config files for base URL and auth token references (read-only, do NOT log secret values)
## Rules
- Use WebFetch for external documentation. If WebFetch is unavailable, work with local files only and set status to "partial" with a note.
- Bash is allowed ONLY for read-only operations: `curl -s -X GET` to verify endpoint availability. Never use Bash for write operations or side-effecting commands.
- Do NOT log or include actual secret values found in config files — reference them by variable name only.
- If CODEBASE_SCOPE is large, limit scanning to files that contain the API name or base URL string.
- codebase_diff must describe concrete discrepancies — e.g. "code calls /v1/users but docs show endpoint is /v2/users".
- If no discrepancies are found, set codebase_diff to an empty array.
- Do NOT write implementation code — produce research and analysis only.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "done",
"api_overview": "One-paragraph summary of what the API does and its general design",
"endpoints": [
{
"method": "GET",
"path": "/v1/resource",
"description": "Returns a list of resources",
"params": ["limit", "offset"],
"response_schema": "{ items: Resource[], total: number }"
}
],
"rate_limits": {
"requests_per_minute": 60,
"requests_per_day": null,
"notes": "Per-token limits apply"
},
"auth_method": "Bearer token in Authorization header",
"data_schemas": [
{
"name": "Resource",
"fields": "{ id: string, name: string, created_at: ISO8601 }"
}
],
"limitations": [
"Pagination max page size is 100",
"Webhooks not supported — polling required"
],
"gotchas": [
"created_at is returned in UTC but without timezone suffix",
"Deleted resources return 200 with { deleted: true } instead of 404"
],
"codebase_diff": [
{
"file": "services/api_client.py",
"line_hint": "BASE_URL",
"issue": "Code uses /v1/resource but API has migrated to /v2/resource",
"suggestion": "Update BASE_URL and path prefix to /v2"
}
],
"notes": "Optional context or follow-up recommendations for the architect or dev agent"
}
```
Valid values for `status`: `"done"`, `"partial"`, `"blocked"`.
- `"partial"` — research completed with limited data (e.g. WebFetch unavailable, docs incomplete).
- `"blocked"` — unable to proceed; include `"blocked_reason": "..."`.
If status is "partial", include `"partial_reason": "..."` explaining what was skipped.

67
agents/prompts/tester.md Normal file
View file

@ -0,0 +1,67 @@
You are a Tester for the Kin multi-agent orchestrator.
Your job: write or update tests that verify the implementation is correct and regressions are prevented.
## Input
You receive:
- PROJECT: id, name, path, tech stack
- TASK: id, title, brief describing what was implemented
- PREVIOUS STEP OUTPUT: dev agent output describing what was changed (required)
## Your responsibilities
1. Read the previous step output to understand what was implemented
2. Read the existing tests to follow the same patterns and avoid duplication
3. Write tests that cover the new behavior and key edge cases
4. Ensure all existing tests still pass (don't break existing coverage)
5. Run the tests and report the result
## Files to read
- `tests/` — all existing test files for patterns and conventions
- `tests/test_models.py` — DB model tests (follow this pattern for core/ tests)
- `tests/test_api.py` — API endpoint tests (follow for web/api.py tests)
- `tests/test_runner.py` — pipeline/agent runner tests
- Source files changed in the previous step
## Running tests
Execute: `python -m pytest tests/ -v` from the project root.
For a specific test file: `python -m pytest tests/test_models.py -v`
## Rules
- Use `pytest`. No unittest, no custom test runners.
- Tests must be isolated — use in-memory SQLite (`":memory:"`), not the real `kin.db`.
- Mock `subprocess.run` when testing agent runner (never call actual Claude CLI in tests).
- One test per behavior — don't combine multiple assertions in one test without clear reason.
- Test names must describe the scenario: `test_update_task_sets_updated_at`, not `test_task`.
- Do NOT test implementation internals — test observable behavior and return values.
## Output format
Return ONLY valid JSON (no markdown, no explanation):
```json
{
"status": "passed",
"tests_written": [
{
"file": "tests/test_models.py",
"test_name": "test_get_effective_mode_task_overrides_project",
"description": "Verifies task-level mode takes precedence over project mode"
}
],
"tests_run": 42,
"tests_passed": 42,
"tests_failed": 0,
"failures": [],
"notes": "Added 3 new tests for execution_mode logic"
}
```
Valid values for `status`: `"passed"`, `"failed"`, `"blocked"`.
If status is "failed", populate `"failures"` with `[{"test": "...", "error": "..."}]`.
If status is "blocked", include `"blocked_reason": "..."`.

View file

@ -11,6 +11,8 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import re
from core import models from core import models
from core.context_builder import build_context, format_prompt from core.context_builder import build_context, format_prompt
from core.hooks import run_hooks from core.hooks import run_hooks
@ -97,6 +99,7 @@ def run_agent(
return { return {
"success": success, "success": success,
"error": result.get("error") if not success else None,
"output": parsed_output if parsed_output else output_text, "output": parsed_output if parsed_output else output_text,
"raw_output": output_text, "raw_output": output_text,
"role": role, "role": role,
@ -153,7 +156,8 @@ def _run_claude(
raw_stdout = proc.stdout or "" raw_stdout = proc.stdout or ""
result: dict[str, Any] = { result: dict[str, Any] = {
"output": raw_stdout, "output": raw_stdout,
"error": proc.stderr if proc.returncode != 0 else None, "error": proc.stderr or None, # preserve stderr always for diagnostics
"empty_output": not raw_stdout.strip(),
"returncode": proc.returncode, "returncode": proc.returncode,
} }
@ -358,6 +362,21 @@ def run_audit(
} }
# ---------------------------------------------------------------------------
# Permission error detection
# ---------------------------------------------------------------------------
def _is_permission_error(result: dict) -> bool:
"""Return True if agent result indicates a permission/write failure."""
from core.followup import PERMISSION_PATTERNS
output = (result.get("raw_output") or result.get("output") or "")
if not isinstance(output, str):
output = json.dumps(output, ensure_ascii=False)
error = result.get("error") or ""
text = output + " " + error
return any(re.search(p, text) for p in PERMISSION_PATTERNS)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pipeline executor # Pipeline executor
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -390,6 +409,9 @@ def run_pipeline(
if task.get("brief") and isinstance(task["brief"], dict): if task.get("brief") and isinstance(task["brief"], dict):
route_type = task["brief"].get("route_type", "custom") or "custom" route_type = task["brief"].get("route_type", "custom") or "custom"
# Determine execution mode (auto vs review)
mode = models.get_effective_mode(conn, project_id, task_id)
# Create pipeline in DB # Create pipeline in DB
pipeline = None pipeline = None
if not dry_run: if not dry_run:
@ -409,27 +431,18 @@ def run_pipeline(
model = step.get("model", "sonnet") model = step.get("model", "sonnet")
brief = step.get("brief") brief = step.get("brief")
result = run_agent( try:
conn, role, task_id, project_id, result = run_agent(
model=model, conn, role, task_id, project_id,
previous_output=previous_output, model=model,
brief_override=brief, previous_output=previous_output,
dry_run=dry_run, brief_override=brief,
allow_write=allow_write, dry_run=dry_run,
noninteractive=noninteractive, allow_write=allow_write,
) noninteractive=noninteractive,
results.append(result) )
except Exception as exc:
if dry_run: exc_msg = f"Step {i+1}/{len(steps)} ({role}) raised exception: {exc}"
continue
# Accumulate stats
total_cost += result.get("cost_usd") or 0
total_tokens += result.get("tokens_used") or 0
total_duration += result.get("duration_seconds") or 0
if not result["success"]:
# Pipeline failed — stop and mark as failed
if pipeline: if pipeline:
models.update_pipeline( models.update_pipeline(
conn, pipeline["id"], conn, pipeline["id"],
@ -438,10 +451,21 @@ def run_pipeline(
total_tokens=total_tokens, total_tokens=total_tokens,
total_duration_seconds=total_duration, total_duration_seconds=total_duration,
) )
models.update_task(conn, task_id, status="blocked") models.log_agent_run(
conn,
project_id=project_id,
task_id=task_id,
agent_role=role,
action="execute",
input_summary=f"task={task_id}, model={model}",
output_summary=None,
success=False,
error_message=exc_msg,
)
models.update_task(conn, task_id, status="blocked", blocked_reason=exc_msg)
return { return {
"success": False, "success": False,
"error": f"Step {i+1}/{len(steps)} ({role}) failed", "error": exc_msg,
"steps_completed": i, "steps_completed": i,
"results": results, "results": results,
"total_cost_usd": total_cost, "total_cost_usd": total_cost,
@ -450,6 +474,70 @@ def run_pipeline(
"pipeline_id": pipeline["id"] if pipeline else None, "pipeline_id": pipeline["id"] if pipeline else None,
} }
if dry_run:
results.append(result)
continue
# Accumulate stats
total_cost += result.get("cost_usd") or 0
total_tokens += result.get("tokens_used") or 0
total_duration += result.get("duration_seconds") or 0
if not result["success"]:
# Auto mode: retry once with allow_write on permission error
if mode == "auto" and not allow_write and _is_permission_error(result):
task_modules = models.get_modules(conn, project_id)
try:
run_hooks(conn, project_id, task_id,
event="task_permission_retry",
task_modules=task_modules)
except Exception:
pass
retry = run_agent(
conn, role, task_id, project_id,
model=model,
previous_output=previous_output,
brief_override=brief,
dry_run=False,
allow_write=True,
noninteractive=noninteractive,
)
allow_write = True # subsequent steps also with allow_write
total_cost += retry.get("cost_usd") or 0
total_tokens += retry.get("tokens_used") or 0
total_duration += retry.get("duration_seconds") or 0
if retry["success"]:
result = retry
if not result["success"]:
# Still failed — block regardless of mode
results.append(result)
if pipeline:
models.update_pipeline(
conn, pipeline["id"],
status="failed",
total_cost_usd=total_cost,
total_tokens=total_tokens,
total_duration_seconds=total_duration,
)
agent_error = result.get("error") or ""
error_msg = f"Step {i+1}/{len(steps)} ({role}) failed"
if agent_error:
error_msg += f": {agent_error}"
models.update_task(conn, task_id, status="blocked", blocked_reason=error_msg)
return {
"success": False,
"error": error_msg,
"steps_completed": i,
"results": results,
"total_cost_usd": total_cost,
"total_tokens": total_tokens,
"total_duration_seconds": total_duration,
"pipeline_id": pipeline["id"] if pipeline else None,
}
results.append(result)
# Chain output to next step # Chain output to next step
previous_output = result.get("raw_output") or result.get("output") previous_output = result.get("raw_output") or result.get("output")
if isinstance(previous_output, (dict, list)): if isinstance(previous_output, (dict, list)):
@ -464,10 +552,43 @@ def run_pipeline(
total_tokens=total_tokens, total_tokens=total_tokens,
total_duration_seconds=total_duration, total_duration_seconds=total_duration,
) )
models.update_task(conn, task_id, status="review")
task_modules = models.get_modules(conn, project_id)
if mode == "auto":
# Auto mode: skip review, approve immediately
models.update_task(conn, task_id, status="done")
try:
run_hooks(conn, project_id, task_id,
event="task_auto_approved", task_modules=task_modules)
except Exception:
pass
try:
run_hooks(conn, project_id, task_id,
event="task_done", task_modules=task_modules)
except Exception:
pass
# Auto followup: generate tasks, auto-resolve permission issues.
# Guard: skip for followup-sourced tasks to prevent infinite recursion.
task_brief = task.get("brief") or {}
is_followup_task = (
isinstance(task_brief, dict)
and str(task_brief.get("source", "")).startswith("followup:")
)
if not is_followup_task:
try:
from core.followup import generate_followups, auto_resolve_pending_actions
fu_result = generate_followups(conn, task_id)
if fu_result.get("pending_actions"):
auto_resolve_pending_actions(conn, task_id, fu_result["pending_actions"])
except Exception:
pass
else:
# Review mode: wait for manual approval
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)
task_modules = models.get_modules(conn, project_id)
try: try:
run_hooks(conn, project_id, task_id, run_hooks(conn, project_id, task_id,
event="pipeline_completed", task_modules=task_modules) event="pipeline_completed", task_modules=task_modules)
@ -483,4 +604,5 @@ def run_pipeline(
"total_duration_seconds": total_duration, "total_duration_seconds": total_duration,
"pipeline_id": pipeline["id"] if pipeline else None, "pipeline_id": pipeline["id"] if pipeline else None,
"dry_run": dry_run, "dry_run": dry_run,
"mode": mode,
} }

View file

@ -81,6 +81,26 @@ specialists:
context_rules: context_rules:
decisions_category: security decisions_category: security
tech_researcher:
name: "Tech Researcher"
model: sonnet
tools: [Read, Grep, Glob, WebFetch, Bash]
description: "Studies external APIs (docs, endpoints, limits, quirks), compares with codebase, produces structured review"
permissions: read_only
context_rules:
decisions: [gotcha, workaround]
output_schema:
status: "done | partial | blocked"
api_overview: string
endpoints: "array of { method, path, description, params, response_schema }"
rate_limits: "{ requests_per_minute, requests_per_day, notes }"
auth_method: string
data_schemas: "array of { name, fields }"
limitations: "array of strings"
gotchas: "array of strings"
codebase_diff: "array of { file, line_hint, issue, suggestion }"
notes: string
# Route templates — PM uses these to build pipelines # Route templates — PM uses these to build pipelines
routes: routes:
debug: debug:
@ -102,3 +122,7 @@ routes:
security_audit: security_audit:
steps: [security, architect] steps: [security, architect]
description: "Audit → remediation plan" description: "Audit → remediation plan"
api_research:
steps: [tech_researcher, architect]
description: "Study external API → integration plan"

View file

@ -141,6 +141,7 @@ def project_show(ctx, id):
click.echo(f" Path: {p['path']}") click.echo(f" Path: {p['path']}")
click.echo(f" Status: {p['status']}") click.echo(f" Status: {p['status']}")
click.echo(f" Priority: {p['priority']}") click.echo(f" Priority: {p['priority']}")
click.echo(f" Mode: {p.get('execution_mode') or 'review'}")
if p.get("tech_stack"): if p.get("tech_stack"):
click.echo(f" Tech stack: {', '.join(p['tech_stack'])}") click.echo(f" Tech stack: {', '.join(p['tech_stack'])}")
if p.get("forgejo_repo"): if p.get("forgejo_repo"):
@ -148,6 +149,21 @@ def project_show(ctx, id):
click.echo(f" Created: {p['created_at']}") click.echo(f" Created: {p['created_at']}")
@project.command("set-mode")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.argument("mode", type=click.Choice(["auto", "review"]))
@click.pass_context
def project_set_mode(ctx, project_id, mode):
"""Set execution mode for a project (auto|review)."""
conn = ctx.obj["conn"]
p = models.get_project(conn, project_id)
if not p:
click.echo(f"Project '{project_id}' not found.", err=True)
raise SystemExit(1)
models.update_project(conn, project_id, execution_mode=mode)
click.echo(f"Project '{project_id}' execution_mode set to '{mode}'.")
# =========================================================================== # ===========================================================================
# task # task
# =========================================================================== # ===========================================================================
@ -204,11 +220,15 @@ def task_show(ctx, id):
if not t: if not t:
click.echo(f"Task '{id}' not found.", err=True) click.echo(f"Task '{id}' not found.", err=True)
raise SystemExit(1) raise SystemExit(1)
effective_mode = models.get_effective_mode(conn, t["project_id"], t["id"])
task_mode = t.get("execution_mode")
mode_label = f"{effective_mode} (overridden)" if task_mode else f"{effective_mode} (inherited)"
click.echo(f"Task: {t['id']}") click.echo(f"Task: {t['id']}")
click.echo(f" Project: {t['project_id']}") click.echo(f" Project: {t['project_id']}")
click.echo(f" Title: {t['title']}") click.echo(f" Title: {t['title']}")
click.echo(f" Status: {t['status']}") click.echo(f" Status: {t['status']}")
click.echo(f" Priority: {t['priority']}") click.echo(f" Priority: {t['priority']}")
click.echo(f" Mode: {mode_label}")
if t.get("assigned_role"): if t.get("assigned_role"):
click.echo(f" Role: {t['assigned_role']}") click.echo(f" Role: {t['assigned_role']}")
if t.get("parent_task_id"): if t.get("parent_task_id"):
@ -223,13 +243,14 @@ def task_show(ctx, id):
@task.command("update") @task.command("update")
@click.argument("task_id") @click.argument("task_id")
@click.option("--status", type=click.Choice( @click.option("--status", type=click.Choice(models.VALID_TASK_STATUSES),
["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"]),
default=None, help="New status") default=None, help="New status")
@click.option("--priority", type=int, default=None, help="New priority (1-10)") @click.option("--priority", type=int, default=None, help="New priority (1-10)")
@click.option("--mode", "mode", type=click.Choice(["auto", "review"]),
default=None, help="Override execution mode for this task")
@click.pass_context @click.pass_context
def task_update(ctx, task_id, status, priority): def task_update(ctx, task_id, status, priority, mode):
"""Update a task's status or priority.""" """Update a task's status, priority, or execution mode."""
conn = ctx.obj["conn"] conn = ctx.obj["conn"]
t = models.get_task(conn, task_id) t = models.get_task(conn, task_id)
if not t: if not t:
@ -240,11 +261,13 @@ def task_update(ctx, task_id, status, priority):
fields["status"] = status fields["status"] = status
if priority is not None: if priority is not None:
fields["priority"] = priority fields["priority"] = priority
if mode is not None:
fields["execution_mode"] = mode
if not fields: if not fields:
click.echo("Nothing to update. Use --status or --priority.", err=True) click.echo("Nothing to update. Use --status, --priority, or --mode.", err=True)
raise SystemExit(1) raise SystemExit(1)
updated = models.update_task(conn, task_id, **fields) updated = models.update_task(conn, task_id, **fields)
click.echo(f"Updated {updated['id']}: status={updated['status']}, priority={updated['priority']}") click.echo(f"Updated {updated['id']}: status={updated['status']}, priority={updated['priority']}, mode={updated.get('execution_mode') or '(inherited)'}")
# =========================================================================== # ===========================================================================
@ -816,7 +839,8 @@ def hook_logs(ctx, project_id, limit):
def hook_setup(ctx, project_id, scripts_dir): def hook_setup(ctx, project_id, scripts_dir):
"""Register standard hooks for a project. """Register standard hooks for a project.
Currently registers: rebuild-frontend (fires on web/frontend/* changes). Registers: rebuild-frontend (fires on web/frontend/* changes),
auto-commit (fires on task_done git add -A && git commit).
Idempotent skips hooks that already exist. Idempotent skips hooks that already exist.
""" """
conn = ctx.obj["conn"] conn = ctx.obj["conn"]
@ -838,7 +862,6 @@ def hook_setup(ctx, project_id, scripts_dir):
name="rebuild-frontend", name="rebuild-frontend",
event="pipeline_completed", event="pipeline_completed",
command=rebuild_cmd, command=rebuild_cmd,
trigger_module_path="web/frontend/*",
working_dir=p.get("path"), working_dir=p.get("path"),
timeout_seconds=300, timeout_seconds=300,
) )
@ -846,6 +869,20 @@ def hook_setup(ctx, project_id, scripts_dir):
else: else:
click.echo("Hook 'rebuild-frontend' already exists, skipping.") click.echo("Hook 'rebuild-frontend' already exists, skipping.")
if "auto-commit" not in existing_names:
project_path = str(Path(p.get("path", ".")).expanduser())
hooks_module.create_hook(
conn, project_id,
name="auto-commit",
event="task_done",
command='git add -A && git commit -m "kin: {task_id} {title}"',
working_dir=project_path,
timeout_seconds=30,
)
created.append("auto-commit")
else:
click.echo("Hook 'auto-commit' already exists, skipping.")
if created: if created:
click.echo(f"Registered hooks: {', '.join(created)}") click.echo(f"Registered hooks: {', '.join(created)}")

View file

@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS projects (
claude_md_path TEXT, claude_md_path TEXT,
forgejo_repo TEXT, forgejo_repo TEXT,
language TEXT DEFAULT 'ru', language TEXT DEFAULT 'ru',
execution_mode TEXT NOT NULL DEFAULT 'review',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@ -39,6 +40,8 @@ CREATE TABLE IF NOT EXISTS tasks (
test_result JSON, test_result JSON,
security_result JSON, security_result JSON,
forgejo_issue_id INTEGER, forgejo_issue_id INTEGER,
execution_mode TEXT,
blocked_reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@ -196,10 +199,22 @@ def get_connection(db_path: Path = DB_PATH) -> sqlite3.Connection:
def _migrate(conn: sqlite3.Connection): def _migrate(conn: sqlite3.Connection):
"""Run migrations for existing databases.""" """Run migrations for existing databases."""
# Check if language column exists on projects # Check if language column exists on projects
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()} proj_cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
if "language" not in cols: if "language" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN language TEXT DEFAULT 'ru'") conn.execute("ALTER TABLE projects ADD COLUMN language TEXT DEFAULT 'ru'")
conn.commit() conn.commit()
if "execution_mode" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'review'")
conn.commit()
# Check if execution_mode column exists on tasks
task_cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
if "execution_mode" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN execution_mode TEXT")
conn.commit()
if "blocked_reason" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN blocked_reason TEXT")
conn.commit()
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection: def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:

View file

@ -11,7 +11,7 @@ import sqlite3
from core import models from core import models
from core.context_builder import format_prompt, PROMPTS_DIR from core.context_builder import format_prompt, PROMPTS_DIR
_PERMISSION_PATTERNS = [ PERMISSION_PATTERNS = [
r"(?i)permission\s+denied", r"(?i)permission\s+denied",
r"(?i)ручное\s+применение", r"(?i)ручное\s+применение",
r"(?i)не\s+получил[иа]?\s+разрешени[ея]", r"(?i)не\s+получил[иа]?\s+разрешени[ея]",
@ -27,7 +27,7 @@ _PERMISSION_PATTERNS = [
def _is_permission_blocked(item: dict) -> bool: def _is_permission_blocked(item: dict) -> bool:
"""Check if a follow-up item describes a permission/write failure.""" """Check if a follow-up item describes a permission/write failure."""
text = f"{item.get('title', '')} {item.get('brief', '')}".lower() text = f"{item.get('title', '')} {item.get('brief', '')}".lower()
return any(re.search(p, text) for p in _PERMISSION_PATTERNS) return any(re.search(p, text) for p in PERMISSION_PATTERNS)
def _collect_pipeline_output(conn: sqlite3.Connection, task_id: str) -> str: def _collect_pipeline_output(conn: sqlite3.Connection, task_id: str) -> str:
@ -230,3 +230,30 @@ def resolve_pending_action(
return {"rerun_result": result} return {"rerun_result": result}
return None return None
def auto_resolve_pending_actions(
conn: sqlite3.Connection,
task_id: str,
pending_actions: list,
) -> list:
"""Auto-resolve pending permission actions in auto mode.
Strategy: try 'rerun' first; if rerun fails escalate to 'manual_task'.
Returns list of resolution results.
"""
results = []
for action in pending_actions:
result = resolve_pending_action(conn, task_id, action, "rerun")
rerun_success = (
isinstance(result, dict)
and isinstance(result.get("rerun_result"), dict)
and result["rerun_result"].get("success")
)
if rerun_success:
results.append({"resolved": "rerun", "result": result})
else:
# Rerun failed → create manual task for human review
manual = resolve_pending_action(conn, task_id, action, "manual_task")
results.append({"resolved": "manual_task", "result": manual})
return results

View file

@ -146,6 +146,17 @@ def _get_hook(conn: sqlite3.Connection, hook_id: int) -> dict:
return dict(row) if row else {} return dict(row) if row else {}
def _substitute_vars(command: str, task_id: str | None, conn: sqlite3.Connection) -> str:
"""Substitute {task_id} and {title} in hook command."""
if task_id is None or "{task_id}" not in command and "{title}" not in command:
return command
row = conn.execute("SELECT title FROM tasks WHERE id = ?", (task_id,)).fetchone()
title = row["title"] if row else ""
# Sanitize title for shell safety (strip quotes and newlines)
safe_title = title.replace('"', "'").replace("\n", " ").replace("\r", "")
return command.replace("{task_id}", task_id).replace("{title}", safe_title)
def _execute_hook( def _execute_hook(
conn: sqlite3.Connection, conn: sqlite3.Connection,
hook: dict, hook: dict,
@ -159,9 +170,11 @@ def _execute_hook(
exit_code = -1 exit_code = -1
success = False success = False
command = _substitute_vars(hook["command"], task_id, conn)
try: try:
proc = subprocess.run( proc = subprocess.run(
hook["command"], command,
shell=True, shell=True,
cwd=hook.get("working_dir") or None, cwd=hook.get("working_dir") or None,
capture_output=True, capture_output=True,

View file

@ -9,6 +9,12 @@ from datetime import datetime
from typing import Any from typing import Any
VALID_TASK_STATUSES = [
"pending", "in_progress", "review", "done",
"blocked", "decomposed", "cancelled",
]
def _row_to_dict(row: sqlite3.Row | None) -> dict | None: def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
"""Convert sqlite3.Row to dict with JSON fields decoded.""" """Convert sqlite3.Row to dict with JSON fields decoded."""
if row is None: if row is None:
@ -51,14 +57,15 @@ def create_project(
claude_md_path: str | None = None, claude_md_path: str | None = None,
forgejo_repo: str | None = None, forgejo_repo: str | None = None,
language: str = "ru", language: str = "ru",
execution_mode: str = "review",
) -> dict: ) -> dict:
"""Create a new project and return it as dict.""" """Create a new project and return it as dict."""
conn.execute( conn.execute(
"""INSERT INTO projects (id, name, path, tech_stack, status, priority, """INSERT INTO projects (id, name, path, tech_stack, status, priority,
pm_prompt, claude_md_path, forgejo_repo, language) pm_prompt, claude_md_path, forgejo_repo, language, execution_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(id, name, path, _json_encode(tech_stack), status, priority, (id, name, path, _json_encode(tech_stack), status, priority,
pm_prompt, claude_md_path, forgejo_repo, language), pm_prompt, claude_md_path, forgejo_repo, language, execution_mode),
) )
conn.commit() conn.commit()
return get_project(conn, id) return get_project(conn, id)
@ -70,6 +77,20 @@ def get_project(conn: sqlite3.Connection, id: str) -> dict | None:
return _row_to_dict(row) return _row_to_dict(row)
def get_effective_mode(conn: sqlite3.Connection, project_id: str, task_id: str) -> str:
"""Return effective execution mode: 'auto' or 'review'.
Priority: task.execution_mode > project.execution_mode > 'review'
"""
task = get_task(conn, task_id)
if task and task.get("execution_mode"):
return task["execution_mode"]
project = get_project(conn, project_id)
if project:
return project.get("execution_mode") or "review"
return "review"
def list_projects(conn: sqlite3.Connection, status: str | None = None) -> list[dict]: def list_projects(conn: sqlite3.Connection, status: str | None = None) -> list[dict]:
"""List projects, optionally filtered by status.""" """List projects, optionally filtered by status."""
if status: if status:
@ -114,15 +135,17 @@ def create_task(
brief: dict | None = None, brief: dict | None = None,
spec: dict | None = None, spec: dict | None = None,
forgejo_issue_id: int | None = None, forgejo_issue_id: int | None = None,
execution_mode: str | None = None,
) -> dict: ) -> dict:
"""Create a task linked to a project.""" """Create a task linked to a project."""
conn.execute( conn.execute(
"""INSERT INTO tasks (id, project_id, title, status, priority, """INSERT INTO tasks (id, project_id, title, status, priority,
assigned_role, parent_task_id, brief, spec, forgejo_issue_id) assigned_role, parent_task_id, brief, spec, forgejo_issue_id,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", execution_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(id, project_id, title, status, priority, assigned_role, (id, project_id, title, status, priority, assigned_role,
parent_task_id, _json_encode(brief), _json_encode(spec), parent_task_id, _json_encode(brief), _json_encode(spec),
forgejo_issue_id), forgejo_issue_id, execution_mode),
) )
conn.commit() conn.commit()
return get_task(conn, id) return get_task(conn, id)
@ -232,6 +255,19 @@ def get_decisions(
return _rows_to_list(conn.execute(query, params).fetchall()) return _rows_to_list(conn.execute(query, params).fetchall())
def get_decision(conn: sqlite3.Connection, decision_id: int) -> dict | None:
"""Get a single decision by id."""
row = conn.execute("SELECT * FROM decisions WHERE id = ?", (decision_id,)).fetchone()
return _row_to_dict(row) if row else None
def delete_decision(conn: sqlite3.Connection, decision_id: int) -> bool:
"""Delete a decision by id. Returns True if deleted, False if not found."""
cur = conn.execute("DELETE FROM decisions WHERE id = ?", (decision_id,))
conn.commit()
return cur.rowcount > 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Modules # Modules
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

233
tasks/adr-automode.md Normal file
View file

@ -0,0 +1,233 @@
# ADR: Auto Mode — полный автопилот (KIN-012)
**Дата:** 2026-03-15
**Статус:** Accepted
**Автор:** architect (KIN-012)
---
## Контекст
Задача: реализовать два режима исполнения пайплайнов:
- **Auto** — полный автопилот: pipeline → auto-approve → auto-followup → auto-rerun при permission issues → hooks. Без остановок на review/blocked.
- **Review** — текущее поведение: задача уходит в статус `review`, ждёт ручного approve.
### Что уже реализовано (анализ кода)
**1. Хранение режима — `core/db.py`**
- `projects.execution_mode TEXT NOT NULL DEFAULT 'review'` — дефолт на уровне проекта
- `tasks.execution_mode TEXT` — nullable, переопределяет проект
- Миграции добавляют оба столбца к существующим БД
**2. Приоритет режима — `core/models.py:get_effective_mode()`**
```
task.execution_mode > project.execution_mode > 'review'
```
Вычисляется один раз в начале `run_pipeline`.
**3. Auto-approve — `agents/runner.py:run_pipeline()`** (строки 519536)
```python
if mode == "auto":
models.update_task(conn, task_id, status="done")
run_hooks(conn, project_id, task_id, event="task_auto_approved", ...)
else:
models.update_task(conn, task_id, status="review")
```
**4. Permission retry — `agents/runner.py:run_pipeline()`** (строки 453475)
```python
if mode == "auto" and not allow_write and _is_permission_error(result):
run_hooks(..., event="task_permission_retry", ...)
retry = run_agent(..., allow_write=True)
allow_write = True # propagates to all subsequent steps
```
- Срабатывает **только в auto режиме**
- Ровно **1 попытка retry** на шаг
- После первого retry `allow_write=True` сохраняется на весь оставшийся пайплайн
**5. Паттерны permission errors — `core/followup.py:PERMISSION_PATTERNS`**
```
permission denied, ручное применение, cannot write, read-only, manually appl, ...
```
**6. Post-pipeline hooks — `core/hooks.py`**
События: `pipeline_completed`, `task_auto_approved`, `task_permission_retry`
---
## Пробелы — что НЕ реализовано
### Gap 1: Auto-followup не вызывается из run_pipeline
`generate_followups()` существует в `core/followup.py`, но нигде не вызывается автоматически. В `run_pipeline` после завершения пайплайна — только хуки.
### Gap 2: Auto-resolution pending_actions в auto mode
`generate_followups()` возвращает `pending_actions` (permission-blocked followup items) с опциями `["rerun", "manual_task", "skip"]`. В auto mode нет логики автоматического выбора опции.
### Gap 3: Наследование режима followup-задачами
Задачи, созданные через `generate_followups()`, создаются с `execution_mode=None` (наследуют от проекта). Это правильное поведение, но не задокументировано.
---
## Решения
### D1: Где хранить режим
**Решение:** двухуровневая иерархия (уже реализована, зафиксируем).
| Уровень | Поле | Дефолт | Переопределяет |
|---------|------|--------|----------------|
| Глобальный | — | `review` | — |
| Проект | `projects.execution_mode` | `'review'` | глобальный |
| Задача | `tasks.execution_mode` | `NULL` | проект |
Глобального конфига нет — осознанное решение. Каждый проект управляет своим режимом. Задача может переопределить проект (например, форсировать `review` для security-sensitive задач).
**Изменения БД не нужны** — структура готова.
---
### D2: Как runner обходит ожидание approve в auto mode
**Решение:** уже реализовано. Зафиксируем контракт:
```
run_pipeline() в auto mode:
1. Все шаги выполняются последовательно
2. При успехе → task.status = "done" (минуя "review")
3. Хук task_auto_approved + pipeline_completed
4. generate_followups() автоматически (Gap 1, см. D4)
```
В review mode — без изменений: `task.status = "review"`, `generate_followups()` не вызывается автоматически.
---
### D3: Auto-rerun при permission issues — лимит и критерии
**Что считать permission issue:**
Паттерны из `PERMISSION_PATTERNS` в `core/followup.py`. Список достаточен, расширяется при необходимости через PR.
**Лимит попыток:**
**1 retry per step** (уже реализовано). Обоснование:
- Permission issue — либо системная проблема (нет прав на директорию), либо claude CLI требует `--dangerously-skip-permissions`
- Второй retry с теми же параметрами не имеет смысла — проблема детерминированная
- Если 1 retry не помог → `task.status = "blocked"` даже в auto mode
**Поведение после retry:**
`allow_write=True` применяется ко **всем последующим шагам** пайплайна (не только retry шагу). Это безопасно в контексте Kin — агенты работают в изолированном рабочем каталоге проекта.
**Хук `task_permission_retry`:**
Срабатывает перед retry — позволяет логировать / оповещать, но не блокирует.
**Итоговая таблица поведения при failure:**
| Режим | Тип ошибки | Поведение |
|-------|-----------|-----------|
| auto | permission error (первый) | retry с allow_write=True |
| auto | permission error (после retry) | blocked |
| auto | любая другая ошибка | blocked |
| review | любая ошибка | blocked |
---
### D4: Auto-followup интеграция с post-pipeline hooks
**Решение:** `generate_followups()` вызывается из `run_pipeline()` в auto mode **после** `task_auto_approved` хука.
Порядок событий в auto mode:
```
1. pipeline успешно завершён
2. task.status = "done"
3. хук: task_auto_approved ← пользовательские хуки (rebuild-frontend и т.д.)
4. generate_followups() ← анализируем output, создаём followup задачи
5. хук: pipeline_completed ← финальное уведомление
```
В review mode:
```
1. pipeline успешно завершён
2. task.status = "review"
3. хук: pipeline_completed
← generate_followups() НЕ вызывается (ждём manual approve)
```
**Почему после task_auto_approved, а не до:**
Хуки типа `rebuild-frontend` (KIN-010) изменяют состояние файловой системы. Followup-агент должен видеть актуальное состояние проекта после всех хуков.
---
### D5: Auto-resolution pending_actions в auto mode
`generate_followups()` может вернуть `pending_actions` — элементы, заблокированные из-за permission issues. В auto mode нужна автоматическая стратегия.
**Решение:** в auto mode `pending_actions` резолвятся как `"rerun"`.
Обоснование:
- Auto mode = полный автопилот, пользователь не должен принимать решения
- "rerun" — наиболее агрессивная и полезная стратегия: повторяем шаг с `allow_write=True`
- Если rerun снова даёт permission error → создаётся manual_task (escalation)
```
auto mode + pending_action:
→ resolve_pending_action(choice="rerun")
→ если rerun провалился → create manual_task с тегом "auto_escalated"
→ всё логируется
review mode + pending_action:
→ возвращается пользователю через API для ручного выбора
```
---
### D6: Наследование режима followup-задачами
Задачи, созданные через `generate_followups()`, создаются с `execution_mode=None`.
**Решение:** followup-задачи наследуют режим через проект (существующая иерархия D1).
Явно устанавливать `execution_mode` в followup-задачах **не нужно** — если проект в auto, все его задачи по умолчанию в auto.
Исключение: если оригинальная задача была в `review` (ручной override), followup-задачи НЕ наследуют это — они создаются "чисто" от проекта. Это намеренное поведение: override в задаче — разовое действие.
---
## Итоговая карта изменений (что нужно реализовать)
| # | Файл | Изменение | Gap |
|---|------|----------|-----|
| 1 | `agents/runner.py` | Вызов `generate_followups()` в auto mode после `task_auto_approved` | D4 |
| 2 | `core/followup.py` | Auto-resolution `pending_actions` в `generate_followups()` при auto mode | D5 |
| 3 | `web/api.py` | Endpoint для смены `execution_mode` проекта/задачи | — |
| 4 | `web/frontend` | UI переключатель Auto/Review (project settings + task detail) | — |
**Что НЕ нужно менять:**
- `core/db.py` — схема готова
- `core/models.py``get_effective_mode()` готов
- `core/hooks.py` — события готовы
- Permission detection в `runner.py` — готово
---
## Риски и ограничения
1. **Стоимость в auto mode**: `generate_followups()` добавляет один запуск агента после каждого пайплайна. При высокой нагрузке это существенный overhead. Митигация: `generate_followups()` можно сделать опциональным (флаг `auto_followup` в project settings).
2. **Permission retry scope**: `allow_write=True` после первого retry применяется ко всем последующим шагам. Это агрессивно, но допустимо, т.к. агент уже начал писать файлы.
3. **Infinite loop в auto-followup**: если followup создаёт задачи, а те создают ещё followup — нет механизма остановки. Митигация: `parent_task_id` позволяет отслеживать глубину. Задачи с `source: followup:*` глубже 1 уровня — не генерируют followup автоматически.
4. **Race condition**: если два пайплайна запускаются для одной задачи одновременно — БД-уровень не блокирует. SQLite WAL + `task.status = 'in_progress'` в начале пайплайна дают частичную защиту, но не полную.
---
## Статус реализации
- [x] DB schema: `execution_mode` в `projects` и `tasks`
- [x] `get_effective_mode()` с приоритетом task > project > review
- [x] Auto-approve: `task.status = "done"` в auto mode
- [x] Permission retry: 1 попытка с `allow_write=True`
- [x] Хуки: `task_auto_approved`, `pipeline_completed`, `task_permission_retry`
- [ ] Auto-followup: вызов `generate_followups()` из `run_pipeline()` в auto mode (Gap 1)
- [ ] Auto-resolution `pending_actions` в auto mode (Gap 2)
- [ ] API endpoints для управления `execution_mode`
- [ ] Frontend UI для Auto/Review переключателя

View file

@ -105,6 +105,18 @@ def test_approve_not_found(client):
assert r.status_code == 404 assert r.status_code == 404
def test_approve_fires_task_done_hooks(client):
"""Ручной апрув задачи должен вызывать хуки с event='task_done'."""
from unittest.mock import patch
with patch("core.hooks.run_hooks") as mock_hooks:
mock_hooks.return_value = []
r = client.post("/api/tasks/P1-001/approve", json={})
assert r.status_code == 200
events_fired = [call[1].get("event") or call[0][3]
for call in mock_hooks.call_args_list]
assert "task_done" in events_fired
def test_reject_task(client): def test_reject_task(client):
from core.db import init_db from core.db import init_db
from core import models from core import models
@ -173,14 +185,15 @@ def test_run_not_found(client):
assert r.status_code == 404 assert r.status_code == 404
def test_run_with_allow_write(client): def test_run_kin_038_without_allow_write(client):
"""POST /run with allow_write=true should be accepted.""" """Регрессионный тест KIN-038: allow_write удалён из схемы,
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True}) эндпоинт принимает запросы с пустым телом без этого параметра."""
r = client.post("/api/tasks/P1-001/run", json={})
assert r.status_code == 202 assert r.status_code == 202
def test_run_with_empty_body(client): def test_run_with_empty_body(client):
"""POST /run with empty JSON body should default allow_write=false.""" """POST /run with empty JSON body should be accepted."""
r = client.post("/api/tasks/P1-001/run", json={}) r = client.post("/api/tasks/P1-001/run", json={})
assert r.status_code == 202 assert r.status_code == 202
@ -256,14 +269,61 @@ def test_patch_task_status_persisted(client):
assert r.json()["status"] == "blocked" assert r.json()["status"] == "blocked"
@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "cancelled"]) @pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"])
def test_patch_task_all_valid_statuses(client, status): def test_patch_task_all_valid_statuses(client, status):
"""Все 6 допустимых статусов должны приниматься.""" """Все 7 допустимых статусов должны приниматься (включая decomposed)."""
r = client.patch("/api/tasks/P1-001", json={"status": status}) r = client.patch("/api/tasks/P1-001", json={"status": status})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["status"] == status assert r.json()["status"] == status
def test_patch_task_status_decomposed(client):
"""Регрессионный тест KIN-033: API принимает статус 'decomposed'."""
r = client.patch("/api/tasks/P1-001", json={"status": "decomposed"})
assert r.status_code == 200
assert r.json()["status"] == "decomposed"
def test_patch_task_status_decomposed_persisted(client):
"""После установки 'decomposed' повторный GET возвращает этот статус."""
client.patch("/api/tasks/P1-001", json={"status": "decomposed"})
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
assert r.json()["status"] == "decomposed"
# ---------------------------------------------------------------------------
# KIN-033 — единый источник истины для статусов
# ---------------------------------------------------------------------------
def test_api_valid_statuses_match_models():
"""API использует models.VALID_TASK_STATUSES как единственный источник истины."""
from core import models
import web.api as api_module
assert api_module.VALID_STATUSES == set(models.VALID_TASK_STATUSES)
def test_cli_valid_statuses_match_models():
"""CLI использует models.VALID_TASK_STATUSES как единственный источник истины."""
from core import models
from cli.main import task_update
status_param = next(p for p in task_update.params if p.name == "status")
cli_choices = set(status_param.type.choices)
assert cli_choices == set(models.VALID_TASK_STATUSES)
def test_cli_and_api_statuses_are_identical():
"""Список статусов в CLI и API идентичен."""
from core import models
import web.api as api_module
from cli.main import task_update
status_param = next(p for p in task_update.params if p.name == "status")
cli_choices = set(status_param.type.choices)
assert cli_choices == api_module.VALID_STATUSES
assert "decomposed" in cli_choices
assert "decomposed" in api_module.VALID_STATUSES
def test_patch_task_invalid_status(client): def test_patch_task_invalid_status(client):
"""Недопустимый статус → 400.""" """Недопустимый статус → 400."""
r = client.patch("/api/tasks/P1-001", json={"status": "flying"}) r = client.patch("/api/tasks/P1-001", json={"status": "flying"})
@ -274,3 +334,258 @@ def test_patch_task_not_found(client):
"""Несуществующая задача → 404.""" """Несуществующая задача → 404."""
r = client.patch("/api/tasks/NOPE-999", json={"status": "done"}) r = client.patch("/api/tasks/NOPE-999", json={"status": "done"})
assert r.status_code == 404 assert r.status_code == 404
def test_patch_task_empty_body_returns_400(client):
"""PATCH с пустым телом (нет status и нет execution_mode) → 400."""
r = client.patch("/api/tasks/P1-001", json={})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# KIN-022 — blocked_reason: регрессионные тесты
# ---------------------------------------------------------------------------
def test_blocked_reason_saved_and_returned(client):
"""При переходе в blocked с blocked_reason поле сохраняется и отдаётся в GET."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="Step 1/2 (debugger) failed")
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] == "Step 1/2 (debugger) failed"
def test_blocked_reason_present_in_full(client):
"""blocked_reason также присутствует в /full эндпоинте."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="tester agent crashed")
conn.close()
r = client.get("/api/tasks/P1-001/full")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] == "tester agent crashed"
def test_blocked_reason_none_by_default(client):
"""Новая задача не имеет blocked_reason."""
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["blocked_reason"] is None
def test_blocked_without_reason_allowed(client):
"""Переход в blocked без причины допустим (reason=None)."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked")
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "blocked"
assert data["blocked_reason"] is None
def test_blocked_reason_cleared_on_retry(client):
"""При повторном запуске (статус pending) blocked_reason сбрасывается."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="failed once")
models.update_task(conn, "P1-001", status="pending", blocked_reason=None)
conn.close()
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
data = r.json()
assert data["status"] == "pending"
assert data["blocked_reason"] is None
# ---------------------------------------------------------------------------
# KIN-029 — DELETE /api/projects/{project_id}/decisions/{decision_id}
# ---------------------------------------------------------------------------
def test_delete_decision_ok(client):
"""Создаём decision через POST, удаляем DELETE → 200 с телом {"deleted": id}."""
r = client.post("/api/decisions", json={
"project_id": "p1",
"type": "decision",
"title": "Use SQLite",
"description": "Chosen for simplicity",
})
assert r.status_code == 200
decision_id = r.json()["id"]
r = client.delete(f"/api/projects/p1/decisions/{decision_id}")
assert r.status_code == 200
assert r.json() == {"deleted": decision_id}
r = client.get("/api/decisions?project=p1")
assert r.status_code == 200
ids = [d["id"] for d in r.json()]
assert decision_id not in ids
def test_delete_decision_not_found(client):
"""DELETE несуществующего decision → 404."""
r = client.delete("/api/projects/p1/decisions/99999")
assert r.status_code == 404
def test_delete_decision_wrong_project(client):
"""DELETE decision с чужим project_id → 404 (не раскрываем существование)."""
r = client.post("/api/decisions", json={
"project_id": "p1",
"type": "decision",
"title": "Cross-project check",
"description": "Should not be deletable from p2",
})
assert r.status_code == 200
decision_id = r.json()["id"]
r = client.delete(f"/api/projects/p2/decisions/{decision_id}")
assert r.status_code == 404
# Decision должен остаться нетронутым
r = client.get("/api/decisions?project=p1")
ids = [d["id"] for d in r.json()]
assert decision_id in ids
# ---------------------------------------------------------------------------
# KIN-035 — регрессионный тест: смена статуса на cancelled
# ---------------------------------------------------------------------------
def test_patch_task_status_cancelled(client):
"""Регрессионный тест KIN-035: PATCH /api/tasks/{id} с status='cancelled' → 200."""
r = client.patch("/api/tasks/P1-001", json={"status": "cancelled"})
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
def test_patch_task_status_cancelled_persisted(client):
"""После установки 'cancelled' повторный GET возвращает этот статус."""
client.patch("/api/tasks/P1-001", json={"status": "cancelled"})
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
def test_cancelled_in_valid_statuses():
"""'cancelled' присутствует в VALID_TASK_STATUSES модели и в VALID_STATUSES API."""
from core import models
import web.api as api_module
assert "cancelled" in models.VALID_TASK_STATUSES
assert "cancelled" in api_module.VALID_STATUSES
# ---------------------------------------------------------------------------
# KIN-036 — регрессионный тест: --allow-write всегда в команде через web API
# ---------------------------------------------------------------------------
def test_run_always_includes_allow_write_when_body_false(client):
"""Регрессионный тест KIN-036: --allow-write присутствует в команде,
даже если allow_write=False в теле запроса.
Баг: условие `if body and body.allow_write` не добавляло флаг при
allow_write=False, что приводило к блокировке агента на 300 с."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run", json={"allow_write": False})
assert r.status_code == 202
cmd = mock_popen.call_args[0][0]
assert "--allow-write" in cmd, (
"--allow-write обязан присутствовать всегда: без него агент зависает "
"при попытке записи, потому что stdin=DEVNULL и нет интерактивного подтверждения"
)
def test_run_always_includes_allow_write_without_body(client):
"""Регрессионный тест KIN-036: --allow-write присутствует даже без тела запроса."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
cmd = mock_popen.call_args[0][0]
assert "--allow-write" in cmd
def test_run_sets_kin_noninteractive_env(client):
"""Регрессионный тест KIN-036: KIN_NONINTERACTIVE=1 всегда устанавливается
при запуске через web API, что вместе с --allow-write предотвращает зависание."""
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 99
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
call_kwargs = mock_popen.call_args[1]
env = call_kwargs.get("env", {})
assert env.get("KIN_NONINTERACTIVE") == "1"
def test_run_sets_stdin_devnull(client):
"""Регрессионный тест KIN-036: stdin=DEVNULL всегда устанавливается,
что является причиной, по которой --allow-write обязателен."""
import subprocess as _subprocess
from unittest.mock import patch, MagicMock
with patch("web.api.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.pid = 42
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
call_kwargs = mock_popen.call_args[1]
assert call_kwargs.get("stdin") == _subprocess.DEVNULL
# ---------------------------------------------------------------------------
# KIN-040 — регрессионные тесты: удаление TaskRun / allow_write из схемы
# ---------------------------------------------------------------------------
def test_run_kin_040_no_taskrun_class():
"""Регрессионный тест KIN-040: класс TaskRun удалён из web/api.py.
allow_write больше не является частью схемы эндпоинта /run."""
import web.api as api_module
assert not hasattr(api_module, "TaskRun"), (
"Класс TaskRun должен быть удалён из web/api.py (KIN-040)"
)
def test_run_kin_040_allow_write_true_ignored(client):
"""Регрессионный тест KIN-040: allow_write=True в теле игнорируется (не 422).
Эндпоинт не имеет body-параметра, поэтому FastAPI не валидирует тело."""
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True})
assert r.status_code == 202

478
tests/test_auto_mode.py Normal file
View file

@ -0,0 +1,478 @@
"""
Tests for KIN-012 auto mode features:
- TestAutoApprove: pipeline auto-approves (status done) без ручного review
- TestAutoRerunOnPermissionDenied: runner делает retry при permission error,
останавливается после одного retry (лимит = 1)
- TestAutoFollowup: generate_followups вызывается сразу, без ожидания
- Регрессия: review-режим работает как раньше
"""
import json
import pytest
from unittest.mock import patch, MagicMock, call
from core.db import init_db
from core import models
from agents.runner import run_pipeline, _is_permission_error
# ---------------------------------------------------------------------------
# Fixtures & helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def conn():
c = init_db(":memory:")
models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek",
tech_stack=["vue3"])
models.create_task(c, "VDOL-001", "vdol", "Fix bug",
brief={"route_type": "debug"})
yield c
c.close()
def _mock_success(output="done"):
"""Мок успешного subprocess.run (claude)."""
mock = MagicMock()
mock.stdout = json.dumps({"result": output})
mock.stderr = ""
mock.returncode = 0
return mock
def _mock_permission_denied():
"""Мок subprocess.run, возвращающего permission denied."""
mock = MagicMock()
mock.stdout = json.dumps({"result": "permission denied on write to config.js"})
mock.stderr = "Error: permission denied"
mock.returncode = 1
return mock
def _mock_failure(error="Agent failed"):
"""Мок subprocess.run, возвращающего общую ошибку."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = error
mock.returncode = 1
return mock
def _get_hook_events(mock_hooks):
"""Извлечь список event из всех вызовов mock_hooks."""
return [c[1].get("event") for c in mock_hooks.call_args_list]
# ---------------------------------------------------------------------------
# test_auto_approve
# ---------------------------------------------------------------------------
class TestAutoApprove:
"""Pipeline auto-approve: в auto-режиме задача переходит в done без ручного review."""
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_sets_status_done(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto-режим: статус задачи становится 'done', а не 'review'."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find bug"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "done", "Auto-mode должен auto-approve: status=done"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_fires_task_auto_approved_hook(self, mock_run, mock_hooks, mock_followup, conn):
"""В auto-режиме срабатывает хук task_auto_approved."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find bug"}]
run_pipeline(conn, "VDOL-001", steps)
events = _get_hook_events(mock_hooks)
assert "task_auto_approved" in events, "Хук task_auto_approved должен сработать"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_sets_status_review(self, mock_run, mock_hooks, mock_followup, conn):
"""Регрессия: review-режим НЕ auto-approve — статус остаётся 'review'."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Проект остаётся в default "review" mode
steps = [{"role": "debugger", "brief": "find bug"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "review", "Review-mode НЕ должен auto-approve"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_does_not_fire_auto_approved_hook(self, mock_run, mock_hooks, mock_followup, conn):
"""Регрессия: в review-режиме хук task_auto_approved НЕ срабатывает."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
steps = [{"role": "debugger", "brief": "find bug"}]
run_pipeline(conn, "VDOL-001", steps)
events = _get_hook_events(mock_hooks)
assert "task_auto_approved" not in events
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_task_level_auto_overrides_project_review(self, mock_run, mock_hooks, mock_followup, conn):
"""Если у задачи execution_mode=auto, pipeline auto-approve, даже если проект в review."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Проект в review, но задача — auto
models.update_task(conn, "VDOL-001", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "done", "Task-level auto должен override project review"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_pipeline_result_includes_mode(self, mock_run, mock_hooks, mock_followup, conn):
"""Pipeline result должен содержать поле mode."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result.get("mode") == "auto"
# ---------------------------------------------------------------------------
# test_auto_rerun_on_permission_denied
# ---------------------------------------------------------------------------
class TestAutoRerunOnPermissionDenied:
"""Runner повторяет шаг при permission issues, останавливается по лимиту (1 retry)."""
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_retries_on_permission_error(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto-режим: при permission denied runner делает 1 retry с allow_write=True."""
mock_run.side_effect = [
_mock_permission_denied(), # 1-й вызов: permission error
_mock_success("fixed"), # 2-й вызов (retry): успех
]
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "fix file"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
assert mock_run.call_count == 2, "Должен быть ровно 1 retry"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_retry_uses_dangerously_skip_permissions(self, mock_run, mock_hooks, mock_followup, conn):
"""Retry при permission error использует --dangerously-skip-permissions."""
mock_run.side_effect = [
_mock_permission_denied(),
_mock_success("fixed"),
]
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "fix"}]
run_pipeline(conn, "VDOL-001", steps)
# Второй вызов (retry) должен содержать --dangerously-skip-permissions
second_cmd = mock_run.call_args_list[1][0][0]
assert "--dangerously-skip-permissions" in second_cmd
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_retry_fires_permission_retry_hook(self, mock_run, mock_hooks, mock_followup, conn):
"""При авто-retry срабатывает хук task_permission_retry."""
mock_run.side_effect = [
_mock_permission_denied(),
_mock_success(),
]
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "fix"}]
run_pipeline(conn, "VDOL-001", steps)
events = _get_hook_events(mock_hooks)
assert "task_permission_retry" in events, "Хук task_permission_retry должен сработать"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_retry_failure_blocks_task(self, mock_run, mock_hooks, mock_followup, conn):
"""Если retry тоже провалился → задача blocked (лимит в 1 retry исчерпан)."""
mock_run.side_effect = [
_mock_permission_denied(), # 1-й: permission error
_mock_failure("still denied"), # retry: снова ошибка
]
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "fix"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert mock_run.call_count == 2, "Стоп после лимита: ровно 1 retry"
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_subsequent_steps_use_allow_write_after_retry(self, mock_run, mock_hooks, mock_followup, conn):
"""После успешного retry все следующие шаги тоже используют allow_write."""
mock_run.side_effect = [
_mock_permission_denied(), # Шаг 1: permission error
_mock_success("fixed"), # Шаг 1 retry: успех
_mock_success("tested"), # Шаг 2: должен получить allow_write
]
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [
{"role": "debugger", "brief": "fix"},
{"role": "tester", "brief": "test"},
]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
assert mock_run.call_count == 3
# Третий вызов (шаг 2) должен содержать --dangerously-skip-permissions
third_cmd = mock_run.call_args_list[2][0][0]
assert "--dangerously-skip-permissions" in third_cmd
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_normal_failure_does_not_trigger_retry(self, mock_run, mock_hooks, mock_followup, conn):
"""Обычная ошибка (не permission) НЕ вызывает авто-retry даже в auto-режиме."""
mock_run.return_value = _mock_failure("compilation error: undefined variable")
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "fix"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert mock_run.call_count == 1, "Retry не нужен для обычных ошибок"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_does_not_retry_on_permission_error(self, mock_run, mock_hooks, mock_followup, conn):
"""В review-режиме при permission denied runner НЕ делает retry."""
mock_run.return_value = _mock_permission_denied()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Проект в default review mode
steps = [{"role": "debugger", "brief": "fix file"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert mock_run.call_count == 1, "В review-режиме retry НЕ должен происходить"
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
# ---------------------------------------------------------------------------
# test_auto_followup
# ---------------------------------------------------------------------------
class TestAutoFollowup:
"""Followup запускается без ожидания сразу после pipeline в auto-режиме."""
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_followup_triggered_immediately(self, mock_run, mock_hooks, mock_followup, conn):
"""В auto-режиме generate_followups вызывается сразу после pipeline."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_called_once_with(conn, "VDOL-001")
@patch("core.followup.auto_resolve_pending_actions")
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_followup_resolves_pending_actions(
self, mock_run, mock_hooks, mock_followup, mock_resolve, conn
):
"""Pending actions из followup авто-резолвятся без ожидания."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
pending = [{"type": "permission_fix", "description": "Fix nginx.conf",
"original_item": {}, "options": ["rerun"]}]
mock_followup.return_value = {"created": [], "pending_actions": pending}
mock_resolve.return_value = [{"resolved": "rerun", "result": {}}]
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
run_pipeline(conn, "VDOL-001", steps)
mock_resolve.assert_called_once_with(conn, "VDOL-001", pending)
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_no_auto_followup(self, mock_run, mock_hooks, mock_followup, conn):
"""Регрессия: в review-режиме generate_followups НЕ вызывается."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Проект в default review mode
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_not_called()
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "review"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_followup_not_triggered_for_followup_tasks(
self, mock_run, mock_hooks, mock_followup, conn
):
"""Для followup-задач generate_followups НЕ вызывается (защита от рекурсии)."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
models.update_task(conn, "VDOL-001", brief={"source": "followup:VDOL-000"})
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_not_called()
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_followup_exception_does_not_block_pipeline(
self, mock_run, mock_hooks, mock_followup, conn
):
"""Ошибка в followup не должна блокировать pipeline (success=True)."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.side_effect = Exception("followup PM crashed")
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True # Pipeline succeeded, followup failure absorbed
@patch("core.followup.auto_resolve_pending_actions")
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_no_pending_actions_skips_auto_resolve(
self, mock_run, mock_hooks, mock_followup, mock_resolve, conn
):
"""Если pending_actions пустой, auto_resolve_pending_actions НЕ вызывается."""
mock_run.return_value = _mock_success()
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
mock_resolve.return_value = []
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
run_pipeline(conn, "VDOL-001", steps)
mock_resolve.assert_not_called()
# ---------------------------------------------------------------------------
# _is_permission_error unit tests
# ---------------------------------------------------------------------------
class TestIsPermissionError:
"""Unit-тесты для функции _is_permission_error."""
def test_detects_permission_denied_in_raw_output(self):
result = {"raw_output": "Error: permission denied writing to nginx.conf",
"returncode": 1}
assert _is_permission_error(result) is True
def test_detects_read_only_in_output(self):
result = {"raw_output": "File is read-only, cannot write",
"returncode": 1}
assert _is_permission_error(result) is True
def test_detects_manual_apply_in_output(self):
result = {"raw_output": "Apply manually to /etc/nginx/nginx.conf",
"returncode": 1}
assert _is_permission_error(result) is True
def test_normal_failure_not_permission_error(self):
result = {"raw_output": "Compilation error: undefined variable x",
"returncode": 1}
assert _is_permission_error(result) is False
def test_empty_output_not_permission_error(self):
result = {"raw_output": "", "returncode": 1}
assert _is_permission_error(result) is False
def test_success_with_permission_word_not_flagged(self):
"""Если returncode=0 и текст содержит 'permission', это не ошибка."""
# Функция проверяет только текст, не returncode
# Но с success output вряд ли содержит "permission denied"
result = {"raw_output": "All permissions granted, build successful",
"returncode": 0}
assert _is_permission_error(result) is False

View file

@ -333,7 +333,8 @@ def test_hook_setup_registers_rebuild_frontend(runner, tmp_path):
r = invoke(runner, ["hook", "list", "--project", "p1"]) r = invoke(runner, ["hook", "list", "--project", "p1"])
assert r.exit_code == 0 assert r.exit_code == 0
assert "rebuild-frontend" in r.output assert "rebuild-frontend" in r.output
assert "web/frontend/*" in r.output # KIN-050: trigger_module_path должен быть NULL — хук срабатывает безусловно
assert "web/frontend/*" not in r.output
def test_hook_setup_idempotent(runner, tmp_path): def test_hook_setup_idempotent(runner, tmp_path):
@ -352,3 +353,123 @@ def test_hook_setup_project_not_found(runner):
r = invoke(runner, ["hook", "setup", "--project", "nope"]) r = invoke(runner, ["hook", "setup", "--project", "nope"])
assert r.exit_code == 1 assert r.exit_code == 1
assert "not found" in r.output assert "not found" in r.output
# ===========================================================================
# KIN-018 — project set-mode / task update --mode / show with mode labels
# ===========================================================================
def test_project_set_mode_auto(runner):
"""project set-mode auto — обновляет режим, выводит подтверждение."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
assert r.exit_code == 0
assert "auto" in r.output
def test_project_set_mode_review(runner):
"""project set-mode review — обновляет режим обратно в review."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "review"])
assert r.exit_code == 0
assert "review" in r.output
def test_project_set_mode_persisted(runner):
"""После project set-mode режим сохраняется в БД и виден в project show."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
r = invoke(runner, ["project", "show", "p1"])
assert r.exit_code == 0
assert "auto" in r.output
def test_project_set_mode_not_found(runner):
"""project set-mode для несуществующего проекта → exit code 1."""
r = invoke(runner, ["project", "set-mode", "--project", "nope", "auto"])
assert r.exit_code == 1
assert "not found" in r.output
def test_project_set_mode_invalid(runner):
"""project set-mode с недопустимым значением → ошибка click."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "set-mode", "--project", "p1", "turbo"])
assert r.exit_code != 0
def test_project_show_displays_mode(runner):
"""project show отображает строку Mode: ..."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["project", "show", "p1"])
assert r.exit_code == 0
assert "Mode:" in r.output
def test_task_update_mode_auto(runner):
"""task update --mode auto задаёт execution_mode на задачу."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "auto"])
assert r.exit_code == 0
assert "auto" in r.output
def test_task_update_mode_review(runner):
"""task update --mode review задаёт execution_mode=review на задачу."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "review"])
assert r.exit_code == 0
assert "review" in r.output
def test_task_update_mode_persisted(runner):
"""После task update --mode режим сохраняется и виден в task show как (overridden)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
invoke(runner, ["task", "update", "P1-001", "--mode", "auto"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "overridden" in r.output
def test_task_update_mode_invalid(runner):
"""task update --mode с недопустимым значением → ошибка click."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "update", "P1-001", "--mode", "turbo"])
assert r.exit_code != 0
def test_task_show_mode_inherited(runner):
"""task show без явного execution_mode показывает (inherited)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "inherited" in r.output
def test_task_show_mode_overridden(runner):
"""task show с task-level execution_mode показывает (overridden)."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
invoke(runner, ["task", "update", "P1-001", "--mode", "review"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "overridden" in r.output
def test_task_show_mode_label_reflects_project_mode(runner):
"""Если у проекта auto, у задачи нет mode — task show показывает 'auto (inherited)'."""
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["project", "set-mode", "--project", "p1", "auto"])
invoke(runner, ["task", "add", "p1", "Fix bug"])
r = invoke(runner, ["task", "show", "P1-001"])
assert r.exit_code == 0
assert "auto" in r.output
assert "inherited" in r.output

View file

@ -7,7 +7,7 @@ from unittest.mock import patch, MagicMock
from core.db import init_db from core.db import init_db
from core import models from core import models
from core.followup import ( from core.followup import (
generate_followups, resolve_pending_action, generate_followups, resolve_pending_action, auto_resolve_pending_actions,
_collect_pipeline_output, _next_task_id, _is_permission_blocked, _collect_pipeline_output, _next_task_id, _is_permission_blocked,
) )
@ -222,3 +222,48 @@ class TestResolvePendingAction:
def test_nonexistent_task(self, conn): def test_nonexistent_task(self, conn):
action = {"type": "permission_fix", "original_item": {}} action = {"type": "permission_fix", "original_item": {}}
assert resolve_pending_action(conn, "NOPE", action, "skip") is None assert resolve_pending_action(conn, "NOPE", action, "skip") is None
class TestAutoResolvePendingActions:
@patch("agents.runner._run_claude")
def test_rerun_success_resolves_as_rerun(self, mock_claude, conn):
"""Успешный rerun должен резолвиться как 'rerun'."""
mock_claude.return_value = {
"output": json.dumps({"result": "fixed"}),
"returncode": 0,
}
action = {
"type": "permission_fix",
"description": "Fix X",
"original_item": {"title": "Fix X", "type": "frontend_dev", "brief": "Apply fix"},
"options": ["rerun", "manual_task", "skip"],
}
results = auto_resolve_pending_actions(conn, "VDOL-001", [action])
assert len(results) == 1
assert results[0]["resolved"] == "rerun"
@patch("agents.runner._run_claude")
def test_rerun_failure_escalates_to_manual_task(self, mock_claude, conn):
"""Провал rerun должен создавать manual_task для эскалации."""
mock_claude.return_value = {"output": "", "returncode": 1}
action = {
"type": "permission_fix",
"description": "Fix X",
"original_item": {"title": "Fix X", "type": "frontend_dev", "brief": "Apply fix"},
"options": ["rerun", "manual_task", "skip"],
}
results = auto_resolve_pending_actions(conn, "VDOL-001", [action])
assert len(results) == 1
assert results[0]["resolved"] == "manual_task"
# Manual task должна быть создана в DB
tasks = models.list_tasks(conn, project_id="vdol")
assert len(tasks) == 2 # VDOL-001 + новая manual task
@patch("agents.runner._run_claude")
def test_empty_pending_actions(self, mock_claude, conn):
"""Пустой список — пустой результат."""
results = auto_resolve_pending_actions(conn, "VDOL-001", [])
assert results == []
mock_claude.assert_not_called()

View file

@ -8,7 +8,7 @@ from core.db import init_db
from core import models from core import models
from core.hooks import ( from core.hooks import (
create_hook, get_hooks, update_hook, delete_hook, create_hook, get_hooks, update_hook, delete_hook,
run_hooks, get_hook_logs, HookResult, run_hooks, get_hook_logs, HookResult, _substitute_vars,
) )
@ -273,3 +273,298 @@ class TestGetHookLogs:
event="pipeline_completed", task_modules=modules) event="pipeline_completed", task_modules=modules)
logs = get_hook_logs(conn, project_id="vdol", limit=3) logs = get_hook_logs(conn, project_id="vdol", limit=3)
assert len(logs) == 3 assert len(logs) == 3
# ---------------------------------------------------------------------------
# Variable substitution in hook commands
# ---------------------------------------------------------------------------
class TestSubstituteVars:
def test_substitutes_task_id_and_title(self, conn):
result = _substitute_vars(
'git commit -m "kin: {task_id} {title}"',
"VDOL-001",
conn,
)
assert result == 'git commit -m "kin: VDOL-001 Fix bug"'
def test_no_substitution_when_task_id_is_none(self, conn):
cmd = 'git commit -m "kin: {task_id} {title}"'
result = _substitute_vars(cmd, None, conn)
assert result == cmd
def test_sanitizes_double_quotes_in_title(self, conn):
conn.execute('UPDATE tasks SET title = ? WHERE id = ?',
('Fix "bug" here', "VDOL-001"))
conn.commit()
result = _substitute_vars(
'git commit -m "kin: {task_id} {title}"',
"VDOL-001",
conn,
)
assert '"' not in result.split('"kin:')[1].split('"')[0]
assert "Fix 'bug' here" in result
def test_sanitizes_newlines_in_title(self, conn):
conn.execute('UPDATE tasks SET title = ? WHERE id = ?',
("Fix\nbug\r\nhere", "VDOL-001"))
conn.commit()
result = _substitute_vars("{title}", "VDOL-001", conn)
assert "\n" not in result
assert "\r" not in result
def test_unknown_task_id_uses_empty_title(self, conn):
result = _substitute_vars("{task_id} {title}", "NONEXISTENT", conn)
assert result == "NONEXISTENT "
def test_no_placeholders_returns_command_unchanged(self, conn):
cmd = "npm run build"
result = _substitute_vars(cmd, "VDOL-001", conn)
assert result == cmd
@patch("core.hooks.subprocess.run")
def test_autocommit_hook_command_substituted(self, mock_run, conn):
"""auto-commit hook должен получать реальные task_id и title в команде."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(conn, "vdol", "auto-commit", "task_done",
'git add -A && git commit -m "kin: {task_id} {title}"',
working_dir="/tmp")
run_hooks(conn, "vdol", "VDOL-001", event="task_done", task_modules=[])
call_kwargs = mock_run.call_args[1]
# shell=True: command is the first positional arg
command = mock_run.call_args[0][0]
assert "VDOL-001" in command
assert "Fix bug" in command
# ---------------------------------------------------------------------------
# KIN-050: rebuild-frontend hook — unconditional firing after pipeline
# ---------------------------------------------------------------------------
class TestRebuildFrontendHookSetup:
"""Regression tests for KIN-050.
Баг: rebuild-frontend не срабатывал, если pipeline не трогал web/frontend/*.
Фикс: убран trigger_module_path из hook_setup хук должен срабатывать всегда.
"""
def test_rebuild_frontend_created_without_trigger_module_path(self, conn):
"""rebuild-frontend hook должен быть создан без trigger_module_path (KIN-050).
Воспроизводит логику hook_setup: создаём хук без фильтра и убеждаемся,
что он сохраняется в БД с trigger_module_path=NULL.
"""
hook = create_hook(
conn, "vdol",
name="rebuild-frontend",
event="pipeline_completed",
command="scripts/rebuild-frontend.sh",
trigger_module_path=None, # фикс KIN-050: без фильтра
working_dir="/tmp",
timeout_seconds=300,
)
assert hook["trigger_module_path"] is None, (
"trigger_module_path должен быть NULL — хук не должен фильтровать по модулям"
)
# Перечитываем из БД — убеждаемся, что NULL сохранился
hooks = get_hooks(conn, "vdol", enabled_only=False)
rebuild = next((h for h in hooks if h["name"] == "rebuild-frontend"), None)
assert rebuild is not None
assert rebuild["trigger_module_path"] is None
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_when_only_backend_modules_changed(self, mock_run, conn):
"""Хук без trigger_module_path должен срабатывать при изменении backend-файлов.
Регрессия KIN-050: раньше хук молчал, если не было web/frontend/* файлов.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="built!", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None, # фикс: нет фильтра
working_dir="/tmp",
)
backend_modules = [
{"path": "core/models.py", "name": "models"},
{"path": "web/api.py", "name": "api"},
]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=backend_modules)
assert len(results) == 1, "Хук должен сработать несмотря на отсутствие frontend-файлов"
assert results[0].name == "rebuild-frontend"
assert results[0].success is True
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_exactly_once_per_pipeline(self, mock_run, conn):
"""Хук rebuild-frontend должен срабатывать ровно один раз за pipeline_completed."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None,
working_dir="/tmp",
)
any_modules = [
{"path": "core/hooks.py", "name": "hooks"},
{"path": "web/frontend/App.vue", "name": "App"},
{"path": "web/api.py", "name": "api"},
]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=any_modules)
assert len(results) == 1, "Хук должен выполниться ровно один раз"
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_fires_with_empty_module_list(self, mock_run, conn):
"""Хук без trigger_module_path должен срабатывать даже с пустым списком модулей."""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"npm run build",
trigger_module_path=None,
working_dir="/tmp",
)
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=[])
assert len(results) == 1
assert results[0].name == "rebuild-frontend"
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_rebuild_frontend_with_module_path_skips_non_frontend(self, mock_run, conn):
"""Контрольный тест: хук С trigger_module_path НЕ срабатывает на backend-файлы.
Подтверждает, что фикс (удаление trigger_module_path) был необходим.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
create_hook(
conn, "vdol", "rebuild-frontend-filtered", "pipeline_completed",
"npm run build",
trigger_module_path="web/frontend/*", # старое (сломанное) поведение
working_dir="/tmp",
)
backend_modules = [{"path": "core/models.py", "name": "models"}]
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=backend_modules)
assert len(results) == 0, (
"Хук с trigger_module_path НЕ должен срабатывать на backend-файлы — "
"именно это было первопричиной бага KIN-050"
)
# ---------------------------------------------------------------------------
# KIN-052: rebuild-frontend hook — команда cd+&& и персистентность в БД
# ---------------------------------------------------------------------------
class TestKIN052RebuildFrontendCommand:
"""Регрессионные тесты для KIN-052.
Хук rebuild-frontend использует команду вида:
cd /path/to/frontend && npm run build
то есть цепочку shell-команд без working_dir.
Тесты проверяют, что такая форма работает корректно и хук переживает
пересоздание соединения с БД (симуляция рестарта).
"""
@patch("core.hooks.subprocess.run")
def test_cd_chained_command_passes_as_string_to_shell(self, mock_run, conn):
"""Команда с && должна передаваться в subprocess как строка (не список) с shell=True.
Если передать список ['cd', '/path', '&&', 'npm', 'run', 'build'] с shell=True,
shell проигнорирует аргументы после первого. Строковая форма обязательна.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="built!", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None, working_dir=None)
run_hooks(conn, "vdol", "VDOL-001", event="pipeline_completed", task_modules=[])
call_args = mock_run.call_args
passed_cmd = call_args[0][0]
assert isinstance(passed_cmd, str), (
"Команда с && должна передаваться как строка, иначе shell не раскроет &&"
)
assert "&&" in passed_cmd
assert call_args[1].get("shell") is True
@patch("core.hooks.subprocess.run")
def test_cd_command_without_working_dir_uses_cwd_none(self, mock_run, conn):
"""Хук с cd-командой и working_dir=None должен вызывать subprocess с cwd=None.
Директория смены задаётся через cd в самой команде, а не через cwd.
"""
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None, working_dir=None)
run_hooks(conn, "vdol", "VDOL-001", event="pipeline_completed", task_modules=[])
cwd = mock_run.call_args[1].get("cwd")
assert cwd is None, (
f"cwd должен быть None когда working_dir не задан, получили: {cwd!r}"
)
@patch("core.hooks.subprocess.run")
def test_cd_command_exits_zero_returns_success(self, mock_run, conn):
"""Хук с cd+npm run build при returncode=0 должен вернуть success=True."""
mock_run.return_value = MagicMock(returncode=0, stdout="✓ build complete", stderr="")
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
create_hook(conn, "vdol", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None)
results = run_hooks(conn, "vdol", "VDOL-001",
event="pipeline_completed", task_modules=[])
assert len(results) == 1
assert results[0].success is True
assert results[0].name == "rebuild-frontend"
@patch("core.hooks.subprocess.run")
def test_hook_persists_after_db_reconnect(self, mock_run):
"""Хук должен сохраняться в файловой БД и быть доступен после пересоздания соединения.
Симулирует рестарт: создаём хук, закрываем соединение, открываем новое хук на месте.
"""
import tempfile
import os
from core.db import init_db
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
# Первое соединение — создаём проект и хук
conn1 = init_db(db_path)
from core import models as _models
_models.create_project(conn1, "kin", "Kin", "/projects/kin", tech_stack=["vue3"])
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
hook = create_hook(conn1, "kin", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None)
hook_id = hook["id"]
conn1.close()
# Второе соединение — «рестарт», хук должен быть на месте
conn2 = init_db(db_path)
hooks = get_hooks(conn2, "kin", event="pipeline_completed", enabled_only=True)
conn2.close()
assert len(hooks) == 1, "После пересоздания соединения хук должен оставаться в БД"
assert hooks[0]["id"] == hook_id
assert hooks[0]["name"] == "rebuild-frontend"
assert hooks[0]["command"] == cmd
assert hooks[0]["trigger_module_path"] is None
finally:
os.unlink(db_path)

View file

@ -289,6 +289,149 @@ class TestRunPipeline:
assert result["success"] is True assert result["success"] is True
# ---------------------------------------------------------------------------
# Auto mode
# ---------------------------------------------------------------------------
class TestAutoMode:
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_generates_followups(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto mode должен вызывать generate_followups после task_auto_approved."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_called_once_with(conn, "VDOL-001")
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "done"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_skips_followups(self, mock_run, mock_hooks, mock_followup, conn):
"""Review mode НЕ должен вызывать generate_followups автоматически."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Проект остаётся в default "review" mode
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_not_called()
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "review"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_skips_followups_for_followup_tasks(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto mode НЕ должен генерировать followups для followup-задач (предотвращение рекурсии)."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
models.update_task(conn, "VDOL-001", brief={"source": "followup:VDOL-000"})
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_not_called()
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_fires_task_done_event(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto mode должен вызывать run_hooks с event='task_done' после task_auto_approved."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
events_fired = [call[1].get("event") or call[0][3]
for call in mock_hooks.call_args_list]
assert "task_done" in events_fired
@patch("core.followup.auto_resolve_pending_actions")
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_resolves_pending_actions(self, mock_run, mock_hooks, mock_followup, mock_resolve, conn):
"""Auto mode должен авто-резолвить pending_actions из followup generation."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
pending = [{"type": "permission_fix", "description": "Fix X",
"original_item": {}, "options": ["rerun"]}]
mock_followup.return_value = {"created": [], "pending_actions": pending}
mock_resolve.return_value = [{"resolved": "rerun", "result": {}}]
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_resolve.assert_called_once_with(conn, "VDOL-001", pending)
# ---------------------------------------------------------------------------
# Retry on permission error
# ---------------------------------------------------------------------------
class TestRetryOnPermissionError:
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_retry_on_permission_error_auto_mode(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto mode: retry при permission error должен срабатывать."""
permission_fail = _mock_claude_failure("permission denied: cannot write file")
retry_success = _mock_claude_success({"result": "fixed"})
mock_run.side_effect = [permission_fail, retry_success]
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
assert mock_run.call_count == 2
# Second call must include --dangerously-skip-permissions
second_cmd = mock_run.call_args_list[1][0][0]
assert "--dangerously-skip-permissions" in second_cmd
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_does_not_retry_on_permission_error(self, mock_run, mock_hooks, conn):
"""Review mode: retry при permission error НЕ должен срабатывать."""
permission_fail = _mock_claude_failure("permission denied: cannot write file")
mock_run.return_value = permission_fail
mock_hooks.return_value = []
# Проект остаётся в default "review" mode
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert mock_run.call_count == 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# JSON parsing # JSON parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -336,20 +479,22 @@ class TestNonInteractive:
call_kwargs = mock_run.call_args[1] call_kwargs = mock_run.call_args[1]
assert call_kwargs.get("timeout") == 300 assert call_kwargs.get("timeout") == 300
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": ""})
@patch("agents.runner.subprocess.run") @patch("agents.runner.subprocess.run")
def test_interactive_uses_600s_timeout(self, mock_run, conn): def test_interactive_uses_600s_timeout(self, mock_run, conn):
mock_run.return_value = _mock_claude_success({"result": "ok"}) mock_run.return_value = _mock_claude_success({"result": "ok"})
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
call_kwargs = mock_run.call_args[1] call_kwargs = mock_run.call_args[1]
assert call_kwargs.get("timeout") == 300 assert call_kwargs.get("timeout") == 600
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": ""})
@patch("agents.runner.subprocess.run") @patch("agents.runner.subprocess.run")
def test_interactive_no_stdin_override(self, mock_run, conn): def test_interactive_no_stdin_override(self, mock_run, conn):
"""In interactive mode, stdin should not be set to DEVNULL.""" """In interactive mode, stdin should not be set to DEVNULL."""
mock_run.return_value = _mock_claude_success({"result": "ok"}) mock_run.return_value = _mock_claude_success({"result": "ok"})
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False) run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
call_kwargs = mock_run.call_args[1] call_kwargs = mock_run.call_args[1]
assert call_kwargs.get("stdin") == subprocess.DEVNULL assert call_kwargs.get("stdin") is None
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"}) @patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"})
@patch("agents.runner.subprocess.run") @patch("agents.runner.subprocess.run")
@ -501,3 +646,108 @@ class TestRunAudit:
cmd = mock_run.call_args[0][0] cmd = mock_run.call_args[0][0]
assert "--dangerously-skip-permissions" in cmd assert "--dangerously-skip-permissions" in cmd
# ---------------------------------------------------------------------------
# KIN-019: Silent FAILED diagnostics (regression tests)
# ---------------------------------------------------------------------------
class TestSilentFailedDiagnostics:
"""Regression: агент падает без вывода — runner должен сохранять диагностику в БД."""
@patch("agents.runner.subprocess.run")
def test_agent_empty_stdout_saves_stderr_as_error_message_in_db(self, mock_run, conn):
"""Когда stdout пустой и returncode != 0, stderr должен сохраняться как error_message в agent_logs."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "API rate limit exceeded (429)"
mock.returncode = 1
mock_run.return_value = mock
run_agent(conn, "debugger", "VDOL-001", "vdol")
log = conn.execute(
"SELECT error_message FROM agent_logs WHERE task_id='VDOL-001'"
).fetchone()
assert log is not None
assert log["error_message"] is not None
assert "rate limit" in log["error_message"]
@patch("agents.runner.subprocess.run")
def test_agent_empty_stdout_returns_error_key_with_stderr(self, mock_run, conn):
"""run_agent должен вернуть ключ 'error' с содержимым stderr при пустом stdout и ненулевом returncode."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "Permission denied: cannot write to /etc/hosts"
mock.returncode = 1
mock_run.return_value = mock
result = run_agent(conn, "debugger", "VDOL-001", "vdol")
assert result["success"] is False
assert "error" in result
assert result["error"] is not None
assert "Permission denied" in result["error"]
@patch("agents.runner.subprocess.run")
def test_pipeline_error_message_includes_agent_stderr(self, mock_run, conn):
"""Сообщение об ошибке pipeline должно включать stderr агента, а не только generic 'step failed'."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "Internal server error: unexpected EOF"
mock.returncode = 1
mock_run.return_value = mock
steps = [{"role": "tester", "brief": "run tests"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert "Internal server error" in result["error"] or "unexpected EOF" in result["error"]
@patch("agents.runner.build_context")
def test_pipeline_exception_in_run_agent_marks_task_blocked(self, mock_ctx, conn):
"""Исключение внутри run_agent (например, из build_context) должно ставить задачу в blocked."""
mock_ctx.side_effect = RuntimeError("DB connection lost")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
@patch("agents.runner.build_context")
def test_pipeline_exception_logs_to_agent_logs(self, mock_ctx, conn):
"""Исключение в run_agent должно быть залогировано в agent_logs с success=False."""
mock_ctx.side_effect = ValueError("bad context data")
steps = [{"role": "tester", "brief": "test"}]
run_pipeline(conn, "VDOL-001", steps)
logs = conn.execute(
"SELECT * FROM agent_logs WHERE task_id='VDOL-001' AND success=0"
).fetchall()
assert len(logs) >= 1
@patch("agents.runner.build_context")
def test_pipeline_exception_marks_pipeline_failed_in_db(self, mock_ctx, conn):
"""При исключении запись pipeline должна существовать в БД и иметь статус failed."""
mock_ctx.side_effect = RuntimeError("network timeout")
steps = [{"role": "debugger", "brief": "find"}]
run_pipeline(conn, "VDOL-001", steps)
pipe = conn.execute("SELECT * FROM pipelines WHERE task_id='VDOL-001'").fetchone()
assert pipe is not None
assert pipe["status"] == "failed"
@patch("agents.runner.subprocess.run")
def test_agent_success_has_no_error_key_populated(self, mock_run, conn):
"""При успешном запуске агента ключ 'error' в результате должен быть None (нет ложных срабатываний)."""
mock_run.return_value = _mock_claude_success({"result": "all good"})
result = run_agent(conn, "debugger", "VDOL-001", "vdol")
assert result["success"] is True
assert result.get("error") is None

View file

@ -0,0 +1,195 @@
"""Tests for KIN-037: tech_researcher specialist — YAML validation and prompt structure."""
from pathlib import Path
import yaml
import pytest
SPECIALISTS_YAML = Path(__file__).parent.parent / "agents" / "specialists.yaml"
PROMPTS_DIR = Path(__file__).parent.parent / "agents" / "prompts"
TECH_RESEARCHER_PROMPT = PROMPTS_DIR / "tech_researcher.md"
REQUIRED_SPECIALIST_FIELDS = {"name", "model", "tools", "description", "permissions"}
REQUIRED_OUTPUT_SCHEMA_FIELDS = {
"status", "api_overview", "endpoints", "rate_limits", "auth_method",
"data_schemas", "limitations", "gotchas", "codebase_diff", "notes",
}
@pytest.fixture(scope="module")
def spec():
"""Load and parse specialists.yaml once for all tests."""
return yaml.safe_load(SPECIALISTS_YAML.read_text())
@pytest.fixture(scope="module")
def tech_researcher(spec):
return spec["specialists"]["tech_researcher"]
@pytest.fixture(scope="module")
def prompt_text():
return TECH_RESEARCHER_PROMPT.read_text()
# ---------------------------------------------------------------------------
# YAML validity
# ---------------------------------------------------------------------------
class TestSpecialistsYaml:
def test_yaml_parses_without_error(self):
content = SPECIALISTS_YAML.read_text()
parsed = yaml.safe_load(content)
assert parsed is not None
def test_yaml_has_specialists_key(self, spec):
assert "specialists" in spec
def test_yaml_has_routes_key(self, spec):
assert "routes" in spec
# ---------------------------------------------------------------------------
# tech_researcher entry structure
# ---------------------------------------------------------------------------
class TestTechResearcherEntry:
def test_tech_researcher_exists_in_specialists(self, spec):
assert "tech_researcher" in spec["specialists"]
def test_tech_researcher_has_required_fields(self, tech_researcher):
missing = REQUIRED_SPECIALIST_FIELDS - set(tech_researcher.keys())
assert not missing, f"Missing fields: {missing}"
def test_tech_researcher_name_is_string(self, tech_researcher):
assert isinstance(tech_researcher["name"], str)
assert tech_researcher["name"].strip()
def test_tech_researcher_model_is_sonnet(self, tech_researcher):
assert tech_researcher["model"] == "sonnet"
def test_tech_researcher_tools_is_list(self, tech_researcher):
assert isinstance(tech_researcher["tools"], list)
assert len(tech_researcher["tools"]) > 0
def test_tech_researcher_tools_include_webfetch(self, tech_researcher):
assert "WebFetch" in tech_researcher["tools"]
def test_tech_researcher_tools_include_read_grep_glob(self, tech_researcher):
for tool in ("Read", "Grep", "Glob"):
assert tool in tech_researcher["tools"], f"Missing tool: {tool}"
def test_tech_researcher_permissions_is_read_only(self, tech_researcher):
assert tech_researcher["permissions"] == "read_only"
def test_tech_researcher_description_is_non_empty_string(self, tech_researcher):
assert isinstance(tech_researcher["description"], str)
assert len(tech_researcher["description"]) > 10
def test_tech_researcher_has_output_schema(self, tech_researcher):
assert "output_schema" in tech_researcher
def test_tech_researcher_output_schema_has_required_fields(self, tech_researcher):
schema = tech_researcher["output_schema"]
missing = REQUIRED_OUTPUT_SCHEMA_FIELDS - set(schema.keys())
assert not missing, f"Missing output_schema fields: {missing}"
def test_tech_researcher_context_rules_decisions_is_list(self, tech_researcher):
decisions = tech_researcher.get("context_rules", {}).get("decisions")
assert isinstance(decisions, list)
def test_tech_researcher_context_rules_includes_gotcha(self, tech_researcher):
decisions = tech_researcher.get("context_rules", {}).get("decisions", [])
assert "gotcha" in decisions
# ---------------------------------------------------------------------------
# api_research route
# ---------------------------------------------------------------------------
class TestApiResearchRoute:
def test_api_research_route_exists(self, spec):
assert "api_research" in spec["routes"]
def test_api_research_route_has_steps(self, spec):
route = spec["routes"]["api_research"]
assert "steps" in route
assert isinstance(route["steps"], list)
assert len(route["steps"]) >= 1
def test_api_research_route_starts_with_tech_researcher(self, spec):
steps = spec["routes"]["api_research"]["steps"]
assert steps[0] == "tech_researcher"
def test_api_research_route_includes_architect(self, spec):
steps = spec["routes"]["api_research"]["steps"]
assert "architect" in steps
def test_api_research_route_has_description(self, spec):
route = spec["routes"]["api_research"]
assert "description" in route
assert isinstance(route["description"], str)
# ---------------------------------------------------------------------------
# Prompt file existence
# ---------------------------------------------------------------------------
class TestTechResearcherPromptFile:
def test_prompt_file_exists(self):
assert TECH_RESEARCHER_PROMPT.exists(), (
f"Prompt file not found: {TECH_RESEARCHER_PROMPT}"
)
def test_prompt_file_is_not_empty(self, prompt_text):
assert len(prompt_text.strip()) > 100
# ---------------------------------------------------------------------------
# Prompt content — structured review instructions
# ---------------------------------------------------------------------------
class TestTechResearcherPromptContent:
def test_prompt_contains_json_output_instruction(self, prompt_text):
assert "JSON" in prompt_text or "json" in prompt_text
def test_prompt_defines_status_field(self, prompt_text):
assert '"status"' in prompt_text
def test_prompt_defines_done_partial_blocked_statuses(self, prompt_text):
assert "done" in prompt_text
assert "partial" in prompt_text
assert "blocked" in prompt_text
def test_prompt_defines_api_overview_field(self, prompt_text):
assert "api_overview" in prompt_text
def test_prompt_defines_endpoints_field(self, prompt_text):
assert "endpoints" in prompt_text
def test_prompt_defines_rate_limits_field(self, prompt_text):
assert "rate_limits" in prompt_text
def test_prompt_defines_codebase_diff_field(self, prompt_text):
assert "codebase_diff" in prompt_text
def test_prompt_defines_gotchas_field(self, prompt_text):
assert "gotchas" in prompt_text
def test_prompt_contains_webfetch_instruction(self, prompt_text):
assert "WebFetch" in prompt_text
def test_prompt_mentions_no_secrets_logging(self, prompt_text):
"""Prompt must instruct agent not to log secret values."""
lower = prompt_text.lower()
assert "secret" in lower or "credential" in lower or "token" in lower
def test_prompt_specifies_readonly_bash(self, prompt_text):
"""Bash must be restricted to read-only operations per rules."""
assert "read-only" in prompt_text or "read only" in prompt_text or "GET" in prompt_text
def test_prompt_defines_partial_reason_for_partial_status(self, prompt_text):
assert "partial_reason" in prompt_text
def test_prompt_defines_blocked_reason_for_blocked_status(self, prompt_text):
assert "blocked_reason" in prompt_text

View file

@ -76,6 +76,25 @@ class ProjectCreate(BaseModel):
priority: int = 5 priority: int = 5
class ProjectPatch(BaseModel):
execution_mode: str
@app.patch("/api/projects/{project_id}")
def patch_project(project_id: str, body: ProjectPatch):
if body.execution_mode not in VALID_EXECUTION_MODES:
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
models.update_project(conn, project_id, execution_mode=body.execution_mode)
p = models.get_project(conn, project_id)
conn.close()
return p
@app.post("/api/projects") @app.post("/api/projects")
def create_project(body: ProjectCreate): def create_project(body: ProjectCreate):
conn = get_conn() conn = get_conn()
@ -138,22 +157,33 @@ def create_task(body: TaskCreate):
class TaskPatch(BaseModel): class TaskPatch(BaseModel):
status: str status: str | None = None
execution_mode: str | None = None
VALID_STATUSES = {"pending", "in_progress", "review", "done", "blocked", "cancelled"} VALID_STATUSES = set(models.VALID_TASK_STATUSES)
VALID_EXECUTION_MODES = {"auto", "review"}
@app.patch("/api/tasks/{task_id}") @app.patch("/api/tasks/{task_id}")
def patch_task(task_id: str, body: TaskPatch): def patch_task(task_id: str, body: TaskPatch):
if body.status not in VALID_STATUSES: if body.status is not None and body.status not in VALID_STATUSES:
raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}") raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}")
if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES:
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
if body.status is None and body.execution_mode is None:
raise HTTPException(400, "Nothing to update. Provide status or execution_mode.")
conn = get_conn() conn = get_conn()
t = models.get_task(conn, task_id) t = models.get_task(conn, task_id)
if not t: if not t:
conn.close() conn.close()
raise HTTPException(404, f"Task '{task_id}' not found") raise HTTPException(404, f"Task '{task_id}' not found")
models.update_task(conn, task_id, status=body.status) fields = {}
if body.status is not None:
fields["status"] = body.status
if body.execution_mode is not None:
fields["execution_mode"] = body.execution_mode
models.update_task(conn, task_id, **fields)
t = models.get_task(conn, task_id) t = models.get_task(conn, task_id)
conn.close() conn.close()
return t return t
@ -218,6 +248,13 @@ def approve_task(task_id: str, body: TaskApprove | None = None):
conn.close() conn.close()
raise HTTPException(404, f"Task '{task_id}' not found") raise HTTPException(404, f"Task '{task_id}' not found")
models.update_task(conn, task_id, status="done") models.update_task(conn, task_id, status="done")
try:
from core.hooks import run_hooks as _run_hooks
task_modules = models.get_modules(conn, t["project_id"])
_run_hooks(conn, t["project_id"], task_id,
event="task_done", task_modules=task_modules)
except Exception:
pass
decision = None decision = None
if body and body.decision_title: if body and body.decision_title:
decision = models.add_decision( decision = models.add_decision(
@ -298,12 +335,8 @@ def is_task_running(task_id: str):
return {"running": False} return {"running": False}
class TaskRun(BaseModel):
allow_write: bool = False
@app.post("/api/tasks/{task_id}/run") @app.post("/api/tasks/{task_id}/run")
def run_task(task_id: str, body: TaskRun | None = None): def run_task(task_id: str):
"""Launch pipeline for a task in background. Returns 202.""" """Launch pipeline for a task in background. Returns 202."""
conn = get_conn() conn = get_conn()
t = models.get_task(conn, task_id) t = models.get_task(conn, task_id)
@ -317,8 +350,7 @@ def run_task(task_id: str, body: TaskRun | None = None):
kin_root = Path(__file__).parent.parent kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id] "run", task_id]
if body and body.allow_write: cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
cmd.append("--allow-write")
import os import os
env = os.environ.copy() env = os.environ.copy()
@ -383,6 +415,18 @@ def create_decision(body: DecisionCreate):
return d return d
@app.delete("/api/projects/{project_id}/decisions/{decision_id}")
def delete_decision(project_id: str, decision_id: int):
conn = get_conn()
decision = models.get_decision(conn, decision_id)
if not decision or decision["project_id"] != project_id:
conn.close()
raise HTTPException(404, f"Decision #{decision_id} not found")
models.delete_decision(conn, decision_id)
conn.close()
return {"deleted": decision_id}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cost # Cost
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.30", "vue": "^3.5.30",
@ -15,12 +17,15 @@
"devDependencies": { "devDependencies": {
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.0", "@vue/tsconfig": "^0.9.0",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"jsdom": "^29.0.0",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.0", "vite": "^8.0.0",
"vitest": "^4.1.0",
"vue-tsc": "^3.2.5" "vue-tsc": "^3.2.5"
} }
} }

View file

@ -0,0 +1,511 @@
/**
* KIN-011/KIN-014: Тесты фильтра статусов при навигации
*
* Проверяет:
* 1. Клик по кнопке статуса обновляет URL (?status=...)
* 2. Прямая ссылка с query param активирует нужную кнопку
* 3. Фильтр показывает только задачи с нужным статусом
* 4. Сброс фильтра () удаляет param из URL
* 5. Без фильтра отображаются все задачи
* 6. goBack() вызывает router.back() при наличии истории
* 7. goBack() делает push на /project/:id без истории
* 8. После router.back() URL проекта восстанавливается с фильтром
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ProjectView from '../views/ProjectView.vue'
import TaskDetail from '../views/TaskDetail.vue'
// Мок api — factory без ссылок на внешние переменные (vi.mock хоистится)
vi.mock('../api', () => ({
api: {
project: vi.fn(),
taskFull: vi.fn(),
runTask: vi.fn(),
auditProject: vi.fn(),
createTask: vi.fn(),
patchTask: vi.fn(),
},
}))
// Импортируем мок после объявления vi.mock
import { api } from '../api'
const Stub = { template: '<div />' }
const MOCK_PROJECT = {
id: 'KIN',
name: 'Kin',
path: '/projects/kin',
status: 'active',
priority: 5,
tech_stack: ['python', 'vue'],
created_at: '2024-01-01',
total_tasks: 3,
done_tasks: 1,
active_tasks: 1,
blocked_tasks: 0,
review_tasks: 0,
tasks: [
{
id: 'KIN-001', project_id: 'KIN', title: 'Task 1', status: 'pending',
priority: 5, assigned_role: null, parent_task_id: null,
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
},
{
id: 'KIN-002', project_id: 'KIN', title: 'Task 2', status: 'in_progress',
priority: 3, assigned_role: null, parent_task_id: null,
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
},
{
id: 'KIN-003', project_id: 'KIN', title: 'Task 3', status: 'done',
priority: 1, assigned_role: null, parent_task_id: null,
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
},
],
decisions: [],
modules: [],
}
const MOCK_TASK_FULL = {
id: 'KIN-002',
project_id: 'KIN',
title: 'Task 2',
status: 'in_progress',
priority: 3,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
pipeline_steps: [],
related_decisions: [],
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Stub },
{ path: '/project/:id', component: ProjectView, props: true },
{ path: '/task/:id', component: TaskDetail, props: true },
],
})
}
// localStorage mock для jsdom-окружения
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = v },
removeItem: (k: string) => { delete store[k] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
beforeEach(() => {
localStorageMock.clear()
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
vi.mocked(api.taskFull).mockResolvedValue(MOCK_TASK_FULL as any)
})
// ─────────────────────────────────────────────────────────────
// ProjectView: фильтр ↔ URL
// ─────────────────────────────────────────────────────────────
describe('KIN-011/KIN-014: ProjectView — фильтр и URL', () => {
it('1. Клик по кнопке статуса обновляет URL (?status=...)', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Изначально status нет в URL
expect(router.currentRoute.value.query.status).toBeUndefined()
// Кликаем по кнопке in_progress
const btn = wrapper.find('[data-status="in_progress"]')
await btn.trigger('click')
await flushPromises()
// URL должен содержать ?status=in_progress
expect(router.currentRoute.value.query.status).toBe('in_progress')
})
it('2. Прямая ссылка ?status=in_progress активирует нужную кнопку', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=in_progress')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Кнопка in_progress должна быть активна (иметь класс text-blue-300)
const btn = wrapper.find('[data-status="in_progress"]')
expect(btn.classes()).toContain('text-blue-300')
// Другие кнопки не активны
const pendingBtn = wrapper.find('[data-status="pending"]')
expect(pendingBtn.classes()).not.toContain('text-blue-300')
})
it('3. Прямая ссылка ?status=in_progress показывает только задачи с этим статусом', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=in_progress')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Должна быть видна только KIN-002 (in_progress)
const links = wrapper.findAll('a[href^="/task/"]')
expect(links).toHaveLength(1)
expect(links[0].text()).toContain('KIN-002')
})
it('4. Сброс фильтра (кнопка ✕) удаляет status из URL', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=done')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Кликаем кнопку сброса
const clearBtn = wrapper.find('[data-action="clear-status"]')
await clearBtn.trigger('click')
await flushPromises()
// status должен исчезнуть из URL
expect(router.currentRoute.value.query.status).toBeUndefined()
})
it('5. Без фильтра отображаются все 3 задачи', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
const links = wrapper.findAll('a[href^="/task/"]')
expect(links).toHaveLength(3)
})
it('KIN-014: Выбор нескольких статусов — URL содержит оба через запятую', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-status="pending"]').trigger('click')
await wrapper.find('[data-status="in_progress"]').trigger('click')
await flushPromises()
const status = router.currentRoute.value.query.status as string
expect(status.split(',').sort()).toEqual(['in_progress', 'pending'])
})
it('KIN-014: Фильтр сохраняется в localStorage', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-status="pending"]').trigger('click')
await flushPromises()
const stored = JSON.parse(localStorageMock.getItem('kin-task-statuses-KIN') ?? '[]')
expect(stored).toContain('pending')
})
})
// ─────────────────────────────────────────────────────────────
// KIN-046: кнопки фильтра и сигнатура runTask
// ─────────────────────────────────────────────────────────────
describe('KIN-046: ProjectView — фильтр статусов и runTask', () => {
it('Все 7 кнопок фильтра статусов отображаются в DOM', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
for (const s of ALL_TASK_STATUSES) {
expect(wrapper.find(`[data-status="${s}"]`).exists(), `кнопка "${s}" должна быть в DOM`).toBe(true)
}
})
it('api.runTask вызывается только с taskId — без второго аргумента', async () => {
vi.mocked(api.runTask).mockResolvedValue({ status: 'ok' } 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()
// KIN-001 имеет статус pending — кнопка "Run pipeline" должна быть видна
const runBtn = wrapper.find('button[title="Run pipeline"]')
expect(runBtn.exists()).toBe(true)
await runBtn.trigger('click')
await flushPromises()
expect(api.runTask).toHaveBeenCalledTimes(1)
// Проверяем: вызван только с taskId, второй аргумент (autoMode) отсутствует
const callArgs = vi.mocked(api.runTask).mock.calls[0]
expect(callArgs).toHaveLength(1)
expect(callArgs[0]).toBe('KIN-001')
})
})
// ─────────────────────────────────────────────────────────────
// TaskDetail: goBack сохраняет URL проекта с фильтром
// ─────────────────────────────────────────────────────────────
describe('KIN-011: TaskDetail — возврат с сохранением URL', () => {
it('6 (KIN-011). goBack() вызывает router.back() когда window.history.length > 1', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=in_progress')
await router.push('/task/KIN-002')
const backSpy = vi.spyOn(router, 'back')
// Эмулируем наличие истории
Object.defineProperty(window, 'history', {
value: { ...window.history, length: 3 },
configurable: true,
})
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-002' },
global: { plugins: [router] },
})
await flushPromises()
// Первая кнопка — кнопка "назад" (← KIN)
const backBtn = wrapper.find('button')
await backBtn.trigger('click')
expect(backSpy).toHaveBeenCalled()
})
it('7. goBack() без истории делает push на /project/:id', async () => {
const router = makeRouter()
await router.push('/task/KIN-002')
const pushSpy = vi.spyOn(router, 'push')
// Эмулируем отсутствие истории
Object.defineProperty(window, 'history', {
value: { ...window.history, length: 1 },
configurable: true,
})
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-002' },
global: { plugins: [router] },
})
await flushPromises()
const backBtn = wrapper.find('button')
await backBtn.trigger('click')
expect(pushSpy).toHaveBeenCalledWith({ path: '/project/KIN', query: undefined })
})
it('8. После router.back() URL проекта восстанавливается с query param ?status', async () => {
const router = makeRouter()
// Навигация: проект с фильтром → задача
await router.push('/project/KIN?status=in_progress')
await router.push('/task/KIN-002')
expect(router.currentRoute.value.path).toBe('/task/KIN-002')
// Возвращаемся назад
router.back()
await flushPromises()
// URL должен вернуться к /project/KIN?status=in_progress
expect(router.currentRoute.value.path).toBe('/project/KIN')
expect(router.currentRoute.value.query.status).toBe('in_progress')
})
})
// ─────────────────────────────────────────────────────────────
// KIN-047: TaskDetail — кнопки Approve/Reject в статусе review
// ─────────────────────────────────────────────────────────────
describe('KIN-047: TaskDetail — Approve/Reject в статусе review', () => {
function makeTaskWith(status: string, executionMode: 'auto' | 'review' | null = null) {
return {
id: 'KIN-047',
project_id: 'KIN',
title: 'Review Task',
status,
priority: 3,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
execution_mode: executionMode,
created_at: '2024-01-01',
updated_at: '2024-01-01',
pipeline_steps: [],
related_decisions: [],
}
}
it('Approve и Reject видны при статусе review и ручном режиме', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'review') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть видна в review + ручной режим').toBe(true)
expect(rejectExists, 'Reject должна быть видна в review + ручной режим').toBe(true)
})
it('Approve и Reject скрыты при autoMode в статусе review', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'auto') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть скрыта в autoMode').toBe(false)
expect(rejectExists, 'Reject должна быть скрыта в autoMode').toBe(false)
})
it('Тоггл Auto/Review виден в статусе review при autoMode (позволяет выйти из автопилота)', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', 'auto') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const toggleExists = buttons.some(b => b.text().includes('Auto') || b.text().includes('Review'))
expect(toggleExists, 'Тоггл Auto/Review должен быть виден в статусе review').toBe(true)
})
it('После клика тоггла в review+autoMode появляются Approve и Reject', async () => {
const task = makeTaskWith('review', 'auto')
vi.mocked(api.taskFull).mockResolvedValue(task as any)
vi.mocked(api.patchTask).mockResolvedValue({ execution_mode: 'review' } as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
// Находим тоггл-кнопку (текст "Auto" когда autoMode=true)
const toggleBtn = wrapper.findAll('button').find(b => b.text().includes('Auto'))
expect(toggleBtn?.exists(), 'Тоггл должен быть виден').toBe(true)
await toggleBtn!.trigger('click')
await flushPromises()
// После переключения autoMode=false → Approve и Reject должны появиться
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна появиться после отключения autoMode').toBe(true)
expect(rejectExists, 'Reject должна появиться после отключения autoMode').toBe(true)
})
it('KIN-051: Approve и Reject видны при статусе review и execution_mode=null (фикс баги)', async () => {
// Воспроизводит баг: задача в review без явного execution_mode зависала
// без кнопок, потому что localStorage мог содержать 'auto'
localStorageMock.setItem('kin-mode-KIN', 'auto') // имитируем "плохой" localStorage
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith('review', null) as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const buttons = wrapper.findAll('button')
const approveExists = buttons.some(b => b.text().includes('Approve'))
const rejectExists = buttons.some(b => b.text().includes('Reject'))
expect(approveExists, 'Approve должна быть видна: review+null mode игнорирует localStorage').toBe(true)
expect(rejectExists, 'Reject должна быть видна: review+null mode игнорирует localStorage').toBe(true)
})
it('Approve скрыта для статусов pending и done', async () => {
for (const status of ['pending', 'done']) {
vi.mocked(api.taskFull).mockResolvedValue(makeTaskWith(status, 'review') as any)
const router = makeRouter()
await router.push('/task/KIN-047')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-047' },
global: { plugins: [router] },
})
await flushPromises()
const approveExists = wrapper.findAll('button').some(b => b.text().includes('Approve'))
expect(approveExists, `Approve не должна быть видна для статуса "${status}"`).toBe(false)
}
})
})

View file

@ -26,6 +26,12 @@ async function post<T>(path: string, body: unknown): Promise<T> {
return res.json() return res.json()
} }
async function del<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json()
}
export interface Project { export interface Project {
id: string id: string
name: string name: string
@ -33,6 +39,7 @@ export interface Project {
status: string status: string
priority: number priority: number
tech_stack: string[] | null tech_stack: string[] | null
execution_mode: string | null
created_at: string created_at: string
total_tasks: number total_tasks: number
done_tasks: number done_tasks: number
@ -57,6 +64,8 @@ export interface Task {
parent_task_id: string | null parent_task_id: string | null
brief: Record<string, unknown> | null brief: Record<string, unknown> | null
spec: Record<string, unknown> | null spec: Record<string, unknown> | null
execution_mode: string | null
blocked_reason: string | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
@ -150,14 +159,18 @@ export const api = {
post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }), post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }),
rejectTask: (id: string, reason: string) => rejectTask: (id: string, reason: string) =>
post<{ status: string }>(`/tasks/${id}/reject`, { reason }), post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
runTask: (id: string, allowWrite = false) => runTask: (id: string) =>
post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }), post<{ status: string }>(`/tasks/${id}/run`, {}),
bootstrap: (data: { path: string; id: string; name: string }) => bootstrap: (data: { path: string; id: string; name: string }) =>
post<{ project: Project }>('/bootstrap', data), post<{ project: Project }>('/bootstrap', data),
auditProject: (projectId: string) => auditProject: (projectId: string) =>
post<AuditResult>(`/projects/${projectId}/audit`, {}), post<AuditResult>(`/projects/${projectId}/audit`, {}),
auditApply: (projectId: string, taskIds: string[]) => auditApply: (projectId: string, taskIds: string[]) =>
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }), post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
patchTask: (id: string, data: { status: string }) => patchTask: (id: string, data: { status?: string; execution_mode?: string }) =>
patch<Task>(`/tasks/${id}`, data), patch<Task>(`/tasks/${id}`, data),
patchProject: (id: string, data: { execution_mode: string }) =>
patch<Project>(`/projects/${id}`, data),
deleteDecision: (projectId: string, decisionId: number) =>
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
} }

View file

@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type ProjectDetail, type AuditResult } from '../api' import { api, type ProjectDetail, type AuditResult } from '../api'
import Badge from '../components/Badge.vue' import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue' import Modal from '../components/Modal.vue'
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const project = ref<ProjectDetail | null>(null) const project = ref<ProjectDetail | null>(null)
const loading = ref(true) const loading = ref(true)
@ -12,7 +15,28 @@ const error = ref('')
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks') const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
// Filters // Filters
const taskStatusFilter = ref('') const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
function initStatusFilter(): string[] {
const q = route.query.status as string
if (q) return q.split(',').filter((s: string) => s)
const stored = localStorage.getItem(`kin-task-statuses-${props.id}`)
if (stored) { try { return JSON.parse(stored) } catch {} }
return []
}
const selectedStatuses = ref<string[]>(initStatusFilter())
function toggleStatus(s: string) {
const idx = selectedStatuses.value.indexOf(s)
if (idx >= 0) selectedStatuses.value.splice(idx, 1)
else selectedStatuses.value.push(s)
}
function clearStatusFilter() {
selectedStatuses.value = []
}
const decisionTypeFilter = ref('') const decisionTypeFilter = ref('')
const decisionSearch = ref('') const decisionSearch = ref('')
@ -20,12 +44,22 @@ const decisionSearch = ref('')
const autoMode = ref(false) const autoMode = ref(false)
function loadMode() { function loadMode() {
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto' if (project.value?.execution_mode) {
autoMode.value = project.value.execution_mode === 'auto'
} else {
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
}
} }
function toggleMode() { async function toggleMode() {
autoMode.value = !autoMode.value autoMode.value = !autoMode.value
localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review') localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review')
try {
await api.patchProject(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
if (project.value) project.value = { ...project.value, execution_mode: autoMode.value ? 'auto' : 'review' }
} catch (e: any) {
error.value = e.message
}
} }
// Audit // Audit
@ -85,12 +119,17 @@ async function load() {
} }
} }
watch(selectedStatuses, (val) => {
localStorage.setItem(`kin-task-statuses-${props.id}`, JSON.stringify(val))
router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } })
}, { deep: true })
onMounted(() => { load(); loadMode() }) onMounted(() => { load(); loadMode() })
const filteredTasks = computed(() => { const filteredTasks = computed(() => {
if (!project.value) return [] if (!project.value) return []
let tasks = project.value.tasks let tasks = project.value.tasks
if (taskStatusFilter.value) tasks = tasks.filter(t => t.status === taskStatusFilter.value) if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
return tasks return tasks
}) })
@ -128,12 +167,6 @@ function modTypeColor(t: string) {
return m[t] || 'gray' return m[t] || 'gray'
} }
const taskStatuses = computed(() => {
if (!project.value) return []
const s = new Set(project.value.tasks.map(t => t.status))
return Array.from(s).sort()
})
const decTypes = computed(() => { const decTypes = computed(() => {
if (!project.value) return [] if (!project.value) return []
const s = new Set(project.value.decisions.map(d => d.type)) const s = new Set(project.value.decisions.map(d => d.type))
@ -162,7 +195,7 @@ async function runTask(taskId: string, event: Event) {
event.stopPropagation() event.stopPropagation()
if (!confirm(`Run pipeline for ${taskId}?`)) return if (!confirm(`Run pipeline for ${taskId}?`)) return
try { try {
await api.runTask(taskId, autoMode.value) await api.runTask(taskId)
await load() await load()
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
@ -236,12 +269,17 @@ async function addDecision() {
<!-- Tasks Tab --> <!-- Tasks Tab -->
<div v-if="activeTab === 'tasks'"> <div v-if="activeTab === 'tasks'">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex gap-2"> <div class="flex gap-1 flex-wrap items-center">
<select v-model="taskStatusFilter" <button v-for="s in ALL_TASK_STATUSES" :key="s"
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300"> :data-status="s"
<option value="">All statuses</option> @click="toggleStatus(s)"
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option> class="px-2 py-0.5 text-xs rounded border transition-colors"
</select> :class="selectedStatuses.includes(s)
? 'bg-blue-900/40 text-blue-300 border-blue-700'
: 'bg-gray-900 text-gray-600 border-gray-800 hover:text-gray-400 hover:border-gray-700'"
>{{ s }}</button>
<button v-if="selectedStatuses.length" data-action="clear-status" @click="clearStatusFilter"
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="toggleMode" <button @click="toggleMode"
@ -267,12 +305,15 @@ async function addDecision() {
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div> <div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<router-link v-for="t in filteredTasks" :key="t.id" <router-link v-for="t in filteredTasks" :key="t.id"
:to="`/task/${t.id}`" :to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors"> class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span> <span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
<Badge :text="t.status" :color="taskStatusColor(t.status)" /> <Badge :text="t.status" :color="taskStatusColor(t.status)" />
<span class="text-gray-300 truncate">{{ t.title }}</span> <span class="text-gray-300 truncate">{{ t.title }}</span>
<span v-if="t.execution_mode === 'auto'"
class="text-[10px] px-1 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded shrink-0"
title="Auto mode">&#x1F513;</span>
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">from {{ t.parent_task_id }}</span> <span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">from {{ t.parent_task_id }}</span>
</div> </div>
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0"> <div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">

View file

@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api' import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
import Badge from '../components/Badge.vue' import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue' import Modal from '../components/Modal.vue'
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const task = ref<TaskFull | null>(null) const task = ref<TaskFull | null>(null)
const loading = ref(true) const loading = ref(true)
@ -25,17 +28,30 @@ const resolvingAction = ref(false)
const showReject = ref(false) const showReject = ref(false)
const rejectReason = ref('') const rejectReason = ref('')
// Auto/Review mode (persisted per project) // Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
const autoMode = ref(false) const autoMode = ref(false)
function loadMode(projectId: string) { function loadMode(t: typeof task.value) {
autoMode.value = localStorage.getItem(`kin-mode-${projectId}`) === 'auto' if (!t) return
if (t.execution_mode) {
autoMode.value = t.execution_mode === 'auto'
} else if (t.status === 'review') {
// Task is in review always show Approve/Reject regardless of localStorage
autoMode.value = false
} else {
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto'
}
} }
function toggleMode() { async function toggleMode() {
if (!task.value) return
autoMode.value = !autoMode.value autoMode.value = !autoMode.value
if (task.value) { localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review')
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review') try {
const updated = await api.patchTask(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
task.value = { ...task.value, ...updated }
} catch (e: any) {
error.value = e.message
} }
} }
@ -43,7 +59,7 @@ async function load() {
try { try {
const prev = task.value const prev = task.value
task.value = await api.taskFull(props.id) task.value = await api.taskFull(props.id)
if (task.value?.project_id) loadMode(task.value.project_id) loadMode(task.value)
// Auto-start polling if task is in_progress // Auto-start polling if task is in_progress
if (task.value.status === 'in_progress' && !polling.value) { if (task.value.status === 'in_progress' && !polling.value) {
startPolling() startPolling()
@ -175,7 +191,7 @@ async function reject() {
async function runPipeline() { async function runPipeline() {
try { try {
await api.runTask(props.id, autoMode.value) await api.runTask(props.id)
startPolling() startPolling()
await load() await load()
} catch (e: any) { } catch (e: any) {
@ -186,6 +202,18 @@ async function runPipeline() {
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0) const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
const isRunning = computed(() => task.value?.status === 'in_progress') const isRunning = computed(() => task.value?.status === 'in_progress')
function goBack() {
if (window.history.length > 1) {
router.back()
} else if (task.value) {
const backStatus = route.query.back_status as string | undefined
router.push({
path: `/project/${task.value.project_id}`,
query: backStatus ? { status: backStatus } : undefined,
})
}
}
const statusChanging = ref(false) const statusChanging = ref(false)
async function changeStatus(newStatus: string) { async function changeStatus(newStatus: string) {
@ -209,14 +237,17 @@ async function changeStatus(newStatus: string) {
<!-- Header --> <!-- Header -->
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<router-link :to="`/project/${task.project_id}`" class="text-gray-600 hover:text-gray-400 text-sm no-underline"> <button @click="goBack" class="text-gray-600 hover:text-gray-400 text-sm cursor-pointer bg-transparent border-none p-0">
&larr; {{ task.project_id }} &larr; {{ task.project_id }}
</router-link> </button>
</div> </div>
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1> <h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
<span class="text-gray-400">{{ task.title }}</span> <span class="text-gray-400">{{ task.title }}</span>
<Badge :text="task.status" :color="statusColor(task.status)" /> <Badge :text="task.status" :color="statusColor(task.status)" />
<span v-if="task.execution_mode === 'auto'"
class="text-[10px] px-1.5 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded"
title="Auto mode: agents can write files">&#x1F513; auto</span>
<select <select
:value="task.status" :value="task.status"
@change="changeStatus(($event.target as HTMLSelectElement).value)" @change="changeStatus(($event.target as HTMLSelectElement).value)"
@ -236,6 +267,9 @@ async function changeStatus(newStatus: string) {
<div v-if="task.brief" class="text-xs text-gray-500 mb-1"> <div v-if="task.brief" class="text-xs text-gray-500 mb-1">
Brief: {{ JSON.stringify(task.brief) }} Brief: {{ JSON.stringify(task.brief) }}
</div> </div>
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
Blocked: {{ task.blocked_reason }}
</div>
<div v-if="task.assigned_role" class="text-xs text-gray-500"> <div v-if="task.assigned_role" class="text-xs text-gray-500">
Assigned: {{ task.assigned_role }} Assigned: {{ task.assigned_role }}
</div> </div>
@ -303,17 +337,22 @@ async function changeStatus(newStatus: string) {
<!-- Actions Bar --> <!-- Actions Bar -->
<div class="sticky bottom-0 bg-gray-950 border-t border-gray-800 py-3 flex gap-3 -mx-6 px-6 mt-8"> <div class="sticky bottom-0 bg-gray-950 border-t border-gray-800 py-3 flex gap-3 -mx-6 px-6 mt-8">
<button v-if="task.status === 'review'" <div v-if="autoMode && (isRunning || task.status === 'review')"
class="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-900/20 border border-yellow-800/50 rounded text-xs text-yellow-400">
<span class="inline-block w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></span>
Автопилот активен
</div>
<button v-if="task.status === 'review' && !autoMode"
@click="showApprove = true" @click="showApprove = true"
class="px-4 py-2 text-sm bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900"> class="px-4 py-2 text-sm bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
&#10003; Approve &#10003; Approve
</button> </button>
<button v-if="task.status === 'review' || task.status === 'in_progress'" <button v-if="(task.status === 'review' || task.status === 'in_progress') && !autoMode"
@click="showReject = true" @click="showReject = true"
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900"> class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
&#10007; Reject &#10007; Reject
</button> </button>
<button v-if="task.status === 'pending' || task.status === 'blocked'" <button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
@click="toggleMode" @click="toggleMode"
class="px-3 py-2 text-sm border rounded transition-colors" class="px-3 py-2 text-sm border rounded transition-colors"
:class="autoMode :class="autoMode

View file

@ -1,7 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
},
}) })