mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(run-hardening): enforce one active work session per task
A task re-claimed by a different agent (pool release, reaper unclaim, escalation redirect) left the prior holder's active work session open. WorkSessionService.get_active_for_task then ran a one-row query over the duplicates and raised MultipleResultsFound; the caught failure surfaced as the cryptic "'NoneType' object has no attribute 'id'" that crashed the claim/plan/start flow — so the task could never advance, the orchestrator re-spawned its PM every ~30s forever, and its dependents stayed blocked. Fixed at three layers: - active-session lookups return the most-recent session instead of raising - claiming a task supersedes any other agent's stale active session (the single-active-per-task invariant), in both WorkSessionService.create and TaskService._create_work_session_if_needed - a partial unique index (migration 047, which de-duplicates existing rows keeping the most recent) enforces it at the DB level; mirrored on the model Verified: 1614 tests green (work_session + gateway + services), ruff/mypy clean, migration chain applies + reverses, dedup proven on the real schema.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""Enforce one ACTIVE work session per task.
|
||||
|
||||
A task is owned by exactly one agent at a time, so it must carry at most one
|
||||
ACTIVE ``work_sessions`` row. Nothing enforced this: ``create`` only blocked a
|
||||
duplicate for the *same* agent, so a re-claim by a different agent (pool
|
||||
release, reaper unclaim, escalation redirect) left the prior holder's ACTIVE
|
||||
session open. ``WorkSessionService.get_active_for_task`` then ran
|
||||
``scalar_one_or_none()`` over the duplicate rows and raised
|
||||
``MultipleResultsFound``; the caught failure left a ``None`` that the verb flow
|
||||
dereferenced as ``'NoneType' object has no attribute 'id'`` — wedging
|
||||
``i_will_plan`` into an infinite PM respawn loop.
|
||||
|
||||
This migration (1) deduplicates existing rows — keeping the most recent ACTIVE
|
||||
session per task and abandoning the rest — then (2) adds a partial unique index
|
||||
so the invariant can never be violated again. The service layer now also
|
||||
supersedes stale sessions on claim; this is the DB backstop.
|
||||
|
||||
Revision ID: 047_ws_single_active
|
||||
Revises: 046_batch_intake
|
||||
Create Date: 2026-06-24
|
||||
|
||||
NOTE: revision id is 20 chars — alembic's ``alembic_version.version_num`` is
|
||||
``VARCHAR(32)`` and a longer id raises at record time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "047_ws_single_active"
|
||||
down_revision = "046_batch_intake"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_INDEX = "uq_work_sessions_one_active_per_task"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) Deduplicate: for every task with >1 ACTIVE session keep the most recent
|
||||
# (latest started_at, id as deterministic tie-break) and abandon the rest.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_sessions ws
|
||||
SET status = 'abandoned',
|
||||
ended_at = COALESCE(ws.ended_at, now())
|
||||
WHERE ws.status = 'active'
|
||||
AND ws.id <> (
|
||||
SELECT keep.id
|
||||
FROM work_sessions keep
|
||||
WHERE keep.task_id = ws.task_id
|
||||
AND keep.status = 'active'
|
||||
ORDER BY keep.started_at DESC, keep.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
# 2) Enforce the invariant at the DB level: at most one ACTIVE row per task.
|
||||
op.create_index(
|
||||
_INDEX,
|
||||
"work_sessions",
|
||||
["task_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("status = 'active'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(_INDEX, table_name="work_sessions")
|
||||
Reference in New Issue
Block a user