Skip to content

Commit ed05bed

Browse files
authored
Release v0.2.1: Fix child tracking races, debounce dashboard cost refresh, remove child injection limit
### Fixed - Child tracking race condition: use Registry fallback when injected children lag state updates, and deduplicate spawn_child results to keep tracking consistent under async races. - Dashboard cost refresh storm: debounce `cost_recorded` PubSub events (2s window) to prevent O(N²) re-query feedback loop that caused UI lockup with 100+ agents. ### Changed - Removed 20-child injection limit that caused agents to lose visibility of children beyond 20, triggering unbounded respawning. - Deferred per-agent GenServer calls (todos, budget) in Dashboard to after mount to prevent longpoll timeout with many live agents. ### Removed - Dead `registry_spawned_at/1` function and unreachable code paths in ChildrenInjector.
1 parent 1415ad9 commit ed05bed

20 files changed

Lines changed: 965 additions & 299 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.1] - 2026-03-12
9+
10+
### Fixed
11+
12+
- Child tracking race condition: use Registry fallback when injected children lag state updates, and deduplicate spawn_child results to keep tracking consistent under async races.
13+
- Dashboard cost refresh storm: debounce `cost_recorded` PubSub events (2s window) to prevent O(N²) re-query feedback loop that caused UI lockup with 100+ agents.
14+
15+
### Changed
16+
17+
- Removed 20-child injection limit that caused agents to lose visibility of children beyond 20, triggering unbounded respawning.
18+
- Deferred per-agent GenServer calls (todos, budget) in Dashboard to after mount to prevent longpoll timeout with many live agents.
19+
20+
### Removed
21+
22+
- Dead `registry_spawned_at/1` function and unreachable code paths in ChildrenInjector.
23+
824
## [0.2.0] - 2026-03-10
925

1026
### Added

‎lib/quoracle/agent/AGENTS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
- ConsensusHandler.Helpers: Helper functions (133 lines), normalize_sibling_context/1, self_contained_actions/0, has_pending_self_contained?/1 (v26.0), coerce_wait_value/1 (v34.0 DRY extraction), prepend_to_content/2, extract_shell_check_id/2 (v25.0 shared helper for shell routing), primary_skill_name/1 (REFACTOR DRY extraction from ActionExecutor)
2929
- ConsensusHandler.LogHelper: Logging helpers (63 lines), safe_broadcast_log/5, log_action_error/1 (v28.0: {:error, _} unwrap, {:action_crashed, tuple} clause, extended @warning_errors with transient network errors)
3030
- ConsensusHandler.TodoInjector: TODO injection (82 lines), inject_todo_context/2, format_todos_as_xml/1, escape_xml/1
31-
- ConsensusHandler.ChildrenInjector: Children context injection (150 lines), inject_children_context/2, format_children/1, Registry-based status check, v2.0: enrich_with_messages/2 cross-references state.messages for latest_message + latest_message_at per child
31+
- ConsensusHandler.ChildrenInjector: Children context injection (218 lines), inject_children_context/2, format_children/1, Registry-based status check, v2.0: enrich_with_messages/2 cross-references state.messages for latest_message + latest_message_at per child, v3.0: Registry fallback discovers children via Registry.select when state.children is empty/incomplete (race condition fix)
3232
- ConsensusHandler.AceInjector: ACE context injection (82 lines, v1.0), inject_ace_context/3 into FIRST user message (historical knowledge), format_ace_context/2
3333
- ConsensusHandler.ContextInjector: Context token injection (95 lines, v2.0), inject_context_tokens/1 counts fully-built messages (excluding system prompt), format_context_tokens/1 with comma separators
3434
- StateUtils: State manipulation helpers (145 lines), action_type tracking for NO_EXECUTE, v3.0 adds merge_consensus_state/2 for ACE state merging (extracted from MessageHandler/ConsensusContinuationHandler), v5.0 adds cancel_wait_timer/1 for DRY timer cancellation (4 pattern-matched clauses), v6.0 adds schedule_consensus_continuation/1 for DRY "set flag + send trigger" pattern, v38.0 adds :cached_system_prompt to merge_keys
@@ -80,13 +80,14 @@
8080

8181
## Children Tracking (2025-12-27)
8282
- Core.State.children: `[%{agent_id: String.t(), spawned_at: DateTime.t()}]` (changed from `[pid()]`)
83-
- Core.ChildrenTracker: handle_child_spawned/2 (prepend), handle_child_dismissed/2 (remove by id), handle_child_restored/2 (restoration)
83+
- Core.ChildrenTracker: handle_child_spawned/2 (idempotent prepend), handle_child_dismissed/2 (remove by id), handle_child_restored/2 (restoration)
8484
- ConsensusHandler.ChildrenInjector: inject_children_context/2 (up to 20 children, Registry-filtered)
8585
- Spawn action: Casts `{:child_spawned, %{agent_id, spawned_at}}` to parent after success
8686
- DismissChild action: Casts `{:child_dismissed, child_id}` to parent after dispatch
8787
- XML format: `<children>{"agent_id":"...", "spawned_at":"...", "latest_message":"...", "latest_message_at":"..."}</children>` (v2.0: enriched with message preview)
8888
- Injection order: children→todos→content (children prepended last, appears first)
8989
- v2.0 enrichment: Cross-references state.messages at injection time, truncates latest_message to 100 chars, RFC 2822 timestamps
90+
- v3.0 Registry fallback: ChildrenInjector discovers children via Registry.select when state.children is empty/incomplete (batch_async race condition fix). Dual-path tracking contract: both ChildrenTracker.handle_child_spawned/2 and ActionResultHandler.maybe_track_child/3 enforce Enum.any? idempotency guards
9091

9192
## Patterns
9293
- Full dependency injection (registry/dynsup/pubsub via opts)

‎lib/quoracle/agent/consensus_handler/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
- Helpers: Shared helper functions (116 lines), self_contained_actions/0, has_pending_self_contained?/1 (v26.0), coerce_wait_value/1, extract_shell_check_id/2, normalize_sibling_context/1
66
- LogHelper: Logging helpers (63 lines), safe_broadcast_log/5, log_action_error/1 (v28.0: {:error, _} unwrap, {:action_crashed, tuple} clause, extended @warning_errors)
77
- TodoInjector: Todo list context injection (82 lines), inject_todo_context/2
8-
- ChildrenInjector: Children context injection (150 lines), inject_children_context/2, format_children/1, v2.0: enrich_with_messages/2 cross-references state.messages to add latest_message + latest_message_at per child
8+
- ChildrenInjector: Children context injection (218 lines), inject_children_context/2, format_children/1, v2.0: enrich_with_messages/2 cross-references state.messages to add latest_message + latest_message_at per child, v3.0: Registry fallback via discover_registry_children/2 + safe_find_children_by_parent/2 + build_registry_child/1 + merge_registry_children/2 for race condition fix
99
- AceInjector: ACE context injection (82 lines), inject_ace_context/3
1010
- CorrectionInjector: Per-model correction feedback injection (56 lines, v1.0), inject_correction_feedback/3 — prepends correction to last user message at MessageBuilder step 7.5
1111
- ContextInjector: Context token injection (95 lines), inject_context_tokens/1

‎lib/quoracle/agent/consensus_handler/children_injector.ex‎

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,38 @@
11
defmodule Quoracle.Agent.ConsensusHandler.ChildrenInjector do
22
@moduledoc """
3-
Handles children injection into consensus messages.
4-
Extracted from ConsensusHandler to maintain <500 line modules.
3+
Injects children context into consensus messages as a `<children>` XML block.
54
6-
v2.0: Message enrichment — cross-references state.messages to show
7-
latest_message_preview and latest_message_at per child in consensus context.
5+
Discovers children from two sources: `state.children` (tracked via casts) and
6+
Registry (fallback for race conditions where casts haven't arrived yet). Merges
7+
both sources, filters to live children, enriches each with the latest inbox
8+
message preview, and prepends the block to the last consensus message.
89
"""
910

1011
alias Quoracle.Agent.ConsensusHandler.Helpers
1112

1213
@max_message_length 100
1314

14-
@doc "Injects children context (up to 20) into last message. Returns messages unchanged only if messages empty."
15+
@doc "Injects children context into last message. Returns messages unchanged only if messages empty."
1516
@spec inject_children_context(map(), list(map())) :: list(map())
1617
def inject_children_context(state, messages) do
1718
if messages == [], do: messages, else: inject_children_block(state, messages)
1819
end
1920

2021
defp inject_children_block(state, messages) do
21-
children = Map.get(state, :children, [])
2222
registry = Map.get(state, :registry)
23-
live_children = if children == [], do: [], else: filter_live_children(children, registry)
23+
registry_children = discover_registry_children(state, registry)
24+
25+
live_children =
26+
state
27+
|> Map.get(:children, [])
28+
|> filter_live_children(registry)
29+
|> merge_registry_children(registry_children)
2430

2531
if live_children == [] do
2632
inject_empty_children(messages)
2733
else
2834
inbox = Map.get(state, :messages, [])
29-
enriched = enrich_with_messages(Enum.take(live_children, 20), inbox)
35+
enriched = enrich_with_messages(live_children, inbox)
3036
inject_into_last_message(messages, enriched)
3137
end
3238
end
@@ -93,6 +99,61 @@ defmodule Quoracle.Agent.ConsensusHandler.ChildrenInjector do
9399
end
94100
end
95101

102+
@spec discover_registry_children(map(), atom() | pid() | nil) :: list(map())
103+
defp discover_registry_children(%{agent_id: nil}, _registry), do: []
104+
defp discover_registry_children(_state, nil), do: []
105+
106+
defp discover_registry_children(state, registry) do
107+
case Map.get(state, :agent_id) do
108+
nil ->
109+
[]
110+
111+
agent_id ->
112+
case safe_registry_lookup(registry, agent_id) do
113+
{:ok, parent_pid} ->
114+
registry
115+
|> safe_find_children_by_parent(parent_pid)
116+
|> Enum.map(&build_registry_child/1)
117+
118+
:not_found ->
119+
[]
120+
end
121+
end
122+
end
123+
124+
@spec safe_find_children_by_parent(atom() | pid(), pid()) :: list({String.t(), map()})
125+
defp safe_find_children_by_parent(registry, parent_pid) do
126+
try do
127+
Registry.select(registry, [
128+
{{{:agent, :"$1"}, :"$2", :"$3"}, [{:==, {:map_get, :parent_pid, :"$3"}, parent_pid}],
129+
[{{:"$1", :"$3"}}]}
130+
])
131+
rescue
132+
_ -> []
133+
catch
134+
_, _ -> []
135+
end
136+
end
137+
138+
# Registry composite is a map with keys :pid, :agent_id, :task_id, :parent_pid,
139+
# :parent_id, :registered_at — but NOT :spawned_at or :budget_allocated,
140+
# so we use approximate values for the child entry.
141+
@spec build_registry_child({String.t(), map()}) :: map()
142+
defp build_registry_child({agent_id, _composite}) do
143+
%{
144+
agent_id: agent_id,
145+
spawned_at: DateTime.utc_now(),
146+
budget_allocated: nil
147+
}
148+
end
149+
150+
@spec merge_registry_children(list(map()), list(map())) :: list(map())
151+
defp merge_registry_children(children, registry_children) do
152+
tracked_ids = MapSet.new(children, & &1.agent_id)
153+
154+
children ++ Enum.reject(registry_children, &MapSet.member?(tracked_ids, &1.agent_id))
155+
end
156+
96157
# Enrich children with latest message data from the agent's inbox.
97158
# Cross-references each child's agent_id against inbox messages to find
98159
# the most recent message (by timestamp) from that child.

‎lib/quoracle/agent/message_handler/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
- format_sender_id/1: :parent→"parent", :user→"user", binary→as-is
1111

1212
## Private Helpers
13-
- maybe_track_child/3: Track spawned children from non-blocking dispatch results (spawn_child action only)
13+
- maybe_track_child/3: Track spawned children from non-blocking dispatch results (spawn_child action only), v30.0: Enum.any? idempotency guard prevents duplicate entries (matches ChildrenTracker.handle_child_spawned/2 pattern)
1414
- maybe_update_budget_committed/3: Update budget_data.committed for spawn_child results (replaces removed Core.update_budget_committed callback)
1515
- maybe_track_shell_router/3: Populate shell_routers with {command_id, router_pid} from async shell Phase 1 results (v25.0, v26.0 refactor: uses shared `async_shell_phase1?/1` predicate). Matches `{:ok, %{status: :running, command_id: binary, sync: false}}`.
1616
- async_shell_phase1?/1: Shared predicate detecting async shell Phase 1 ack. Used by both pending_actions guard and shell_routers tracking. Pattern: `{:ok, %{status: :running, command_id: binary, sync: false}}`.

‎lib/quoracle/agent/message_handler/action_result_handler.ex‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,22 +194,28 @@ defmodule Quoracle.Agent.MessageHandler.ActionResultHandler do
194194
when is_map(child_result) do
195195
if Keyword.get(opts, :action_atom) == :spawn_child and
196196
Map.has_key?(child_result, :agent_id) do
197-
child_data = %{
198-
agent_id: child_result.agent_id,
199-
spawned_at: Map.get(child_result, :spawned_at, DateTime.utc_now()),
200-
budget_allocated: Map.get(child_result, :budget_allocated)
201-
}
197+
child_id = child_result.agent_id
198+
children = Map.get(state, :children, [])
202199

203-
Map.update(state, :children, [child_data], &[child_data | &1])
200+
if Enum.any?(children, &(&1.agent_id == child_id)) do
201+
state
202+
else
203+
child_data = %{
204+
agent_id: child_id,
205+
spawned_at: Map.get(child_result, :spawned_at, DateTime.utc_now()),
206+
budget_allocated: Map.get(child_result, :budget_allocated)
207+
}
208+
209+
Map.update(state, :children, [child_data], &[child_data | &1])
210+
end
204211
else
205212
state
206213
end
207214
end
208215

209216
defp maybe_track_child(state, _result, _opts), do: state
210217

211-
# v35.0 R67: Update budget_committed when spawn_child result includes budget_allocated
212-
# Replaces the removed Core.update_budget_committed callback from Spawn (FIX_BudgetCallbackElimination)
218+
# v35.0 R67: Update committed budget from successful spawn_child results
213219
defp maybe_update_budget_committed(state, {:ok, child_result}, opts)
214220
when is_map(child_result) do
215221
if Keyword.get(opts, :action_atom) == :spawn_child do

0 commit comments

Comments
 (0)