Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ boundary.
- [Design principles](#design-principles)
- [System topology](#system-topology)
- [Backend: layered architecture](#backend-layered-architecture)
- [Loop orchestration](#loop-orchestration)
- [Request lifecycle](#request-lifecycle)
- [Data & persistence](#data--persistence)
- [Caching & background work](#caching--background-work)
Expand Down Expand Up @@ -102,6 +103,57 @@ slice rather than bypassing the service/repository boundary.

---

## Loop orchestration

`AgentReactService` is the runtime coordinator, not the source of every orchestration
decision. It wires model clients, sandboxes, gateways, repositories, and Receipts around
small policy components with explicit inputs and no side effects:

| Component | Owns |
| ---------------------- | --------------------------------------------------------------- |
| `domain/loop.py` | allowed lifecycle transitions and status/stop-reason projection |
| `loop/decisions.py` | extraction and validation of one model action |
| `loop/context.py` | planner/verifier token allocation and bounded history |
| `progress.py` | semantic no-progress and repeated-branch detection |
| `loop/dispatch.py` | invalid, blocked, approval, or executable routing |
| `loop/verification.py` | evidence gates, coverage, verifier verdict, and acceptance |
| `loop/delegation.py` | child step/token allocation within the parent's remainder |

The lifecycle is persisted as `loop_state`, `transition_reason`, and a monotonically
increasing `transition_sequence`. `status` remains the coarse external compatibility
projection. A transition not present in the policy fails closed.

```mermaid
stateDiagram-v2
[*] --> queued
queued --> preparing: claim
preparing --> understanding: contract or rubric required
preparing --> planning: runtime prepared
understanding --> planning: contract or rubric ready
planning --> acting: action selected
acting --> planning: action recorded
planning --> verifying: verification requested
verifying --> planning: evidence rejected
verifying --> completed: evidence accepted
preparing --> awaiting_input: clarification required
acting --> awaiting_input: input required
acting --> awaiting_approval: approval required
awaiting_input --> queued: user responded
awaiting_approval --> queued: user responded
preparing --> stopped: bounded limit
understanding --> stopped: bounded limit
planning --> stopped: bounded limit
acting --> stopped: bounded limit
verifying --> stopped: bounded limit
```

Every active phase also allows cancellation and failure. Every working phase can recover
to `preparing`; the durable runner normally requeues an interrupted task first, while
atomic claiming also repairs a legacy or partially upgraded `pending`/working-state row.
Waiting phases are deliberately excluded from automatic claiming.

---

## Request lifecycle

```mermaid
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ an instruction to an accepted result. It separates **work** from **acceptance**:
- **Interruption is a first-class state.** In-flight cancellation tears down the active
execution boundary, while a durable operation journal refuses crash recovery that
could repeat a mutation with an unknown outcome.
- **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.

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
Expand Down Expand Up @@ -158,6 +161,9 @@ and residual risks.
first mutation, and an explicit pause when risk, confidence, or authority is not
safe to infer.
- ReAct planning with independent executor/verifier provider selection and fallback.
- A persisted fail-closed loop state machine with independently tested policies for
transitions, decision parsing, context budgeting, progress control, dispatch,
verification, and delegation.
- Strict acceptance contracts with required final artifacts, independent check
snapshots, baseline regression checks, criterion-to-evidence mappings, Receipt
replay, and offline verification.
Expand Down
63 changes: 63 additions & 0 deletions apps/api/alembic/versions/0011_loop_state_machine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Persist the explicit loop state and latest transition reason.

Revision ID: 0011_loop_state_machine
Revises: 0010_operation_journal
"""

from __future__ import annotations

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "0011_loop_state_machine"
down_revision: str | None = "0010_operation_journal"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("loop_state", sa.String(length=30), nullable=False, server_default="queued"),
)
op.add_column("tasks", sa.Column("transition_reason", sa.Text(), nullable=True))
op.add_column(
"tasks",
sa.Column("transition_sequence", sa.Integer(), nullable=False, server_default="0"),
)
op.create_index("ix_tasks_loop_state", "tasks", ["loop_state"], unique=False)
op.execute(
"""
UPDATE tasks
SET loop_state = CASE
WHEN status = 'pending' THEN 'queued'
WHEN status = 'running' THEN 'preparing'
WHEN status = 'awaiting_input' THEN 'awaiting_input'
WHEN status = 'completed' AND stop_reason = 'goal_achieved' THEN 'completed'
WHEN status = 'completed' THEN 'stopped'
WHEN status = 'stopped' THEN 'stopped'
WHEN status = 'cancelled' THEN 'cancelled'
WHEN status = 'failed' THEN 'failed'
ELSE 'failed'
END,
status = CASE
WHEN status = 'completed' AND COALESCE(stop_reason, '') <> 'goal_achieved'
THEN 'stopped'
WHEN status IN (
'pending', 'running', 'awaiting_input', 'completed',
'stopped', 'cancelled', 'failed'
) THEN status
ELSE 'failed'
END,
transition_reason = 'migrated_from_task_status'
"""
)


def downgrade() -> None:
op.drop_index("ix_tasks_loop_state", table_name="tasks")
op.drop_column("tasks", "transition_sequence")
op.drop_column("tasks", "transition_reason")
op.drop_column("tasks", "loop_state")
6 changes: 6 additions & 0 deletions apps/api/app/db/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.domain.loop import LoopState
from app.domain.task import TaskStatus


Expand All @@ -29,6 +30,11 @@ class TaskModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
status: Mapped[str] = mapped_column(
String(20), nullable=False, default=TaskStatus.PENDING.value, index=True
)
loop_state: Mapped[str] = mapped_column(
String(30), nullable=False, default=LoopState.QUEUED.value, index=True
)
transition_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
transition_sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
rubric: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
criteria_source: Mapped[str] = mapped_column(String(20), nullable=False, default="generated")
verification_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="judgment")
Expand Down
178 changes: 178 additions & 0 deletions apps/api/app/domain/loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""The authoritative task-loop transition policy."""

from __future__ import annotations

import enum
from dataclasses import dataclass
from typing import Protocol

from app.domain.task import StopReason, TaskStatus


class LoopState(enum.StrEnum):
QUEUED = "queued"
PREPARING = "preparing"
UNDERSTANDING = "understanding"
PLANNING = "planning"
ACTING = "acting"
VERIFYING = "verifying"
AWAITING_INPUT = "awaiting_input"
AWAITING_APPROVAL = "awaiting_approval"
COMPLETED = "completed"
STOPPED = "stopped"
CANCELLED = "cancelled"
FAILED = "failed"


class LoopEvent(enum.StrEnum):
CLAIM = "claim"
RECOVER = "recover"
CONTRACT_REQUIRED = "contract_required"
CONTRACT_READY = "contract_ready"
RUBRIC_REQUIRED = "rubric_required"
PREPARATION_COMPLETED = "preparation_completed"
RUBRIC_READY = "rubric_ready"
ACTION_SELECTED = "action_selected"
ACTION_RECORDED = "action_recorded"
VERIFICATION_REQUESTED = "verification_requested"
VERIFICATION_REJECTED = "verification_rejected"
VERIFICATION_ACCEPTED = "verification_accepted"
INPUT_REQUIRED = "input_required"
APPROVAL_REQUIRED = "approval_required"
USER_RESPONDED = "user_responded"
LIMIT_REACHED = "limit_reached"
CANCEL = "cancel"
FAIL = "fail"


class InvalidLoopTransitionError(ValueError):
pass


class TransitionTarget(Protocol):
loop_state: str
transition_reason: str | None
transition_sequence: int
status: str
stop_reason: str | None


@dataclass(frozen=True, slots=True)
class LoopTransition:
sequence: int
source: LoopState
target: LoopState
event: LoopEvent
reason: str


_WORKING = {
LoopState.PREPARING,
LoopState.UNDERSTANDING,
LoopState.PLANNING,
LoopState.ACTING,
LoopState.VERIFYING,
}
_ACTIVE = {LoopState.QUEUED, *_WORKING, LoopState.AWAITING_INPUT, LoopState.AWAITING_APPROVAL}

_RULES: dict[tuple[LoopState, LoopEvent], LoopState] = {
(LoopState.QUEUED, LoopEvent.CLAIM): LoopState.PREPARING,
(LoopState.PREPARING, LoopEvent.CONTRACT_REQUIRED): LoopState.UNDERSTANDING,
(LoopState.UNDERSTANDING, LoopEvent.CONTRACT_READY): LoopState.PLANNING,
(LoopState.PREPARING, LoopEvent.RUBRIC_REQUIRED): LoopState.UNDERSTANDING,
(LoopState.PREPARING, LoopEvent.PREPARATION_COMPLETED): LoopState.PLANNING,
(LoopState.UNDERSTANDING, LoopEvent.RUBRIC_READY): LoopState.PLANNING,
(LoopState.PLANNING, LoopEvent.ACTION_SELECTED): LoopState.ACTING,
(LoopState.ACTING, LoopEvent.ACTION_RECORDED): LoopState.PLANNING,
(LoopState.PLANNING, LoopEvent.VERIFICATION_REQUESTED): LoopState.VERIFYING,
(LoopState.VERIFYING, LoopEvent.VERIFICATION_REJECTED): LoopState.PLANNING,
(LoopState.VERIFYING, LoopEvent.VERIFICATION_ACCEPTED): LoopState.COMPLETED,
(LoopState.PREPARING, LoopEvent.INPUT_REQUIRED): LoopState.AWAITING_INPUT,
(LoopState.UNDERSTANDING, LoopEvent.INPUT_REQUIRED): LoopState.AWAITING_INPUT,
(LoopState.ACTING, LoopEvent.INPUT_REQUIRED): LoopState.AWAITING_INPUT,
(LoopState.ACTING, LoopEvent.APPROVAL_REQUIRED): LoopState.AWAITING_APPROVAL,
(LoopState.AWAITING_INPUT, LoopEvent.USER_RESPONDED): LoopState.QUEUED,
(LoopState.AWAITING_APPROVAL, LoopEvent.USER_RESPONDED): LoopState.QUEUED,
}
for state in _WORKING:
_RULES[(state, LoopEvent.RECOVER)] = LoopState.PREPARING
_RULES[(state, LoopEvent.LIMIT_REACHED)] = LoopState.STOPPED
for state in _ACTIVE:
_RULES[(state, LoopEvent.CANCEL)] = LoopState.CANCELLED
_RULES[(state, LoopEvent.FAIL)] = LoopState.FAILED

_STATUS_BY_STATE = {
LoopState.QUEUED: TaskStatus.PENDING,
LoopState.PREPARING: TaskStatus.RUNNING,
LoopState.UNDERSTANDING: TaskStatus.RUNNING,
LoopState.PLANNING: TaskStatus.RUNNING,
LoopState.ACTING: TaskStatus.RUNNING,
LoopState.VERIFYING: TaskStatus.RUNNING,
LoopState.AWAITING_INPUT: TaskStatus.AWAITING_INPUT,
LoopState.AWAITING_APPROVAL: TaskStatus.AWAITING_INPUT,
LoopState.COMPLETED: TaskStatus.COMPLETED,
LoopState.STOPPED: TaskStatus.STOPPED,
LoopState.CANCELLED: TaskStatus.CANCELLED,
LoopState.FAILED: TaskStatus.FAILED,
}


class LoopTransitionPolicy:
@staticmethod
def claimable_states() -> frozenset[LoopState]:
return frozenset({LoopState.QUEUED, *_WORKING})

@staticmethod
def allowed_events(state: LoopState) -> frozenset[LoopEvent]:
return frozenset(event for source, event in _RULES if source is state)

@staticmethod
def status_for(state: LoopState) -> TaskStatus:
return _STATUS_BY_STATE[state]

def apply(
self,
task: TransitionTarget,
event: LoopEvent,
reason: str,
*,
stop_reason: StopReason | None = None,
) -> LoopTransition:
try:
source = LoopState(task.loop_state)
except ValueError as exc:
raise InvalidLoopTransitionError(f"Unknown loop state {task.loop_state!r}") from exc
target = _RULES.get((source, event))
if target is None:
raise InvalidLoopTransitionError(f"{event.value} is not allowed from {source.value}")
if event is LoopEvent.LIMIT_REACHED and stop_reason not in {
StopReason.MAX_STEPS,
StopReason.BUDGET_EXHAUSTED,
StopReason.STUCK,
}:
raise InvalidLoopTransitionError("A limit transition needs a bounded stop reason")

sequence = task.transition_sequence + 1
transition = LoopTransition(
sequence=sequence,
source=source,
target=target,
event=event,
reason=reason.strip() or event.value,
)
task.loop_state = target.value
task.transition_reason = transition.reason
task.transition_sequence = sequence
task.status = _STATUS_BY_STATE[target].value
if target is LoopState.COMPLETED:
task.stop_reason = StopReason.GOAL_ACHIEVED.value
elif target is LoopState.STOPPED:
assert stop_reason is not None
task.stop_reason = stop_reason.value
elif target is LoopState.CANCELLED:
task.stop_reason = StopReason.CANCELLED.value
elif target is LoopState.FAILED:
task.stop_reason = StopReason.ERROR.value
else:
task.stop_reason = None
return transition
Loading
Loading