From c2f9536377fffce6a7abf6fad8b0580b1d6d5383 Mon Sep 17 00:00:00 2001 From: Yichen Wu Date: Wed, 22 Jul 2026 15:11:24 -0700 Subject: [PATCH] Ship versioned product sessions --- README.md | 9 +- .../alembic/versions/0012_product_sessions.py | 133 ++++++++++ apps/api/app/api/v1/routes/tasks.py | 29 +++ apps/api/app/db/models/__init__.py | 3 +- apps/api/app/db/models/product_session.py | 15 ++ apps/api/app/db/models/task.py | 22 +- apps/api/app/repositories/product_session.py | 26 ++ apps/api/app/repositories/task.py | 20 +- apps/api/app/schemas/task.py | 66 +++++ apps/api/app/services/agent_react.py | 2 + apps/api/app/services/changeset.py | 21 ++ apps/api/app/services/contract.py | 34 +++ apps/api/app/services/product_session.py | 96 ++++++++ apps/api/app/services/receipt.py | 21 ++ apps/api/app/services/task.py | 210 +++++++++++++++- apps/api/tests/test_changeset.py | 232 +++++++++++++++++- apps/api/tests/test_contract.py | 37 +++ apps/api/tests/test_deployment_boundaries.py | 2 +- apps/web/app/tasks/[id]/page.tsx | 3 + apps/web/components/authority-panel.test.tsx | 1 + .../components/product-session-panel.test.tsx | 94 +++++++ apps/web/components/product-session-panel.tsx | 153 ++++++++++++ apps/web/lib/api-client.ts | 14 ++ docs/STRATEGY.md | 17 ++ docs/adr/0011-product-session-revisions.md | 52 ++++ packages/api-contract/src/index.ts | 25 ++ scripts/demo.sh | 10 +- scripts/k8s-deployment-acceptance.sh | 2 +- 28 files changed, 1333 insertions(+), 16 deletions(-) create mode 100644 apps/api/alembic/versions/0012_product_sessions.py create mode 100644 apps/api/app/db/models/product_session.py create mode 100644 apps/api/app/repositories/product_session.py create mode 100644 apps/api/app/services/product_session.py create mode 100644 apps/web/components/product-session-panel.test.tsx create mode 100644 apps/web/components/product-session-panel.tsx create mode 100644 docs/adr/0011-product-session-revisions.md diff --git a/README.md b/README.md index 2c0a457..6519abd 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ an instruction to an accepted result. It separates **work** from **acceptance**: - **The loop is an explicit state machine.** Phase, transition reason, and sequence are persisted rather than inferred from logs; recovery, the API, and the task UI therefore share the same semantics. +- **Delivery can become the next loop.** Local-project tasks form a Product Session. A + verified v1 can accept a bug correction or changed product decision as an immutable + feedback delta, seed v2 from v1's exact verified patch, and retain both contracts, + Receipts, specifications, and change sets. If the evidence fails, the task does not become `completed` merely because the model called `finish`. Limit, stuck, cancelled, and error outcomes still receive an @@ -213,6 +217,9 @@ and residual risks. bounded retries, dead-letter handling, and compare-and-update task claims. - Transactional local Git project edits through isolated clones and verified Apply/Discard/Undo change sets. +- Product Sessions with content-addressed specifications, explicit implementation-fix + versus product-decision feedback, cumulative isolated revisions, latest-only Apply, + and a version navigator in the task UI. - File upload/download and `.xlsx`, `.docx`, `.csv`, image/vision workflows. - Signed capability-scoped skills, project/owner-scoped memory, and bounded Receipt-producing sub-agents. @@ -266,7 +273,7 @@ make k8s-deployment-acceptance # disposable production-mode cluster Current CI also audits locked Python/JavaScript dependencies, builds all runtime images, validates Compose/Kustomize boundaries, and packages/startup-smokes the desktop shell on macOS, Windows, and Ubuntu. Backend tests enforce a branch-aware -70% coverage floor; the current full suite reports 72%. +70% coverage floor; the current full suite reports 75%. ## Repository map diff --git a/apps/api/alembic/versions/0012_product_sessions.py b/apps/api/alembic/versions/0012_product_sessions.py new file mode 100644 index 0000000..69f0810 --- /dev/null +++ b/apps/api/alembic/versions/0012_product_sessions.py @@ -0,0 +1,133 @@ +"""Add durable Product Sessions and immutable revision specifications. + +Revision ID: 0012_product_sessions +Revises: 0011_loop_state_machine +""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0012_product_sessions" +down_revision: str | None = "0011_loop_state_machine" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _specification(goal: str, criteria: object) -> tuple[dict[str, object], str]: + rubric = criteria if isinstance(criteria, list) else [] + specification: dict[str, object] = { + "schema": "loop.product-specification/v1", + "original_goal": goal.strip(), + "required_acceptance_criteria": rubric, + "feedback_history": [], + "previous_contract_hash": None, + "previous_receipt_hash": None, + } + canonical = json.dumps( + specification, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return specification, hashlib.sha256(canonical).hexdigest() + + +def upgrade() -> None: + op.create_table( + "product_sessions", + sa.Column("owner_id", sa.String(length=255), nullable=False), + sa.Column("project_id", sa.String(length=100), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_product_sessions")), + ) + op.create_index(op.f("ix_product_sessions_owner_id"), "product_sessions", ["owner_id"]) + op.create_index(op.f("ix_product_sessions_project_id"), "product_sessions", ["project_id"]) + op.add_column("tasks", sa.Column("product_session_id", sa.Uuid(), nullable=True)) + op.add_column("tasks", sa.Column("product_revision", sa.Integer(), nullable=True)) + op.add_column("tasks", sa.Column("previous_revision_id", sa.Uuid(), nullable=True)) + op.add_column("tasks", sa.Column("superseded_by_id", sa.Uuid(), nullable=True)) + op.add_column("tasks", sa.Column("feedback_kind", sa.String(length=30), nullable=True)) + op.add_column("tasks", sa.Column("feedback_delta", sa.Text(), nullable=True)) + op.add_column("tasks", sa.Column("product_specification", sa.JSON(), nullable=True)) + op.add_column("tasks", sa.Column("specification_hash", sa.String(length=64), nullable=True)) + op.create_index(op.f("ix_tasks_product_session_id"), "tasks", ["product_session_id"]) + op.create_index( + "uq_tasks_product_session_id_product_revision", + "tasks", + ["product_session_id", "product_revision"], + unique=True, + ) + + connection = op.get_bind() + product_sessions = sa.table( + "product_sessions", + sa.column("id", sa.Uuid()), + sa.column("owner_id", sa.String()), + sa.column("project_id", sa.String()), + ) + tasks = sa.table( + "tasks", + sa.column("id", sa.Uuid()), + sa.column("product_session_id", sa.Uuid()), + sa.column("product_revision", sa.Integer()), + sa.column("product_specification", sa.JSON()), + sa.column("specification_hash", sa.String()), + ) + rows = connection.execute( + sa.text( + "SELECT id, owner_id, project_id, goal, rubric FROM tasks " + "WHERE project_source_path IS NOT NULL AND parent_id IS NULL" + ) + ).mappings() + for row in rows: + session_id = uuid.uuid4() + raw_rubric = row["rubric"] + if isinstance(raw_rubric, str): + try: + raw_rubric = json.loads(raw_rubric) + except ValueError: + raw_rubric = [] + specification, digest = _specification(str(row["goal"]), raw_rubric) + connection.execute( + product_sessions.insert().values( + id=session_id, + owner_id=row["owner_id"], + project_id=row["project_id"], + ) + ) + connection.execute( + tasks.update() + .where(tasks.c.id == uuid.UUID(str(row["id"]))) + .values( + product_session_id=session_id, + product_revision=1, + product_specification=specification, + specification_hash=digest, + ) + ) + + +def downgrade() -> None: + op.drop_index("uq_tasks_product_session_id_product_revision", table_name="tasks") + op.drop_index(op.f("ix_tasks_product_session_id"), table_name="tasks") + op.drop_column("tasks", "specification_hash") + op.drop_column("tasks", "product_specification") + op.drop_column("tasks", "feedback_delta") + op.drop_column("tasks", "feedback_kind") + op.drop_column("tasks", "superseded_by_id") + op.drop_column("tasks", "previous_revision_id") + op.drop_column("tasks", "product_revision") + op.drop_column("tasks", "product_session_id") + op.drop_index(op.f("ix_product_sessions_project_id"), table_name="product_sessions") + op.drop_index(op.f("ix_product_sessions_owner_id"), table_name="product_sessions") + op.drop_table("product_sessions") diff --git a/apps/api/app/api/v1/routes/tasks.py b/apps/api/app/api/v1/routes/tasks.py index c8a7eaa..bc78dbe 100644 --- a/apps/api/app/api/v1/routes/tasks.py +++ b/apps/api/app/api/v1/routes/tasks.py @@ -36,6 +36,7 @@ from app.schemas.task import ( ChangeSetRead, LimitDefaults, + ProductRevisionCreate, RespondIn, TaskCreate, TaskRead, @@ -144,6 +145,34 @@ async def retry_task( return TaskRead.from_model(task) +@router.get( + "/{task_id}/revisions", + response_model=list[TaskRead], + summary="List every immutable delivery in a Product Session", +) +async def product_revisions(task_id: uuid.UUID, service: TaskServiceDep) -> list[TaskRead]: + return [TaskRead.from_model(task) for task in await service.list_revisions(task_id)] + + +@router.post( + "/{task_id}/revisions", + response_model=TaskRead, + status_code=status.HTTP_201_CREATED, + summary="Turn verified-delivery feedback into the next Product Session revision", + dependencies=[rate_limit(limit=10, window_seconds=60)], +) +async def create_product_revision( + task_id: uuid.UUID, + payload: ProductRevisionCreate, + service: TaskServiceDep, + background: BackgroundTasks, +) -> TaskRead: + task = await service.create_revision(task_id, payload) + if payload.autostart: + background.add_task(trigger_task, task.id) + return TaskRead.from_model(task) + + @router.get("/{task_id}", response_model=TaskRead, summary="Get a task") async def get_task(task_id: uuid.UUID, service: TaskServiceDep) -> TaskRead: task = await service.get(task_id) diff --git a/apps/api/app/db/models/__init__.py b/apps/api/app/db/models/__init__.py index 3e191a7..9f3f815 100644 --- a/apps/api/app/db/models/__init__.py +++ b/apps/api/app/db/models/__init__.py @@ -1,7 +1,8 @@ """Import all models here so Alembic's autogenerate sees them via ``Base.metadata``.""" +from app.db.models.product_session import ProductSessionModel from app.db.models.step import StepModel from app.db.models.task import TaskModel from app.db.models.trigger import TriggerModel -__all__ = ["StepModel", "TaskModel", "TriggerModel"] +__all__ = ["ProductSessionModel", "StepModel", "TaskModel", "TriggerModel"] diff --git a/apps/api/app/db/models/product_session.py b/apps/api/app/db/models/product_session.py new file mode 100644 index 0000000..3ac83e8 --- /dev/null +++ b/apps/api/app/db/models/product_session.py @@ -0,0 +1,15 @@ +"""A durable product-delivery lineage across successive verified tasks.""" + +from __future__ import annotations + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + + +class ProductSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "product_sessions" + + owner_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + project_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) diff --git a/apps/api/app/db/models/task.py b/apps/api/app/db/models/task.py index 71cf3c7..c28595b 100644 --- a/apps/api/app/db/models/task.py +++ b/apps/api/app/db/models/task.py @@ -10,7 +10,7 @@ import uuid from typing import Any -from sqlalchemy import JSON, Boolean, Integer, String, Text, UniqueConstraint, Uuid +from sqlalchemy import JSON, Boolean, Index, Integer, String, Text, UniqueConstraint, Uuid from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin @@ -20,7 +20,15 @@ class TaskModel(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "tasks" - __table_args__ = (UniqueConstraint("owner_id", "idempotency_key"),) + __table_args__ = ( + UniqueConstraint("owner_id", "idempotency_key"), + Index( + "uq_tasks_product_session_id_product_revision", + "product_session_id", + "product_revision", + unique=True, + ), + ) goal: Mapped[str] = mapped_column(Text, nullable=False) owner_id: Mapped[str] = mapped_column(String(255), nullable=False, default="local", index=True) @@ -73,6 +81,16 @@ class TaskModel(UUIDPrimaryKeyMixin, TimestampMixin, Base): # Sub-agent delegation: a spawned task points at its parent and tracks depth. parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(), nullable=True, index=True) depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Product revision lineage is separate from parent_id, which exclusively + # represents the sub-agent execution tree. + product_session_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(), nullable=True, index=True) + product_revision: Mapped[int | None] = mapped_column(Integer, nullable=True) + previous_revision_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(), nullable=True) + superseded_by_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(), nullable=True) + feedback_kind: Mapped[str | None] = mapped_column(String(30), nullable=True) + feedback_delta: Mapped[str | None] = mapped_column(Text, nullable=True) + product_specification: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + specification_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) # Chat origin: the channel/chat this task came from, so replies route back. chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) # The action awaiting approval while paused: {"tool": ..., "args": {...}}. diff --git a/apps/api/app/repositories/product_session.py b/apps/api/app/repositories/product_session.py new file mode 100644 index 0000000..57647cf --- /dev/null +++ b/apps/api/app/repositories/product_session.py @@ -0,0 +1,26 @@ +"""Data access for durable product-delivery lineages.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models.product_session import ProductSessionModel +from app.repositories.base import BaseRepository + + +class ProductSessionRepository(BaseRepository[ProductSessionModel]): + model = ProductSessionModel + + def __init__(self, session: AsyncSession) -> None: + super().__init__(session) + + async def get_for_update(self, session_id: uuid.UUID) -> ProductSessionModel | None: + result: ProductSessionModel | None = await self.session.scalar( + select(ProductSessionModel) + .where(ProductSessionModel.id == session_id) + .with_for_update() + ) + return result diff --git a/apps/api/app/repositories/task.py b/apps/api/app/repositories/task.py index 52ead3c..494057a 100644 --- a/apps/api/app/repositories/task.py +++ b/apps/api/app/repositories/task.py @@ -4,7 +4,7 @@ import uuid -from sqlalchemy import case, func, select, update +from sqlalchemy import case, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement @@ -24,7 +24,10 @@ async def list_roots( ) -> list[TaskModel]: """Top-level tasks only — spawned sub-agents (parent_id set) are excluded so they don't pollute the task list; they show under their parent.""" - conditions: list[ColumnElement[bool]] = [TaskModel.parent_id.is_(None)] + conditions: list[ColumnElement[bool]] = [ + TaskModel.parent_id.is_(None), + or_(TaskModel.product_session_id.is_(None), TaskModel.superseded_by_id.is_(None)), + ] if owner_id is not None: conditions.append(TaskModel.owner_id == owner_id) stmt = ( @@ -78,7 +81,10 @@ async def claim_pending(self, task_id: uuid.UUID) -> TaskModel | None: return task async def count_roots(self, *, owner_id: str | None = None) -> int: - conditions: list[ColumnElement[bool]] = [TaskModel.parent_id.is_(None)] + conditions: list[ColumnElement[bool]] = [ + TaskModel.parent_id.is_(None), + or_(TaskModel.product_session_id.is_(None), TaskModel.superseded_by_id.is_(None)), + ] if owner_id is not None: conditions.append(TaskModel.owner_id == owner_id) stmt = select(func.count()).select_from(TaskModel).where(*conditions) @@ -106,6 +112,14 @@ async def list_children(self, parent_id: uuid.UUID) -> list[TaskModel]: ) return list((await self.session.scalars(stmt)).all()) + async def list_revisions(self, session_id: uuid.UUID) -> list[TaskModel]: + stmt = ( + select(TaskModel) + .where(TaskModel.product_session_id == session_id) + .order_by(TaskModel.product_revision.asc()) + ) + return list((await self.session.scalars(stmt)).all()) + async def list_descendants(self, parent_id: uuid.UUID) -> list[TaskModel]: descendants: list[TaskModel] = [] frontier = [parent_id] diff --git a/apps/api/app/schemas/task.py b/apps/api/app/schemas/task.py index 0f002c7..d43c23a 100644 --- a/apps/api/app/schemas/task.py +++ b/apps/api/app/schemas/task.py @@ -8,6 +8,7 @@ import uuid from datetime import datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -130,6 +131,20 @@ class RespondIn(BaseModel): answer: str = Field(min_length=1, max_length=4_000) +class ProductRevisionCreate(BaseModel): + feedback: str = Field(min_length=4, max_length=4_000) + kind: Literal["implementation_fix", "product_decision"] = "implementation_fix" + autostart: bool = True + + @field_validator("feedback") + @classmethod + def normalize_feedback(cls, value: str) -> str: + normalized = value.strip() + if len(normalized) < 4: + raise ValueError("feedback must contain at least 4 non-whitespace characters") + return normalized + + class LimitsRead(BaseModel): max_steps: int token_budget: int @@ -191,6 +206,38 @@ class LoopStateRead(BaseModel): sequence: int +class ProductFeedbackRead(BaseModel): + revision: int = Field(ge=2) + kind: Literal["implementation_fix", "product_decision"] + feedback: str + previous_task_id: uuid.UUID + + +class ProductSpecificationRead(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + schema_version: Literal["loop.product-specification/v1"] = Field( + alias="schema", serialization_alias="schema" + ) + original_goal: str + required_acceptance_criteria: list[str] + feedback_history: list[ProductFeedbackRead] + previous_contract_hash: str | None + previous_receipt_hash: str | None + + +class ProductRevisionRead(BaseModel): + session_id: uuid.UUID + revision: int + previous_task_id: uuid.UUID | None + superseded_by_task_id: uuid.UUID | None + feedback_kind: Literal["implementation_fix", "product_decision"] | None + feedback_delta: str | None + specification: ProductSpecificationRead + specification_hash: str + is_latest: bool + + class TaskRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -221,6 +268,7 @@ class TaskRead(BaseModel): skill: str | None parent_id: uuid.UUID | None depth: int + product_revision: ProductRevisionRead | None idempotency_key: str | None attempt: int limits: LimitsRead @@ -302,6 +350,24 @@ def from_model(cls, model: object) -> TaskRead: skill=m.skill, # type: ignore[attr-defined] parent_id=m.parent_id, # type: ignore[attr-defined] depth=m.depth, # type: ignore[attr-defined] + product_revision=( + ProductRevisionRead( + session_id=m.product_session_id, # type: ignore[attr-defined] + revision=m.product_revision, # type: ignore[attr-defined] + previous_task_id=m.previous_revision_id, # type: ignore[attr-defined] + superseded_by_task_id=m.superseded_by_id, # type: ignore[attr-defined] + feedback_kind=m.feedback_kind, # type: ignore[attr-defined] + feedback_delta=m.feedback_delta, # type: ignore[attr-defined] + specification=m.product_specification, # type: ignore[attr-defined] + specification_hash=m.specification_hash, # type: ignore[attr-defined] + is_latest=m.superseded_by_id is None, # type: ignore[attr-defined] + ) + if m.product_session_id # type: ignore[attr-defined] + and m.product_revision # type: ignore[attr-defined] + and m.product_specification # type: ignore[attr-defined] + and m.specification_hash # type: ignore[attr-defined] + else None + ), idempotency_key=m.idempotency_key, # type: ignore[attr-defined] attempt=m.attempt, # type: ignore[attr-defined] limits=LimitsRead( diff --git a/apps/api/app/services/agent_react.py b/apps/api/app/services/agent_react.py index 0eb653a..d30e42c 100644 --- a/apps/api/app/services/agent_react.py +++ b/apps/api/app/services/agent_react.py @@ -84,6 +84,7 @@ extract_json, ) from app.services.memory import MemoryStore, scoped_memory_root +from app.services.product_session import required_revision_criteria from app.services.progress import HistoryEntry, ProgressGuard from app.services.prompts import plan_prompts, understand_prompts, verify_prompts from app.services.receipt import RECEIPT_SCHEMA, build_receipt, refresh_receipt_authority @@ -1126,6 +1127,7 @@ async def _prepare_project_contract( critic=self.verifier_llm, granted_capabilities=granted_capabilities, required_checks=list(task.required_checks or []), + required_criteria=required_revision_criteria(task), clarifications=clarifications, token_budget=compilation_budget, ) diff --git a/apps/api/app/services/changeset.py b/apps/api/app/services/changeset.py index 35ca50d..4e4dcee 100644 --- a/apps/api/app/services/changeset.py +++ b/apps/api/app/services/changeset.py @@ -178,6 +178,27 @@ def clone_project(binding: ProjectBinding, destination: Path) -> None: raise ConflictError(f"Could not create the isolated Git checkout: {exc}") from exc +def clone_project_revision( + binding: ProjectBinding, + destination: Path, + verified_patch: bytes, +) -> None: + """Create a detached clone and seed it with the prior verified delivery.""" + clone_project(binding, destination) + try: + _git(destination, "apply", "--binary", "--check", input_data=verified_patch) + _git(destination, "apply", "--binary", input_data=verified_patch) + seeded = inspect_changes(destination, binding.base_commit) + expected = hashlib.sha256(verified_patch).hexdigest() + if seeded.patch_sha256 != expected: + raise ConflictError("The revision workspace did not reproduce the verified patch.") + except (ConflictError, GitCommandError, OSError) as exc: + shutil.rmtree(destination, ignore_errors=True) + if isinstance(exc, ConflictError): + raise + raise ConflictError(f"Could not seed the product revision workspace: {exc}") from exc + + def _temporary_index( repo: Path, base_commit: str ) -> tuple[dict[str, str], tempfile.TemporaryDirectory[str]]: diff --git a/apps/api/app/services/contract.py b/apps/api/app/services/contract.py index 41c516f..6013321 100644 --- a/apps/api/app/services/contract.py +++ b/apps/api/app/services/contract.py @@ -181,6 +181,7 @@ async def compile_project_contract( critic: LLMClient, granted_capabilities: set[Capability], required_checks: list[dict[str, Any]] | None = None, + required_criteria: list[str] | None = None, clarifications: list[str] | None = None, token_budget: int | None = None, ) -> CompiledContract: @@ -200,6 +201,7 @@ async def compile_project_contract( compiler_result.content, fallback_criteria=[goal], ) + proposal = _merge_required_criteria(proposal, required_criteria or []) proposal = _merge_required_checks(proposal, required_checks or []) except (TypeError, ValueError) as exc: draft = _failed_draft( @@ -351,6 +353,7 @@ async def compile_project_contract( fallback_criteria=[goal], ) criteria_recovered = criteria_recovered or repair_recovered + proposal = _merge_required_criteria(proposal, required_criteria or []) proposal = _merge_required_checks(proposal, required_checks or []) except (TypeError, ValueError) as exc: repair_critique = final_critique.model_copy( @@ -791,6 +794,37 @@ def _merge_required_checks( return proposal.model_copy(update={"checks": checks, "artifacts": artifacts}) +def _merge_required_criteria( + proposal: ContractProposal, + required_criteria: list[str], +) -> ContractProposal: + required = list(dict.fromkeys(item.strip() for item in required_criteria if item.strip()))[:12] + if not required: + return proposal + prior = list(proposal.criteria) + extras = [criterion for criterion in prior if criterion not in required] + criteria = [*required, *extras[: max(0, _MAX_AUTO_CRITERIA - len(required))]] + positions = {criterion: index for index, criterion in enumerate(criteria, start=1)} + revision_criterion_id = f"criterion-{len(required):03d}" + remapped: list[ContractCheck] = [] + for check in proposal.checks: + mapped: set[str] = set() + for identifier in check.criterion_ids: + suffix = identifier.removeprefix("criterion-") + if not identifier.startswith("criterion-") or not suffix.isdigit(): + continue + prior_index = int(suffix) - 1 + if not 0 <= prior_index < len(prior): + continue + position = positions.get(prior[prior_index]) + if position is not None: + mapped.add(f"criterion-{position:03d}") + if check.criterion_ids: + mapped.add(revision_criterion_id) + remapped.append(check.model_copy(update={"criterion_ids": sorted(mapped)})) + return proposal.model_copy(update={"criteria": criteria, "checks": remapped}) + + def _critique_from_result(result: LLMResult) -> ContractCritique: parsed = _extract_json(result.content) if not isinstance(parsed, dict): diff --git a/apps/api/app/services/product_session.py b/apps/api/app/services/product_session.py new file mode 100644 index 0000000..2ab0005 --- /dev/null +++ b/apps/api/app/services/product_session.py @@ -0,0 +1,96 @@ +"""Canonical Product Session specifications and revision instructions.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Literal + +from app.db.models.task import TaskModel + +PRODUCT_SPECIFICATION_SCHEMA = "loop.product-specification/v1" +FeedbackKind = Literal["implementation_fix", "product_decision"] + + +def initial_specification(goal: str, criteria: list[str]) -> dict[str, Any]: + return { + "schema": PRODUCT_SPECIFICATION_SCHEMA, + "original_goal": goal.strip(), + "required_acceptance_criteria": list(criteria), + "feedback_history": [], + "previous_contract_hash": None, + "previous_receipt_hash": None, + } + + +def revised_specification( + previous: TaskModel, + *, + feedback: str, + kind: FeedbackKind, +) -> dict[str, Any]: + prior = ( + previous.product_specification if isinstance(previous.product_specification, dict) else {} + ) + original_goal = str(prior.get("original_goal") or previous.goal).strip() + history = [item for item in prior.get("feedback_history", []) if isinstance(item, dict)] + delta = { + "revision": (previous.product_revision or 1) + 1, + "kind": kind, + "feedback": feedback.strip(), + "previous_task_id": str(previous.id), + } + required = [f"Product decision: {feedback.strip()}"] + if kind == "implementation_fix": + required = [ + ( + "The previous verified acceptance contract remains satisfied without regression " + f"(contract {previous.contract_hash})." + ), + f"Regression requirement: {feedback.strip()}", + ] + return { + "schema": PRODUCT_SPECIFICATION_SCHEMA, + "original_goal": original_goal, + "required_acceptance_criteria": required, + "feedback_history": [*history, delta], + "previous_contract_hash": previous.contract_hash, + "previous_receipt_hash": previous.receipt_hash, + } + + +def specification_hash(specification: dict[str, Any]) -> str: + canonical = json.dumps( + specification, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def revision_goal(specification: dict[str, Any], *, feedback: str, kind: FeedbackKind) -> str: + original = str(specification.get("original_goal") or "").strip() + required = [ + str(item).strip() + for item in specification.get("required_acceptance_criteria", []) + if str(item).strip() + ] + label = "implementation correction" if kind == "implementation_fix" else "product decision" + criteria = "\n".join(f"- {item}" for item in required) + return ( + f"Continue the prior verified delivery with this {label}.\n\n" + f"Original product instruction:\n{original}\n\n" + f"Feedback delta:\n{feedback.strip()}\n\n" + f"Non-negotiable acceptance criteria for this revision:\n{criteria}" + ) + + +def required_revision_criteria(task: TaskModel) -> list[str]: + if (task.product_revision or 0) <= 1 or not isinstance(task.product_specification, dict): + return [] + return [ + str(item).strip() + for item in task.product_specification.get("required_acceptance_criteria", []) + if str(item).strip() + ][:12] diff --git a/apps/api/app/services/receipt.py b/apps/api/app/services/receipt.py index d6d3b95..805a1aa 100644 --- a/apps/api/app/services/receipt.py +++ b/apps/api/app/services/receipt.py @@ -15,6 +15,7 @@ import json import platform import sys +import uuid from datetime import UTC, datetime from typing import Any @@ -236,6 +237,26 @@ def build_receipt( } if change_set is not None: body["change_set"] = change_set + product_session_id = ( + task.product_session_id if isinstance(task.product_session_id, uuid.UUID) else None + ) + product_revision = task.product_revision if isinstance(task.product_revision, int) else None + if product_session_id and product_revision and isinstance(task.product_specification, dict): + body["product_revision"] = { + "session_id": str(product_session_id), + "revision": product_revision, + "previous_task_id": ( + str(task.previous_revision_id) + if isinstance(task.previous_revision_id, uuid.UUID) + else None + ), + "feedback_kind": task.feedback_kind if isinstance(task.feedback_kind, str) else None, + "feedback_delta": task.feedback_delta if isinstance(task.feedback_delta, str) else None, + "specification": task.product_specification, + "specification_hash": ( + task.specification_hash if isinstance(task.specification_hash, str) else None + ), + } signer = _signing_key() if signer is not None: body["signature_key_id"] = _signing_key_id(signer) diff --git a/apps/api/app/services/task.py b/apps/api/app/services/task.py index 2894413..99c9bb7 100644 --- a/apps/api/app/services/task.py +++ b/apps/api/app/services/task.py @@ -23,14 +23,22 @@ from app.domain.task import StopReason, TaskStatus from app.exceptions import ConflictError, NotFoundError from app.observability.metrics import RECEIPT_REPLAYS +from app.repositories.product_session import ProductSessionRepository from app.repositories.step import StepRepository from app.repositories.task import TaskRepository -from app.schemas.task import ChangeSetFileRead, ChangeSetRead, LimitsIn, TaskCreate +from app.schemas.task import ( + ChangeSetFileRead, + ChangeSetRead, + LimitsIn, + ProductRevisionCreate, + TaskCreate, +) from app.services.changeset import ( ProjectBinding, acquire_source_lock, apply_patch, clone_project, + clone_project_revision, delete_patch, inspect_changes, load_patch, @@ -40,6 +48,12 @@ ) from app.services.ledger import genesis_hash, step_hash, verify_chain from app.services.loop import LoopEvent, LoopTransitionPolicy +from app.services.product_session import ( + initial_specification, + revised_specification, + revision_goal, + specification_hash, +) from app.tools.base import ToolError from app.tools.workspace import Workspace @@ -76,6 +90,7 @@ def __init__( ) -> None: self.tasks = tasks self.steps = steps + self.product_sessions = ProductSessionRepository(tasks.session) self.subject = subject self._transition_policy = LoopTransitionPolicy() @@ -134,6 +149,14 @@ async def publish(self, payload: TaskCreate) -> TaskModel: else None ) ) + product_session = ( + await self.product_sessions.create(owner_id=self.subject, project_id=payload.project_id) + if binding is not None + else None + ) + product_specification = ( + initial_specification(payload.goal, criteria) if product_session is not None else None + ) task = await self.tasks.create( goal=payload.goal.strip(), owner_id=self.subject, @@ -176,6 +199,16 @@ async def publish(self, payload: TaskCreate) -> TaskModel: project_base_branch=binding.branch if binding else None, change_state="pending" if binding else None, applied_patch_sha256=None, + product_session_id=product_session.id if product_session else None, + product_revision=1 if product_session else None, + previous_revision_id=None, + superseded_by_id=None, + feedback_kind=None, + feedback_delta=None, + product_specification=product_specification, + specification_hash=( + specification_hash(product_specification) if product_specification else None + ), ) if binding is not None: try: @@ -212,8 +245,9 @@ async def _attach_project(self, task: TaskModel, binding: ProjectBinding) -> Non await self.tasks.session.flush() await asyncio.to_thread(clone_project, binding, destination) task.workspace_path = str(destination.resolve()) - await self.tasks.session.commit() + await self.tasks.session.flush() await self.tasks.session.refresh(task) + await self.tasks.session.commit() except Exception: await self.tasks.session.rollback() await asyncio.to_thread(shutil.rmtree, destination, ignore_errors=True) @@ -293,6 +327,174 @@ async def retry(self, task_id: uuid.UUID) -> TaskModel: await self.tasks.session.commit() return task + async def create_revision( + self, + task_id: uuid.UUID, + payload: ProductRevisionCreate, + ) -> TaskModel: + original = await self.get(task_id) + if not original.product_session_id or not original.product_revision: + raise ConflictError("Only local-project Product Session tasks can be revised.") + if original.parent_id is not None: + raise ConflictError("A sub-agent task cannot become a product revision.") + if not original.project_source_path or not original.project_relative_path: + raise ConflictError("This Product Session has no local project binding.") + + source_lock = await asyncio.to_thread(acquire_source_lock, original.project_source_path) + destination: Path | None = None + try: + product_session = await self.product_sessions.get_for_update( + original.product_session_id + ) + await self.tasks.session.refresh(original) + if product_session is None or product_session.owner_id != self.subject: + raise NotFoundError("This Product Session does not exist.") + if original.superseded_by_id is not None: + raise ConflictError("Only the latest Product Session revision can be revised.") + if ( + original.status != TaskStatus.COMPLETED.value + or original.stop_reason != StopReason.GOAL_ACHIEVED.value + or original.verified_by != "execution" + or original.verification_score < settings.agent_acceptance_score + ): + raise ConflictError( + "Feedback can create a revision only after execution-verified delivery." + ) + if original.contract_status != "locked" or not original.contract_hash: + raise ConflictError("The prior delivery has no locked acceptance contract.") + if original.change_state not in {"pending", "reverted"}: + raise ConflictError( + "Create the next revision before Apply, or Undo the applied change set first." + ) + preview = await self.inspect_change_set(original.id) + if not preview.can_apply: + raise ConflictError( + preview.blocked_reason or "The prior delivery cannot seed a safe revision." + ) + receipt_report = await self.get_receipt_report(original.id) + if not receipt_report or not receipt_report.get("valid"): + raise ConflictError("The prior Receipt failed integrity verification.") + if not original.project_base_commit: + raise ConflictError("The prior delivery has no immutable base commit.") + + binding = await asyncio.to_thread(prepare_project, original.project_relative_path) + if binding.base_commit != original.project_base_commit: + raise ConflictError( + "The source project moved to another commit; the revision was refused." + ) + prior_snapshot = await asyncio.to_thread( + inspect_changes, + Path(original.workspace_path or ""), + original.project_base_commit, + ) + specification = revised_specification( + original, + feedback=payload.feedback, + kind=payload.kind, + ) + next_revision = original.product_revision + 1 + required_checks: list[dict[str, Any]] = [] + for raw_check in original.required_checks or []: + check = dict(raw_check) + if payload.kind == "implementation_fix" and check.get("source") == "contract": + check["criterion_ids"] = ["criterion-001"] + required_checks.append(check) + + max_steps, token_budget = self._resolve_limits( + LimitsIn(max_steps=original.max_steps, token_budget=original.token_budget) + ) + task = await self.tasks.create( + goal=revision_goal( + specification, + feedback=payload.feedback, + kind=payload.kind, + ), + owner_id=self.subject, + project_id=original.project_id, + status=TaskStatus.PENDING.value, + rubric=[], + criteria_source="generated", + verification_mode="strict", + required_checks=required_checks, + baseline_checks=[], + contract_draft=None, + contract_hash=None, + contract_status="pending", + requested_capabilities=original.requested_capabilities, + resolved_capabilities=[], + allowed_tools=original.allowed_tools, + allow_egress=original.allow_egress, + egress_hosts=original.egress_hosts, + require_approval=original.require_approval, + use_browser=original.use_browser, + use_email=original.use_email, + use_calendar=original.use_calendar, + use_vision=original.use_vision, + chat_id=original.chat_id, + skill=original.skill, + idempotency_key=None, + attempt=1, + max_steps=max_steps, + token_budget=token_budget, + summary=None, + verification_score=0, + executor_models=[], + verifier_model=None, + authority_audit=[], + steps_used=0, + tokens_used=0, + workspace_path=None, + project_source_path=str(binding.source), + project_relative_path=binding.relative_path, + project_base_commit=binding.base_commit, + project_base_branch=binding.branch, + change_state="pending", + applied_patch_sha256=None, + product_session_id=product_session.id, + product_revision=next_revision, + previous_revision_id=original.id, + superseded_by_id=None, + feedback_kind=payload.kind, + feedback_delta=payload.feedback, + product_specification=specification, + specification_hash=specification_hash(specification), + ) + destination = Path(settings.agent_workspaces_root) / str(task.id) + await asyncio.to_thread( + clone_project_revision, + binding, + destination, + prior_snapshot.patch, + ) + task.workspace_path = str(destination.resolve()) + original.superseded_by_id = task.id + await self.tasks.session.flush() + await self.tasks.session.refresh(task) + await self.tasks.session.commit() + return task + except IntegrityError as exc: + await self.tasks.session.rollback() + if destination is not None: + await asyncio.to_thread(shutil.rmtree, destination, ignore_errors=True) + raise ConflictError("Another Product Session revision won the race.") from exc + except Exception: + await self.tasks.session.rollback() + if destination is not None: + await asyncio.to_thread(shutil.rmtree, destination, ignore_errors=True) + raise + finally: + await asyncio.to_thread(release_source_lock, source_lock) + + async def list_revisions(self, task_id: uuid.UUID) -> list[TaskModel]: + task = await self.get(task_id) + if not task.product_session_id: + raise NotFoundError("This task is not part of a Product Session.") + return [ + revision + for revision in await self.tasks.list_revisions(task.product_session_id) + if revision.owner_id == self.subject + ] + async def list_tasks( self, *, limit: int, offset: int, root_only: bool = True ) -> tuple[list[TaskModel], int]: @@ -403,7 +605,9 @@ async def inspect_change_set(self, task_id: uuid.UUID) -> ChangeSetRead: snapshot = await asyncio.to_thread(inspect_changes, Path(task.workspace_path), base_commit) state = task.change_state or "pending" blocked_reason: str | None = None - if not snapshot.files: + if task.superseded_by_id is not None: + blocked_reason = "A newer Product Session revision supersedes this change set." + elif not snapshot.files: blocked_reason = "The isolated checkout has no project changes." elif task.status != TaskStatus.COMPLETED.value or task.stop_reason != ( StopReason.GOAL_ACHIEVED.value diff --git a/apps/api/tests/test_changeset.py b/apps/api/tests/test_changeset.py index f6f76d6..7904863 100644 --- a/apps/api/tests/test_changeset.py +++ b/apps/api/tests/test_changeset.py @@ -7,13 +7,14 @@ import pytest from filelock import FileLock -from httpx import AsyncClient +from httpx import AsyncClient, Response from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker from app.core.config import settings from app.db.models.task import TaskModel from app.domain.task import StopReason, TaskStatus from app.services.changeset import acquire_source_lock, release_source_lock +from app.services.contract import lock_user_project_contract from app.services.receipt import RECEIPT_SCHEMA, build_receipt from app.services.verification import CheckResult from app.tools.workspace import Workspace @@ -76,6 +77,32 @@ async def _mark_verified(engine: AsyncEngine, task_id: str) -> None: task.verified_by = "execution" task.verification_score = 100 task.summary = "Updated and checked the project." + criteria = list(task.rubric or []) + if task.product_revision and task.product_revision > 1: + specification = task.product_specification or {} + criteria = list(specification.get("required_acceptance_criteria", [])) + criterion_ids = tuple(f"criterion-{index:03d}" for index in range(1, len(criteria) + 1)) + contract_check = { + "id": "contract-test", + "kind": "command", + "command": "true", + "expect_exit": 0, + "criterion_ids": list(criterion_ids), + "source": "contract", + } + draft, contract_hash = lock_user_project_contract( + root=Path(task.workspace_path), + criteria=criteria, + required_checks=[contract_check], + ) + task.rubric = criteria + task.criteria_source = "user" + task.required_checks = [ + check.model_dump(mode="json", exclude_none=True) for check in draft.checks + ] + task.contract_draft = draft.model_dump(mode="json") + task.contract_hash = contract_hash + task.contract_status = "locked" receipt_hash, _ = build_receipt( task, [ @@ -85,8 +112,8 @@ async def _mark_verified(engine: AsyncEngine, task_id: str) -> None: True, "exit code 0", check_id="check-001", - criterion_ids=("criterion-001",), - definition={"kind": "command", "command": "true", "expect_exit": 0}, + criterion_ids=criterion_ids, + definition=contract_check, ) ], score=100, @@ -322,3 +349,202 @@ async def test_apply_refuses_a_diff_changed_after_receipt( response = await client.post(f"/api/v1/tasks/{created['id']}/changes/apply") assert response.status_code == 409 assert "Receipt" in response.json()["detail"] + + +async def test_product_session_revision_preserves_verified_history_and_applies_cumulative_patch( + client: AsyncClient, + engine: AsyncEngine, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + projects = tmp_path / "projects" + projects.mkdir() + source = _repository(projects) + monkeypatch.setattr(settings, "loop_local_projects_root", str(projects)) + monkeypatch.setattr(settings, "agent_workspaces_root", str(tmp_path / "workspaces")) + + created = await client.post( + "/api/v1/tasks", + json={ + "goal": "Change the greeting without breaking the project", + "success_criteria": ["The greeting reflects the requested product behavior."], + "project_path": "project", + "autostart": False, + }, + ) + assert created.status_code == 201 + first = created.json() + assert first["product_revision"]["revision"] == 1 + first_workspace = await _workspace_path(engine, first["id"]) + (first_workspace / "app.py").write_text("print('v1')\n") + (first_workspace / "asset.bin").write_bytes(b"\x00v1") + (first_workspace / "delete.txt").unlink() + (first_workspace / "rename-me.txt").rename(first_workspace / "renamed.txt") + (first_workspace / "new.txt").write_text("v1 output\n") + await _mark_verified(engine, first["id"]) + + revised = await client.post( + f"/api/v1/tasks/{first['id']}/revisions", + json={ + "feedback": "The greeting must say v2 and a regression test must protect it.", + "kind": "implementation_fix", + "autostart": False, + }, + ) + assert revised.status_code == 201 + second = revised.json() + product_revision = second["product_revision"] + assert product_revision["revision"] == 2 + assert product_revision["previous_task_id"] == first["id"] + assert product_revision["is_latest"] is True + assert product_revision["feedback_kind"] == "implementation_fix" + assert product_revision["specification"]["previous_receipt_hash"] + assert product_revision["specification"]["previous_contract_hash"] + assert product_revision["specification"]["required_acceptance_criteria"][1].startswith( + "Regression requirement:" + ) + assert all( + check.get("criterion_ids") == ["criterion-001"] for check in second["required_checks"] + ) + + second_workspace = await _workspace_path(engine, second["id"]) + assert (second_workspace / "app.py").read_text() == "print('v1')\n" + assert (second_workspace / "asset.bin").read_bytes() == b"\x00v1" + assert not (second_workspace / "delete.txt").exists() + assert not (second_workspace / "rename-me.txt").exists() + assert (second_workspace / "renamed.txt").read_text() == "rename me\n" + assert (second_workspace / "new.txt").read_text() == "v1 output\n" + assert (source / "app.py").read_text() == "print('before')\n" + (second_workspace / "app.py").write_text("print('v2')\n") + + history = await client.get(f"/api/v1/tasks/{second['id']}/revisions") + assert history.status_code == 200 + assert [item["product_revision"]["revision"] for item in history.json()] == [1, 2] + page = (await client.get("/api/v1/tasks")).json() + assert page["total"] == 1 + assert page["items"][0]["id"] == second["id"] + + stale = await client.get(f"/api/v1/tasks/{first['id']}/changes") + assert stale.status_code == 200 + assert stale.json()["can_apply"] is False + assert "supersedes" in stale.json()["blocked_reason"] + stale_apply = await client.post(f"/api/v1/tasks/{first['id']}/changes/apply") + assert stale_apply.status_code == 409 + first_receipt = await client.get(f"/api/v1/tasks/{first['id']}/receipt") + assert first_receipt.json()["valid"] is True + assert first_receipt.json()["receipt"]["product_revision"]["revision"] == 1 + + await _mark_verified(engine, second["id"]) + applied = await client.post(f"/api/v1/tasks/{second['id']}/changes/apply") + assert applied.status_code == 200 + assert (source / "app.py").read_text() == "print('v2')\n" + assert (source / "asset.bin").read_bytes() == b"\x00v1" + assert not (source / "delete.txt").exists() + assert not (source / "rename-me.txt").exists() + assert (source / "renamed.txt").read_text() == "rename me\n" + assert (source / "new.txt").read_text() == "v1 output\n" + second_receipt = await client.get(f"/api/v1/tasks/{second['id']}/receipt") + assert second_receipt.json()["receipt"]["product_revision"]["previous_task_id"] == first["id"] + undone = await client.post(f"/api/v1/tasks/{second['id']}/changes/undo") + assert undone.status_code == 200 + assert (source / "app.py").read_text() == "print('before')\n" + assert (source / "asset.bin").read_bytes() == b"\x00before" + assert (source / "delete.txt").read_text() == "remove me\n" + assert (source / "rename-me.txt").read_text() == "rename me\n" + assert not (source / "renamed.txt").exists() + assert not (source / "new.txt").exists() + + +async def test_product_revision_rejects_unverified_and_applied_deliveries( + client: AsyncClient, + engine: AsyncEngine, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + projects = tmp_path / "projects" + projects.mkdir() + _repository(projects) + monkeypatch.setattr(settings, "loop_local_projects_root", str(projects)) + monkeypatch.setattr(settings, "agent_workspaces_root", str(tmp_path / "workspaces")) + created = ( + await client.post( + "/api/v1/tasks", + json={ + "goal": "Make the verified change", + "success_criteria": ["The verified behavior is present."], + "project_path": "project", + "autostart": False, + }, + ) + ).json() + + unverified = await client.post( + f"/api/v1/tasks/{created['id']}/revisions", + json={"feedback": "Fix the remaining bug.", "autostart": False}, + ) + assert unverified.status_code == 409 + + workspace = await _workspace_path(engine, created["id"]) + (workspace / "app.py").write_text("print('verified')\n") + await _mark_verified(engine, created["id"]) + applied = await client.post(f"/api/v1/tasks/{created['id']}/changes/apply") + assert applied.status_code == 200 + refused = await client.post( + f"/api/v1/tasks/{created['id']}/revisions", + json={"feedback": "Fix the remaining bug.", "autostart": False}, + ) + assert refused.status_code == 409 + assert "Undo" in refused.json()["detail"] + undone = await client.post(f"/api/v1/tasks/{created['id']}/changes/undo") + assert undone.status_code == 200 + accepted = await client.post( + f"/api/v1/tasks/{created['id']}/revisions", + json={ + "feedback": "Adopt the corrected product behavior.", + "kind": "product_decision", + "autostart": False, + }, + ) + assert accepted.status_code == 201 + assert accepted.json()["product_revision"]["feedback_kind"] == "product_decision" + + +async def test_product_revision_allows_only_one_concurrent_successor( + client: AsyncClient, + engine: AsyncEngine, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + projects = tmp_path / "projects" + projects.mkdir() + _repository(projects) + monkeypatch.setattr(settings, "loop_local_projects_root", str(projects)) + monkeypatch.setattr(settings, "agent_workspaces_root", str(tmp_path / "workspaces")) + created = ( + await client.post( + "/api/v1/tasks", + json={ + "goal": "Prepare one verified revision base", + "success_criteria": ["The revision base is ready."], + "project_path": "project", + "autostart": False, + }, + ) + ).json() + workspace = await _workspace_path(engine, created["id"]) + (workspace / "app.py").write_text("print('revision base')\n") + await _mark_verified(engine, created["id"]) + + async def revise(feedback: str) -> Response: + return await client.post( + f"/api/v1/tasks/{created['id']}/revisions", + json={"feedback": feedback, "autostart": False}, + ) + + responses = await asyncio.gather( + revise("First concurrent correction."), + revise("Second concurrent correction."), + ) + assert sorted(response.status_code for response in responses) == [201, 409] + history = await client.get(f"/api/v1/tasks/{created['id']}/revisions") + assert len(history.json()) == 2 diff --git a/apps/api/tests/test_contract.py b/apps/api/tests/test_contract.py index db4b0ad..ff91460 100644 --- a/apps/api/tests/test_contract.py +++ b/apps/api/tests/test_contract.py @@ -812,6 +812,43 @@ async def test_compiler_preserves_explicit_advanced_checks(project_settings: Pat assert any(check.path == "user-required.txt" for check in compiled.draft.checks) +async def test_revision_contract_preserves_required_feedback_and_prior_gate( + project_settings: Path, +) -> None: + model = ContractLoopLLM() + required = [ + "The previous verified acceptance contract remains satisfied without regression.", + "Regression requirement: the greeting must remain configurable.", + ] + compiled = await compile_project_contract( + goal="Continue the prior delivery and make the greeting configurable", + root=project_settings, + compiler=model, + critic=model, + granted_capabilities={Capability.FS_READ, Capability.FS_WRITE, Capability.EXEC}, + required_criteria=required, + required_checks=[ + { + "id": "prior-suite", + "kind": "command", + "command": "python3 -m pytest -q", + "criterion_ids": ["criterion-001"], + "source": "contract", + } + ], + token_budget=10_000, + ) + + assert compiled.contract_hash + assert compiled.draft.criteria[:2] == required + feedback_checks = [ + check + for check in compiled.draft.checks + if "criterion-002" in check.criterion_ids and check.id != "contract-user-001" + ] + assert feedback_checks + + def test_user_contract_supports_full_advanced_input_bounds(project_settings: Path) -> None: required_checks = [ { diff --git a/apps/api/tests/test_deployment_boundaries.py b/apps/api/tests/test_deployment_boundaries.py index 6be7c76..b109050 100644 --- a/apps/api/tests/test_deployment_boundaries.py +++ b/apps/api/tests/test_deployment_boundaries.py @@ -305,7 +305,7 @@ def test_kubernetes_acceptance_migrates_runs_task_and_rolls_back() -> None: assert "kubectl rollout undo deployment/api" in script assert 'task["sandbox"] != "kubernetes"' in script assert 'report.get("authentic")' in script - assert "0011_loop_state_machine" in script + assert "0012_product_sessions" in script assert "run_cluster_probe after-postgres-restart true" in script assert "gosu postgres pg_ctl -D /var/lib/postgresql/data -m immediate stop" in script assert "kill -9 1" not in script diff --git a/apps/web/app/tasks/[id]/page.tsx b/apps/web/app/tasks/[id]/page.tsx index 75d1bbd..c223ca8 100644 --- a/apps/web/app/tasks/[id]/page.tsx +++ b/apps/web/app/tasks/[id]/page.tsx @@ -13,6 +13,7 @@ import { ReceiptPanel } from '@/components/receipt-panel'; import { StepItem } from '@/components/step-item'; import { StatusPill, stopReasonLabel } from '@/components/status-pill'; import { LoopState } from '@/components/loop-state'; +import { ProductSessionPanel } from '@/components/product-session-panel'; import { WorkspaceFiles } from '@/components/workspace-files'; import { ApiError, tasksApi } from '@/lib/api-client'; import { apiBaseUrl } from '@/lib/env'; @@ -222,6 +223,8 @@ export default function TaskDetail() { + + {/* The agent's final account of what it did. */} {task.summary && (
diff --git a/apps/web/components/authority-panel.test.tsx b/apps/web/components/authority-panel.test.tsx index 078078c..6ed3718 100644 --- a/apps/web/components/authority-panel.test.tsx +++ b/apps/web/components/authority-panel.test.tsx @@ -47,6 +47,7 @@ const baseTask: Task = { skill: null, parent_id: null, depth: 0, + product_revision: null, idempotency_key: 'publish-1', attempt: 1, limits: { max_steps: 20, token_budget: 20_000 }, diff --git a/apps/web/components/product-session-panel.test.tsx b/apps/web/components/product-session-panel.test.tsx new file mode 100644 index 0000000..2e8b5b5 --- /dev/null +++ b/apps/web/components/product-session-panel.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { Task } from '@repo/api-contract'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProductSessionPanel } from './product-session-panel'; + +const { revisions, createRevision, push } = vi.hoisted(() => ({ + revisions: vi.fn(), + createRevision: vi.fn(), + push: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })); +vi.mock('@/lib/api-client', () => ({ + ApiError: class ApiError extends Error {}, + tasksApi: { revisions, createRevision }, +})); + +const task = { + id: 'task-v1', + status: 'completed', + stop_reason: 'goal_achieved', + verified_by: 'execution', + receipt_hash: 'receipt-v1', + change_set: { state: 'pending' }, + product_revision: { + session_id: 'session-1', + revision: 1, + previous_task_id: null, + superseded_by_task_id: null, + feedback_kind: null, + feedback_delta: null, + specification: { + schema: 'loop.product-specification/v1', + original_goal: 'Ship the greeting', + required_acceptance_criteria: [], + feedback_history: [], + previous_contract_hash: null, + previous_receipt_hash: null, + }, + specification_hash: 'a'.repeat(64), + is_latest: true, + }, +} as unknown as Task; + +describe('ProductSessionPanel', () => { + beforeEach(() => { + revisions.mockReset(); + createRevision.mockReset(); + push.mockReset(); + revisions.mockResolvedValue([task]); + }); + + it('turns feedback into the next linked product revision', async () => { + createRevision.mockResolvedValue({ ...task, id: 'task-v2' }); + render(); + + expect(await screen.findByText('v1 · Receipt')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Feedback type'), { + target: { value: 'product_decision' }, + }); + fireEvent.change(screen.getByLabelText('Continue from this verified delivery'), { + target: { value: 'Make the greeting configurable.' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create next revision' })); + + await waitFor(() => + expect(createRevision).toHaveBeenCalledWith('task-v1', { + feedback: 'Make the greeting configurable.', + kind: 'product_decision', + }), + ); + expect(push).toHaveBeenCalledWith('/tasks/task-v2'); + }); + + it('keeps a superseded revision read-only', async () => { + render( + , + ); + + expect( + await screen.findByText('This is immutable history. Continue from the latest revision.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Create next revision' })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/product-session-panel.tsx b/apps/web/components/product-session-panel.tsx new file mode 100644 index 0000000..84ee3c8 --- /dev/null +++ b/apps/web/components/product-session-panel.tsx @@ -0,0 +1,153 @@ +'use client'; + +import type { Task } from '@repo/api-contract'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { FormEvent, useEffect, useState } from 'react'; +import { ApiError, tasksApi } from '@/lib/api-client'; + +export function ProductSessionPanel({ task }: { task: Task }) { + const router = useRouter(); + const [revisions, setRevisions] = useState([]); + const [feedback, setFeedback] = useState(''); + const [kind, setKind] = useState<'implementation_fix' | 'product_decision'>('implementation_fix'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const revisionNumber = task.product_revision?.revision; + + useEffect(() => { + if (!revisionNumber) return; + tasksApi + .revisions(task.id) + .then(setRevisions) + .catch((reason: unknown) => + setError(reason instanceof ApiError ? reason.message : 'Could not load product revisions.'), + ); + }, [task.id, revisionNumber]); + + if (!task.product_revision) return null; + + const revision = task.product_revision; + const canRevise = + revision.is_latest && + task.status === 'completed' && + task.stop_reason === 'goal_achieved' && + task.verified_by === 'execution' && + (task.change_set?.state === 'pending' || task.change_set?.state === 'reverted'); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (feedback.trim().length < 4) return; + setSubmitting(true); + setError(null); + try { + const next = await tasksApi.createRevision(task.id, { + feedback: feedback.trim(), + kind, + }); + router.push(`/tasks/${next.id}`); + } catch (reason) { + setError(reason instanceof ApiError ? reason.message : 'Could not create the next revision.'); + setSubmitting(false); + } + } + + return ( +
+
+
+

Product Session

+

+ Versioned specifications, feedback deltas, Receipts, and change sets stay linked. +

+
+ + v{revision.revision} + +
+ + {revisions.length > 0 && ( +
+ {revisions.map((item) => { + const itemRevision = item.product_revision; + if (!itemRevision) return null; + const current = item.id === task.id; + return ( + + v{itemRevision.revision} · {item.receipt_hash ? 'Receipt' : item.status} + + ); + })} +
+ )} + + {revision.feedback_delta && ( +
+ + {revision.feedback_kind === 'implementation_fix' + ? 'Implementation correction' + : 'Product decision'} + +

{revision.feedback_delta}

+
+ )} + +

+ specification {revision.specification_hash} +

+ + {canRevise ? ( +
+ + +