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,221 @@
1
+ """PipelineStrategy (v0.2) — sequential chain of agents as stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from syncmill.agents import AgentRunner
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_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
+ _attempt_span,
17
+ _breaker_all_open_failure,
18
+ _breaker_record,
19
+ _breaker_runnable,
20
+ _collect_files_changed,
21
+ _enrich_prompt,
22
+ _gate_accepts,
23
+ _gate_span,
24
+ _no_stages_failure,
25
+ _trace_attempt,
26
+ _trace_record,
27
+ )
28
+ from syncmill.worktree import WorktreePool
29
+
30
+
31
+ class PipelineStrategy:
32
+ """Sequential chain. Agents run in priority order as pipeline stages, each
33
+ building on the previous stage's work.
34
+
35
+ Two handoff channels, both honoring agent isolation — agents stay pure
36
+ ``execute(prompt, workdir, timeout)`` functions and never receive another
37
+ stage's ``TaskResult``:
38
+
39
+ * **Knowledge** — each completed stage writes its result to ``shared``
40
+ (``share_result``); the next stage reads ``shared`` back
41
+ (``search_context``) and prepends the most relevant matches to its prompt.
42
+ This is best-effort and relevance-ranked, not a guaranteed point-to-point
43
+ handoff — it surfaces the strongest shared context, which for a short
44
+ pipeline is the recent stages — consistent with memory being advisory.
45
+ * **Files** — every stage shares the one worktree acquired from the pool, so
46
+ a stage edits the working tree the next stage inherits. Per-agent worktrees
47
+ deliberately do *not* apply here: isolating stages would defeat the file
48
+ handoff, so the pipeline acquires a single shared workspace (where
49
+ RouteStrategy/Compete acquire one per agent).
50
+
51
+ Fail-fast: a stage failure breaks the chain — later stages would have
52
+ nothing valid to build on — so the pipeline stops and reports which stage
53
+ broke.
54
+
55
+ Tracing (S6): each stage is a ``stage:{i}:{agent}`` span parented on the
56
+ previous stage's node (stage 0 on ``run``) — the chain IS the causality. A
57
+ failed stage records its span and stops (later stages never ran, so they
58
+ leave no spans). The final stage carries ``winner``/``files_changed_count``
59
+ when gate-accepted (it is the result the pipeline returns — route's
60
+ accepted-attempt rule), with its OWN duration snapshotted before the
61
+ pipeline-sum mutation; its gate verdict rides a ``gate:{agent}:{i}`` span.
62
+ No ``worktree_slot`` (sequential) and no select span (no convergence).
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, # sequential: nothing concurrent to watch
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
+ # A circuit-open stage is skipped; the pipeline chains the remaining
81
+ # stages (consistent with Route skipping OPEN agents). If the breaker has
82
+ # tripped every stage, report that rather than acquiring a worktree.
83
+ runnable = _breaker_runnable(agents, breaker)
84
+ if not runnable:
85
+ return _breaker_all_open_failure(task, agents)
86
+
87
+ # One worktree shared by every stage: a pipeline's whole point is that
88
+ # each stage builds on the working tree the previous stage left behind,
89
+ # so — unlike RouteStrategy/Compete — it acquires a single workspace.
90
+ worktree = pool.acquire(runnable[0].agent_id)
91
+
92
+ results: list[TaskResult] = []
93
+ last = len(runnable) - 1
94
+ prev_node: str | None = None
95
+ for stage, agent in enumerate(runnable):
96
+ # Each stage reads shared memory first, so it sees the prior stages'
97
+ # outputs (plus any context from earlier syncmill runs).
98
+ prompt = _enrich_prompt(
99
+ task.prompt,
100
+ run_memory_bounded(
101
+ memory.search_context(
102
+ task.prompt,
103
+ tag_filter="pipeline-stage" if stage > 0 else None,
104
+ ),
105
+ default="",
106
+ memory=memory,
107
+ label="search_context",
108
+ ),
109
+ )
110
+ timeout = config.agent_config(agent.agent_id).timeout_seconds
111
+ result = agent.execute(prompt=prompt, workdir=worktree.path, timeout=timeout)
112
+ # RAW snapshot for the stage span: status/error before any gate
113
+ # mutation, duration before the post-loop pipeline-sum mutation.
114
+ stage_end = time.time()
115
+ raw_status, raw_error = result.status, result.error
116
+ stage_duration = result.duration_seconds
117
+ results.append(result)
118
+ # Record each stage's RAW execution outcome (before the final gate).
119
+ _breaker_record(breaker, agent.agent_id, result.status)
120
+
121
+ node_id = f"stage:{stage}:{agent.agent_id}"
122
+ parent = prev_node if prev_node is not None else "run"
123
+ if result.status != TaskStatus.COMPLETED:
124
+ # The broken stage still leaves its span; later stages never ran.
125
+ _trace_attempt(tracer, _attempt_span(
126
+ agent.agent_id,
127
+ node_id=node_id,
128
+ parent=parent,
129
+ attempt=stage,
130
+ phase="stage",
131
+ raw_status=raw_status,
132
+ error=raw_error,
133
+ exit_code=result.exit_code,
134
+ end=stage_end,
135
+ duration=stage_duration,
136
+ ), result)
137
+ return _pipeline_failure(task, stage + 1, len(runnable), results)
138
+
139
+ # Hand this stage's output to the next one via shared memory
140
+ # (best-effort). The final stage is shared by the Supervisor, so
141
+ # don't double-write it here. The FINAL stage's span is recorded
142
+ # after the gate below, once winner/files_changed_count are known.
143
+ if stage != last:
144
+ _trace_attempt(tracer, _attempt_span(
145
+ agent.agent_id,
146
+ node_id=node_id,
147
+ parent=parent,
148
+ attempt=stage,
149
+ phase="stage",
150
+ raw_status=raw_status,
151
+ error=raw_error,
152
+ exit_code=result.exit_code,
153
+ end=stage_end,
154
+ duration=stage_duration,
155
+ ), result)
156
+ run_memory_bounded(
157
+ memory.share_result(task, result, kind="pipeline-stage"),
158
+ memory=memory, label="share_result",
159
+ )
160
+ else:
161
+ # Explicit bindings for the final stage's span below — relying
162
+ # on loop-leaked variables was correct only because the guards
163
+ # above make an empty loop impossible.
164
+ final_node, final_parent = node_id, parent
165
+ final_end, final_duration = stage_end, stage_duration
166
+ final_raw_status, final_raw_error = raw_status, raw_error
167
+ prev_node = node_id
168
+
169
+ # The chain's product is the last stage's result; report the whole
170
+ # pipeline's wall-clock as its duration. The gate runs once, against the
171
+ # FINAL cumulative worktree (intermediate stages may legitimately leave
172
+ # the tree broken mid-chain) — a gate failure fails the pipeline.
173
+ final = results[-1]
174
+ final.duration_seconds = round(sum(r.duration_seconds for r in results), 2)
175
+ accepted = _gate_accepts(final, worktree, gate)
176
+ if accepted:
177
+ _collect_files_changed(final, worktree)
178
+ _trace_attempt(tracer, _attempt_span(
179
+ final.agent_id,
180
+ node_id=final_node,
181
+ parent=final_parent,
182
+ attempt=last,
183
+ phase="stage",
184
+ raw_status=final_raw_status,
185
+ error=final_raw_error,
186
+ exit_code=final.exit_code,
187
+ end=final_end,
188
+ duration=final_duration,
189
+ winner=accepted,
190
+ files_changed_count=len(final.files_changed) if accepted else None,
191
+ ), final)
192
+ if final.gate is not None:
193
+ _trace_record(tracer, _gate_span(
194
+ final.agent_id,
195
+ node_id=f"gate:{final.agent_id}:{last}",
196
+ parent=final_node,
197
+ report=final.gate,
198
+ end=time.time(),
199
+ ))
200
+ # Gate-rejected → already downgraded to FAILED with the gate error.
201
+ return final
202
+
203
+
204
+ def _pipeline_failure(
205
+ task: Task, stage: int, total_stages: int, results: list[TaskResult]
206
+ ) -> TaskResult:
207
+ """Build the failure result for a broken pipeline stage."""
208
+ failed = results[-1]
209
+ reason = failed.error or f"exit_code={failed.exit_code}"
210
+ return TaskResult(
211
+ task_id=task.id,
212
+ agent_id=failed.agent_id,
213
+ status=TaskStatus.FAILED,
214
+ output=failed.output,
215
+ exit_code=failed.exit_code,
216
+ duration_seconds=round(sum(r.duration_seconds for r in results), 2),
217
+ error=(
218
+ f"Pipeline failed at stage {stage}/{total_stages} "
219
+ f"({failed.agent_id}): {reason}"
220
+ ),
221
+ )
@@ -0,0 +1,130 @@
1
+ """RouteStrategy (v0.1 MVP) — priority-based fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from syncmill.agents import AgentRunner
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_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_accepts,
23
+ _gate_span,
24
+ _trace_attempt,
25
+ _trace_record,
26
+ )
27
+ from syncmill.tracer import Tracer
28
+ from syncmill.worktree import WorktreePool
29
+
30
+
31
+ class RouteStrategy:
32
+ """Priority-based fallback. Try agents in order, fall back on failure."""
33
+
34
+ def execute(
35
+ self,
36
+ task: Task,
37
+ agents: list[AgentRunner],
38
+ memory: SyncmillMemory,
39
+ pool: WorktreePool,
40
+ config: SyncmillConfig,
41
+ gate: QualityGate | None = None,
42
+ breaker: CircuitBreaker | None = None,
43
+ monitor: Monitor | None = None, # sequential: nothing concurrent to watch
44
+ tracer: Tracer | None = None,
45
+ ) -> TaskResult:
46
+ # Drop agents whose circuit is OPEN before doing any work; if the breaker
47
+ # has tripped them all, say so clearly rather than searching memory or
48
+ # acquiring worktrees for nobody.
49
+ runnable = _breaker_runnable(agents, breaker)
50
+ if not runnable:
51
+ return _breaker_all_open_failure(task, agents)
52
+
53
+ # Search memory for relevant context
54
+ enriched_prompt = _enrich_prompt(
55
+ task.prompt,
56
+ run_memory_bounded(
57
+ memory.search_context(task.prompt),
58
+ default="", memory=memory, label="search_context",
59
+ ),
60
+ )
61
+
62
+ errors: list[TaskResult] = []
63
+ # The fallback causal chain: each attempt after the first is CAUSED BY
64
+ # the previous failed/rejected one (graph.node.parent_id), so the trace
65
+ # reads "codex failed, therefore claude ran".
66
+ prev_node: str | None = None
67
+ for i, agent in enumerate(runnable):
68
+ # A fresh, isolated worktree per attempt: a failed agent's partial
69
+ # edits never leak into the next agent's run, so the winner's
70
+ # files_changed reflects only its own work.
71
+ worktree = pool.acquire(agent.agent_id)
72
+ timeout = config.agent_config(agent.agent_id).timeout_seconds
73
+ result = agent.execute(
74
+ prompt=enriched_prompt,
75
+ workdir=worktree.path,
76
+ timeout=timeout,
77
+ )
78
+ # The RAW execution snapshot, taken before gating mutates the result
79
+ # in place: the breaker AND the attempt span both report execution
80
+ # reliability, not the gate's quality verdict.
81
+ attempt_end = time.time()
82
+ raw_status, raw_error = result.status, result.error
83
+ # Record the RAW execution outcome before gating: a completed run
84
+ # closes the agent's circuit even if its diff is later gate-rejected
85
+ # (a quality verdict, not an execution failure).
86
+ _breaker_record(breaker, agent.agent_id, result.status)
87
+ node_id = f"attempt:{agent.agent_id}:{i}"
88
+ # Accept only a completed result that also clears the quality gate;
89
+ # a gate failure downgrades the result and falls through to the next
90
+ # agent (recorded in errors), so an unverified diff is never accepted.
91
+ accepted = result.status == TaskStatus.COMPLETED and _gate_accepts(
92
+ result, worktree, gate
93
+ )
94
+ if accepted:
95
+ _collect_files_changed(result, worktree)
96
+ _trace_attempt(
97
+ tracer,
98
+ _attempt_span(
99
+ agent.agent_id,
100
+ node_id=node_id,
101
+ parent=prev_node if prev_node is not None else "run",
102
+ attempt=i,
103
+ raw_status=raw_status,
104
+ error=raw_error,
105
+ exit_code=result.exit_code,
106
+ end=attempt_end,
107
+ duration=result.duration_seconds,
108
+ winner=accepted,
109
+ files_changed_count=len(result.files_changed) if accepted else None,
110
+ ),
111
+ result,
112
+ )
113
+ if result.gate is not None:
114
+ _trace_record(
115
+ tracer,
116
+ _gate_span(
117
+ agent.agent_id,
118
+ node_id=f"gate:{agent.agent_id}:{i}",
119
+ parent=node_id,
120
+ report=result.gate,
121
+ end=time.time(),
122
+ ),
123
+ )
124
+ if accepted:
125
+ return result
126
+ errors.append(result)
127
+ prev_node = node_id
128
+
129
+ # All agents failed (or failed the gate) — return aggregate error
130
+ return _aggregate_failure(task, errors)