feat(content): task structured-note fields + orchestration-marker accessors

This commit is contained in:
Renn F
2026-06-21 01:55:36 +02:00
parent dd03c218d1
commit a6e09f6d75
6 changed files with 254 additions and 0 deletions
+4
View File
@@ -235,6 +235,7 @@ class TaskUpdate(BaseModel):
dev_notes: str | None = None
qa_notes: str | None = None
auditor_notes: str | None = None
pr_reviewer_notes: str | None = None
quick_context: str | None = None
# Lifecycle override — privileged/admin only. Applied by the route as an
@@ -333,7 +334,10 @@ class TaskResponse(BaseModel):
dev_notes: str | None
qa_notes: str | None
auditor_notes: str | None = None
pr_reviewer_notes: str | None = None
quick_context: str | None
notes_structured: dict | None = None
orchestration_markers: dict | None = None
# Review Status
self_verified: bool
+11
View File
@@ -339,6 +339,17 @@ class TaskTable(Base):
JSON, nullable=True
)
# Structured content (migration 041). notes_structured is the typed source
# of truth for every role's note; the TEXT note columns above are a derived
# mirror. orchestration_markers holds the machine markers split out of
# quick_context (never human-facing). pr_reviewer_notes is the reviewer's
# own slot so a review no longer overwrites qa_notes / dev_notes.
pr_reviewer_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
notes_structured: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
orchestration_markers: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
# Gateway coordination (added in migration 006_gateway_columns).
# active_claimant_id + last_heartbeat_at implement the single-claimant
# invariant: only one agent holds a task at a time and they prove
+134
View File
@@ -0,0 +1,134 @@
"""Typed accessors for ``Task.orchestration_markers``.
The machine markers that used to be string-packed into the human
``quick_context`` blob (``original_developer:<uuid>``, ``documenter:<uuid>``,
``required_cells:``, ``external_pr_head=``, ``self_heal_fp=``, ``dismissed=1``,
``external_pr_supersede ...``) live in the ``orchestration_markers`` JSON column
after migration 041. These accessors are the single read/write surface for them.
Writes REASSIGN the dict (``task.orchestration_markers = {...}``) rather than
mutate in place, so SQLAlchemy's change tracking flags the column dirty (a plain
JSON column does not detect in-place mutation).
"""
from __future__ import annotations
from typing import Any, Protocol
class HasMarkers(Protocol):
"""Anything carrying the markers column (the ORM task row or domain model)."""
orchestration_markers: dict[str, Any] | None
# Marker keys — the single source of the vocabulary.
ORIGINAL_DEVELOPER = "original_developer"
DOCUMENTER = "documenter"
REQUIRED_CELLS = "required_cells"
EXTERNAL_PR_HEAD = "external_pr_head"
EXTERNAL_PR_SUPERSEDE = "external_pr_supersede"
SELF_HEAL_FP = "self_heal_fp"
DISMISSED = "dismissed"
def get_marker(task: HasMarkers, key: str, default: Any = None) -> Any:
return (task.orchestration_markers or {}).get(key, default)
def set_marker(task: HasMarkers, key: str, value: Any) -> None:
markers = dict(task.orchestration_markers or {})
markers[key] = value
task.orchestration_markers = markers
def clear_marker(task: HasMarkers, key: str) -> None:
current = task.orchestration_markers or {}
if key not in current:
return
markers = dict(current)
del markers[key]
task.orchestration_markers = markers or None
# --- original developer ---------------------------------------------------- #
def get_original_developer(task: HasMarkers) -> str | None:
val = get_marker(task, ORIGINAL_DEVELOPER)
return str(val) if val else None
def set_original_developer(task: HasMarkers, agent_id: Any) -> None:
set_marker(task, ORIGINAL_DEVELOPER, str(agent_id))
# --- documenter ------------------------------------------------------------ #
def get_documenter(task: HasMarkers) -> str | None:
val = get_marker(task, DOCUMENTER)
return str(val) if val else None
def set_documenter(task: HasMarkers, agent_id: Any) -> None:
set_marker(task, DOCUMENTER, str(agent_id))
# --- required cells -------------------------------------------------------- #
def get_required_cells(task: HasMarkers) -> list[str]:
val = get_marker(task, REQUIRED_CELLS, [])
return [str(c) for c in val] if isinstance(val, list) else []
def set_required_cells(task: HasMarkers, cells: list[str]) -> None:
set_marker(task, REQUIRED_CELLS, [str(c) for c in cells])
# --- self-heal fingerprint ------------------------------------------------- #
def get_self_heal_fingerprint(task: HasMarkers) -> str | None:
val = get_marker(task, SELF_HEAL_FP)
return str(val) if val else None
def set_self_heal_fingerprint(task: HasMarkers, fingerprint: str) -> None:
set_marker(task, SELF_HEAL_FP, fingerprint)
# --- external PR head ------------------------------------------------------ #
def get_external_pr_head(task: HasMarkers) -> str | None:
val = get_marker(task, EXTERNAL_PR_HEAD)
return str(val) if val else None
def set_external_pr_head(task: HasMarkers, head_sha: str) -> None:
set_marker(task, EXTERNAL_PR_HEAD, head_sha)
# --- external PR supersede ------------------------------------------------- #
def get_external_pr_supersede(task: HasMarkers) -> str | None:
val = get_marker(task, EXTERNAL_PR_SUPERSEDE)
return str(val) if val else None
def set_external_pr_supersede(task: HasMarkers, marker: str) -> None:
set_marker(task, EXTERNAL_PR_SUPERSEDE, marker)
# --- dismissed ------------------------------------------------------------- #
def is_dismissed(task: HasMarkers) -> bool:
return bool(get_marker(task, DISMISSED, False))
def mark_dismissed(task: HasMarkers) -> None:
set_marker(task, DISMISSED, True)
+15
View File
@@ -247,6 +247,19 @@ class Task(TimestampMixin):
description="RAG context: similar tasks, learnings, patterns, standards",
)
# Structured content (migration 041)
pr_reviewer_notes: str | None = Field(
default=None, description="PR reviewer's rendered verdict (own slot)"
)
notes_structured: dict | None = Field(
default=None,
description="Typed structured note payloads — the source of truth",
)
orchestration_markers: dict | None = Field(
default=None,
description="Machine markers split out of quick_context (not human-facing)",
)
# Gateway coordination (added in migration 006_gateway_columns).
active_claimant_id: UUID | None = Field(
default=None,
@@ -374,7 +387,9 @@ class TaskUpdate(RobocoBase):
estimated_complexity: Complexity | None = None
dev_notes: str | None = None
qa_notes: str | None = None
auditor_notes: str | None = None
quick_context: str | None = None
pr_reviewer_notes: str | None = None
# Git fields
task_type: TaskType | None = None
@@ -0,0 +1,28 @@
"""The note fields are first-class on the task update + response schemas."""
from __future__ import annotations
from roboco.api.schemas.tasks import TaskResponse
from roboco.api.schemas.tasks import TaskUpdate as ApiTaskUpdate
from roboco.models.task import TaskUpdate as DomainTaskUpdate
def test_api_task_update_accepts_auditor_and_pr_reviewer_notes() -> None:
u = ApiTaskUpdate(auditor_notes="audit", pr_reviewer_notes="review")
assert u.auditor_notes == "audit"
assert u.pr_reviewer_notes == "review"
def test_domain_task_update_accepts_new_notes() -> None:
u = DomainTaskUpdate(auditor_notes="audit", pr_reviewer_notes="review")
assert u.auditor_notes == "audit"
assert u.pr_reviewer_notes == "review"
def test_task_response_exposes_structured_fields() -> None:
fields = set(TaskResponse.model_fields)
assert {
"pr_reviewer_notes",
"notes_structured",
"orchestration_markers",
} <= fields
@@ -0,0 +1,62 @@
"""Tests for the orchestration-marker accessors."""
from __future__ import annotations
from types import SimpleNamespace
from roboco.foundation.policy.content import markers as m
def _task(om: dict | None = None) -> SimpleNamespace:
return SimpleNamespace(orchestration_markers=om)
def test_original_developer_roundtrip() -> None:
t = _task()
assert m.get_original_developer(t) is None
m.set_original_developer(t, "00000000-0000-0000-0001-000000000002")
assert m.get_original_developer(t) == "00000000-0000-0000-0001-000000000002"
def test_required_cells_roundtrip() -> None:
t = _task()
assert m.get_required_cells(t) == []
m.set_required_cells(t, ["backend", "frontend"])
assert m.get_required_cells(t) == ["backend", "frontend"]
def test_dismissed_flag() -> None:
t = _task()
assert m.is_dismissed(t) is False
m.mark_dismissed(t)
assert m.is_dismissed(t) is True
def test_set_marker_reassigns_dict_for_orm_dirty_tracking() -> None:
t = _task({"a": 1})
before = t.orchestration_markers
m.set_marker(t, "b", 2)
# A new dict object — SQLAlchemy only flags JSON columns dirty on reassign.
assert t.orchestration_markers is not before
assert t.orchestration_markers == {"a": 1, "b": 2}
def test_clear_marker_nulls_when_empty() -> None:
t = _task({"x": 1})
m.clear_marker(t, "x")
assert t.orchestration_markers is None
# Clearing an absent key is a no-op.
m.clear_marker(t, "missing")
assert t.orchestration_markers is None
def test_documenter_self_heal_head_supersede() -> None:
t = _task()
m.set_documenter(t, "doc-uuid")
m.set_self_heal_fingerprint(t, "deadbeef")
m.set_external_pr_head(t, "sha123")
m.set_external_pr_supersede(t, "pr=1 review=2 closed=1")
assert m.get_documenter(t) == "doc-uuid"
assert m.get_self_heal_fingerprint(t) == "deadbeef"
assert m.get_external_pr_head(t) == "sha123"
assert m.get_external_pr_supersede(t) == "pr=1 review=2 closed=1"