Skip to content

fix(streaming): default to official astream output, fix duplicated Send parallel updates, restore tool/replay/interrupt - #66

Open
LiangMuYuan wants to merge 32 commits into
ob-labs:developfrom
LiangMuYuan:feature/fix-updates-duplication
Open

fix(streaming): default to official astream output, fix duplicated Send parallel updates, restore tool/replay/interrupt#66
LiangMuYuan wants to merge 32 commits into
ob-labs:developfrom
LiangMuYuan:feature/fix-updates-duplication

Conversation

@LiangMuYuan

@LiangMuYuan LiangMuYuan commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Root cause (#48): develop used astream_events (event stream) to fake the updates stream. On Send-parallel graphs, astream_events emits two layers of events per node 鈥?the node's bare channel value (dict) and the root-level node wrapper (tuple). The run_executor on_chain_stream handler treated both layers as updates and sent them to the client, causing every node's updates to appear twice.

Fix: the default streaming path now uses the official graph.astream(stream_mode=[...]) 鈥?langgraph internally emits exactly one standard {"node_name": {...}} chunk per super-step / stream mode, so updates has a single source of truth and duplication is eliminated at the root. The migration also fixed the regressions it introduced:

  • tool events: restored via the native tools stream mode (tool-started/tool-finished/tool-error, tool_name tracked by tool_call_id)
  • replay endpoint: default GET /runs/{id}/stream now replays thread-level protocol events (values/updates/messages/tools), supports Last-Event-ID resume, and closes with end
  • interrupts: aligned with official 鈥?when updates is not explicitly requested, interrupts are rewritten as values.__interrupt__ (parsable by the official SDK stream()); when updates is requested, the __interrupt__-bearing updates pass through; already-delivered interrupts are not re-emitted
  • protocol command now forwards stream_modes/stream_subgraphs

How it fixes

  • run_executor.py: default path switched to graph.astream(), consuming (mode, chunk) / (ns, mode, chunk) events; shares _handle_stream_mode / _handle_live_message; the events mode keeps the astream_events path
  • interrupts: under _only_interrupt_updates, interrupt-bearing updates are rewritten as values.__interrupt__; explicitly requested updates pass through
  • runs.py: replay endpoint adds thread-level protocol events + saw_interrupt dedup + seq/Last-Event-ID resume

Test changes

Tests were adapted and extended to cover the new execution path:

  • unit (tests/unit/test_run_executor.py): adapted to the official astream path; added updates-no-duplication regression, interrupt-rewritten-to-values.__interrupt__ assertion, interrupt pass-through when updates requested; added coverage for the events mode / subgraphs triples / interrupt merge

  • integration: test_runs_streaming.py tool assertions changed from legacy tool_start/tool_end to protocol tool-started/tool-finished; test_protocol_v2_streaming.py namespace test adapted to stream_mode

  • live e2e (test_live_provider_api.py): store PUT assertion relaxed from ==200 to (200, 204) (official contract is 204); HITL streaming endpoint moved to /stream/events; added real-time messages/partial incremental accumulation end-to-end test (verifies token-by-token streaming that accumulates to the final answer)

  • CI script (scripts/verify_docker_api.py): adapted to the replay endpoint's protocol-event format (tool events 鈫?tool-started/tool_name, end assertions 鈫?.get() to tolerate mixed payloads)

  • reconnect regressions: HTTP-level mid-run disconnect/reconnect exactly-once tests for both executor backends (test_run_stream_midrun_reconnect_is_exactly_once for inline, test_run_stream_midrun_reconnect_is_exactly_once_in_redis for Redis); a protocol run.start invalid-stream-mode → 400 test (test_protocol_run_start_invalid_stream_mode_returns_400); a live-namespace-filter tuple regression (test_thread_protocol_stream_live_filter_rejects_tuple_and_accepts_list_namespace); and a real-HTTP reconnect assertion in scripts/verify_docker_api.py that runs in both the cli-docker and redis-durable CI jobs against the real store

  • ci: verify_docker_api.py --mode full now also asserts run-stream mid-run reconnect exactly-once (real Redis + real SeekDB in redis-durable, real HTTP in both)

Reproduction (real Send-parallel graph, 11 nodes)

Scenario develop fix branch
stream_subgraphs=false two updates per node (duplicated) one updates per node (no dup)
stream_subgraphs=true 12 (root aggregate dropped, info missing) 13 (full node wrappers, includes aggregate)

Notes

This fix started because updates were duplicated, but the root cause was initially unclear and filtering attempts failed, so I switched to the official astream approach for alignment. Only after switching did I discover the old astream_events path has problems with stream_subgraphs both on and off: off duplicates, on drops the root aggregate node. The new astream path works correctly under both switches, resolving the issue as a side effect.

This change is large (run_executor.py overhaul + test refactor), and my understanding of langgraph's streaming internals is limited, so there may be oversights. I'd appreciate careful review of the streaming event formats, protocol v2 compatibility, and whether each channel (messages/updates/tools/values) behaves as expected.

One more note about a pre-existing test blind spot unrelated to this change: three protocol v2 issues (GET /threads/{id}/stream join endpoint missing, /stream/events not supporting subscribe-before-run.start, messages channel mixing in messages/partial) currently exist only in the live-provider e2e, and CI skips those tests because LIVE_PROVIDER_KIND is not configured 鈥?so they have never been covered by CI. These same three issues exist on develop; I'd suggest tracking them separately (or moving the LLM-independent protocol assertions into regular integration tests so CI can cover them). They are out of scope for this PR.

Test Plan

  • tests/unit: 526 passed
  • full tests/unit tests/integration: 794 passed, 5 skipped (coverage 90.45% 鈮?90%)
  • tests/e2e (seekdb): 15 passed
  • live provider e2e: 7 passed (streaming / create-time / store / HITL / MCP / real-time incremental, real model calls)
  • full local CI replica: all 9 job categories and every matrix pass (incl. redis-durable, cli-docker, pgsql-metadata, mysql-family checkpoint 脳 3 backends)

Target Branch Check

  • feature/* PRs target develop

Closes #48

@LiangMuYuan

Copy link
Copy Markdown
Contributor Author

Hi @webup, could you help re-run the CI? The current run failed entirely due to GitHub Actions infrastructure issues, not code:

  • 8 jobs failed at \Failed to resolve action download info. Service Unavailable\ (never reached checkout/code)
  • 5 jobs failed with \The job was not acquired by Runner of type hosted\ (runner never started, no logs)

The jobs that did run all passed (Embedded SeekDB Smoke, Sample Graphs, Redis Durable Execution/seekdb). I've verified the full suite locally including the 90% coverage gate (90.19%), so a re-run should go green. Thanks!

@webup
webup requested review from TBice123123 and webup August 8, 2026 15:49

@webup webup left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the substantial work on this streaming migration—the switch to graph.astream() is a sound direction, and the focused tests and green CI provide good coverage. I found one merge-blocking replay defect: the default run stream now combines two independent sequence domains, so Last-Event-ID no longer represents a monotonic cursor. Please keep all emitted SSE ids in one cursor domain and add a regression showing that reconnecting after the terminal end does not replay previously delivered protocol events.

Comment thread src/agentseek_api/api/runs.py Outdated
@LiangMuYuan

Copy link
Copy Markdown
Contributor Author

Hi @webup, I've addressed the replay defect. The default \GET /runs/{id}/stream\ now assigns every emitted SSE frame a single monotonic cursor (1-based position in the ordered replay set of run lifecycle + thread protocol events + terminal end), so \Last-Event-ID\ is a valid cursor again and reconnecting after the terminal \end\ no longer replays previously delivered protocol events.

Added two regression tests:

  • \ est_run_stream_sse_ids_are_monotonic: all SSE ids strictly increasing and unique
  • \ est_run_stream_resume_after_terminal_end_does_not_replay: resume with the terminal frame's Last-Event-ID returns no frames

Full suite: 788 passed, 5 skipped, coverage 90.18% (>= 90%). Thanks!

@webup webup left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the substantial follow-up and the added regression coverage. The astream() migration is directionally sound, and the duplicate-updates test is valuable. A full-range independent review plus targeted reproductions found that the current head still drops live protocol frames and uses an unstable response-position cursor; explicit events, live subgraph namespace filtering, the canonical provider workflow, invalid stream-mode validation, and the declared minimum LangGraph tool path also regress. Please address the inline findings together and add HTTP-level regressions for inline and Redis reconnects before approval. Hosted CI is green, but the affected paths are not covered by that run.

Comment thread src/agentseek_api/api/runs.py Outdated
# Replay the run's protocol-v2 thread events so the default endpoint
# still returns the full stream (run-scoped stream events are no longer
# published by the astream migration).
try:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Tail protocol events after the initial snapshot

This is the only read of values/updates/messages/tools. After it completes, both executor branches tail only run lifecycle records, even though the astream migration stopped publishing translated run-scoped protocol frames. Connecting while a run is active therefore returns start/end while omitting frames that are persisted later; I reproduced those missing values appearing only on a post-terminal request. Please persist or merge protocol and lifecycle frames into one run-scoped ordered log, tail it live for inline and Redis, and commit end only after earlier frames.

Comment thread src/agentseek_api/api/runs.py Outdated
# ``Last-Event-ID`` skips the already-delivered frames (``idx <=
# after_seq``) and never replays them; live frames after the replay set
# continue numbering from the end of the set.
emit_seq = len(replay_frames)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Use a persisted cursor, not replay-list position

The replay set is mutable during active execution. I reproduced initial IDs start=1,end=2; after a protocol frame became visible, reconnecting with Last-Event-ID: 2 skipped that unseen frame (now position 2) and replayed end as ID 3. Assign the cursor at publication time in one run-scoped sequence, and add disconnect/reconnect mid-run exact-once tests.

if line.startswith("data: ")
]
message_chunks = [
# The default run-stream replay returns the run's persisted protocol

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Keep the canonical provider workflow consistent

This replacement accepts a final values snapshot, but scripts/test-live-provider.sh still runs tests/integration/test_live_provider_streaming.py, which requires multiple default-stream message_chunk frames plus node_start/node_end. This head emits none; a deterministic equivalent fails, and no live-provider workflow run exists for this branch. Either preserve the default wire contract or update the canonical proof and workflow coherently while retaining a real incremental-provider assertion.

run_id=run_id,
)

if _use_astream_events:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Publish the requested events channel

When stream_mode=events, this branch translates selected messages/custom/values side effects but never publishes each raw astream_events() item onto the events channel. At the HTTP boundary, POST /runs/stream with stream_mode: events returns only metadata. Please emit raw events on the requested channel and add an HTTP-level regression.

Comment thread src/agentseek_api/api/streaming.py Outdated
try:
run_kwargs: dict[str, Any] | None = None
if payload.params.get("stream_mode") is not None:
run_kwargs = {"stream_modes": normalize_stream_modes(payload.params.get("stream_mode"))}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Return 400 for invalid stream modes

normalize_stream_modes() raises ValueError, but the shared handler below maps every ValueError to 404 for missing resources. I reproduced an invalid run.start stream mode returning invalid_argument with HTTP 404. Validate stream controls separately and return 400; reserve 404 for unknown assistants or graphs.

graph.astream(invocation, config, **_astream_kwargs)
) as stream:
async for event in stream:
if _astream_kwargs.get("subgraphs"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Normalize live subgraph namespaces

Real astream(..., subgraphs=True) returns tuple namespaces. Passing ns through unchanged stores a tuple in the in-memory broker, whose live filter treats every non-list namespace as root. I reproduced a tuple-namespaced update disappearing when filtered by its real prefix, even though persistence can later mask the problem by JSON-normalizing it to a list. Convert ns to list(ns) before publication and test the live path.



try:
from langgraph.pregel import _tools as _langgraph_tools # noqa: E402

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Honor the declared LangGraph minimum for tools

The project still allows langgraph>=1.0.3, but 1.0.3 has no langgraph.pregel._tools module. This fallback therefore disables the native tools mode, while the old astream_events tool translation has been removed, so allowed installations silently lose tool lifecycle events. Either raise the minimum to a verified version providing this mode or retain a functional fallback and test the minimum supported dependency.

@LiangMuYuan

Copy link
Copy Markdown
Contributor Author

Thanks for the second round. All findings are addressed, each with a regression, including the HTTP-level inline + Redis reconnect tests you asked for.

  • P1 - run stream in one sequence domain / tail protocol frames after snapshot / persisted cursor: protocol frames are appended to the run stream at publication time, sharing one run-scoped monotonic seq with lifecycle records, and GET /runs/{id}/stream reads that single domain. Added test_run_stream_sse_ids_are_monotonic and test_run_stream_resume_after_terminal_end_does_not_replay, plus HTTP-level mid-run disconnect/reconnect exactly-once tests for inline and Redis, and a real-HTTP reconnect assertion in scripts/verify_docker_api.py that runs in both the cli-docker and redis-durable CI jobs. Verified against a real Redis + SeekDB + worker stack (phase1 ids 1,2,3 -> reconnect 4..60, no replay, no loss).
  • P1 - publish the requested events channel: each raw astream_events() item is now published onto the events channel; HTTP-level regression test_create_run_stream_events_mode_emits_raw_astream_events.
  • P1 - normalize live subgraph namespaces: tuples are converted to lists before publication (run_executor.py), with test_execute_run_normalizes_tuple_namespaces_for_live_filter and a live-filter regression test_thread_protocol_stream_live_filter_rejects_tuple_and_accepts_list_namespace.
  • P2 - 400 for invalid stream modes: protocol run.start now returns 400 invalid_argument; regression test_protocol_run_start_invalid_stream_mode_returns_400.
  • P2 - LangGraph minimum: raised to >=1.2.0.
  • P1 - canonical provider workflow: test_live_provider_streaming.py aligned with the replay contract; the real incremental-provider assertion is retained in test_live_provider_realtime_stream_incremental_messages_partial (verified against real DeepSeek).

Full suite: 794 passed, 5 skipped, coverage 90.45% (>= 90%).

@webup webup left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the substantial follow-up. The raw events channel, live namespace normalization, invalid-mode 400 response, LangGraph minimum, and ordinary inline/Redis reconnect coverage are all meaningful improvements, and the exact-head regular CI is green.

A fresh exact-head review found three remaining merge blockers:

  1. Resumed runs still violate the monotonic SSE cursor contract. The default renderer removes every historical end record from sequence order and emits it after newer resume frames. I reproduced IDs 1..9, 11..17, 10, 18. Please emit the persisted run log strictly by sequence and add resumed-run monotonic/reconnect regressions for inline and Redis.

  2. Inline sequence allocation remains process-local. After clearing broker state before resuming the same persisted run, allocation restarted at 1, collided with existing rows, and the stream returned IDs 1..7, 9, 8, 10 with terminal statuses success, interrupted. Please allocate from persistent state, shared by lifecycle and protocol publication, and add a cold-broker resume regression that clears _next_seq as well as event state.

  3. The canonical live-provider proof was weakened from incremental message_chunk assertions to a final values snapshot. The repository contract requires the manual workflow to prove real provider-backed SSE token chunks, and there is still no live-provider-streaming.yml run for this branch. Please restore the token-level assertion and run the manual workflow on the repaired exact head.

Focused streaming/replay verification passed 126 tests; the broad local suite reached 794 passed and 25 skipped, and the two localhost fixtures blocked by the sandbox passed separately. Please address these three findings together before approval.

@LiangMuYuan

Copy link
Copy Markdown
Contributor Author

Thanks for the third round. All three findings are fixed on the current head (9bd73bc), each with a regression. The full CI for that head is green (including coverage >= 90%).

1. Resumed runs now keep the monotonic SSE cursor
The default renderer no longer defers historical end records past newer resume frames; it emits the persisted run log strictly by sequence, so every id stays ascending across a resume (previously 1..9, 11..17, 10, 18). test_resumed_run_stream_preserves_each_terminal_status now asserts strictly monotonic, unique ids.

2. Sequence allocation now comes from persistent state (inline)
next_run_stream_seq (run domain) and next_thread_stream_seq (thread domain) now allocate from the persisted max(seq)+1 instead of a process-local counter, and both brokers clamp their in-memory watermark so a cold broker re-seeded mid-run never reuses an id. Regression: test_run_stream_cold_broker_resume_keeps_monotonic_ids and test_thread_protocol_cold_broker_keeps_monotonic_seq_inline (both clear _next_seq and assert strictly monotonic ids with ordered terminal statuses). Verified by injecting the old behavior back (returning None / process-local) — both tests fail exactly as you reproduced.

3. Token-level live-provider assertion restored
test_live_provider_streaming.py now opens an explicit stream_mode=messages run and asserts real incremental messages/partial frames accumulate to the final answer (>=2 partial frames, no content-block noise), in addition to the default replay snapshot. Passed against real DeepSeek locally.

All three reverted-injection checks reproduce your exact reported failures, and the full suite is 796 passed / 5 skipped at 90.37% coverage on 9bd73bc.

@webup webup left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the substantial follow-up. The strict resumed-run ordering, cold-broker sequence lookup, raw events publication, live namespace normalization, invalid-mode handling, and added regressions are meaningful improvements. I rechecked exact head 9bd73bc1731fd09005c492970bc25847cf6c6144; all 17 regular CI checks are green.

A knowledge-graph blast-radius review plus focused exact-head reproductions found four remaining merge blockers:

  1. [P1] Non-Redis sequence allocation is not atomic. next_run_stream_seq() and next_thread_stream_seq() execute SELECT MAX(seq) + 1 separately from the eventual insert. Concurrent publishers can therefore receive the same sequence; I reproduced [1, 1] for both run and thread domains. The unique constraints then reject one insert, and the persistence helpers swallow that failure, dropping a stream frame. Please use a database-atomic append/allocation shared by lifecycle and protocol publication, and add concurrent-publisher regressions for the supported metadata databases.

  2. [P1] A live cursor can be exposed before it is durable. The inline paths record an event in run_broker / thread_protocol_broker before committing it, while persist_run_stream_event() and persist_thread_stream_event() catch every database exception and return. A client can therefore receive an SSE id that disappears after broker loss or restart and may later be reused. Please make the durable append succeed before exposing the event (or surface/repair the failure), with a failure-injection test that reconnects after clearing broker state.

  3. [P2] Id-less streamed messages collide across nodes/subgraphs. The fallback id is f"{run_id}:message:{message_index}", but message_index restarts for every yielded stream event. A focused reproduction with two id-less chunks from different subgraph namespaces produced only one messages/metadata identity; the second chunk was accumulated into the first message. Please derive a stable identity from the stream/node/namespace context or maintain a per-stream ordinal, and add a two-message regression.

  4. [P1 verification gate] The canonical provider proof is still incomplete. The repository's manual provider workflow is the source of truth for real-provider SSE streaming and its proof target is incremental message_chunk events. The current tests assert messages/partial instead, and there are zero live-provider-streaming.yml runs for this exact SHA. Please make the workflow and asserted wire contract coherent, retain the real incremental-provider assertion, and run the manual workflow on the repaired head.

Please address these together before approval. For the first two items, an atomic append API that allocates and persists the sequence in one database operation would close both the concurrency and restart-consistency gaps more reliably than a split read/publish/write path.

@LiangMuYuan

Copy link
Copy Markdown
Contributor Author

Thanks for the fourth round. All four items are addressed: items 1-3 are fixed and verified, item 4 is aligned on the wire contract with the workflow gate called out as needing a maintainer.

1. [P1] Non-Redis sequence allocation is now atomic.
Added a persistent StreamSequence counter table plus append_run_stream_event_atomic / append_thread_stream_event_atomic: row-lock + allocation + event insert happen in one transaction, with uniqueness-retry on collision. Verified on real MySQL 8.4 / PostgreSQL 16 / SQLite with 12 concurrent run + 12 concurrent thread appends each -> unique, gapless 1..12, zero dropped frames (new scripts/check_atomic_append_concurrency.py, regression test_stream_persistence_atomic.py). This also surfaced and fixed two MySQL-specific pitfalls: the gap-lock deadlock on a missing counter row, and REPEATABLE READ snapshot isolation hiding a just-seeded row.

2. [P1] A live cursor can no longer be exposed before it is durable.
Every inline publish path is now durable-before-expose: the event row (and its seq) is committed atomically first, and only then is it published to the in-memory broker. A failed durable append raises instead of being swallowed, so a client can never receive a seq that was not durably committed. Terminal events are staged in the same transaction as the run status and exposed only after commit. Regression: test_atomic_append_raises_on_db_failure_and_is_not_exposed (failure injection -> event not exposed).

3. [P2] Id-less streamed messages no longer collide across namespaces.
The fallback id changed from {run}:message:{index} (index restarts per yielded event) to {run}:message:{namespace}:{index}, deriving a stable identity from the subgraph namespace. Regression asserts two id-less messages from different namespaces produce two distinct messages/metadata identities; re-injecting the old behavior collapses them into one and the test reproduces exactly the "only one messages/metadata identity" you reported.

4. [P1] Canonical provider proof - wire contract aligned, workflow needs a maintainer.

  • messages/metadata assertions added to test_live_provider_streaming.py: a real provider run must surface metadata identities, the payload shape is {message_id: {"metadata": {...}}}, and the metadata id must match the AI message id accumulated in messages/partial (verified against real DeepSeek).
  • AGENTS.md proof target updated from the legacy message_chunk to the official v1 messages wire contract (messages/partial + messages/metadata/messages/complete). message_chunk does not exist in the official SDK wire format; langgraph_api/stream.py v1 path emits messages/metadata + messages/partial/messages/complete.
  • Running the manual workflow requires a maintainer to configure the OPENAI_COMPAT_MODEL / OPENAI_COMPAT_BASE_URL repo variables and the OPENAI_COMPAT_API_KEY secret, then trigger live-provider-streaming.yml - as a contributor I do not have that permission.

Verification: full suite 800 passed / 5 skipped (90.08% coverage) on the latest HEAD, and the three-dialect concurrency script passes.

@webup webup left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the substantial follow-up. The atomic metadata-DB append API, strict sequence replay, namespace-aware identity attempt, and expanded regression coverage are meaningful progress. I rechecked exact head 582e7b17b257f7ba0e329b874f53d04a51aa6cbe: all 17 ordinary CI checks are green, the broad local suite passed 802 tests with 25 skips after rerunning the two sandbox-blocked localhost tests, and the standalone SQLite concurrency check is gapless.

A fresh exact-head review with failure injection and adversarial concurrency checks found the following remaining merge blockers:

  1. [P1] Redis append failures still expose non-durable events. src/agentseek_api/services/run_jobs.py:142-153 and src/agentseek_api/services/thread_protocol.py:227-271 catch atomic Redis append failures and then publish broker-local events. Injection produced visible sequence 1 events even though nothing was appended to Redis. Because /runs/{id}/stream merges broker snapshots, a client can observe a cursor that disappears after restart; with split workers, the frame is silently absent instead. Please propagate the append failure or suppress publication, and cover both run and thread paths with failure-injection/reconnect tests.

  2. [P1] First appends can exhaust the metadata connection pool. src/agentseek_api/services/stream_persistence.py:219-230 checks for the counter row using the caller session, retaining that connection, and then opens a second seed session. Concurrent first appends for distinct stream IDs bypass the per-key lock relationship and can occupy all 10+5 configured connections while each waits for another. An exact-head reproduction with two distinct streams and a two-connection pool made both operations time out. Please seed before acquiring the caller connection or use a dialect-safe insert/upsert-and-lock design that needs only one connection.

  3. [P1] An initial lifecycle append failure strands the run before submission. src/agentseek_api/services/run_preparation.py:268-270 calls run_started() and awaits the fail-fast durable lifecycle append before entering the submission recovery try. Injection left the committed run pending, the thread busy, the broker active-run count at one, and submitted no job. Please put lifecycle publication and submission under one compensation boundary so every failure calls run_finished() and persists consistent terminal run/thread state.

  4. [P1] Id-less message identity is still incomplete. src/agentseek_api/services/run_executor.py:890-897 synthesizes an ID for messages/metadata, but messages/partial is serialized from the original id-less message at lines 952-974 and still carries id: null. Also, message_index restarts for each yielded event, so sequential root or same-namespace messages still reuse r1:message:0 and merge. Please assign one stable identity to the normalized message before generating any wire event, and test both different namespaces and sequential messages in the same/root namespace across metadata, partial, and complete frames.

  5. [P2] SQLite in-session terminal appends still have an unhandled allocation race. The standalone _db_append() retries uniqueness collisions, but add_run_stream_event_to_session() and add_thread_stream_event_to_session() call _stage_db_event() directly. A forced two-session diagnostic allocated sequence 1 twice; one commit failed and only one terminal row persisted. Please either make the enclosing transaction retryable, retain the allocation lock through commit, or enforce and test a single-writer invariant.

  6. [P1 verification gate] The canonical provider proof is still absent. There are zero Live Provider Streaming runs for this branch. This update changes AGENTS.md from the previously required message_chunk target to messages/partial; that is a contract decision, not execution evidence, and needs explicit maintainer acceptance. After agreeing on the wire contract, please run the canonical workflow on the repaired exact head and show incremental real-provider frames rather than only a final response.

These failures share one pattern: each local repair addresses the happy path but leaves an adjacent transaction, failure, or wire-contract boundary open. To avoid another patch cycle, I recommend pausing production changes and first posting a short design that defines these invariants:

  • a sequence exists only after its durable append succeeds, and broker publication always follows it;
  • the same append/publish contract applies to inline and Redis run/thread events;
  • sequence initialization cannot require a nested connection while one is held;
  • lifecycle and submission form one recoverable state transition with guaranteed cleanup;
  • each logical message has one non-null identity across metadata, partial, and complete events;
  • the real-provider acceptance contract is agreed explicitly and verified on the exact SHA.

Before revising the implementation, please add failing regressions for: Redis append failure with no broker event; a pool-capacity burst of distinct new streams; initial lifecycle failure cleanup; sequential id-less same/root-namespace messages; ID parity across wire events; concurrent in-session terminal appends; and the canonical exact-head provider workflow. Keeping each invariant in a separate commit with its exact test command/result will make the next review substantially faster and safer.

Please address these findings together before approval.

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.

[Bug]: astream_events(version="v2") 中导致 SSE updates 事件重复

2 participants