Skip to content

[fix] Fill session titles server-side and persist complete workflow references - #5991

Merged
mmabrouk merged 1 commit into
release/v0.112.1from
fix/sessions-headless-title-and-references
Aug 12, 2026
Merged

[fix] Fill session titles server-side and persist complete workflow references#5991
mmabrouk merged 1 commit into
release/v0.112.1from
fix/sessions-headless-title-and-references

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Aug 12, 2026

Copy link
Copy Markdown
Member

Context

What the user sees. The session list in the OSS deployment shows thousands of rows named "Untitled". Most of those rows do nothing when you click them.

Why it happens. There are two causes. We wrote them up in docs/design/untitled-sessions-investigation/findings.md, which lands with the top PR of this stack.

Cause 1: only the browser writes session titles. A run that no browser renders never gets a title. That covers test runs, evaluations, scheduled triggers and direct API calls. We audited one database. It held 7,948 sessions, and 7,836 of them had no title. Not one of those came from the UI.

Cause 2: headless runs save an incomplete reference set. A "reference" is a pointer to a stored entity, such as {"id": "..."}. A run has three of them: the workflow, its variant and its revision. This PR calls those three the "family". The API function _ensure_request_revision puts the resolved revision directly into the request body. The SDK sees the revision, so it skips its own reference lookup. That lookup is the only step that attaches the sibling workflow and workflow_revision references. Without them the frontend has no workflow id, so it cannot build a link for the row.

Changes

1. The server fills the title, one time only

The heartbeat is the periodic call the runner makes to the API to say a run is still alive. The heartbeat request now takes an optional name. The browser send path (_start_turn) sends a name too.

Both writers set the name only while the stored name is NULL. This PR calls that "fill once". A rename overwrites the name, so a rename always wins. The browser auto-title also overwrites, so it wins too. The fill never overwrites anything.

2. The API writes the resolved references back onto the request

_ensure_request_revision now copies the references it resolved back onto the request. The turn then records the whole family instead of only what the caller sent.

Look at the references a test_run request ends up storing.

Before:

[{"id": "<variant-id>"}]

After:

[{"id": "<workflow-id>", "slug": "my-agent", "key": "workflow"},
 {"id": "<variant-id>", "slug": "my-agent.default", "key": "workflow_variant"},
 {"id": "<revision-id>", "version": "3", "key": "workflow_revision"}]

The caller's own values always win. The write-back only fills the fields the caller left out. It refuses to merge when the caller's id or slug names a different entity than the one the lookup found.

3. The session row keeps its own copy of the references

A turn append is fire and forget. The API does not wait for it, so it can be lost. Until now the references rode only on that append.

Migration oss000000021 adds a references column to session_streams. The column is JSONB and nullable. The migration also adds a GIN index that uses jsonb_path_ops.

The heartbeat fills the column once. The session list reads the column first, and falls back to the latest turn's references when the column is empty. The reference filter searches both columns and joins the two result sets. Each of the two queries sorts by last activity, so the cap keeps the newest rows.

A session whose turn append was dropped is no longer stuck. It opens, and agent-scoped queries still find it.

4. Each reference says which family member it is

A reference element now accepts an optional key. The value is workflow, workflow_variant or workflow_revision. Evaluation-run references and tracing attributes already use this same convention.

We store key permissively. An unknown value is kept, never rejected. A strict check would drop the whole turn append, and a dropped append is the failure this field exists to prevent. Containment filters leave key out, so rows written before the tag still match.

Deploy ordering (read this before you deploy)

What breaks. The new API code needs the new column. Against a database without the migration, three things fail: /sessions/query, the heartbeat and _start_turn. The playground cannot start a turn.

When it breaks. Compose deploys are safe, because the api service waits for the alembic job. Two other cases are real risks:

  1. A dev box hot-reloads this code but never reruns alembic. Note that run.sh --recreate api does not rerun it.
  2. A rolling deploy puts the API image live before the migration runs.

What to do. Run the migration first. Start the new API code after it finishes.

Tests

  • 2,683 unit tests pass (oss plus ee). ruff reports nothing.
  • 41 integration tests ran against a throwaway postgres:16. The full migration chain ran from an empty database. We read the query plan and confirmed it uses the new GIN index for the containment check. The suite skips itself when Postgres is unreachable.
  • Live QA on a standalone OSS dev stack:
    • A headless test_run created a session. Its title was 60 code points long. created_by_id was NULL. The complete keyed family was on both the stream row and the turn.
    • The list returned that session even though it had zero turns.
    • Running the invoke again did not change the title. Fill-once held.
    • A rename stuck.
    • A first message with only an image wrote NULL, not an empty string.

Related issue

Related: #5110 (AGE-3915). The write-back records the resolved revision on the session references when the run fires. That covers most of the audit-trail ask in that issue. But the trigger delivery row's result field still has no resolved revision, because this PR does not touch the dispatcher. So this PR does not close #5110.

Stack

This is the bottom PR of three.

  1. This PR: the API.
  2. Next: the runner lane. It adds typed references and the name proposal.
  3. Top: the web lane. It makes each list show only rows it can open.

@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Aug 12, 2026
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Error Error Aug 12, 2026 7:56pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: a37f9b97-bde2-4c33-a935-7357cfb45882

📥 Commits

Reviewing files that changed from the base of the PR and between c361a2a and c5f0362.

📒 Files selected for processing (1)
  • api/oss/src/core/sessions/service.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • api/oss/src/core/sessions/service.py

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added session references for workflows, variants, and revisions.
    • Session streams now retain references and automatically fill missing names or references without overwriting edits.
    • Added reference-based session filtering across stream and turn data, with recent-session ordering.
    • Session names can be suggested from browser activity and refined through heartbeats.
    • Improved workflow reference resolution by enriching incomplete request details while preserving supplied values.
  • Bug Fixes

    • Improved handling of legacy and untagged references during storage, lookup, and filtering.

Walkthrough

The change adds typed session references, stream-level JSONB storage, fill-once session metadata, combined stream and turn reference filtering, and workflow reference enrichment.

Changes

Session references and metadata

Layer / File(s) Summary
Session reference contracts and serialization
api/oss/src/core/sessions/types.py, api/oss/src/core/sessions/*/dtos.py, api/oss/src/apis/fastapi/sessions/models.py, api/oss/src/dbs/postgres/sessions/references.py, api/oss/src/dbs/postgres/sessions/*/mappings.py
Added SessionReference and ReferenceKey. Updated session APIs and DTOs. Added shared JSON serialization and containment helpers.
Stream metadata persistence and fill-once updates
api/oss/databases/postgres/migrations/core_oss/versions/..., api/oss/src/core/sessions/streams/*, api/oss/src/dbs/postgres/sessions/streams/*, api/oss/tests/pytest/unit/sessions/test_stream_*
Added nullable JSONB stream references, a GIN index, guarded fill-once updates, heartbeat metadata proposals, and derived session names.
Combined session reference querying
api/oss/src/core/sessions/service.py, api/oss/src/dbs/postgres/sessions/turns/*, api/oss/src/dbs/postgres/sessions/streams/dao.py, api/oss/tests/pytest/unit/sessions/test_query_*, api/oss/tests/pytest/unit/sessions/test_sessions_*
Session listing prefers stream references and falls back to the latest turn. Reference filters combine turn and stream matches, deduplicate IDs, and apply a 500-ID cap.
Workflow reference resolution and write-back
api/oss/src/core/workflows/service.py, api/oss/tests/pytest/unit/workflows/test_ensure_request_revision_references.py
Workflow revision resolution preserves the caller’s reference family and enriches missing fields without replacing caller-supplied identities.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SessionHeartbeatRequest
  participant SessionStreamsService
  participant SessionStreamsDAO
  participant PostgreSQL
  SessionHeartbeatRequest->>SessionStreamsService: submit metadata proposals
  SessionStreamsService->>SessionStreamsDAO: create stream or fill missing fields
  SessionStreamsDAO->>PostgreSQL: execute guarded JSONB and name update
  PostgreSQL-->>SessionStreamsDAO: return update result
Loading
sequenceDiagram
  participant SessionQueryService
  participant SessionTurnsDAO
  participant SessionStreamsDAO
  SessionQueryService->>SessionTurnsDAO: query turn reference matches
  SessionQueryService->>SessionStreamsDAO: query stream reference matches
  SessionTurnsDAO-->>SessionQueryService: return session IDs
  SessionStreamsDAO-->>SessionQueryService: return session IDs
  SessionQueryService->>SessionQueryService: union, deduplicate, and cap results
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR does not update trigger delivery results with the resolved revision ID and version required by [#5110]. Update the dispatcher to write the resolved revision ID and version into the trigger delivery result, or link this PR to the appropriate session-metadata issue.
Out of Scope Changes check ⚠️ Warning Most changes implement session titles and session-level references, which are outside the trigger-delivery audit requirement in [#5110]. Limit this PR to the dispatcher delivery-result change, or associate the session metadata work with a separate issue.
Docstring Coverage ⚠️ Warning Docstring coverage is 22.67% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main session-title and workflow-reference changes.
Description check ✅ Passed The description directly explains the session-title, reference-persistence, migration, deployment, and testing changes.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sessions-headless-title-and-references

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
api/oss/src/core/workflows/service.py (1)

929-951: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

_enrich_reference can leave a raw dict in request.references; add type annotations.

When the identity guard trips, the function returns existing unchanged. If the caller sent that entry as a raw dict, the entry stays a dict while sibling entries are Reference instances. Pydantic does not revalidate on the request.references = references assignment unless validate_assignment=True, so a downstream consumer that reads ref.id on that entry fails. Normalizing the returned value to Reference keeps one shape for all entries and also makes the signature typable.

♻️ Proposed normalization and annotations
-    `@staticmethod`
-    def _enrich_reference(*, existing, resolved: Reference):
+    `@staticmethod`
+    def _enrich_reference(
+        *,
+        existing: Union[Reference, dict],
+        resolved: Reference,
+    ) -> Reference:
         """Fill the fields the caller omitted; keep every field they set.
 
         Refuses to merge when the two disagree on an identity the caller pinned — the id
         or the slug — because either mismatch means they name different entities, and the
         merge would produce one reference carrying the caller's id with another entity's
         slug, or vice versa.
         """
         current = (
             {key: value for key, value in existing.items() if value is not None}
             if isinstance(existing, dict)
             else existing.model_dump(exclude_none=True)
         )
 
         for field in ("id", "slug"):
             mine, theirs = current.get(field), getattr(resolved, field)
             if mine is not None and theirs is not None and str(mine) != str(theirs):
-                return existing
+                return Reference(**current)
 
         merged = resolved.model_dump(exclude_none=True)
         merged.update(current)
         return Reference(**merged)

Note: the test at line 171 asserts request.references["workflow_variant"].slug is None on the conflict path, which the normalized return still satisfies.

api/oss/tests/pytest/unit/workflows/test_ensure_request_revision_references.py (1)

132-148: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a dict caller reference on the conflict path.

This test covers the raw dict shape only when the merge succeeds. The conflict path in _enrich_reference returns existing unchanged, so a raw dict entry survives as a dict while sibling entries are Reference instances. A test for that combination pins the shape that downstream consumers read.

💚 Proposed additional test
`@pytest.mark.asyncio`
async def test_a_raw_dict_reference_naming_a_different_entity_keeps_one_shape():
    caller_variant_id = uuid4()
    service = _StubWorkflowsService(
        revision=_revision(),
        retrieval_info=RetrievalInfo(
            references={
                "workflow_variant": Reference(id=uuid4(), slug="some-other-variant")
            }
        ),
    )
    request = WorkflowServiceRequest(
        references={"workflow_variant": {"id": caller_variant_id}}
    )

    await service._ensure_request_revision(project_id=uuid4(), request=request)

    reference = request.references["workflow_variant"]
    assert isinstance(reference, Reference)
    assert reference.id == caller_variant_id
    assert reference.slug is None

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 593ab72d-7a72-42c6-829c-0fa8534be01f

📥 Commits

Reviewing files that changed from the base of the PR and between ab084b5 and deb3f69.

📒 Files selected for processing (27)
  • api/oss/databases/postgres/migrations/core_oss/versions/oss000000021_add_session_streams_references.py
  • api/oss/src/apis/fastapi/sessions/models.py
  • api/oss/src/core/sessions/dtos.py
  • api/oss/src/core/sessions/service.py
  • api/oss/src/core/sessions/streams/dtos.py
  • api/oss/src/core/sessions/streams/interfaces.py
  • api/oss/src/core/sessions/streams/service.py
  • api/oss/src/core/sessions/turns/dtos.py
  • api/oss/src/core/sessions/types.py
  • api/oss/src/core/workflows/service.py
  • api/oss/src/dbs/postgres/sessions/references.py
  • api/oss/src/dbs/postgres/sessions/streams/dao.py
  • api/oss/src/dbs/postgres/sessions/streams/dbes.py
  • api/oss/src/dbs/postgres/sessions/streams/mappings.py
  • api/oss/src/dbs/postgres/sessions/turns/dao.py
  • api/oss/src/dbs/postgres/sessions/turns/mappings.py
  • api/oss/src/dbs/postgres/sessions/turns/utils.py
  • api/oss/tests/pytest/unit/sessions/test_query_sessions_filters.py
  • api/oss/tests/pytest/unit/sessions/test_query_sessions_references.py
  • api/oss/tests/pytest/unit/sessions/test_sessions_query_contract.py
  • api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py
  • api/oss/tests/pytest/unit/sessions/test_stream_fill_missing_postgres.py
  • api/oss/tests/pytest/unit/sessions/test_stream_fill_once.py
  • api/oss/tests/pytest/unit/sessions/test_stream_references_storage.py
  • api/oss/tests/pytest/unit/sessions/test_turns_dao.py
  • api/oss/tests/pytest/unit/sessions/test_wp5_root_router.py
  • api/oss/tests/pytest/unit/workflows/test_ensure_request_revision_references.py

Comment thread api/oss/src/core/sessions/dtos.py
Comment thread api/oss/src/core/sessions/service.py
Comment on lines +320 to +323
if len(from_references) > TURN_REFERENCES_SESSION_ID_CAP:
from_references = set(
sorted(from_references)[:TURN_REFERENCES_SESSION_ID_CAP]
)

@coderabbitai coderabbitai Bot Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve activity order when applying the combined cap.

Lines 320-323 sort session_id, not activity. If more than 500 sessions match, this can discard newer sessions returned by either DAO. The session list then omits sessions that should appear in a newest-first result.

Carry a shared activity rank from both DAO queries, or perform the union and ranking in one database query before applying the cap.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both queries already order newest-first before their own cap, so each side contributes its most recently active matches rather than an arbitrary slice. What stays approximate is only the final trim when the two sides together exceed the cap, because the lists arrive without timestamps to merge on. That can only bite a project with more than 500 sessions matching a single reference, in a list that is windowed anyway. We recorded this as a known residual, with the exact fix (carry an activity rank through both queries, or do the union and ranking in one query), in the comment where the cap is applied in _resolve_session_ids. Worth noting the previous behavior here was weaker: the turns-only query had no ORDER BY at all, so it returned an arbitrary 500.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mmabrouk, thanks for the clarification. The two source queries preserve activity order before their individual caps. The remaining concern is limited to the combined cap, where sorted(from_references) does not preserve activity order.

The inline comment documents that limitation and the required fix. The finding remains technically valid only for projects where the combined reference result exceeds 500. If this residual behavior is accepted for this PR, please confirm whether you want to track the ranked merge as follow-up work.

🐇🔎


✏️ Learnings added
Learnt from: mmabrouk
Repo: Agenta-AI/agenta PR: 5991
File: api/oss/src/core/sessions/service.py:320-323
Timestamp: 2026-08-12T19:37:13.564Z
Learning: In `api/oss/src/core/sessions/service.py`, `SessionsService._resolve_session_ids` queries turn and stream reference matches independently in newest-first order with a 500-item cap. If their union exceeds 500, the current final trim sorts by `session_id`, so it does not preserve a shared activity order. The code documents this as a known residual; a complete fix requires activity ranks from both DAO queries or one database query that unions and ranks before applying the cap.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Comment thread api/oss/src/core/sessions/streams/service.py
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Status Destroyed (PR closed)

Updated at 2026-08-12T20:09:27.850Z

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline notes to explain the intent behind each part of this change. They are explanations for the reviewer, not change requests.

["references"],
postgresql_using="gin",
postgresql_ops={"references": "jsonb_path_ops"},
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why a new column: until now the references lived only on session_turns, and the API appends that turn row fire and forget. If the append is lost, the session row itself says nothing about what it runs, so the list has no id to open it with. This column gives the row its own copy.

Why the index: the reference filter now searches this column with the @> containment operator. Without a GIN jsonb_path_ops index that query falls back to a sequential scan. session_turns.references is indexed the same way.

)
result = await session.execute(stmt)
await session.commit()
return bool(result.rowcount)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the "fill once" write. It sets name or references only where the row still holds NULL.

The guard lives in SQL on purpose. A read-then-write would leave a gap between the read and the write. A rename landing in that gap would be silently overwritten by the next heartbeat proposal, which is the one thing fill-once must never do. One COALESCEd UPDATE closes the gap.

updated_at is deliberately not bumped. The flag mirror owns that column, and filling a title is not activity.

.limit(limit)
)
result = await session.execute(stmt)
return [row[0] for row in result.all()]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The twin of the turns query, over the new column. The two results get unioned rather than one replacing the other: a session whose turn append was dropped is findable only here, and a session written before this column existed is findable only through its turns.

The ORDER BY matters because of the cap. It sorts by last activity, the same order the list itself uses, so a capped filter keeps the rows a user would see first.

)
.distinct()
.group_by(SessionTurnDBE.session_id)
.order_by(func.max(SessionTurnDBE.start_time).desc().nullslast())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DISTINCT became GROUP BY so this query can order by each session's newest turn. Same reason as its twin above: the result is capped, and an arbitrary slice of the matches is worse than the most recent ones.

turn_id: Optional[str] = None # the current TURN (proves alive-lock ownership)
is_running: bool = True
name: Optional[str] = None
references: Optional[List[SessionReference]] = None

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two new heartbeat fields. A heartbeat is the periodic call the runner makes to say a run is still alive.

name and references are proposals, not edits. The service writes each one only onto a NULL column. The runner is the only component on every execution path, whether the run comes from the browser, a headless invoke or a scheduled trigger. So it is the only component that can title and attribute a session that no browser will ever render.

)
)

request.references = references

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the write-back, and it is the fix for the dead-route half of the bug.

Embedding the revision into request.data.revision (just above) is what makes the SDK skip its reference hydration. That hydration was the only step that added the sibling workflow and revision references. So a caller that sent one variant reference, which is exactly what test_run does, produced a turn with a single reference and a session the UI could not open.

Only the caller's own family gets written. A key the caller left empty is added outright. A key they did supply is enriched, never replaced.


merged = resolved.model_dump(exclude_none=True)
merged.update(current)
return Reference(**merged)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The merge rule. Every field the caller set is kept. Only the fields they omitted get filled from the resolution.

The refusal above is the important part. If the caller pinned an id or a slug and the resolution found a different one, the two name different entities. Merging them would produce one reference holding the caller's id next to another entity's slug. In that case the caller's reference is returned unchanged.

Why enrich at all: a caller who sends a bare variant id would otherwise store a variant element with no slug, while the SDK path stores one with a slug. That is the same session described two ways depending on which producer wrote it.

# is rather than making every caller change type.
if isinstance(value, Reference):
return value.model_dump()
return value

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

key says which member of the workflow family an element points at. Sessions store references as a flat list, so the map key that named the family upstream is gone by the time a reader sees the row. Without key, "first UUID in the list" is the only way to guess which element is the workflow.

The field is a plain string, not the ReferenceKey enum, on purpose. A turn append is fire and forget. Rejecting an unknown family name would drop the whole turn, which is the exact failure this field exists to prevent. Producers inside the API use the enum; readers treat anything else as untyped.

The model_validator accepts a plain Reference so existing producers do not all have to change type.

# The row's own references first: they are written by the beat, which
# every run makes, whereas the turn append is fire-and-forget. The turn
# is the fallback that keeps pre-column rows openable.
references=stream.references or (turn.references if turn else None),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read preference for the list: the stream row's own references first, the latest turn's as the fallback.

The row wins because the heartbeat writes it, and every run beats. The turn append does not have that guarantee. The fallback is what keeps rows written before this column existed openable.

if len(from_references) > TURN_REFERENCES_SESSION_ID_CAP:
from_references = set(
sorted(from_references)[:TURN_REFERENCES_SESSION_ID_CAP]
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter unions both reference columns, then re-caps the union.

The union is needed because each column covers a case the other misses: a dropped turn append leaves only the stream row, and an old session has only turns. Matching either is what makes an agent-scoped list agree with what the list can actually open.

The re-cap is there because each side is capped on its own, so the union could carry twice the intended bound. Each side already returns its most recently active matches. Which of them survives this final trim is arbitrary, since the two lists carry no timestamp to merge on. It only bites a project with more matching sessions than the cap, where the list is windowed anyway.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mmabrouk mmabrouk added the lgtm This PR has been approved by a maintainer label Aug 12, 2026
@mmabrouk
mmabrouk force-pushed the fix/sessions-headless-title-and-references branch from deb3f69 to c361a2a Compare August 12, 2026 19:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: d342cac2-5c0b-4b91-86e8-5921aa975913

📥 Commits

Reviewing files that changed from the base of the PR and between deb3f69 and c361a2a.

📒 Files selected for processing (3)
  • api/oss/src/dbs/postgres/sessions/streams/dao.py
  • api/oss/tests/pytest/unit/sessions/test_stream_fill_once.py
  • api/oss/tests/pytest/unit/sessions/test_stream_references_storage.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • api/oss/tests/pytest/unit/sessions/test_stream_references_storage.py

Comment on lines +518 to +552
async def query_session_ids_by_references(
self,
*,
project_id: UUID,
references: List[SessionReference],
limit: int,
) -> List[str]:
"""Sessions whose OWN references satisfy the filter — the turns query's twin.

Unioned with the turns result rather than replacing it: a session whose turn
append was dropped is findable only through this column, and one that predates
the column only through the turns.
"""
containment = references_containment_json(references)
if containment is None:
return []
async with self.engine.session() as session:
# No DISTINCT needed — (project_id, session_id) is unique here — which is what
# lets the cap order by last activity, the same expression the list itself
# sorts by, so a capped filter keeps the rows a user would see first.
stmt = (
select(SessionStreamDBE.session_id)
.where(
SessionStreamDBE.project_id == project_id,
SessionStreamDBE.references.contains(containment),
)
.order_by(
func.coalesce(
SessionStreamDBE.updated_at, SessionStreamDBE.created_at
).desc()
)
.limit(limit)
)
result = await session.execute(stmt)
return [row[0] for row in result.all()]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the same tie-breaker as the session list.

query() orders equal activity timestamps by SessionStreamDBE.id.desc() at Lines 430-435. This capped query does not. When timestamps tie, PostgreSQL can select arbitrary rows before limit, and the reference filter can omit rows that the session list would rank first.

Proposed fix
                 .order_by(
                     func.coalesce(
                         SessionStreamDBE.updated_at, SessionStreamDBE.created_at
-                    ).desc()
+                    ).desc(),
+                    SessionStreamDBE.id.desc(),
                 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def query_session_ids_by_references(
self,
*,
project_id: UUID,
references: List[SessionReference],
limit: int,
) -> List[str]:
"""Sessions whose OWN references satisfy the filterthe turns query's twin.
Unioned with the turns result rather than replacing it: a session whose turn
append was dropped is findable only through this column, and one that predates
the column only through the turns.
"""
containment = references_containment_json(references)
if containment is None:
return []
async with self.engine.session() as session:
# No DISTINCT needed — (project_id, session_id) is unique here — which is what
# lets the cap order by last activity, the same expression the list itself
# sorts by, so a capped filter keeps the rows a user would see first.
stmt = (
select(SessionStreamDBE.session_id)
.where(
SessionStreamDBE.project_id == project_id,
SessionStreamDBE.references.contains(containment),
)
.order_by(
func.coalesce(
SessionStreamDBE.updated_at, SessionStreamDBE.created_at
).desc()
)
.limit(limit)
)
result = await session.execute(stmt)
return [row[0] for row in result.all()]
async def query_session_ids_by_references(
self,
*,
project_id: UUID,
references: List[SessionReference],
limit: int,
) -> List[str]:
"""Sessions whose OWN references satisfy the filterthe turns query's twin.
Unioned with the turns result rather than replacing it: a session whose turn
append was dropped is findable only through this column, and one that
predates the column only through the turns.
"""
containment = references_containment_json(references)
if containment is None:
return []
async with self.engine.session() as session:
# No DISTINCT needed — (project_id, session_id) is unique here — which is what
# lets the cap order by last activity, the same expression the list itself
# sorts by, so a capped filter keeps the rows a user would see first.
stmt = (
select(SessionStreamDBE.session_id)
.where(
SessionStreamDBE.project_id == project_id,
SessionStreamDBE.references.contains(containment),
)
.order_by(
func.coalesce(
SessionStreamDBE.updated_at, SessionStreamDBE.created_at
).desc(),
SessionStreamDBE.id.desc(),
)
.limit(limit)
)
result = await session.execute(stmt)
return [row[0] for row in result.all()]

…low references

The session heartbeat and turn-start now fill session_streams.name once (only when NULL) from the first user message, so runs no browser ever renders stop showing as untitled. _ensure_request_revision writes the resolved workflow/workflow_revision references back onto the request (enriching a bare caller variant with its slug/version, refusing mismatched identities), so headless runs no longer strand sessions with a variant-only reference. New nullable session_streams.references JSONB (migration oss000000021, GIN jsonb_path_ops indexed) is filled once from the heartbeat and preferred by the session list, with the reference-scoped filter unioning both columns. Reference elements accept and persist a 'key' family discriminator (workflow | workflow_variant | workflow_revision), matching the evaluation-runs convention.
@mmabrouk
mmabrouk force-pushed the fix/sessions-headless-title-and-references branch from c361a2a to c5f0362 Compare August 12, 2026 19:56
@mmabrouk
mmabrouk merged commit 7b2a2af into release/v0.112.1 Aug 12, 2026
59 of 64 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend lgtm This PR has been approved by a maintainer python Pull requests that update Python code size:XL This PR changes 500-999 lines, ignoring generated files. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant