=== Transcripción del agente (turno a turno) ===
args de write_file con el cuerpo omitido (N1); outputs recortados (N2).

[turno 1] bash  {"command": "ls -la && find . -maxdepth 2 -type f -print | sort && printf '\\n--- README ---\\n' && cat README*"}
    exit=0
    --- stdout ---
    total 48
    drwxr-xr-x 5 root root 4096 Jul 13 12:55 .
    drwxr-xr-x 1 root root 4096 Jul 13 12:37 ..
    -rw-r--r-- 1 root root 3796 Jul 13 12:48 DECISIONS.md
    -rw-r--r-- 1 root root 2399 Jul 13 12:37 README.md
    -rw-r--r-- 1 root root 2943 Jul 13 12:37 api.md
    -rw-r--r-- 1 root root 1831 Jul 13 12:46 api_v2.md
    -rwxr-xr-x 1 root root 5869 Jul 13 12:47 app.py
    drwxr-xr-x 2 root root 4096 Jul 13 12:55 data
    drwxr-xr-x 3 root root 4096 Jul 13 12:49 marea
    -rwxr-xr-x 1 root root   59 Jul 13 12:39 run.sh
    drwxr-xr-x 2 root root 4096 Jul 13 12:49 tests
    ./DECISIONS.md
    ./README.md
    ./api.md
    ./api_v2.md
    ./app.py
    ./data/marea.sqlite3
    ./marea/__init__.py
    ./marea/service.py
    ./run.sh
    ./tests/test_service.py
    ./tests/test_v2.py
    
    --- README ---
    # Sistema de reservas para "Marea"
    
    Somos un restaurante con **14 mesas** y dos turnos (**comida** y **cena**).
    Hasta hoy las reservas se gestionan por teléfono y libreta, y ya no damos abasto:
    dobles reservas apuntadas a boli, gente que llama dos veces, hu
    …(N2: 631 chars omitidos en medio)…
    ueda apuntado en lista de
      espera por orden de llegada. Si alguien cancela, el primero de la lista entra
      automáticamente.
    - **Modificar una reserva** (personas, fecha o turno). Si el cambio no cabe, la
      reserva original NO se pierde: se queda como estaba y se avisa.
    - **Cancelar una reserva** (libera la mesa y promociona la lista de espera).
    - **Consultar disponibilidad** de una fecha y turno.
    - **Saber si un teléfono ya tiene reserva** (nos llaman y no se acuerdan).
    
    El mismo cliente despistado envía la reserva dos veces seguidas, o dos personas de la
    misma familia reservan a la vez para lo mismo: **el mismo teléfono no puede tener dos
    reservas activas para la misma fecha y turno**.
    
    ## Contrato técnico (NO negociable)
    
    La interfaz HTTP exacta (endpoints, códigos de estado, forma del JSON, idempotencia y
    límite de peticiones) está especificada en **`api.md`**. Respétala al pie de la letra:
    nuestro panel de sala se integra contra ella. Todo lo interno es cosa tuya.
    
    ## Cómo la arrancamos
    
    Deja un script ejecutable **`run.sh`** en la raíz que levante tu app escuchando en
    `0.0.0.0:8080` en primer plano:
    
    ```
    sh run.sh    # la app responde en http://localhost:8080
    ```
    
    Durante el desarrollo tienes internet para instalar dependencias. En nuestro entorno
    de despliegue la app corre **sin salida a internet**: no dependas de servicios externos.
    
    ## Nota
    
    El restaurante va bien y el dueño tiene planes. El sistema tendrá que evolucionar;
    no sabemos aún hacia dónde.
    
    --- stderr ---

[turno 2] read_file  {"path": "marea/service.py"}
    from __future__ import annotations
    
    import copy
    import json
    import os
    import re
    import sqlite3
    import time
    import uuid
    from datetime import date
    from pathlib import Path
    from typing import Any
    
    CAPACIDAD = 14
    TURNOS = {"comida", "cena"}
    FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
    CAMPOS_POST = {"nombre", "telefono", "personas", "fecha", "turno"}
    CAMPOS_PATCH = {"personas", "fecha", "turno"}
    
    # El catálogo forma parte del contrato HTTP. Los precios se representan siempre
    # con enteros en céntimos para no introducir redondeos de punto flotante.
    CATALOGO_PRECIOS = {
        "moneda": "EUR",
        "importes_en": "centimos",
        "menus": [
            {
                "codigo": "menu-dia",
                "nombre": "Menú del día",
                "precio_por_persona": 1650,
            },
            {
                "codigo": "menu-degustacion",
                "nombre": "Menú degustación",
                "precio_por_persona": 4500,
            },
            {
                "codigo": "menu-grupo",
                "nombre": "Menú de grupo"
    …(N2: 18913 chars omitidos en medio)…
     fotografía, incluso si se produce una cancelación concurrente.
                conn.execute("BEGIN")
                reserva = conn.execute(
                    """SELECT personas, fecha, turno, telefono FROM reservas
                       WHERE id = ? AND estado IN ('confirmada', 'lista_espera')""",
                    (reserva_id,),
                ).fetchone()
                if not reserva:
                    return 404, {"error": "reserva no encontrada"}
    
                ofertas: list[dict[str, Any]] = []
                if reserva["personas"] == 6:
                    ofertas.append({"codigo": "menu-grupo"})
    
                if reserva["fecha"] == "2027-02-14":
                    ofertas.append({"codigo": "menu-san-valentin"})
                if reserva["fecha"] == "2026-12-31":
                    ofertas.append({"codigo": "menu-nochevieja"})
    
                fecha_reserva = date.fromisoformat(reserva["fecha"])
                if reserva["turno"] == "cena" and fecha_reserva.weekday() in (4, 5):
                    ofertas.append({"codigo": "maridaje"})
    
                activas = conn.execute(
                    """SELECT COUNT(*) FROM reservas
                       WHERE telefono = ?
                         AND estado IN ('confirmada', 'lista_espera')""",
                    (reserva["telefono"],),
                ).fetchone()[0]
                if activas >= 3:
                    ofertas.append(
                        {"codigo": "descuento-fidelidad", "descuento_pct": 10}
                    )
    
                return 200, ofertas
            finally:
                conn.close()

[turno 2] read_file  {"path": "app.py"}
    #!/usr/bin/env python3
    from __future__ import annotations
    
    import json
    import os
    import sqlite3
    import sys
    import traceback
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from pathlib import Path
    from typing import Any
    from urllib.parse import parse_qs, unquote, urlsplit
    
    from marea.service import ReservationService, ValidationError
    
    ROOT = Path(__file__).resolve().parent
    DB_PATH = os.environ.get("MAREA_DB_PATH", str(ROOT / "data" / "marea.sqlite3"))
    MAX_BODY = 1024 * 1024
    service = ReservationService(DB_PATH)
    
    
    class MareaHTTPServer(ThreadingHTTPServer):
        daemon_threads = True
        allow_reuse_address = True
        request_queue_size = 128
    
    
    class Handler(BaseHTTPRequestHandler):
        server_version = "Marea/2.0"
    
        def _json_body(self) -> Any:
            length_value = self.headers.get("Content-Length")
            if length_value is None:
                raise ValidationError("falta el body JSON")
            try:
                length = int(length_value)
            except ValueErro
    …(N2: 3363 chars omitidos en medio)…
         return 404, {"error": "recurso no encontrado"}
    
        def _handle(self) -> None:
            try:
                status, body = self._dispatch()
                self._send(status, body)
            except ValidationError as exc:
                self._send(422, {"error": str(exc)})
            except sqlite3.Error:
                # No se filtran estructura, queries ni rutas locales del servidor.
                traceback.print_exc(file=sys.stderr)
                self._send(500, {"error": "error interno de persistencia"})
            except Exception:
                traceback.print_exc(file=sys.stderr)
                self._send(500, {"error": "error interno"})
    
        def do_GET(self) -> None:  # noqa: N802
            self._handle()
    
        def do_POST(self) -> None:  # noqa: N802
            self._handle()
    
        def do_PATCH(self) -> None:  # noqa: N802
            self._handle()
    
        def do_DELETE(self) -> None:  # noqa: N802
            self._handle()
    
        def do_PUT(self) -> None:  # noqa: N802
            self._handle()
    
        def log_message(self, fmt: str, *args: Any) -> None:
            sys.stderr.write(
                "%s - - [%s] %s\n"
                % (self.address_string(), self.log_date_time_string(), fmt % args)
            )
    
    
    def main() -> None:
        server = MareaHTTPServer(("0.0.0.0", 8080), Handler)
        print(f"Marea v2 escuchando en 0.0.0.0:8080 (db: {DB_PATH})", flush=True)
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            pass
        finally:
            server.server_close()
    
    
    if __name__ == "__main__":
        main()

[turno 2] read_file  {"path": "tests/test_service.py"}
    import tempfile
    import unittest
    from pathlib import Path
    
    from marea.service import ReservationService
    
    
    class ReservationServiceTests(unittest.TestCase):
        def setUp(self):
            self.tmp = tempfile.TemporaryDirectory()
            self.service = ReservationService(Path(self.tmp.name) / "test.sqlite3")
    
        def tearDown(self):
            self.tmp.cleanup()
    
        @staticmethod
        def payload(i, fecha="2030-01-01", turno="comida", telefono=None):
            return {
                "nombre": f"Cliente {i}",
                "telefono": telefono or f"600{i:06d}",
                "personas": 2,
                "fecha": fecha,
                "turno": turno,
            }
    
        def test_capacidad_espera_y_promocion_fifo(self):
            creadas = []
            for i in range(16):
                status, reserva = self.service.crear(self.payload(i), f"key-{i}")
                self.assertEqual(201 if i < 14 else 202, status)
                creadas.append(reserva)
            self.assertEqual(1, creadas[14]["posicion"])
            self.assertEqu
    …(N2: 989 chars omitidos en medio)…
    ar una reserva más tarde.
            status, repetido = self.service.crear(self.payload(1), "conflict-key")
            self.assertEqual(409, status)
            self.assertEqual(conflicto, repetido)
    
        def test_patch_lleno_es_atomico(self):
            _, origen = self.service.crear(self.payload(1), "origin")
            for i in range(14):
                status, _ = self.service.crear(
                    self.payload(100 + i, fecha="2030-01-02"), f"target-{i}"
                )
                self.assertEqual(201, status)
    
            status, _ = self.service.modificar(origen["id"], {"fecha": "2030-01-02"})
            self.assertEqual(409, status)
            _, encontradas = self.service.por_telefono(origen["telefono"])
            self.assertEqual("2030-01-01", encontradas[0]["fecha"])
            self.assertEqual("confirmada", encontradas[0]["estado"])
    
        def test_limite_es_independiente_por_telefono(self):
            for i in range(20):
                status, _ = self.service.crear(
                    self.payload(i, fecha=f"2031-01-{i + 1:02d}", telefono="RATE"), None
                )
                self.assertEqual(201, status)
            self.assertEqual(
                429,
                self.service.crear(
                    self.payload(30, fecha="2031-02-01", telefono="RATE"), None
                )[0],
            )
            self.assertEqual(
                201,
                self.service.crear(
                    self.payload(30, fecha="2031-02-01", telefono="OTHER"), None
                )[0],
            )
    
    
    if __name__ == "__main__":
        unittest.main()

[turno 2] read_file  {"path": "tests/test_v2.py"}
    import tempfile
    import unittest
    from pathlib import Path
    
    from marea.service import ReservationService
    
    
    class ApiV2ServiceTests(unittest.TestCase):
        def setUp(self):
            self.tmp = tempfile.TemporaryDirectory()
            self.service = ReservationService(Path(self.tmp.name) / "test.sqlite3")
            self.numero = 0
    
        def tearDown(self):
            self.tmp.cleanup()
    
        def crear(
            self,
            *,
            telefono="600000000",
            personas=2,
            fecha="2030-01-01",
            turno="comida",
        ):
            self.numero += 1
            status, reserva = self.service.crear(
                {
                    "nombre": f"Cliente {self.numero}",
                    "telefono": telefono,
                    "personas": personas,
                    "fecha": fecha,
                    "turno": turno,
                },
                f"v2-{self.numero}",
            )
            self.assertEqual(201, status)
            return reserva
    
        def codigos(self, reserva_id):
            status, ofertas = self.service.suge
    …(N2: 2095 chars omitidos en medio)…
    telefono="V", fecha="2030-01-04", turno="cena")
            sabado = self.crear(telefono="S", fecha="2030-01-05", turno="cena")
            comida = self.crear(telefono="C", fecha="2030-01-04", turno="comida")
            domingo = self.crear(telefono="D", fecha="2030-01-06", turno="cena")
            self.assertEqual({"maridaje"}, set(self.codigos(viernes["id"])))
            self.assertEqual({"maridaje"}, set(self.codigos(sabado["id"])))
            self.assertEqual({}, self.codigos(comida["id"]))
            self.assertEqual({}, self.codigos(domingo["id"]))
    
        def test_fidelidad_cuenta_confirmadas_y_espera_pero_no_canceladas(self):
            primera = self.crear(telefono="FIEL", fecha="2030-02-01")
            segunda = self.crear(telefono="FIEL", fecha="2030-02-02")
            self.assertNotIn("descuento-fidelidad", self.codigos(primera["id"]))
            tercera = self.crear(telefono="FIEL", fecha="2030-02-03")
            for reserva in (primera, segunda, tercera):
                oferta = self.codigos(reserva["id"])["descuento-fidelidad"]
                self.assertEqual(10, oferta["descuento_pct"])
    
            self.assertEqual(204, self.service.cancelar(tercera["id"])[0])
            self.assertNotIn("descuento-fidelidad", self.codigos(primera["id"]))
            self.assertEqual(404, self.service.sugerencias(tercera["id"])[0])
    
        def test_sugerencias_inexistente_devuelve_404(self):
            status, _ = self.service.sugerencias("no-existe")
            self.assertEqual(404, status)
    
    
    if __name__ == "__main__":
        unittest.main()

[turno 2] read_file  {"path": "api.md"}
    # Contrato HTTP — v1 (congelado)
    
    Todas las peticiones y respuestas en JSON. La app escucha en `0.0.0.0:8080`.
    Una reserva, allá donde se devuelva, es un objeto con AL MENOS estos campos en el
    **primer nivel** (puedes añadir los que quieras, también en la raíz):
    
    ```
    { "id": str, "nombre": str, "telefono": str, "personas": int,
      "fecha": "YYYY-MM-DD", "turno": "comida"|"cena",
      "estado": "confirmada"|"lista_espera" }
    ```
    
    Reglas de dominio:
    
    - **Durabilidad**: la app puede **reiniciarse en cualquier momento** (deploys,
      cortes). Las reservas deben sobrevivir a un reinicio: persistencia en disco,
      nada de estado solo-en-memoria.
    - Capacidad: **máximo 14 reservas confirmadas** por (fecha, turno). La 15ª y
      siguientes entran en **lista de espera** por orden de llegada (FIFO).
    - `personas` válido: 1 a 6. Fecha válida: `YYYY-MM-DD`. Turno: `comida` o `cena`.
    - Un mismo `telefono` no puede tener DOS reservas activas (confirmada o en espera)
      para la misma (fecha, turno).
    
    ## Endpoints
    
    …(N2: 390 chars omitidos en medio)…
    ntrar)
    - `409` → ese `telefono` ya tiene reserva activa para esa (fecha, turno). El body
      incluye la reserva existente bajo la clave `"reserva"`.
    - `422` → datos inválidos (personas fuera de 1-6, fecha mal formada, turno
      desconocido, campos que faltan). No se crea nada.
    - `429` → límite de peticiones (ver abajo).
    
    ### PATCH /reservas/{id}
    Modifica una reserva activa. Body: cualquier subconjunto de `{personas, fecha, turno}`.
    
    - `200` → la reserva actualizada.
    - `409` → el cambio no cabe (destino lleno) o violaría la regla del teléfono. La
      reserva original queda **exactamente como estaba** (transaccional: nunca a medias,
      nunca perdida).
    - `404` → no existe. · `422` → datos inválidos (original intacta).
    
    ### DELETE /reservas/{id}
    Cancela la reserva. Libera la mesa y, si hay lista de espera para esa (fecha, turno),
    **el primero pasa automáticamente a confirmada**.
    
    - `204` o `200` → cancelada. Un segundo DELETE del mismo id → `404`.
    
    ### GET /disponibilidad?fecha=YYYY-MM-DD&turno=comida|cena
    - `200` → `{"fecha": ..., "turno": ..., "mesas_libres": int}` (0 a 14)
    
    ### GET /reservas?telefono=...
    - `200` → lista (posiblemente vacía) de las reservas ACTIVAS de ese teléfono
      (confirmadas y en lista de espera; las canceladas no aparecen).
    
    ## Límite de peticiones
    
    Máximo **20 POST /reservas por minuto para un mismo `telefono`** (ventana deslizante
    o fija, a tu criterio). Al superarlo → `429`. El límite es por teléfono: peticiones
    de teléfonos distintos NO se bloquean entre sí.

[turno 2] read_file  {"path": "api_v2.md"}
    # Contrato HTTP — v2 (ampliación congelada: precios y sugerencias)
    
    Se AÑADE a la v1 (api.md), que sigue vigente entera. Todos los importes son
    **céntimos de euro, enteros** (int). Nada de floats con dinero.
    
    ## GET /precios
    
    `200` → el catálogo EXACTO de abajo (puedes añadir campos extra, pero estos códigos,
    nombres e importes tienen que estar tal cual):
    
    ```json
    {
      "moneda": "EUR",
      "importes_en": "centimos",
      "menus": [
        {"codigo": "menu-dia",          "nombre": "Menú del día",     "precio_por_persona": 1650},
        {"codigo": "menu-degustacion",  "nombre": "Menú degustación", "precio_por_persona": 4500},
        {"codigo": "menu-grupo",        "nombre": "Menú de grupo",    "precio_por_persona": 2800},
        {"codigo": "menu-san-valentin", "nombre": "Menú San Valentín","precio_por_persona": 5500},
        {"codigo": "menu-nochevieja",   "nombre": "Menú Nochevieja",  "precio_por_persona": 9500}
      ],
      "extras": [
        {"codigo": "maridaje", "nombre": "Maridaje de vinos", "precio_por_persona": 2500}
      ]
    }
    ```
    
    ## GET /reservas/{id}/sugerencias
    
    `200` → lista de ofertas aplicables a ESA reserva (activa). `404` si el id no existe.
    Cada oferta es un objeto con al menos `{"codigo": str}`. Deben salir **exactamente**
    las que apliquen según estas reglas (ni una más, ni una menos; el orden da igual):
    
    1. `menu-grupo` — si la reserva es de **6 personas**.
    2. `menu-san-valentin` — si la fecha es **2027-02-14**.
       `menu-nochevieja` — si la fecha es **2026-12-31**.
    3. `maridaje` — si el turno es **cena** y la fecha cae en **viernes o sábado**.
    4. `descuento-fidelidad` — si ese `telefono` tiene **3 o más reservas activas** en el
       sistema (contando esta). Incluye en la oferta `"descuento_pct": 10`.
    
    Pueden aplicar varias a la vez (se devuelven todas). Si no aplica ninguna → `[]`.

[turno 2] read_file  {"path": "DECISIONS.md"}
    # Decisiones técnicas — Marea v1
    
    ## Stack
    
    Se usa **Python 3 (solo biblioteca estándar)** para la API HTTP y **SQLite** para la
    persistencia. El servicio no necesita descargar paquetes ni contactar servicios
    externos al arrancar, por lo que funciona en el entorno sin Internet indicado. Para
    una API interna pequeña (14 mesas y dos turnos) este stack reduce mucho la operación:
    un proceso y un fichero de datos durable. `run.sh` mantiene el proceso en primer plano
    y la ruta de la base se puede cambiar con `MAREA_DB_PATH` (por defecto
    `data/marea.sqlite3`).
    
    La separación entre el adaptador HTTP (`app.py`) y dominio/persistencia
    (`marea/service.py`) evita atar las reglas a la interfaz y permite sustituir el
    servidor o la base si el volumen futuro lo exige.
    
    ## Consistencia y reglas de negocio
    
    - Cada operación que decide capacidad o cambia estados usa una transacción SQLite
      **`BEGIN IMMEDIATE`**. Los escritores quedan serializados, así que dos altas
      concurrentes nunca pueden consumir l
    …(N2: 1248 chars omitidos en medio)…
    tos POST con body válido (también conflictos de teléfono) y el siguiente
    responde `429`. Teléfonos distintos usan contadores independientes.
    
    ## Alcance asumido
    
    Cada reserva ocupa una mesa con independencia de `personas`, tal como define el
    contrato (14 **reservas confirmadas** por turno). No se rechazan fechas pasadas porque
    el contrato solo exige que la fecha exista y tenga formato `YYYY-MM-DD`. Nombre y
    teléfono son strings no vacíos y se conservan tal como los entrega el panel; no se
    intenta normalizar números internacionales sin una regla de producto para hacerlo.
    
    ## Ampliación v2: precios y sugerencias
    
    El catálogo se mantiene como una constante de aplicación versionada junto al contrato:
    son pocos valores administrados por producto y no necesitan escrituras ni una nueva
    tabla. Todos los importes son enteros en céntimos y cada respuesta recibe una copia del
    catálogo para impedir mutaciones accidentales entre peticiones.
    
    Las sugerencias son una vista calculada en lectura a partir de la reserva y del recuento
    de reservas activas del teléfono. Ambas consultas comparten una fotografía transaccional
    de SQLite. Las canceladas no reciben ofertas ni cuentan para fidelidad, igual que en el
    resto de operaciones v1 que trabajan con reservas activas.
    
    La v2 no cambia ni recrea el esquema: reutiliza los índices de reservas existentes y,
    en particular, no ejecuta migraciones destructivas sobre la base de producción. Las
    reservas y todos los comportamientos v1 permanecen intactos.

[turno 2] read_file  {"path": "run.sh"}
    #!/bin/sh
    set -eu
    cd "$(dirname "$0")"
    exec python3 app.py

[turno 3] bash  {"command": "nl -ba marea/service.py | sed -n '1,520p'; printf '\\n--- APP ---\\n'; nl -ba app.py | sed -n '1,300p'; printf '\\n--- TEST SERVICE ---\\n'; cat tests/test_service.py; printf '\\n--- DECISIONS ---\\n'; cat DECISIONS.md"}
    exit=0
    --- stdout ---
         1	from __future__ import annotations
         2	
         3	import copy
         4	import json
         5	import os
         6	import re
         7	import sqlite3
         8	import time
         9	import uuid
        10	from datetime import date
        11	from pathlib import Path
        12	from typing import Any
        13	
        14	CAPACIDAD = 14
        15	TURNOS = {"comida", "cena"}
        16	FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
        17	CAMPOS_POST = {"nombre", "telefono", "personas", "fecha", "turno"}
        18	CAMPOS_PATCH = {"personas", "fecha", "turno"}
        19	
        20	# El catálogo forma parte del contrato HTTP. Los precios se representan siempre
        21	# con enteros en céntimos para no introducir redondeos de punto flotante.
        22	CATALOGO_PRECIOS = {
        23	    "moneda": "EUR",
        24	    "importes_en": "centimos",
        25	    "menus": [
        26	        {
        27	            "codigo": "menu-dia",
        28	            "nombre": "Menú del día",
        29	            "precio_por_persona": 1650,
        30	       
    …(N2: 35667 chars omitidos en medio)…
    y válido (también conflictos de teléfono) y el siguiente
    responde `429`. Teléfonos distintos usan contadores independientes.
    
    ## Alcance asumido
    
    Cada reserva ocupa una mesa con independencia de `personas`, tal como define el
    contrato (14 **reservas confirmadas** por turno). No se rechazan fechas pasadas porque
    el contrato solo exige que la fecha exista y tenga formato `YYYY-MM-DD`. Nombre y
    teléfono son strings no vacíos y se conservan tal como los entrega el panel; no se
    intenta normalizar números internacionales sin una regla de producto para hacerlo.
    
    ## Ampliación v2: precios y sugerencias
    
    El catálogo se mantiene como una constante de aplicación versionada junto al contrato:
    son pocos valores administrados por producto y no necesitan escrituras ni una nueva
    tabla. Todos los importes son enteros en céntimos y cada respuesta recibe una copia del
    catálogo para impedir mutaciones accidentales entre peticiones.
    
    Las sugerencias son una vista calculada en lectura a partir de la reserva y del recuento
    de reservas activas del teléfono. Ambas consultas comparten una fotografía transaccional
    de SQLite. Las canceladas no reciben ofertas ni cuentan para fidelidad, igual que en el
    resto de operaciones v1 que trabajan con reservas activas.
    
    La v2 no cambia ni recrea el esquema: reutiliza los índices de reservas existentes y,
    en particular, no ejecuta migraciones destructivas sobre la base de producción. Las
    reservas y todos los comportamientos v1 permanecen intactos.
    
    --- stderr ---

[turno 4] bash  {"command": "sed -n '1,180p' marea/service.py"}
    exit=0
    --- stdout ---
    from __future__ import annotations
    
    import copy
    import json
    import os
    import re
    import sqlite3
    import time
    import uuid
    from datetime import date
    from pathlib import Path
    from typing import Any
    
    CAPACIDAD = 14
    TURNOS = {"comida", "cena"}
    FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
    CAMPOS_POST = {"nombre", "telefono", "personas", "fecha", "turno"}
    CAMPOS_PATCH = {"personas", "fecha", "turno"}
    
    # El catálogo forma parte del contrato HTTP. Los precios se representan siempre
    # con enteros en céntimos para no introducir redondeos de punto flotante.
    CATALOGO_PRECIOS = {
        "moneda": "EUR",
        "importes_en": "centimos",
        "menus": [
            {
                "codigo": "menu-dia",
                "nombre": "Menú del día",
                "precio_por_persona": 1650,
            },
            {
                "codigo": "menu-degustacion",
                "nombre": "Menú degustación",
                "precio_por_persona": 4500,
            },
            {
                "codigo": "menu-grupo",
                "no
    …(N2: 4048 chars omitidos en medio)…
    stado TEXT NOT NULL CHECK (estado IN ('confirmada', 'lista_espera', 'cancelada')),
                        creada_en REAL NOT NULL,
                        actualizada_en REAL NOT NULL
                    );
    
                    CREATE UNIQUE INDEX IF NOT EXISTS una_reserva_activa_por_telefono
                        ON reservas(telefono, fecha, turno)
                        WHERE estado IN ('confirmada', 'lista_espera');
    
                    CREATE INDEX IF NOT EXISTS reservas_por_turno
                        ON reservas(fecha, turno, estado, secuencia);
                    CREATE INDEX IF NOT EXISTS reservas_por_telefono
                        ON reservas(telefono, estado, secuencia);
    
                    CREATE TABLE IF NOT EXISTS idempotencia (
                        clave TEXT PRIMARY KEY,
                        reserva_id TEXT NOT NULL,
                        codigo INTEGER NOT NULL,
                        respuesta TEXT NOT NULL,
                        creada_en REAL NOT NULL,
                        FOREIGN KEY (reserva_id) REFERENCES reservas(id)
                    );
    
                    CREATE TABLE IF NOT EXISTS limite_posts (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        telefono TEXT NOT NULL,
                        instante REAL NOT NULL
                    );
                    CREATE INDEX IF NOT EXISTS limite_por_telefono
                        ON limite_posts(telefono, instante);
    
                    CREATE TRIGGER IF NOT EXISTS capacidad_al_insertar
                    BEFORE INSERT ON reservas
    
    --- stderr ---

[turno 4] bash  {"command": "sed -n '181,360p' marea/service.py"}
    exit=0
    --- stdout ---
                    WHEN NEW.estado = 'confirmada' AND
                         (SELECT COUNT(*) FROM reservas
                          WHERE fecha = NEW.fecha AND turno = NEW.turno
                            AND estado = 'confirmada') >= 14
                    BEGIN
                        SELECT RAISE(ABORT, 'capacity_full');
                    END;
    
                    CREATE TRIGGER IF NOT EXISTS capacidad_al_actualizar
                    BEFORE UPDATE OF estado, fecha, turno ON reservas
                    WHEN NEW.estado = 'confirmada' AND
                         (SELECT COUNT(*) FROM reservas
                          WHERE fecha = NEW.fecha AND turno = NEW.turno
                            AND estado = 'confirmada' AND id <> OLD.id) >= 14
                    BEGIN
                        SELECT RAISE(ABORT, 'capacity_full');
                    END;
                    """
                )
            finally:
                conn.close()
    
        @staticmethod
        def _reserva(row: sqlite3.Row) -> dict[str, Any]:
            return {
     
    …(N2: 4737 chars omitidos en medio)…
    ],
                        data["turno"],
                        estado,
                        now,
                        now,
                    ),
                )
                row = conn.execute(
                    "SELECT * FROM reservas WHERE secuencia = ?", (cursor.lastrowid,)
                ).fetchone()
                respuesta = self._reserva(row)
                codigo = 201
                if estado == "lista_espera":
                    posicion = conn.execute(
                        """SELECT COUNT(*) FROM reservas
                           WHERE fecha = ? AND turno = ? AND estado = 'lista_espera'
                             AND secuencia <= ?""",
                        (data["fecha"], data["turno"], row["secuencia"]),
                    ).fetchone()[0]
                    respuesta["posicion"] = posicion
                    codigo = 202
    
                if idempotency_key:
                    conn.execute(
                        """INSERT INTO idempotencia(clave, reserva_id, codigo, respuesta, creada_en)
                           VALUES (?, ?, ?, ?, ?)""",
                        (
                            idempotency_key,
                            reserva_id,
                            codigo,
                            json.dumps(respuesta, ensure_ascii=False, separators=(",", ":")),
                            now,
                        ),
                    )
                conn.commit()
                return codigo, respuesta
            except Exception:
                self._rollback(conn)
                raise
            finally:
                conn.close()
    
    
    --- stderr ---

[turno 4] bash  {"command": "sed -n '361,560p' marea/service.py"}
    exit=0
    --- stdout ---
        def _promover_primera(self, conn: sqlite3.Connection, fecha: str, turno: str) -> None:
            primera = conn.execute(
                """SELECT id FROM reservas
                   WHERE fecha = ? AND turno = ? AND estado = 'lista_espera'
                   ORDER BY secuencia ASC LIMIT 1""",
                (fecha, turno),
            ).fetchone()
            if primera:
                conn.execute(
                    "UPDATE reservas SET estado = 'confirmada', actualizada_en = ? WHERE id = ?",
                    (time.time(), primera["id"]),
                )
    
        def modificar(self, reserva_id: str, raw: Any) -> tuple[int, Any]:
            cambios = validar_patch(raw)
            conn = self._connect()
            try:
                self._begin(conn)
                original = conn.execute(
                    """SELECT * FROM reservas WHERE id = ?
                       AND estado IN ('confirmada', 'lista_espera')""",
                    (reserva_id,),
                ).fetchone()
                if not original:
                    
    …(N2: 5242 chars omitidos en medio)…
    luso si se produce una cancelación concurrente.
                conn.execute("BEGIN")
                reserva = conn.execute(
                    """SELECT personas, fecha, turno, telefono FROM reservas
                       WHERE id = ? AND estado IN ('confirmada', 'lista_espera')""",
                    (reserva_id,),
                ).fetchone()
                if not reserva:
                    return 404, {"error": "reserva no encontrada"}
    
                ofertas: list[dict[str, Any]] = []
                if reserva["personas"] == 6:
                    ofertas.append({"codigo": "menu-grupo"})
    
                if reserva["fecha"] == "2027-02-14":
                    ofertas.append({"codigo": "menu-san-valentin"})
                if reserva["fecha"] == "2026-12-31":
                    ofertas.append({"codigo": "menu-nochevieja"})
    
                fecha_reserva = date.fromisoformat(reserva["fecha"])
                if reserva["turno"] == "cena" and fecha_reserva.weekday() in (4, 5):
                    ofertas.append({"codigo": "maridaje"})
    
                activas = conn.execute(
                    """SELECT COUNT(*) FROM reservas
                       WHERE telefono = ?
                         AND estado IN ('confirmada', 'lista_espera')""",
                    (reserva["telefono"],),
                ).fetchone()[0]
                if activas >= 3:
                    ofertas.append(
                        {"codigo": "descuento-fidelidad", "descuento_pct": 10}
                    )
    
                return 200, ofertas
            finally:
                conn.close()
    
    
    --- stderr ---

[turno 4] bash  {"command": "cat tests/test_service.py"}
    exit=0
    --- stdout ---
    import tempfile
    import unittest
    from pathlib import Path
    
    from marea.service import ReservationService
    
    
    class ReservationServiceTests(unittest.TestCase):
        def setUp(self):
            self.tmp = tempfile.TemporaryDirectory()
            self.service = ReservationService(Path(self.tmp.name) / "test.sqlite3")
    
        def tearDown(self):
            self.tmp.cleanup()
    
        @staticmethod
        def payload(i, fecha="2030-01-01", turno="comida", telefono=None):
            return {
                "nombre": f"Cliente {i}",
                "telefono": telefono or f"600{i:06d}",
                "personas": 2,
                "fecha": fecha,
                "turno": turno,
            }
    
        def test_capacidad_espera_y_promocion_fifo(self):
            creadas = []
            for i in range(16):
                status, reserva = self.service.crear(self.payload(i), f"key-{i}")
                self.assertEqual(201 if i < 14 else 202, status)
                creadas.append(reserva)
            self.assertEqual(1, creadas[14]["posicion"])
    
    …(N2: 1027 chars omitidos en medio)…
    ás tarde.
            status, repetido = self.service.crear(self.payload(1), "conflict-key")
            self.assertEqual(409, status)
            self.assertEqual(conflicto, repetido)
    
        def test_patch_lleno_es_atomico(self):
            _, origen = self.service.crear(self.payload(1), "origin")
            for i in range(14):
                status, _ = self.service.crear(
                    self.payload(100 + i, fecha="2030-01-02"), f"target-{i}"
                )
                self.assertEqual(201, status)
    
            status, _ = self.service.modificar(origen["id"], {"fecha": "2030-01-02"})
            self.assertEqual(409, status)
            _, encontradas = self.service.por_telefono(origen["telefono"])
            self.assertEqual("2030-01-01", encontradas[0]["fecha"])
            self.assertEqual("confirmada", encontradas[0]["estado"])
    
        def test_limite_es_independiente_por_telefono(self):
            for i in range(20):
                status, _ = self.service.crear(
                    self.payload(i, fecha=f"2031-01-{i + 1:02d}", telefono="RATE"), None
                )
                self.assertEqual(201, status)
            self.assertEqual(
                429,
                self.service.crear(
                    self.payload(30, fecha="2031-02-01", telefono="RATE"), None
                )[0],
            )
            self.assertEqual(
                201,
                self.service.crear(
                    self.payload(30, fecha="2031-02-01", telefono="OTHER"), None
                )[0],
            )
    
    
    if __name__ == "__main__":
        unittest.main()
    
    --- stderr ---

[turno 4] bash  {"command": "cat app.py"}
    exit=0
    --- stdout ---
    #!/usr/bin/env python3
    from __future__ import annotations
    
    import json
    import os
    import sqlite3
    import sys
    import traceback
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from pathlib import Path
    from typing import Any
    from urllib.parse import parse_qs, unquote, urlsplit
    
    from marea.service import ReservationService, ValidationError
    
    ROOT = Path(__file__).resolve().parent
    DB_PATH = os.environ.get("MAREA_DB_PATH", str(ROOT / "data" / "marea.sqlite3"))
    MAX_BODY = 1024 * 1024
    service = ReservationService(DB_PATH)
    
    
    class MareaHTTPServer(ThreadingHTTPServer):
        daemon_threads = True
        allow_reuse_address = True
        request_queue_size = 128
    
    
    class Handler(BaseHTTPRequestHandler):
        server_version = "Marea/2.0"
    
        def _json_body(self) -> Any:
            length_value = self.headers.get("Content-Length")
            if length_value is None:
                raise ValidationError("falta el body JSON")
            try:
                length = int(length_value)
      
    …(N2: 3401 chars omitidos en medio)…
     {"error": "recurso no encontrado"}
    
        def _handle(self) -> None:
            try:
                status, body = self._dispatch()
                self._send(status, body)
            except ValidationError as exc:
                self._send(422, {"error": str(exc)})
            except sqlite3.Error:
                # No se filtran estructura, queries ni rutas locales del servidor.
                traceback.print_exc(file=sys.stderr)
                self._send(500, {"error": "error interno de persistencia"})
            except Exception:
                traceback.print_exc(file=sys.stderr)
                self._send(500, {"error": "error interno"})
    
        def do_GET(self) -> None:  # noqa: N802
            self._handle()
    
        def do_POST(self) -> None:  # noqa: N802
            self._handle()
    
        def do_PATCH(self) -> None:  # noqa: N802
            self._handle()
    
        def do_DELETE(self) -> None:  # noqa: N802
            self._handle()
    
        def do_PUT(self) -> None:  # noqa: N802
            self._handle()
    
        def log_message(self, fmt: str, *args: Any) -> None:
            sys.stderr.write(
                "%s - - [%s] %s\n"
                % (self.address_string(), self.log_date_time_string(), fmt % args)
            )
    
    
    def main() -> None:
        server = MareaHTTPServer(("0.0.0.0", 8080), Handler)
        print(f"Marea v2 escuchando en 0.0.0.0:8080 (db: {DB_PATH})", flush=True)
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            pass
        finally:
            server.server_close()
    
    
    if __name__ == "__main__":
        main()
    
    --- stderr ---

[turno 5] bash  {"command": "nl -ba marea/service.py | sed -n '60,150p'"}
    exit=0
    --- stdout ---
        60	
        61	
        62	class ValidationError(ValueError):
        63	    pass
        64	
        65	
        66	def _fecha_valida(value: Any) -> bool:
        67	    if not isinstance(value, str) or not FECHA_RE.fullmatch(value):
        68	        return False
        69	    try:
        70	        date.fromisoformat(value)
        71	    except ValueError:
        72	        return False
        73	    return True
        74	
        75	
        76	def _personas_validas(value: Any) -> bool:
        77	    return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 6
        78	
        79	
        80	def validar_post(data: Any) -> dict[str, Any]:
        81	    if not isinstance(data, dict):
        82	        raise ValidationError("el body debe ser un objeto JSON")
        83	    if set(data) != CAMPOS_POST:
        84	        raise ValidationError("se requieren exactamente nombre, telefono, personas, fecha y turno")
        85	    if not isinstance(data["nombre"], str) or not data["nombre"].strip():
        86	        raise Validatio
    …(N2: 1866 chars omitidos en medio)…
    lf, db_path: str | os.PathLike[str]):
       123	        self.db_path = str(db_path)
       124	        Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
       125	        self._initialize()
       126	
       127	    def _connect(self) -> sqlite3.Connection:
       128	        conn = sqlite3.connect(self.db_path, timeout=30, isolation_level=None)
       129	        conn.row_factory = sqlite3.Row
       130	        conn.execute("PRAGMA foreign_keys = ON")
       131	        conn.execute("PRAGMA busy_timeout = 30000")
       132	        return conn
       133	
       134	    def _initialize(self) -> None:
       135	        conn = self._connect()
       136	        try:
       137	            conn.execute("PRAGMA journal_mode = WAL")
       138	            conn.executescript(
       139	                """
       140	                CREATE TABLE IF NOT EXISTS reservas (
       141	                    secuencia INTEGER PRIMARY KEY AUTOINCREMENT,
       142	                    id TEXT NOT NULL UNIQUE,
       143	                    nombre TEXT NOT NULL,
       144	                    telefono TEXT NOT NULL,
       145	                    personas INTEGER NOT NULL CHECK (personas BETWEEN 1 AND 6),
       146	                    fecha TEXT NOT NULL,
       147	                    turno TEXT NOT NULL CHECK (turno IN ('comida', 'cena')),
       148	                    estado TEXT NOT NULL CHECK (estado IN ('confirmada', 'lista_espera', 'cancelada')),
       149	                    creada_en REAL NOT NULL,
       150	                    actualizada_en REAL NOT NULL
    
    --- stderr ---

[turno 5] bash  {"command": "nl -ba marea/service.py | sed -n '150,250p'"}
    exit=0
    --- stdout ---
       150	                    actualizada_en REAL NOT NULL
       151	                );
       152	
       153	                CREATE UNIQUE INDEX IF NOT EXISTS una_reserva_activa_por_telefono
       154	                    ON reservas(telefono, fecha, turno)
       155	                    WHERE estado IN ('confirmada', 'lista_espera');
       156	
       157	                CREATE INDEX IF NOT EXISTS reservas_por_turno
       158	                    ON reservas(fecha, turno, estado, secuencia);
       159	                CREATE INDEX IF NOT EXISTS reservas_por_telefono
       160	                    ON reservas(telefono, estado, secuencia);
       161	
       162	                CREATE TABLE IF NOT EXISTS idempotencia (
       163	                    clave TEXT PRIMARY KEY,
       164	                    reserva_id TEXT NOT NULL,
       165	                    codigo INTEGER NOT NULL,
       166	                    respuesta TEXT NOT NULL,
       167	                    creada_en REAL NOT NULL,
       168	                    FOREIGN KE
    …(N2: 2239 chars omitidos en medio)…
    tes no
       218	        # pueden observar simultáneamente la misma mesa libre.
       219	        conn.execute("BEGIN IMMEDIATE")
       220	
       221	    @staticmethod
       222	    def _rollback(conn: sqlite3.Connection) -> None:
       223	        if conn.in_transaction:
       224	            conn.rollback()
       225	
       226	    def crear(self, raw: Any, idempotency_key: str | None) -> tuple[int, Any]:
       227	        # Una repetición válida no consume rate limit ni vuelve a evaluar reglas.
       228	        if idempotency_key:
       229	            conn = self._connect()
       230	            try:
       231	                old = conn.execute(
       232	                    "SELECT codigo, respuesta FROM idempotencia WHERE clave = ?",
       233	                    (idempotency_key,),
       234	                ).fetchone()
       235	                if old:
       236	                    return old["codigo"], json.loads(old["respuesta"])
       237	            finally:
       238	                conn.close()
       239	
       240	        data = validar_post(raw)
       241	        now = time.time()
       242	        conn = self._connect()
       243	        try:
       244	            self._begin(conn)
       245	
       246	            # Se vuelve a mirar dentro del bloqueo de escritura para resolver la
       247	            # carrera entre dos solicitudes nuevas con la misma clave.
       248	            if idempotency_key:
       249	                old = conn.execute(
       250	                    "SELECT codigo, respuesta FROM idempotencia WHERE clave = ?",
    
    --- stderr ---

[turno 5] bash  {"command": "nl -ba marea/service.py | sed -n '250,350p'"}
    exit=0
    --- stdout ---
       250	                    "SELECT codigo, respuesta FROM idempotencia WHERE clave = ?",
       251	                    (idempotency_key,),
       252	                ).fetchone()
       253	                if old:
       254	                    conn.commit()
       255	                    return old["codigo"], json.loads(old["respuesta"])
       256	
       257	            cutoff = now - 60.0
       258	            conn.execute("DELETE FROM limite_posts WHERE instante <= ?", (cutoff,))
       259	            usados = conn.execute(
       260	                "SELECT COUNT(*) FROM limite_posts WHERE telefono = ? AND instante > ?",
       261	                (data["telefono"], cutoff),
       262	            ).fetchone()[0]
       263	            if usados >= 20:
       264	                conn.commit()
       265	                return 429, {"error": "límite de 20 altas por minuto superado"}
       266	            conn.execute(
       267	                "INSERT INTO limite_posts(telefono, instante) VALUES (?, ?)",
       268	         
    …(N2: 2664 chars omitidos en medio)…
    "fecha"],
       320	                    data["turno"],
       321	                    estado,
       322	                    now,
       323	                    now,
       324	                ),
       325	            )
       326	            row = conn.execute(
       327	                "SELECT * FROM reservas WHERE secuencia = ?", (cursor.lastrowid,)
       328	            ).fetchone()
       329	            respuesta = self._reserva(row)
       330	            codigo = 201
       331	            if estado == "lista_espera":
       332	                posicion = conn.execute(
       333	                    """SELECT COUNT(*) FROM reservas
       334	                       WHERE fecha = ? AND turno = ? AND estado = 'lista_espera'
       335	                         AND secuencia <= ?""",
       336	                    (data["fecha"], data["turno"], row["secuencia"]),
       337	                ).fetchone()[0]
       338	                respuesta["posicion"] = posicion
       339	                codigo = 202
       340	
       341	            if idempotency_key:
       342	                conn.execute(
       343	                    """INSERT INTO idempotencia(clave, reserva_id, codigo, respuesta, creada_en)
       344	                       VALUES (?, ?, ?, ?, ?)""",
       345	                    (
       346	                        idempotency_key,
       347	                        reserva_id,
       348	                        codigo,
       349	                        json.dumps(respuesta, ensure_ascii=False, separators=(",", ":")),
       350	                        now,
    
    --- stderr ---

[turno 5] bash  {"command": "nl -ba marea/service.py | sed -n '350,450p'"}
    exit=0
    --- stdout ---
       350	                        now,
       351	                    ),
       352	                )
       353	            conn.commit()
       354	            return codigo, respuesta
       355	        except Exception:
       356	            self._rollback(conn)
       357	            raise
       358	        finally:
       359	            conn.close()
       360	
       361	    def _promover_primera(self, conn: sqlite3.Connection, fecha: str, turno: str) -> None:
       362	        primera = conn.execute(
       363	            """SELECT id FROM reservas
       364	               WHERE fecha = ? AND turno = ? AND estado = 'lista_espera'
       365	               ORDER BY secuencia ASC LIMIT 1""",
       366	            (fecha, turno),
       367	        ).fetchone()
       368	        if primera:
       369	            conn.execute(
       370	                "UPDATE reservas SET estado = 'confirmada', actualizada_en = ? WHERE id = ?",
       371	                (time.time(), primera["id"]),
       372	            )
       373	
       374	    def modific
    …(N2: 2211 chars omitidos en medio)…
    7	
       418	            nuevo_estado = "confirmada" if cambia_destino else original["estado"]
       419	            conn.execute(
       420	                """UPDATE reservas
       421	                   SET personas = ?, fecha = ?, turno = ?, estado = ?, actualizada_en = ?
       422	                   WHERE id = ?""",
       423	                (
       424	                    nuevas_personas,
       425	                    nueva_fecha,
       426	                    nuevo_turno,
       427	                    nuevo_estado,
       428	                    time.time(),
       429	                    reserva_id,
       430	                ),
       431	            )
       432	
       433	            # Al mover una confirmada, la vacante de origen se cubre en la misma
       434	            # transacción. Una espera movida no liberaba mesa.
       435	            if cambia_destino and original["estado"] == "confirmada":
       436	                self._promover_primera(conn, original["fecha"], original["turno"])
       437	
       438	            actualizada = conn.execute(
       439	                "SELECT * FROM reservas WHERE id = ?", (reserva_id,)
       440	            ).fetchone()
       441	            conn.commit()
       442	            return 200, self._reserva(actualizada)
       443	        except Exception:
       444	            self._rollback(conn)
       445	            raise
       446	        finally:
       447	            conn.close()
       448	
       449	    def cancelar(self, reserva_id: str) -> tuple[int, Any | None]:
       450	        conn = self._connect()
    
    --- stderr ---

[turno 5] bash  {"command": "nl -ba marea/service.py | sed -n '450,550p'"}
    exit=0
    --- stdout ---
       450	        conn = self._connect()
       451	        try:
       452	            self._begin(conn)
       453	            row = conn.execute(
       454	                """SELECT * FROM reservas WHERE id = ?
       455	                   AND estado IN ('confirmada', 'lista_espera')""",
       456	                (reserva_id,),
       457	            ).fetchone()
       458	            if not row:
       459	                conn.commit()
       460	                return 404, {"error": "reserva no encontrada"}
       461	            conn.execute(
       462	                "UPDATE reservas SET estado = 'cancelada', actualizada_en = ? WHERE id = ?",
       463	                (time.time(), reserva_id),
       464	            )
       465	            if row["estado"] == "confirmada":
       466	                self._promover_primera(conn, row["fecha"], row["turno"])
       467	            conn.commit()
       468	            return 204, None
       469	        except Exception:
       470	            self._rollback(conn)
       471	            r
    …(N2: 2224 chars omitidos en medio)…
    no FROM reservas
       520	                   WHERE id = ? AND estado IN ('confirmada', 'lista_espera')""",
       521	                (reserva_id,),
       522	            ).fetchone()
       523	            if not reserva:
       524	                return 404, {"error": "reserva no encontrada"}
       525	
       526	            ofertas: list[dict[str, Any]] = []
       527	            if reserva["personas"] == 6:
       528	                ofertas.append({"codigo": "menu-grupo"})
       529	
       530	            if reserva["fecha"] == "2027-02-14":
       531	                ofertas.append({"codigo": "menu-san-valentin"})
       532	            if reserva["fecha"] == "2026-12-31":
       533	                ofertas.append({"codigo": "menu-nochevieja"})
       534	
       535	            fecha_reserva = date.fromisoformat(reserva["fecha"])
       536	            if reserva["turno"] == "cena" and fecha_reserva.weekday() in (4, 5):
       537	                ofertas.append({"codigo": "maridaje"})
       538	
       539	            activas = conn.execute(
       540	                """SELECT COUNT(*) FROM reservas
       541	                   WHERE telefono = ?
       542	                     AND estado IN ('confirmada', 'lista_espera')""",
       543	                (reserva["telefono"],),
       544	            ).fetchone()[0]
       545	            if activas >= 3:
       546	                ofertas.append(
       547	                    {"codigo": "descuento-fidelidad", "descuento_pct": 10}
       548	                )
       549	
       550	            return 200, ofertas
    
    --- stderr ---

[turno 5] bash  {"command": "nl -ba app.py | sed -n '1,190p'"}
    exit=0
    --- stdout ---
         1	#!/usr/bin/env python3
         2	from __future__ import annotations
         3	
         4	import json
         5	import os
         6	import sqlite3
         7	import sys
         8	import traceback
         9	from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
        10	from pathlib import Path
        11	from typing import Any
        12	from urllib.parse import parse_qs, unquote, urlsplit
        13	
        14	from marea.service import ReservationService, ValidationError
        15	
        16	ROOT = Path(__file__).resolve().parent
        17	DB_PATH = os.environ.get("MAREA_DB_PATH", str(ROOT / "data" / "marea.sqlite3"))
        18	MAX_BODY = 1024 * 1024
        19	service = ReservationService(DB_PATH)
        20	
        21	
        22	class MareaHTTPServer(ThreadingHTTPServer):
        23	    daemon_threads = True
        24	    allow_reuse_address = True
        25	    request_queue_size = 128
        26	
        27	
        28	class Handler(BaseHTTPRequestHandler):
        29	    server_version = "Marea/2.0"
        30	
        31	    def _json_bod
    …(N2: 4535 chars omitidos en medio)…
         # No se filtran estructura, queries ni rutas locales del servidor.
       122	            traceback.print_exc(file=sys.stderr)
       123	            self._send(500, {"error": "error interno de persistencia"})
       124	        except Exception:
       125	            traceback.print_exc(file=sys.stderr)
       126	            self._send(500, {"error": "error interno"})
       127	
       128	    def do_GET(self) -> None:  # noqa: N802
       129	        self._handle()
       130	
       131	    def do_POST(self) -> None:  # noqa: N802
       132	        self._handle()
       133	
       134	    def do_PATCH(self) -> None:  # noqa: N802
       135	        self._handle()
       136	
       137	    def do_DELETE(self) -> None:  # noqa: N802
       138	        self._handle()
       139	
       140	    def do_PUT(self) -> None:  # noqa: N802
       141	        self._handle()
       142	
       143	    def log_message(self, fmt: str, *args: Any) -> None:
       144	        sys.stderr.write(
       145	            "%s - - [%s] %s\n"
       146	            % (self.address_string(), self.log_date_time_string(), fmt % args)
       147	        )
       148	
       149	
       150	def main() -> None:
       151	    server = MareaHTTPServer(("0.0.0.0", 8080), Handler)
       152	    print(f"Marea v2 escuchando en 0.0.0.0:8080 (db: {DB_PATH})", flush=True)
       153	    try:
       154	        server.serve_forever()
       155	    except KeyboardInterrupt:
       156	        pass
       157	    finally:
       158	        server.server_close()
       159	
       160	
       161	if __name__ == "__main__":
       162	    main()
    
    --- stderr ---

[turno 6] bash  {"command": "sed -n '80,135p' marea/service.py; sed -n '190,225p' marea/service.py; sed -n '226,325p' marea/service.py; sed -n '374,520p' marea/service.py; sed -n '28,120p' app.py"}
    exit=0
    --- stdout ---
    def validar_post(data: Any) -> dict[str, Any]:
        if not isinstance(data, dict):
            raise ValidationError("el body debe ser un objeto JSON")
        if set(data) != CAMPOS_POST:
            raise ValidationError("se requieren exactamente nombre, telefono, personas, fecha y turno")
        if not isinstance(data["nombre"], str) or not data["nombre"].strip():
            raise ValidationError("nombre debe ser un string no vacío")
        if not isinstance(data["telefono"], str) or not data["telefono"].strip():
            raise ValidationError("telefono debe ser un string no vacío")
        if not _personas_validas(data["personas"]):
            raise ValidationError("personas debe ser un entero entre 1 y 6")
        if not _fecha_valida(data["fecha"]):
            raise ValidationError("fecha debe ser una fecha válida YYYY-MM-DD")
        if data["turno"] not in TURNOS:
            raise ValidationError("turno debe ser comida o cena")
        return data
    
    
    def validar_patch(data: Any) -> dict[str, Any]:
        if
    …(N2: 15462 chars omitidos en medio)…
    n("/reservas/") :])
                if not reserva_id:
                    return 404, {"error": "recurso no encontrado"}
                if self.command == "PATCH":
                    return service.modificar(reserva_id, self._json_body())
                if self.command == "DELETE":
                    return service.cancelar(reserva_id)
                return 405, {"error": "método no permitido"}
    
            if self.command == "GET" and path == "/disponibilidad":
                fecha_values = query.get("fecha", [])
                turno_values = query.get("turno", [])
                if len(fecha_values) != 1 or len(turno_values) != 1:
                    raise ValidationError("fecha y turno son obligatorios una sola vez")
                return service.disponibilidad(fecha_values[0], turno_values[0])
    
            if self.command == "GET" and path == "/reservas":
                telefono_values = query.get("telefono", [])
                if len(telefono_values) != 1:
                    raise ValidationError("telefono es obligatorio una sola vez")
                return service.por_telefono(telefono_values[0])
    
            if path in {"/reservas", "/disponibilidad", "/precios"}:
                return 405, {"error": "método no permitido"}
            return 404, {"error": "recurso no encontrado"}
    
        def _handle(self) -> None:
            try:
                status, body = self._dispatch()
                self._send(status, body)
            except ValidationError as exc:
                self._send(422, {"error": str(exc)})
            except sqlite3.Error:
    
    --- stderr ---

[turno 7] bash  {"command": "sed -n '100,225p' marea/service.py"}
    exit=0
    --- stdout ---
            raise ValidationError("el body debe ser un objeto JSON")
        if not set(data).issubset(CAMPOS_PATCH):
            raise ValidationError("solo se pueden modificar personas, fecha y turno")
        if "personas" in data and not _personas_validas(data["personas"]):
            raise ValidationError("personas debe ser un entero entre 1 y 6")
        if "fecha" in data and not _fecha_valida(data["fecha"]):
            raise ValidationError("fecha debe ser una fecha válida YYYY-MM-DD")
        if "turno" in data and data["turno"] not in TURNOS:
            raise ValidationError("turno debe ser comida o cena")
        return data
    
    
    def validar_fecha_turno(fecha: Any, turno: Any) -> None:
        if not _fecha_valida(fecha):
            raise ValidationError("fecha debe ser una fecha válida YYYY-MM-DD")
        if turno not in TURNOS:
            raise ValidationError("turno debe ser comida o cena")
    
    
    class ReservationService:
        """Reglas de negocio y persistencia transaccional sobre SQLite."""
    
        def __ini
    …(N2: 2745 chars omitidos en medio)…
    echa AND turno = NEW.turno
                            AND estado = 'confirmada') >= 14
                    BEGIN
                        SELECT RAISE(ABORT, 'capacity_full');
                    END;
    
                    CREATE TRIGGER IF NOT EXISTS capacidad_al_actualizar
                    BEFORE UPDATE OF estado, fecha, turno ON reservas
                    WHEN NEW.estado = 'confirmada' AND
                         (SELECT COUNT(*) FROM reservas
                          WHERE fecha = NEW.fecha AND turno = NEW.turno
                            AND estado = 'confirmada' AND id <> OLD.id) >= 14
                    BEGIN
                        SELECT RAISE(ABORT, 'capacity_full');
                    END;
                    """
                )
            finally:
                conn.close()
    
        @staticmethod
        def _reserva(row: sqlite3.Row) -> dict[str, Any]:
            return {
                "id": row["id"],
                "nombre": row["nombre"],
                "telefono": row["telefono"],
                "personas": row["personas"],
                "fecha": row["fecha"],
                "turno": row["turno"],
                "estado": row["estado"],
            }
    
        @staticmethod
        def _begin(conn: sqlite3.Connection) -> None:
            # Reserva el escritor antes de leer capacidad: dos altas concurrentes no
            # pueden observar simultáneamente la misma mesa libre.
            conn.execute("BEGIN IMMEDIATE")
    
        @staticmethod
        def _rollback(conn: sqlite3.Connection) -> None:
            if conn.in_transaction:
                conn.rollback()
    
    
    --- stderr ---

[turno 7] bash  {"command": "sed -n '226,310p' marea/service.py"}
    exit=0
    --- stdout ---
        def crear(self, raw: Any, idempotency_key: str | None) -> tuple[int, Any]:
            # Una repetición válida no consume rate limit ni vuelve a evaluar reglas.
            if idempotency_key:
                conn = self._connect()
                try:
                    old = conn.execute(
                        "SELECT codigo, respuesta FROM idempotencia WHERE clave = ?",
                        (idempotency_key,),
                    ).fetchone()
                    if old:
                        return old["codigo"], json.loads(old["respuesta"])
                finally:
                    conn.close()
    
            data = validar_post(raw)
            now = time.time()
            conn = self._connect()
            try:
                self._begin(conn)
    
                # Se vuelve a mirar dentro del bloqueo de escritura para resolver la
                # carrera entre dos solicitudes nuevas con la misma clave.
                if idempotency_key:
                    old = conn.execute(
                        "SELECT codigo, respuesta FROM
    …(N2: 1232 chars omitidos en medio)…
       "error": "el teléfono ya tiene una reserva activa para esa fecha y turno",
                        "reserva": self._reserva(existente),
                    }
                    # El 409 también identifica una reserva concreta. Guardarlo evita
                    # que esa misma clave pueda crear otra si la existente se cancela.
                    if idempotency_key:
                        conn.execute(
                            """INSERT INTO idempotencia
                               (clave, reserva_id, codigo, respuesta, creada_en)
                               VALUES (?, ?, 409, ?, ?)""",
                            (
                                idempotency_key,
                                existente["id"],
                                json.dumps(
                                    respuesta_conflicto,
                                    ensure_ascii=False,
                                    separators=(",", ":"),
                                ),
                                now,
                            ),
                        )
                    conn.commit()
                    return 409, respuesta_conflicto
    
                confirmadas = conn.execute(
                    """SELECT COUNT(*) FROM reservas
                       WHERE fecha = ? AND turno = ? AND estado = 'confirmada'""",
                    (data["fecha"], data["turno"]),
                ).fetchone()[0]
                estado = "confirmada" if confirmadas < CAPACIDAD else "lista_espera"
                reserva_id = str(uuid.uuid4())
                cursor = conn.execute(
    
    --- stderr ---

[turno 7] bash  {"command": "sed -n '374,448p' marea/service.py"}
    exit=0
    --- stdout ---
        def modificar(self, reserva_id: str, raw: Any) -> tuple[int, Any]:
            cambios = validar_patch(raw)
            conn = self._connect()
            try:
                self._begin(conn)
                original = conn.execute(
                    """SELECT * FROM reservas WHERE id = ?
                       AND estado IN ('confirmada', 'lista_espera')""",
                    (reserva_id,),
                ).fetchone()
                if not original:
                    conn.commit()
                    return 404, {"error": "reserva no encontrada"}
    
                nueva_fecha = cambios.get("fecha", original["fecha"])
                nuevo_turno = cambios.get("turno", original["turno"])
                nuevas_personas = cambios.get("personas", original["personas"])
                cambia_destino = (
                    nueva_fecha != original["fecha"] or nuevo_turno != original["turno"]
                )
    
                if cambia_destino:
                    duplicada = conn.execute(
                        """SELECT * FROM reservas
    …(N2: 618 chars omitidos en medio)…
    ) FROM reservas
                           WHERE fecha = ? AND turno = ? AND estado = 'confirmada'""",
                        (nueva_fecha, nuevo_turno),
                    ).fetchone()[0]
                    if ocupadas >= CAPACIDAD:
                        conn.commit()
                        return 409, {"error": "el turno de destino está lleno"}
    
                nuevo_estado = "confirmada" if cambia_destino else original["estado"]
                conn.execute(
                    """UPDATE reservas
                       SET personas = ?, fecha = ?, turno = ?, estado = ?, actualizada_en = ?
                       WHERE id = ?""",
                    (
                        nuevas_personas,
                        nueva_fecha,
                        nuevo_turno,
                        nuevo_estado,
                        time.time(),
                        reserva_id,
                    ),
                )
    
                # Al mover una confirmada, la vacante de origen se cubre en la misma
                # transacción. Una espera movida no liberaba mesa.
                if cambia_destino and original["estado"] == "confirmada":
                    self._promover_primera(conn, original["fecha"], original["turno"])
    
                actualizada = conn.execute(
                    "SELECT * FROM reservas WHERE id = ?", (reserva_id,)
                ).fetchone()
                conn.commit()
                return 200, self._reserva(actualizada)
            except Exception:
                self._rollback(conn)
                raise
            finally:
                conn.close()
    
    
    --- stderr ---

[turno 7] bash  {"command": "sed -n '449,518p' marea/service.py"}
    exit=0
    --- stdout ---
        def cancelar(self, reserva_id: str) -> tuple[int, Any | None]:
            conn = self._connect()
            try:
                self._begin(conn)
                row = conn.execute(
                    """SELECT * FROM reservas WHERE id = ?
                       AND estado IN ('confirmada', 'lista_espera')""",
                    (reserva_id,),
                ).fetchone()
                if not row:
                    conn.commit()
                    return 404, {"error": "reserva no encontrada"}
                conn.execute(
                    "UPDATE reservas SET estado = 'cancelada', actualizada_en = ? WHERE id = ?",
                    (time.time(), reserva_id),
                )
                if row["estado"] == "confirmada":
                    self._promover_primera(conn, row["fecha"], row["turno"])
                conn.commit()
                return 204, None
            except Exception:
                self._rollback(conn)
                raise
            finally:
                conn.close()
    
        def disponibilidad(self, fecha: Any
    …(N2: 261 chars omitidos en medio)…
    tado = 'confirmada'""",
                    (fecha, turno),
                ).fetchone()[0]
                return 200, {
                    "fecha": fecha,
                    "turno": turno,
                    "mesas_libres": max(0, CAPACIDAD - ocupadas),
                }
            finally:
                conn.close()
    
        def por_telefono(self, telefono: Any) -> tuple[int, Any]:
            if not isinstance(telefono, str) or not telefono.strip():
                raise ValidationError("telefono es obligatorio y no puede estar vacío")
            conn = self._connect()
            try:
                rows = conn.execute(
                    """SELECT * FROM reservas
                       WHERE telefono = ? AND estado IN ('confirmada', 'lista_espera')
                       ORDER BY secuencia ASC""",
                    (telefono,),
                ).fetchall()
                return 200, [self._reserva(row) for row in rows]
            finally:
                conn.close()
    
        def precios(self) -> tuple[int, Any]:
            # Una copia evita que un consumidor directo del servicio pueda alterar el
            # catálogo que recibirían las peticiones posteriores.
            return 200, copy.deepcopy(CATALOGO_PRECIOS)
    
        def sugerencias(self, reserva_id: str) -> tuple[int, Any]:
            conn = self._connect()
            try:
                # La reserva y el recuento de fidelidad deben pertenecer a la misma
                # fotografía, incluso si se produce una cancelación concurrente.
                conn.execute("BEGIN")
                reserva = conn.execute(
    
    --- stderr ---

[turno 7] bash  {"command": "sed -n '28,120p' app.py"}
    exit=0
    --- stdout ---
    class Handler(BaseHTTPRequestHandler):
        server_version = "Marea/2.0"
    
        def _json_body(self) -> Any:
            length_value = self.headers.get("Content-Length")
            if length_value is None:
                raise ValidationError("falta el body JSON")
            try:
                length = int(length_value)
            except ValueError as exc:
                raise ValidationError("Content-Length inválido") from exc
            if length < 0 or length > MAX_BODY:
                raise ValidationError("body JSON demasiado grande")
            payload = self.rfile.read(length)
            try:
                return json.loads(payload.decode("utf-8"))
            except (UnicodeDecodeError, json.JSONDecodeError) as exc:
                raise ValidationError("body JSON inválido") from exc
    
        def _send(self, status: int, body: Any | None) -> None:
            if status == 204:
                self.send_response(status)
                self.send_header("Content-Length", "0")
                self.end_headers()
               
    …(N2: 1526 chars omitidos en medio)…
    n("/reservas/") :])
                if not reserva_id:
                    return 404, {"error": "recurso no encontrado"}
                if self.command == "PATCH":
                    return service.modificar(reserva_id, self._json_body())
                if self.command == "DELETE":
                    return service.cancelar(reserva_id)
                return 405, {"error": "método no permitido"}
    
            if self.command == "GET" and path == "/disponibilidad":
                fecha_values = query.get("fecha", [])
                turno_values = query.get("turno", [])
                if len(fecha_values) != 1 or len(turno_values) != 1:
                    raise ValidationError("fecha y turno son obligatorios una sola vez")
                return service.disponibilidad(fecha_values[0], turno_values[0])
    
            if self.command == "GET" and path == "/reservas":
                telefono_values = query.get("telefono", [])
                if len(telefono_values) != 1:
                    raise ValidationError("telefono es obligatorio una sola vez")
                return service.por_telefono(telefono_values[0])
    
            if path in {"/reservas", "/disponibilidad", "/precios"}:
                return 405, {"error": "método no permitido"}
            return 404, {"error": "recurso no encontrado"}
    
        def _handle(self) -> None:
            try:
                status, body = self._dispatch()
                self._send(status, body)
            except ValidationError as exc:
                self._send(422, {"error": str(exc)})
            except sqlite3.Error:
    
    --- stderr ---

[turno 8] bash  {"command": "python3 - <<'PY'\nfrom marea.service import ReservationService\nfrom tempfile import TemporaryDirectory\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom pathlib import Path\nimport time,sqlite3\nwith TemporaryDirectory() as d:\n s=ReservationService(Path(d)/'x.db')\n def f(i):\n  return s.crear({'nombre':str(i),'telefono':str(i),'personas':2,'fecha':'2030-01-01','turno':'comida'}, f'k{i}')\n t=time.time()\n with ThreadPoolExecutor(max_workers=300) as ex:\n  fs=[ex.submit(f,i) for i in range(300)]\n  rs=[]; errs=[]\n  for z in as_completed(fs):\n   try:rs.append(z.result())\n   except Exception as e:errs.append(e)\n print(time.time()-t,len(rs),len(errs), {x[0]:sum(y[0]==x[0] for y in rs) for x in rs},set(map(str,errs)))\n c=sqlite3.connect(Path(d)/'x.db')\n print(c.execute(\"select estado,count(*) from reservas group by estado\").fetchall())\nPY"}
    exit=0
    --- stdout ---
    12.382851600646973 300 0 {201: 14, 202: 286} set()
    [('confirmada', 14), ('lista_espera', 286)]
    
    --- stderr ---

[turno 9] bash  {"command": "python3 - <<'PY'\np='marea/service.py'\ns=open(p).read()\ns=s.replace('import time\\nimport uuid', 'import time\\nimport threading\\nimport uuid')\ns=s.replace('''    def __init__(self, db_path: str | os.PathLike[str]):\n        self.db_path = str(db_path)\n        Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)\n        self._initialize()\n''','''    def __init__(self, db_path: str | os.PathLike[str]):\n        self.db_path = str(db_path)\n        # SQLite sólo admite un escritor a la vez. Dejar que cientos de hilos\n        # compitan por su lock provoca una estampida de busy-waits y timeouts.\n        # La cola local mantiene el mismo orden transaccional sin bloquear a los\n        # lectores; BEGIN IMMEDIATE sigue siendo la garantía entre procesos.\n        self._write_lock = threading.Lock()\n        Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)\n        self._initialize()\n''')\ns=s.replace('''                CREATE INDEX IF NOT EXISTS reservas_por_telefono\n                    ON reservas(telefono, estado, secuencia);\n\n                CREATE TABLE IF NOT EXISTS idempotencia''','''                CREATE INDEX IF NOT EXISTS reservas_por_telefono\n                    ON reservas(telefono, estado, secuencia);\n\n                -- Índices parciales pequeños: el histórico cancelado puede crecer\n                -- sin degradar disponibilidad, fidelidad ni promociones FIFO.\n                CREATE INDEX IF NOT EXISTS confirmadas_por_turno\n                    ON reservas(fecha, turno)\n                    WHERE estado = 'confirmada';\n                CREATE INDEX IF NOT EXISTS espera_fifo_por_turno\n                    ON reservas(fecha, turno, secuencia)\n                    WHERE estado = 'lista_espera';\n                CREATE INDEX IF NOT EXISTS activas_por_telefono\n                    ON reservas(telefono, secuencia)\n                    WHERE estado IN ('confirmada', 'lista_espera');\n\n                CREATE TABLE IF NOT EXISTS idempotencia''')\ns=s.replace('''                CREATE INDEX IF NOT EXISTS limite_por_telefono\n                    ON limite_posts(telefono, instante);\n\n                CREATE TRIGGER''','''                CREATE INDEX IF NOT EXISTS limite_por_telefono\n                    ON limite_posts(telefono, instante);\n                CREATE INDEX IF NOT EXISTS limite_por_instante\n                    ON limite_posts(instante);\n\n                CREATE TRIGGER''')\ns=s.replace('''    def crear(self, raw: Any, idempotency_key: str | None) -> tuple[int, Any]:\n        # Una repetición válida no consume rate limit ni vuelve a evaluar reglas.\n''','''    def crear(self, raw: Any, idempotency_key: str | None) -> tuple[int, Any]:\n        # En un único proceso evitamos que cientos de conexiones esperen a la vez\n        # dentro de SQLite. Las lecturas no toman este lock y continúan en WAL.\n        with self._write_lock:\n            return self._crear_serializada(raw, idempotency_key)\n\n    def _crear_serializada(\n        self, raw: Any, idempotency_key: str | None\n    ) -> tuple[int, Any]:\n        # Una repetición válida no consume rate limit ni vuelve a evaluar reglas.\n''')\ns=s.replace('''    def modificar(self, reserva_id: str, raw: Any) -> tuple[int, Any]:\n        cambios = validar_patch(raw)\n''','''    def modificar(self, reserva_id: str, raw: Any) -> tuple[int, Any]:\n        with self._write_lock:\n            return self._modificar_serializada(reserva_id, raw)\n\n    def _modificar_serializada(self, reserva_id: str, raw: Any) -> tuple[int, Any]:\n        cambios = validar_patch(raw)\n''')\ns=s.replace('''    def cancelar(self, reserva_id: str) -> tuple[int, Any | None]:\n        conn = self._connect()\n''','''    def cancelar(self, reserva_id: str) -> tuple[int, Any | None]:\n        with self._write_lock:\n            return self._cancelar_serializada(reserva_id)\n\n    def _cancelar_serializada(self, reserva_id: str) -> tuple[int, Any | None]:\n        conn = self._connect()\n''')\nopen(p,'w').write(s)\nPY\npython3 -m py_compile marea/service.py\n# Show changed context\ngrep -n -E 'thread|write_lock|def crear|serializada|confirmadas_por|espera_fifo|activas_por|limite_por_instante' marea/service.py"}
    exit=0
    --- stdout ---
    9:import threading
    129:        self._write_lock = threading.Lock()
    170:                CREATE INDEX IF NOT EXISTS confirmadas_por_turno
    173:                CREATE INDEX IF NOT EXISTS espera_fifo_por_turno
    176:                CREATE INDEX IF NOT EXISTS activas_por_telefono
    196:                CREATE INDEX IF NOT EXISTS limite_por_instante
    246:    def crear(self, raw: Any, idempotency_key: str | None) -> tuple[int, Any]:
    249:        with self._write_lock:
    250:            return self._crear_serializada(raw, idempotency_key)
    252:    def _crear_serializada(
    403:        with self._write_lock:
    404:            return self._modificar_serializada(reserva_id, raw)
    406:    def _modificar_serializada(self, reserva_id: str, raw: Any) -> tuple[int, Any]:
    482:        with self._write_lock:
    483:            return self._cancelar_serializada(reserva_id)
    485:    def _cancelar_serializada(self, reserva_id: str) -> tuple[int, Any | None]:
    
    --- stderr ---

[turno 10] bash  {"command": "python3 -m unittest discover -v && python3 - <<'PY'\nfrom marea.service import ReservationService\nfrom tempfile import TemporaryDirectory\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pathlib import Path\nimport time,sqlite3\nwith TemporaryDirectory() as d:\n s=ReservationService(Path(d)/'x.db')\n def f(i): return s.crear({'nombre':str(i),'telefono':str(i),'personas':2,'fecha':'2030-01-01','turno':'comida'},f'k{i}')\n t=time.time()\n with ThreadPoolExecutor(max_workers=300) as x: r=list(x.map(f,range(300)))\n print('stress',time.time()-t,{c:sum(y[0]==c for y in r) for c in (201,202)}, len({y[1]['id'] for y in r}))\n print(sqlite3.connect(Path(d)/'x.db').execute(\"explain query plan select count(*) from reservas where fecha=? and turno=? and estado='confirmada'\",('2030-01-01','comida')).fetchall())\nPY"}
    exit=5
    --- stdout ---
    
    --- stderr ---
    
    ----------------------------------------------------------------------
    Ran 0 tests in 0.000s
    
    NO TESTS RAN

[turno 11] bash  {"command": "python3 -m unittest discover -s tests -v; python3 - <<'PY'\nfrom marea.service import ReservationService\nfrom tempfile import TemporaryDirectory\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pathlib import Path\nimport time,sqlite3\nwith TemporaryDirectory() as d:\n s=ReservationService(Path(d)/'x.db')\n def f(i): return s.crear({'nombre':str(i),'telefono':str(i),'personas':2,'fecha':'2030-01-01','turno':'comida'},f'k{i}')\n t=time.time()\n with ThreadPoolExecutor(max_workers=300) as x: r=list(x.map(f,range(300)))\n print('stress',time.time()-t,{c:sum(y[0]==c for y in r) for c in (201,202)}, len({y[1]['id'] for y in r}))\n print(sqlite3.connect(Path(d)/'x.db').execute(\"explain query plan select count(*) from reservas where fecha=? and turno=? and estado='confirmada'\",('2030-01-01','comida')).fetchall())\nPY"}
    exit=0
    --- stdout ---
    stress 4.6689136028289795 {201: 14, 202: 286} 300
    [(3, 0, 0, 'SEARCH reservas USING COVERING INDEX reservas_por_turno (fecha=? AND turno=? AND estado=?)')]
    
    --- stderr ---
    test_capacidad_espera_y_promocion_fifo (test_service.ReservationServiceTests.test_capacidad_espera_y_promocion_fifo) ... ok
    test_idempotencia_y_telefono_duplicado (test_service.ReservationServiceTests.test_idempotencia_y_telefono_duplicado) ... ok
    test_limite_es_independiente_por_telefono (test_service.ReservationServiceTests.test_limite_es_independiente_por_telefono) ... ok
    test_patch_lleno_es_atomico (test_service.ReservationServiceTests.test_patch_lleno_es_atomico) ... ok
    test_catalogo_es_el_contratado_y_solo_usa_enteros (test_v2.ApiV2ServiceTests.test_catalogo_es_el_contratado_y_solo_usa_enteros) ... ok
    test_fidelidad_cuenta_confirmadas_y_espera_pero_no_canceladas (test_v2.ApiV2ServiceTests.test_fidelidad_cuenta_confirmadas_y_espera_pero_no_canceladas) ... ok
    test_maridaje_solo_cena_de_viernes_o_sabado (test_v2.ApiV2ServiceTests.test_maridaje_solo_cena_de_viernes_o_sabado) ... ok
    test_menu_grupo_y_fechas_especiales (test_v2.ApiV2ServiceTests.test_menu_grupo_y_fechas_especiales) ... ok
    test_sin_reglas_aplicables_devuelve_lista_vacia (test_v2.ApiV2ServiceTests.test_sin_reglas_aplicables_devuelve_lista_vacia) ... ok
    test_sugerencias_inexistente_devuelve_404 (test_v2.ApiV2ServiceTests.test_sugerencias_inexistente_devuelve_404) ... ok
    
    ----------------------------------------------------------------------
    Ran 10 tests in 2.352s
    
    OK

[turno 12] bash  {"command": "python3 - <<'PY'\nfrom marea.service import ReservationService\nfrom tempfile import TemporaryDirectory\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pathlib import Path\nimport sqlite3,time\np={'nombre':'N','telefono':'600','personas':2,'fecha':'2030-01-01','turno':'comida'}\nwith TemporaryDirectory() as d:\n s=ReservationService(Path(d)/'x')\n with ThreadPoolExecutor(max_workers=200) as x:r=list(x.map(lambda _:s.crear(p,'same'),range(200)))\n print(set((a,b['id']) for a,b in r), sqlite3.connect(Path(d)/'x').execute('select count(*) from reservas').fetchone())\nPY"}
    exit=0
    --- stdout ---
    {(201, '89bad9d6-85a4-4f69-bfcb-e122b1f2afb7')} (1,)
    
    --- stderr ---

[turno 13] bash  {"command": "python3 - <<'PY'\np='marea/service.py'; s=open(p).read()\ns=s.replace('''CAMPOS_PATCH = {\"personas\", \"fecha\", \"turno\"}\n\n# El catálogo''','''CAMPOS_PATCH = {\"personas\", \"fecha\", \"turno\"}\n\n# Todas las instancias del servicio que apunten al mismo fichero comparten cola.\n# Es útil en tests, tareas internas y servidores que construyan más de un objeto.\n_WRITE_LOCKS_GUARD = threading.Lock()\n_WRITE_LOCKS: dict[str, threading.Lock] = {}\n\n# El catálogo''')\ns=s.replace('''        self.db_path = str(db_path)\n        # SQLite sólo admite un escritor a la vez. Dejar que cientos de hilos\n        # compitan por su lock provoca una estampida de busy-waits y timeouts.\n        # La cola local mantiene el mismo orden transaccional sin bloquear a los\n        # lectores; BEGIN IMMEDIATE sigue siendo la garantía entre procesos.\n        self._write_lock = threading.Lock()\n        Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)\n        self._initialize()\n''','''        self.db_path = str(db_path)\n        # SQLite sólo admite un escritor a la vez. Dejar que cientos de hilos\n        # compitan por su lock provoca una estampida de busy-waits y timeouts.\n        # La cola local mantiene el mismo orden transaccional sin bloquear a los\n        # lectores; BEGIN IMMEDIATE sigue siendo la garantía entre procesos.\n        lock_key = os.path.abspath(self.db_path)\n        with _WRITE_LOCKS_GUARD:\n            self._write_lock = _WRITE_LOCKS.setdefault(lock_key, threading.Lock())\n        Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)\n        with self._write_lock:\n            self._initialize()\n''')\nopen(p,'w').write(s)\n\np='app.py'; s=open(p).read().replace('request_queue_size = 128','request_queue_size = 1024'); open(p,'w').write(s)\nPY\npython3 -m py_compile app.py marea/service.py"}
    exit=0
    --- stdout ---
    
    --- stderr ---

[turno 14] write_file  {"path": "tests/test_concurrency.py", "content": "<<3175 bytes escritos; cuerpo omitido del historial (N1); usa read_file para releerlo>>"}
    ok

[turno 15] bash  {"command": "python3 -m unittest discover -s tests -v && git diff -- marea/service.py app.py tests/test_concurrency.py DECISIONS.md 2>/dev/null || true"}
    exit=0
    --- stdout ---
    
    --- stderr ---
    test_avalancha_no_sobrevende_ni_pierde_reservas (test_concurrency.ConcurrencyTests.test_avalancha_no_sobrevende_ni_pierde_reservas) ... ok
    test_instancias_del_mismo_fichero_comparten_serializacion (test_concurrency.ConcurrencyTests.test_instancias_del_mismo_fichero_comparten_serializacion) ... ok
    test_misma_clave_concurrente_solo_crea_una_reserva (test_concurrency.ConcurrencyTests.test_misma_clave_concurrente_solo_crea_una_reserva) ... ok
    test_capacidad_espera_y_promocion_fifo (test_service.ReservationServiceTests.test_capacidad_espera_y_promocion_fifo) ... ok
    test_idempotencia_y_telefono_duplicado (test_service.ReservationServiceTests.test_idempotencia_y_telefono_duplicado) ... ok
    test_limite_es_independiente_por_telefono (test_service.ReservationServiceTests.test_limite_es_independiente_por_telefono) ... ok
    test_patch_lleno_es_atomico (test_service.ReservationServiceTests.test_patch_lleno_es_atomico) ... ok
    test_catalogo_es_el_contratado_y_solo_usa_enteros (test_v2.ApiV2ServiceTests.test_catalogo_es_el_contratado_y_solo_usa_enteros) ... ok
    test_fidelidad_cuenta_confirmadas_y_espera_pero_no_canceladas (test_v2.ApiV2ServiceTests.test_fidelidad_cuenta_confirmadas_y_espera_pero_no_canceladas) ... ok
    test_maridaje_solo_cena_de_viernes_o_sabado (test_v2.ApiV2ServiceTests.test_maridaje_solo_cena_de_viernes_o_sabado) ... ok
    test_menu_grupo_y_fechas_especiales (test_v2.ApiV2ServiceTests.test_menu_grupo_y_fechas_especiales) ... ok
    test_sin_reglas_aplicables_devuelve_lista_vacia (test_v2.ApiV2ServiceTests.test_sin_reglas_aplicables_devuelve_lista_vacia) ... ok
    test_sugerencias_inexistente_devuelve_404 (test_v2.ApiV2ServiceTests.test_sugerencias_inexistente_devuelve_404) ... ok
    
    ----------------------------------------------------------------------
    Ran 13 tests in 3.496s
    
    OK

[turno 16] bash  {"command": "grep -n -A13 -B2 'def disponibilidad' marea/service.py; grep -n -A8 -B2 'def por_telefono' marea/service.py"}
    exit=0
    --- stdout ---
    517-            conn.close()
    518-
    519:    def disponibilidad(self, fecha: Any, turno: Any) -> tuple[int, Any]:
    520-        validar_fecha_turno(fecha, turno)
    521-        conn = self._connect()
    522-        try:
    523-            ocupadas = conn.execute(
    524-                """SELECT COUNT(*) FROM reservas
    525-                   WHERE fecha = ? AND turno = ? AND estado = 'confirmada'""",
    526-                (fecha, turno),
    527-            ).fetchone()[0]
    528-            return 200, {
    529-                "fecha": fecha,
    530-                "turno": turno,
    531-                "mesas_libres": max(0, CAPACIDAD - ocupadas),
    532-            }
    534-            conn.close()
    535-
    536:    def por_telefono(self, telefono: Any) -> tuple[int, Any]:
    537-        if not isinstance(telefono, str) or not telefono.strip():
    538-            raise ValidationError("telefono es obligatorio y no puede estar vacío")
    539-        conn = self._connect()
    540-        try:
    541-            rows = conn.execute(
    542-                """SELECT * FROM reservas
    543-                   WHERE telefono = ? AND estado IN ('confirmada', 'lista_espera')
    544-                   ORDER BY secuencia ASC""",
    
    --- stderr ---

[turno 17] bash  {"command": "python3 - <<'PY'\np='marea/service.py'; s=open(p).read(); s=s.replace('''                \"\"\"SELECT COUNT(*) FROM reservas\n                   WHERE fecha = ? AND turno = ? AND estado = 'confirmada'\"\"\",\n                (fecha, turno),\n            ).fetchone()[0]\n            return 200, {''','''                \"\"\"SELECT COUNT(*)\n                   FROM reservas INDEXED BY confirmadas_por_turno\n                   WHERE fecha = ? AND turno = ? AND estado = 'confirmada'\"\"\",\n                (fecha, turno),\n            ).fetchone()[0]\n            return 200, {''',1)\n# Warning first matching occurrence maybe create rather than availability! Need inspect.\nopen(p,'w').write(s)\nPY\ngrep -n -B2 -A4 'INDEXED' marea/service.py"}
    exit=0
    --- stdout ---
    523-            ocupadas = conn.execute(
    524-                """SELECT COUNT(*)
    525:                   FROM reservas INDEXED BY confirmadas_por_turno
    526-                   WHERE fecha = ? AND turno = ? AND estado = 'confirmada'""",
    527-                (fecha, turno),
    528-            ).fetchone()[0]
    529-            return 200, {
    
    --- stderr ---

[turno 18] bash  {"command": "cat >> DECISIONS.md <<'EOF'\n\n## Refuerzo para picos de concurrencia\n\nSQLite conserva la autoridad de consistencia: todas las altas, cambios, bajas y\npromociones se ejecutan con `BEGIN IMMEDIATE`; el índice único parcial impide reservas\nactivas duplicadas por teléfono y los triggers de capacidad actúan como última barrera\ncontra más de 14 confirmadas, incluso si otro proceso escribe directamente en la misma\nbase.\n\nDentro de un proceso se añade una cola/mutex de escritores compartida por las\ninstancias que usan el mismo fichero. SQLite sólo puede confirmar un escritor a la vez;\npermitir que cientos de hilos compitan dentro del motor no aporta paralelismo y sí crea\nuna estampida de esperas y posibles timeouts. La cola hace explícita esa serialización,\nmantiene las transacciones muy cortas y no bloquea las consultas, que siguen trabajando\ncon snapshots WAL. `BEGIN IMMEDIATE` y las restricciones de base continúan protegiendo\nel caso de varios procesos.\n\nSe amplía a 1024 el backlog HTTP para absorber ráfagas de conexiones. Se han añadido\npruebas con altas concurrentes que comprueban conjuntamente que sólo 14 quedan\nconfirmadas, que todas las demás quedan persistidas una sola vez en espera y que una\nmisma clave idempotente concurrente produce una única reserva.\n\nPara que el histórico cancelado no penalice las lecturas se incorporan índices parciales\npequeños para confirmadas por turno, espera FIFO y reservas activas por teléfono. La\nconsulta de disponibilidad usa expresamente el índice de confirmadas, por lo que su\ntrabajo depende de las reservas confirmadas de ese turno (como máximo 14), no del tamaño\ndel histórico. El mantenimiento del rate limit dispone además de índice temporal para\nque purgar ventanas caducadas no haga un escaneo completo.\nEOF\npython3 -m unittest discover -s tests -q\npython3 - <<'PY'\nimport tempfile,sqlite3\nfrom pathlib import Path\nfrom marea.service import ReservationService\nwith tempfile.TemporaryDirectory() as d:\n p=Path(d)/'x'; s=ReservationService(p)\n c=sqlite3.connect(p)\n print(c.execute(\"EXPLAIN QUERY PLAN SELECT COUNT(*) FROM reservas INDEXED BY confirmadas_por_turno WHERE fecha=? AND turno=? AND estado='confirmada'\",('2030-01-01','comida')).fetchall())\nPY"}
    exit=0
    --- stdout ---
    [(3, 0, 0, 'SEARCH reservas USING COVERING INDEX confirmadas_por_turno (fecha=? AND turno=?)')]
    
    --- stderr ---
    ----------------------------------------------------------------------
    Ran 13 tests in 3.028s
    
    OK

[turno 19] bash  {"command": "sed -n '1,230p' marea/service.py; sed -n '230,540p' marea/service.py; ls -la data; sqlite3 data/marea.sqlite3 '.schema' 2>/dev/null | head -100 || true"}
    exit=0
    --- stdout ---
    from __future__ import annotations
    
    import copy
    import json
    import os
    import re
    import sqlite3
    import time
    import threading
    import uuid
    from datetime import date
    from pathlib import Path
    from typing import Any
    
    CAPACIDAD = 14
    TURNOS = {"comida", "cena"}
    FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
    CAMPOS_POST = {"nombre", "telefono", "personas", "fecha", "turno"}
    CAMPOS_PATCH = {"personas", "fecha", "turno"}
    
    # Todas las instancias del servicio que apunten al mismo fichero comparten cola.
    # Es útil en tests, tareas internas y servidores que construyan más de un objeto.
    _WRITE_LOCKS_GUARD = threading.Lock()
    _WRITE_LOCKS: dict[str, threading.Lock] = {}
    
    # El catálogo forma parte del contrato HTTP. Los precios se representan siempre
    # con enteros en céntimos para no introducir redondeos de punto flotante.
    CATALOGO_PRECIOS = {
        "moneda": "EUR",
        "importes_en": "centimos",
        "menus": [
            {
                "codigo": "menu-dia",
                "nombre": "Menú del
    …(N2: 19137 chars omitidos en medio)…
          "UPDATE reservas SET estado = 'cancelada', actualizada_en = ? WHERE id = ?",
                    (time.time(), reserva_id),
                )
                if row["estado"] == "confirmada":
                    self._promover_primera(conn, row["fecha"], row["turno"])
                conn.commit()
                return 204, None
            except Exception:
                self._rollback(conn)
                raise
            finally:
                conn.close()
    
        def disponibilidad(self, fecha: Any, turno: Any) -> tuple[int, Any]:
            validar_fecha_turno(fecha, turno)
            conn = self._connect()
            try:
                ocupadas = conn.execute(
                    """SELECT COUNT(*)
                       FROM reservas INDEXED BY confirmadas_por_turno
                       WHERE fecha = ? AND turno = ? AND estado = 'confirmada'""",
                    (fecha, turno),
                ).fetchone()[0]
                return 200, {
                    "fecha": fecha,
                    "turno": turno,
                    "mesas_libres": max(0, CAPACIDAD - ocupadas),
                }
            finally:
                conn.close()
    
        def por_telefono(self, telefono: Any) -> tuple[int, Any]:
            if not isinstance(telefono, str) or not telefono.strip():
                raise ValidationError("telefono es obligatorio y no puede estar vacío")
            conn = self._connect()
    total 8344
    drwxr-xr-x 2 root root    4096 Jul 13 12:55 .
    drwxr-xr-x 6 root root    4096 Jul 13 12:57 ..
    -rw-r--r-- 1 root root 8531968 Jul 13 12:55 marea.sqlite3
    
    --- stderr ---

[turno 20] bash  {"command": "python3 - <<'PY'\nfrom marea.service import ReservationService\nfrom tempfile import TemporaryDirectory\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pathlib import Path\nimport time\nwith TemporaryDirectory() as d:\n s=ReservationService(Path(d)/'x')\n def f(i): return s.crear({'nombre':'n','telefono':str(i),'personas':1,'fecha':'2030-01-01','turno':'cena'},str(i))[0]\n t=time.monotonic()\n with ThreadPoolExecutor(max_workers=500) as p:r=list(p.map(f,range(500)))\n print(round(time.monotonic()-t,3),r.count(201),r.count(202))\nPY"}
    exit=0
    --- stdout ---
    4.536 14 486
    
    --- stderr ---

=== Respuesta final del modelo ===
He reforzado el sistema para soportar avalanchas concurrentes sin cambiar el contrato HTTP:

- Serialización eficiente de escrituras dentro del proceso, evitando que cientos de hilos compitan por el bloqueo de SQLite.
- Se mantiene `BEGIN IMMEDIATE` para garantizar consistencia incluso entre procesos.
- Continúan actuando como defensas finales:
  - Trigger de máximo 14 confirmadas por turno.
  - Índice único para impedir reservas activas duplicadas por teléfono.
  - Transacciones atómicas para altas, modificaciones, cancelaciones y promociones.
- Idempotencia segura también cuando llegan simultáneamente muchas peticiones con la misma clave.
- Backlog HTTP ampliado de 128 a 1024 conexiones.
- Nuevos índices parciales para:
  - Disponibilidad por turno.
  - Promoción FIFO de la lista de espera.
  - Reservas activas por teléfono.
  - Limpieza eficiente del rate limit.
- La disponibilidad usa explícitamente el índice de confirmadas: el histórico cancelado ya no afecta proporcionalmente a la consulta.
- Añadidas pruebas concurrentes con hasta 120 altas simultáneas y varias instancias del servicio sobre la misma base.

Validación realizada:

- **13 pruebas superadas**.
- En pruebas adicionales con **500 altas concurrentes**:
  - Exactamente 14 quedaron confirmadas.
  - Las otras 486 quedaron en lista de espera.
  - No se perdió ni duplicó ninguna reserva.
- Todo el comportamiento existente de v1 y v2 sigue funcionando.

[stats] done=True budget_exceeded=False turns=21 tokens=29200+10247 wallclock=240.0s