kin: KIN-128-backend_dev

This commit is contained in:
Gros Frumos 2026-03-18 22:11:14 +02:00
parent d3bb5ef6a9
commit 11314a8c37
9 changed files with 348 additions and 4 deletions

View file

@ -986,6 +986,85 @@ def _save_decomposer_output(
return {"created": created, "skipped": skipped}
# ---------------------------------------------------------------------------
# Tech debt: create followup child task from dev agent output
# ---------------------------------------------------------------------------
# Roles whose output is parsed for tech_debt (KIN-128)
_TECH_DEBT_ROLES = {"backend_dev", "frontend_dev", "debugger", "sysadmin"}
def _save_tech_debt_output(
conn: sqlite3.Connection,
project_id: str,
task_id: str,
result: dict,
) -> dict:
"""Parse dev agent JSON output for tech_debt field and create a child task.
If the agent output contains a non-empty 'tech_debt' object with a 'description',
creates one child task with title='[TECH DEBT] {description}'.
At most 1 tech_debt task per call (prevents runaway task creation).
Returns {created: bool, task_id: str | None}.
"""
raw = result.get("raw_output") or result.get("output") or ""
if isinstance(raw, (dict, list)):
raw = json.dumps(raw, ensure_ascii=False)
try:
parsed = _try_parse_json(raw)
except Exception:
return {"created": False, "task_id": None}
if not isinstance(parsed, dict):
return {"created": False, "task_id": None}
tech_debt = parsed.get("tech_debt")
if not isinstance(tech_debt, dict):
return {"created": False, "task_id": None}
description = (tech_debt.get("description") or "").strip()
if not description:
return {"created": False, "task_id": None}
reason_temporary = (tech_debt.get("reason_temporary") or "").strip()
proper_fix = (tech_debt.get("proper_fix") or "").strip()
# Idempotency: skip if a [TECH DEBT] child with same description already exists
title = f"[TECH DEBT] {description}"
existing = conn.execute(
"""SELECT id FROM tasks
WHERE parent_task_id = ? AND lower(trim(title)) = lower(trim(?))""",
(task_id, title),
).fetchone()
if existing:
return {"created": False, "task_id": existing[0]}
category = (tech_debt.get("category") or "").strip().upper()
if category not in models.TASK_CATEGORIES:
category = "FIX"
brief_text = f"Технический долг из задачи {task_id}."
if reason_temporary:
brief_text += f"\n\nПричина временного решения: {reason_temporary}"
if proper_fix:
brief_text += f"\n\nПравильный фикс: {proper_fix}"
new_task_id = models.next_task_id(conn, project_id, category=category)
models.create_task(
conn,
new_task_id,
project_id,
title,
priority=7,
brief={"text": brief_text, "source": f"tech_debt:{task_id}"},
category=category,
parent_task_id=task_id,
)
_logger.info("tech_debt: created task %s for parent %s", new_task_id, task_id)
return {"created": True, "task_id": new_task_id}
# ---------------------------------------------------------------------------
# Auto-learning: extract decisions from pipeline results
# ---------------------------------------------------------------------------
@ -1820,6 +1899,74 @@ def run_pipeline(
except Exception:
pass # Never block pipeline on decomposer save errors
# Smoke tester: parse result and escalate if cannot_confirm (KIN-128)
if role == "smoke_tester" and result["success"] and not dry_run:
smoke_output = result.get("output") or result.get("raw_output") or ""
smoke_parsed = None
try:
if isinstance(smoke_output, dict):
smoke_parsed = smoke_output
elif isinstance(smoke_output, str):
smoke_parsed = _try_parse_json(smoke_output)
except Exception:
pass
if isinstance(smoke_parsed, dict):
# Save smoke_test_result regardless of outcome
try:
models.update_task(conn, task_id, smoke_test_result=smoke_parsed)
except Exception:
pass
smoke_status = smoke_parsed.get("status", "")
if smoke_status == "cannot_confirm":
reason = smoke_parsed.get("reason") or "smoke_tester: cannot confirm — no proof of working service"
blocked_reason = f"smoke_test: cannot_confirm — {reason}"
models.update_task(
conn, task_id,
status="blocked",
blocked_reason=blocked_reason,
blocked_agent_role="smoke_tester",
blocked_pipeline_step=str(i + 1),
)
if pipeline:
models.update_pipeline(
conn, pipeline["id"],
status="failed",
total_cost_usd=total_cost,
total_tokens=total_tokens,
total_duration_seconds=total_duration,
)
try:
models.write_log(
conn, pipeline["id"],
f"Smoke test cannot_confirm: {reason}",
level="WARN",
extra={"role": "smoke_tester", "reason": reason},
)
except Exception:
pass
return {
"success": False,
"error": blocked_reason,
"blocked_by": "smoke_tester",
"blocked_reason": blocked_reason,
"steps_completed": i + 1,
"results": results,
"total_cost_usd": total_cost,
"total_tokens": total_tokens,
"total_duration_seconds": total_duration,
"pipeline_id": pipeline["id"] if pipeline else None,
}
# status == 'confirmed': smoke test passed, continue pipeline
# Tech debt: create followup child task from dev agent output (KIN-128)
if role in _TECH_DEBT_ROLES and result["success"] and not dry_run:
try:
_save_tech_debt_output(conn, project_id, task_id, result)
except Exception:
pass # Never block pipeline on tech_debt save errors
# Department head: execute sub-pipeline planned by the dept head
if _is_department_head(role) and result["success"] and not dry_run:
# Determine next department for handoff routing