From 0f06f9ec381c0b4a7f5e15387504b233c0f4e82b Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:26:52 +0100 Subject: [PATCH 01/12] chore: start work on #25 From be41ac364b2abc634f4ffef116c1c3ad6b27ee02 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:49:52 +0100 Subject: [PATCH 02/12] docs: agent lifecycle model design and implementation plan (#25) --- ...2026-09-27-agent-lifecycle-model-design.md | 281 +++ .../specs/2026-09-27-agent-lifecycle-model.md | 1564 +++++++++++++++++ 2 files changed, 1845 insertions(+) create mode 100644 .agents/specs/2026-09-27-agent-lifecycle-model-design.md create mode 100644 .agents/specs/2026-09-27-agent-lifecycle-model.md diff --git a/.agents/specs/2026-09-27-agent-lifecycle-model-design.md b/.agents/specs/2026-09-27-agent-lifecycle-model-design.md new file mode 100644 index 0000000..7aeae06 --- /dev/null +++ b/.agents/specs/2026-09-27-agent-lifecycle-model-design.md @@ -0,0 +1,281 @@ +# Agent Lifecycle Model — Design + +> Issue: #25 (scope redesigned in planning) · PR: #105 · Branch: `feature/agent-lifecycle-model` +> Date: 2026-09-27 · Status: Approved in brainstorming +> Depends on: 2026-09-13 Sessions/Events/Participants ADR, 2026-09-20 Context Vault Data Model ADR +> Consumers: Agent Manager roadmap #2–#6, Context Manager #5, Agent Manager UI + +## Problem Statement + +Issue #25 as originally written ("spawned, running, paused, resumed, terminated") +places a single lifecycle on the **agent definition** (`agents.status`). That +contradicts two requirements surfaced during planning: + +1. **Isolation** — one user talking to the same agent in multiple sessions (or + multiple users) must not share running state. A `status` column on the + definition row makes pause in one session pause it everywhere. +2. **Mobility** — an agent's running context should be movable to a new session + in the future. A lifecycle welded to a single row offers no seam between + "the thing the user configured" and "the thing currently executing". + +The shipped schema already half-agrees: `sessions` owns its own status, and the +2026-09-13 ADR deliberately deferred orchestration (`mode`, +`driver_participant_id`, `turn_policy`) to this area of work. + +## Design Decisions (from brainstorming) + +| # | Decision | Rejected alternative | +|---|----------|----------------------| +| 1 | Split **definition** (persistent config) from **instance** (ephemeral runtime binding to one session) | Lifecycle on the `agents` singleton | +| 2 | The session is the scheduler: "this agent doesn't get the next turn" is session turn-policy, not instance state | Instance-level `paused` gate | +| 3 | Instances are **ephemeral**: created on spawn, hard-deleted on removal. They carry **no context** — live context *is* the session transcript; long-term memory *is* the vault | Persistent instance rows with `terminated` tombstones; dormant detach state | +| 4 | `pause` exists only on the **definition** ("don't run this agent anywhere"); terminate-in-session is `session_participants.left_at` | `paused`/`terminated` instance states | +| 5 | Run archival is **decoupled from instance lifecycle**: `run_record` appends ride the turn boundary; instance destroy is a pure row delete | Archive-on-destroy hook | +| 6 | Definition assignments (prompt, skills, preferences) are **named vault-item references in a JSON column**, per the vault ADR's convention | Inline prompt TEXT; eager link tables | + +## Entity Model + +```mermaid +graph TD + A["Agent definition - agents table - persistent - name, model_binding, assignments, status"] + I["Agent instance - agent_instances table - ephemeral - idle or active, bound to one session"] + S["Session - sessions table - shipped - container and future scheduler"] + V["Vault - run_summary and run_record items"] + A -->|"spawn creates zero or more"| I + I -->|"binding: exactly one live session"| S + V -.->|"continuity across instances"| A +``` + +- **Definition** (`agents`): user-authored configuration; read-only at runtime. + The only place `paused` exists. +- **Instance** (`agent_instances`, new): the runtime binding of a definition to + a session. Exists only while it can take turns. Holds no private context, so + nothing is stored twice — the vault and the transcript remain the sole + persistent context stores. +- **Session**: unchanged by this spec. Owns its lifecycle + (`active | waiting | completed | failed | cancelled`) and will own + `turn_policy` (2026-09-13 ADR deferral). + +**Isolation guarantee:** N sessions running one agent produce N independent +instances with zero shared mutable state. The definition row is never written +during execution; `instance_id` keys all running state. + +**Mobility:** "moving an instance to a new session" = spawn a fresh instance in +the target session. Continuity flows from the unchanged definition plus vault +retrieval (`run_record` provenance is keyed by `session_id`/`agent_id`, never +`instance_id`). No detach races, no garbage collection of dormant rows. + +## Schema Changes + +### `agents` (modified) + +| Column | Change | Notes | +|---|---|---| +| `id`, `name`, `created_at` | keep | | +| `model_binding` (JSON) | **replaces `model_tag`** | Pydantic discriminated union (below). Migration copies existing `model_tag` values into tag form | +| `assignments` (JSON) | new | Named vault-item references (below) | +| `status` | narrow to `active \| paused` | `terminated` dropped for definitions. App-validated enum `AgentStatus`; deleting a definition is a row delete (see Deletion caveat) | + +`ModelBinding` union (discriminator `kind`): + +```json +{"kind": "tag", "tag": "thinking"} +{"kind": "explicit", "adapter": "openai", "model": "llama3"} +``` + +`AgentAssignments` (extra keys allowed, per vault `meta` convention): + +```json +{"prompt": "", "skills": [""], "preference_tags": [""]} +``` + +- References are app-validated strings; the DB does not understand them + (deliberate, matching `vault_items.meta` conventions from 2026-09-20). +- **Dangling references are tolerated at resolution time** (skip + warn): + vault items may be edited/deleted between turns; an agent with a missing + prompt simply runs without a system prompt. +- **Link-table promotion triggers** (recorded per the 2026-09-20 deferral + rule): promote `agent_prompts`/`agent_skills`/`agent_preferences` join tables + when (i) a consumer needs reverse lookup ("which agents use vault item X"), + or (ii) vault deletion requires DB-enforced `RESTRICT` semantics. + +### `agent_instances` (new) + +| Column | Type | Notes | +|---|---|---| +| `id` | TEXT PK | ULID | +| `agent_id` | FK → `agents.id`, `RESTRICT` | refuse deleting an agent with live instances | +| `session_id` | FK → `sessions.id`, `CASCADE` | session ends → instances die | +| `status` | TEXT, app-validated `InstanceStatus` | `idle \| active` — TEXT by house style (no DB CHECK; cf. 2026-09-13 ADR rationale for `events.kind`) | +| `created_at` / `updated_at` | `UTCDateTime` | | + +Constraint: `UniqueConstraint(agent_id, session_id)` — one live instance per +agent per session. Rows are hard-deleted on destroy, so a later re-invite of +the same agent into the same session inserts cleanly under a fresh `instance_id` +and extends the same `(session_id, agent_id)` archival lineage. + +### Vocabulary placement + +`AgentStatus`, `InstanceStatus`, `ModelBinding`, `AgentAssignments` live in +`octave.db.types` alongside `EventKind`/`VaultKind`, following the established +app-validated-enum pattern. The ORM `AgentInstance` model lives in +`octave.db.models.instances`. + +## Lifecycle State Machine + +```mermaid +stateDiagram-v2 + [*] --> idle: spawn agent in session + idle --> active: begin turn + active --> idle: end turn or turn failure + idle --> [*]: destroy on participant removal or session end + active --> [*]: destroy while running cancels then deletes +``` + +All transitions are enforced by a single write path — `AgentInstanceManager` +(in `octave.agent`), which follows the `VaultStore` transaction convention: +operates on a caller-supplied session, **never commits**. + +1. **spawn(agent, session)** — requires definition `status = active` and a + resolvable tag-form binding; requires the session not be in a terminal + status. Creates the instance row (`idle`) and a `session_participants` + membership row (`role = speaker`) if absent. Spawning a paused definition + raises `AgentPausedError`. Spawning a duplicate raises `InstanceExistsError`. +2. **begin_turn(instance)** — `idle → active` via atomic + `UPDATE ... WHERE id = ? AND status = 'idle'`; rowcount 0 raises + `TurnInProgressError`. This mutex *is* the interference guarantee: two + callers can never drive one instance concurrently. +3. **end_turn(instance)** — `active → idle`. Turn failure (adapter error, + `ToolLoopLimitError`) still lands `idle`; the error is an event in the + session, not instance state. There is no `failed` instance status — sessions + already own `failed`. +4. **destroy(instance)** — pure row delete. No hook, no archival coupling, + nothing to roll back. Triggered by participant removal (`left_at` set) or + session end (FK cascade). +5. **Crash reconciliation** — a manager startup hook resets any `active` row + to `idle` with a warning log. The interrupted turn is already visible as a + truncated transcript in `events`; no zombie states survive restart. + +**Pause semantics:** pausing a definition prevents new spawns and new +`begin_turn` calls; already-`active` turns run to completion (no kill — pause +is a policy gate, not an interrupt). Resuming restores both. + +**Mapping from the original issue vocabulary:** `spawned` → spawn + `idle`; +`running` → `active`; `paused` → definition status (session-scoped refusal is +`turn_policy`, deferred); `resumed` → transition, not a state; `terminated` → +`left_at` in-session, definition pause everywhere; instance destroy carries no +name because it is just a delete. + +## Model Binding Resolution + +At spawn (or first turn), the manager resolves `model_binding` to a concrete +`(adapter, model)` pair for `CompletionRequest`: + +- `tag` form → resolved through the inference registry's model-tag lookup + (roadmap Inference #7). **Tag miss fails loud at spawn**: an agent with no + usable model is unusable; silent fallback would hide misconfiguration. +- `explicit` form → used directly. Adapter existence is validated when the + definition is saved (a contract for the future definition CRUD routes), not + at spawn — config may legitimately change between turns. + +The resolved pair is **not persisted** on the instance: binding edits take +effect on the next turn, and the instance stays a thin binding record. + +## Archival Contract (turn boundary, not instance lifecycle) + +An agent leaving a session does not end the run — other participants remain and +the session may continue. Archival therefore rides the **turn boundary**: + +- **`run_record`** (verbatim chunks): extended at `end_turn` to cover the + completed `seq` range, scoped by existing vault provenance + (`session_id`, `agent_id`, `seq_range`). Cheap (no inference), always + current, crash-safe: a lost append is recoverable because `events` remains + the source of truth. +- **`run_summary`** (headline prose): regeneration requires an LLM call, so + per-turn refresh is not mandated. This spec records the requirement — the + summary stays fresh with respect to the session — and defers trigger policy + (per-N-turns / session-end / on-demand) to Context Manager #5, which the + 2026-09-20 ADR already designated as owner of archival triggers. +- The mechanism (callback, event, or direct `VaultStore` composition) is chosen + by CM #5. This spec's obligation is only that the turn-completion path + exposes the completed `seq` range per `(session_id, agent_id)`. + +## Integration Caveats + +- **Definition deletion is app-guarded, not solved here.** + `participants.agent_id` is `ON DELETE CASCADE` (`core.py`), and events cascade + from participants — deleting a definition row would silently destroy + transcript authorship. Rule recorded: deletion is refused while any session + references the agent; full deletion semantics (tombstone vs. authorship + rewriting) is a **follow-up issue**. +- **`events.instance_id` is deferred**, recorded as a known one-way door: a + nullable column with a cheap ALTER while the table is small. Per-instance + provenance is added only when a consumer needs it. + +## Non-Goals + +- Message router (roadmap #3), result collector (#4), inter-agent sharing (#5), + priority & scheduling (#6). +- Session `turn_policy` / multi-agent orchestration columns (2026-09-13 ADR + deferral; this spec supplies the instance entity they will act on). +- REST routes and frontend wiring for definitions or instances. +- Run archival implementation (CM #5) — contract only. +- Link tables, definition tombstones, `events.instance_id` (all recorded above + with promotion triggers). + +Library-level scope only — consistent with how `octave.agent` has shipped so +far (`ToolLoop` is unwired by design; composition arrives with Integration & +Testing #1). + +## Testing + +- **Transition table**: every legal edge; every illegal edge (`begin_turn` on + `active`, `end_turn` on `idle`, spawn paused definition, spawn into terminal + session, duplicate spawn). +- **Turn mutex**: concurrent `begin_turn` calls → exactly one wins. +- **Cascade**: session delete destroys instances; agent delete refused while + instances exist (`RESTRICT`). +- **Migration**: existing `model_tag` values survive into `model_binding` tag + form; model-vs-migration equivalence test per the 2026-09-13 adapter ADR. +- **Binding resolution**: tag hit / tag miss (fail loud) / explicit pair. +- **Crash reconciliation**: stale `active` row resets to `idle` on manager + startup. +- **Pause semantics**: paused definition blocks spawn and `begin_turn`; does + not interrupt an `active` turn. +- **Assignments validation**: shape validation, extra keys allowed, dangling + references skipped with warning at resolution. +- **Archival decoupling**: destroy performs no archival; `end_turn` exposes the + completed seq range (stub consumer asserts the contract). + +## Implementation Sketch + +Paths for the implementation plan to refine: + +| File | Change | +|---|---| +| `backend/src/octave/db/types.py` | `AgentStatus`, `InstanceStatus`, `ModelBinding`, `AgentAssignments` | +| `backend/src/octave/db/models/core.py` | `Agent`: `model_binding`, `assignments`, narrowed status docstring | +| `backend/src/octave/db/models/instances.py` | new `AgentInstance` model | +| `backend/src/octave/db/migrations/versions/` | revision: alter `agents`, create `agent_instances`, copy `model_tag` → `model_binding` | +| `backend/src/octave/agent/instances.py` | `AgentInstanceManager` (spawn / begin_turn / end_turn / destroy / reconcile) | +| `backend/src/octave/agent/errors.py` | `AgentPausedError`, `InstanceExistsError`, `TurnInProgressError`, `InstanceNotFoundError` | +| `backend/tests/db/` | model + migration + types tests | +| `backend/tests/agent/test_instances.py` | manager transition, mutex, pause, reconcile tests | + +## Consequences + +- `octave.agent` gains its first DB-touching component; the SDK-quarantine AST + guard is unaffected (no `openai`/`mcp` imports). +- Agent Manager #2 (registry) becomes queries over `agent_instances` rather + than a parallel bookkeeping structure. +- Context Manager #5 inherits a well-keyed archival surface: + `(session_id, agent_id, seq_range)` — no `instance_id` in provenance. +- The original issue's five-state machine is deliberately **not** implemented; + this spec supersedes its vocabulary with the entity split recorded above. + +## Follow-up Issues to File + +1. Definition deletion semantics (tombstone vs. refuse-forever; authorship + preservation under `participants` cascade). +2. Surface for definition CRUD routes to validate `ModelBinding.explicit` + adapter existence at save time. diff --git a/.agents/specs/2026-09-27-agent-lifecycle-model.md b/.agents/specs/2026-09-27-agent-lifecycle-model.md new file mode 100644 index 0000000..450e2a7 --- /dev/null +++ b/.agents/specs/2026-09-27-agent-lifecycle-model.md @@ -0,0 +1,1564 @@ +# Agent Lifecycle Model Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the agent definition/instance split: `AgentStatus`/`InstanceStatus`/`ModelBinding`/`AgentAssignments` vocabulary, the `agent_instances` table, the `agents` column rework with data migration, and the `AgentInstanceManager` enforcing the spawn → idle ⇄ active → destroy lifecycle. + +**Architecture:** Definitions (`agents`) are persistent config with a definition-level `active|paused` gate. Instances (`agent_instances`) are ephemeral rows binding one definition to one session, existing only while they can take turns — hard-deleted on removal, carrying no context (the session transcript and the vault own all context). `AgentInstanceManager` in `octave.agent` is the single write path, following the `VaultStore` convention: caller-supplied `AsyncSession`, never commits. + +**Tech Stack:** Python 3.12, SQLAlchemy 2.0 async ORM, Pydantic v2 (discriminated unions), Alembic (SQLite), pytest + pytest-asyncio (`asyncio_mode = "auto"`), ruff, mypy strict. + +**Spec:** [`.agents/specs/2026-09-27-agent-lifecycle-model-design.md`](./2026-09-27-agent-lifecycle-model-design.md) +**Branch:** `feature/agent-lifecycle-model` · **Draft PR:** https://github.com/Svagtlys/Octave/pull/105 + +--- + +## Conventions (read before starting) + +- All commands run from `backend/` (e.g. `uv run pytest tests/db/test_types.py -v`). +- Tests hit a **real temp-file SQLite DB** via the `session_factory` fixture — no mocks (see `tests/db/conftest.py`). +- Enum-ish columns are `TEXT` with app-level `StrEnum` validation; **no DB `CHECK`** on status values (ADR 2026-09-13). +- Every constraint carries an explicit name (`uq_…`, `pk_…`) — see `models/base.py` docstring. +- The manager **never commits**; tests commit explicitly. +- `octave.agent` must never import `openai` or `mcp` (AST guard in `tests/agent/test_package.py`). `octave.db` imports are allowed. +- IDs: `uuid.uuid4().hex`, app-generated (house convention; the spec says "ULID" — see Deviations). + +## Deviations from spec (recorded deliberately) + +1. **ULID → uuid4 hex.** The shipped convention is uuid4 hex (`models/__init__.py` docstring). Nothing consumes lexicographic ordering. +2. **Tag resolution lands at turn start, not spawn.** Spec §4 allows "at spawn (or first turn)". The tag→model lookup API doesn't exist yet (Inference roadmap #7), so `resolve_model()` ships as a pure function with an injected `tag_lookup`, ready for the turn runner. Spawn still fails loud on a **missing or malformed** binding. + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `backend/src/octave/db/types.py` | Modify | Add `AgentStatus`, `InstanceStatus`, `TagModelBinding`, `ExplicitModelBinding`, `ModelBinding`, `AgentAssignments` | +| `backend/src/octave/db/models/core.py` | Modify | `Agent`: `model_binding` JSON replaces `model_tag`; add `assignments` JSON | +| `backend/src/octave/db/models/instances.py` | Create | `AgentInstance` ORM model | +| `backend/src/octave/db/models/__init__.py` | Modify | Re-export `AgentInstance` | +| `backend/src/octave/db/migrations/versions/b7c3f1a2d9e4_agent_definition_rework.py` | Create | `agents`: add `model_binding`/`assignments`, copy `model_tag`, drop `model_tag` | +| `backend/src/octave/db/migrations/versions/c9d4e2f6a1b8_agent_instances.py` | Create | Create `agent_instances` | +| `backend/src/octave/db/migrations/__init__.py` | Modify | `upgrade()` gains optional `revision` param | +| `backend/src/octave/agent/errors.py` | Modify | Lifecycle error classes | +| `backend/src/octave/agent/instances.py` | Create | `AgentInstanceManager`, `resolve_model`, `ResolvedModel` | +| `backend/src/octave/agent/__init__.py` | Modify | Re-export new public names | +| `backend/tests/conftest.py` | Create | Move `engine`/`session_factory` fixtures up from `tests/db/` so `tests/agent/` can use them | +| `backend/tests/db/conftest.py` | Modify | Remove moved fixtures (inherit from parent) | +| `backend/tests/db/test_types.py` | Modify | Vocabulary tests | +| `backend/tests/db/test_models.py` | Modify | Agent column tests + instance constraint tests | +| `backend/tests/db/test_migrations.py` | Modify | `EXPECTED_TABLES`, data-migration test | +| `backend/tests/agent/test_instances.py` | Create | Manager transition/mutex/pause/reconcile tests | +| `backend/tests/agent/test_package.py` | Modify | Public-name list | + +Every task ends green: commit only when the **full backend suite** plus `ruff check .` and `mypy src` pass. + +--- + +## Task 1: DB vocabulary types + +**Files:** +- Modify: `backend/src/octave/db/types.py` +- Test: `backend/tests/db/test_types.py` + +- [ ] **Step 1: Commit the design docs** (deferred from architect mode) + +```bash +git add .agents/specs/2026-09-27-agent-lifecycle-model-design.md .agents/specs/2026-09-27-agent-lifecycle-model.md +git commit -m "docs: agent lifecycle model design and implementation plan (#25)" +``` + +- [ ] **Step 2: Write the failing tests** — append to `backend/tests/db/test_types.py` + +```python +from pydantic import TypeAdapter, ValidationError + +from octave.db.types import ( + AgentAssignments, + AgentStatus, + InstanceStatus, + ModelBinding, +) + + +def test_agent_status_values() -> None: + assert AgentStatus.ACTIVE == "active" + assert AgentStatus.PAUSED == "paused" + + +def test_instance_status_values() -> None: + assert InstanceStatus.IDLE == "idle" + assert InstanceStatus.ACTIVE == "active" + + +def test_model_binding_tag_form_parses() -> None: + binding = TypeAdapter(ModelBinding).validate_python({"kind": "tag", "tag": "quick"}) + assert binding.kind == "tag" + assert binding.tag == "quick" + + +def test_model_binding_explicit_form_parses() -> None: + binding = TypeAdapter(ModelBinding).validate_python( + {"kind": "explicit", "adapter": "openai", "model": "llama3"} + ) + assert binding.kind == "explicit" + assert binding.adapter == "openai" + assert binding.model == "llama3" + + +def test_model_binding_unknown_kind_rejected() -> None: + with pytest.raises(ValidationError): + TypeAdapter(ModelBinding).validate_python({"kind": "magic"}) + + +def test_model_binding_explicit_requires_model() -> None: + with pytest.raises(ValidationError): + TypeAdapter(ModelBinding).validate_python({"kind": "explicit", "adapter": "openai"}) + + +def test_assignments_defaults_are_empty() -> None: + assignments = AgentAssignments() + assert assignments.prompt is None + assert assignments.skills == [] + assert assignments.preference_tags == [] + + +def test_assignments_allow_extra_keys() -> None: + assignments = AgentAssignments.model_validate({"workflow": "vi_1"}) + assert assignments.workflow == "vi_1" +``` + +Note: `pytest` is already imported at the top of `test_types.py` if present; if not, add `import pytest`. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `uv run pytest tests/db/test_types.py -v` +Expected: FAIL — `ImportError: cannot import name 'AgentStatus'` + +- [ ] **Step 4: Implement** — in `backend/src/octave/db/types.py` + +Extend imports at the top of the file to include `Annotated`, `Literal` (from `typing`), `ConfigDict`, `Field` (from `pydantic`), and extend `__all__` with the new names: + +```python +__all__ = [ + "AgentAssignments", + "AgentStatus", + "AssistantMessagePayload", + "EventKind", + "ExplicitModelBinding", + "InstanceStatus", + "ModelBinding", + "TagModelBinding", + "UserMessagePayload", + "VaultKind", + "VectorHit", +] +``` + +Add after `VaultKind`: + +```python +class AgentStatus(StrEnum): + """Definition-level gate for spawning and turns. Stored verbatim in + ``agents.status``. ``paused`` means "do not run this agent anywhere" — + session-scoped refusal is turn policy, not agent status (design spec).""" + + ACTIVE = "active" + PAUSED = "paused" + + +class InstanceStatus(StrEnum): + """Runtime state of one agent instance. Stored verbatim in + ``agent_instances.status``. There is no ``failed``: turn failures are + events in the session, and sessions own ``failed``.""" + + IDLE = "idle" + ACTIVE = "active" + + +class TagModelBinding(BaseModel): + """Capability-tag binding, resolved at turn start via model tagging.""" + + kind: Literal["tag"] + tag: str + + +class ExplicitModelBinding(BaseModel): + """Direct provider-model pair; adapter existence validated at + definition-save time (follow-up issue), not at turn start.""" + + kind: Literal["explicit"] + adapter: str + model: str + + +ModelBinding = Annotated[ + TagModelBinding | ExplicitModelBinding, Field(discriminator="kind") +] + + +class AgentAssignments(BaseModel): + """Named vault-item references assigned to a definition. + + References are app-validated strings; dangling references are tolerated + at resolution time (skip + warn). Link-table promotion triggers live in + the design spec. Extra keys allowed, mirroring the ``vault_items.meta`` + convention (ADR 2026-09-20).""" + + model_config = ConfigDict(extra="allow") + + prompt: str | None = None + skills: list[str] = Field(default_factory=list) + preference_tags: list[str] = Field(default_factory=list) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/db/test_types.py -v` +Expected: all PASS (8 new + existing) + +- [ ] **Step 6: Lint, typecheck, commit** + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q +git add src/octave/db/types.py tests/db/test_types.py +git commit -m "feat(db): agent lifecycle vocabulary types (#25)" +``` + +--- + +## Task 2: Agent definition rework + migration + +**Files:** +- Modify: `backend/src/octave/db/models/core.py:35-52` +- Create: `backend/src/octave/db/migrations/versions/b7c3f1a2d9e4_agent_definition_rework.py` +- Test: `backend/tests/db/test_models.py` + +- [ ] **Step 1: Write the failing tests** — append to `backend/tests/db/test_models.py` + +```python +async def test_agent_binding_and_assignments_round_trip( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + session.add( + Agent( + id="a_1", + name="Octave", + model_binding={"kind": "tag", "tag": "quick"}, + assignments={"skills": ["vi_1"]}, + ) + ) + await session.commit() + agent = await session.get(Agent, "a_1") + assert agent is not None + assert agent.model_binding == {"kind": "tag", "tag": "quick"} + assert agent.assignments == {"skills": ["vi_1"]} + + +async def test_agent_assignments_default_empty( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + session.add(Agent(id="a_1", name="Octave")) + await session.commit() + agent = await session.get(Agent, "a_1") + assert agent is not None and agent.assignments == {} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/db/test_models.py -v -k "binding or assignments"` +Expected: FAIL — `TypeError: 'model_binding' is an invalid keyword argument for Agent` + +- [ ] **Step 3: Implement the model change** — in `backend/src/octave/db/models/core.py` + +Add `from typing import Any` to imports and `JSON` to the `sqlalchemy` import list. Replace the `Agent` body columns (`model_tag` at lines 43-48) with: + +```python +class Agent(Base): + """The Agent Registry. An agent is NOT a subtype of user: it has a model + binding, a lifecycle, and it never owns data (``sessions``/``vault_items`` + point at ``users``).""" + + __tablename__ = "agents" + + id: Mapped[str] = mapped_column(Text, primary_key=True) + name: Mapped[str] = mapped_column(Text, nullable=False) + model_binding: Mapped[dict[str, Any] | None] = mapped_column(JSON) + """``octave.db.types.ModelBinding`` JSON: tag or explicit provider-model + pair. App-validated at write time; NULL means "unusable" — spawn fails + loud on it.""" + + assignments: Mapped[dict[str, Any]] = mapped_column( + JSON, nullable=False, default=dict + ) + """``octave.db.types.AgentAssignments`` JSON: named vault-item references + (prompt / skills / preference_tags).""" + + status: Mapped[str] = mapped_column(Text, nullable=False, default="active") + """``active | paused`` (AgentStatus, app-validated) — definition-level + gate; instance lifecycle lives in ``agent_instances``.""" + + created_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=utcnow, nullable=False + ) +``` + +- [ ] **Step 4: Fix existing tests referencing `model_tag`** + +In `backend/tests/db/test_models.py` line 25, replace `model_tag="quick"` with `model_binding={"kind": "tag", "tag": "quick"}`. + +Run: `uv run pytest tests/db/test_models.py -v` +Expected: all PASS (tests use `Base.metadata.create_all`, so they pass before the migration exists) + +- [ ] **Step 5: Write the Alembic revision** — create `backend/src/octave/db/migrations/versions/b7c3f1a2d9e4_agent_definition_rework.py` + +```python +"""agent definition rework: model_binding, assignments + +Revision ID: b7c3f1a2d9e4 +Revises: 4bf075ee2ede +Create Date: 2026-09-27 + +SQLite table rebuilds run via batch_alter_table. Two batch contexts (add, +then drop) bracket the data copy: batch queues its ops until context exit, +so the copy cannot be interleaved inside one batch. +""" +from collections.abc import Sequence +import json + +from alembic import op +import sqlalchemy as sa + +# Autogenerate renders the UTCDateTime TypeDecorator by qualified name. +import octave.db.models.base + + +revision: str = 'b7c3f1a2d9e4' +down_revision: str | None = '4bf075ee2ede' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table('agents') as batch_op: + batch_op.add_column(sa.Column('model_binding', sa.JSON(), nullable=True)) + batch_op.add_column( + sa.Column( + 'assignments', + sa.JSON(), + nullable=False, + server_default=sa.text("('{}')"), + ) + ) + bind = op.get_bind() + rows = bind.execute( + sa.text("SELECT id, model_tag FROM agents WHERE model_tag IS NOT NULL") + ).all() + for row in rows: + bind.execute( + sa.text("UPDATE agents SET model_binding = :binding WHERE id = :id"), + {"binding": json.dumps({"kind": "tag", "tag": row.model_tag}), "id": row.id}, + ) + with op.batch_alter_table('agents') as batch_op: + batch_op.drop_column('model_tag') + + +def downgrade() -> None: + with op.batch_alter_table('agents') as batch_op: + batch_op.add_column(sa.Column('model_tag', sa.Text(), nullable=True)) + bind = op.get_bind() + rows = bind.execute(sa.text("SELECT id, model_binding FROM agents")).all() + for row in rows: + if not row.model_binding: + continue + binding = json.loads(row.model_binding) + if binding.get("kind") == "tag": + bind.execute( + sa.text("UPDATE agents SET model_tag = :tag WHERE id = :id"), + {"tag": binding.get("tag"), "id": row.id}, + ) + with op.batch_alter_table('agents') as batch_op: + batch_op.drop_column('assignments') + batch_op.drop_column('model_binding') +``` + +- [ ] **Step 6: Verify migrations run and schema matches models** + +Run: `uv run pytest tests/db/test_migrations.py -v` +Expected: all PASS — `test_migrated_schema_matches_models` catches any drift between the new revision and the ORM. + +- [ ] **Step 7: Lint, typecheck, commit** + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q +git add src/octave/db/models/core.py src/octave/db/migrations/versions/b7c3f1a2d9e4_agent_definition_rework.py tests/db/test_models.py +git commit -m "feat(db): replace model_tag with model_binding/assignments JSON (#25)" +``` + +--- + +## Task 3: AgentInstance model + migration + +**Files:** +- Create: `backend/src/octave/db/models/instances.py` +- Modify: `backend/src/octave/db/models/__init__.py` +- Create: `backend/src/octave/db/migrations/versions/c9d4e2f6a1b8_agent_instances.py` +- Modify: `backend/tests/db/test_migrations.py:12-21` +- Test: `backend/tests/db/test_models.py` + +- [ ] **Step 1: Write the failing tests** — append to `backend/tests/db/test_models.py` + +Add `AgentInstance` to the `from octave.db.models import (...)` list at the top, then: + +```python +async def _seed_agent_session( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """One user, one agent, one session — no membership yet.""" + async with session_factory() as session: + session.add(User(id="u_1", display_name="Alice")) + session.add(Agent(id="a_1", name="Octave")) + session.add(Session(id="s_1", created_by_user_id="u_1")) + await session.commit() + + +async def test_instance_status_defaults_idle( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_agent_session(session_factory) + async with session_factory() as session: + session.add(AgentInstance(id="i_1", agent_id="a_1", session_id="s_1")) + await session.commit() + instance = await session.get(AgentInstance, "i_1") + assert instance is not None and instance.status == "idle" + + +async def test_instance_unique_per_agent_session( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_agent_session(session_factory) + async with session_factory() as session: + session.add(AgentInstance(id="i_1", agent_id="a_1", session_id="s_1")) + session.add(AgentInstance(id="i_2", agent_id="a_1", session_id="s_1")) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_agent_delete_restricted_while_instance_exists( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_agent_session(session_factory) + async with session_factory() as session: + session.add(AgentInstance(id="i_1", agent_id="a_1", session_id="s_1")) + await session.commit() + async with session_factory() as session: + agent = await session.get(Agent, "a_1") + await session.delete(agent) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_session_delete_cascades_instances( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_agent_session(session_factory) + async with session_factory() as session: + session.add(AgentInstance(id="i_1", agent_id="a_1", session_id="s_1")) + await session.commit() + async with session_factory() as session: + sess = await session.get(Session, "s_1") + await session.delete(sess) + await session.commit() + async with session_factory() as session: + assert await session.get(AgentInstance, "i_1") is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/db/test_models.py -v -k instance` +Expected: FAIL — `ImportError: cannot import name 'AgentInstance'` + +- [ ] **Step 3: Implement the model** — create `backend/src/octave/db/models/instances.py` + +```python +"""Agent runtime instances: ephemeral bindings of a definition to a session. + +Rows exist only while the instance can take turns; destroy is a hard delete +(design spec 2026-09-27). No context lives on this table: live context is +the session transcript, long-term memory is the vault. +""" + +from datetime import datetime + +from sqlalchemy import ForeignKey, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from octave.db.models.base import Base, UTCDateTime, utcnow + +__all__ = ["AgentInstance"] + + +class AgentInstance(Base): + """One live binding of an agent definition to one session. + + ``agent_id`` is RESTRICT: deleting a definition with live instances is + the app-guarded door recorded in the design spec. ``session_id`` is + CASCADE: a session ending destroys its instances. + """ + + __tablename__ = "agent_instances" + __table_args__ = ( + UniqueConstraint( + "agent_id", "session_id", name="uq_agent_instances_agent_session" + ), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + agent_id: Mapped[str] = mapped_column( + ForeignKey("agents.id", ondelete="RESTRICT"), nullable=False + ) + session_id: Mapped[str] = mapped_column( + ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False + ) + status: Mapped[str] = mapped_column(Text, nullable=False, default="idle") + """``idle | active`` (InstanceStatus, app-validated; TEXT by house + style — cf. events.kind rationale).""" + created_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False + ) +``` + +- [ ] **Step 4: Re-export** — in `backend/src/octave/db/models/__init__.py` + +```python +from octave.db.models.instances import AgentInstance +``` + +and add `"AgentInstance",` to `__all__` (alphabetical: after `"Agent",`). + +- [ ] **Step 5: Write the Alembic revision** — create `backend/src/octave/db/migrations/versions/c9d4e2f6a1b8_agent_instances.py` + +```python +"""agent instances table + +Revision ID: c9d4e2f6a1b8 +Revises: b7c3f1a2d9e4 +Create Date: 2026-09-27 +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# Autogenerate renders the UTCDateTime TypeDecorator by qualified name. +import octave.db.models.base + + +revision: str = 'c9d4e2f6a1b8' +down_revision: str | None = 'b7c3f1a2d9e4' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table('agent_instances', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('agent_id', sa.Text(), nullable=False), + sa.Column('session_id', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('created_at', octave.db.models.base.UTCDateTime(), nullable=False), + sa.Column('updated_at', octave.db.models.base.UTCDateTime(), nullable=False), + sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('agent_id', 'session_id', name='uq_agent_instances_agent_session') + ) + + +def downgrade() -> None: + op.drop_table('agent_instances') +``` + +- [ ] **Step 6: Update EXPECTED_TABLES** — in `backend/tests/db/test_migrations.py`, add `"agent_instances",` to the `EXPECTED_TABLES` set (lines 12-21). + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `uv run pytest tests/db/test_models.py tests/db/test_migrations.py -v` +Expected: all PASS (4 new instance tests; schema-equality test covers the new table) + +- [ ] **Step 8: Lint, typecheck, commit** + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q +git add src/octave/db/models/instances.py src/octave/db/models/__init__.py src/octave/db/migrations/versions/c9d4e2f6a1b8_agent_instances.py tests/db/test_models.py tests/db/test_migrations.py +git commit -m "feat(db): agent_instances table with unique binding and FK policy (#25)" +``` + +--- + +## Task 4: `upgrade(revision=)` + data-migration test + +**Files:** +- Modify: `backend/src/octave/db/migrations/__init__.py:40-51` +- Test: `backend/tests/db/test_migrations.py` + +- [ ] **Step 1: Write the failing test** — append to `backend/tests/db/test_migrations.py` + +Add `import json` and `from sqlalchemy import text` to imports, then: + +```python +def test_model_tag_migrates_to_tag_binding(tmp_path: Path) -> None: + """Old ``model_tag`` values survive as tag-form ``model_binding``.""" + url = f"sqlite:///{tmp_path / 'mig.db'}" + upgrade(url, revision="4bf075ee2ede") # pre-rework schema + engine = create_engine(url) + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO agents (id, name, model_tag, status, created_at) " + "VALUES ('a_1', 'Octave', 'quick', 'active', '2026-01-01 00:00:00')" + ) + ) + engine.dispose() + upgrade(url) # to head + engine = create_engine(url) + with engine.connect() as conn: + row = conn.execute( + text("SELECT model_binding FROM agents WHERE id = 'a_1'") + ).one() + engine.dispose() + assert json.loads(row.model_binding) == {"kind": "tag", "tag": "quick"} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/db/test_migrations.py::test_model_tag_migrates_to_tag_binding -v` +Expected: FAIL — `TypeError: upgrade() got an unexpected keyword argument 'revision'` + +- [ ] **Step 3: Implement** — in `backend/src/octave/db/migrations/__init__.py`, replace `upgrade`: + +```python +def upgrade(sync_url: str, revision: str = "head") -> None: + """Apply migrations to ``revision`` (default: head). Idempotent. + + Raises ``DbMigrationError`` on failure; Alembic's own exceptions never + escape this module. The ``revision`` parameter exists for tests that + seed data at an older schema revision. + """ + logger.info("applying migrations to %s (target %s)", sync_url, revision) + try: + _alembic_upgrade(_config(sync_url), revision) + except Exception as exc: # Alembic raises broad; translate at the boundary + logger.exception("migration failed for %s", sync_url) + raise DbMigrationError(f"migration failed: {exc}") from exc + logger.info("migrations applied to %s", sync_url) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/db/test_migrations.py -v` +Expected: all PASS + +- [ ] **Step 5: Lint, typecheck, commit** + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q +git add src/octave/db/migrations/__init__.py tests/db/test_migrations.py +git commit -m "feat(db): upgrade accepts target revision; pin model_tag data migration (#25)" +``` + +--- + +## Task 5: Manager spawn + +**Files:** +- Create: `backend/tests/conftest.py` +- Modify: `backend/tests/db/conftest.py` +- Modify: `backend/src/octave/agent/errors.py` +- Create: `backend/src/octave/agent/instances.py` +- Test: `backend/tests/agent/test_instances.py` + +- [ ] **Step 1: Move DB fixtures up one level** + +Create `backend/tests/conftest.py` containing **exactly the current contents of `backend/tests/db/conftest.py`** (the `_make_async_creator` helper plus the `engine` and `session_factory` fixtures, docstring included). Then empty `backend/tests/db/conftest.py` to a single comment line: + +```python +# Fixtures live in tests/conftest.py so tests/agent/ shares them. +``` + +Run: `uv run pytest tests/db -q` +Expected: all PASS (fixtures inherited from the parent conftest) + +- [ ] **Step 2: Write the failing tests** — create `backend/tests/agent/test_instances.py` + +```python +"""AgentInstanceManager lifecycle (design spec 2026-09-27). + +One write path, real SQLite, explicit commits — mirrors VaultStore testing. +""" + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from octave.agent.errors import ( + AgentNotFoundError, + AgentPausedError, + InstanceExistsError, + ModelBindingError, + SessionNotFoundError, + TerminalSessionError, +) +from octave.agent.instances import AgentInstanceManager +from octave.db.models import ( + Agent, + AgentInstance, + Participant, + Session, + SessionParticipant, + User, +) + +_TAG_BINDING = {"kind": "tag", "tag": "quick"} + + +async def _seed_defs( + session_factory: async_sessionmaker[AsyncSession], + *, + agent_status: str = "active", + session_status: str = "active", + model_binding: dict[str, str] | None = _TAG_BINDING, +) -> None: + """One user, one agent (a_1), one session (s_1); no membership.""" + async with session_factory() as session: + session.add(User(id="u_1", display_name="Alice")) + session.add( + Agent(id="a_1", name="Octave", status=agent_status, model_binding=model_binding) + ) + session.add(Session(id="s_1", created_by_user_id="u_1", status=session_status)) + await session.commit() + + +async def test_spawn_creates_idle_instance_and_membership( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + manager = AgentInstanceManager(session) + instance = await manager.spawn(agent_id="a_1", session_id="s_1") + await session.commit() + assert instance.status == "idle" + assert instance.agent_id == "a_1" and instance.session_id == "s_1" + async with session_factory() as session: + members = (await session.execute(select(SessionParticipant))).scalars().all() + assert len(members) == 1 + participant = await session.get(Participant, members[0].participant_id) + assert participant is not None and participant.agent_id == "a_1" + assert participant.label == "Octave" + + +async def test_spawn_reuses_existing_participant( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + session.add(Participant(id="p_agent", agent_id="a_1", label="Octave")) + await session.commit() + async with session_factory() as session: + manager = AgentInstanceManager(session) + await manager.spawn(agent_id="a_1", session_id="s_1") + await session.commit() + async with session_factory() as session: + participants = (await session.execute(select(Participant))).scalars().all() + assert len(participants) == 1 + + +async def test_spawn_paused_agent_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory, agent_status="paused") + async with session_factory() as session: + with pytest.raises(AgentPausedError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + + +async def test_spawn_terminal_session_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory, session_status="completed") + async with session_factory() as session: + with pytest.raises(TerminalSessionError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + + +async def test_spawn_missing_agent_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + with pytest.raises(AgentNotFoundError): + await AgentInstanceManager(session).spawn(agent_id="ghost", session_id="s_1") + + +async def test_spawn_missing_session_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + with pytest.raises(SessionNotFoundError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="ghost") + + +async def test_spawn_missing_binding_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory, model_binding=None) + async with session_factory() as session: + with pytest.raises(ModelBindingError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + + +async def test_spawn_malformed_binding_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory, model_binding={"kind": "magic"}) + async with session_factory() as session: + with pytest.raises(ModelBindingError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + + +async def test_spawn_duplicate_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + manager = AgentInstanceManager(session) + await manager.spawn(agent_id="a_1", session_id="s_1") + await session.commit() + async with session_factory() as session: + with pytest.raises(InstanceExistsError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + count = ( + await session.execute(select(AgentInstance)) + ).scalars().all() + assert len(count) == 1 +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `uv run pytest tests/agent/test_instances.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'octave.agent.instances'` + +- [ ] **Step 4: Add errors** — in `backend/src/octave/agent/errors.py`, extend `__all__` and add classes: + +```python +__all__ = [ + "AgentError", + "AgentNotFoundError", + "AgentPausedError", + "InstanceExistsError", + "InstanceNotFoundError", + "InvalidTransitionError", + "ModelBindingError", + "SessionNotFoundError", + "TerminalSessionError", + "ToolLoopLimitError", + "TurnInProgressError", +] + + +class AgentError(Exception): + """Base for lifecycle-gate failures (design spec 2026-09-27).""" + + +class AgentNotFoundError(AgentError): + """No such agent definition.""" + + +class SessionNotFoundError(AgentError): + """No such session.""" + + +class AgentPausedError(AgentError): + """Definition-level gate: paused agents do not spawn or take turns.""" + + +class TerminalSessionError(AgentError): + """Session status is completed/failed/cancelled; no instances spawn.""" + + +class InstanceExistsError(AgentError): + """One live instance per agent per session (uq constraint).""" + + +class InstanceNotFoundError(AgentError): + """No such instance.""" + + +class TurnInProgressError(AgentError): + """begin_turn lost the idle→active race; the instance is already active.""" + + +class InvalidTransitionError(AgentError): + """Operation requires a state the instance is not in.""" + + +class ModelBindingError(AgentError): + """Binding missing, malformed, or (for tag form) unresolvable.""" +``` + +- [ ] **Step 5: Implement the manager** — create `backend/src/octave/agent/instances.py` + +```python +"""Agent instance lifecycle (design spec 2026-09-27). + +The only write path for ``agent_instances``. Follows the VaultStore +convention: constructed with the caller's AsyncSession, never commits. +Instances carry no context — the session transcript and the vault own it. +""" + +import logging +import uuid +from collections.abc import Callable + +from pydantic import TypeAdapter, ValidationError +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from octave.agent.errors import ( + AgentNotFoundError, + AgentPausedError, + InstanceExistsError, + InstanceNotFoundError, + InvalidTransitionError, + ModelBindingError, + SessionNotFoundError, + TerminalSessionError, + TurnInProgressError, +) +from octave.db.models import ( + Agent, + AgentInstance, + Participant, + Session, + SessionParticipant, +) +from octave.db.models.base import utcnow +from octave.db.types import AgentStatus, InstanceStatus, ModelBinding + +__all__ = ["AgentInstanceManager", "ResolvedModel", "resolve_model"] + +logger = logging.getLogger(__name__) + +_TERMINAL_SESSION_STATUSES = frozenset({"completed", "failed", "cancelled"}) + +_BINDING_ADAPTER: TypeAdapter[ModelBinding] = TypeAdapter(ModelBinding) + + +def _validate_binding(agent: Agent) -> None: + """Spawn-time gate: shape only. Tag resolution happens at turn start + via ``resolve_model`` (see design spec §4 and plan Deviations).""" + if agent.model_binding is None: + raise ModelBindingError(f"agent {agent.id} has no model_binding") + try: + _BINDING_ADAPTER.validate_python(agent.model_binding) + except ValidationError as exc: + raise ModelBindingError( + f"agent {agent.id} has invalid model_binding: {exc}" + ) from exc + + +class AgentInstanceManager: + """spawn / begin_turn / end_turn / destroy / reconcile. Never commits; + callers own transaction boundaries (see ``octave.db.deps``).""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def spawn(self, *, agent_id: str, session_id: str) -> AgentInstance: + """Bind an active definition to a live session. + + Ensures the agent has a ``Participant`` row and active membership + (re-invite resets ``left_at``). Raises the matching + ``AgentError`` subclass on every gate. + """ + agent = await self._session.get(Agent, agent_id) + if agent is None: + raise AgentNotFoundError(agent_id) + if agent.status != AgentStatus.ACTIVE: + raise AgentPausedError(agent_id) + _validate_binding(agent) + session_row = await self._session.get(Session, session_id) + if session_row is None: + raise SessionNotFoundError(session_id) + if session_row.status in _TERMINAL_SESSION_STATUSES: + raise TerminalSessionError(session_id, session_row.status) + existing = await self._session.execute( + select(AgentInstance).where( + AgentInstance.agent_id == agent_id, + AgentInstance.session_id == session_id, + ) + ) + if existing.scalar_one_or_none() is not None: + raise InstanceExistsError(agent_id, session_id) + participant = ( + await self._session.execute( + select(Participant).where(Participant.agent_id == agent_id) + ) + ).scalar_one_or_none() + if participant is None: + participant = Participant( + id=uuid.uuid4().hex, agent_id=agent_id, label=agent.name + ) + self._session.add(participant) + await self._session.flush() + membership = await self._session.get( + SessionParticipant, (session_id, participant.id) + ) + if membership is None: + self._session.add( + SessionParticipant(session_id=session_id, participant_id=participant.id) + ) + elif membership.left_at is not None: + membership.left_at = None # re-invite: membership is live again + instance = AgentInstance( + id=uuid.uuid4().hex, + agent_id=agent_id, + session_id=session_id, + status=str(InstanceStatus.IDLE), + ) + self._session.add(instance) + await self._session.flush() + return instance + + async def _get(self, instance_id: str) -> AgentInstance: + instance = await self._session.get(AgentInstance, instance_id) + if instance is None: + raise InstanceNotFoundError(instance_id) + return instance +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest tests/agent/test_instances.py -v` +Expected: all PASS + +- [ ] **Step 7: Lint, typecheck, commit** + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q +git add tests/conftest.py tests/db/conftest.py src/octave/agent/errors.py src/octave/agent/instances.py tests/agent/test_instances.py +git commit -m "feat(agent): AgentInstanceManager.spawn with lifecycle gates (#25)" +``` + +--- + +## Task 6: begin_turn / end_turn (turn mutex) + +**Files:** +- Modify: `backend/src/octave/agent/instances.py` +- Test: `backend/tests/agent/test_instances.py` + +- [ ] **Step 1: Write the failing tests** — append to `backend/tests/agent/test_instances.py` + +```python +async def _spawn_idle( + session_factory: async_sessionmaker[AsyncSession], +) -> str: + """Seed defs, spawn, commit; return the instance id.""" + await _seed_defs(session_factory) + async with session_factory() as session: + instance = await AgentInstanceManager(session).spawn( + agent_id="a_1", session_id="s_1" + ) + await session.commit() + return instance.id + + +async def test_begin_turn_activates( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + instance = await AgentInstanceManager(session).begin_turn(instance_id) + await session.commit() + assert instance.status == "active" + + +async def test_begin_turn_twice_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + await AgentInstanceManager(session).begin_turn(instance_id) + await session.commit() + async with session_factory() as session: + with pytest.raises(TurnInProgressError): + await AgentInstanceManager(session).begin_turn(instance_id) + + +async def test_begin_turn_paused_agent_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + agent = await session.get(Agent, "a_1") + assert agent is not None + agent.status = "paused" + await session.commit() + async with session_factory() as session: + with pytest.raises(AgentPausedError): + await AgentInstanceManager(session).begin_turn(instance_id) + + +async def test_begin_turn_missing_instance_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + with pytest.raises(InstanceNotFoundError): + await AgentInstanceManager(session).begin_turn("ghost") + + +async def test_end_turn_idles( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + manager = AgentInstanceManager(session) + await manager.begin_turn(instance_id) + instance = await manager.end_turn(instance_id) + await session.commit() + assert instance.status == "idle" + + +async def test_end_turn_on_idle_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + with pytest.raises(InvalidTransitionError): + await AgentInstanceManager(session).end_turn(instance_id) +``` + +Add `TurnInProgressError` and `InvalidTransitionError` to the errors import at the top of the test file. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/agent/test_instances.py -v -k "turn"` +Expected: FAIL — `AttributeError: 'AgentInstanceManager' object has no attribute 'begin_turn'` + +- [ ] **Step 3: Implement** — in `backend/src/octave/agent/instances.py`, add methods to `AgentInstanceManager` (after `spawn`): + +```python + async def begin_turn(self, instance_id: str) -> AgentInstance: + """Atomically claim the idle→active transition (the turn mutex). + + Re-checks the definition gate: a pause after spawn blocks new + turns; it never interrupts an active one (pause is a gate, not an + interrupt). Rowcount 0 means someone else holds the turn. + """ + instance = await self._get(instance_id) + agent = await self._session.get(Agent, instance.agent_id) + if agent is None: # RESTRICT makes this unreachable; defensive + raise AgentNotFoundError(instance.agent_id) + if agent.status != AgentStatus.ACTIVE: + raise AgentPausedError(instance.agent_id) + result = await self._session.execute( + update(AgentInstance) + .where( + AgentInstance.id == instance_id, + AgentInstance.status == str(InstanceStatus.IDLE), + ) + .values(status=str(InstanceStatus.ACTIVE), updated_at=utcnow()) + ) + if not result.rowcount: + raise TurnInProgressError(instance_id) + await self._session.refresh(instance) + return instance + + async def end_turn(self, instance_id: str) -> AgentInstance: + """Release the turn: active→idle. Turn failures use this too — + the error is an event in the session, not instance state.""" + instance = await self._get(instance_id) + if instance.status == str(InstanceStatus.IDLE): + raise InvalidTransitionError( + f"instance {instance_id}: end_turn requires active, got idle" + ) + result = await self._session.execute( + update(AgentInstance) + .where( + AgentInstance.id == instance_id, + AgentInstance.status == str(InstanceStatus.ACTIVE), + ) + .values(status=str(InstanceStatus.IDLE), updated_at=utcnow()) + ) + if not result.rowcount: + raise InvalidTransitionError( + f"instance {instance_id}: turn ended concurrently" + ) + await self._session.refresh(instance) + return instance +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/agent/test_instances.py -v` +Expected: all PASS + +- [ ] **Step 5: Lint, typecheck, commit** + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q +git add src/octave/agent/instances.py tests/agent/test_instances.py +git commit -m "feat(agent): turn mutex via atomic idle/active transitions (#25)" +``` + +--- + +## Task 7: destroy + reconcile + +**Files:** +- Modify: `backend/src/octave/agent/instances.py` +- Test: `backend/tests/agent/test_instances.py` + +- [ ] **Step 1: Write the failing tests** — append to `backend/tests/agent/test_instances.py` + +```python +async def test_destroy_deletes_row( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + destroyed = await AgentInstanceManager(session).destroy(instance_id) + await session.commit() + assert destroyed is True + async with session_factory() as session: + assert await session.get(AgentInstance, instance_id) is None + + +async def test_destroy_absent_returns_false( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + assert await AgentInstanceManager(session).destroy("ghost") is False + + +async def test_destroy_active_is_plain_delete( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """No archival hook, no state precondition — destroy is a delete.""" + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + manager = AgentInstanceManager(session) + await manager.begin_turn(instance_id) + assert await manager.destroy(instance_id) is True + await session.commit() + + +async def test_reconcile_resets_stale_active( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + await AgentInstanceManager(session).begin_turn(instance_id) + await session.commit() + async with session_factory() as session: + reset = await AgentInstanceManager(session).reconcile() + await session.commit() + assert reset == 1 + async with session_factory() as session: + instance = await session.get(AgentInstance, instance_id) + assert instance is not None and instance.status == "idle" + + +async def test_reconcile_noop_when_all_idle( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _spawn_idle(session_factory) + async with session_factory() as session: + assert await AgentInstanceManager(session).reconcile() == 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/agent/test_instances.py -v -k "destroy or reconcile"` +Expected: FAIL — `AttributeError: ... has no attribute 'destroy'` + +- [ ] **Step 3: Implement** — in `backend/src/octave/agent/instances.py`, add methods to `AgentInstanceManager` (after `end_turn`): + +```python + async def destroy(self, instance_id: str) -> bool: + """Hard-delete the instance binding. False if absent. + + Pure delete by design: no archival hook, nothing to roll back. + Archival rides the turn boundary (end_turn), not this call — + design spec "Archival Contract". + """ + result = await self._session.execute( + delete(AgentInstance).where(AgentInstance.id == instance_id) + ) + return bool(result.rowcount) + + async def reconcile(self) -> int: + """Reset stale active rows to idle after an unclean shutdown. + + The interrupted turn is already visible as a truncated transcript + in events; no zombie states survive restart. Returns rows reset. + """ + result = await self._session.execute( + update(AgentInstance) + .where(AgentInstance.status == str(InstanceStatus.ACTIVE)) + .values(status=str(InstanceStatus.IDLE), updated_at=utcnow()) + ) + if result.rowcount: + logger.warning( + "reconciled %s stale active instance(s) to idle", result.rowcount + ) + return int(result.rowcount) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/agent/test_instances.py -v` +Expected: all PASS + +- [ ] **Step 5: Lint, typecheck, commit** + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q +git add src/octave/agent/instances.py tests/agent/test_instances.py +git commit -m "feat(agent): destroy is plain delete; reconcile resets stale active rows (#25)" +``` + +--- + +## Task 8: Model binding resolution + +**Files:** +- Modify: `backend/src/octave/agent/instances.py` +- Test: `backend/tests/agent/test_instances.py` + +- [ ] **Step 1: Write the failing tests** — append to `backend/tests/agent/test_instances.py` + +Extend the imports at the top: + +```python +from octave.agent.instances import ResolvedModel, resolve_model +from octave.db.types import ExplicitModelBinding, TagModelBinding +``` + +Then: + +```python +def test_resolve_explicit_binding() -> None: + resolved = resolve_model( + ExplicitModelBinding(kind="explicit", adapter="openai", model="llama3") + ) + assert resolved == ResolvedModel(adapter="openai", model="llama3") + + +def test_resolve_tag_binding_with_lookup() -> None: + resolved = resolve_model( + TagModelBinding(kind="tag", tag="quick"), tag_lookup=lambda tag: "llama3" + ) + assert resolved == ResolvedModel(adapter=None, model="llama3") + + +def test_resolve_tag_miss_raises() -> None: + with pytest.raises(ModelBindingError): + resolve_model(TagModelBinding(kind="tag", tag="nope"), tag_lookup=lambda t: None) + + +def test_resolve_tag_without_lookup_raises() -> None: + with pytest.raises(ModelBindingError): + resolve_model(TagModelBinding(kind="tag", tag="quick")) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/agent/test_instances.py -v -k resolve` +Expected: FAIL — `ImportError: cannot import name 'resolve_model'` (the module declares it in `__all__` but it does not exist yet) + +- [ ] **Step 3: Implement** — in `backend/src/octave/agent/instances.py`, add after `_validate_binding`: + +```python +@dataclass(frozen=True) +class ResolvedModel: + """Concrete (adapter, model) pair for a turn. ``adapter=None`` means + "use the configured default adapter" (tag bindings defer engine + selection to the inference layer).""" + + adapter: str | None + model: str + + +def resolve_model( + binding: ModelBinding, + *, + tag_lookup: Callable[[str], str | None] | None = None, +) -> ResolvedModel: + """Resolve a binding to a concrete pair at turn start. + + ``tag_lookup`` maps a capability tag to a model name; the turn runner + wires it once model tagging lands (Inference roadmap #7). A tag miss + fails loud — an agent with no usable model is unusable, and silent + fallback would hide misconfiguration (design spec §4). + """ + if isinstance(binding, ExplicitModelBinding): + return ResolvedModel(adapter=binding.adapter, model=binding.model) + if tag_lookup is None: + raise ModelBindingError( + f"tag binding {binding.tag!r} requires a tag_lookup" + ) + model = tag_lookup(binding.tag) + if model is None: + raise ModelBindingError(f"model tag {binding.tag!r} resolves to nothing") + return ResolvedModel(adapter=None, model=model) +``` + +Add `from dataclasses import dataclass` to imports and `ExplicitModelBinding`, `TagModelBinding` to the `octave.db.types` import line. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/agent/test_instances.py -v` +Expected: all PASS + +- [ ] **Step 5: Lint, typecheck, commit** + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q +git add src/octave/agent/instances.py tests/agent/test_instances.py +git commit -m "feat(agent): resolve_model with fail-loud tag lookup (#25)" +``` + +--- + +## Task 9: Public surface + full verification + +**Files:** +- Modify: `backend/src/octave/agent/__init__.py` +- Test: `backend/tests/agent/test_package.py` + +- [ ] **Step 1: Write the failing test** — in `backend/tests/agent/test_package.py`, replace `test_public_names_are_exported` with: + +```python +def test_public_names_are_exported() -> None: + for name in ( + "AgentError", + "AgentInstanceManager", + "AgentNotFoundError", + "AgentPausedError", + "InstanceExistsError", + "InstanceNotFoundError", + "InvalidTransitionError", + "McpToolExecutor", + "ModelBindingError", + "ResolvedModel", + "SessionNotFoundError", + "TerminalSessionError", + "ToolError", + "ToolExecutor", + "ToolLoop", + "ToolLoopLimitError", + "ToolOutcome", + "ToolTurn", + "TurnInProgressError", + "resolve_model", + ): + assert hasattr(agent_pkg, name), name +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/agent/test_package.py -v` +Expected: FAIL — `assert hasattr(octave.agent, 'AgentError')` → `AssertionError: AgentError` + +- [ ] **Step 3: Implement** — replace `backend/src/octave/agent/__init__.py` with: + +```python +"""Agent plane: tool-use orchestration (issue #79) and instance lifecycle +(design spec #25). + +Composition layer — the only package importing both octave.mcp and +octave.inference, plus octave.db for the instance manager. Future Agent +Manager components (registry, router) land here as additional modules; +they do not exist yet. +""" + +from octave.agent.errors import ( + AgentError, + AgentNotFoundError, + AgentPausedError, + InstanceExistsError, + InstanceNotFoundError, + InvalidTransitionError, + ModelBindingError, + SessionNotFoundError, + TerminalSessionError, + ToolLoopLimitError, + TurnInProgressError, +) +from octave.agent.executor import ToolExecutor +from octave.agent.instances import AgentInstanceManager, ResolvedModel, resolve_model +from octave.agent.loop import ToolLoop +from octave.agent.mcp_executor import McpToolExecutor +from octave.agent.types import ToolOutcome, ToolTurn +from octave.tools.errors import ToolError + +__all__ = [ + "AgentError", + "AgentInstanceManager", + "AgentNotFoundError", + "AgentPausedError", + "InstanceExistsError", + "InstanceNotFoundError", + "InvalidTransitionError", + "McpToolExecutor", + "ModelBindingError", + "ResolvedModel", + "SessionNotFoundError", + "TerminalSessionError", + "ToolError", + "ToolExecutor", + "ToolLoop", + "ToolLoopLimitError", + "ToolOutcome", + "ToolTurn", + "TurnInProgressError", + "resolve_model", +] +``` + +- [ ] **Step 4: Run the full suite, lint, typecheck** + +Run: `uv run pytest -q && uv run ruff check . && uv run mypy src` +Expected: `-- OK --` on all three; `test_package.py::test_no_sdk_imports` still passes (`octave.db` imports are not SDK imports). + +- [ ] **Step 5: Commit** + +```bash +git add src/octave/agent/__init__.py tests/agent/test_package.py +git commit -m "feat(agent): export instance lifecycle public surface (#25)" +``` + +--- + +## Spec coverage self-check + +| Spec requirement | Task | +|---|---| +| `AgentStatus` / `InstanceStatus` / `ModelBinding` / `AgentAssignments` in `octave.db.types` | 1 | +| `agents`: `model_binding` replaces `model_tag`; `assignments` JSON; status narrowed | 2 | +| `agent_instances` table: unique binding, RESTRICT/CASCADE FKs, idle default | 3 | +| `model_tag` data survives as tag-form binding | 4 | +| Spawn gates (paused, terminal session, duplicate, missing, binding shape) + participant/membership ensure + re-invite | 5 | +| Turn mutex (atomic idle→active; end_turn; pause gate on begin_turn; no failed state) | 6 | +| Destroy = plain delete (archival decoupled); crash reconcile | 7 | +| Binding resolution, fail-loud tag miss, not persisted | 8 | +| Public surface; SDK quarantine intact | 9 | + +Explicitly **not in this plan** (spec non-goals): router, result collector, inter-agent sharing, scheduling, REST/UI, archival implementation, link tables, `events.instance_id`, definition deletion. Follow-up issues to file after merge (spec "Follow-up Issues"): definition deletion semantics; save-time adapter validation for `ExplicitModelBinding`. From 88b79a171355aedf6aa7378dfd4b76d17d31e0a2 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:54:50 +0100 Subject: [PATCH 03/12] feat(db): agent lifecycle vocabulary types (#25) --- backend/src/octave/db/types.py | 63 +++++++++++++++++++++++++++++++++- backend/tests/db/test_types.py | 55 ++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/backend/src/octave/db/types.py b/backend/src/octave/db/types.py index 9d6e671..6fded71 100644 --- a/backend/src/octave/db/types.py +++ b/backend/src/octave/db/types.py @@ -13,12 +13,19 @@ from dataclasses import dataclass from enum import StrEnum +from typing import Annotated, Literal -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field __all__ = [ + "AgentAssignments", + "AgentStatus", "AssistantMessagePayload", "EventKind", + "ExplicitModelBinding", + "InstanceStatus", + "ModelBinding", + "TagModelBinding", "UserMessagePayload", "VaultKind", "VectorHit", @@ -50,6 +57,60 @@ class VaultKind(StrEnum): RUN_RECORD = "run_record" +class AgentStatus(StrEnum): + """Definition-level gate for spawning and turns. Stored verbatim in + ``agents.status``. ``paused`` means "do not run this agent anywhere" — + session-scoped refusal is turn policy, not agent status (design spec).""" + + ACTIVE = "active" + PAUSED = "paused" + + +class InstanceStatus(StrEnum): + """Runtime state of one agent instance. Stored verbatim in + ``agent_instances.status``. There is no ``failed``: turn failures are + events in the session, and sessions own ``failed``.""" + + IDLE = "idle" + ACTIVE = "active" + + +class TagModelBinding(BaseModel): + """Capability-tag binding, resolved at turn start via model tagging.""" + + kind: Literal["tag"] + tag: str + + +class ExplicitModelBinding(BaseModel): + """Direct provider-model pair; adapter existence validated at + definition-save time (follow-up issue), not at turn start.""" + + kind: Literal["explicit"] + adapter: str + model: str + + +ModelBinding = Annotated[ + TagModelBinding | ExplicitModelBinding, Field(discriminator="kind") +] + + +class AgentAssignments(BaseModel): + """Named vault-item references assigned to a definition. + + References are app-validated strings; dangling references are tolerated + at resolution time (skip + warn). Link-table promotion triggers live in + the design spec. Extra keys allowed, mirroring the ``vault_items.meta`` + convention (ADR 2026-09-20).""" + + model_config = ConfigDict(extra="allow") + + prompt: str | None = None + skills: list[str] = Field(default_factory=list) + preference_tags: list[str] = Field(default_factory=list) + + @dataclass(frozen=True) class VectorHit: """One neighbour returned by a similarity search.""" diff --git a/backend/tests/db/test_types.py b/backend/tests/db/test_types.py index e942046..5ad9bb4 100644 --- a/backend/tests/db/test_types.py +++ b/backend/tests/db/test_types.py @@ -1,11 +1,15 @@ """Octave DB domain types — event kinds, vector hits, payload models.""" import pytest -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from octave.db.types import ( + AgentAssignments, + AgentStatus, AssistantMessagePayload, EventKind, + InstanceStatus, + ModelBinding, UserMessagePayload, VaultKind, VectorHit, @@ -73,3 +77,52 @@ def test_vault_kind_membership_is_exhaustive() -> None: "run_summary", "run_record", } + + +def test_agent_status_values() -> None: + assert AgentStatus.ACTIVE == "active" + assert AgentStatus.PAUSED == "paused" + + +def test_instance_status_values() -> None: + assert InstanceStatus.IDLE == "idle" + assert InstanceStatus.ACTIVE == "active" + + +def test_model_binding_tag_form_parses() -> None: + binding = TypeAdapter(ModelBinding).validate_python({"kind": "tag", "tag": "quick"}) + assert binding.kind == "tag" + assert binding.tag == "quick" + + +def test_model_binding_explicit_form_parses() -> None: + binding = TypeAdapter(ModelBinding).validate_python( + {"kind": "explicit", "adapter": "openai", "model": "llama3"} + ) + assert binding.kind == "explicit" + assert binding.adapter == "openai" + assert binding.model == "llama3" + + +def test_model_binding_unknown_kind_rejected() -> None: + with pytest.raises(ValidationError): + TypeAdapter(ModelBinding).validate_python({"kind": "magic"}) + + +def test_model_binding_explicit_requires_model() -> None: + with pytest.raises(ValidationError): + TypeAdapter(ModelBinding).validate_python( + {"kind": "explicit", "adapter": "openai"} + ) + + +def test_assignments_defaults_are_empty() -> None: + assignments = AgentAssignments() + assert assignments.prompt is None + assert assignments.skills == [] + assert assignments.preference_tags == [] + + +def test_assignments_allow_extra_keys() -> None: + assignments = AgentAssignments.model_validate({"workflow": "vi_1"}) + assert assignments.workflow == "vi_1" From 065d48862829bbc45b530b9d295433f0b45aacc4 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:04:11 +0100 Subject: [PATCH 04/12] feat(db): replace model_tag with model_binding/assignments JSON (#25) --- .../b7c3f1a2d9e4_agent_definition_rework.py | 64 +++++++++++++++++++ backend/src/octave/db/models/core.py | 22 +++++-- backend/tests/db/test_models.py | 35 +++++++++- 3 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 backend/src/octave/db/migrations/versions/b7c3f1a2d9e4_agent_definition_rework.py diff --git a/backend/src/octave/db/migrations/versions/b7c3f1a2d9e4_agent_definition_rework.py b/backend/src/octave/db/migrations/versions/b7c3f1a2d9e4_agent_definition_rework.py new file mode 100644 index 0000000..3bd63cb --- /dev/null +++ b/backend/src/octave/db/migrations/versions/b7c3f1a2d9e4_agent_definition_rework.py @@ -0,0 +1,64 @@ +"""agent definition rework: model_binding, assignments + +Revision ID: b7c3f1a2d9e4 +Revises: 4bf075ee2ede +Create Date: 2026-09-27 + +SQLite table rebuilds run via batch_alter_table. Two batch contexts (add, +then drop) bracket the data copy: batch queues its ops until context exit, +so the copy cannot be interleaved inside one batch. +""" +from collections.abc import Sequence +import json + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b7c3f1a2d9e4' +down_revision: str | None = '4bf075ee2ede' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table('agents') as batch_op: + batch_op.add_column(sa.Column('model_binding', sa.JSON(), nullable=True)) + batch_op.add_column( + sa.Column( + 'assignments', + sa.JSON(), + nullable=False, + server_default=sa.text("('{}')"), + ) + ) + bind = op.get_bind() + rows = bind.execute( + sa.text("SELECT id, model_tag FROM agents WHERE model_tag IS NOT NULL") + ).all() + for row in rows: + bind.execute( + sa.text("UPDATE agents SET model_binding = :binding WHERE id = :id"), + {"binding": json.dumps({"kind": "tag", "tag": row.model_tag}), "id": row.id}, + ) + with op.batch_alter_table('agents') as batch_op: + batch_op.drop_column('model_tag') + + +def downgrade() -> None: + with op.batch_alter_table('agents') as batch_op: + batch_op.add_column(sa.Column('model_tag', sa.Text(), nullable=True)) + bind = op.get_bind() + rows = bind.execute(sa.text("SELECT id, model_binding FROM agents")).all() + for row in rows: + if not row.model_binding: + continue + binding = json.loads(row.model_binding) + if binding.get("kind") == "tag": + bind.execute( + sa.text("UPDATE agents SET model_tag = :tag WHERE id = :id"), + {"tag": binding.get("tag"), "id": row.id}, + ) + with op.batch_alter_table('agents') as batch_op: + batch_op.drop_column('assignments') + batch_op.drop_column('model_binding') diff --git a/backend/src/octave/db/models/core.py b/backend/src/octave/db/models/core.py index 313df42..08b2a99 100644 --- a/backend/src/octave/db/models/core.py +++ b/backend/src/octave/db/models/core.py @@ -7,8 +7,9 @@ """ from datetime import datetime +from typing import Any -from sqlalchemy import CheckConstraint, ForeignKey, Text, UniqueConstraint +from sqlalchemy import JSON, CheckConstraint, ForeignKey, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from octave.db.models.base import Base, UTCDateTime, utcnow @@ -33,19 +34,28 @@ class User(Base): class Agent(Base): - """The Agent Registry. An agent is NOT a subtype of user: it has model - tags and a lifecycle, and it never owns data (``sessions``/``vault_items`` + """The Agent Registry. An agent is NOT a subtype of user: it has a model + binding, a lifecycle, and it never owns data (``sessions``/``vault_items`` point at ``users``).""" __tablename__ = "agents" id: Mapped[str] = mapped_column(Text, primary_key=True) name: Mapped[str] = mapped_column(Text, nullable=False) - model_tag: Mapped[str | None] = mapped_column(Text) - """Capability tag (``thinking``/``coding``/``quick``); tag-driven wiring.""" + model_binding: Mapped[dict[str, Any] | None] = mapped_column(JSON) + """``octave.db.types.ModelBinding`` JSON: tag or explicit provider-model + pair. App-validated at write time; NULL means "unusable" — spawn fails + loud on it.""" + + assignments: Mapped[dict[str, Any]] = mapped_column( + JSON, nullable=False, default=dict + ) + """``octave.db.types.AgentAssignments`` JSON: named vault-item references + (prompt / skills / preference_tags).""" status: Mapped[str] = mapped_column(Text, nullable=False, default="active") - """``active | paused | terminated`` — app-validated, TEXT by design.""" + """``active | paused`` (AgentStatus, app-validated) — definition-level + gate; instance lifecycle lives in ``agent_instances``.""" created_at: Mapped[datetime] = mapped_column( UTCDateTime, default=utcnow, nullable=False diff --git a/backend/tests/db/test_models.py b/backend/tests/db/test_models.py index 5f68528..2962d36 100644 --- a/backend/tests/db/test_models.py +++ b/backend/tests/db/test_models.py @@ -22,7 +22,11 @@ async def test_identity_round_trip( ) -> None: async with session_factory() as session: session.add(User(id="u_1", display_name="Alice")) - session.add(Agent(id="a_1", name="Octave", model_tag="quick")) + session.add( + Agent( + id="a_1", name="Octave", model_binding={"kind": "tag", "tag": "quick"} + ) + ) session.add(Participant(id="p_1", user_id="u_1", label="Alice")) session.add(Participant(id="p_2", agent_id="a_1", label="Octave")) await session.commit() @@ -352,3 +356,32 @@ async def test_vault_item_fk_to_user_enforced( ) with pytest.raises(IntegrityError): await session.commit() + + +async def test_agent_binding_and_assignments_round_trip( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + session.add( + Agent( + id="a_1", + name="Octave", + model_binding={"kind": "tag", "tag": "quick"}, + assignments={"skills": ["vi_1"]}, + ) + ) + await session.commit() + agent = await session.get(Agent, "a_1") + assert agent is not None + assert agent.model_binding == {"kind": "tag", "tag": "quick"} + assert agent.assignments == {"skills": ["vi_1"]} + + +async def test_agent_assignments_default_empty( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + session.add(Agent(id="a_1", name="Octave")) + await session.commit() + agent = await session.get(Agent, "a_1") + assert agent is not None and agent.assignments == {} From c1bba9a86f2311a744c95481174fc151d9d7515b Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:10:58 +0100 Subject: [PATCH 05/12] feat(db): agent_instances table with unique binding and FK policy (#25) --- .../versions/c9d4e2f6a1b8_agent_instances.py | 38 +++++++++++ backend/src/octave/db/models/__init__.py | 2 + backend/src/octave/db/models/instances.py | 48 ++++++++++++++ backend/tests/db/test_migrations.py | 1 + backend/tests/db/test_models.py | 63 +++++++++++++++++++ 5 files changed, 152 insertions(+) create mode 100644 backend/src/octave/db/migrations/versions/c9d4e2f6a1b8_agent_instances.py create mode 100644 backend/src/octave/db/models/instances.py diff --git a/backend/src/octave/db/migrations/versions/c9d4e2f6a1b8_agent_instances.py b/backend/src/octave/db/migrations/versions/c9d4e2f6a1b8_agent_instances.py new file mode 100644 index 0000000..a6351ee --- /dev/null +++ b/backend/src/octave/db/migrations/versions/c9d4e2f6a1b8_agent_instances.py @@ -0,0 +1,38 @@ +"""agent instances table + +Revision ID: c9d4e2f6a1b8 +Revises: b7c3f1a2d9e4 +Create Date: 2026-09-27 +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# Autogenerate renders the UTCDateTime TypeDecorator by qualified name. +import octave.db.models.base # noqa: F401 + + +revision: str = 'c9d4e2f6a1b8' +down_revision: str | None = 'b7c3f1a2d9e4' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table('agent_instances', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('agent_id', sa.Text(), nullable=False), + sa.Column('session_id', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('created_at', octave.db.models.base.UTCDateTime(), nullable=False), + sa.Column('updated_at', octave.db.models.base.UTCDateTime(), nullable=False), + sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('agent_id', 'session_id', name='uq_agent_instances_agent_session') + ) + + +def downgrade() -> None: + op.drop_table('agent_instances') diff --git a/backend/src/octave/db/models/__init__.py b/backend/src/octave/db/models/__init__.py index 3942809..22e10e0 100644 --- a/backend/src/octave/db/models/__init__.py +++ b/backend/src/octave/db/models/__init__.py @@ -7,12 +7,14 @@ from octave.db.models.base import Base, utcnow from octave.db.models.core import Agent, Participant, User +from octave.db.models.instances import AgentInstance from octave.db.models.mcp import McpServer from octave.db.models.sessions import Event, Session, SessionParticipant from octave.db.models.vault import VaultItem __all__ = [ "Agent", + "AgentInstance", "Base", "Event", "McpServer", diff --git a/backend/src/octave/db/models/instances.py b/backend/src/octave/db/models/instances.py new file mode 100644 index 0000000..6b84d22 --- /dev/null +++ b/backend/src/octave/db/models/instances.py @@ -0,0 +1,48 @@ +"""Agent runtime instances: ephemeral bindings of a definition to a session. + +Rows exist only while the instance can take turns; destroy is a hard delete +(design spec 2026-09-27). No context lives on this table: live context is +the session transcript, long-term memory is the vault. +""" + +from datetime import datetime + +from sqlalchemy import ForeignKey, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from octave.db.models.base import Base, UTCDateTime, utcnow + +__all__ = ["AgentInstance"] + + +class AgentInstance(Base): + """One live binding of an agent definition to one session. + + ``agent_id`` is RESTRICT: deleting a definition with live instances is + the app-guarded door recorded in the design spec. ``session_id`` is + CASCADE: a session ending destroys its instances. + """ + + __tablename__ = "agent_instances" + __table_args__ = ( + UniqueConstraint( + "agent_id", "session_id", name="uq_agent_instances_agent_session" + ), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + agent_id: Mapped[str] = mapped_column( + ForeignKey("agents.id", ondelete="RESTRICT"), nullable=False + ) + session_id: Mapped[str] = mapped_column( + ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False + ) + status: Mapped[str] = mapped_column(Text, nullable=False, default="idle") + """``idle | active`` (InstanceStatus, app-validated; TEXT by house + style — cf. events.kind rationale).""" + created_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False + ) diff --git a/backend/tests/db/test_migrations.py b/backend/tests/db/test_migrations.py index 3ca55f1..a1480c5 100644 --- a/backend/tests/db/test_migrations.py +++ b/backend/tests/db/test_migrations.py @@ -18,6 +18,7 @@ "events", "mcp_servers", "vault_items", + "agent_instances", } diff --git a/backend/tests/db/test_models.py b/backend/tests/db/test_models.py index 2962d36..364686c 100644 --- a/backend/tests/db/test_models.py +++ b/backend/tests/db/test_models.py @@ -7,6 +7,7 @@ from octave.db.models import ( Agent, + AgentInstance, Event, McpServer, Participant, @@ -385,3 +386,65 @@ async def test_agent_assignments_default_empty( await session.commit() agent = await session.get(Agent, "a_1") assert agent is not None and agent.assignments == {} + + +async def _seed_agent_session( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """One user, one agent, one session — no membership yet.""" + async with session_factory() as session: + session.add(User(id="u_1", display_name="Alice")) + session.add(Agent(id="a_1", name="Octave")) + session.add(Session(id="s_1", created_by_user_id="u_1")) + await session.commit() + + +async def test_instance_status_defaults_idle( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_agent_session(session_factory) + async with session_factory() as session: + session.add(AgentInstance(id="i_1", agent_id="a_1", session_id="s_1")) + await session.commit() + instance = await session.get(AgentInstance, "i_1") + assert instance is not None and instance.status == "idle" + + +async def test_instance_unique_per_agent_session( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_agent_session(session_factory) + async with session_factory() as session: + session.add(AgentInstance(id="i_1", agent_id="a_1", session_id="s_1")) + session.add(AgentInstance(id="i_2", agent_id="a_1", session_id="s_1")) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_agent_delete_restricted_while_instance_exists( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_agent_session(session_factory) + async with session_factory() as session: + session.add(AgentInstance(id="i_1", agent_id="a_1", session_id="s_1")) + await session.commit() + async with session_factory() as session: + agent = await session.get(Agent, "a_1") + await session.delete(agent) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_session_delete_cascades_instances( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_agent_session(session_factory) + async with session_factory() as session: + session.add(AgentInstance(id="i_1", agent_id="a_1", session_id="s_1")) + await session.commit() + async with session_factory() as session: + sess = await session.get(Session, "s_1") + await session.delete(sess) + await session.commit() + async with session_factory() as session: + assert await session.get(AgentInstance, "i_1") is None From 01ded2d174056398e47665a76b2f30cd3cf94c19 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:15:11 +0100 Subject: [PATCH 06/12] feat(db): upgrade accepts target revision; pin model_tag data migration (#25) --- backend/src/octave/db/migrations/__init__.py | 11 +++++---- backend/tests/db/test_migrations.py | 26 +++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/backend/src/octave/db/migrations/__init__.py b/backend/src/octave/db/migrations/__init__.py index 47e0f86..7006c34 100644 --- a/backend/src/octave/db/migrations/__init__.py +++ b/backend/src/octave/db/migrations/__init__.py @@ -37,15 +37,16 @@ def _config(sync_url: str) -> Config: return cfg -def upgrade(sync_url: str) -> None: - """Apply all pending migrations to ``sync_url``. Idempotent. +def upgrade(sync_url: str, revision: str = "head") -> None: + """Apply migrations to ``revision`` (default: head). Idempotent. Raises ``DbMigrationError`` on failure; Alembic's own exceptions never - escape this module. + escape this module. The ``revision`` parameter exists for tests that + seed data at an older schema revision. """ - logger.info("applying migrations to %s", sync_url) + logger.info("applying migrations to %s (target %s)", sync_url, revision) try: - _alembic_upgrade(_config(sync_url), "head") + _alembic_upgrade(_config(sync_url), revision) except Exception as exc: # Alembic raises broad; translate at the boundary logger.exception("migration failed for %s", sync_url) raise DbMigrationError(f"migration failed: {exc}") from exc diff --git a/backend/tests/db/test_migrations.py b/backend/tests/db/test_migrations.py index a1480c5..668e723 100644 --- a/backend/tests/db/test_migrations.py +++ b/backend/tests/db/test_migrations.py @@ -1,10 +1,11 @@ """Migrations run programmatically against a temp SQLite file.""" +import json from pathlib import Path from typing import Any import pytest -from sqlalchemy import create_engine, inspect +from sqlalchemy import create_engine, inspect, text from octave.db.errors import DbMigrationError from octave.db.migrations import current, upgrade @@ -110,3 +111,26 @@ def _fks(insp: Any, table: str) -> set[tuple[str, ...]]: (fk["name"] or "", tuple(fk["constrained_columns"]), fk["referred_table"]) for fk in insp.get_foreign_keys(table) } + + +def test_model_tag_migrates_to_tag_binding(tmp_path: Path) -> None: + """Old ``model_tag`` values survive as tag-form ``model_binding``.""" + url = f"sqlite:///{tmp_path / 'mig.db'}" + upgrade(url, revision="4bf075ee2ede") # pre-rework schema + engine = create_engine(url) + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO agents (id, name, model_tag, status, created_at) " + "VALUES ('a_1', 'Octave', 'quick', 'active', '2026-01-01 00:00:00')" + ) + ) + engine.dispose() + upgrade(url) # to head + engine = create_engine(url) + with engine.connect() as conn: + row = conn.execute( + text("SELECT model_binding FROM agents WHERE id = 'a_1'") + ).one() + engine.dispose() + assert json.loads(row.model_binding) == {"kind": "tag", "tag": "quick"} From c57f37a72a24babfb3572e16c9815ec7976d2669 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:28:42 +0100 Subject: [PATCH 07/12] feat(agent): AgentInstanceManager.spawn with lifecycle gates (#25) --- backend/src/octave/agent/errors.py | 57 ++++++++- backend/src/octave/agent/instances.py | 119 +++++++++++++++++++ backend/tests/agent/test_instances.py | 159 ++++++++++++++++++++++++++ backend/tests/conftest.py | 57 +++++++++ backend/tests/db/conftest.py | 58 +--------- 5 files changed, 391 insertions(+), 59 deletions(-) create mode 100644 backend/src/octave/agent/instances.py create mode 100644 backend/tests/agent/test_instances.py create mode 100644 backend/tests/conftest.py diff --git a/backend/src/octave/agent/errors.py b/backend/src/octave/agent/errors.py index 92fae05..db14a00 100644 --- a/backend/src/octave/agent/errors.py +++ b/backend/src/octave/agent/errors.py @@ -1,9 +1,62 @@ -"""Orchestration-fatal errors (design spec #79).""" +"""Orchestration-fatal errors (design spec #79) and lifecycle-gate errors +(design spec 2026-09-27).""" from octave.inference.types import Message from octave.tools.errors import ToolError -__all__ = ["ToolLoopLimitError"] +__all__ = [ + "AgentError", + "AgentNotFoundError", + "AgentPausedError", + "InstanceExistsError", + "InstanceNotFoundError", + "InvalidTransitionError", + "ModelBindingError", + "SessionNotFoundError", + "TerminalSessionError", + "ToolLoopLimitError", + "TurnInProgressError", +] + + +class AgentError(Exception): + """Base for lifecycle-gate failures (design spec 2026-09-27).""" + + +class AgentNotFoundError(AgentError): + """No such agent definition.""" + + +class SessionNotFoundError(AgentError): + """No such session.""" + + +class AgentPausedError(AgentError): + """Definition-level gate: paused agents do not spawn or take turns.""" + + +class TerminalSessionError(AgentError): + """Session status is completed/failed/cancelled; no instances spawn.""" + + +class InstanceExistsError(AgentError): + """One live instance per agent per session (uq constraint).""" + + +class InstanceNotFoundError(AgentError): + """No such instance.""" + + +class TurnInProgressError(AgentError): + """begin_turn lost the idle→active race; the instance is already active.""" + + +class InvalidTransitionError(AgentError): + """Operation requires a state the instance is not in.""" + + +class ModelBindingError(AgentError): + """Binding missing, malformed, or (for tag form) unresolvable.""" class ToolLoopLimitError(ToolError): diff --git a/backend/src/octave/agent/instances.py b/backend/src/octave/agent/instances.py new file mode 100644 index 0000000..ef76764 --- /dev/null +++ b/backend/src/octave/agent/instances.py @@ -0,0 +1,119 @@ +"""Agent instance lifecycle (design spec 2026-09-27). + +The only write path for ``agent_instances``. Follows the VaultStore +convention: constructed with the caller's AsyncSession, never commits. +Instances carry no context — the session transcript and the vault own it. +""" + +import uuid + +from pydantic import TypeAdapter, ValidationError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from octave.agent.errors import ( + AgentNotFoundError, + AgentPausedError, + InstanceExistsError, + InstanceNotFoundError, + ModelBindingError, + SessionNotFoundError, + TerminalSessionError, +) +from octave.db.models import ( + Agent, + AgentInstance, + Participant, + Session, + SessionParticipant, +) +from octave.db.types import AgentStatus, InstanceStatus, ModelBinding + +__all__ = ["AgentInstanceManager"] + +_TERMINAL_SESSION_STATUSES = frozenset({"completed", "failed", "cancelled"}) + +_BINDING_ADAPTER: TypeAdapter[ModelBinding] = TypeAdapter(ModelBinding) + + +def _validate_binding(agent: Agent) -> None: + """Spawn-time gate: shape only. Tag resolution happens at turn start + via ``resolve_model`` (see design spec §4 and plan Deviations).""" + if agent.model_binding is None: + raise ModelBindingError(f"agent {agent.id} has no model_binding") + try: + _BINDING_ADAPTER.validate_python(agent.model_binding) + except ValidationError as exc: + raise ModelBindingError( + f"agent {agent.id} has invalid model_binding: {exc}" + ) from exc + + +class AgentInstanceManager: + """spawn / begin_turn / end_turn / destroy / reconcile. Never commits; + callers own transaction boundaries (see ``octave.db.deps``).""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def spawn(self, *, agent_id: str, session_id: str) -> AgentInstance: + """Bind an active definition to a live session. + + Ensures the agent has a ``Participant`` row and active membership + (re-invite resets ``left_at``). Raises the matching + ``AgentError`` subclass on every gate. + """ + agent = await self._session.get(Agent, agent_id) + if agent is None: + raise AgentNotFoundError(agent_id) + if agent.status != AgentStatus.ACTIVE: + raise AgentPausedError(agent_id) + _validate_binding(agent) + session_row = await self._session.get(Session, session_id) + if session_row is None: + raise SessionNotFoundError(session_id) + if session_row.status in _TERMINAL_SESSION_STATUSES: + raise TerminalSessionError(session_id, session_row.status) + existing = await self._session.execute( + select(AgentInstance).where( + AgentInstance.agent_id == agent_id, + AgentInstance.session_id == session_id, + ) + ) + if existing.scalar_one_or_none() is not None: + raise InstanceExistsError(agent_id, session_id) + participant = ( + await self._session.execute( + select(Participant).where(Participant.agent_id == agent_id) + ) + ).scalar_one_or_none() + if participant is None: + participant = Participant( + id=uuid.uuid4().hex, agent_id=agent_id, label=agent.name + ) + self._session.add(participant) + await self._session.flush() + membership = await self._session.get( + SessionParticipant, (session_id, participant.id) + ) + if membership is None: + self._session.add( + SessionParticipant(session_id=session_id, participant_id=participant.id) + ) + elif membership.left_at is not None: + membership.left_at = None # re-invite: membership is live again + instance = AgentInstance( + id=uuid.uuid4().hex, + agent_id=agent_id, + session_id=session_id, + status=str(InstanceStatus.IDLE), + ) + self._session.add(instance) + await self._session.flush() + return instance + + async def _get(self, instance_id: str) -> AgentInstance: + instance = await self._session.get(AgentInstance, instance_id) + if instance is None: + raise InstanceNotFoundError(instance_id) + return instance diff --git a/backend/tests/agent/test_instances.py b/backend/tests/agent/test_instances.py new file mode 100644 index 0000000..95390b8 --- /dev/null +++ b/backend/tests/agent/test_instances.py @@ -0,0 +1,159 @@ +"""AgentInstanceManager lifecycle (design spec 2026-09-27). + +One write path, real SQLite, explicit commits — mirrors VaultStore testing. +""" + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from octave.agent.errors import ( + AgentNotFoundError, + AgentPausedError, + InstanceExistsError, + ModelBindingError, + SessionNotFoundError, + TerminalSessionError, +) +from octave.agent.instances import AgentInstanceManager +from octave.db.models import ( + Agent, + AgentInstance, + Participant, + Session, + SessionParticipant, + User, +) + +_TAG_BINDING = {"kind": "tag", "tag": "quick"} + + +async def _seed_defs( + session_factory: async_sessionmaker[AsyncSession], + *, + agent_status: str = "active", + session_status: str = "active", + model_binding: dict[str, str] | None = _TAG_BINDING, +) -> None: + """One user, one agent (a_1), one session (s_1); no membership.""" + async with session_factory() as session: + session.add(User(id="u_1", display_name="Alice")) + session.add( + Agent( + id="a_1", + name="Octave", + status=agent_status, + model_binding=model_binding, + ) + ) + session.add(Session(id="s_1", created_by_user_id="u_1", status=session_status)) + await session.commit() + + +async def test_spawn_creates_idle_instance_and_membership( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + manager = AgentInstanceManager(session) + instance = await manager.spawn(agent_id="a_1", session_id="s_1") + await session.commit() + assert instance.status == "idle" + assert instance.agent_id == "a_1" and instance.session_id == "s_1" + async with session_factory() as session: + members = (await session.execute(select(SessionParticipant))).scalars().all() + assert len(members) == 1 + participant = await session.get(Participant, members[0].participant_id) + assert participant is not None and participant.agent_id == "a_1" + assert participant.label == "Octave" + + +async def test_spawn_reuses_existing_participant( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + session.add(Participant(id="p_agent", agent_id="a_1", label="Octave")) + await session.commit() + async with session_factory() as session: + manager = AgentInstanceManager(session) + await manager.spawn(agent_id="a_1", session_id="s_1") + await session.commit() + async with session_factory() as session: + participants = (await session.execute(select(Participant))).scalars().all() + assert len(participants) == 1 + + +async def test_spawn_paused_agent_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory, agent_status="paused") + async with session_factory() as session: + with pytest.raises(AgentPausedError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + + +async def test_spawn_terminal_session_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory, session_status="completed") + async with session_factory() as session: + with pytest.raises(TerminalSessionError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + + +async def test_spawn_missing_agent_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + with pytest.raises(AgentNotFoundError): + await AgentInstanceManager(session).spawn( + agent_id="ghost", session_id="s_1" + ) + + +async def test_spawn_missing_session_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + with pytest.raises(SessionNotFoundError): + await AgentInstanceManager(session).spawn( + agent_id="a_1", session_id="ghost" + ) + + +async def test_spawn_missing_binding_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory, model_binding=None) + async with session_factory() as session: + with pytest.raises(ModelBindingError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + + +async def test_spawn_malformed_binding_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory, model_binding={"kind": "magic"}) + async with session_factory() as session: + with pytest.raises(ModelBindingError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + + +async def test_spawn_duplicate_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + manager = AgentInstanceManager(session) + await manager.spawn(agent_id="a_1", session_id="s_1") + await session.commit() + async with session_factory() as session: + with pytest.raises(InstanceExistsError): + await AgentInstanceManager(session).spawn(agent_id="a_1", session_id="s_1") + count = ( + await session.execute(select(AgentInstance)) + ).scalars().all() + assert len(count) == 1 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..1240d21 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,57 @@ +"""Shared fixtures: temp-file SQLite DB, async engine, session factory. + +Temp FILE (not :memory:) so every connection shares one database, and +constraints are exercised on a real SQLite file — no mocks. + +The connect hook uses the Variant B ``async_creator`` (see Task 1 Spike A): +aiosqlite's raw sqlite3 connection lives on aiosqlite's own thread, so +PRAGMAs executed from the connect-event thread raise a threading +ProgrammingError. Inside the async_creator coroutine everything runs on +aiosqlite's thread, where PRAGMAs apply correctly — a cursor handed back +by the async adapter object would silently never execute. +""" + +from collections.abc import AsyncIterator, Awaitable, Callable +from pathlib import Path + +import aiosqlite +import pytest_asyncio +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from octave.db.models import Base + + +def _make_async_creator( + db_path: str, +) -> Callable[[], Awaitable[aiosqlite.Connection]]: + async def _connect() -> aiosqlite.Connection: + conn = await aiosqlite.connect(db_path) + await conn.execute("PRAGMA foreign_keys=ON") + await conn.execute("PRAGMA busy_timeout=5000") + return conn + + return _connect + + +@pytest_asyncio.fixture +async def engine(tmp_path: Path) -> AsyncIterator[AsyncEngine]: + """Fresh SQLite file per test (tests stay independent per coding rules).""" + eng = create_async_engine( + "sqlite+aiosqlite://", + async_creator=_make_async_creator(str(tmp_path / "test.db")), + ) + + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield eng + await eng.dispose() + + +@pytest_asyncio.fixture +async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(engine, expire_on_commit=False) diff --git a/backend/tests/db/conftest.py b/backend/tests/db/conftest.py index 1240d21..c8f204b 100644 --- a/backend/tests/db/conftest.py +++ b/backend/tests/db/conftest.py @@ -1,57 +1 @@ -"""Shared fixtures: temp-file SQLite DB, async engine, session factory. - -Temp FILE (not :memory:) so every connection shares one database, and -constraints are exercised on a real SQLite file — no mocks. - -The connect hook uses the Variant B ``async_creator`` (see Task 1 Spike A): -aiosqlite's raw sqlite3 connection lives on aiosqlite's own thread, so -PRAGMAs executed from the connect-event thread raise a threading -ProgrammingError. Inside the async_creator coroutine everything runs on -aiosqlite's thread, where PRAGMAs apply correctly — a cursor handed back -by the async adapter object would silently never execute. -""" - -from collections.abc import AsyncIterator, Awaitable, Callable -from pathlib import Path - -import aiosqlite -import pytest_asyncio -from sqlalchemy.ext.asyncio import ( - AsyncEngine, - AsyncSession, - async_sessionmaker, - create_async_engine, -) - -from octave.db.models import Base - - -def _make_async_creator( - db_path: str, -) -> Callable[[], Awaitable[aiosqlite.Connection]]: - async def _connect() -> aiosqlite.Connection: - conn = await aiosqlite.connect(db_path) - await conn.execute("PRAGMA foreign_keys=ON") - await conn.execute("PRAGMA busy_timeout=5000") - return conn - - return _connect - - -@pytest_asyncio.fixture -async def engine(tmp_path: Path) -> AsyncIterator[AsyncEngine]: - """Fresh SQLite file per test (tests stay independent per coding rules).""" - eng = create_async_engine( - "sqlite+aiosqlite://", - async_creator=_make_async_creator(str(tmp_path / "test.db")), - ) - - async with eng.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - yield eng - await eng.dispose() - - -@pytest_asyncio.fixture -async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: - return async_sessionmaker(engine, expire_on_commit=False) +# Fixtures live in tests/conftest.py so tests/agent/ shares them. From 4a46e1c96c3419ec32df998d5fb6463863b657e2 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:38:54 +0100 Subject: [PATCH 08/12] feat(agent): turn mutex via atomic idle/active transitions (#25) --- backend/src/octave/agent/instances.py | 62 +++++++++++++++++++- backend/tests/agent/test_instances.py | 81 +++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/backend/src/octave/agent/instances.py b/backend/src/octave/agent/instances.py index ef76764..673f1b8 100644 --- a/backend/src/octave/agent/instances.py +++ b/backend/src/octave/agent/instances.py @@ -6,9 +6,11 @@ """ import uuid +from typing import Any, cast from pydantic import TypeAdapter, ValidationError -from sqlalchemy import select +from sqlalchemy import select, update +from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession from octave.agent.errors import ( @@ -16,9 +18,11 @@ AgentPausedError, InstanceExistsError, InstanceNotFoundError, + InvalidTransitionError, ModelBindingError, SessionNotFoundError, TerminalSessionError, + TurnInProgressError, ) from octave.db.models import ( Agent, @@ -27,6 +31,7 @@ Session, SessionParticipant, ) +from octave.db.models.base import utcnow from octave.db.types import AgentStatus, InstanceStatus, ModelBinding __all__ = ["AgentInstanceManager"] @@ -112,6 +117,61 @@ async def spawn(self, *, agent_id: str, session_id: str) -> AgentInstance: await self._session.flush() return instance + async def begin_turn(self, instance_id: str) -> AgentInstance: + """Atomically claim the idle→active transition (the turn mutex). + + Re-checks the definition gate: a pause after spawn blocks new + turns; it never interrupts an active one (pause is a gate, not an + interrupt). Rowcount 0 means someone else holds the turn. + """ + instance = await self._get(instance_id) + agent = await self._session.get(Agent, instance.agent_id) + if agent is None: # RESTRICT makes this unreachable; defensive + raise AgentNotFoundError(instance.agent_id) + if agent.status != AgentStatus.ACTIVE: + raise AgentPausedError(instance.agent_id) + result = cast( + CursorResult[Any], + await self._session.execute( + update(AgentInstance) + .where( + AgentInstance.id == instance_id, + AgentInstance.status == str(InstanceStatus.IDLE), + ) + .values(status=str(InstanceStatus.ACTIVE), updated_at=utcnow()) + ), + ) + if not result.rowcount: + raise TurnInProgressError(instance_id) + await self._session.refresh(instance) + return instance + + async def end_turn(self, instance_id: str) -> AgentInstance: + """Release the turn: active→idle. Turn failures use this too — + the error is an event in the session, not instance state.""" + instance = await self._get(instance_id) + if instance.status == str(InstanceStatus.IDLE): + raise InvalidTransitionError( + f"instance {instance_id}: end_turn requires active, got idle" + ) + result = cast( + CursorResult[Any], + await self._session.execute( + update(AgentInstance) + .where( + AgentInstance.id == instance_id, + AgentInstance.status == str(InstanceStatus.ACTIVE), + ) + .values(status=str(InstanceStatus.IDLE), updated_at=utcnow()) + ), + ) + if not result.rowcount: + raise InvalidTransitionError( + f"instance {instance_id}: turn ended concurrently" + ) + await self._session.refresh(instance) + return instance + async def _get(self, instance_id: str) -> AgentInstance: instance = await self._session.get(AgentInstance, instance_id) if instance is None: diff --git a/backend/tests/agent/test_instances.py b/backend/tests/agent/test_instances.py index 95390b8..e7f13c3 100644 --- a/backend/tests/agent/test_instances.py +++ b/backend/tests/agent/test_instances.py @@ -11,9 +11,12 @@ AgentNotFoundError, AgentPausedError, InstanceExistsError, + InstanceNotFoundError, + InvalidTransitionError, ModelBindingError, SessionNotFoundError, TerminalSessionError, + TurnInProgressError, ) from octave.agent.instances import AgentInstanceManager from octave.db.models import ( @@ -157,3 +160,81 @@ async def test_spawn_duplicate_raises( await session.execute(select(AgentInstance)) ).scalars().all() assert len(count) == 1 + + +async def _spawn_idle( + session_factory: async_sessionmaker[AsyncSession], +) -> str: + """Seed defs, spawn, commit; return the instance id.""" + await _seed_defs(session_factory) + async with session_factory() as session: + instance = await AgentInstanceManager(session).spawn( + agent_id="a_1", session_id="s_1" + ) + await session.commit() + return instance.id + + +async def test_begin_turn_activates( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + instance = await AgentInstanceManager(session).begin_turn(instance_id) + await session.commit() + assert instance.status == "active" + + +async def test_begin_turn_twice_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + await AgentInstanceManager(session).begin_turn(instance_id) + await session.commit() + async with session_factory() as session: + with pytest.raises(TurnInProgressError): + await AgentInstanceManager(session).begin_turn(instance_id) + + +async def test_begin_turn_paused_agent_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + agent = await session.get(Agent, "a_1") + assert agent is not None + agent.status = "paused" + await session.commit() + async with session_factory() as session: + with pytest.raises(AgentPausedError): + await AgentInstanceManager(session).begin_turn(instance_id) + + +async def test_begin_turn_missing_instance_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + with pytest.raises(InstanceNotFoundError): + await AgentInstanceManager(session).begin_turn("ghost") + + +async def test_end_turn_idles( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + manager = AgentInstanceManager(session) + await manager.begin_turn(instance_id) + instance = await manager.end_turn(instance_id) + await session.commit() + assert instance.status == "idle" + + +async def test_end_turn_on_idle_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + with pytest.raises(InvalidTransitionError): + await AgentInstanceManager(session).end_turn(instance_id) From feb2387d27964515174e03efce2b58a4bd55e6de Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:44:46 +0100 Subject: [PATCH 09/12] feat(agent): destroy is plain delete; reconcile resets stale active rows (#25) --- backend/src/octave/agent/instances.py | 40 ++++++++++++++++++- backend/tests/agent/test_instances.py | 55 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/backend/src/octave/agent/instances.py b/backend/src/octave/agent/instances.py index 673f1b8..bbb5f39 100644 --- a/backend/src/octave/agent/instances.py +++ b/backend/src/octave/agent/instances.py @@ -5,11 +5,12 @@ Instances carry no context — the session transcript and the vault own it. """ +import logging import uuid from typing import Any, cast from pydantic import TypeAdapter, ValidationError -from sqlalchemy import select, update +from sqlalchemy import delete, select, update from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession @@ -36,6 +37,8 @@ __all__ = ["AgentInstanceManager"] +logger = logging.getLogger(__name__) + _TERMINAL_SESSION_STATUSES = frozenset({"completed", "failed", "cancelled"}) _BINDING_ADAPTER: TypeAdapter[ModelBinding] = TypeAdapter(ModelBinding) @@ -172,6 +175,41 @@ async def end_turn(self, instance_id: str) -> AgentInstance: await self._session.refresh(instance) return instance + async def destroy(self, instance_id: str) -> bool: + """Hard-delete the instance binding. False if absent. + + Pure delete by design: no archival hook, nothing to roll back. + Archival rides the turn boundary (end_turn), not this call — + design spec "Archival Contract". + """ + result = cast( + CursorResult[Any], + await self._session.execute( + delete(AgentInstance).where(AgentInstance.id == instance_id) + ), + ) + return bool(result.rowcount) + + async def reconcile(self) -> int: + """Reset stale active rows to idle after an unclean shutdown. + + The interrupted turn is already visible as a truncated transcript + in events; no zombie states survive restart. Returns rows reset. + """ + result = cast( + CursorResult[Any], + await self._session.execute( + update(AgentInstance) + .where(AgentInstance.status == str(InstanceStatus.ACTIVE)) + .values(status=str(InstanceStatus.IDLE), updated_at=utcnow()) + ), + ) + if result.rowcount: + logger.warning( + "reconciled %s stale active instance(s) to idle", result.rowcount + ) + return int(result.rowcount) + async def _get(self, instance_id: str) -> AgentInstance: instance = await self._session.get(AgentInstance, instance_id) if instance is None: diff --git a/backend/tests/agent/test_instances.py b/backend/tests/agent/test_instances.py index e7f13c3..07f58ee 100644 --- a/backend/tests/agent/test_instances.py +++ b/backend/tests/agent/test_instances.py @@ -238,3 +238,58 @@ async def test_end_turn_on_idle_raises( async with session_factory() as session: with pytest.raises(InvalidTransitionError): await AgentInstanceManager(session).end_turn(instance_id) + + +async def test_destroy_deletes_row( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + destroyed = await AgentInstanceManager(session).destroy(instance_id) + await session.commit() + assert destroyed is True + async with session_factory() as session: + assert await session.get(AgentInstance, instance_id) is None + + +async def test_destroy_absent_returns_false( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + assert await AgentInstanceManager(session).destroy("ghost") is False + + +async def test_destroy_active_is_plain_delete( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """No archival hook, no state precondition — destroy is a delete.""" + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + manager = AgentInstanceManager(session) + await manager.begin_turn(instance_id) + assert await manager.destroy(instance_id) is True + await session.commit() + + +async def test_reconcile_resets_stale_active( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + instance_id = await _spawn_idle(session_factory) + async with session_factory() as session: + await AgentInstanceManager(session).begin_turn(instance_id) + await session.commit() + async with session_factory() as session: + reset = await AgentInstanceManager(session).reconcile() + await session.commit() + assert reset == 1 + async with session_factory() as session: + instance = await session.get(AgentInstance, instance_id) + assert instance is not None and instance.status == "idle" + + +async def test_reconcile_noop_when_all_idle( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _spawn_idle(session_factory) + async with session_factory() as session: + assert await AgentInstanceManager(session).reconcile() == 0 From 2074ba20a123209dd55e000b726cfe72c3a6c663 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:52:30 +0100 Subject: [PATCH 10/12] feat(agent): resolve_model with fail-loud tag lookup (#25) --- backend/src/octave/agent/instances.py | 45 +++++++++++++++++++++++++-- backend/tests/agent/test_instances.py | 33 +++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/backend/src/octave/agent/instances.py b/backend/src/octave/agent/instances.py index bbb5f39..dfedd59 100644 --- a/backend/src/octave/agent/instances.py +++ b/backend/src/octave/agent/instances.py @@ -7,6 +7,8 @@ import logging import uuid +from collections.abc import Callable +from dataclasses import dataclass from typing import Any, cast from pydantic import TypeAdapter, ValidationError @@ -33,9 +35,14 @@ SessionParticipant, ) from octave.db.models.base import utcnow -from octave.db.types import AgentStatus, InstanceStatus, ModelBinding +from octave.db.types import ( + AgentStatus, + ExplicitModelBinding, + InstanceStatus, + ModelBinding, +) -__all__ = ["AgentInstanceManager"] +__all__ = ["AgentInstanceManager", "ResolvedModel", "resolve_model"] logger = logging.getLogger(__name__) @@ -57,6 +64,40 @@ def _validate_binding(agent: Agent) -> None: ) from exc +@dataclass(frozen=True) +class ResolvedModel: + """Concrete (adapter, model) pair for a turn. ``adapter=None`` means + "use the configured default adapter" (tag bindings defer engine + selection to the inference layer).""" + + adapter: str | None + model: str + + +def resolve_model( + binding: ModelBinding, + *, + tag_lookup: Callable[[str], str | None] | None = None, +) -> ResolvedModel: + """Resolve a binding to a concrete pair at turn start. + + ``tag_lookup`` maps a capability tag to a model name; the turn runner + wires it once model tagging lands (Inference roadmap #7). A tag miss + fails loud — an agent with no usable model is unusable, and silent + fallback would hide misconfiguration (design spec §4). + """ + if isinstance(binding, ExplicitModelBinding): + return ResolvedModel(adapter=binding.adapter, model=binding.model) + if tag_lookup is None: + raise ModelBindingError( + f"tag binding {binding.tag!r} requires a tag_lookup" + ) + model = tag_lookup(binding.tag) + if model is None: + raise ModelBindingError(f"model tag {binding.tag!r} resolves to nothing") + return ResolvedModel(adapter=None, model=model) + + class AgentInstanceManager: """spawn / begin_turn / end_turn / destroy / reconcile. Never commits; callers own transaction boundaries (see ``octave.db.deps``).""" diff --git a/backend/tests/agent/test_instances.py b/backend/tests/agent/test_instances.py index 07f58ee..e693e24 100644 --- a/backend/tests/agent/test_instances.py +++ b/backend/tests/agent/test_instances.py @@ -18,7 +18,11 @@ TerminalSessionError, TurnInProgressError, ) -from octave.agent.instances import AgentInstanceManager +from octave.agent.instances import ( + AgentInstanceManager, + ResolvedModel, + resolve_model, +) from octave.db.models import ( Agent, AgentInstance, @@ -27,6 +31,7 @@ SessionParticipant, User, ) +from octave.db.types import ExplicitModelBinding, TagModelBinding _TAG_BINDING = {"kind": "tag", "tag": "quick"} @@ -293,3 +298,29 @@ async def test_reconcile_noop_when_all_idle( await _spawn_idle(session_factory) async with session_factory() as session: assert await AgentInstanceManager(session).reconcile() == 0 + + +def test_resolve_explicit_binding() -> None: + resolved = resolve_model( + ExplicitModelBinding(kind="explicit", adapter="openai", model="llama3") + ) + assert resolved == ResolvedModel(adapter="openai", model="llama3") + + +def test_resolve_tag_binding_with_lookup() -> None: + resolved = resolve_model( + TagModelBinding(kind="tag", tag="quick"), tag_lookup=lambda tag: "llama3" + ) + assert resolved == ResolvedModel(adapter=None, model="llama3") + + +def test_resolve_tag_miss_raises() -> None: + with pytest.raises(ModelBindingError): + resolve_model( + TagModelBinding(kind="tag", tag="nope"), tag_lookup=lambda t: None + ) + + +def test_resolve_tag_without_lookup_raises() -> None: + with pytest.raises(ModelBindingError): + resolve_model(TagModelBinding(kind="tag", tag="quick")) From 6cb93c4475d7c516e983ee297af0361c47b39b1d Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:56:33 +0100 Subject: [PATCH 11/12] feat(agent): export instance lifecycle public surface (#25) --- backend/src/octave/agent/__init__.py | 36 ++++++++++++++++++++++++---- backend/tests/agent/test_package.py | 13 ++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/backend/src/octave/agent/__init__.py b/backend/src/octave/agent/__init__.py index 589de2d..bfdef6d 100644 --- a/backend/src/octave/agent/__init__.py +++ b/backend/src/octave/agent/__init__.py @@ -1,23 +1,51 @@ -"""Agent plane: tool-use orchestration loop (issue #79). +"""Agent plane: tool-use orchestration (issue #79) and instance lifecycle +(design spec #25). Composition layer — the only package importing both octave.mcp and -octave.inference. Future Agent Manager components (manager, registry, -router) land here as additional modules; they do not exist yet. +octave.inference, plus octave.db for the instance manager. Future Agent +Manager components (registry, router) land here as additional modules; +they do not exist yet. """ -from octave.agent.errors import ToolLoopLimitError +from octave.agent.errors import ( + AgentError, + AgentNotFoundError, + AgentPausedError, + InstanceExistsError, + InstanceNotFoundError, + InvalidTransitionError, + ModelBindingError, + SessionNotFoundError, + TerminalSessionError, + ToolLoopLimitError, + TurnInProgressError, +) from octave.agent.executor import ToolExecutor +from octave.agent.instances import AgentInstanceManager, ResolvedModel, resolve_model from octave.agent.loop import ToolLoop from octave.agent.mcp_executor import McpToolExecutor from octave.agent.types import ToolOutcome, ToolTurn from octave.tools.errors import ToolError __all__ = [ + "AgentError", + "AgentInstanceManager", + "AgentNotFoundError", + "AgentPausedError", + "InstanceExistsError", + "InstanceNotFoundError", + "InvalidTransitionError", "McpToolExecutor", + "ModelBindingError", + "ResolvedModel", + "SessionNotFoundError", + "TerminalSessionError", "ToolError", "ToolExecutor", "ToolLoop", "ToolLoopLimitError", "ToolOutcome", "ToolTurn", + "TurnInProgressError", + "resolve_model", ] diff --git a/backend/tests/agent/test_package.py b/backend/tests/agent/test_package.py index 9378186..576ef2a 100644 --- a/backend/tests/agent/test_package.py +++ b/backend/tests/agent/test_package.py @@ -27,12 +27,25 @@ def test_no_sdk_imports() -> None: def test_public_names_are_exported() -> None: for name in ( + "AgentError", + "AgentInstanceManager", + "AgentNotFoundError", + "AgentPausedError", + "InstanceExistsError", + "InstanceNotFoundError", + "InvalidTransitionError", "McpToolExecutor", + "ModelBindingError", + "ResolvedModel", + "SessionNotFoundError", + "TerminalSessionError", "ToolError", "ToolExecutor", "ToolLoop", "ToolLoopLimitError", "ToolOutcome", "ToolTurn", + "TurnInProgressError", + "resolve_model", ): assert hasattr(agent_pkg, name), name From a5cbdb69be537e9830b3141e2eb148b3d4226dc9 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:05:42 +0100 Subject: [PATCH 12/12] docs: record agent lifecycle model in architecture and todo (#25) --- docs/ARCHITECTURE.md | 23 +++++++++++++++++++++-- docs/TODO.md | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2f4ba02..3714732 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -113,7 +113,7 @@ Central knowledge and context assembly subsystem. Manages the context vault — Orchestrates agent lifecycles, routes messages between agents and subsystems, and collects results for inter-agent sharing and context archival. **Responsibilities:** -- **Agent Lifecycle Model** — Spawn, pause, resume, and terminate states +- **Agent Lifecycle Model** — Definition `active|paused` gate; instance spawn → idle ⇄ active → destroy - **Agent Registry** — Tracks running agents, their IDs, status, and assigned context - **Message Router** — Delivers incoming messages to the correct agent; supports broadcast - **Result Collector** — Captures agent outputs and makes them queryable @@ -139,6 +139,23 @@ no routes/lifespan wiring yet — composition arrives with Integration & Testing `openai`/`mcp` SDKs stay quarantined from the package (AST guard). Design: [`.agents/specs/2026-09-25-tool-use-orchestration-loop-design.md`](../.agents/specs/2026-09-25-tool-use-orchestration-loop-design.md). +**Implemented — agent lifecycle model (issue #25):** definitions (`agents`) carry a +definition-level `active|paused` gate, a `model_binding` (tag or explicit +provider-model pair) and `assignments` (named vault-item references); instances +(`agent_instances`) are ephemeral one-definition-per-session rows, hard-deleted on +destroy and carrying no context — the session transcript and the vault own it. +`AgentInstanceManager` (`octave.agent.instances`) is the single write path, following +the `VaultStore` convention (caller-supplied `AsyncSession`, never commits): `spawn` +ensures participant + membership (re-invite resets `left_at`) and fails loud on +paused definitions, terminal sessions, duplicate bindings, and missing/malformed +model bindings; `begin_turn` claims the idle→active turn mutex atomically; `destroy` +is a plain delete (archival rides the turn boundary, decoupled by design); +`reconcile` resets stale `active` rows to idle after unclean shutdown. +`resolve_model` resolves a binding to a concrete (adapter, model) pair at turn start, +fail-loud on tag misses (tag→model lookup lands with Inference #7). Library-only: no +routes/lifespan wiring yet. Design: +[`.agents/specs/2026-09-27-agent-lifecycle-model-design.md`](../.agents/specs/2026-09-27-agent-lifecycle-model-design.md). + --- ## Data Layer @@ -160,7 +177,9 @@ the inference adapter pattern: vec0 virtual table, not Alembic-managed; `vault_items.embedding` is the engine-neutral cache and `content` is the source of truth - Stores: MCP server configs, vault items with embeddings, session transcripts, - agent registry entries + agent registry entries (`agents`: `model_binding`/`assignments` JSON, + `active|paused` gate) and live instance bindings (`agent_instances`: unique + agent×session, RESTRICT on definition delete, CASCADE on session delete) --- diff --git a/docs/TODO.md b/docs/TODO.md index f354839..56b9a0c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -104,7 +104,7 @@ ## Agent Manager -- [ ] 1. Design agent lifecycle model (spawn, pause, resume, terminate states) +- [x] 1. Design agent lifecycle model (definition active/paused gate; instance spawn → idle ⇄ active → destroy) — PR #105 (lifecycle vocabulary, `agent_instances` table, `AgentInstanceManager`; router/registry/scheduling remain #2–6) - [ ] 2. Build agent registry (track running agents, their IDs, status, and assigned context) - [ ] 3. Implement agent message routing (deliver messages to correct agent, broadcast when needed) - [ ] 4. Create agent result collection (capture agent outputs and make them queryable)