Skip to content

feat(sessions): unify Activity and ChatThread into a Sessions app#1374

Merged
srtab merged 61 commits into
mainfrom
claude/determined-maxwell-3d7694
Jul 10, 2026
Merged

feat(sessions): unify Activity and ChatThread into a Sessions app#1374
srtab merged 61 commits into
mainfrom
claude/determined-maxwell-3d7694

Conversation

@srtab

@srtab srtab commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary

Unifies activity.Activity and chat.ChatThread into a new daiv/sessions/ app (Django app label agent_sessions), built around two models:

  • Session — one per LangGraph thread (PK = thread_id = checkpoint key)
  • Run — one per execution, including chat turns, with denormalized usage/cost

The result is one unified data model, one transcript-centric UI, and one execution lock across every surface (chat, background jobs, webhooks, schedules).

How it was built

The app was built additively, then consumers switched one surface at a time, then the unified UI landed, then the old models/pages were deleted:

  • FoundationSession/Run models, unified SessionLock (claim/heartbeat/release with stale takeover), submit services + FIFO dispatcher + task-sync/backfill signals, and a data migration that copies every historical Activity/ChatThread row into Session/Run (Run.pk is preserved from Activity.pk so external job IDs keep resolving).
  • Consumer switchesrun_job_task claims the session lock; /api/jobs and the MCP submit_job/get_job_status/list_jobs tools are backed by Session/Run with byte-identical contracts; webhooks/schedules/dashboard/sandbox_envs switched; chat turns now create Runs with usage tracking; memory observations and notifications hang off Run (both skip chat-origin runs).
  • Unified UI — sessions list (origin/status/repo/schedule/batch filters), transcript-centric detail page with a run timeline, live updates (SSE status stream + background-run transcript polling), unified navigation, and permanent redirects from every old /dashboard/activity/ and /dashboard/chat/ URL.
  • CleanupActivity and ChatThread models dropped; the activity/chat apps remain as migration-only stubs (their migration history is referenced by other apps).

Deploy note

This branch is safe to deploy only at completion (this state). Migration ordering guarantees no data loss: the backfill (agent_sessions.0002) and the memory FK-swap (memory.0002) run before either old table is dropped (the DeleteModel migrations explicitly depend on them). On a populated DB, every ActivityRun (same UUID) and ChatThreadSession is copied, and memory observations re-point to the equal-PK Run, before the drops.

External contracts

Unchanged: /api/jobs request/response shapes, MCP tool names/wording/JSON keys, job_id == Run.pk, and the MCP mcp_job trigger are all preserved.

Verification

  • make test3352 passed, 0 failed
  • makemigrations --check --dry-run — clean ("No changes detected")
  • make lint / make lint-typing — clean / no new error classes
  • mkdocs build --strict — clean
  • findstatic — confirms the relocated run-form JS resolves under manifest static storage

Each of the 16 plan tasks was implemented and reviewed independently; a final whole-branch review verified cross-cutting coherence, migration-chain safety, contract preservation, and spec fidelity.

Notes for the reviewer

  • The commit chore(migrations): add missing migrations for pre-existing thinking-level field drift addresses pre-existing core/schedules model-vs-migration drift (unrelated to this change) so the branch's makemigrations --check gate is clean. It can be split into its own PR if preferred.
  • Deferred non-blocking follow-ups: consolidate the shared presentation tags/partials still living in activity/templates + activity_tags.py (duplicated by session_tags.py) into the sessions app so activity becomes a true stub; use SessionOrigin members instead of raw strings in notifications EXCLUDED_RUN_TRIGGERS; a few minor test-coverage additions.
  • Manual end-to-end validation (chat stream → Run row; UI run → live transcript; API round-trip; old-bookmark redirects) is recommended before merge.

srtab added 30 commits July 7, 2026 15:20
- sandbox_envs migration test now restores migrations to head: reversing
  sandbox_envs.0006 cascade-reverses agent_sessions.0001 (drops its tables),
  which left later Session/Run tests failing with 'no such table'.
- test_by_owner_admin_sees_all uses a leak-immune subset assertion: async ORM
  writes commit to the shared in-memory SQLite DB and escape rollback.
- Drop the earlier threaded cleanup fixture (wrong approach: a worker thread
  hit a separate empty in-memory DB and corrupted connection state).
…ff Run

- Replace MemoryObservation.activity FK (→ activity.Activity) with
  MemoryObservation.run FK (→ agent_sessions.Run); migration 0002 adds the
  new column, copies activity_id values whose PKs exist in Run, then drops
  the old column.
- Switch memory.signals receiver from activity_finished to run_finished;
  add CHAT-trigger guard so chat turns are never mined for observations.
- Update extract_observations_task to accept run_id, look up Run, and use
  run.session_id as the LangGraph thread_id.
- notifications.signals: on_run_finished receiver (already scaffolded) now
  fully wired with CHAT- and webhook-trigger guards, schedule resolution
  via run.session.scheduled_job, and a top-level try/except so errors never
  crash the run lifecycle.
- Update memory/detail.html template to reference obs.run instead of obs.activity.
Complete the FK swap: memory models, signals, tasks, views and templates now
reference sessions.Run instead of activity.Activity. Tests updated to use
Run/Session fixtures. Notifications on_run_finished receiver completed with
chat-skip guard and run-based helpers.
…un form move

- Replace activity/chat URL mounts with permanent redirect patterns (301)
  via sessions.urls_legacy; LegacyActivityDetailRedirectView resolves
  Run by pk → session_detail#run-<id>
- Move AgentRunCreateView, AgentRunCreateForm, RepoListField,
  AgentRunFieldsMixin from activity app into sessions app; run-form
  templates copied to sessions/templates/sessions/
- Switch runs include from activity.urls_runs to sessions.urls_runs
  (app_name="runs", route name "agent_run_new" preserved)
- Sidebar: two items (Activity + Chat) → one "Sessions" item with
  running-jobs badge; "New chat" CTA → session_new
- context_processors: replace "activity"/"chat" SECTION_URL_NAMES with
  "sessions"; running_jobs_count now queries Run instead of Activity
- accounts/views.py dashboard tiles: all segment URLs → session_list
- schedules/forms.py: import AgentRunFieldsMixin/RepoListField from
  sessions.forms (not activity.forms)
- Fix all live stragglers: schedules/views.py, schedules/_schedule_row.html,
  memory/detail.html, notifications/signals.py, dashboard.html
- Tests: new test_redirects.py (8 tests); update accounts/sidebar,
  breadcrumbs, context_processors and schedules tests to use session routes
…t become stubs

- Empty activity/models.py and chat/models.py to module docstrings; both apps
  stay in INSTALLED_APPS with migration history intact (other apps' historical
  migrations reference them)
- Delete dead files from activity app: views, urls, urls_runs, filters, forms,
  services, signals, management commands, static assets, and most templates;
  keep _agent_run_fields.html and _copy_markdown_button.html (still included by
  schedules and sessions templates respectively), and the activity_tags
  templatetag library (still loaded by notification email templates and
  sessions/_prompt_disclosure.html) with Activity-model-dependent tags removed
- Delete dead files from chat app: views, urls, managers, chat_list.html,
  chat_detail.html; keep api/, turns.py, repo_state.py, static/,
  templatetags/, and _composer.html (included by sessions/session_detail.html)
- Generate DeleteModel migrations for Activity (activity/0016) and ChatThread
  (chat/0004) with explicit dependencies on agent_sessions.0002 and
  memory.0002 so tables are never dropped before data is copied
- Remove on_activity_finished receiver and all Activity-based helpers from
  notifications/signals.py; keep on_run_finished and all _run helpers
- Drop legacy chat_thread/activity branches from generate_title_task in
  automation/titling/tasks.py; narrow Literal to ["session", "run"]
- Delete all tests/unit_tests/activity/ and dead chat test files (test_models,
  test_views, test_composer_agent_picker, test_composer_env_select,
  test_sandbox_env_link); delete sessions/test_data_migration.py which
  depended on live Activity/ChatThread model access
- Update notifications/test_signals.py to remove Activity-based test classes;
  keep TestUserBindingSeeder and TestOnRunFinished
- Update automation/titling/test_tasks.py to use Session/Run entities instead
  of Activity; preserve all behavioural coverage
- Fix global conftest mock_generate_title_task to remove the now-dead patches
  on activity.services and chat.models
Follow-up hardening for the Session/Run unification based on review:

- SessionLock.try_claim logs stale takeovers and splits the free-slot
  fast path from the takeover path (one query in the common case);
  heartbeat reports lost ownership and run_job_task awaits the cancelled
  heartbeat before releasing the slot.
- Backfill wraps bulk_create with a contextual error plus summary logs
  and corrects the merge-precedence comment (user is first-wins).
- Add DB CheckConstraints pinning Session.origin, Run.trigger_type and
  Run.status to their enums (new migration 0003).
- Surface the orphan-task failure mode in error_message via a shared
  LINK_FAILED_PREFIX constant.
- SSE stream guards malformed frames, auto-reconnects, and re-subscribes
  on timeout; the turns poller caps consecutive failures.
- hydration returns a HydratedThread NamedTuple; rename
  _mark_failed_and_release -> _mark_failed_and_advance.

Tests: backfill migration (historical registry), run-form view, run
sync/usage parsing, sandbox-env propagation, templatetags, forms,
enum-drift and effective_notify_on.
- sync_stuck_runs now fails chat runs (which have no DBTaskResult, so the
  task-result sync can never reconcile one whose streamer finally never
  ran) once their session heartbeat goes stale — the same staleness signal
  SessionLock uses to declare a holder dead — so a genuinely long-running
  chat turn is never reaped. A direct .update() (no run_finished emit)
  mirrors finalize_chat_run: chat runs stay out of the notification /
  memory / dispatch receivers.
- notifications batch payload renders the human-readable status via
  RunStatus(agg_status).label instead of the raw enum value.
- webhook callbacks log "Failed to create run ..." (was "activity").
_run_timeline.html included _status_pill.html without the pill's required
variant/label args, so every run's timeline pill rendered with no color and
no status text. Pass variant=run.status|status_variant and label=run.status,
matching the session list page.
Fill 705 previously untranslated or fuzzy msgstr entries across 12
Django apps' Portuguese catalogs (European Portuguese), keeping
terminology consistent with the existing glossary and preserving all
format placeholders, HTML markup, and product/code names.
Follow-up refinements to the Activity/ChatThread → Sessions unification
(callbacks, signals, views, managers, MCP, email templates) together with fixes
from a code review of the change:

- streaming: detect the agent's RUN_ERROR event (ag_ui surfaces it without
  raising) so an errored chat turn is finalized FAILED with an error_message
  instead of a silent SUCCESSFUL that also fed memory extraction
- notifications: serialize Notification.context via DjangoJSONEncoder so lazy
  translation proxies no longer crash the save — the batch-rollup payload was
  silently failing, so batch notifications were never delivered
- models: add DB CheckConstraints for agent_thinking_level (Session, Run) and
  Run.notify_on, consistent with the existing enum-constraint pattern
- run timeline: render human-readable status labels and non-empty API/MCP
  origin badges instead of raw enum values / empty pills
- sidebar: highlight the Sessions section on the namespaced "Start a run" route
- remove dead code and stale comments, fix a sandbox docs link

Add coverage for the chat-run failure paths, the notification batch rollup +
fanout suite (restoring what the rename dropped), the new enum constraints,
RunManager.by_owner, the API auth/ownership gates, and context serialization.
Reconcile main's per-user repository authorization (#1365, #1366) with this
branch's activity+chat -> sessions unification.

Conflict resolution highlights:
- Ported the authorization wiring into the sessions app: assert_can_run in
  sessions/forms.py, aassert_can_run in sessions/services.py, chat/jobs/mcp
  submission gates, and the RepositoryAccessDenied handling in sessions views.
- Split the run/session manager: by_owner stays ownership-only (async-safe,
  used by thread-continuation lookups in chat/jobs/mcp/sessions APIs) and a new
  visible_to adds the repo-read visibility clause for the sync viewing surfaces
  (list/detail/download/stream, dashboard, nav badge), matching main's own
  ownership-vs-visibility split. The stream view wraps visible_to in
  sync_to_async since identity resolution does a sync DB read.
- Repointed the conftest allow-all authorization fixture to sessions.* targets
  (activity.services/forms were removed) and adapted the auto-merged
  authorization tests that still referenced the deleted Activity model to
  Run/Session (context-processors, schedules dispatch, batch-submit authz).
- Kept the retired activity app as a migration-only stub; removed the leftover
  activity views/templates/tests carried in by main.

Verified: make test (3549 passed), ruff check/format, makemigrations --check.
…fill

The 0002 backfill copied text columns verbatim from historical Activity/ChatThread
rows into Session/Run, whose text columns (repo_id, ref, title, prompt, agent_model,
external_username, ...) are NOT NULL. Legacy rows can carry NULL on any of them (the
columns pre-date their NOT NULL / default="" tightening on some deployments) — both a
NULL repo_id and a NULL ref were hit in production — aborting the whole atomic migration
with an IntegrityError.

Coalesce NULL -> "" on every non-nullable text column via model introspection
(``_blank_null_text``) for the activity Session/Run copy, and inline on the chat
get_or_create defaults. Discriminator columns (origin/trigger_type/status) are
deliberately excluded — a NULL there is corruption, and "" would violate their enum
check constraints. Regression tests drive run_backfill with stubbed Activity/ChatThread
iterators yielding NULL-text rows (SQLite enforces NOT NULL on the historical tables, so
such rows can't be seeded directly).
…of schema drift

activity.0016 / chat.0004 dropped the source tables by first issuing an individual
RemoveIndex/RemoveConstraint per object and then DeleteModel. On deployments whose
tables drifted from migration history — e.g. the activity_thread_id_nonempty check
constraint (added in activity.0009) that was never actually created in production —
RemoveConstraint aborts the migration with "constraint does not exist".

Since both migrations drop the table wholesale, wrap the operations in
SeparateDatabaseAndState: the database side is now a single DeleteModel (DROP TABLE
CASCADE on PostgreSQL), which removes every index/constraint at once regardless of
which ones are actually present, while the state side keeps the exact original
operations so Django's migration bookkeeping and reversibility are unchanged.

End state and behavior on a clean database are identical (verified: full suite +
makemigrations --check green; backfill tests exercise both directions on a fresh DB).
- _run_timeline.html applied the duration filter to the Run object
  instead of its duration property, raising a TypeError whenever a
  session had a finished run.
- session_list.html dropped the stretched-link anchor during the
  sessions unification, leaving rows styled as clickable (hover, chevron)
  but not actually linking to session_detail.
srtab added 2 commits July 9, 2026 11:20
Post-review cleanup of the sessions list/filter UI:

- Drive the Time presets from a `ranges` context var sourced from
  RANGE_CHOICES (matching the origins/statuses loop pattern) and replace
  the int(token.rstrip("d")) parse with a _RANGE_WINDOWS map, so the
  presets/window sizes live in one place instead of three.
- Narrow the runs prefetch with .only(...) so list rows skip the fat
  prompt/result_summary/error_message/usage_by_model columns; verified no
  per-run deferred-field query.
- Add a clearable repository filter chip for the deep-link-only ?repo
  param (previously filtered invisibly), with pt-PT translation.
- Remove the dead setRange() JS helper and tighten now-stale comments.
- Expand tests: day_bucket None/edge windows, session_cost mixed null
  costs, filter_range 2d/30d + inclusive boundary, filter_q dual-column
  match; narrow the brittle day-group header assertion.
srtab added 23 commits July 9, 2026 15:08
The detail view suppressed the "expired" state whenever any run was
non-terminal, so a crashed or orphaned background run (stuck in
QUEUED/READY/RUNNING) pinned the session on a permanent "working"
spinner with no error surfaced. Gate the suppression on heartbeat
freshness: a holder that hasn't bumped last_active_at within
STALE_RUN_MINUTES is treated as dead, so the session falls through to
the expired banner.

Extract the shared staleness threshold into sessions.locks.stale_cutoff()
so the detail view, SessionLock takeover, and sync_stuck_runs compute the
window one way.

Add regression tests: a stale in-flight run renders as expired, and the
split-out session_new_chat route stays login-gated.
- Show the repository on session rows and drop the run-count pill;
  add a .session-repo style for it.
- Rename the SUCCESSFUL run status label "Successful" -> "Done"
  (model, migration 0005, session-stream.js, pt catalog).
- Read `breadcrumbs` directly in accounts/_breadcrumb.html and drop the
  redundant `with crumbs=breadcrumbs` from every include site.
- Harden session-list view tests: anchor the New CTA assertion and
  cover the repository row.
Butt folded untracked-file diff sections directly together with a single
newline boundary instead of prefixing each with "\n", matching what one
git diff invocation emits. A blank line after a hunkless section (an
empty-file addition with no @@) made unidiff abort the whole parse, which
silently bypassed redact_diff_content's omit-pattern redaction and leaked
excluded file content to the diff-to-metadata model.

Add regression tests covering the empty-then-real fold ordering, the
empty-base leading-newline case, and redaction alongside a hunkless
empty-file section.
Run sync_stuck_runs every 5 minutes as a crash-recovery backstop instead
of relying on manual operator invocation. The new cron task reuses the
shared locked_task guard (non-blocking) so an overrunning pass skips the
tick rather than double-dispatching.

Switch the command's output from stdout to the daiv.sessions logger so it
is observable when run headless via the cron task, and include the failed
run ids in the error summary so the failed DBTaskResult is self-contained.
The command still raises CommandError on per-row failures, which fails the
task's DBTaskResult and surfaces errors to monitoring.
Fold the enum check constraints (0003, 0004) and the run status label
change (0005) into 0001_initial, since none of these were applied on
prod. The 0002 data backfill is kept as-is so the cross-app dependency
anchors in activity/0016, chat/0004 and memory/0002 stay valid.
Robustness and cleanup on the client-driven sessions listing:

- Guard overlapping swaps with a monotonic token, commit the URL to
  history only after a real swap fires, and toast on a failed swap so
  the address bar never describes results the user isn't seeing.
- Preserve modifier/middle clicks on swap-marked pagination links.
- Derive the Type/Time button labels from the rendered menu instead of
  storing them, dropping the label args and the escapejs threading.
- Extract a shared core.utils.is_htmx() and reuse it in both sessions
  and sandbox_envs; skip the schedule-name query on the HTMX fragment
  path (the filter-bar chip only renders on the full page).
- Warn instead of silently degrading when the SSE re-arm can't find its
  in-flight marker or a filter value has no menu item.
- Expand list-view tests for the fragment path and pagination markers.
Replace the per-turn error hover popup (which rendered the raw
traceback into the page) with a boolean `errored` flag that only
drives the icon + red border; the message stays developer-only in
the logs. Drop the now-redundant `_run_timeline.html` partial, since
per-turn errors already surface run failures.

Also persist the resolved agent model/thinking level onto Run +
Session right after resolution in `run_job_task`, so the locked
"Auto" pill on session detail reflects what actually ran instead of
the requested-override placeholder.
@srtab
srtab merged commit 2e25f19 into main Jul 10, 2026
8 checks passed
@srtab
srtab deleted the claude/determined-maxwell-3d7694 branch July 10, 2026 16:47
srtab added a commit that referenced this pull request Jul 10, 2026
Revising the merge-conflict resolution: main's #1374 is the reviewed
successor of this branch's own sessions foundation, so the session-UI
files should match main exactly and PR #1378 should add only delegate_jobs
(the DELEGATED_JOB origin flows into the filter UI automatically via
SessionOrigin.choices — no bespoke UI needed).

Reverts the two hand-merge grafts that re-introduced branch-only UI main's
review had dropped:
- run-timeline rail + _run_timeline.html partial (deleted)
- the "N runs" pill in _session_row.html
- the 3 run-timeline tests spliced into test_views_detail.py

_session_row.html, session_detail.html and test_views_detail.py are now
identical to origin/main.
srtab added a commit that referenced this pull request Jul 10, 2026
Reconcile the parallel sessions-app fork (main's PR #1374 vs this branch):

- Keep this branch's RunRelay/EventSource run streaming; drop the
  superseded session_status/thread_status probe endpoints.
- Integrate main's _persist_resolved_agent (records the resolved model
  on Run/Session) plus the "Auto" locked-pill fallback.
- Adopt main's failed-run design: icon + red border only, with no raw
  error text on the turn, fallback banner, or run timeline.
- Remove the run timeline entirely, matching main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant