Skip to content

Event-core correctness fixes + refactoring (isolation, provider DRY, EventCodec, mypy→0) - #97

Merged
GuanyiLi-Craig merged 7 commits into
mainfrom
gl/event-core-correctness-and-mypy
Jun 20, 2026
Merged

Event-core correctness fixes + refactoring (isolation, provider DRY, EventCodec, mypy→0)#97
GuanyiLi-Craig merged 7 commits into
mainfrom
gl/event-core-correctness-and-mypy

Conversation

@GuanyiLi-Craig

Copy link
Copy Markdown
Contributor

Summary

Event-core correctness fixes plus the first structural passes of the codebase-design refactoring plan, landed incrementally with green tests at every step. Five commits, each independently green: 52 files, +1,530 / −877.

Headline: fixes three confirmed runtime defects (recovery offset, fan-out quiescence, span-error recording), isolates concurrent invocations, consolidates the OpenAI-compatible LLM tools, and takes mypy grafi to 0 errors — then a recall code-review pass found and fixed an AND-subscription hang regression and hardened the quiescence detection.

Correctness fixes

  • Recovery offsetrestore_topic misused fetch(offset+1) and skipped the event after a committed one. Added a dedicated TopicEventQueue.restore_consumer(committed_offset) and use it.
  • Fan-out quiescence — publish counted per-topic while commits happen per-consumer, so a topic feeding multiple subscribers could quiesce after only the first committed. Now counts one delivery per consumer (_topic_consumers), registered before the event is consumable.
  • AND-subscription hang (review regression) — per-consumer counting then hung a topic feeding a firing node and a non-firing A AND B node. Kept per-consumer counting (fan-out is real) and added no-progress detection in AsyncOutputQueue (driven by _progress_possible, backed by tracker-activity deltas so a busy run is never mistaken for stuck).
  • Span errors — error attributes were set after the span context manager exited (dropped). Enrichment now happens while the span is recording.
  • Per-invocation isolation — concurrent invoke()s on one workflow shared topic queues + tracker and reset each other. Each invocation now runs on an isolated per-run clone (fresh queues/tracker, nodes rebound).
  • OpenRouter — no longer routes structured_output to OpenAI's beta parse endpoint; from_dict now restores model.
  • stop() race — a stop forwarded to an in-flight run is no longer cleared by a redundant reset.

Structural / quality

  • DRY — OpenAI/DeepSeek/OpenRouter collapse into one OpenAICompatibleTool base (~540 lines removed); shared to_openai_tool adapter.
  • Open/Closed — event decoding moved to an EventCodec registry; EventStore no longer imports concrete event classes.
  • DIPgrafi/common no longer imports the assistants/nodes/tools/workflows layers (record_base typed via Any); FunctionSpec is OpenAI-free.
  • Cleanupscloudpickle dependency removed (no pickle); generate_manifest returns its path; SubscriptionBuilder.build raises instead of returning None; mutable defaults → default_factory; dead Command.from_dict removed; doc accuracy (idempotency = at-least-once, not exactly-once).

Verification

  • pytest: 669 passed (recovery, fan-out, AND-hang, concurrent-isolation, span-before-end, codec round-trip tests added)
  • mypy grafi: clean (0 errors)
  • pre-commit (black + isort + flake8): green

Deliberate scope decisions (deferred — not in this PR)

  • Isolation via cloning, not a dedicated WorkflowRun class — lower blast radius; full extraction deferred.
  • Per-consumer count + no-progress backstop, not a DeliveryId set — simpler and correct for both fan-out and parked deliveries.
  • Message stays OpenAI-shaped — only FunctionSpec was made provider-neutral; the full Message rewrite round-trips onto the provider wire, so it needs live-provider integration tests + a deprecation window.
  • mypy clean but with disable_error_code / warn_no_return=false still set — removing them surfaces ~35 SDK/dynamic-decorator errors (measured, deferred).

🤖 Generated with Claude Code

GuanyiLi-Craig and others added 5 commits June 19, 2026 18:38
Implements Phase 1 (correctness) of the codebase design refactoring plan
and brings `mypy grafi` to zero errors. Full suite: 660 passed.

Correctness fixes:
- Recovery no longer skips an event: add restore_consumer() to the
  topic-queue port and use it in TopicBase.restore_topic instead of
  misusing fetch(offset+1), which over-advanced the consumed cursor.
- Fan-out quiescence: the workflow counts one pending delivery per
  consumer (new EventDrivenWorkflow._topic_consumers), registered before
  an event becomes consumable, and rejects accounting underflow instead
  of silently clamping. Prevents premature termination when one topic
  feeds multiple subscribers.
- Span error attributes are recorded while the span is still active
  (inside start_as_current_span), not after it has ended.

Typing (mypy 56 -> 0 across 18 files):
- Real fixes for ~51 (annotations, Optional, narrowing, SDK omit/Omit
  sentinel for absent tools=, AsyncStream cast, centralized postgres
  row->event helper, ParameterSchema.model_validate, builder Optional
  params).
- 5 targeted, commented `# type: ignore` for unverifiable dynamic
  dispatch / SDK-stub / known-deferred-LSP cases (warn_unused_ignores
  keeps them honest).

Behavior note: OpenAI-compatible and Claude tools now pass the SDK omit
sentinel instead of None/NOT_GIVEN when no tools are present.

Tests: +7 characterization tests (queue restore, span-before-end,
fan-out); updated 3 LLM tool tests for the omit sentinel.

Also adds the design audit & refactoring plan doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nups

Continues the codebase design refactoring plan.

Phase 2 (Defect #3 — invocation isolation):
- invoke() runs on a per-invocation clone (_spawn_run) with its own tracker,
  invoke queue, stop flag, and topic queues; nodes are rebound to the per-run
  topics (NodeBase.rebind_topics). Concurrent invocations of one workflow no
  longer share/reset each other's runtime state. stop() forwards to in-flight
  runs. +tests/workflow/test_invocation_isolation.py (failed pre-fix).

Phase 5 (provider DRY):
- New OpenAICompatibleTool base owns the shared prepare/invoke/convert logic;
  OpenAITool/DeepseekTool/OpenRouterTool shrink to ~55-77 lines of config
  (env var, model, base_url, extra headers, error label). Tests adjusted to
  patch the shared base and mock the client as an async context manager.

Phase 6 (Open/Closed — EventCodec):
- New grafi/common/events/event_codec.py owns event-type registration and
  decoding; EventStore delegates and no longer imports concrete event classes.
  New event types register on the codec without editing the store. +tests.

Phase 7 (cleanups):
- generate_manifest returns the written path (+test); removed cloudpickle dep,
  lockfile entry, and stale jsonpickle/cloudpickle mypy overrides
  ("no pickle dependency"); corrected the jsonpickle doc; removed dead
  Command.from_dict.

Phase 8a/8b (interfaces/typing):
- Mutable Field(default=[])/PrivateAttr(default={}) -> default_factory.
- SubscriptionBuilder.build() raises on empty instead of returning None under
  its -> SubExpr annotation.

All green: 668 tests pass, `mypy grafi` clean, pre-commit (black/isort/flake8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continues the codebase design refactoring plan.

Phase 3 (Dependency Inversion):
- Add grafi/runtime/execution_services.py: ExecutionServices(event_store,
  tracer) port + default_execution_services() that reads the global container
  (kept as the default composition root) so injection can be adopted
  incrementally. +tests.
- record_base.py no longer imports AssistantBase/Workflow/NodeBase/Tool (typed
  the decorated component as Any). grafi/common now has no runtime imports of
  the assistants/nodes/tools/workflows layers.

Phase 4 (provider-neutral, safe subset):
- Move FunctionSpec.to_openai_tool() to grafi/tools/llms/impl/openai_adapter.py
  (to_openai_tool(spec)). grafi/common/models/function_spec.py no longer imports
  the OpenAI SDK. Callers (openai_compatible, ollama, a test) use the adapter.
  NOTE: the full provider-neutral Message rewrite is intentionally deferred --
  Message field types round-trip onto the provider wire (tool_calls etc.), so it
  needs live-provider integration testing and a deprecation window per the plan.

Phase 9 (doc accuracy):
- features.md: corrected the idempotency section -- event persistence/replay is
  idempotent, but node/tool execution is at-least-once (no exactly-once external
  side effects unless the tool is idempotent).
- event-driven-workflow.md: replaced the non-existent MergeIdleQueue with the
  real AsyncOutputQueue. (A comprehensive doc refresh remains.)

All green: 670 tests pass, `mypy grafi` clean, pre-commit (black/isort/flake8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e + cleanups

Addresses the recall code-review on the refactor branch.

Correctness:
- #1 (HIGH) Parallel workflow no longer hangs when a topic fans out to a firing
  node AND a node whose AND-subscription can never be satisfied. Per-consumer
  delivery counting is kept (fixes fan-out), but AsyncOutputQueue now ends
  iteration when the workflow reports no progress is possible (no active node,
  nothing consumable, no node can invoke) for two consecutive idle polls, so a
  parked delivery cannot hang the run. New EventDrivenWorkflow._progress_possible;
  regression test added (was a confirmed hang).
- #2 OpenRouter (and any non-OpenAI-parse provider) no longer routes
  structured_output to client.beta.chat.completions.parse; new
  _supports_beta_parse=False keeps it on the standard create call.
- #3 OpenRouterTool.from_dict restores `model` (was dropped, reverting to
  openrouter/auto on round-trip).
- #4 stop() forwarded to an in-flight run is no longer undone: the per-run clone
  no longer calls reset_stop_flag(), and init_workflow skips tracker.reset() when
  a stop is already requested.
- #14 OpenAI-compatible invoke merges request kwargs with chat_params into one
  dict, so a caller-supplied extra_headers/response_format overrides instead of
  raising a duplicate-keyword TypeError.

Cleanup / robustness:
- #10 base_url serialization moved to OpenAICompatibleTool.to_dict (deepseek no
  longer overrides; openrouter only adds extra_headers).
- #8 _spawn_run rebuilds each per-run queue as type(topic.event_queue)() instead
  of hardcoding InMemTopicEventQueue, preserving a topic's queue kind.
- #12 _topic_consumers is memoized (static topology; read per published event).
- #9 removed the dead ExecutionServices module + test (unwired DI port).
- #5/#6/#7 doc accuracy: tracker underflow (logs+clamps, backstopped by #1),
  EventStore._codec (shared process-wide default), and tool-statelessness note.

669 tests pass, mypy clean, pre-commit (black/isort/flake8) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second-pass review of the fix commit surfaced two low-severity fragilities;
this hardens both.

- AsyncOutputQueue stuck-detection no longer depends on the code-layout
  invariant that a node is never observable between consuming its inputs and
  calling tracker.enter(). It now also resets the idle counter whenever the
  tracker's activity count changes between polls (a node processed = real
  progress), so a busy run is never mistaken for stuck regardless of
  scheduling. Termination still requires consecutive idle polls with no
  progress possible.
- _spawn_run allocates a fresh InMemTopicEventQueue per topic again, with a
  comment explaining why: a topic's event queue is a transient per-run buffer
  (durability/recovery come from the EventStore), so an in-memory queue is
  correct per run. This removes the prior type(queue)() form that silently
  required an arg-less constructor and dropped queue configuration.

669 tests pass, mypy clean, pre-commit green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 19, 2026 22:12

This comment was marked as off-topic.

GuanyiLi-Craig and others added 2 commits June 20, 2026 09:34
Cleanup pass (no behavior change):
- _progress_possible: drop the per-node can_invoke() sweep. A node can only
  invoke when a subscribed topic has an unconsumed event, so a zero
  pending-consumable count already implies no node can invoke -- the sweep was
  redundant (and a duplicate lock-acquiring pass over the same topic state).
- Remove the _topic_consumers memoization cache (field + per-clone reset +
  lookup branch): the computation is a tiny pure function over static topology
  (dict.fromkeys + a type check), read once per published event; the cache added
  state for negligible savings.
- on_messages_committed: collapse the if/else that duplicated the decrement into
  a single max(0, ...) with the underflow log kept as a tripwire.
- openai_compatible.invoke: build call_kwargs directly instead of constructing a
  throwaway request_kwargs dict and immediately merging it.

669 tests pass, mypy clean, pre-commit green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-invocation clone (Defect #3 isolation) guarded against two concurrent
invoke() calls on the SAME workflow instance corrupting each other's topic
queues/tracker. In current usage each request creates a fresh assistant
instance, so instances are never reused concurrently and that bug cannot occur
-- the machinery solved a future (instance-reuse) problem. Defer it to that
refactor and remove the complexity now (YAGNI).

Removed:
- NodeBase.rebind_topics (only used by the clone).
- EventDrivenWorkflow._spawn_run, _active_runs, the invoke/_invoke_impl split,
  stop() run-forwarding, and the init_workflow stop-guard. invoke() runs on
  self again with reset_stop_flag(), as before.
- tests/workflow/test_invocation_isolation.py (tested the deferred behavior).

Kept (all verified working on self): recovery-offset fix, fan-out per-consumer
delivery accounting, the no-progress stuck-detection (AsyncOutputQueue +
_progress_possible), span-error fix, EventCodec, provider DRY, and cleanups.

667 tests pass, mypy clean, pre-commit green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@GuanyiLi-Craig
GuanyiLi-Craig merged commit c09c1f5 into main Jun 20, 2026
19 checks passed
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.

2 participants