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,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