chore(lifecycle): remove quarantined state (phantom)

State existed in the lifecycle table and the enum but no verb, route,
or service path ever set it. Removing dead state. If we need
problem-task isolation later we'll add it explicitly with a verb.
This commit is contained in:
Renn F
2026-05-03 09:52:07 +02:00
parent 5c742e6aec
commit 3cabee155e
11 changed files with 156 additions and 14 deletions
-1
View File
@@ -220,7 +220,6 @@ backlog -> pending -> claimed -> in_progress -> [blocked|paused] -> verifying
| `awaiting_ceo_approval` | Major tasks escalated for CEO final approval |
| `completed` | Terminal state - work done and merged |
| `cancelled` | Terminal state - work cancelled |
| `quarantined` | Special state for problematic tasks (can return to pending) |
### Role-Based Transitions
@@ -0,0 +1,156 @@
"""Drop the `quarantined` value from the taskstatus enum (phantom state).
Audit D15. The `quarantined` member was added to the taskstatus enum in
migration 009 alongside the lifecycle-table entry
`"quarantined": ["pending"]` in `roboco/enforcement/task_lifecycle.py`,
on the assumption that some service path would later route problematic
tasks through it. None ever did. No verb, no API route, no orchestrator
sweep, and no service method ever sets `task.status = QUARANTINED` or
reads it. It is a phantom state.
This migration removes the value from the postgres enum. PostgreSQL has
no `ALTER TYPE ... DROP VALUE` primitive, so the only safe path is to
rebuild the type:
1. Pre-flight: refuse to proceed if any row in any table currently
holds the value `quarantined`. Per the audit no row should — the
check is here so an unexpected row stops the migration loudly
instead of silently being lost during the column-cast step below.
2. Read every (table, column) that references `taskstatus`.
3. Rename `taskstatus` -> `taskstatus_old`.
4. Create the new `taskstatus` type without `quarantined`.
5. ALTER each column to use the new type with
`USING column::text::taskstatus`.
6. Drop `taskstatus_old`.
The downgrade re-adds `quarantined` as a member at the end of the enum
(this is what postgres allows directly with `ALTER TYPE ADD VALUE`).
This restores the schema-level shape so a rollback can re-create the
prior member; it does not restore any rows because none had the value.
Revision ID: 011_drop_quarantined_state
Revises: 010_audit_log_details_jsonb
Create Date: 2026-05-03
"""
from __future__ import annotations
from alembic import op
from sqlalchemy import text
revision = "011_drop_quarantined_state"
down_revision = "010_audit_log_details_jsonb"
branch_labels = None
depends_on = None
# Members of the rebuilt taskstatus type, in canonical lifecycle order.
# Matches `roboco.models.base.TaskStatus` minus `quarantined`.
_NEW_TASKSTATUS_MEMBERS: tuple[str, ...] = (
"backlog",
"pending",
"claimed",
"in_progress",
"blocked",
"paused",
"verifying",
"needs_revision",
"awaiting_qa",
"awaiting_documentation",
"awaiting_pm_review",
"awaiting_ceo_approval",
"completed",
"cancelled",
)
def upgrade() -> None:
"""Drop `quarantined` from taskstatus by rebuilding the type."""
bind = op.get_bind()
# Step 1: pre-flight. Look up every column that references taskstatus,
# capture each one's column-default expression (so we can re-apply it
# after the type swap), and refuse to proceed if any row currently
# holds `quarantined`. PG won't auto-cast a default expression across
# a type change, so the type swap below requires DROP DEFAULT first
# then re-applying the same default text against the new type.
usages = [
(row[0], row[1], row[2])
for row in bind.execute(
text(
"""
SELECT n.nspname || '.' || c.relname AS table_qualified,
a.attname,
pg_get_expr(d.adbin, d.adrelid) AS default_expr
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
JOIN pg_type t ON a.atttypid = t.oid
LEFT JOIN pg_attrdef d
ON d.adrelid = a.attrelid
AND d.adnum = a.attnum
WHERE t.typname = 'taskstatus'
AND a.attnum > 0
AND NOT a.attisdropped
AND c.relkind = 'r'
"""
)
)
]
for table_qualified, column, _default in usages:
offending = bind.execute(
text(
f"SELECT COUNT(*) FROM {table_qualified} "
f"WHERE {column}::text = 'quarantined'"
)
).scalar_one()
if offending:
raise RuntimeError(
f"Refusing to drop `quarantined`: {offending} row(s) in "
f"{table_qualified}.{column} currently hold the value. "
f"Migrate them to a real lifecycle state (e.g. `pending` "
f"or `cancelled`) before re-running this migration."
)
# Step 2: rebuild the enum without `quarantined`.
# For each column: DROP DEFAULT, ALTER TYPE with USING cast, SET DEFAULT
# back to the captured expression cast to the new type. The default
# text is re-evaluated against the new type, so a stored default of
# `'pending'::taskstatus` becomes valid again because the new
# `taskstatus` still has a `pending` member.
op.execute("ALTER TYPE taskstatus RENAME TO taskstatus_old")
members_sql = ", ".join(f"'{v}'" for v in _NEW_TASKSTATUS_MEMBERS)
op.execute(f"CREATE TYPE taskstatus AS ENUM ({members_sql})")
for table_qualified, column, default_expr in usages:
if default_expr is not None:
op.execute(
f"ALTER TABLE {table_qualified} ALTER COLUMN {column} DROP DEFAULT"
)
op.execute(
f"ALTER TABLE {table_qualified} "
f"ALTER COLUMN {column} TYPE taskstatus "
f"USING {column}::text::taskstatus"
)
if default_expr is not None:
# Strip any explicit ::taskstatus_old cast in the captured
# expression; the new column will re-cast against the
# current `taskstatus`.
cleaned = default_expr.replace("::taskstatus_old", "::taskstatus")
op.execute(
f"ALTER TABLE {table_qualified} "
f"ALTER COLUMN {column} SET DEFAULT {cleaned}"
)
op.execute("DROP TYPE taskstatus_old")
def downgrade() -> None:
"""Re-add `quarantined` as a taskstatus member.
Postgres allows adding a member to an existing enum directly, so the
downgrade is a single ALTER. This restores the schema shape but does
not (and cannot) re-introduce any row data — there was none.
"""
op.execute("ALTER TYPE taskstatus ADD VALUE IF NOT EXISTS 'quarantined'")
-3
View File
@@ -20,9 +20,6 @@
- `completed` - Work finished
- `cancelled` - Work cancelled
### Special States
- `quarantined` - Problematic task, can return to pending
### Setup State
- `backlog` - PM setup phase, not ready for work
@@ -30,7 +30,6 @@ const STATUS_COLORS: Record<TaskStatus, string> = {
[TaskStatus.AWAITING_CEO_APPROVAL]: "bg-amber-200 text-amber-800 dark:bg-amber-800 dark:text-amber-200",
[TaskStatus.COMPLETED]: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
[TaskStatus.CANCELLED]: "bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
[TaskStatus.QUARANTINED]: "bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300",
};
export function SubtasksList({ task }: SubtasksListProps) {
@@ -66,7 +66,6 @@ const statusColors: Record<TaskStatus, string> = {
[TaskStatus.AWAITING_CEO_APPROVAL]: "bg-amber-200 text-amber-800 dark:bg-amber-800 dark:text-amber-200",
[TaskStatus.COMPLETED]: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
[TaskStatus.CANCELLED]: "bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
[TaskStatus.QUARANTINED]: "bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300",
};
const statusLabels: Record<TaskStatus, string> = {
@@ -84,7 +83,6 @@ const statusLabels: Record<TaskStatus, string> = {
[TaskStatus.AWAITING_CEO_APPROVAL]: "CEO Approval",
[TaskStatus.COMPLETED]: "Completed",
[TaskStatus.CANCELLED]: "Cancelled",
[TaskStatus.QUARANTINED]: "Quarantined",
};
interface TaskHeaderProps {
@@ -40,7 +40,6 @@ const STATUS_LABELS: Record<TaskStatus, string> = {
[TaskStatus.AWAITING_CEO_APPROVAL]: "Awaiting CEO Approval",
[TaskStatus.COMPLETED]: "Completed",
[TaskStatus.CANCELLED]: "Cancelled",
[TaskStatus.QUARANTINED]: "Quarantined",
};
const TEAM_LABELS: Record<Team, string> = {
@@ -16,7 +16,6 @@ const statusColors: Record<TaskStatus, string> = {
[TaskStatus.AWAITING_CEO_APPROVAL]: "bg-amber-600",
[TaskStatus.COMPLETED]: "bg-green-500",
[TaskStatus.CANCELLED]: "bg-gray-400",
[TaskStatus.QUARANTINED]: "bg-pink-500",
};
interface TaskStatusBadgeProps {
-1
View File
@@ -17,7 +17,6 @@ export enum TaskStatus {
AWAITING_CEO_APPROVAL = "awaiting_ceo_approval",
COMPLETED = "completed",
CANCELLED = "cancelled",
QUARANTINED = "quarantined", // Special state for problematic tasks
}
export enum Team {
-2
View File
@@ -93,8 +93,6 @@ VALID_TRANSITIONS: dict[str, list[str]] = {
# Terminal states - cannot transition out
"completed": [],
"cancelled": [],
# Special state for quarantined tasks
"quarantined": ["pending"], # Can be un-quarantined back to pending
}
# =============================================================================
-1
View File
@@ -101,7 +101,6 @@ AWAITING_PM_REVIEW = "awaiting_pm_review" # Ready for PM review
AWAITING_CEO_APPROVAL = "awaiting_ceo_approval" # Major task, CEO decides
COMPLETED = "completed" # Done
CANCELLED = "cancelled" # Cancelled
QUARANTINED = "quarantined" # Problem task, can return to pending
```
### AgentRole
-1
View File
@@ -33,7 +33,6 @@ class TaskStatus(StrEnum):
AWAITING_CEO_APPROVAL = "awaiting_ceo_approval" # PMs approved, CEO decides
COMPLETED = "completed"
CANCELLED = "cancelled"
QUARANTINED = "quarantined" # Special state for problematic tasks
class BlockerResolverType(StrEnum):