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
4 changes: 2 additions & 2 deletions docs/memory/feature-flows/session-tab.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ Voice mic and SSE dynamic status labels are **deferred** (each requires a backen
2. Persist the user message immediately so it appears even on failure.
3. Read `cached_claude_session_id`.
4. Resolve dynamic lock TTL via `_resolve_lock_ttl(agent_name)` = `db.get_execution_timeout(agent) + 30s`, capped at 7230s. The static 300s constant was removed in #759 because turns running longer than 5 min would silently drop the lock and allow concurrent JSONL writes.
5. SET in-flight sentinel `session_inflight:{session_id}` with the same TTL (#759). Bracketed via `_InflightSentinel` async context manager so DEL fires on success **and** exception. Distinct from the resume lock — the sentinel covers cold turns too, which the lock skips by design. Drives the `turn_in_progress` field on the GET endpoint.
6. Acquire `_ResumeLock(agent, cached_uuid, ttl_seconds=lock_ttl)` — Redis SET NX EX with async wait-and-retry (250 ms tick, 30 s ceiling). Cold turns skip the lock. Lock key constructed via `_session_lock_key(agent, uuid)` helper (shared by producer + any future probe — regression guard against split-brain typos).
5. SET in-flight sentinel `session_inflight:{session_id}` with the same TTL (#759). Bracketed via `_InflightSentinel` async context manager so DEL fires on success **and** exception. Distinct from the resume lock — the sentinel covers cold turns too. Drives the `turn_in_progress` field on the GET endpoint.
6. Acquire `_ResumeLock(agent, cached_uuid, session.id, ttl_seconds=lock_ttl)` — Redis SET NX EX with async wait-and-retry (250 ms tick, 30 s ceiling). Two key shapes: warm turns use `_session_lock_key(agent, uuid)`; cold turns key on `session_lock:cold:{session_id}` (#779) — same session serialised, different sessions still concurrent. Pre-#779 the cold path short-circuited to no-op and allowed two concurrent first-turn POSTs to race on `update_cached_claude_session_id` and orphan a JSONL.
7. Call `task_execution_service.execute_task(..., resume_session_id=cached, persist_session=True)`. The persist flag is unconditional — even cold turns must write the JSONL so turn 2's resume succeeds.
8. **Resume-failure fallback** (Phase 2.2): if execute_task returned failed with `"no conversation found"` AND we had a cached UUID, clear the cache, `mark_resume_failure`, retry **once** with `resume_session_id=None`. Log structured warning `event=session_resume_fallback`. Anthropic #39667 (cleanupPeriodDays) and #53417 (CLI upgrade) both produce this signal.
9. On success, trust `result.session_id` directly (Phase 1.3 fixed the agent-server stream parser to recognise `{"type":"system","subtype":"init"}` — no execution_log scan needed). Update `cached_claude_session_id` if changed, `mark_resume_success` to reset the failure counter.
Expand Down
1 change: 1 addition & 0 deletions docs/memory/feature-flows/subscription-auto-switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import from `backend.services`. Keep the two in sync when editing either.
| Frontend | `src/frontend/src/views/Settings.vue` | Toggle in Subscriptions section |
| Tests | `tests/test_subscription_auto_switch.py` | Smoke tests |
| Tests | `tests/unit/test_subscription_auto_switch_pingpong.py` | Unit regression for #444 ping-pong prevention; `TestRateLimitAging` (#476) pins 2h-window correctness |
| Tests | `tests/unit/test_subscription_auto_switch_no_cred_import.py` | Chain-level regression for #606 — pins `_restart_agent → start_agent_internal → inject_assigned_credentials` reaches the `lifecycle.py:155` `subscription_mode` short-circuit and never re-enters file-based credential import |
| Tests | `tests/unit/test_iso_cutoff.py` | Format parity between `iso_cutoff(N)` and `utc_now_iso()` (#476) |
| Util | `src/backend/utils/helpers.py::iso_cutoff` | Canonical cutoff helper for ISO-Z TEXT comparisons (#476) |
| Spec | `docs/requirements/SUB-003-subscription-auto-switch.md` | Full requirements |
Expand Down
6 changes: 5 additions & 1 deletion tests/lint_sys_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@
FIX_HINT = (
"use monkeypatch.setitem(sys.modules, ...) or "
"monkeypatch.delitem(sys.modules, ..., raising=False), "
"or move to conftest.py"
"or move to conftest.py. "
"For import-time stubs that monkeypatch can't reach (e.g. preloads "
"of heavy backend deps before any fixture runs), declare a top-level "
"`_STUBBED_MODULE_NAMES = [...]` list + an autouse `_restore_sys_modules` "
"fixture in this file (precedent: tests/unit/test_telegram_webhook_backfill.py)"
)


Expand Down
36 changes: 36 additions & 0 deletions tests/unit/test_cleanup_unreachable_orphan.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,42 @@ async def _exec(*a, **kw):
monkeypatch.setitem(sys.modules, "database", MagicMock(name="database_stub"))


# ─── sys.modules snapshot/restore (Issue #762) ───────────────────────────
#
# The three preload blocks above plant stubs at module-import time —
# monkeypatch is fixture-scoped and structurally cannot rewind them, and
# moving them to conftest would leak the stubs across the whole tests/unit/
# directory (the exact #762 failure mode). Adopt the named snapshot/restore
# escape hatch documented in `tests/lint_sys_modules.py` (precedent:
# `tests/unit/test_telegram_webhook_backfill.py`): the autouse fixture
# snapshots these slots at test setup and restores them at teardown so
# the stubs cannot leak into other tests in the same pytest session.

_STUBBED_MODULE_NAMES = [
"docker",
"services.docker_service",
"database",
]


@pytest.fixture(autouse=True)
def _restore_sys_modules():
"""Snapshot sys.modules before each test and restore after.

Bounds the blast radius of this file's import-time stubs so they
cannot leak into other test files in the same pytest session.
"""
saved = {name: sys.modules.get(name) for name in _STUBBED_MODULE_NAMES}
try:
yield
finally:
for name, value in saved.items():
if value is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = value


def _iso_past_minutes(minutes: int) -> str:
return (
datetime.now(timezone.utc).replace(microsecond=0) - _td(minutes=minutes)
Expand Down
Loading
Loading