Compare commits

...

3 commits

Author SHA1 Message Date
Gros Frumos
d42ee4246d kin: auto-commit after pipeline 2026-03-21 12:17:25 +02:00
Gros Frumos
f1935d2af2 Merge branch 'KIN-FIX-025-backend_dev' 2026-03-21 11:59:22 +02:00
Gros Frumos
544175e9eb kin: KIN-FIX-025-backend_dev 2026-03-21 11:59:22 +02:00
5 changed files with 289 additions and 138 deletions

View file

@ -1280,6 +1280,39 @@ Real-time через SSE (Server-Sent Events) — runner пишет лог → A
---
## ЧАСТЬ 5: Критические ограничения реализации
### 5.1 Запрет блокирующего time.sleep в pipeline execution thread
**Корень бага KIN-145.** Pipeline агентов выполняется в отдельном потоке (ThreadPoolExecutor или threading.Thread). Этот поток является единственным для всего пайплайна задачи. Любой блокирующий вызов внутри него блокирует весь runner-процесс на время сна.
**Механизм каскадного падения:**
1. `runner.py` вызывает `merge_worktree(worktree_path, p_path, max_retries=3, retry_delay_s=15)` (старый баг)
2. При git-конфликте `merge_worktree` вызывает `time.sleep(15)` трижды → 45 секунд блокировки
3. Родительский web-сервер (FastAPI) теряет ответ от воркера
4. `_check_parent_alive()` во всех других пайплайнах видит `ESRCH` (процесс недоступен) или таймаут
5. Все in-flight пайплайны помечаются как `failed` → массовое падение всех задач во всех проектах
**Почему retry для git-конфликтов бессмысленен:**
Git merge conflict — детерминированная ошибка. Конфликт возник потому что два ветки расходятся в одном месте файла. Повторная попытка через 15 секунд не изменит содержимое файлов и снова завершится конфликтом. Retry тут не помогает, только блокирует.
**Допустимые альтернативы в зависимости от контекста:**
- `async`-задержка (`asyncio.sleep`) — если код работает в async-окружении
- отдельный поток (`threading.Thread`) — если нужен polling или retry с задержкой
- отказ от retry — для детерминированных ошибок (git merge conflict, validation error)
- `Event.wait(timeout)` — если нужно ждать внешнего события без CPU spin
**Правило:**
> `time.sleep()` в `agents/` ЗАПРЕЩЁН без исключений.
> В `core/` разрешён только в файлах с явным daemon-thread или retry-контекстом:
> — `core/watchdog.py` (daemon-поток, отдельный от pipeline)
> — `core/worktree.py` (retry-delay, вызывается только с явными kwargs, не из runner.py)
**Static regression guard:** `tests/test_kin_fix_025_regression.py` сканирует `agents/` и `core/`
на наличие `time.sleep` — любой новый вызов вне allowlist ломает тест.
---
## Заметки
**Архитектура:** Изоляция контекста через процессы. Decisions = внешняя память PM. PM тупой/памятливый, workers умные/забывчивые. context-builder фильтрует по роли. ~22 роли = полная софтверная компания.

View file

@ -51,6 +51,25 @@ def _check_dead_pipelines(db_path: Path) -> None:
except Exception as upd_exc:
_logger.error("Watchdog: failed to update pipeline/task: %s", upd_exc)
# else: PermissionError (EACCES) — process exists but we can't signal it, skip
# Cleanup stale pipelines with no PID (zombie entries)
try:
stale = conn.execute(
"SELECT id, task_id FROM pipelines WHERE status = 'running' AND pid IS NULL "
"AND created_at < datetime('now', '-2 hours')"
).fetchall()
for row in stale:
_logger.warning(
"Watchdog: stale pipeline %s (no PID) — marking failed (%s)",
row["id"], row["task_id"],
)
models.update_pipeline(conn, row["id"], status="failed")
models.update_task(
conn, row["task_id"], status="blocked",
blocked_reason="Stale pipeline (no PID recorded)",
)
except Exception as stale_exc:
_logger.error("Watchdog: stale cleanup failed: %s", stale_exc)
except Exception as exc:
_logger.error("Watchdog pass failed: %s", exc)
finally:

View file

@ -0,0 +1,143 @@
"""Regression tests for KIN-FIX-025 — запрет time.sleep в pipeline execution thread.
Корень бага KIN-145: blocking sleep в pipeline-треде останавливает весь runner-процесс.
При краше родителя через _check_parent_alive роняет все in-flight pipeline.
Правило:
- agents/ time.sleep ЗАПРЕЩЁН без исключений
- core/ time.sleep разрешён только в явно задокументированных файлах (ALLOWLIST)
ALLOWLIST (core-файлы с законным time.sleep):
- core/watchdog.py daemon-поток, работает независимо от pipeline, sleep-цикл штатный
- core/worktree.py retry-delay, вызывается только с explicit max_retries kwargs,
runner.py НЕ передаёт эти kwargs (guard: test_KIN-145_regression.py)
Тест сканирует все .py файлы в core/ и agents/, исключая test_*.py и *.pyc.
Падает при нахождении time.sleep вне allowlist, выводя путь и строку нарушения.
"""
import re
from pathlib import Path
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Конфигурация
# ─────────────────────────────────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).parent.parent
# Файлы core/, в которых time.sleep допустим (с задокументированной причиной)
CORE_SLEEP_ALLOWLIST: set[str] = {
"core/watchdog.py", # daemon-поток: sleep-цикл мониторинга, не pipeline
"core/worktree.py", # retry-delay: вызывается с explicit kwargs, НЕ из runner.py
}
SLEEP_PATTERN = re.compile(r"\btime\.sleep\s*\(")
def _find_sleep_violations(directory: Path, allowlist: set[str]) -> list[str]:
"""Ищет time.sleep в .py файлах директории, возвращает список нарушений."""
violations = []
for py_file in sorted(directory.rglob("*.py")):
# Исключаем тест-файлы
if py_file.name.startswith("test_"):
continue
# Нормализуем путь относительно проекта для сравнения с allowlist
rel_path = py_file.relative_to(PROJECT_ROOT)
rel_str = str(rel_path).replace("\\", "/")
if rel_str in allowlist:
continue
# Сканируем строки файла
try:
lines = py_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
continue
for lineno, line in enumerate(lines, start=1):
if SLEEP_PATTERN.search(line):
violations.append(f"{rel_str}:{lineno}: {line.strip()}")
return violations
# ─────────────────────────────────────────────────────────────────────────────
# Тест 1: agents/ — time.sleep запрещён полностью
# ─────────────────────────────────────────────────────────────────────────────
class TestNoSleepInAgents:
def test_no_time_sleep_in_agents(self):
"""KIN-FIX-025: agents/ не должен содержать time.sleep.
Blocking sleep в pipeline-треде каскадное падение через _check_parent_alive.
Допустимые альтернативы: asyncio.sleep, threading.Event.wait, отдельный поток.
"""
agents_dir = PROJECT_ROOT / "agents"
violations = _find_sleep_violations(agents_dir, allowlist=set())
assert not violations, (
"НАРУШЕНИЕ KIN-FIX-025: time.sleep найден в agents/ (pipeline thread).\n"
"Blocking sleep в pipeline-треде → каскадное падение всех in-flight задач.\n"
"Используй asyncio.sleep / threading.Event.wait / отдельный поток.\n"
"Нарушения:\n" + "\n".join(f" {v}" for v in violations)
)
# ─────────────────────────────────────────────────────────────────────────────
# Тест 2: core/ — time.sleep запрещён вне allowlist
# ─────────────────────────────────────────────────────────────────────────────
class TestNoSleepInCoreOutsideAllowlist:
def test_no_time_sleep_in_core_outside_allowlist(self):
"""KIN-FIX-025: core/ не должен содержать time.sleep вне CORE_SLEEP_ALLOWLIST.
Разрешённые исключения (CORE_SLEEP_ALLOWLIST):
- core/watchdog.py daemon-поток, не pipeline
- core/worktree.py retry-delay с explicit kwargs
Любой новый time.sleep в core/ требует явного добавления в allowlist
с документированным обоснованием.
"""
core_dir = PROJECT_ROOT / "core"
violations = _find_sleep_violations(core_dir, allowlist=CORE_SLEEP_ALLOWLIST)
assert not violations, (
"НАРУШЕНИЕ KIN-FIX-025: time.sleep найден в core/ вне CORE_SLEEP_ALLOWLIST.\n"
"Если sleep необходим — добавь файл в CORE_SLEEP_ALLOWLIST в этом тест-файле\n"
"с задокументированным обоснованием (daemon-поток или retry с explicit kwargs).\n"
"Нарушения:\n" + "\n".join(f" {v}" for v in violations)
)
def test_allowlisted_files_still_exist(self):
"""KIN-FIX-025: файлы из CORE_SLEEP_ALLOWLIST должны существовать.
Если файл удалён убери его из allowlist, чтобы allowlist не гнил.
"""
missing = []
for rel_path in CORE_SLEEP_ALLOWLIST:
full_path = PROJECT_ROOT / rel_path
if not full_path.exists():
missing.append(rel_path)
assert not missing, (
"Файлы из CORE_SLEEP_ALLOWLIST не найдены — удали их из allowlist:\n"
+ "\n".join(f" {p}" for p in missing)
)
def test_allowlisted_files_actually_contain_sleep(self):
"""KIN-FIX-025: файлы из CORE_SLEEP_ALLOWLIST должны реально содержать time.sleep.
Если sleep убрали убери файл из allowlist, чтобы allowlist не стал dead weight.
"""
stale = []
for rel_path in CORE_SLEEP_ALLOWLIST:
full_path = PROJECT_ROOT / rel_path
if not full_path.exists():
continue # покрывается тестом выше
content = full_path.read_text(encoding="utf-8")
if not SLEEP_PATTERN.search(content):
stale.append(rel_path)
assert not stale, (
"Файлы из CORE_SLEEP_ALLOWLIST не содержат time.sleep — удали их из allowlist:\n"
+ "\n".join(f" {p}" for p in stale)
)

View file

@ -6,6 +6,7 @@ Run: uvicorn web.api:app --reload --port 8420
import glob as _glob
import logging
import mimetypes
import os
import shutil
import sqlite3
import subprocess
@ -107,16 +108,30 @@ def get_conn():
return init_db(DB_PATH)
def _launch_pipeline_subprocess(task_id: str) -> None:
"""Spawn `cli.main run {task_id}` in a detached background subprocess.
_MAX_CONCURRENT_PIPELINES = 5
Used by auto-trigger (label 'auto') and revise endpoint.
Never raises subprocess errors are logged only.
def _spawn_pipeline(task_id: str, extra_log: str = "") -> subprocess.Popen | None:
"""Spawn pipeline subprocess with process isolation and concurrency limit.
Uses start_new_session=True so pipeline survives API restarts.
Returns Popen or None on failure/limit. Never raises.
"""
import os
conn = get_conn()
count = conn.execute(
"SELECT COUNT(*) FROM pipelines WHERE status = 'running'"
).fetchone()[0]
conn.close()
if count >= _MAX_CONCURRENT_PIPELINES:
_logger.warning(
"Concurrency limit (%d/%d): skipping %s",
count, _MAX_CONCURRENT_PIPELINES, task_id,
)
return None
kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id]
cmd.append("--allow-write")
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id, "--allow-write"]
env = os.environ.copy()
if 'SSH_AUTH_SOCK' not in env:
_socks = _glob.glob('/private/tmp/com.apple.launchd.*/Listeners')
@ -125,16 +140,21 @@ def _launch_pipeline_subprocess(task_id: str) -> None:
env["KIN_NONINTERACTIVE"] = "1"
try:
proc = subprocess.Popen(
cmd,
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env,
cmd, cwd=str(kin_root),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, env=env,
start_new_session=True,
)
_logger.info("Auto-triggered pipeline for %s, pid=%d", task_id, proc.pid)
_logger.info("Pipeline spawned for %s%s, pid=%d", task_id, extra_log, proc.pid)
return proc
except Exception as exc:
_logger.warning("Failed to launch pipeline for %s: %s", task_id, exc)
_logger.warning("Failed to spawn pipeline for %s: %s", task_id, exc)
return None
def _launch_pipeline_subprocess(task_id: str) -> None:
"""Backward-compat wrapper for auto-trigger and revise."""
_spawn_pipeline(task_id)
# ---------------------------------------------------------------------------
@ -621,28 +641,9 @@ def start_project_phase(project_id: str):
models.update_task(conn, task_id, status="in_progress")
conn.close()
kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id]
cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
import os
env = os.environ.copy()
env["KIN_NONINTERACTIVE"] = "1"
try:
proc = subprocess.Popen(
cmd,
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env,
)
_logger.info("Phase agent started for task %s (phase %d), pid=%d",
task_id, active_phase["id"], proc.pid)
except Exception as e:
raise HTTPException(500, f"Failed to start phase agent: {e}")
proc = _spawn_pipeline(task_id, extra_log=f" (phase {active_phase['id']})")
if proc is None:
raise HTTPException(429, "Concurrency limit reached — try again later")
return JSONResponse(
{"status": "started", "phase_id": active_phase["id"], "task_id": task_id},
@ -1125,29 +1126,10 @@ def run_task(task_id: str):
# Set task to in_progress immediately so UI updates
models.update_task(conn, task_id, status="in_progress")
conn.close()
# Launch kin run in background subprocess
kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id]
cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
import os
env = os.environ.copy()
env["KIN_NONINTERACTIVE"] = "1"
try:
proc = subprocess.Popen(
cmd,
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env,
)
import logging
logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}")
except Exception as e:
raise HTTPException(500, f"Failed to start pipeline: {e}")
proc = _spawn_pipeline(task_id)
if proc is None:
raise HTTPException(429, "Concurrency limit reached — try again later")
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
@ -1416,23 +1398,7 @@ def _trigger_sysadmin_scan(conn, project_id: str, env: dict) -> str:
)
models.update_task(conn, task_id, status="in_progress")
kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id]
cmd.append("--allow-write")
import os as _os
env_vars = _os.environ.copy()
env_vars["KIN_NONINTERACTIVE"] = "1"
try:
subprocess.Popen(
cmd,
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env_vars,
)
except Exception as e:
_logger.warning("Failed to start sysadmin scan for %s: %s", task_id, e)
_spawn_pipeline(task_id, extra_log=" (sysadmin scan)")
return task_id
@ -1808,22 +1774,7 @@ def send_chat_message(project_id: str, body: ChatMessageIn):
)
task = t
import os as _os
env_vars = _os.environ.copy()
env_vars["KIN_NONINTERACTIVE"] = "1"
kin_root = Path(__file__).parent.parent
try:
subprocess.Popen(
[sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id, "--allow-write"],
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env_vars,
)
except Exception as e:
_logger.warning("Failed to start pipeline for chat task %s: %s", task_id, e)
_spawn_pipeline(task_id, extra_log=" (chat)")
assistant_content = f"Создал задачу {task_id}: {title}"
assistant_msg = models.add_chat_message(

View file

@ -175,8 +175,8 @@ function phaseStatusColor(s: string) {
}
// Tab groups
const PRIMARY_TABS = ['tasks', 'kanban', 'phases', 'decisions', 'modules', 'links'] as const
const MORE_TABS = ['environments', 'settings'] as const
const PRIMARY_TABS = ['tasks', 'kanban', 'decisions'] as const
const MORE_TABS = ['phases', 'modules', 'environments', 'links', 'settings'] as const
function tabLabel(tab: string): string {
const labels: Record<string, string> = {
@ -1193,16 +1193,7 @@ async function addDecision() {
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button>
</div>
<div class="flex gap-2">
<!-- Mode toggle (always visible) -->
<button
:data-mode="autoMode ? 'auto' : 'review'"
@click="toggleMode"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autoMode ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20 hover:bg-yellow-900/50' : 'text-gray-400 border-gray-700 bg-gray-800/50 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '🔓 Auto' : '🔒 Review' }}
</button>
<!-- Aux settings dropdown (Autocommit, AutoTest, Worktrees) -->
<!-- Mode dropdown (Auto, Autocommit, AutoTest, Worktrees) -->
<div class="relative">
<div v-if="showModeMenu" class="fixed inset-0 z-[5]" @click="showModeMenu = false"></div>
<button
@ -1210,8 +1201,8 @@ async function addDecision() {
:data-mode="autoMode ? 'auto' : 'review'"
@click="showModeMenu = !showModeMenu"
class="px-2 py-1 text-xs border rounded relative z-10 transition-colors"
:class="(autocommit || autoTest || worktrees) ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'">
:class="(autoMode || autocommit || autoTest || worktrees) ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'">
Mode
</button>
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 z-10 w-52 bg-gray-900 border border-gray-700 rounded shadow-lg py-1">
<button
@ -1558,38 +1549,52 @@ async function addDecision() {
class="text-gray-600 hover:text-red-400 text-xs px-1"></button>
</div>
<div class="flex gap-2">
<button @click="toggleMode"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autoMode
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '&#x1F513; Авто' : '&#x1F512; Review' }}
</button>
<button @click="toggleAutocommit"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autocommit
? 'bg-green-900/30 text-green-400 border-green-800 hover:bg-green-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'">
{{ autocommit ? '&#x2713; Автокомит' : 'Автокомит' }}
</button>
<button @click="toggleAutoTest"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autoTest
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
</button>
<button @click="toggleWorktrees"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="worktrees
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
</button>
<!-- Mode dropdown (Auto, Autocommit, AutoTest, Worktrees) -->
<div class="relative">
<div v-if="showModeMenu" class="fixed inset-0 z-[5]" @click="showModeMenu = false"></div>
<button
data-testid="mode-menu-trigger"
:data-mode="autoMode ? 'auto' : 'review'"
@click="showModeMenu = !showModeMenu"
class="px-2 py-1 text-xs border rounded relative z-10 transition-colors"
:class="(autoMode || autocommit || autoTest || worktrees) ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'">
Mode
</button>
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 z-10 w-52 bg-gray-900 border border-gray-700 rounded shadow-lg py-1">
<button
data-testid="mode-toggle-auto"
@click="toggleMode(); showModeMenu = false"
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
<span>{{ autoMode ? '🔓 Auto' : '🔒 Review' }}</span>
<span :class="autoMode ? 'text-yellow-400' : 'text-gray-600'" class="text-[10px]">{{ autoMode ? 'on' : 'off' }}</span>
</button>
<button
data-testid="mode-toggle-autocommit"
@click="toggleAutocommit"
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
:title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'">
<span>Autocommit</span>
<span :class="autocommit ? 'text-green-400' : 'text-gray-600'" class="text-[10px]">{{ autocommit ? 'on' : 'off' }}</span>
</button>
<button
data-testid="mode-toggle-autotest"
@click="toggleAutoTest"
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
<span>{{ t('projectView.auto_test_label') }}</span>
<span :class="autoTest ? 'text-blue-400' : 'text-gray-600'" class="text-[10px]">{{ autoTest ? 'on' : 'off' }}</span>
</button>
<button
data-testid="mode-toggle-worktrees"
@click="toggleWorktrees"
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
<span>{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}</span>
<span :class="worktrees ? 'text-teal-400' : 'text-gray-600'" class="text-[10px]">{{ worktrees ? 'on' : 'off' }}</span>
</button>
</div>
</div>
<button @click="runAudit" :disabled="auditLoading"
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
title="Check which pending tasks are already done">