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
12 changes: 8 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -945,10 +945,14 @@ every supervisor sweep, so a later ledger read cannot disagree with the boot.
When a successful pass leaves a restart remainder, the same supervisor follows
up after two seconds; a no-progress pass returns to the event/60-second cadence.
It creates neither per-chat workers nor a permanent short poll. Paid
provider-limit continuation (`auto_resume_on_limit`) initially defaults off;
planned-restart continuation (`auto_resume_on_restart`) initially defaults on.
Each chat stores both choices independently, and changing either choice seeds
future chats without rewriting existing conversations.
provider-limit continuation (`auto_resume_on_limit`) is an owner choice that
initially defaults off; each chat stores it independently, and changing it
seeds future chats without rewriting existing conversations. Planned-restart
continuation is always on and has no owner toggle — Möbius interrupted the work
itself, so it should just continue. The per-chat `auto_resume_on_restart`
column remains only as an internal latch: it defaults on and is cleared solely
by `delegations.mark_cancelled`, so a cancelled delegated child cannot
resurrect itself when the boot sweep claims restart parks.

### Tool output rendering

Expand Down
13 changes: 6 additions & 7 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,6 @@ class Owner(Base):
auto_resume_on_limit_default = Column(
Boolean, nullable=False, default=False, server_default=false()
)
# Planned restarts are initiated by Möbius, so continuing interrupted work
# is initially on. This remains independently configurable per chat.
auto_resume_on_restart_default = Column(
Boolean, nullable=False, default=True, server_default=true()
)
# Per-owner model-picker preferences. Shape:
# {"hidden_ids": ["claude-haiku-4-5-20251001", ...]}
# The picker filters out any registry entry whose ID appears in
Expand Down Expand Up @@ -194,8 +189,12 @@ class Chat(Base):
auto_resume_on_limit = Column(
Boolean, nullable=False, default=False, server_default=false()
)
# Per-chat policy for continuing after a supervisor-authenticated planned
# restart. Initially on because Möbius interrupted the work itself.
# Internal latch for continuing after a supervisor-authenticated planned
# restart. This is NOT an owner preference — a Möbius-initiated restart
# should always continue interrupted work, so it is on for every real chat
# and there is no toggle. It is only ever cleared internally, by
# delegations.mark_cancelled, so a cancelled delegated child cannot
# resurrect itself when the boot sweep claims restart parks.
auto_resume_on_restart = Column(
Boolean, nullable=False, default=True, server_default=true()
)
Expand Down
12 changes: 0 additions & 12 deletions backend/app/routes/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,6 @@ def _chat_detail_response(
"provider": provider,
"created_by_app_id": chat.created_by_app_id,
"auto_resume_on_limit": bool(chat.auto_resume_on_limit),
"auto_resume_on_restart": bool(chat.auto_resume_on_restart),
"agent_settings_json": settings_obj,
"effective_agent_settings": effective_agent_settings(
get_settings().data_dir,
Expand Down Expand Up @@ -852,9 +851,6 @@ def create_chat(
auto_resume_on_limit=(
bool(owner.auto_resume_on_limit_default) if owner else False
),
auto_resume_on_restart=(
bool(owner.auto_resume_on_restart_default) if owner else True
),
)
db.add(chat)
try:
Expand Down Expand Up @@ -1028,7 +1024,6 @@ async def patch_chat(
body.title is not None
or body.pinned is not None
or body.auto_resume_on_limit is not None
or body.auto_resume_on_restart is not None
or body.by_agent
or body.clear_title
):
Expand Down Expand Up @@ -1091,12 +1086,6 @@ async def patch_chat(
# global at runtime.
principal.owner.auto_resume_on_limit_default = body.auto_resume_on_limit

if body.auto_resume_on_restart is not None:
chat.auto_resume_on_restart = body.auto_resume_on_restart
principal.owner.auto_resume_on_restart_default = (
body.auto_resume_on_restart
)

# Determine the effective target provider. The body may set it
# explicitly, OR it may be implied by a model-only PATCH whose
# `model` belongs to a different provider than the chat is
Expand Down Expand Up @@ -1260,7 +1249,6 @@ async def patch_chat(
"agent_settings_json": _coerce_agent_settings(chat.agent_settings_json) or None,
"provider": chat.provider or "claude",
"auto_resume_on_limit": bool(chat.auto_resume_on_limit),
"auto_resume_on_restart": bool(chat.auto_resume_on_restart),
"effective": effective_agent_settings(
data_dir,
_coerce_agent_settings(chat.agent_settings_json) or None,
Expand Down
43 changes: 43 additions & 0 deletions backend/app/schema_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1643,6 +1643,48 @@ def _add_app_hosted_publication(eng) -> None:
conn.execute(text(f"ALTER TABLE apps DROP COLUMN {retired}"))


def _retire_restart_resume_toggle(eng) -> None:
"""Retire the owner restart-resume seed and lift chats a toggle latched off.

Restart continuation is now always on with no owner toggle. Earlier installs
carried an ``auto_resume_on_restart_default`` owner seed and let a per-chat
toggle latch continuation off. Lift every chat a prior toggle latched off —
EXCEPT a cancelled delegation child, whose ``False`` is an internal
do-not-resurrect latch owned by ``delegations.mark_cancelled`` — then drop the
dead seed column. Guarded on the seed column's presence, so it no-ops on any
database that never carried it.
"""
from sqlalchemy import inspect as sa_inspect, text

inspector = sa_inspect(eng)
tables = set(inspector.get_table_names())
if "owner" not in tables:
return
owner_cols = {c["name"] for c in inspector.get_columns("owner")}
if "auto_resume_on_restart_default" not in owner_cols:
return
# The data lift and schema retirement are one migration outcome. If the
# column drop fails (for example because the database is locked), roll the
# lift back and let the migration ledger retry the complete operation later.
with eng.begin() as conn:
if "chats" in tables:
if "delegations" in tables:
conn.execute(text(
"UPDATE chats SET auto_resume_on_restart = 1 "
"WHERE auto_resume_on_restart = 0 AND id NOT IN ("
"SELECT child_chat_id FROM delegations "
"WHERE cancelled_at IS NOT NULL AND child_chat_id IS NOT NULL)"
))
else:
conn.execute(text(
"UPDATE chats SET auto_resume_on_restart = 1 "
"WHERE auto_resume_on_restart = 0"
))
conn.execute(text(
"ALTER TABLE owner DROP COLUMN auto_resume_on_restart_default"
))


_SCHEMA_MIGRATIONS = (
("0001_legacy_schema_convergence", _converge_legacy_schema),
("0002_chat_run_goal_objective", _add_chat_run_goal_objective),
Expand All @@ -1660,6 +1702,7 @@ def _add_app_hosted_publication(eng) -> None:
("0014_chat_run_goal_plan", _add_chat_run_goal_plan),
("0015_chat_run_goal_identity", _add_chat_run_goal_identity),
("0016_app_connect_manage", _add_app_connect_manage),
("0017_retire_restart_resume_toggle", _retire_restart_resume_toggle),
)


Expand Down
2 changes: 0 additions & 2 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,8 +628,6 @@ class ChatPatch(BaseModel):
pinned: bool | None = None
# Per-chat automatic continuation after a paid provider limit.
auto_resume_on_limit: bool | None = None
# Per-chat automatic continuation after a supervisor-authenticated restart.
auto_resume_on_restart: bool | None = None
# Naming precedence. by_agent marks an AGENT title-sync — it fills the name
# only when the owner hasn't locked it via a manual rename. clear_title resets
# the name (unlock + drop to the first-message default; re-derived next turn).
Expand Down
3 changes: 2 additions & 1 deletion backend/tests/fixtures/migration_history.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"0012_connector_oauth_gcloud": "cc0f805478540ee55fd927786f10e03a020873f349cc3b0ee99a7b741cff45f1",
"0013_app_hosted_publication": "62d7ff873ded24c52da4031684ca5fd95ed244f416345ae1dcbae9cf880025ae",
"0014_chat_run_goal_plan": "28a1e1a60164ae48845a9cfb4227e7d8c58655852caed6b95e1e1725dd12a217",
"0015_chat_run_goal_identity": "aa63ad39a2b55b364b97c950f912041ff88cd59ebf315218a824bb567bc6bf77"
"0015_chat_run_goal_identity": "aa63ad39a2b55b364b97c950f912041ff88cd59ebf315218a824bb567bc6bf77",
"0017_retire_restart_resume_toggle": "ef68e15bcea2a9799f0a0e2bbd212dd18d69818ea24c2bfe60c25ca72af61d4d"
}
}
51 changes: 25 additions & 26 deletions backend/tests/test_chat_agent_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ def test_auto_resume_is_per_chat_and_survives_runtime_clear(
)
assert enabled.status_code == 200
assert enabled.json()["auto_resume_on_limit"] is True
assert enabled.json()["auto_resume_on_restart"] is True
assert enabled.json()["agent_settings_json"] == {
"model": "historical-model", "effort": "high",
}
Expand All @@ -187,7 +186,6 @@ def test_auto_resume_is_per_chat_and_survives_runtime_clear(

sibling = client.get(f"/api/chats/{other['id']}", headers=auth).json()
assert sibling["auto_resume_on_limit"] is False
assert sibling["auto_resume_on_restart"] is True

cleared = client.patch(
f"/api/chats/{chat.id}",
Expand Down Expand Up @@ -248,38 +246,39 @@ def test_new_chat_inherits_last_auto_resume_selection(client, auth, chat):
).json()["auto_resume_on_limit"] is True


def test_restart_resume_is_separate_and_defaults_on(client, auth, chat):
"""Restart recovery starts on while paid usage retries remain off."""
def test_restart_continuation_is_always_on_and_not_owner_configurable(
client, auth, chat, db,
):
"""Planned-restart continuation is always on and has no owner toggle.

A Möbius-initiated restart should always continue interrupted work, so the
per-chat column is on for every real chat and is neither exposed nor settable
through the chat API. It is only ever cleared internally (delegation
cancellation, covered in test_delegations)."""
from app import models

# The runtime setting is no longer part of any chat payload.
initial = client.get(f"/api/chats/{chat.id}", headers=auth).json()
assert initial["auto_resume_on_limit"] is False
assert initial["auto_resume_on_restart"] is True
assert "auto_resume_on_restart" not in initial

existing_on = client.post(
"/api/chats", headers=auth, json={"title": "existing on"},
# Every freshly created chat continues after a restart at the storage layer.
created = client.post(
"/api/chats", headers=auth, json={"title": "fresh"},
).json()
assert client.get(
f"/api/chats/{existing_on['id']}", headers=auth,
).json()["auto_resume_on_restart"] is True
assert "auto_resume_on_restart" not in created
db.expire_all()
assert db.get(models.Chat, created["id"]).auto_resume_on_restart is True

disabled = client.patch(
# An attempt to turn it off through the API is ignored, not honored.
patched = client.patch(
f"/api/chats/{chat.id}",
headers=auth,
json={"auto_resume_on_restart": False},
)
assert disabled.status_code == 200
assert disabled.json()["auto_resume_on_restart"] is False
assert disabled.json()["auto_resume_on_limit"] is False

inherited_off = client.post(
"/api/chats", headers=auth, json={"title": "inherits restart off"},
).json()
assert client.get(
f"/api/chats/{inherited_off['id']}", headers=auth,
).json()["auto_resume_on_restart"] is False
# Changing the seed never rewrites older conversations.
assert client.get(
f"/api/chats/{existing_on['id']}", headers=auth,
).json()["auto_resume_on_restart"] is True
assert patched.status_code == 200
assert "auto_resume_on_restart" not in patched.json()
db.expire_all()
assert db.get(models.Chat, chat.id).auto_resume_on_restart is True


def test_stale_global_auto_resume_setting_is_not_a_chat_default(
Expand Down
117 changes: 105 additions & 12 deletions backend/tests/test_db_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from types import SimpleNamespace

import pytest
from sqlalchemy import String, create_engine, inspect, text
from sqlalchemy import String, create_engine, event, inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -970,17 +970,112 @@ def test_run_migrations_adds_owner_auto_resume_default(tmp_path):
cols = {c["name"]: c for c in inspect(eng).get_columns("owner")}
assert "auto_resume_on_limit_default" in cols
assert cols["auto_resume_on_limit_default"]["nullable"] is False
assert "auto_resume_on_restart_default" in cols
assert cols["auto_resume_on_restart_default"]["nullable"] is False
# Restart continuation is always on and has no owner-default seed.
assert "auto_resume_on_restart_default" not in cols
with eng.connect() as conn:
value = conn.execute(text(
"SELECT auto_resume_on_limit_default FROM owner WHERE id = 1"
)).scalar_one()
restart_value = conn.execute(text(
"SELECT auto_resume_on_restart_default FROM owner WHERE id = 1"
)).scalar_one()
assert value in (False, 0)
assert restart_value in (True, 1)


def test_retire_restart_resume_toggle_lifts_stranded_chats(tmp_path):
"""The one-time retirement drops the owner seed column and lifts every chat a
prior toggle latched off, while preserving a cancelled delegation child's
internal do-not-resurrect latch."""
from app.schema_migrations import _retire_restart_resume_toggle

db_path = tmp_path / "retire.db"
eng = create_engine(f"sqlite:///{db_path}")
with eng.begin() as conn:
conn.execute(text(
"CREATE TABLE owner (id INTEGER PRIMARY KEY, "
"auto_resume_on_restart_default BOOLEAN NOT NULL DEFAULT TRUE)"
))
conn.execute(text(
"CREATE TABLE chats (id VARCHAR PRIMARY KEY, "
"auto_resume_on_restart BOOLEAN NOT NULL DEFAULT TRUE)"
))
conn.execute(text(
"CREATE TABLE delegations (id VARCHAR PRIMARY KEY, "
"child_chat_id VARCHAR, cancelled_at DATETIME NULL)"
))
conn.execute(text(
"INSERT INTO chats (id, auto_resume_on_restart) VALUES "
"('stranded', 0), ('kept', 1), ('cancelled-child', 0), "
"('active-child', 0)"
))
# Only a CANCELLED delegation child keeps its latch; a live delegation
# child is lifted like any other chat.
conn.execute(text(
"INSERT INTO delegations (id, child_chat_id, cancelled_at) VALUES "
"('d1', 'cancelled-child', '2026-08-01 00:00:00'), "
"('d2', 'active-child', NULL)"
))

_retire_restart_resume_toggle(eng)
# Idempotent: the dropped seed column means a second pass is a clean no-op.
_retire_restart_resume_toggle(eng)

assert "auto_resume_on_restart_default" not in {
c["name"] for c in inspect(eng).get_columns("owner")
}
with eng.connect() as conn:
rows = dict(conn.execute(text(
"SELECT id, auto_resume_on_restart FROM chats"
)).all())
assert rows["stranded"] in (True, 1)
assert rows["kept"] in (True, 1)
assert rows["active-child"] in (True, 1)
# The cancelled delegation child keeps its internal latch.
assert rows["cancelled-child"] in (False, 0)


def test_retire_restart_resume_toggle_retries_as_one_transaction(tmp_path):
"""A failed schema retirement must not hide a half-applied migration."""
from app.schema_migrations import _retire_restart_resume_toggle

eng = create_engine(f"sqlite:///{tmp_path / 'retire-retry.db'}")
with eng.begin() as conn:
conn.execute(text(
"CREATE TABLE owner (id INTEGER PRIMARY KEY, "
"auto_resume_on_restart_default BOOLEAN NOT NULL DEFAULT TRUE)"
))
conn.execute(text(
"CREATE TABLE chats (id VARCHAR PRIMARY KEY, "
"auto_resume_on_restart BOOLEAN NOT NULL DEFAULT TRUE)"
))
conn.execute(text(
"INSERT INTO chats (id, auto_resume_on_restart) VALUES ('stranded', 0)"
))

def refuse_drop(_conn, _cursor, statement, _parameters, _context, _many):
if statement.startswith("ALTER TABLE owner DROP COLUMN"):
raise RuntimeError("simulated locked schema")

event.listen(eng, "before_cursor_execute", refuse_drop)
try:
with pytest.raises(RuntimeError, match="simulated locked schema"):
_retire_restart_resume_toggle(eng)
finally:
event.remove(eng, "before_cursor_execute", refuse_drop)

assert "auto_resume_on_restart_default" in {
c["name"] for c in inspect(eng).get_columns("owner")
}
with eng.connect() as conn:
assert conn.execute(text(
"SELECT auto_resume_on_restart FROM chats WHERE id = 'stranded'"
)).scalar_one() in (False, 0)

_retire_restart_resume_toggle(eng)
assert "auto_resume_on_restart_default" not in {
c["name"] for c in inspect(eng).get_columns("owner")
}
with eng.connect() as conn:
assert conn.execute(text(
"SELECT auto_resume_on_restart FROM chats WHERE id = 'stranded'"
)).scalar_one() in (True, 1)


def test_fresh_owner_schema_has_auto_resume_default():
Expand All @@ -990,11 +1085,8 @@ def test_fresh_owner_schema_has_auto_resume_default():
assert column.default is not None
assert column.server_default is not None
assert str(column.server_default.arg).lower() == "false"
restart = models.Owner.__table__.c.auto_resume_on_restart_default
assert restart.nullable is False
assert restart.default is not None
assert restart.server_default is not None
assert str(restart.server_default.arg).lower() == "true"
# Restart continuation is always on: there is no owner-default column.
assert not hasattr(models.Owner.__table__.c, "auto_resume_on_restart_default")


def test_run_migrations_adds_read_at_and_backfills_notifications(tmp_path):
Expand Down Expand Up @@ -1070,6 +1162,7 @@ def test_run_migrations_records_an_inspectable_append_only_history(tmp_path):
"0014_chat_run_goal_plan",
"0015_chat_run_goal_identity",
"0016_app_connect_manage",
"0017_retire_restart_resume_toggle",
]
assert second == first

Expand Down
Loading
Loading