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,570 @@
|
|
|
1
|
+
"""DecomposeStrategy (v0.3) — split a task into subtasks across agents, synthesize."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
|
|
10
|
+
from syncmill.agents import AgentRunner, aexecute_each, aexecute_isolated
|
|
11
|
+
from syncmill.breaker import CircuitBreaker
|
|
12
|
+
from syncmill.config import SyncmillConfig
|
|
13
|
+
from syncmill.gates import QualityGate
|
|
14
|
+
from syncmill.memory import (
|
|
15
|
+
_CONTEXT_TOP_K,
|
|
16
|
+
SyncmillMemory,
|
|
17
|
+
run_async,
|
|
18
|
+
run_memory_bounded,
|
|
19
|
+
)
|
|
20
|
+
from syncmill.models import Task, TaskResult, TaskStatus
|
|
21
|
+
from syncmill.monitor import Monitor
|
|
22
|
+
from syncmill.tracer import Tracer
|
|
23
|
+
from syncmill.strategies.base import (
|
|
24
|
+
_aggregate_failure,
|
|
25
|
+
_attempt_span,
|
|
26
|
+
_breaker_all_open_failure,
|
|
27
|
+
_breaker_record,
|
|
28
|
+
_breaker_runnable,
|
|
29
|
+
_collect_files_changed,
|
|
30
|
+
_enrich_prompt,
|
|
31
|
+
_gate_span,
|
|
32
|
+
_monitor_phase,
|
|
33
|
+
_monitor_status,
|
|
34
|
+
_monitor_streaming,
|
|
35
|
+
_no_stages_failure,
|
|
36
|
+
_select_span,
|
|
37
|
+
_select_winner,
|
|
38
|
+
_trace_attempt,
|
|
39
|
+
_trace_record,
|
|
40
|
+
)
|
|
41
|
+
from syncmill.worktree import WorktreePool
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _decompose_plan_prompt(task_prompt: str, max_subtasks: int) -> str:
|
|
47
|
+
"""Phase-1 instruction: the planner breaks the task into subtasks.
|
|
48
|
+
|
|
49
|
+
Asks for a STRICT JSON array of strings (no prose, no fences) so the plan parses
|
|
50
|
+
deterministically; a non-conforming/empty plan degrades to a single whole-task
|
|
51
|
+
subtask. Wrapped by ``_enrich_prompt`` (shared context prepended as "Context
|
|
52
|
+
from previous work", this labeled "Task"), so it's phrased to avoid a nested
|
|
53
|
+
literal "Task:".
|
|
54
|
+
"""
|
|
55
|
+
return (
|
|
56
|
+
"You are the planner for a larger software task that will be split into "
|
|
57
|
+
"independent subtasks, each implemented by a separate coding agent in its "
|
|
58
|
+
"own isolated copy of the repository (the agents cannot see each other's "
|
|
59
|
+
"work). Break the work into at most "
|
|
60
|
+
f"{max_subtasks} self-contained subtasks that can be implemented in "
|
|
61
|
+
"parallel, each a single concrete, standalone instruction. Output ONLY a "
|
|
62
|
+
"JSON array of strings — no prose, no markdown fences, no wrapping object — "
|
|
63
|
+
'for example ["add the parser module", "add tests for the parser"]. Do NOT '
|
|
64
|
+
"modify any files; output only the plan.\n\n"
|
|
65
|
+
f"The overall task to break down: {task_prompt}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _decompose_subtask_prompt(
|
|
70
|
+
task_prompt: str, subtask: str, index: int, total: int
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Per-subtask instruction (one of ``total``, run concurrently and isolated).
|
|
73
|
+
|
|
74
|
+
Wrapped by ``_enrich_prompt`` (shared context prepended, this labeled "Task"),
|
|
75
|
+
so it reads as the assignment; phrased to avoid a nested literal "Task:". The
|
|
76
|
+
agent sees only its own piece — other subtasks run in parallel in separate
|
|
77
|
+
worktrees it can't read — so it must implement its slice completely.
|
|
78
|
+
"""
|
|
79
|
+
return (
|
|
80
|
+
f"You are implementing subtask {index} of {total} of a larger software "
|
|
81
|
+
"task, working in your own isolated copy of the repository. The other "
|
|
82
|
+
"subtasks are being implemented in parallel by other agents whose work you "
|
|
83
|
+
"cannot see, so implement ONLY your piece, completely and self-containedly. "
|
|
84
|
+
"Explain what you changed and why in your final message so the work can be "
|
|
85
|
+
"integrated later.\n\n"
|
|
86
|
+
f"For reference, the overall task is: {task_prompt}\n\n"
|
|
87
|
+
f"YOUR subtask to implement now: {subtask}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _decompose_synthesize_prompt(task_prompt: str) -> str:
|
|
92
|
+
"""Final instruction: integrate the subtask results into one solution.
|
|
93
|
+
|
|
94
|
+
Every runnable agent runs this concurrently in a FRESH worktree and reads the
|
|
95
|
+
subtask summaries from the prepended ``shared`` context; the winner is chosen by
|
|
96
|
+
``_select_winner``. Integration is via shared KNOWLEDGE, not a file merge — the
|
|
97
|
+
subtask worktrees are gone — so each synthesizer must produce a complete,
|
|
98
|
+
standalone implementation rather than a diff against a peer.
|
|
99
|
+
"""
|
|
100
|
+
return (
|
|
101
|
+
"Independent coding agents have each implemented one subtask of a larger "
|
|
102
|
+
"software task in separate workspaces; the context above holds their "
|
|
103
|
+
"summaries of what each did. Integrate that work into a single, coherent, "
|
|
104
|
+
"complete implementation of the overall task in THIS worktree — reconciling "
|
|
105
|
+
"any overlaps or gaps between the pieces. Produce a standalone solution, not "
|
|
106
|
+
"a diff against another agent's work.\n\n"
|
|
107
|
+
f"The overall task to complete: {task_prompt}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _synthesis_window(published: int) -> int:
|
|
112
|
+
"""Rows to request for synthesis: the published count, never below default.
|
|
113
|
+
|
|
114
|
+
Widening alone was a half-fix. `max_subtasks` (8) exceeds the default window
|
|
115
|
+
(5), so a full breakdown needs the widening; but a breakdown that mostly
|
|
116
|
+
FAILED, or a degraded single-subtask plan, publishes fewer than 5, and
|
|
117
|
+
asking for exactly that count would narrow the window below where it started
|
|
118
|
+
and let the plan row crowd out the one result that matters.
|
|
119
|
+
"""
|
|
120
|
+
return max(_CONTEXT_TOP_K, published)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_subtasks(raw: str, max_subtasks: int) -> list[str]:
|
|
124
|
+
"""Parse the planner's output into an ordered list of subtask strings.
|
|
125
|
+
|
|
126
|
+
Tolerant of a surrounding markdown fence (but not of arbitrary prose — see
|
|
127
|
+
``_extract_json_list``). STRICT on the array's contents: it accepts only a
|
|
128
|
+
non-empty list whose every element is a non-empty string, then truncates to
|
|
129
|
+
``max_subtasks`` (logging what it drops — no silent cap). A mixed or partial
|
|
130
|
+
array — objects, numbers, blanks alongside strings (e.g.
|
|
131
|
+
``[{"task": "x"}, "y"]`` or ``["x", 123]``) — is treated as malformed and
|
|
132
|
+
returns ``[]`` rather than *salvaging the string subset*, which would silently
|
|
133
|
+
run a wrong/partial plan (the same failure class ``_extract_json_list`` guards
|
|
134
|
+
against for prose/objects). Returns ``[]`` when nothing usable parses; the
|
|
135
|
+
caller degrades that to a single whole-task subtask.
|
|
136
|
+
"""
|
|
137
|
+
items = _extract_json_list(raw)
|
|
138
|
+
if not items or not all(isinstance(s, str) and s.strip() for s in items):
|
|
139
|
+
return [] # empty / mixed / partial array → degrade, never salvage a subset
|
|
140
|
+
cleaned = [s.strip() for s in items]
|
|
141
|
+
if len(cleaned) > max_subtasks:
|
|
142
|
+
logger.info(
|
|
143
|
+
"decompose: capping %d planned subtasks to max_subtasks=%d (dropping %d)",
|
|
144
|
+
len(cleaned),
|
|
145
|
+
max_subtasks,
|
|
146
|
+
len(cleaned) - max_subtasks,
|
|
147
|
+
)
|
|
148
|
+
cleaned = cleaned[:max_subtasks]
|
|
149
|
+
return cleaned
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _extract_json_list(raw: str) -> list:
|
|
153
|
+
"""Parse ``raw`` as a JSON array — the whole message, after stripping a
|
|
154
|
+
surrounding markdown fence. Returns ``[]`` if it isn't a JSON array.
|
|
155
|
+
|
|
156
|
+
Deliberately STRICT: it does **not** greedily slice a ``[...]`` span out of
|
|
157
|
+
prose. A greedy slice would happily lift an *example* array out of a refusal
|
|
158
|
+
(``"I can't; the format would be [\\"a\\", \\"b\\"]"``) or the inner array out of
|
|
159
|
+
a wrapping object — yielding a silently-wrong breakdown instead of degrading.
|
|
160
|
+
The planner is instructed to output ONLY the array, so anything that isn't a
|
|
161
|
+
bare array (optionally fenced) degrades to the single whole-task subtask — the
|
|
162
|
+
safe default — rather than risking a wrong plan.
|
|
163
|
+
"""
|
|
164
|
+
text = raw.strip()
|
|
165
|
+
if text.startswith("```"): # strip a ```json … ``` fence if present
|
|
166
|
+
lines = text.splitlines()
|
|
167
|
+
if lines and lines[0].startswith("```"):
|
|
168
|
+
lines = lines[1:]
|
|
169
|
+
if lines and lines[-1].strip().startswith("```"):
|
|
170
|
+
lines = lines[:-1]
|
|
171
|
+
text = "\n".join(lines).strip()
|
|
172
|
+
try:
|
|
173
|
+
parsed = json.loads(text)
|
|
174
|
+
except (json.JSONDecodeError, ValueError):
|
|
175
|
+
return []
|
|
176
|
+
return parsed if isinstance(parsed, list) else []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class DecomposeStrategy:
|
|
180
|
+
"""Split a compound task into subtasks, run them ACROSS agents concurrently and
|
|
181
|
+
in isolation, then synthesize the results into one outcome (#14, v0.3).
|
|
182
|
+
|
|
183
|
+
syncmill's first task-SPLITTING strategy: where route/pipeline/compete/council
|
|
184
|
+
run agents on the SAME task, decompose runs agents on DIFFERENT pieces. Three
|
|
185
|
+
phases, bridging async via ``run_async`` over the concurrent fan-outs, with all
|
|
186
|
+
memory I/O done strictly BETWEEN fan-outs (never ``run_async`` inside a gathered
|
|
187
|
+
coroutine — the shared-loop rule):
|
|
188
|
+
|
|
189
|
+
1. **Decompose** (sync, single planner) — the highest-priority runnable agent
|
|
190
|
+
plans the breakdown in a scratch worktree it's told not to edit: it returns a
|
|
191
|
+
JSON array of subtask strings, parsed by ``_parse_subtasks``. A failed,
|
|
192
|
+
empty, or unparseable plan degrades to a single whole-task subtask, so a
|
|
193
|
+
flaky planner never aborts the run (best-effort, like memory) — decompose
|
|
194
|
+
then behaves like "do the whole task, synthesized across agents". The planner
|
|
195
|
+
is a single sequential step, so it uses the sync ``execute`` path (like
|
|
196
|
+
route/pipeline); only the genuinely concurrent phases fan out.
|
|
197
|
+
2. **Subtasks** (concurrent, isolated, ACROSS agents) — the subtasks are
|
|
198
|
+
distributed round-robin over the runnable agents (faithful to the issue's
|
|
199
|
+
"split a task into subtasks *across agents*") and run at once, each in its own
|
|
200
|
+
worktree via ``aexecute_each`` (the per-item-prompt counterpart to
|
|
201
|
+
``aexecute_isolated``'s broadcast). Each COMPLETED subtask's summary is
|
|
202
|
+
published to ``shared``; a failed subtask is tolerated (just not shared). If
|
|
203
|
+
NONE complete there is nothing to integrate → aggregate failure (like council
|
|
204
|
+
when no proposal completes).
|
|
205
|
+
3. **Synthesize** (concurrent over all runnable) + converge — every runnable
|
|
206
|
+
agent reads the subtask summaries from ``shared`` (relevance-ranked,
|
|
207
|
+
best-effort — the handoff pipeline/council use) and produces the integrated
|
|
208
|
+
solution in a FRESH worktree; the council/compete ``_select_winner`` seam
|
|
209
|
+
picks the winner (first gate-passing COMPLETED in priority order). Integration
|
|
210
|
+
is via shared KNOWLEDGE, not a file merge — the subtask worktrees are torn
|
|
211
|
+
down, exactly as compete/council discard the losers' trees.
|
|
212
|
+
|
|
213
|
+
The returned winner's ``duration_seconds`` is the decompose wall-clock (planner
|
|
214
|
+
+ slowest subtask + slowest synthesizer); its ``files_changed`` is its own
|
|
215
|
+
worktree's diff. The Supervisor shares the winner to ``shared`` and tears every
|
|
216
|
+
worktree down, as for compete/council — decompose neither cleans worktrees nor
|
|
217
|
+
applies a diff.
|
|
218
|
+
|
|
219
|
+
**Breaker (#8) is recorded once per agent per run, at each agent's FIRST
|
|
220
|
+
execution** (a dedup set). Unlike council — whose propose phase runs every agent
|
|
221
|
+
once from identical input, the natural single recording point — decompose has no
|
|
222
|
+
such uniform-and-easy phase: the planner is one agent on a planning prompt, the
|
|
223
|
+
subtasks are a non-uniform round-robin, and synthesis (the only phase where
|
|
224
|
+
every runnable agent runs) is the HARDEST prompt, where recording would
|
|
225
|
+
over-penalize a working agent struggling on integration — the very reason
|
|
226
|
+
council records at propose, not synthesize. So decompose records each agent's RAW
|
|
227
|
+
outcome the first time it runs anything this invocation (planner, a subtask, or
|
|
228
|
+
synthesis) and skips it thereafter — keeping the one-record-per-agent-per-run
|
|
229
|
+
semantics identical to the other strategies while still probing every agent that
|
|
230
|
+
runs, and recording the planner at its short/early (so typically easy) planning
|
|
231
|
+
step rather than at the hard synthesis phase. As everywhere, the RAW pre-gate
|
|
232
|
+
status is recorded, so a synthesis gate rejection (a quality verdict on a working
|
|
233
|
+
agent) never trips the breaker.
|
|
234
|
+
|
|
235
|
+
Tracing (S6): the ``plan`` node is the fan-out hub — recorded even when the
|
|
236
|
+
plan FAILED/degraded (the degraded whole-task subtask still parents on it).
|
|
237
|
+
Each ``subtask:{i}`` parents on ``plan`` (its NAME is ``subtask:{i}:{agent}``
|
|
238
|
+
— the round-robin repeats agent ids, so the diff-stable label needs the
|
|
239
|
+
index) with ``worktree_slot=i``. Each ``synthesize:{agent}`` also parents on
|
|
240
|
+
``plan`` and links the COMPLETED subtask nodes — only completed subtasks were
|
|
241
|
+
published to ``shared``, i.e. declared available, never claimed consumed (the
|
|
242
|
+
finding-6 approximation). Convergence reuses Compete's select-span semantics
|
|
243
|
+
(gate spans for the lazily-gated prefix; ``select`` parented on the winning
|
|
244
|
+
synthesize node, else ``run``, linking the other raw-COMPLETED candidates).
|
|
245
|
+
The no-completed-subtasks early return leaves only plan + subtask spans
|
|
246
|
+
(selection never ran, so there is no select span).
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def execute(
|
|
250
|
+
self,
|
|
251
|
+
task: Task,
|
|
252
|
+
agents: list[AgentRunner],
|
|
253
|
+
memory: SyncmillMemory,
|
|
254
|
+
pool: WorktreePool,
|
|
255
|
+
config: SyncmillConfig,
|
|
256
|
+
gate: QualityGate | None = None,
|
|
257
|
+
breaker: CircuitBreaker | None = None,
|
|
258
|
+
monitor: Monitor | None = None,
|
|
259
|
+
tracer: Tracer | None = None,
|
|
260
|
+
) -> TaskResult:
|
|
261
|
+
if not agents: # Supervisor guards this; stay safe if run standalone
|
|
262
|
+
return _no_stages_failure(task)
|
|
263
|
+
|
|
264
|
+
# Drop circuit-open agents BEFORE any work; if the breaker tripped them all,
|
|
265
|
+
# say so rather than planning/fanning out for nobody.
|
|
266
|
+
runnable = _breaker_runnable(agents, breaker)
|
|
267
|
+
if not runnable:
|
|
268
|
+
return _breaker_all_open_failure(task, agents)
|
|
269
|
+
|
|
270
|
+
def timeout_for(aid: str) -> int:
|
|
271
|
+
return config.agent_config(aid).timeout_seconds
|
|
272
|
+
|
|
273
|
+
# Record each agent's RAW execution outcome exactly ONCE per run, at its
|
|
274
|
+
# first execution (see the class docstring) — preserving the
|
|
275
|
+
# one-record-per-agent-per-run semantics the cross-run breaker threshold
|
|
276
|
+
# relies on, while still probing every agent that runs.
|
|
277
|
+
recorded: set[str] = set()
|
|
278
|
+
|
|
279
|
+
def record_once(agent_id: str, status: TaskStatus) -> None:
|
|
280
|
+
if agent_id not in recorded:
|
|
281
|
+
recorded.add(agent_id)
|
|
282
|
+
_breaker_record(breaker, agent_id, status)
|
|
283
|
+
|
|
284
|
+
elapsed = 0.0 # decompose wall-clock: planner + slowest subtask + slowest synth
|
|
285
|
+
|
|
286
|
+
# ── Phase 1: DECOMPOSE (sync, single planner) ─────────────────────
|
|
287
|
+
# The planner uses the SYNC execute path, which the streaming seam (an
|
|
288
|
+
# aexecute-only ContextVar) doesn't reach — so this phase is status-only in
|
|
289
|
+
# the monitor (the `status.log` line is the signal; no live pane).
|
|
290
|
+
_monitor_phase(monitor, "plan")
|
|
291
|
+
planner = runnable[0]
|
|
292
|
+
plan_wt = pool.acquire(planner.agent_id)
|
|
293
|
+
plan_prompt = _enrich_prompt(
|
|
294
|
+
_decompose_plan_prompt(task.prompt, config.decompose.max_subtasks),
|
|
295
|
+
run_memory_bounded(
|
|
296
|
+
memory.search_context(task.prompt),
|
|
297
|
+
default="", memory=memory, label="search_context",
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
plan = planner.execute(
|
|
301
|
+
prompt=plan_prompt,
|
|
302
|
+
workdir=plan_wt.path,
|
|
303
|
+
timeout=timeout_for(planner.agent_id),
|
|
304
|
+
)
|
|
305
|
+
plan_end = time.time()
|
|
306
|
+
elapsed += plan.duration_seconds
|
|
307
|
+
record_once(planner.agent_id, plan.status)
|
|
308
|
+
_monitor_status(monitor, planner.agent_id, plan.status)
|
|
309
|
+
# The plan node is the fan-out hub — recorded even when FAILED/degraded,
|
|
310
|
+
# since the degraded whole-task subtask still parents on it.
|
|
311
|
+
_trace_attempt(tracer, _attempt_span(
|
|
312
|
+
planner.agent_id,
|
|
313
|
+
node_id="plan",
|
|
314
|
+
parent="run",
|
|
315
|
+
attempt=0,
|
|
316
|
+
phase="plan",
|
|
317
|
+
raw_status=plan.status,
|
|
318
|
+
error=plan.error,
|
|
319
|
+
exit_code=plan.exit_code,
|
|
320
|
+
end=plan_end,
|
|
321
|
+
duration=plan.duration_seconds,
|
|
322
|
+
), plan)
|
|
323
|
+
|
|
324
|
+
subtasks = (
|
|
325
|
+
_parse_subtasks(plan.output, config.decompose.max_subtasks)
|
|
326
|
+
if plan.status == TaskStatus.COMPLETED
|
|
327
|
+
else []
|
|
328
|
+
)
|
|
329
|
+
if subtasks:
|
|
330
|
+
# Publish the work plan itself to `shared` so unified search (and
|
|
331
|
+
# tag_filter="decompose-plan") can surface how the task was split.
|
|
332
|
+
# Sits between the sync planner and the fan-out (shared-loop rule).
|
|
333
|
+
# Share the PARSED plan (what actually runs), not the raw planner
|
|
334
|
+
# response — _parse_subtasks may have normalized or capped it, and
|
|
335
|
+
# advertising dropped subtasks would mislead later enrichment.
|
|
336
|
+
parsed_plan = replace(
|
|
337
|
+
plan, output=json.dumps(subtasks, ensure_ascii=False)
|
|
338
|
+
)
|
|
339
|
+
run_memory_bounded(
|
|
340
|
+
memory.share_result(task, parsed_plan, kind="decompose-plan"),
|
|
341
|
+
memory=memory, label="share_result",
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
logger.info(
|
|
345
|
+
"decompose: no usable subtask plan — running the whole task as one "
|
|
346
|
+
"subtask"
|
|
347
|
+
)
|
|
348
|
+
# The degraded whole-task fallback is not a real plan — never shared.
|
|
349
|
+
subtasks = [task.prompt]
|
|
350
|
+
|
|
351
|
+
# ── Phase 2: SUBTASKS (concurrent, isolated, round-robin across agents) ─
|
|
352
|
+
n = len(subtasks)
|
|
353
|
+
assigned = [runnable[i % len(runnable)] for i in range(n)]
|
|
354
|
+
sub_worktrees = [pool.acquire(agent.agent_id) for agent in assigned]
|
|
355
|
+
# One shared-memory read, broadcast as context to every subtask (the
|
|
356
|
+
# subtasks are independent and run before any of them shares back, so a
|
|
357
|
+
# per-subtask search would only surface prior-run context anyway). Done
|
|
358
|
+
# fully sync, before the fan-out.
|
|
359
|
+
sub_context = run_memory_bounded(
|
|
360
|
+
memory.search_context(
|
|
361
|
+
task.prompt, tag_filter="decompose-plan,decompose-subtask"
|
|
362
|
+
),
|
|
363
|
+
default="",
|
|
364
|
+
memory=memory,
|
|
365
|
+
label="search_context",
|
|
366
|
+
)
|
|
367
|
+
sub_prompts = [
|
|
368
|
+
_enrich_prompt(
|
|
369
|
+
_decompose_subtask_prompt(task.prompt, subtasks[i], i + 1, n),
|
|
370
|
+
sub_context,
|
|
371
|
+
)
|
|
372
|
+
for i in range(n)
|
|
373
|
+
]
|
|
374
|
+
_monitor_phase(monitor, "subtasks")
|
|
375
|
+
# The round-robin runs one agent several times AT ONCE, so the monitor is
|
|
376
|
+
# keyed per SUBTASK rather than per agent — keyed by agent id, subtasks 0
|
|
377
|
+
# and 2 of the same agent would interleave byte-wise in one log and share
|
|
378
|
+
# one pane. Every other strategy runs each agent once per fan-out, so its
|
|
379
|
+
# labels stay the bare agent ids.
|
|
380
|
+
sub_labels = [f"{a.agent_id}-sub{i + 1}" for i, a in enumerate(assigned)]
|
|
381
|
+
with _monitor_streaming(monitor, sub_labels):
|
|
382
|
+
sub_results = run_async(
|
|
383
|
+
aexecute_each(
|
|
384
|
+
assigned,
|
|
385
|
+
sub_prompts,
|
|
386
|
+
[wt.path for wt in sub_worktrees],
|
|
387
|
+
timeout_for,
|
|
388
|
+
labels=sub_labels,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
subtask_end = time.time()
|
|
392
|
+
elapsed += max((r.duration_seconds for r in sub_results), default=0.0)
|
|
393
|
+
for i, (agent, result) in enumerate(zip(assigned, sub_results)):
|
|
394
|
+
record_once(agent.agent_id, result.status)
|
|
395
|
+
_monitor_status(monitor, sub_labels[i], result.status)
|
|
396
|
+
_trace_attempt(tracer, _attempt_span(
|
|
397
|
+
agent.agent_id,
|
|
398
|
+
node_id=f"subtask:{i}",
|
|
399
|
+
# the round-robin repeats agent ids, so the diff-stable label
|
|
400
|
+
# needs the index (the one name= override across strategies)
|
|
401
|
+
name=f"subtask:{i}:{agent.agent_id}",
|
|
402
|
+
parent="plan",
|
|
403
|
+
attempt=i,
|
|
404
|
+
phase="subtask",
|
|
405
|
+
raw_status=result.status,
|
|
406
|
+
error=result.error,
|
|
407
|
+
exit_code=result.exit_code,
|
|
408
|
+
end=subtask_end,
|
|
409
|
+
duration=result.duration_seconds,
|
|
410
|
+
worktree_slot=i,
|
|
411
|
+
), result)
|
|
412
|
+
|
|
413
|
+
completed = [r for r in sub_results if r.status == TaskStatus.COMPLETED]
|
|
414
|
+
# Only completed subtasks are published to `shared` — the synthesize
|
|
415
|
+
# spans link exactly these (declared availability).
|
|
416
|
+
completed_nodes = tuple(
|
|
417
|
+
f"subtask:{i}"
|
|
418
|
+
for i, r in enumerate(sub_results)
|
|
419
|
+
if r.status == TaskStatus.COMPLETED
|
|
420
|
+
)
|
|
421
|
+
if not completed:
|
|
422
|
+
# Nothing decomposed completed → nothing to integrate. Concurrent, so the
|
|
423
|
+
# wall-clock is the slowest, not the sum (like Compete/Council).
|
|
424
|
+
return _aggregate_failure(
|
|
425
|
+
task, sub_results, duration_seconds=round(elapsed, 2)
|
|
426
|
+
)
|
|
427
|
+
for result in completed: # publish each completed subtask for synthesis
|
|
428
|
+
run_memory_bounded(
|
|
429
|
+
memory.share_result(task, result, kind="decompose-subtask"),
|
|
430
|
+
memory=memory, label="share_result",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# ── Phase 3: SYNTHESIZE (concurrent over all runnable) + converge ──
|
|
434
|
+
synth_worktrees = [pool.acquire(agent.agent_id) for agent in runnable]
|
|
435
|
+
synth_prompt = _enrich_prompt(
|
|
436
|
+
_decompose_synthesize_prompt(task.prompt),
|
|
437
|
+
run_memory_bounded(
|
|
438
|
+
# Ask for as many rows as we just published, but never fewer
|
|
439
|
+
# than the default. The default window is 5 while `max_subtasks`
|
|
440
|
+
# is 8, so a default decompose asked for fewer rows than it had
|
|
441
|
+
# subtask results and could not surface three of them — in the
|
|
442
|
+
# one strategy whose entire job is integrating every piece.
|
|
443
|
+
# Passing the count RAW would fix that and break the other end:
|
|
444
|
+
# a run where one subtask completes would ask for one row, which
|
|
445
|
+
# is NARROWER than before, and the single completed subtask
|
|
446
|
+
# could lose its slot to older subtask output.
|
|
447
|
+
#
|
|
448
|
+
# This lifts the FIXED-FIVE ceiling. It is not a guarantee of
|
|
449
|
+
# inclusion and two ceilings remain even in principle: the
|
|
450
|
+
# request is clamped at `_CONTEXT_TOP_K_MAX`, so a breakdown
|
|
451
|
+
# larger than that is short again; and one published result can
|
|
452
|
+
# chunk into SEVERAL rows, so N results need not be N rows.
|
|
453
|
+
# Same-kind rows from earlier runs still compete, and the
|
|
454
|
+
# byte budget can shorten or drop rows, so the handoff is
|
|
455
|
+
# still best-effort — see `search_context` and #125.
|
|
456
|
+
memory.search_context(
|
|
457
|
+
task.prompt,
|
|
458
|
+
top_k=_synthesis_window(len(completed)),
|
|
459
|
+
tag_filter="decompose-subtask",
|
|
460
|
+
),
|
|
461
|
+
default="",
|
|
462
|
+
memory=memory,
|
|
463
|
+
label="search_context",
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
_monitor_phase(monitor, "synthesize")
|
|
467
|
+
with _monitor_streaming(monitor, [a.agent_id for a in runnable]):
|
|
468
|
+
synth_results = run_async(
|
|
469
|
+
aexecute_isolated(
|
|
470
|
+
runnable,
|
|
471
|
+
synth_prompt,
|
|
472
|
+
[wt.path for wt in synth_worktrees],
|
|
473
|
+
timeout_for,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
synth_end = time.time()
|
|
477
|
+
elapsed += max((r.duration_seconds for r in synth_results), default=0.0)
|
|
478
|
+
# RAW pre-select snapshot for the spans: `_select_winner` downgrades
|
|
479
|
+
# gate-failing completers in place, and the winner's duration is mutated
|
|
480
|
+
# to the whole-decompose wall-clock below — the span must carry the
|
|
481
|
+
# synthesizer's own raw outcome/duration (3-tuple, like council).
|
|
482
|
+
raw = [(r.status, r.error, r.duration_seconds) for r in synth_results]
|
|
483
|
+
for agent, result in zip(runnable, synth_results):
|
|
484
|
+
record_once(agent.agent_id, result.status)
|
|
485
|
+
_monitor_status(monitor, agent.agent_id, result.status)
|
|
486
|
+
|
|
487
|
+
# Converge via Compete's selection seam: first gate-passing COMPLETED synth
|
|
488
|
+
# in priority order (a gate failer is downgraded in place so the aggregate
|
|
489
|
+
# below names it). Nobody qualifying → aggregate failure.
|
|
490
|
+
sel_start = time.time()
|
|
491
|
+
trace_out: dict = {}
|
|
492
|
+
winner = _select_winner(
|
|
493
|
+
synth_worktrees,
|
|
494
|
+
synth_results,
|
|
495
|
+
gate,
|
|
496
|
+
mode=config.selection.mode,
|
|
497
|
+
agents=runnable,
|
|
498
|
+
pool=pool,
|
|
499
|
+
config=config,
|
|
500
|
+
tracer=tracer,
|
|
501
|
+
candidate_node_prefix="synthesize",
|
|
502
|
+
trace_out=trace_out,
|
|
503
|
+
)
|
|
504
|
+
winner_result: TaskResult | None = None
|
|
505
|
+
if winner is not None:
|
|
506
|
+
winner_result, winner_worktree = winner
|
|
507
|
+
winner_result.duration_seconds = round(elapsed, 2) # wall-clock
|
|
508
|
+
_collect_files_changed(winner_result, winner_worktree) # its OWN diff
|
|
509
|
+
sel_end = time.time()
|
|
510
|
+
|
|
511
|
+
if tracer is not None:
|
|
512
|
+
for slot, ((agent, synth), (raw_status, raw_error, raw_duration)) in (
|
|
513
|
+
enumerate(zip(zip(runnable, synth_results), raw))
|
|
514
|
+
):
|
|
515
|
+
is_winner = synth is winner_result
|
|
516
|
+
node_id = f"synthesize:{agent.agent_id}"
|
|
517
|
+
_trace_attempt(tracer, _attempt_span(
|
|
518
|
+
agent.agent_id,
|
|
519
|
+
node_id=node_id,
|
|
520
|
+
parent="plan", # fan-in over the plan's decomposition
|
|
521
|
+
attempt=0,
|
|
522
|
+
phase="synthesize",
|
|
523
|
+
links=completed_nodes, # the published subtask results
|
|
524
|
+
raw_status=raw_status,
|
|
525
|
+
error=raw_error,
|
|
526
|
+
exit_code=synth.exit_code,
|
|
527
|
+
end=synth_end,
|
|
528
|
+
duration=raw_duration,
|
|
529
|
+
winner=is_winner,
|
|
530
|
+
files_changed_count=(
|
|
531
|
+
len(synth.files_changed) if is_winner else None
|
|
532
|
+
),
|
|
533
|
+
worktree_slot=slot, # fresh worktrees for this fan-out
|
|
534
|
+
), synth)
|
|
535
|
+
if synth.gate is not None: # the lazily-gated prefix only
|
|
536
|
+
_trace_record(tracer, _gate_span(
|
|
537
|
+
agent.agent_id,
|
|
538
|
+
node_id=f"gate:{agent.agent_id}",
|
|
539
|
+
parent=node_id,
|
|
540
|
+
report=synth.gate,
|
|
541
|
+
end=sel_end,
|
|
542
|
+
))
|
|
543
|
+
links = [
|
|
544
|
+
f"synthesize:{agent.agent_id}"
|
|
545
|
+
for agent, synth, (raw_status, _, _) in zip(
|
|
546
|
+
runnable, synth_results, raw
|
|
547
|
+
)
|
|
548
|
+
if raw_status == TaskStatus.COMPLETED and synth is not winner_result
|
|
549
|
+
]
|
|
550
|
+
if trace_out.get("judge_span") is not None:
|
|
551
|
+
# judge mode ran: emit its deferred span now that the candidate
|
|
552
|
+
# spans it links are recorded (a streaming sink drops forward
|
|
553
|
+
# links), and link the verdict the selection depended on.
|
|
554
|
+
_trace_attempt(tracer, *trace_out["judge_span"])
|
|
555
|
+
links.append(trace_out["judge_node"])
|
|
556
|
+
_trace_record(tracer, _select_span(
|
|
557
|
+
winner_node=(
|
|
558
|
+
f"synthesize:{winner_result.agent_id}"
|
|
559
|
+
if winner_result is not None else None
|
|
560
|
+
),
|
|
561
|
+
links=links,
|
|
562
|
+
start=sel_start,
|
|
563
|
+
end=sel_end,
|
|
564
|
+
))
|
|
565
|
+
|
|
566
|
+
if winner_result is None:
|
|
567
|
+
return _aggregate_failure(
|
|
568
|
+
task, synth_results, duration_seconds=round(elapsed, 2)
|
|
569
|
+
)
|
|
570
|
+
return winner_result
|