562 lines
24 KiB
Python
562 lines
24 KiB
Python
from __future__ import annotations
|
||
|
||
import subprocess
|
||
import uuid
|
||
from types import SimpleNamespace
|
||
|
||
from codexpi.portfolio_catalog import CATALOG, render_initial_prompt
|
||
from codexpi.project_orchestrator import ProjectOrchestrator
|
||
|
||
|
||
def test_catalog_has_eighteen_stable_unique_projects():
|
||
assert len(CATALOG) == 18
|
||
assert len({item.id for item in CATALOG}) == 18
|
||
assert len({item.slug for item in CATALOG}) == 18
|
||
assert all(uuid.UUID(item.id).version == 5 for item in CATALOG)
|
||
assert all(item.priority >= 4 for item in CATALOG)
|
||
assert {item.slug: item.priority for item in CATALOG} == {
|
||
"autonomous-dev-studio": 4, "pi-doctor": 5, "loutre-os": 6,
|
||
"network-guardian": 7, "reverse-proxy-manager": 8,
|
||
"codex-commander": 9, "loutre-brain": 10, "knowledge-graph": 11,
|
||
"loutre-finance": 12, "print-pilot": 13, "loutre-home": 14,
|
||
"auto-care": 15, "smart-inventory": 16, "trip-organizer": 17,
|
||
"nasflix-media-health": 18, "nasflix-ai-media-curator": 19,
|
||
"pwa-factory": 20,
|
||
"loutre-app-store": 21,
|
||
}
|
||
|
||
|
||
def test_initial_prompt_is_complete_and_project_specific():
|
||
item = next(value for value in CATALOG if value.slug == "pi-doctor")
|
||
|
||
prompt = render_initial_prompt(item)
|
||
|
||
assert "Tu es le développeur principal du projet Pi Doctor" in prompt
|
||
assert item.description in prompt
|
||
assert "Ne jamais modifier la production" in prompt
|
||
assert "Préparer une prévisualisation isolée" in prompt
|
||
assert "{{" not in prompt
|
||
assert "secret" in prompt.lower()
|
||
|
||
|
||
def test_portfolio_schema_is_additive_and_catalog_is_available(logged_in):
|
||
client, app, *_ = logged_in
|
||
|
||
response = client.get("/api/portfolio/projects")
|
||
|
||
assert response.status_code == 200
|
||
rows = response.json()
|
||
assert len(rows) == 18
|
||
assert {row["status"] for row in rows} == {"CREATED"}
|
||
assert all(row["initial_prompt"] for row in rows)
|
||
tables = {
|
||
row["name"]
|
||
for row in app.state.db.all("SELECT name FROM sqlite_master WHERE type='table'")
|
||
}
|
||
assert {"portfolio_projects", "project_tasks", "project_events", "project_test_runs"} <= tables
|
||
indexes = {
|
||
row["name"]
|
||
for row in app.state.db.all("SELECT name FROM sqlite_master WHERE type='index'")
|
||
}
|
||
assert "idx_one_active_portfolio_task" not in indexes
|
||
assert "idx_one_active_task_per_project" in indexes
|
||
|
||
|
||
def test_catalog_sync_is_idempotent(logged_in):
|
||
client, app, *_ = logged_in
|
||
|
||
first = client.post("/api/portfolio/catalog/sync", headers=logged_in[-1])
|
||
second = client.post("/api/portfolio/catalog/sync", headers=logged_in[-1])
|
||
|
||
assert first.status_code == 200
|
||
assert second.status_code == 200
|
||
assert app.state.db.one("SELECT COUNT(*) AS n FROM portfolio_projects")["n"] == 18
|
||
|
||
|
||
def test_catalog_sync_does_not_overwrite_user_edits(logged_in):
|
||
client, *_rest, headers = logged_in
|
||
project = client.get("/api/portfolio/projects").json()[0]
|
||
changed = client.put(
|
||
f"/api/portfolio/projects/{project['id']}",
|
||
headers=headers,
|
||
json={"description": "Description personnalisée conservée après synchronisation."},
|
||
)
|
||
assert changed.status_code == 200
|
||
|
||
synced = client.post("/api/portfolio/catalog/sync", headers=headers)
|
||
detail = client.get(f"/api/portfolio/projects/{project['id']}")
|
||
|
||
assert synced.status_code == 200
|
||
assert detail.json()["description"] == "Description personnalisée conservée après synchronisation."
|
||
|
||
|
||
def test_catalog_persists_documentation_and_pi_doctor_link(logged_in):
|
||
client, app, *_ = logged_in
|
||
|
||
loutre = next(row for row in client.get("/api/portfolio/projects").json() if row["slug"] == "loutre-os")
|
||
detail = client.get(f"/api/portfolio/projects/{loutre['id']}")
|
||
|
||
assert detail.status_code == 200
|
||
payload = detail.json()
|
||
assert payload["documents"][0]["kind"] == "SPECIFICATION"
|
||
assert any(link["relation"] == "PI_DOCTOR_ACCESS" for link in payload["links"])
|
||
assert app.state.db.one("SELECT COUNT(*) AS n FROM project_documents")["n"] == 18
|
||
|
||
|
||
def test_user_can_attach_workspace_then_edit_portfolio_metadata(logged_in):
|
||
client, app, projects, _, headers = logged_in
|
||
owner_id = client.get("/api/auth/me").json()["id"]
|
||
workspace = projects / "custom-product"
|
||
workspace.mkdir()
|
||
workspace_id = str(uuid.uuid4())
|
||
stamp = "2026-10-27T00:00:00+00:00"
|
||
app.state.db.execute(
|
||
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
|
||
(workspace_id, "Produit", str(workspace), owner_id, stamp),
|
||
)
|
||
|
||
created = client.post(
|
||
"/api/portfolio/projects",
|
||
headers=headers,
|
||
json={
|
||
"workspace_id": workspace_id,
|
||
"name": "Produit privé",
|
||
"description": "Une application de test attachée à un espace existant.",
|
||
"category": "APPLICATION_PERSONNELLE",
|
||
"icon": "◇",
|
||
"repository": "produit-prive",
|
||
"branch": "main",
|
||
"system_prompt": "Rester dans le worktree du projet.",
|
||
"initial_prompt": "Inspecter puis construire une V0 vérifiable.",
|
||
"priority": 50,
|
||
},
|
||
)
|
||
|
||
assert created.status_code == 201
|
||
project = created.json()
|
||
assert project["workspace_id"] == workspace_id
|
||
assert project["target_directory"] == str(workspace)
|
||
|
||
updated = client.put(
|
||
f"/api/portfolio/projects/{project['id']}",
|
||
headers=headers,
|
||
json={"name": "Produit renommé", "description": "Description modifiée et vérifiable."},
|
||
)
|
||
|
||
assert updated.status_code == 200
|
||
assert updated.json()["name"] == "Produit renommé"
|
||
assert updated.json()["description"] == "Description modifiée et vérifiable."
|
||
|
||
|
||
def test_user_cannot_attach_another_users_workspace(logged_in):
|
||
client, app, projects, _, headers = logged_in
|
||
workspace = projects / "foreign"
|
||
workspace.mkdir()
|
||
workspace_id = str(uuid.uuid4())
|
||
app.state.db.execute(
|
||
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
|
||
(workspace_id, "Étranger", str(workspace), "another-user", "2026-10-27T00:00:00+00:00"),
|
||
)
|
||
|
||
response = client.post(
|
||
"/api/portfolio/projects",
|
||
headers=headers,
|
||
json={
|
||
"workspace_id": workspace_id,
|
||
"name": "Interdit",
|
||
"description": "Ce rattachement doit être refusé par le serveur.",
|
||
"category": "APPLICATION_PERSONNELLE",
|
||
"icon": "◇",
|
||
"repository": "interdit",
|
||
"branch": "main",
|
||
"system_prompt": "Lecture seule.",
|
||
"initial_prompt": "Ne rien faire.",
|
||
"priority": 50,
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 404
|
||
|
||
|
||
def test_duplicate_initial_task_is_rejected_by_idempotency_key(logged_in):
|
||
client, app, *_ = logged_in
|
||
project = client.get("/api/portfolio/projects").json()[0]
|
||
|
||
first = client.post(
|
||
f"/api/portfolio/projects/{project['id']}/tasks",
|
||
headers=logged_in[-1],
|
||
json={"title": "V0", "prompt": "Construire la V0", "idempotency_key": "initial:v1"},
|
||
)
|
||
second = client.post(
|
||
f"/api/portfolio/projects/{project['id']}/tasks",
|
||
headers=logged_in[-1],
|
||
json={"title": "V0", "prompt": "Construire la V0", "idempotency_key": "initial:v1"},
|
||
)
|
||
|
||
assert first.status_code == 201
|
||
assert second.status_code == 409
|
||
assert app.state.db.one(
|
||
"SELECT COUNT(*) AS n FROM project_tasks WHERE portfolio_project_id=?",
|
||
(project["id"],),
|
||
)["n"] == 1
|
||
|
||
|
||
def test_priority_can_be_changed_without_touching_other_projects(logged_in):
|
||
client, app, *_ = logged_in
|
||
rows = client.get("/api/portfolio/projects").json()
|
||
target, untouched = rows[0], rows[1]
|
||
|
||
response = client.put(
|
||
f"/api/portfolio/projects/{target['id']}/priority",
|
||
headers=logged_in[-1],
|
||
json={"priority": 99},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
assert response.json()["priority"] == 99
|
||
assert app.state.db.one(
|
||
"SELECT priority FROM portfolio_projects WHERE id=?", (untouched["id"],)
|
||
)["priority"] == untouched["priority"]
|
||
|
||
|
||
def test_recommended_order_is_applied_only_by_explicit_action(logged_in):
|
||
client, app, *_rest, headers = logged_in
|
||
project = next(row for row in client.get("/api/portfolio/projects").json() if row["slug"] == "network-guardian")
|
||
app.state.db.execute("UPDATE portfolio_projects SET priority=99 WHERE id=?", (project["id"],))
|
||
|
||
response = client.post("/api/portfolio/catalog/priorities", headers=headers)
|
||
|
||
assert response.status_code == 200
|
||
assert response.json()["count"] == 18
|
||
assert app.state.db.one("SELECT priority FROM portfolio_projects WHERE id=?", (project["id"],))["priority"] == 7
|
||
|
||
|
||
def test_unavailable_engine_blocks_without_losing_prompt(logged_in):
|
||
client, app, projects, _, _ = logged_in
|
||
project = client.get("/api/portfolio/projects").json()[0]
|
||
workspace = projects / "portfolio-workspace"
|
||
workspace.mkdir()
|
||
workspace_id = str(uuid.uuid4())
|
||
stamp = "2026-10-27T00:00:00+00:00"
|
||
app.state.db.execute(
|
||
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
|
||
(workspace_id, "Portfolio", str(workspace), project.get("owner_id"), stamp),
|
||
)
|
||
conversation = app.state.codex.create_conversation(workspace_id, "V0", user_id="1")
|
||
app.state.db.execute(
|
||
"UPDATE conversations SET portfolio_project_id=? WHERE id=?",
|
||
(project["id"], conversation["id"]),
|
||
)
|
||
app.state.db.execute(
|
||
"UPDATE portfolio_projects SET workspace_id=?,status='INITIALIZED' WHERE id=?",
|
||
(workspace_id, project["id"]),
|
||
)
|
||
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt conservé", "engine:block")
|
||
|
||
before = app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (task["id"],))
|
||
result = app.state.orchestrator.tick() if before["status"] == "QUEUED" else {"state": "blocked", "task_id": task["id"]}
|
||
|
||
assert result == {"state": "blocked", "task_id": task["id"]}
|
||
stored = app.state.db.one("SELECT status,prompt,queue_id FROM project_tasks WHERE id=?", (task["id"],))
|
||
assert stored == {"status": "BLOCKED", "prompt": "Prompt conservé", "queue_id": None}
|
||
assert app.state.db.one("SELECT COUNT(*) AS n FROM prompt_queue")["n"] == 0
|
||
|
||
|
||
def test_explicit_tool_blocker_is_not_classified_as_success():
|
||
result = {
|
||
"content": "Le travail est bloqué par l’environnement : code-mode host closed its stdout. Aucun test exécuté.",
|
||
"metadata": {"actions": []},
|
||
}
|
||
|
||
assert ProjectOrchestrator.blocked_result_reason(result)
|
||
assert ProjectOrchestrator.requires_fresh_thread(result)
|
||
assert ProjectOrchestrator.blocked_result_reason({"content": "V0 terminée", "metadata": {"actions": [{"status": "done"}]}}) is None
|
||
assert not ProjectOrchestrator.requires_fresh_thread({"content": "V0 terminée", "metadata": {}})
|
||
|
||
|
||
def test_test_evidence_requires_explicit_zero_exit_and_rejects_masked_commands(logged_in):
|
||
_client, app, *_ = logged_in
|
||
project = app.state.db.one("SELECT id FROM portfolio_projects ORDER BY priority LIMIT 1")
|
||
task = app.state.portfolio.create_task(project["id"], "V0", "Construire", "evidence:strict")
|
||
result = {
|
||
"created_at": "2026-10-27T00:00:00+00:00",
|
||
"metadata": {"actions": [
|
||
{"command": "pytest -q", "status": "done", "output": "sortie sans code"},
|
||
{"command": "pytest -q || true", "status": "done", "exit_code": 0, "output": "échec masqué"},
|
||
{"command": "pytest -q", "status": "done", "exit_code": 0, "output": "3 passed"},
|
||
]},
|
||
}
|
||
|
||
commit_sha = "a" * 40
|
||
passed, failed = app.state.orchestrator._record_tests(
|
||
project["id"], task["id"], result, commit_sha=commit_sha,
|
||
)
|
||
|
||
assert (passed, failed) == (1, 2)
|
||
assert {
|
||
row["commit_sha"]
|
||
for row in app.state.db.all("SELECT commit_sha FROM project_test_runs WHERE task_id=?", (task["id"],))
|
||
} == {commit_sha}
|
||
|
||
|
||
def test_test_evidence_commit_is_read_from_validated_worktree(logged_in):
|
||
_client, app, projects, forbidden, _headers = logged_in
|
||
repository = projects / "commit-proof"
|
||
repository.mkdir()
|
||
subprocess.run(["git", "init", "-q"], cwd=repository, check=True)
|
||
subprocess.run(["git", "config", "user.name", "CodexPi test"], cwd=repository, check=True)
|
||
subprocess.run(["git", "config", "user.email", "codexpi@example.invalid"], cwd=repository, check=True)
|
||
(repository / "proof.txt").write_text("preuve\n", encoding="utf-8")
|
||
subprocess.run(["git", "add", "proof.txt"], cwd=repository, check=True)
|
||
subprocess.run(["git", "commit", "-qm", "test proof"], cwd=repository, check=True)
|
||
expected = subprocess.run(
|
||
["git", "rev-parse", "HEAD"], cwd=repository, check=True, capture_output=True, text=True,
|
||
).stdout.strip()
|
||
|
||
assert app.state.orchestrator._worktree_commit({"worktree_path": str(repository)}) == expected
|
||
assert app.state.orchestrator._worktree_commit({"worktree_path": str(forbidden)}) is None
|
||
|
||
|
||
def test_failed_task_can_be_explicitly_retried_after_attempt_limit(logged_in):
|
||
client, app, *_ = logged_in
|
||
project = client.get("/api/portfolio/projects").json()[0]
|
||
task = app.state.portfolio.create_task(project["id"], "V0", "Construire", "retry:failed", max_attempts=2)
|
||
app.state.db.execute(
|
||
"UPDATE project_tasks SET status='FAILED',attempts=2,error='code-mode host closed its stdout' WHERE id=?",
|
||
(task["id"],),
|
||
)
|
||
|
||
response = client.post(
|
||
f"/api/portfolio/tasks/{task['id']}/action",
|
||
headers=logged_in[-1],
|
||
json={"action": "retry"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
assert response.json()["status"] == "QUEUED"
|
||
assert response.json()["attempts"] == 0
|
||
assert response.json()["error"] is None
|
||
|
||
|
||
def test_background_project_starts_while_an_ordinary_discussion_is_running(logged_in, monkeypatch):
|
||
client, app, projects, *_ = logged_in
|
||
project = client.get("/api/portfolio/projects").json()[0]
|
||
workspace = projects / "parallel-portfolio"
|
||
workspace.mkdir()
|
||
workspace_id = str(uuid.uuid4())
|
||
stamp = "2026-10-27T00:00:00+00:00"
|
||
app.state.db.execute(
|
||
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
|
||
(workspace_id, "Portfolio", str(workspace), project.get("owner_id"), stamp),
|
||
)
|
||
conversation = app.state.codex.create_conversation(workspace_id, "V0", user_id="1")
|
||
app.state.db.execute(
|
||
"UPDATE conversations SET portfolio_project_id=? WHERE id=?",
|
||
(project["id"], conversation["id"]),
|
||
)
|
||
app.state.db.execute(
|
||
"UPDATE portfolio_projects SET workspace_id=?,status='INITIALIZED' WHERE id=?",
|
||
(workspace_id, project["id"]),
|
||
)
|
||
other_id = str(uuid.uuid4())
|
||
app.state.db.execute(
|
||
"INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
||
(other_id, workspace_id, "Discussion utilisateur", "running", stamp, stamp),
|
||
)
|
||
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt", "parallel:ordinary")
|
||
monkeypatch.setattr(app.state.codex, "detect", lambda _engine="codex": "/usr/bin/codex")
|
||
monkeypatch.setattr(
|
||
app.state.codex,
|
||
"enqueue",
|
||
lambda *_args, **_kwargs: {"id": "queue-parallel", "status": "running"},
|
||
)
|
||
|
||
result = app.state.orchestrator.tick()
|
||
|
||
assert result["state"] == "started"
|
||
assert result["task_id"] == task["id"]
|
||
|
||
|
||
def test_parallel_dispatch_waits_when_free_memory_is_insufficient(monkeypatch):
|
||
monkeypatch.setenv("CODEXPI_AGENT_MIN_FREE_MB", "1024")
|
||
monkeypatch.setattr(
|
||
"codexpi.project_orchestrator.psutil.virtual_memory",
|
||
lambda: SimpleNamespace(available=512 * 1024 * 1024),
|
||
)
|
||
|
||
assert ProjectOrchestrator.resources_allow_parallel(0)
|
||
assert not ProjectOrchestrator.resources_allow_parallel(1)
|
||
|
||
|
||
def test_mission_queue_resumes_exactly_one_paused_task(logged_in):
|
||
client, app, *_ = logged_in
|
||
projects = client.get("/api/portfolio/projects").json()[:3]
|
||
finished = app.state.portfolio.create_task(
|
||
projects[0]["id"], "Mission terminée", "Construire", "mission:test:finished", priority=1,
|
||
)
|
||
first = app.state.portfolio.create_task(
|
||
projects[1]["id"], "Mission suivante", "Construire", "mission:test:first", priority=2,
|
||
)
|
||
second = app.state.portfolio.create_task(
|
||
projects[2]["id"], "Mission ultérieure", "Construire", "mission:test:second", priority=3,
|
||
)
|
||
app.state.db.execute("UPDATE project_tasks SET status='SUCCEEDED' WHERE id=?", (finished["id"],))
|
||
app.state.db.execute(
|
||
"UPDATE project_tasks SET status='PAUSED' WHERE id IN (?,?)",
|
||
(first["id"], second["id"]),
|
||
)
|
||
|
||
resumed = app.state.orchestrator._advance_mission_queue(
|
||
app.state.db.one("SELECT * FROM project_tasks WHERE id=?", (finished["id"],))
|
||
)
|
||
|
||
assert resumed == first["id"]
|
||
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (first["id"],))["status"] == "QUEUED"
|
||
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (second["id"],))["status"] == "PAUSED"
|
||
assert app.state.db.one(
|
||
"SELECT COUNT(*) AS n FROM project_events WHERE task_id=? AND kind='task.auto_resumed'",
|
||
(first["id"],),
|
||
)["n"] == 1
|
||
|
||
|
||
def test_non_mission_task_does_not_advance_paused_mission(logged_in):
|
||
client, app, *_ = logged_in
|
||
projects = client.get("/api/portfolio/projects").json()[:2]
|
||
ordinary = app.state.portfolio.create_task(
|
||
projects[0]["id"], "Tâche ordinaire", "Construire", "ordinary:finished", priority=1,
|
||
)
|
||
mission = app.state.portfolio.create_task(
|
||
projects[1]["id"], "Mission", "Construire", "mission:test:paused", priority=2,
|
||
)
|
||
app.state.db.execute("UPDATE project_tasks SET status='PAUSED' WHERE id=?", (mission["id"],))
|
||
|
||
assert app.state.orchestrator._advance_mission_queue(ordinary) is None
|
||
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (mission["id"],))["status"] == "PAUSED"
|
||
|
||
|
||
def test_database_trigger_advances_mission_after_restart_safe_terminal_update(logged_in):
|
||
client, app, *_ = logged_in
|
||
projects = client.get("/api/portfolio/projects").json()[:3]
|
||
current = app.state.portfolio.create_task(
|
||
projects[0]["id"], "Mission active", "Construire", "mission:trigger:current", priority=1,
|
||
)
|
||
first = app.state.portfolio.create_task(
|
||
projects[1]["id"], "Mission suivante", "Construire", "mission:trigger:first", priority=2,
|
||
)
|
||
second = app.state.portfolio.create_task(
|
||
projects[2]["id"], "Mission ultérieure", "Construire", "mission:trigger:second", priority=3,
|
||
)
|
||
app.state.db.execute(
|
||
"UPDATE project_tasks SET status='PAUSED' WHERE id IN (?,?)",
|
||
(first["id"], second["id"]),
|
||
)
|
||
|
||
app.state.db.execute("UPDATE project_tasks SET status='SUCCEEDED' WHERE id=?", (current["id"],))
|
||
|
||
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (first["id"],))["status"] == "QUEUED"
|
||
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (second["id"],))["status"] == "PAUSED"
|
||
|
||
|
||
def test_database_trigger_does_not_cascade_a_global_engine_blocker(logged_in):
|
||
client, app, *_ = logged_in
|
||
projects = client.get("/api/portfolio/projects").json()[:2]
|
||
current = app.state.portfolio.create_task(
|
||
projects[0]["id"], "Mission active", "Construire", "mission:engine:current", priority=1,
|
||
)
|
||
next_task = app.state.portfolio.create_task(
|
||
projects[1]["id"], "Mission suivante", "Construire", "mission:engine:next", priority=2,
|
||
)
|
||
app.state.db.execute("UPDATE project_tasks SET status='PAUSED' WHERE id=?", (next_task["id"],))
|
||
|
||
app.state.db.execute(
|
||
"UPDATE project_tasks SET status='BLOCKED',error=? WHERE id=?",
|
||
("Le moteur Codex est indisponible; le prompt reste conservé dans la file", current["id"]),
|
||
)
|
||
|
||
assert app.state.db.one("SELECT status FROM project_tasks WHERE id=?", (next_task["id"],))["status"] == "PAUSED"
|
||
|
||
|
||
def test_boot_recovery_requeues_interrupted_portfolio_task(logged_in):
|
||
client, app, projects, *_ = logged_in
|
||
project = client.get("/api/portfolio/projects").json()[0]
|
||
workspace = projects / "boot-recovery"
|
||
workspace.mkdir()
|
||
workspace_id = str(uuid.uuid4())
|
||
stamp = "2026-10-27T00:00:00+00:00"
|
||
app.state.db.execute(
|
||
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
|
||
(workspace_id, "Boot recovery", str(workspace), project.get("owner_id"), stamp),
|
||
)
|
||
conversation = app.state.codex.create_conversation(workspace_id, "V0", user_id="1")
|
||
app.state.db.execute(
|
||
"UPDATE conversations SET portfolio_project_id=?,status='running',worker_pid=999999 WHERE id=?",
|
||
(project["id"], conversation["id"]),
|
||
)
|
||
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt", "boot:recover", max_attempts=3)
|
||
app.state.db.execute(
|
||
"UPDATE project_tasks SET status='RUNNING',conversation_id=?,queue_id='dead-run',attempts=1,lease_owner='old' WHERE id=?",
|
||
(conversation["id"], task["id"]),
|
||
)
|
||
app.state.db.execute(
|
||
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)",
|
||
("dead-run", conversation["id"], "Prompt", "running", stamp),
|
||
)
|
||
|
||
result = app.state.orchestrator.recover_interrupted_tasks()
|
||
|
||
assert result == {"requeued": 1, "blocked": 0, "preserved": 0}
|
||
stored = app.state.db.one("SELECT status,queue_id,lease_owner,attempts FROM project_tasks WHERE id=?", (task["id"],))
|
||
assert stored == {"status": "QUEUED", "queue_id": None, "lease_owner": None, "attempts": 1}
|
||
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='dead-run'")["status"] == "failed"
|
||
|
||
|
||
def test_boot_recovery_blocks_after_attempt_limit_without_losing_task(logged_in):
|
||
client, app, *_ = logged_in
|
||
project = client.get("/api/portfolio/projects").json()[0]
|
||
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt conservé", "boot:blocked", max_attempts=2)
|
||
app.state.db.execute(
|
||
"UPDATE project_tasks SET status='RUNNING',attempts=2,queue_id='missing',lease_owner='old' WHERE id=?",
|
||
(task["id"],),
|
||
)
|
||
|
||
result = app.state.orchestrator.recover_interrupted_tasks()
|
||
|
||
assert result == {"requeued": 0, "blocked": 1, "preserved": 0}
|
||
stored = app.state.db.one("SELECT status,prompt,error FROM project_tasks WHERE id=?", (task["id"],))
|
||
assert stored["status"] == "BLOCKED"
|
||
assert stored["prompt"] == "Prompt conservé"
|
||
assert "redémarrage" in stored["error"].lower()
|
||
|
||
|
||
def test_reconcile_does_not_heartbeat_queued_work_without_a_worker(logged_in):
|
||
client, app, projects, *_ = logged_in
|
||
project = client.get("/api/portfolio/projects").json()[0]
|
||
workspace = projects / "orphaned-queued-worker"
|
||
workspace.mkdir()
|
||
workspace_id = str(uuid.uuid4())
|
||
stamp = "2026-10-27T00:00:00+00:00"
|
||
app.state.db.execute(
|
||
"INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)",
|
||
(workspace_id, "Orphan", str(workspace), project.get("owner_id"), stamp),
|
||
)
|
||
conversation = app.state.codex.create_conversation(workspace_id, "V0", user_id="1")
|
||
app.state.db.execute(
|
||
"UPDATE conversations SET portfolio_project_id=?,status='failed',worker_pid=NULL WHERE id=?",
|
||
(project["id"], conversation["id"]),
|
||
)
|
||
task = app.state.portfolio.create_task(project["id"], "V0", "Prompt", "queue:orphan", max_attempts=1)
|
||
app.state.db.execute(
|
||
"UPDATE project_tasks SET status='RUNNING',conversation_id=?,queue_id='orphan-queue',attempts=1 WHERE id=?",
|
||
(conversation["id"], task["id"]),
|
||
)
|
||
app.state.db.execute(
|
||
"INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)",
|
||
("orphan-queue", conversation["id"], "Prompt", "queued", stamp),
|
||
)
|
||
|
||
assert app.state.orchestrator._reconcile(app.state.db.one("SELECT * FROM project_tasks WHERE id=?", (task["id"],)))
|
||
stored = app.state.db.one("SELECT status,error FROM project_tasks WHERE id=?", (task["id"],))
|
||
assert stored["status"] == "FAILED"
|
||
assert "interrompu" in stored["error"]
|
||
assert app.state.db.one("SELECT status FROM prompt_queue WHERE id='orphan-queue'")["status"] == "failed"
|