syncmill 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,233 @@
1
+ """CompeteStrategy (v0.2) — concurrent head-to-head, keep the winner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from syncmill.agents import AgentRunner, aexecute_isolated
8
+ from syncmill.breaker import CircuitBreaker
9
+ from syncmill.config import SyncmillConfig
10
+ from syncmill.gates import QualityGate
11
+ from syncmill.memory import SyncmillMemory, run_async, run_memory_bounded
12
+ from syncmill.models import Task, TaskResult, TaskStatus
13
+ from syncmill.monitor import Monitor
14
+ from syncmill.strategies.base import (
15
+ _aggregate_failure,
16
+ _attempt_span,
17
+ _breaker_all_open_failure,
18
+ _breaker_record,
19
+ _breaker_runnable,
20
+ _collect_files_changed,
21
+ _enrich_prompt,
22
+ _gate_span,
23
+ _monitor_phase,
24
+ _monitor_status,
25
+ _monitor_streaming,
26
+ _no_stages_failure,
27
+ _select_span,
28
+ _select_winner,
29
+ _trace_attempt,
30
+ _trace_record,
31
+ )
32
+ from syncmill.tracer import Tracer
33
+ from syncmill.worktree import WorktreePool
34
+
35
+
36
+ class CompeteStrategy:
37
+ """Concurrent head-to-head. Every enabled agent runs the SAME task at once,
38
+ each in its own isolated worktree; the highest-priority agent that COMPLETED
39
+ wins.
40
+
41
+ syncmill's first *concurrent* strategy. It bridges async via ``run_async``
42
+ (reusing the module event loop — never ``asyncio.run``) to fan agents out over
43
+ ``aexecute``, one private worktree per agent so parallel edits never clobber
44
+ (where Pipeline shares a single tree). Every agent's prompt is enriched
45
+ identically from ``shared`` (``_enrich_prompt``): they compete from the same
46
+ starting context, with no stage-to-stage handoff.
47
+
48
+ Unlike RouteStrategy it does **not** stop at the first success — all agents
49
+ run and each produces a real diff in its own worktree — but the winner is the
50
+ same priority preference made *deterministic* by ignoring finish time: among
51
+ the COMPLETED results, the first in priority (input ``agents``) order. The
52
+ criterion never reads ``duration_seconds`` (nondeterministic wall-clock), so
53
+ it's reproducible across runs and across git vs tempdir workspaces. Selection
54
+ is funnelled through ``_select_winner`` — the seam where quality gates (#10)
55
+ will later rank the COMPLETED candidates by a verified score. If no agent
56
+ completes, it reports an aggregate failure (``_aggregate_failure``), the same
57
+ shape Route uses.
58
+
59
+ The Supervisor owns worktree lifetime — it shares the returned winner to
60
+ ``shared`` and tears down EVERY worktree (winner + losers) in its ``finally``
61
+ — so compete neither cleans up worktrees nor applies the winner's diff back to
62
+ the repo; it only *surfaces* it (``files_changed`` + the Supervisor's share).
63
+ """
64
+
65
+ def execute(
66
+ self,
67
+ task: Task,
68
+ agents: list[AgentRunner],
69
+ memory: SyncmillMemory,
70
+ pool: WorktreePool,
71
+ config: SyncmillConfig,
72
+ gate: QualityGate | None = None,
73
+ breaker: CircuitBreaker | None = None,
74
+ monitor: Monitor | None = None,
75
+ tracer: Tracer | None = None,
76
+ ) -> TaskResult:
77
+ if not agents: # Supervisor guards this; stay safe if run standalone
78
+ return _no_stages_failure(task)
79
+
80
+ # Drop circuit-open agents BEFORE acquiring worktrees / fanning out, so
81
+ # `runnable`, `worktrees`, and `results` stay the same length and
82
+ # positionally aligned (the alignment _select_winner and recording rely
83
+ # on). If the breaker tripped them all, say so.
84
+ runnable = _breaker_runnable(agents, breaker)
85
+ if not runnable:
86
+ return _breaker_all_open_failure(task, agents)
87
+
88
+ # Memory read happens FIRST, fully sync — never call run_async from inside
89
+ # the gather below (that would re-enter the running loop). Every agent
90
+ # competes from the same enriched starting context.
91
+ enriched_prompt = _enrich_prompt(
92
+ task.prompt,
93
+ run_memory_bounded(
94
+ memory.search_context(task.prompt),
95
+ default="", memory=memory, label="search_context",
96
+ ),
97
+ )
98
+
99
+ # One isolated worktree per agent, acquired UP FRONT and SERIALLY:
100
+ # acquire() is sync git (contends on the index lock) and must not run
101
+ # inside the concurrent fan-out. The comprehension over `runnable` keeps
102
+ # the lists the same length, so results/worktrees/agents stay aligned.
103
+ worktrees = [pool.acquire(agent.agent_id) for agent in runnable]
104
+
105
+ # One concurrent batch on the shared loop; gather preserves submission
106
+ # order, so results line up with `runnable` and `worktrees`. The live
107
+ # monitor (#16) opens a pane per competitor and tees its output as it runs.
108
+ runnable_ids = [agent.agent_id for agent in runnable]
109
+ _monitor_phase(monitor, "compete")
110
+ with _monitor_streaming(monitor, runnable_ids):
111
+ results = run_async(
112
+ aexecute_isolated(
113
+ runnable,
114
+ enriched_prompt,
115
+ [wt.path for wt in worktrees],
116
+ lambda aid: config.agent_config(aid).timeout_seconds,
117
+ )
118
+ )
119
+
120
+ # The fan-out's shared end-of-phase clock plus a RAW (pre-gate) snapshot:
121
+ # _select_winner downgrades gate-rejected completers to FAILED in place,
122
+ # but the attempt spans and the select links report execution outcomes —
123
+ # a gate rejection is a quality verdict, not an execution failure.
124
+ phase_end = time.time()
125
+ raw = [(r.status, r.error) for r in results]
126
+
127
+ # Record EVERY competitor's raw outcome (winner + losers), before gating,
128
+ # so a repeatedly-crashing agent's failures accumulate across runs and a
129
+ # working one's circuit closes — even when a higher-priority peer wins.
130
+ for agent, result in zip(runnable, results):
131
+ _breaker_record(breaker, agent.agent_id, result.status)
132
+ _monitor_status(monitor, agent.agent_id, result.status)
133
+
134
+ sel_start = time.time()
135
+ trace_out: dict = {}
136
+ winner = _select_winner(
137
+ worktrees,
138
+ results,
139
+ gate,
140
+ mode=config.selection.mode,
141
+ agents=runnable,
142
+ pool=pool,
143
+ config=config,
144
+ tracer=tracer,
145
+ candidate_node_prefix="attempt",
146
+ trace_out=trace_out,
147
+ )
148
+ winner_result: TaskResult | None = None
149
+ if winner is not None:
150
+ winner_result, winner_worktree = winner
151
+ _collect_files_changed(winner_result, winner_worktree) # winner's OWN diff
152
+ sel_end = time.time()
153
+
154
+ # Retrospective span recording, all AFTER selection: the tracer is
155
+ # append-only, and winner/files_changed_count are only known now. Every
156
+ # attempt fans out from the common `run` parent (completion order is
157
+ # never causal); gate spans exist only for candidates _select_winner
158
+ # actually gated (the lazy prefix); the select span is parented on the
159
+ # winning attempt and linked to the other raw-COMPLETED candidates.
160
+ if tracer is not None:
161
+ for slot, (agent, result) in enumerate(zip(runnable, results)):
162
+ raw_status, raw_error = raw[slot]
163
+ is_winner = result is winner_result
164
+ _trace_attempt(
165
+ tracer,
166
+ _attempt_span(
167
+ agent.agent_id,
168
+ node_id=f"attempt:{agent.agent_id}",
169
+ parent="run",
170
+ attempt=0,
171
+ raw_status=raw_status,
172
+ error=raw_error,
173
+ exit_code=result.exit_code,
174
+ end=phase_end,
175
+ duration=result.duration_seconds,
176
+ winner=is_winner,
177
+ files_changed_count=(
178
+ len(result.files_changed) if is_winner else None
179
+ ),
180
+ worktree_slot=slot,
181
+ ),
182
+ result,
183
+ )
184
+ if result.gate is not None:
185
+ _trace_record(
186
+ tracer,
187
+ _gate_span(
188
+ agent.agent_id,
189
+ node_id=f"gate:{agent.agent_id}",
190
+ parent=f"attempt:{agent.agent_id}",
191
+ report=result.gate,
192
+ end=sel_end,
193
+ ),
194
+ )
195
+ links = [
196
+ f"attempt:{agent.agent_id}"
197
+ for agent, result, (raw_status, _) in zip(runnable, results, raw)
198
+ if raw_status == TaskStatus.COMPLETED and result is not winner_result
199
+ ]
200
+ if trace_out.get("judge_span") is not None:
201
+ # judge mode ran: emit its deferred span now that the candidate
202
+ # spans it links are recorded (a streaming sink drops forward
203
+ # links), and link the verdict the selection depended on.
204
+ _trace_attempt(tracer, *trace_out["judge_span"])
205
+ links.append(trace_out["judge_node"])
206
+ _trace_record(
207
+ tracer,
208
+ _select_span(
209
+ winner_node=(
210
+ f"attempt:{winner_result.agent_id}"
211
+ if winner_result is not None
212
+ else None
213
+ ),
214
+ links=links,
215
+ start=sel_start,
216
+ end=sel_end,
217
+ ),
218
+ )
219
+
220
+ if winner_result is None:
221
+ # Nobody completed, or completers all failed the gate (which downgrades
222
+ # them to FAILED in `results`, so the aggregate names the gate failure).
223
+ # Same all-failed shape as Route, but agents ran concurrently, so the
224
+ # wall-clock is the slowest, not the sum.
225
+ return _aggregate_failure(
226
+ task,
227
+ results,
228
+ duration_seconds=max(
229
+ (r.duration_seconds for r in results), default=0.0
230
+ ),
231
+ )
232
+
233
+ return winner_result
@@ -0,0 +1,429 @@
1
+ """CouncilStrategy (v0.3) — multi-round propose → critique → synthesize."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from syncmill.agents import AgentRunner, aexecute_isolated
8
+ from syncmill.breaker import CircuitBreaker
9
+ from syncmill.config import SyncmillConfig
10
+ from syncmill.gates import QualityGate
11
+ from syncmill.memory import SyncmillMemory, run_async, run_memory_bounded
12
+ from syncmill.models import Task, TaskResult, TaskStatus
13
+ from syncmill.monitor import Monitor
14
+ from syncmill.tracer import Tracer
15
+ from syncmill.strategies.base import (
16
+ _aggregate_failure,
17
+ _attempt_span,
18
+ _breaker_all_open_failure,
19
+ _breaker_record,
20
+ _breaker_runnable,
21
+ _collect_files_changed,
22
+ _enrich_prompt,
23
+ _gate_span,
24
+ _monitor_phase,
25
+ _monitor_status,
26
+ _monitor_streaming,
27
+ _no_stages_failure,
28
+ _select_span,
29
+ _select_winner,
30
+ _trace_attempt,
31
+ _trace_record,
32
+ )
33
+ from syncmill.worktree import Worktree, WorktreePool
34
+
35
+
36
+ def _council_propose(task_prompt: str) -> str:
37
+ """Phase-1 instruction: each council member drafts its own solution.
38
+
39
+ Wrapped by ``_enrich_prompt`` (which prepends the ``shared`` context as
40
+ "Context from previous work" and labels this as the "Task"), so this reads as
41
+ the assignment. Phrased to avoid a nested literal "Task:".
42
+ """
43
+ return (
44
+ "You are one member of a council of independent AI coding agents, all "
45
+ "solving the SAME problem in separate workspaces. Draft and implement your "
46
+ "best solution in this worktree. Be concrete and explain your approach in "
47
+ "your final message so the rest of the council can learn from it.\n\n"
48
+ f"The problem to solve: {task_prompt}"
49
+ )
50
+
51
+
52
+ def _council_critique(task_prompt: str) -> str:
53
+ """Phase-2 instruction (repeated each critique round): refine after reading the
54
+ council's shared proposals and prior critiques (carried in the prepended
55
+ context). Deliberation flows ONLY through that shared context — the agent never
56
+ sees another agent's worktree."""
57
+ return (
58
+ "You are one member of a council of independent AI coding agents solving "
59
+ "the SAME problem. The context above holds the council's proposals and "
60
+ "critiques so far. Critique those ideas, then refine YOUR solution in this "
61
+ "worktree to incorporate the strongest points. Summarize what you changed "
62
+ "and why in your final message.\n\n"
63
+ f"The problem to solve: {task_prompt}"
64
+ )
65
+
66
+
67
+ def _council_synthesize(task_prompt: str) -> str:
68
+ """Phase-3 instruction: produce the final solution from the whole deliberation.
69
+
70
+ Every member runs this; the council converges by selecting the winner among
71
+ the synthesized candidates (``_select_winner``), so each member's output should
72
+ be a complete, standalone solution — not a diff against a peer."""
73
+ return (
74
+ "You are one member of a council of independent AI coding agents that has "
75
+ "deliberated on the SAME problem. The context above holds the council's "
76
+ "proposals and critiques. Synthesize them into a single best FINAL solution "
77
+ "in this worktree, combining the strongest ideas from the whole council "
78
+ "into a complete, coherent implementation.\n\n"
79
+ f"The problem to solve: {task_prompt}"
80
+ )
81
+
82
+
83
+ class CouncilStrategy:
84
+ """Multi-round deliberation. Agents PROPOSE, CRITIQUE over rounds, then
85
+ SYNTHESIZE a converged final solution — exchanging context ONLY through the
86
+ ``shared`` namespace (agent isolation preserved).
87
+
88
+ Three kinds of concurrent phase, each fanned out over ``aexecute_isolated``
89
+ (one private worktree per agent, reused across all phases so a member's
90
+ solution accumulates), with all memory I/O done strictly between fan-outs
91
+ (never ``run_async`` inside a gathered coroutine — the shared-loop rule):
92
+
93
+ 1. **Propose** — every runnable agent drafts a solution from the same enriched
94
+ prompt; each completed proposal is published to ``shared``. The agents that
95
+ complete become the council *members*. If none complete, it aggregates the
96
+ failures (concurrent → max wall-clock, like Compete).
97
+ 2. **Critique** (``config.council.rounds`` times) — each member re-reads the
98
+ council's proposals + prior critiques from ``shared`` (relevance-ranked,
99
+ best-effort — the same handoff Pipeline uses) and refines its own worktree;
100
+ each completed critique is published back to ``shared``. A failed critique
101
+ is tolerated (not shared, member not dropped) — synthesis re-runs everyone
102
+ and the winner selection sorts it out, so no per-member state is tracked.
103
+ 3. **Synthesize** — every member produces a final solution from the whole
104
+ deliberation; the council converges by reusing Compete's ``_select_winner``
105
+ (first gate-passing COMPLETED in priority order), so the result reflects
106
+ more than one agent's input *when ``shared`` surfaces peers* — the
107
+ relevance-ranked, best-effort handoff Pipeline uses, so each synthesizer's
108
+ prompt carries the council's shared proposals/critiques (under a degraded
109
+ memtomem it injects nothing and each synthesizer falls back to its own
110
+ accumulated worktree). No member completing/passing the gate is an
111
+ aggregate failure.
112
+
113
+ The returned winner's ``duration_seconds`` is the council's wall-clock — the
114
+ sum of each concurrent phase's slowest agent (propose + every critique round +
115
+ synthesize) — not just the synthesizer's own time. The Supervisor shares the
116
+ winner to ``shared`` and tears down every worktree, exactly as for Compete.
117
+
118
+ **Breaker (#8) is recorded exactly ONCE per agent, at propose** — even though a
119
+ member runs up to ``2 + rounds`` times. Propose is the unique phase where every
120
+ runnable agent executes once, from identical input, so it is the fair
121
+ "can this agent run?" probe the breaker wants. The breaker threshold is
122
+ denominated in *runs*; recording every council execution would make council
123
+ trip it ~``(2 + rounds)``× faster than the other strategies (a strategy-
124
+ dependent regression) and conflate execution-reliability with a member
125
+ struggling on the harder critique/synthesis prompts — the same reason a quality
126
+ gate rejection never trips the breaker. Critique/synthesize re-runs of an
127
+ already-recorded member are therefore not recorded, keeping council's
128
+ contribution to the cross-run "N failures in a row" count one-per-agent-per-run,
129
+ identical to Route/Pipeline/Compete.
130
+
131
+ Tracing (S6): ``propose:{agent}`` fans out from ``run`` (with
132
+ ``worktree_slot`` — the one phase that acquires fresh worktrees);
133
+ ``critique:{agent}:{r}`` and ``synthesize:{agent}`` each parent on that
134
+ member's OWN previous-phase node (worktree continuity — its execution really
135
+ ran in, and possibly mutated, that worktree, so a FAILED critique still
136
+ becomes the next phase's parent), while span ``links`` carry the nodes whose
137
+ results were PUBLISHED to ``shared`` at the previous phase boundary —
138
+ declared availability, never claimed consumption (the handoff is a
139
+ relevance-ranked best-effort search, so actual consumption is unknowable).
140
+ Synthesis reuses Compete's select-span semantics: gate spans for the lazily
141
+ gated prefix, then ``select`` parented on the winning synthesize node (or
142
+ ``run`` when nobody won) linking the other raw-COMPLETED candidates. The
143
+ no-members early return leaves only propose spans (selection never ran, so
144
+ there is no select span).
145
+ """
146
+
147
+ def execute(
148
+ self,
149
+ task: Task,
150
+ agents: list[AgentRunner],
151
+ memory: SyncmillMemory,
152
+ pool: WorktreePool,
153
+ config: SyncmillConfig,
154
+ gate: QualityGate | None = None,
155
+ breaker: CircuitBreaker | None = None,
156
+ monitor: Monitor | None = None,
157
+ tracer: Tracer | None = None,
158
+ ) -> TaskResult:
159
+ if not agents: # Supervisor guards this; stay safe if run standalone
160
+ return _no_stages_failure(task)
161
+
162
+ # Drop circuit-open agents BEFORE acquiring worktrees / fanning out, so the
163
+ # lists stay positionally aligned. If the breaker tripped them all, say so.
164
+ runnable = _breaker_runnable(agents, breaker)
165
+ if not runnable:
166
+ return _breaker_all_open_failure(task, agents)
167
+
168
+ # One isolated worktree per agent, acquired UP FRONT and SERIALLY (acquire
169
+ # is sync git on the index lock; never inside the concurrent fan-out). Each
170
+ # agent reuses its own worktree across every phase, so its work accumulates.
171
+ worktrees = [pool.acquire(agent.agent_id) for agent in runnable]
172
+
173
+ def timeout_for(aid: str) -> int:
174
+ return config.agent_config(aid).timeout_seconds
175
+
176
+ elapsed = 0.0 # council wall-clock: sum of each concurrent phase's slowest
177
+
178
+ # ── Phase 1: PROPOSE (concurrent) ─────────────────────────────────
179
+ propose_prompt = _enrich_prompt(
180
+ _council_propose(task.prompt),
181
+ run_memory_bounded(
182
+ memory.search_context(task.prompt),
183
+ default="", memory=memory, label="search_context",
184
+ ),
185
+ )
186
+ _monitor_phase(monitor, "propose")
187
+ with _monitor_streaming(monitor, [a.agent_id for a in runnable]):
188
+ results = run_async(
189
+ aexecute_isolated(
190
+ runnable, propose_prompt, [wt.path for wt in worktrees], timeout_for
191
+ )
192
+ )
193
+ propose_end = time.time()
194
+ elapsed += max((r.duration_seconds for r in results), default=0.0)
195
+
196
+ # Record EVERY proposer's raw outcome — the council's single breaker
197
+ # recording point (see the class docstring), before any sharing/gating.
198
+ # Every proposer (completer or failure) also leaves a propose span, so
199
+ # later links to published proposals always resolve.
200
+ for slot, (agent, result) in enumerate(zip(runnable, results)):
201
+ _breaker_record(breaker, agent.agent_id, result.status)
202
+ _monitor_status(monitor, agent.agent_id, result.status)
203
+ _trace_attempt(tracer, _attempt_span(
204
+ agent.agent_id,
205
+ node_id=f"propose:{agent.agent_id}",
206
+ parent="run",
207
+ attempt=0,
208
+ phase="propose",
209
+ raw_status=result.status,
210
+ error=result.error,
211
+ exit_code=result.exit_code,
212
+ end=propose_end,
213
+ duration=result.duration_seconds,
214
+ worktree_slot=slot,
215
+ ), result)
216
+
217
+ # The completers become the members; publish each proposal to `shared` so
218
+ # the critique rounds can read it. members carry only (agent, worktree) —
219
+ # no result state, because synthesis re-runs every member and _select_winner
220
+ # is the single convergence point.
221
+ members: list[tuple[AgentRunner, Worktree]] = []
222
+ for agent, worktree, result in zip(runnable, worktrees, results):
223
+ if result.status == TaskStatus.COMPLETED:
224
+ members.append((agent, worktree))
225
+ run_memory_bounded(
226
+ memory.share_result(task, result, kind="council-proposal"),
227
+ memory=memory, label="share_result",
228
+ )
229
+
230
+ if not members:
231
+ return _aggregate_failure(
232
+ task, results, duration_seconds=round(elapsed, 2)
233
+ )
234
+
235
+ member_agents = [agent for agent, _ in members]
236
+ member_worktrees = [wt for _, wt in members]
237
+
238
+ # Trace bookkeeping: each member's own previous-phase node (its next
239
+ # span's parent — worktree continuity), and the nodes whose results were
240
+ # PUBLISHED to `shared` at the last phase boundary (the next spans'
241
+ # links — declared availability, the finding-6 approximation).
242
+ prev_node = {a.agent_id: f"propose:{a.agent_id}" for a in member_agents}
243
+ published = [
244
+ f"propose:{agent.agent_id}"
245
+ for agent, result in zip(runnable, results)
246
+ if result.status == TaskStatus.COMPLETED
247
+ ]
248
+
249
+ # ── Phase 2: CRITIQUE × rounds (concurrent over members) ───────────
250
+ member_ids = [a.agent_id for a in member_agents]
251
+ for round_no in range(config.council.rounds):
252
+ critique_prompt = _enrich_prompt(
253
+ _council_critique(task.prompt),
254
+ run_memory_bounded(
255
+ memory.search_context(
256
+ task.prompt,
257
+ tag_filter=(
258
+ "council-proposal"
259
+ if round_no == 0
260
+ else "council-proposal,council-critique"
261
+ ),
262
+ ),
263
+ default="",
264
+ memory=memory,
265
+ label="search_context",
266
+ ),
267
+ )
268
+ _monitor_phase(monitor, f"critique {round_no + 1}/{config.council.rounds}")
269
+ with _monitor_streaming(monitor, member_ids):
270
+ critiques = run_async(
271
+ aexecute_isolated(
272
+ member_agents,
273
+ critique_prompt,
274
+ [wt.path for wt in member_worktrees],
275
+ timeout_for,
276
+ )
277
+ )
278
+ critique_end = time.time()
279
+ elapsed += max((c.duration_seconds for c in critiques), default=0.0)
280
+ # No breaker recording here (already recorded at propose). Share each
281
+ # completed critique back to `shared`; a failed critique is tolerated
282
+ # (not shared, member kept) — synthesis re-runs everyone regardless.
283
+ round_published: list[str] = []
284
+ for agent, critique in zip(member_agents, critiques):
285
+ _monitor_status(monitor, agent.agent_id, critique.status)
286
+ node_id = f"critique:{agent.agent_id}:{round_no}"
287
+ _trace_attempt(tracer, _attempt_span(
288
+ agent.agent_id,
289
+ node_id=node_id,
290
+ parent=prev_node[agent.agent_id],
291
+ attempt=0,
292
+ phase="critique",
293
+ round_no=round_no,
294
+ links=tuple(published),
295
+ raw_status=critique.status,
296
+ error=critique.error,
297
+ exit_code=critique.exit_code,
298
+ end=critique_end,
299
+ duration=critique.duration_seconds,
300
+ ), critique)
301
+ # Unconditional: a FAILED critique still ran in (and possibly
302
+ # mutated) the member's worktree, so it stays the next parent —
303
+ # it just never becomes a peer's link target (published only).
304
+ prev_node[agent.agent_id] = node_id
305
+ if critique.status == TaskStatus.COMPLETED:
306
+ round_published.append(node_id)
307
+ run_memory_bounded(
308
+ memory.share_result(task, critique, kind="council-critique"),
309
+ memory=memory, label="share_result",
310
+ )
311
+ published = round_published
312
+
313
+ # ── Phase 3: SYNTHESIZE (concurrent over members) + converge ───────
314
+ synth_prompt = _enrich_prompt(
315
+ _council_synthesize(task.prompt),
316
+ run_memory_bounded(
317
+ memory.search_context(
318
+ task.prompt,
319
+ tag_filter="council-proposal,council-critique",
320
+ ),
321
+ default="",
322
+ memory=memory,
323
+ label="search_context",
324
+ ),
325
+ )
326
+ _monitor_phase(monitor, "synthesize")
327
+ with _monitor_streaming(monitor, member_ids):
328
+ synth_results = run_async(
329
+ aexecute_isolated(
330
+ member_agents,
331
+ synth_prompt,
332
+ [wt.path for wt in member_worktrees],
333
+ timeout_for,
334
+ )
335
+ )
336
+ synth_end = time.time()
337
+ elapsed += max((s.duration_seconds for s in synth_results), default=0.0)
338
+ # RAW pre-select snapshot for the spans: `_select_winner` downgrades
339
+ # gate-failing completers in place, and the winner's duration is mutated
340
+ # to the whole-council wall-clock below — the span must carry the
341
+ # synthesizer's own raw outcome/duration (3-tuple, unlike compete's 2).
342
+ raw = [(s.status, s.error, s.duration_seconds) for s in synth_results]
343
+ # No breaker recording here either (already recorded at propose).
344
+ for agent, synth in zip(member_agents, synth_results):
345
+ _monitor_status(monitor, agent.agent_id, synth.status)
346
+
347
+ # Converge via Compete's selection seam: first gate-passing COMPLETED
348
+ # synthesis in priority order (a gate failer is downgraded in place so the
349
+ # aggregate below names it). Nobody qualifying → aggregate failure.
350
+ sel_start = time.time()
351
+ trace_out: dict = {}
352
+ winner = _select_winner(
353
+ member_worktrees,
354
+ synth_results,
355
+ gate,
356
+ mode=config.selection.mode,
357
+ agents=member_agents,
358
+ pool=pool,
359
+ config=config,
360
+ tracer=tracer,
361
+ candidate_node_prefix="synthesize",
362
+ trace_out=trace_out,
363
+ )
364
+ winner_result: TaskResult | None = None
365
+ if winner is not None:
366
+ winner_result, winner_worktree = winner
367
+ winner_result.duration_seconds = round(elapsed, 2) # council wall-clock
368
+ _collect_files_changed(winner_result, winner_worktree) # its OWN diff
369
+ sel_end = time.time()
370
+
371
+ if tracer is not None:
372
+ for (agent, synth), (raw_status, raw_error, raw_duration) in zip(
373
+ zip(member_agents, synth_results), raw
374
+ ):
375
+ is_winner = synth is winner_result
376
+ node_id = f"synthesize:{agent.agent_id}"
377
+ _trace_attempt(tracer, _attempt_span(
378
+ agent.agent_id,
379
+ node_id=node_id,
380
+ parent=prev_node[agent.agent_id], # its last critique node
381
+ attempt=0,
382
+ phase="synthesize",
383
+ links=tuple(published), # the last round's published critiques
384
+ raw_status=raw_status,
385
+ error=raw_error,
386
+ exit_code=synth.exit_code,
387
+ end=synth_end,
388
+ duration=raw_duration,
389
+ winner=is_winner,
390
+ files_changed_count=(
391
+ len(synth.files_changed) if is_winner else None
392
+ ),
393
+ ), synth)
394
+ if synth.gate is not None: # the lazily-gated prefix only
395
+ _trace_record(tracer, _gate_span(
396
+ agent.agent_id,
397
+ node_id=f"gate:{agent.agent_id}",
398
+ parent=node_id,
399
+ report=synth.gate,
400
+ end=sel_end,
401
+ ))
402
+ links = [
403
+ f"synthesize:{agent.agent_id}"
404
+ for agent, synth, (raw_status, _, _) in zip(
405
+ member_agents, synth_results, raw
406
+ )
407
+ if raw_status == TaskStatus.COMPLETED and synth is not winner_result
408
+ ]
409
+ if trace_out.get("judge_span") is not None:
410
+ # judge mode ran: emit its deferred span now that the candidate
411
+ # spans it links are recorded (a streaming sink drops forward
412
+ # links), and link the verdict the selection depended on.
413
+ _trace_attempt(tracer, *trace_out["judge_span"])
414
+ links.append(trace_out["judge_node"])
415
+ _trace_record(tracer, _select_span(
416
+ winner_node=(
417
+ f"synthesize:{winner_result.agent_id}"
418
+ if winner_result is not None else None
419
+ ),
420
+ links=links,
421
+ start=sel_start,
422
+ end=sel_end,
423
+ ))
424
+
425
+ if winner_result is None:
426
+ return _aggregate_failure(
427
+ task, synth_results, duration_seconds=round(elapsed, 2)
428
+ )
429
+ return winner_result