[fix] Fill session titles server-side and persist complete workflow references - #5991
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe change adds typed session references, stream-level JSONB storage, fill-once session metadata, combined stream and turn reference filtering, and workflow reference enrichment. ChangesSession references and metadata
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
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
api/oss/src/core/workflows/service.py (1)
929-951: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
_enrich_referencecan leave a raw dict inrequest.references; add type annotations.When the identity guard trips, the function returns
existingunchanged. If the caller sent that entry as a raw dict, the entry stays a dict while sibling entries areReferenceinstances. Pydantic does not revalidate on therequest.references = referencesassignment unlessvalidate_assignment=True, so a downstream consumer that readsref.idon that entry fails. Normalizing the returned value toReferencekeeps 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 Noneon 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 winAdd 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_referencereturnsexistingunchanged, so a raw dict entry survives as a dict while sibling entries areReferenceinstances. 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
📒 Files selected for processing (27)
api/oss/databases/postgres/migrations/core_oss/versions/oss000000021_add_session_streams_references.pyapi/oss/src/apis/fastapi/sessions/models.pyapi/oss/src/core/sessions/dtos.pyapi/oss/src/core/sessions/service.pyapi/oss/src/core/sessions/streams/dtos.pyapi/oss/src/core/sessions/streams/interfaces.pyapi/oss/src/core/sessions/streams/service.pyapi/oss/src/core/sessions/turns/dtos.pyapi/oss/src/core/sessions/types.pyapi/oss/src/core/workflows/service.pyapi/oss/src/dbs/postgres/sessions/references.pyapi/oss/src/dbs/postgres/sessions/streams/dao.pyapi/oss/src/dbs/postgres/sessions/streams/dbes.pyapi/oss/src/dbs/postgres/sessions/streams/mappings.pyapi/oss/src/dbs/postgres/sessions/turns/dao.pyapi/oss/src/dbs/postgres/sessions/turns/mappings.pyapi/oss/src/dbs/postgres/sessions/turns/utils.pyapi/oss/tests/pytest/unit/sessions/test_query_sessions_filters.pyapi/oss/tests/pytest/unit/sessions/test_query_sessions_references.pyapi/oss/tests/pytest/unit/sessions/test_sessions_query_contract.pyapi/oss/tests/pytest/unit/sessions/test_sessions_root_service.pyapi/oss/tests/pytest/unit/sessions/test_stream_fill_missing_postgres.pyapi/oss/tests/pytest/unit/sessions/test_stream_fill_once.pyapi/oss/tests/pytest/unit/sessions/test_stream_references_storage.pyapi/oss/tests/pytest/unit/sessions/test_turns_dao.pyapi/oss/tests/pytest/unit/sessions/test_wp5_root_router.pyapi/oss/tests/pytest/unit/workflows/test_ensure_request_revision_references.py
| if len(from_references) > TURN_REFERENCES_SESSION_ID_CAP: | ||
| from_references = set( | ||
| sorted(from_references)[:TURN_REFERENCES_SESSION_ID_CAP] | ||
| ) |
There was a problem hiding this comment.
🎯 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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.
Railway Preview Environment
Updated at 2026-08-12T20:09:27.850Z |
mmabrouk
left a comment
There was a problem hiding this comment.
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"}, | ||
| ) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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()] |
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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] | ||
| ) |
There was a problem hiding this comment.
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.
deb3f69 to
c361a2a
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
api/oss/src/dbs/postgres/sessions/streams/dao.pyapi/oss/tests/pytest/unit/sessions/test_stream_fill_once.pyapi/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
| 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()] |
There was a problem hiding this comment.
🎯 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.
| 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()] | |
| 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(), | |
| 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.
c361a2a to
c5f0362
Compare
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_revisionputs 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 siblingworkflowandworkflow_revisionreferences. 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_revisionnow 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_runrequest 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
oss000000021adds areferencescolumn tosession_streams. The column is JSONB and nullable. The migration also adds a GIN index that usesjsonb_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 isworkflow,workflow_variantorworkflow_revision. Evaluation-run references and tracing attributes already use this same convention.We store
keypermissively. 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 leavekeyout, 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:
run.sh --recreate apidoes not rerun it.What to do. Run the migration first. Start the new API code after it finishes.
Tests
ossplusee). ruff reports nothing.test_runcreated a session. Its title was 60 code points long.created_by_idwas NULL. The complete keyed family was on both the stream row and the turn.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
resultfield 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.