fix(retirement): close projection and launch contract gaps
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02b7c-1c49-76a0-955a-49e7b3ddfc0d
This commit is contained in:
parent
c43266f626
commit
b0e1af24f9
13 changed files with 1717 additions and 50 deletions
|
|
@ -9,7 +9,7 @@ EXECUTION_STATES = {
|
|||
"manual": "Not queued for autonomous pickup; humans or agents may still work manually.",
|
||||
"queued": "Candidate for ordered pickup when dependencies and concurrency allow it.",
|
||||
"scheduled": "Waiting for an external launch window; State Hub stores the requested time.",
|
||||
"launching": "A launch request asks for immediate pickup or has been handed off.",
|
||||
"launching": "Legacy state only; it does not prove that any consumer accepted pickup.",
|
||||
"paused": "Temporarily held outside the pickup stack.",
|
||||
"completed": "Execution intent is closed; lifecycle status remains authoritative.",
|
||||
"cancelled": "Execution intent was cancelled without changing lifecycle status.",
|
||||
|
|
@ -19,7 +19,7 @@ LAUNCH_MODES = {
|
|||
"manual": "Do not request automation; keep intent visible only.",
|
||||
"queued": "Place in the prioritized stack for later pickup.",
|
||||
"scheduled": "Request pickup at or after a selected time.",
|
||||
"immediate": "Request prompt activity-core or agent pickup.",
|
||||
"immediate": "Legacy intent only; workplan launch-request pickup is retired.",
|
||||
}
|
||||
|
||||
CONCURRENCY_MODES = {
|
||||
|
|
@ -30,16 +30,26 @@ CONCURRENCY_MODES = {
|
|||
STATE_HUB_RESPONSIBILITIES = [
|
||||
"store lifecycle status separately from execution intent",
|
||||
"rank candidate workplans and expose dependency-aware eligibility",
|
||||
"record launch requests and handoff metadata durably",
|
||||
"surface manual, queued, scheduled, and immediate intent to operators",
|
||||
"preserve historical launch-request and execution-intent rows during retirement",
|
||||
"reject new workplan launch requests because no pickup consumer exists",
|
||||
]
|
||||
|
||||
ACTIVITY_CORE_RESPONSIBILITIES = [
|
||||
"own schedules, wakeups, and recurring automation",
|
||||
"dispatch coding agents and coordinate parallel execution",
|
||||
"acknowledge, run, and complete launch requests when available",
|
||||
"claim and complete ops runs created by ActivityDefinition fires",
|
||||
"do not consume State Hub workplan launch requests",
|
||||
]
|
||||
|
||||
EXECUTION_REPLACEMENTS = {
|
||||
"POST /execution/launch-requests": (
|
||||
"Queue work in the authoritative repository file; for recurring or operational "
|
||||
"fires use an ActivityDefinition and activity-core ops_run"
|
||||
),
|
||||
"GET /execution/launch-requests": "GET /ops-runs on activity-core for automation history",
|
||||
"GET /execution/workplan-stack": "Repo Manager work index or hub-core projection",
|
||||
"PATCH /execution/workplans/{id}/intent": "Edit the authoritative workplan file",
|
||||
}
|
||||
|
||||
EXECUTION_STATE_RANK = {
|
||||
"launching": 0,
|
||||
"queued": 1,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ from dataclasses import dataclass
|
|||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
|
||||
from api.models.workplan import Workplan
|
||||
|
||||
PLAN_SCHEMA = "repo-manager.identifier-migration-plan.v1"
|
||||
FLEET_NAMESPACE = "helixforge"
|
||||
|
|
@ -34,6 +37,24 @@ class IdentifierMigrationResult:
|
|||
assignments_deferred: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SealedProjectionRepairReceipt:
|
||||
"""Non-secret evidence for one bounded pre-derivation projection repair."""
|
||||
|
||||
schema: str
|
||||
outcome: str
|
||||
repo_slug: str
|
||||
repository_id: str
|
||||
workplan_record_id: str
|
||||
workplan_old_id: str
|
||||
task_records: tuple[tuple[str, str], ...]
|
||||
plan_sha256: str
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
projection_identity: str
|
||||
observed_at: str
|
||||
|
||||
|
||||
def verify_plan(plan: dict[str, Any]) -> str:
|
||||
"""Validate the immutable plan envelope and deterministic UUID mappings."""
|
||||
if plan.get("schema") != PLAN_SCHEMA:
|
||||
|
|
@ -117,6 +138,328 @@ def _repository_mappings(
|
|||
return replacements, assignments
|
||||
|
||||
|
||||
def _repository_entry(plan: dict[str, Any], repo_slug: str) -> dict[str, Any]:
|
||||
matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug]
|
||||
if len(matches) != 1:
|
||||
raise IdentifierMigrationError(
|
||||
f"repository {repo_slug!r} must occur exactly once in plan"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _repair_unit_mappings(
|
||||
plan: dict[str, Any], repo_slug: str, unit: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
replacements, _assignments = _repository_mappings(plan, repo_slug)
|
||||
workplan = unit.get("workplan")
|
||||
tasks = unit.get("tasks")
|
||||
if not isinstance(workplan, dict) or not isinstance(tasks, list):
|
||||
raise IdentifierMigrationError("repair unit requires one workplan and a task list")
|
||||
|
||||
workplan_record_id = workplan.get("record_id")
|
||||
workplan_matches = [
|
||||
mapping
|
||||
for mapping in replacements
|
||||
if mapping["kind"] == "workplan" and mapping["record_id"] == workplan_record_id
|
||||
]
|
||||
if len(workplan_matches) != 1:
|
||||
raise IdentifierMigrationError(
|
||||
"repair workplan must occur exactly once as a replacement in the sealed plan"
|
||||
)
|
||||
workplan_mapping = workplan_matches[0]
|
||||
raw_workplan_mapping = next(
|
||||
raw
|
||||
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
|
||||
if raw.get("kind") == "workplan" and raw.get("record_id") == workplan_record_id
|
||||
)
|
||||
authoritative_path = raw_workplan_mapping.get("path")
|
||||
if not isinstance(authoritative_path, str) or not authoritative_path:
|
||||
raise IdentifierMigrationError("sealed workplan mapping has no authoritative path")
|
||||
|
||||
task_record_ids = [task.get("record_id") for task in tasks if isinstance(task, dict)]
|
||||
if len(task_record_ids) != len(tasks) or len(set(task_record_ids)) != len(tasks):
|
||||
raise IdentifierMigrationError("repair task record ids must be explicit and unique")
|
||||
task_mappings = [
|
||||
mapping
|
||||
for mapping in replacements
|
||||
if mapping["kind"] == "task" and mapping["record_id"] in task_record_ids
|
||||
]
|
||||
mapped_task_ids = {mapping["record_id"] for mapping in task_mappings}
|
||||
if mapped_task_ids != set(task_record_ids):
|
||||
raise IdentifierMigrationError(
|
||||
"repair tasks do not exactly match replacement mappings in the sealed plan"
|
||||
)
|
||||
|
||||
raw_unit_task_ids = {
|
||||
raw.get("record_id")
|
||||
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
|
||||
if raw.get("kind") == "task"
|
||||
and raw.get("action") == "replace"
|
||||
and raw.get("path") == authoritative_path
|
||||
}
|
||||
if raw_unit_task_ids != set(task_record_ids):
|
||||
raise IdentifierMigrationError(
|
||||
"repair must include every replaced task in the sealed workplan unit"
|
||||
)
|
||||
return workplan_mapping, task_mappings
|
||||
|
||||
|
||||
def _uuid_field(value: Any, label: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise IdentifierMigrationError(f"invalid UUID for {label}") from exc
|
||||
|
||||
|
||||
def _repair_payloads(
|
||||
unit: dict[str, Any],
|
||||
workplan_mapping: dict[str, Any],
|
||||
task_mappings: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
raw_workplan = unit["workplan"]
|
||||
if _uuid_field(raw_workplan.get("id"), "workplan") != workplan_mapping["old_id"]:
|
||||
raise IdentifierMigrationError("repair workplan UUID does not match sealed old UUID")
|
||||
slug = raw_workplan.get("slug")
|
||||
title = raw_workplan.get("title")
|
||||
status = raw_workplan.get("status")
|
||||
if not isinstance(slug, str) or not slug or not isinstance(title, str) or not title:
|
||||
raise IdentifierMigrationError("repair workplan requires explicit slug and title")
|
||||
if status not in {
|
||||
"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"
|
||||
}:
|
||||
raise IdentifierMigrationError(f"unsupported repair workplan status {status!r}")
|
||||
workplan_payload = {
|
||||
"id": workplan_mapping["old_id"],
|
||||
"topic_id": _uuid_field(raw_workplan.get("topic_id"), "workplan topic"),
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"description": raw_workplan.get("description"),
|
||||
"status": status,
|
||||
"owner": raw_workplan.get("owner"),
|
||||
"planning_priority": raw_workplan.get("planning_priority"),
|
||||
"planning_order": raw_workplan.get("planning_order"),
|
||||
}
|
||||
|
||||
mappings_by_record = {mapping["record_id"]: mapping for mapping in task_mappings}
|
||||
task_payloads: list[dict[str, Any]] = []
|
||||
for raw_task in unit["tasks"]:
|
||||
mapping = mappings_by_record[raw_task["record_id"]]
|
||||
if _uuid_field(raw_task.get("id"), raw_task["record_id"]) != mapping["old_id"]:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task UUID does not match sealed old UUID for {raw_task['record_id']}"
|
||||
)
|
||||
title = raw_task.get("title")
|
||||
if not isinstance(title, str) or not title:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} requires an explicit title"
|
||||
)
|
||||
try:
|
||||
status_value = TaskStatus(raw_task.get("status", "todo"))
|
||||
priority_value = TaskPriority(raw_task.get("priority", "medium"))
|
||||
except ValueError as exc:
|
||||
raise IdentifierMigrationError(
|
||||
f"invalid task state for {raw_task['record_id']}"
|
||||
) from exc
|
||||
parent_record_id = raw_task.get("parent_record_id")
|
||||
if parent_record_id is not None and parent_record_id not in mappings_by_record:
|
||||
raise IdentifierMigrationError(
|
||||
f"parent task for {raw_task['record_id']} is outside the sealed repair unit"
|
||||
)
|
||||
task_payloads.append(
|
||||
{
|
||||
"record_id": raw_task["record_id"],
|
||||
"id": mapping["old_id"],
|
||||
"title": title,
|
||||
"description": raw_task.get("description"),
|
||||
"status": status_value,
|
||||
"priority": priority_value,
|
||||
"assignee": raw_task.get("assignee"),
|
||||
"blocking_reason": raw_task.get("blocking_reason"),
|
||||
"needs_human": bool(raw_task.get("needs_human", False)),
|
||||
"intervention_note": raw_task.get("intervention_note"),
|
||||
"parent_task_id": (
|
||||
mappings_by_record[parent_record_id]["old_id"]
|
||||
if parent_record_id is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
if task_payloads[-1]["needs_human"] and not task_payloads[-1]["intervention_note"]:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} needs an intervention note"
|
||||
)
|
||||
if (
|
||||
task_payloads[-1]["status"] == TaskStatus.wait
|
||||
and task_payloads[-1]["needs_human"]
|
||||
and not task_payloads[-1]["blocking_reason"]
|
||||
):
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} needs a blocking reason"
|
||||
)
|
||||
|
||||
pending = list(task_payloads)
|
||||
ordered: list[dict[str, Any]] = []
|
||||
emitted: set[uuid.UUID] = set()
|
||||
while pending:
|
||||
ready = [
|
||||
task
|
||||
for task in pending
|
||||
if task["parent_task_id"] is None or task["parent_task_id"] in emitted
|
||||
]
|
||||
if not ready:
|
||||
raise IdentifierMigrationError("repair task parentage contains a cycle")
|
||||
for task in ready:
|
||||
pending.remove(task)
|
||||
ordered.append(task)
|
||||
emitted.add(task["id"])
|
||||
return workplan_payload, ordered
|
||||
|
||||
|
||||
def _same_workplan(row: Workplan, expected: dict[str, Any], repo_id: uuid.UUID) -> bool:
|
||||
fields = (
|
||||
"id", "topic_id", "slug", "title", "description", "status", "owner",
|
||||
"planning_priority", "planning_order",
|
||||
)
|
||||
return row.repo_id == repo_id and all(
|
||||
getattr(row, field) == expected[field] for field in fields
|
||||
)
|
||||
|
||||
|
||||
def _same_task(row: Task, expected: dict[str, Any], workplan_id: uuid.UUID) -> bool:
|
||||
fields = (
|
||||
"id", "title", "description", "status", "priority", "assignee",
|
||||
"blocking_reason", "needs_human", "intervention_note", "parent_task_id",
|
||||
)
|
||||
return row.workplan_id == workplan_id and all(
|
||||
getattr(row, field) == expected[field] for field in fields
|
||||
)
|
||||
|
||||
|
||||
async def repair_absent_prederivation_projection(
|
||||
session: AsyncSession,
|
||||
plan: dict[str, Any],
|
||||
repo_slug: str,
|
||||
unit: dict[str, Any],
|
||||
*,
|
||||
expected_plan_sha256: str,
|
||||
source_revision: str,
|
||||
source_fingerprint: str,
|
||||
source_clean: bool,
|
||||
source_synchronized: bool,
|
||||
primary_confirmed: bool,
|
||||
projection_identity: str,
|
||||
observed_at: datetime | None = None,
|
||||
) -> SealedProjectionRepairReceipt:
|
||||
"""Restore one exact random-ID workplan unit into an absent projection.
|
||||
|
||||
This is intentionally separate from ordinary consistency repair. The caller
|
||||
must independently prove Git cleanliness/synchronization and provide the
|
||||
authoritative unit parsed from the pinned source revision.
|
||||
"""
|
||||
plan_sha256 = verify_plan(plan)
|
||||
if plan_sha256 != expected_plan_sha256:
|
||||
raise IdentifierMigrationError("explicit plan SHA-256 does not match sealed plan")
|
||||
if not primary_confirmed:
|
||||
raise IdentifierMigrationError("projection repair requires explicit primary confirmation")
|
||||
if not source_clean or not source_synchronized:
|
||||
raise IdentifierMigrationError("projection repair requires a clean synchronized source")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", source_revision):
|
||||
raise IdentifierMigrationError("source revision must be a full Git SHA")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
|
||||
raise IdentifierMigrationError("source fingerprint must be SHA-256")
|
||||
if not projection_identity.strip():
|
||||
raise IdentifierMigrationError("projection identity is required")
|
||||
|
||||
repository_entry = _repository_entry(plan, repo_slug)
|
||||
if source_revision != repository_entry.get("planned_head_sha"):
|
||||
raise IdentifierMigrationError("source revision drifted from sealed plan")
|
||||
if source_fingerprint != repository_entry.get("source_fingerprint"):
|
||||
raise IdentifierMigrationError("source fingerprint drifted from sealed plan")
|
||||
workplan_mapping, task_mappings = _repair_unit_mappings(plan, repo_slug, unit)
|
||||
workplan_payload, task_payloads = _repair_payloads(
|
||||
unit, workplan_mapping, task_mappings
|
||||
)
|
||||
if session.in_transaction():
|
||||
raise IdentifierMigrationError("projection repair requires a fresh database session")
|
||||
|
||||
outcome = "repaired"
|
||||
async with session.begin():
|
||||
repo = await session.scalar(
|
||||
select(ManagedRepo).where(ManagedRepo.slug == repo_slug).with_for_update()
|
||||
)
|
||||
if repo is None:
|
||||
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
|
||||
if repo.topic_id != workplan_payload["topic_id"]:
|
||||
raise IdentifierMigrationError("repair topic does not match repository projection")
|
||||
|
||||
old_ids = [workplan_mapping["old_id"], *(m["old_id"] for m in task_mappings)]
|
||||
new_workplan = await session.get(Workplan, workplan_mapping["new_id"])
|
||||
new_tasks = [await session.get(Task, mapping["new_id"]) for mapping in task_mappings]
|
||||
if new_workplan is not None or any(task is not None for task in new_tasks):
|
||||
raise IdentifierMigrationError("derived target presence blocks projection repair")
|
||||
alias_count = await session.scalar(
|
||||
select(func.count()).select_from(WorkRecordIdentifierAlias).where(
|
||||
WorkRecordIdentifierAlias.old_id.in_(old_ids)
|
||||
)
|
||||
)
|
||||
if alias_count:
|
||||
raise IdentifierMigrationError("durable alias presence blocks projection repair")
|
||||
|
||||
old_workplan = await session.get(Workplan, workplan_mapping["old_id"])
|
||||
old_tasks = [await session.get(Task, mapping["old_id"]) for mapping in task_mappings]
|
||||
present_count = int(old_workplan is not None) + sum(task is not None for task in old_tasks)
|
||||
expected_count = 1 + len(task_mappings)
|
||||
if present_count not in {0, expected_count}:
|
||||
raise IdentifierMigrationError("partial old projection presence blocks repair")
|
||||
|
||||
if present_count == expected_count:
|
||||
if not _same_workplan(old_workplan, workplan_payload, repo.id):
|
||||
raise IdentifierMigrationError(
|
||||
"existing old workplan differs from sealed repair unit"
|
||||
)
|
||||
expected_tasks = {task["id"]: task for task in task_payloads}
|
||||
if any(
|
||||
not _same_task(task, expected_tasks[task.id], workplan_mapping["old_id"])
|
||||
for task in old_tasks
|
||||
):
|
||||
raise IdentifierMigrationError("existing old tasks differ from sealed repair unit")
|
||||
outcome = "verified_noop"
|
||||
else:
|
||||
slug_conflict = await session.scalar(
|
||||
select(Workplan.id).where(Workplan.slug == workplan_payload["slug"])
|
||||
)
|
||||
if slug_conflict is not None:
|
||||
raise IdentifierMigrationError("workplan slug is already projected")
|
||||
session.add(
|
||||
Workplan(repo_id=repo.id, **workplan_payload)
|
||||
)
|
||||
await session.flush()
|
||||
for task_payload in task_payloads:
|
||||
values = {key: value for key, value in task_payload.items() if key != "record_id"}
|
||||
session.add(Task(workplan_id=workplan_mapping["old_id"], **values))
|
||||
await session.flush()
|
||||
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
raise IdentifierMigrationError("receipt observation time must be timezone-aware")
|
||||
return SealedProjectionRepairReceipt(
|
||||
schema="state-hub.sealed-prederivation-projection-repair-receipt.v1",
|
||||
outcome=outcome,
|
||||
repo_slug=repo_slug,
|
||||
repository_id=str(repo.id),
|
||||
workplan_record_id=workplan_mapping["record_id"],
|
||||
workplan_old_id=str(workplan_mapping["old_id"]),
|
||||
task_records=tuple(
|
||||
(mapping["record_id"], str(mapping["old_id"])) for mapping in task_mappings
|
||||
),
|
||||
plan_sha256=plan_sha256,
|
||||
source_revision=source_revision,
|
||||
source_fingerprint=source_fingerprint,
|
||||
projection_identity=projection_identity,
|
||||
observed_at=timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z"),
|
||||
)
|
||||
|
||||
|
||||
async def _assert_projection_preconditions(
|
||||
session: AsyncSession,
|
||||
repo_slug: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue