fix(reconcile): retire workflow session rows whose owners are no longer live

Implement a sanctioned session lifecycle for post-restart reconciliation so
active session rows with dead or reused owner PIDs can be terminalized
without deleting history. Protect live owners, live leases, and live
client-managed sessions; record durable session_retired audit events; keep
cleanup idempotent under concurrent reconciles.

Closes #969

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-29 05:27:42 -04:00
co-authored by Claude Opus 4.8
parent 956fa15fe3
commit e8bae606cb
8 changed files with 1630 additions and 30 deletions
+226 -7
View File
@@ -27,12 +27,12 @@ import uuid
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Iterator, Sequence
from typing import Any, Iterator, Mapping, Sequence
import dependency_graph
import gitea_audit
SCHEMA_VERSION = 5
SCHEMA_VERSION = 6
# Assignable work kinds only — raw monitoring incidents are never work items.
WORK_KINDS = frozenset({"issue", "pr"})
@@ -419,6 +419,7 @@ class ControlPlaneDB:
self._migrate_incident_links_null_scope(conn)
self._migrate_lease_lifecycle_columns(conn)
self._migrate_session_ownership_columns(conn)
self._migrate_session_lifecycle_columns(conn)
self._migrate_usage_events_table(conn)
conn.execute(
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
@@ -812,6 +813,7 @@ class ControlPlaneDB:
pid: int | None = None,
status: str = "active",
controller_instance_id: str | None = None,
owner_process_started_at: str | None = None,
) -> dict[str, Any]:
"""Register/refresh a session row.
@@ -821,16 +823,22 @@ class ControlPlaneDB:
controller instance can. It is never overwritten with ``None``, so a
heartbeat from a caller that does not supply one cannot erase
ownership.
*owner_process_started_at* (#969) is the OS start time of the owner
process at registration time. When present it is retained across
heartbeats (never cleared by ``None``) so later PID-reuse checks do
not depend on a live ``ps`` probe of a long-dead process.
"""
now = _ts()
instance = (controller_instance_id or "").strip() or None
proc_start = (owner_process_started_at or "").strip() or None
with self._tx() as conn:
existing = conn.execute(
"SELECT session_id FROM sessions WHERE session_id = ?",
(session_id,),
).fetchone()
if existing:
if instance is None:
if instance is None and proc_start is None:
conn.execute(
"""
UPDATE sessions
@@ -840,7 +848,21 @@ class ControlPlaneDB:
""",
(role, profile, namespace, pid, now, status, session_id),
)
else:
elif instance is None:
conn.execute(
"""
UPDATE sessions
SET role = ?, profile = ?, namespace = ?, pid = ?,
last_heartbeat_at = ?, status = ?,
owner_process_started_at = COALESCE(?, owner_process_started_at)
WHERE session_id = ?
""",
(
role, profile, namespace, pid, now, status,
proc_start, session_id,
),
)
elif proc_start is None:
conn.execute(
"""
UPDATE sessions
@@ -854,18 +876,33 @@ class ControlPlaneDB:
instance, session_id,
),
)
else:
conn.execute(
"""
UPDATE sessions
SET role = ?, profile = ?, namespace = ?, pid = ?,
last_heartbeat_at = ?, status = ?,
controller_instance_id = ?,
owner_process_started_at = COALESCE(?, owner_process_started_at)
WHERE session_id = ?
""",
(
role, profile, namespace, pid, now, status,
instance, proc_start, session_id,
),
)
else:
conn.execute(
"""
INSERT INTO sessions(
session_id, role, profile, namespace, pid,
started_at, last_heartbeat_at, status,
controller_instance_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
controller_instance_id, owner_process_started_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
session_id, role, profile, namespace, pid, now, now,
status, instance,
status, instance, proc_start,
),
)
row = conn.execute(
@@ -881,6 +918,165 @@ class ControlPlaneDB:
(_ts(), session_id),
)
def retire_session(
self,
*,
session_id: str,
reason: str,
actor_session_id: str | None = None,
details: Mapping[str, Any] | None = None,
now: datetime | None = None,
terminal_status: str = "retired",
) -> dict[str, Any]:
"""Terminalize one session row if it is still non-terminal (#969).
CAS on non-terminal status: concurrent retirements of the same row
yield exactly one ``retired`` outcome and subsequent
``already_terminal`` outcomes. Never deletes historical rows. Writes a
durable ``session_retired`` event for audit.
"""
moment = _ts(now)
reason_s = (reason or "").strip() or "unspecified"
term = (terminal_status or "retired").strip().lower() or "retired"
terminal_set = {
"retired",
"ended",
"terminal",
"dead",
"stale",
"orphaned",
}
with self._tx() as conn:
row = conn.execute(
"SELECT * FROM sessions WHERE session_id = ?",
(session_id,),
).fetchone()
if row is None:
return {
"outcome": "missing",
"reason": reason_s,
"prior_status": None,
"new_status": None,
"details": {"session_id": session_id},
}
prior = dict(row)
prior_status = str(prior.get("status") or "").strip().lower()
if prior_status in terminal_set:
return {
"outcome": "already_terminal",
"reason": reason_s,
"prior_status": prior_status,
"new_status": prior_status,
"details": {"session_id": session_id, "idempotent": True},
}
# Optional: refuse when an active lease still names this session.
active_lease = conn.execute(
"""
SELECT lease_id, status, expires_at FROM leases
WHERE session_id = ? AND status = 'active'
LIMIT 1
""",
(session_id,),
).fetchone()
if active_lease is not None:
return {
"outcome": "blocked",
"reason": "live_lease",
"prior_status": prior_status,
"new_status": prior_status,
"details": {
"session_id": session_id,
"lease_id": active_lease["lease_id"],
"blocker": "active_lease_row",
},
}
cols = {
r[1] for r in conn.execute("PRAGMA table_info(sessions)").fetchall()
}
if "retired_at" in cols and "retire_reason" in cols:
conn.execute(
"""
UPDATE sessions
SET status = ?, retired_at = ?, retire_reason = ?
WHERE session_id = ? AND status = ?
""",
(term, moment, reason_s, session_id, prior.get("status")),
)
else:
conn.execute(
"""
UPDATE sessions
SET status = ?
WHERE session_id = ? AND status = ?
""",
(term, session_id, prior.get("status")),
)
changed = conn.execute(
"SELECT changes()"
).fetchone()[0]
if not changed:
# Lost CAS race — re-read.
refreshed = conn.execute(
"SELECT status FROM sessions WHERE session_id = ?",
(session_id,),
).fetchone()
cur = (
str(refreshed["status"]).strip().lower()
if refreshed is not None
else None
)
return {
"outcome": "already_terminal"
if cur in terminal_set
else "blocked",
"reason": reason_s,
"prior_status": prior_status,
"new_status": cur,
"details": {
"session_id": session_id,
"cas_lost": True,
},
}
detail_payload: dict[str, Any] = {
"session_id": session_id,
"prior_status": prior_status,
"new_status": term,
"reason": reason_s,
"actor_session_id": actor_session_id,
"pid": prior.get("pid"),
"role": prior.get("role"),
"profile": prior.get("profile"),
}
if isinstance(details, Mapping):
for key, value in details.items():
if key not in detail_payload:
detail_payload[key] = value
message = (
f"session {session_id} retired reason={reason_s} "
f"prior_status={prior_status}"
)
# events.work_item_id is nullable; session retirement is not work-scoped.
conn.execute(
"""
INSERT INTO events(work_item_id, event_type, message, created_at)
VALUES (NULL, 'session_retired', ?, ?)
""",
(message[:2000], moment),
)
# Also persist a structured JSON line in the message when short enough
# by appending a compact summary (full detail stays in return value /
# audit log; events.message is human-readable).
return {
"outcome": "retired",
"reason": reason_s,
"prior_status": prior_status,
"new_status": term,
"details": detail_payload,
}
def list_sessions(
self,
*,
@@ -1639,6 +1835,29 @@ class ControlPlaneDB:
if name not in cols:
conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {decl}")
_SESSION_LIFECYCLE_COLUMNS: tuple[tuple[str, str], ...] = (
("owner_process_started_at", "TEXT"),
("retired_at", "TEXT"),
("retire_reason", "TEXT"),
)
def _migrate_session_lifecycle_columns(self, conn: sqlite3.Connection) -> None:
"""Add session retirement / PID-reuse provenance columns (#969).
Additive and idempotent. Pre-existing rows migrate with NULL; retirement
fills ``retired_at`` / ``retire_reason``, and new upserts may record
``owner_process_started_at`` for stronger identity checks.
"""
cols = {
row[1]
for row in conn.execute("PRAGMA table_info(sessions)").fetchall()
}
if not cols:
return
for name, decl in self._SESSION_LIFECYCLE_COLUMNS:
if name not in cols:
conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {decl}")
def list_active_claims(
self,
*,