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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,19 @@ All chat-domain mutations — transcript writes, run-markers, question rows, ans

Streaming state is physically bounded: `PersistTranscript`/`PersistError` replace `Chat.live_assistant`, never the historical `Chat.messages` JSON blob. Read routes overlay that current assistant on immutable history. `QuestionCommit` merges the card into history before broadcast, `Finalize` performs the terminal merge and clears the live value, and startup reconciliation performs the same merge after a crash. This keeps one-second crash-resilient snapshots without quadratic transcript rewrites as chats grow.

Settled transcript reads have a separate bounded presentation contract.
`GET /api/chats/{id}?compact=1` keeps prose, cards, distinctive image-view
beats, and small collapsed activity metadata, but replaces each multi-step
thinking/tool run with an `activity` reference into the immutable stored
message. Repeated steps are bounded by activity variety rather than raw call
count. Only an explicit disclosure resolves that exact range through
`GET /api/chats/{id}/activity-detail`; the live assistant stays self-contained.
Mounted runtime reconciliation uses `GET /api/chats/{id}/runtime`, whose ORM
projection raiseloads every unrequested field so polling can never silently
decode `Chat.messages`. These are read projections, never a second persistence
format: provider context, recovery, export, and writer commands continue to
use the full transcript.

- **Commit-before-ack (strict paths):** the caller's `await` on `QuestionCommit`/`Finalize`/`AnswerQuestion`/`Barrier`/`DrainAndStop` doesn't unblock until the commit succeeds; `PersistTranscript` and `PersistError` are fire-and-forget (submitted without awaiting the ack).
- **Questions commit-before-broadcast:** a question row is durable before its SSE push fires, so a reconnect's catch-up burst always finds it.
- **Concurrency invariant:** ack `Future`s are NEVER resolved while a producer lock is held — collect `(ack, value)` under the lock, resolve after release — so even a synchronous done-callback that re-enters `submit()`/`stop()` can't deadlock. Do not move an ack resolution back inside a `with` block.
Expand Down
10 changes: 10 additions & 0 deletions backend/app/app_compile_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ def esbuild_command(
"--format=esm",
"--jsx=automatic",
"--platform=browser",
# Mini-apps are runtime artifacts, not development builds. Without an
# explicit production define React selects its development branches and
# every app carries nearly a megabyte of validation/debug code that the
# opaque frame must copy, parse, and execute on every cold mount.
'--define:process.env.NODE_ENV="production"',
# Keep the one-module offline contract while reducing transfer, cache,
# parse, and evaluation cost. Preserve Function.name/Class.name for apps
# that use names in labels or diagnostics.
"--minify",
"--keep-names",
f"--banner:js={COMPILED_RUNTIME_BANNER}",
f"--inject:{runtime_inject_path()}",
f"--alias:mobius-runtime={mobius_runtime_path()}",
Expand Down
96 changes: 96 additions & 0 deletions backend/app/app_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Durable per-build acknowledgement for an app's owning-chat open button."""

from datetime import UTC, datetime

from sqlalchemy import update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app import models


def naive_utc(value: datetime) -> datetime:
"""Normalize an API datetime to the naive-UTC shape SQLite returns."""
if value.tzinfo is not None:
return value.astimezone(UTC).replace(tzinfo=None)
return value


def _advance_existing(
db: Session, app_id: int, seen_updated_at: datetime, seen_as_final: bool,
) -> bool:
"""Advance one row without letting an older acknowledgement move it back."""
advanced = db.execute(
update(models.AppPreviewState)
.where(
models.AppPreviewState.app_id == app_id,
models.AppPreviewState.seen_updated_at < seen_updated_at,
)
.values(
seen_updated_at=seen_updated_at,
seen_as_final=seen_as_final,
)
)
if advanced.rowcount:
return True
if seen_as_final:
promoted = db.execute(
update(models.AppPreviewState)
.where(
models.AppPreviewState.app_id == app_id,
models.AppPreviewState.seen_updated_at == seen_updated_at,
models.AppPreviewState.seen_as_final.is_(False),
)
.values(seen_as_final=True)
)
if promoted.rowcount:
return True
return db.get(models.AppPreviewState, app_id) is not None


def mark_seen(
db: Session,
app_id: int,
seen_updated_at: datetime,
*,
seen_as_final: bool,
) -> None:
"""Acknowledge only the build the opening shell actually observed.

An older request may arrive after a newer build was opened on another device.
The monotonic timestamp update keeps that late request from hiding or
downgrading the newer acknowledgement.
"""
seen_updated_at = naive_utc(seen_updated_at)
if _advance_existing(db, app_id, seen_updated_at, seen_as_final):
return
try:
with db.begin_nested():
db.add(models.AppPreviewState(
app_id=app_id,
seen_updated_at=seen_updated_at,
seen_as_final=seen_as_final,
))
db.flush()
except IntegrityError:
# Two devices acknowledged the first visible build concurrently. The
# savepoint preserves the outer request; replay the monotonic update.
_advance_existing(db, app_id, seen_updated_at, seen_as_final)


def annotate_apps(db: Session, apps: list[models.App]) -> list[models.App]:
"""Attach response-only preview acknowledgement fields to app rows."""
ids = [app.id for app in apps]
state_by_id = {}
if ids:
state_by_id = {
row.app_id: row
for row in db.query(models.AppPreviewState).filter(
models.AppPreviewState.app_id.in_(ids)
).all()
}
for app in apps:
state = state_by_id.get(app.id)
app.preview_seen_updated_at = state.seen_updated_at if state else None
app.preview_seen_final = bool(state and state.seen_as_final)
return apps
111 changes: 94 additions & 17 deletions backend/app/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
PersistError,
PersistTranscript,
QuestionCommit,
RecordRunMetrics,
ResolvePark,
RollbackAutoResume,
StashThinkingTrace,
Expand Down Expand Up @@ -1194,6 +1195,45 @@ async def _clear_run_status(
)


async def _record_run_metrics(
*,
chat_id: str,
run_token: str,
provider_session_id: str | None,
cost_usd: float | None,
usage: dict | None,
) -> None:
"""Best-effort durable accounting for one provider run.

Usage must not be able to turn an otherwise successful chat response into a
failed turn. The exact run identity keeps a delayed completion from
attributing counters to a successor, and the writer actor keeps this scalar
update ordered with the later terminal transition.
"""
if not chat_id or not run_token:
return
# A provider can legitimately omit usage (and Codex currently omits cost).
# With no accounting signal there is nothing to record; avoiding a no-op
# actor round-trip also preserves the runner's connection-release contract.
if usage is None and cost_usd in (None, 0):
return
try:
await _await_ack(get_writer().submit(RecordRunMetrics(
chat_id=chat_id,
run_token=run_token,
provider_session_id=provider_session_id,
cost_usd=cost_usd,
usage=usage,
)))
except Exception:
_get_logger().warning(
"RecordRunMetrics did not persist chat_id=%s run_token=%s",
chat_id,
run_token,
exc_info=True,
)


async def _clear_run_status_strict(
chat_id: str,
run_token: str = "",
Expand Down Expand Up @@ -2213,10 +2253,12 @@ async def _auto_resume_chat(
# Share the queue lock with owner/app sends. The outer sweep check can
# go stale while this task waits, so re-check global liveness, policy,
# attribution, the exact latest park, and provider ownership at the
# actual claim point.
# actual claim point. Provider-limit retries are globally serial to
# avoid a reset storm. Planned-restart continuations are different:
# they are the exact, owner-opted set that was already live together
# before the restart, so each chat may reclaim its own slot
# independently.
async with chat_queue.get_lock(chat_id):
if _any_chat_turn_active():
return False
with SessionLocal() as check_db:
chat = check_db.query(models.Chat).filter(
models.Chat.id == chat_id,
Expand Down Expand Up @@ -2275,6 +2317,11 @@ async def _auto_resume_chat(
resume_reason = (
"restart" if park.park_reason == "restart" else "usage_limit"
)
if (
resume_reason != "restart"
and _any_chat_turn_active()
):
return False
if not mark_starting(chat_id):
return False
claimed = True
Expand Down Expand Up @@ -2369,11 +2416,13 @@ async def sweep_reset_parks(db: Session) -> list[str]:
failure cannot silently consume the promised continuation. The narrow
post-promote SIGKILL boundary is documented on `_auto_resume_chat`.
- A park whose chat was deleted resolves silently.
- Auto-resume is controlled per chat and STRICTLY SERIAL: at most one
enabled park starts per tick, and none while any turn is live anywhere.
A blocked enabled chat stays pending for a later tick, while notify-only
chats in the same due batch still resolve normally. App-attributed runs
and queues never auto-resume.
- Auto-resume is controlled per chat. Provider-limit retries are strictly
serial: at most one starts per tick, and none while any turn is live
anywhere. Planned-restart continuations reclaim the exact set that was
already live before the restart, so every eligible chat in the batch may
resume independently. A blocked enabled chat stays pending for a later
tick, while notify-only chats in the same due batch still resolve
normally. App-attributed runs and queues never auto-resume.

Stands down while draining — a restart is in progress, and the fresh
process's immediate sweep picks everything up. Never raises.
Expand Down Expand Up @@ -2485,14 +2534,15 @@ def wants_auto_resume(chat, run) -> bool:
and restart_authorized
)

auto_resume_started = False
limit_resume_started = False
for run in due:
chat_id = run.chat_id
chat = chats.get(chat_id)
chat_gone = chat is None or chat.deleted_at is not None
auto_resume = wants_auto_resume(chat, run)
if auto_resume and (
auto_resume_started or _any_chat_turn_active()
restart_auto_resume = auto_resume and run.park_reason == "restart"
if auto_resume and not restart_auto_resume and (
limit_resume_started or _any_chat_turn_active()
):
# Strictly-serial gate: a live turn (an earlier auto-resume, or the
# owner's own send) must settle before this enabled park is processed.
Expand Down Expand Up @@ -2550,16 +2600,18 @@ def wants_auto_resume(chat, run) -> bool:

if prepared.get("notify"):
notify_due(chat_id, run)
if _any_chat_turn_active():
if not restart_auto_resume and _any_chat_turn_active():
# The notification or refresh window admitted another turn. Keep the
# durable pending state so the next sweep retries instead of silently
# dropping the promised continuation.
continue
auto_resume_started = await _auto_resume_chat(
resume_started = await _auto_resume_chat(
chat_id, park_token=run.id,
)
if auto_resume_started:
if resume_started:
resolved.append(chat_id)
if not restart_auto_resume:
limit_resume_started = True
continue

# Notify-only/app/deleted path: resolve before the best-effort push so a
Expand Down Expand Up @@ -3647,7 +3699,8 @@ async def _complete_turn(
# legitimately-silent turn: a user Stop lands as stop_handoff_successor (or
# disowns the generation above), a park sets limit_reached, an errored/refused
# turn sets _last_error, and any real text/thinking/tool_use makes the blocks
# renderable. cost_usd is unusable (None for every run here) and not consulted.
# renderable. Provider usage/cost is accounting data, not proof of a reply,
# and is deliberately not consulted.
lost_reply = (
we_own_gen
and not stop_handoff_successor
Expand Down Expand Up @@ -5144,6 +5197,14 @@ async def _run_chat_impl_with_db(
)
new_session_id = runner_result.get("session_id")
err = runner_result.get("error")
usage_metrics = runner_result.get("usage_metrics")
await _record_run_metrics(
chat_id=chat_id,
run_token=run_token or "",
provider_session_id=new_session_id or session_id,
cost_usd=runner_result.get("cost_usd"),
usage=usage_metrics,
)
if not err and new_session_id and chat_id:
chat_obj = db.query(models.Chat).filter(
models.Chat.id == chat_id
Expand All @@ -5161,10 +5222,14 @@ async def _run_chat_impl_with_db(
)
else:
log.info(
"chat done chat_id=%s cost_usd=%.4f sdk=codex status=%s phase=%s",
"chat done chat_id=%s cost_usd=%.4f sdk=codex status=%s phase=%s "
"input_tokens=%s output_tokens=%s total_tokens=%s",
chat_id, runner_result.get("cost_usd") or 0.0,
runner_result.get("terminal_status"),
runner_result.get("final_message_phase"),
(usage_metrics or {}).get("input_tokens"),
(usage_metrics or {}).get("output_tokens"),
(usage_metrics or {}).get("total_tokens"),
)
except Exception as exc:
log.exception("codex SDK turn failed chat_id=%s: %s", chat_id, exc)
Expand Down Expand Up @@ -5266,6 +5331,14 @@ async def _run_chat_impl_with_db(
)
new_session_id = runner_result.get("session_id")
err = runner_result.get("error")
usage_metrics = runner_result.get("usage_metrics")
await _record_run_metrics(
chat_id=chat_id,
run_token=run_token or "",
provider_session_id=new_session_id or claude_session_id,
cost_usd=runner_result.get("cost_usd"),
usage=usage_metrics,
)
if not err and new_session_id and chat_id:
chat_obj = db.query(models.Chat).filter(
models.Chat.id == chat_id
Expand All @@ -5277,8 +5350,12 @@ async def _run_chat_impl_with_db(
log.error("claude SDK error chat_id=%s: %s", chat_id, err)
else:
log.info(
"chat done chat_id=%s cost_usd=%.4f sdk=claude",
"chat done chat_id=%s cost_usd=%.4f sdk=claude "
"input_tokens=%s output_tokens=%s total_tokens=%s",
chat_id, runner_result.get("cost_usd") or 0.0,
(usage_metrics or {}).get("input_tokens"),
(usage_metrics or {}).get("output_tokens"),
(usage_metrics or {}).get("total_tokens"),
)
except Exception as exc:
log.exception("claude SDK turn failed chat_id=%s: %s", chat_id, exc)
Expand Down
Loading