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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|