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