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
syncmill/planning.py ADDED
@@ -0,0 +1,512 @@
1
+ """Bounded, inspectable execution plans for orchestration strategies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ import hashlib
7
+ from typing import Any
8
+
9
+ from syncmill import __version__
10
+ from syncmill.config import SyncmillConfig
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RunPlan:
15
+ strategy: str
16
+ agents: tuple[str, ...]
17
+ phases: tuple[str, ...]
18
+ maximum_agent_calls: int
19
+ selection: str
20
+ gate_commands: tuple[str, ...]
21
+ # The canonical model-routing decision dict, or None when routing is off.
22
+ # as_dict() OMITS the key when None so disabled artifacts stay identical.
23
+ model_routing: dict[str, Any] | None = None
24
+
25
+ def as_dict(self) -> dict:
26
+ data = asdict(self)
27
+ if data["model_routing"] is None:
28
+ del data["model_routing"]
29
+ return data
30
+
31
+
32
+ UNMAPPED_PRINCIPAL_PREFIX = "syncmill-unmapped-"
33
+
34
+
35
+ def unmapped_principal_id(agent_id: str) -> str:
36
+ """The synthetic principal for an agent with no ``agent_map`` entry.
37
+
38
+ The one place this spelling is produced: the prefix followed by sixteen
39
+ lowercase hex characters. A consumer deciding whether a principal is
40
+ synthetic compares against THIS, computed from the agents that actually had
41
+ no mapping — never against the prefix alone, which an explicit mapping is
42
+ free to use for any other suffix.
43
+ """
44
+ suffix = hashlib.sha256(agent_id.encode()).hexdigest()[:16]
45
+ return f"{UNMAPPED_PRINCIPAL_PREFIX}{suffix}"
46
+
47
+
48
+ def control_principal_id(config: SyncmillConfig, agent_id: str) -> str:
49
+ """Return the stable Toolgraph principal for one SyncMill agent."""
50
+ mapped = config.integration.toolgraph.agent_map.get(agent_id)
51
+ if mapped is not None:
52
+ return mapped
53
+ return unmapped_principal_id(agent_id)
54
+
55
+
56
+ def build_run_plan(
57
+ config: SyncmillConfig,
58
+ agents: list[str],
59
+ *,
60
+ model_routing: dict[str, Any] | None = None,
61
+ ) -> RunPlan:
62
+ """Mirror the bounded phase topology implemented in ``strategies/*.py``.
63
+
64
+ Update this projection whenever a concrete strategy adds or removes a phase;
65
+ tests should compare its call bound with that strategy's configured topology.
66
+ """
67
+ n = len(agents)
68
+ strategy = config.default_strategy
69
+ if strategy == "route":
70
+ phases, calls = ("route",), n
71
+ elif strategy == "pipeline":
72
+ phases, calls = ("pipeline",), n
73
+ elif strategy == "compete":
74
+ phases, calls = ("compete", "select"), n
75
+ elif strategy == "council":
76
+ phases = (
77
+ ("propose",)
78
+ + tuple(f"critique-{i + 1}" for i in range(config.council.rounds))
79
+ + ("synthesize", "select")
80
+ )
81
+ calls = n * (config.council.rounds + 2)
82
+ elif strategy == "decompose":
83
+ phases = ("plan", "subtasks", "synthesize", "select")
84
+ calls = 1 + config.decompose.max_subtasks + n
85
+ else:
86
+ # Mirror _make_strategy's hard fail: a bare else silently attributed an
87
+ # unknown strategy to decompose's topology — a wrong promise recorded
88
+ # into the plan artifact. Unreachable via the CLI (validated there);
89
+ # programmatic callers get the same error the strategy registry raises.
90
+ raise ValueError(f"unknown strategy: {strategy!r}")
91
+ # Judge selection spends one MORE agent call than the phases above: with two
92
+ # or more gate-verified candidates, `_select_winner` runs a judge agent in its
93
+ # own worktree. The bound is a promise the artifact records, so it has to
94
+ # cover every call the run can actually make — the sequential strategies never
95
+ # reach that seam, so it only applies where a `select` phase exists.
96
+ if config.selection.mode == "judge" and "select" in phases:
97
+ calls += 1
98
+ return RunPlan(
99
+ strategy=strategy,
100
+ agents=tuple(agents),
101
+ phases=phases,
102
+ maximum_agent_calls=calls,
103
+ # selection.mode only means something where a `select` phase exists
104
+ # (compete/council/decompose reach _select_winner); printing `judge`
105
+ # for a route/pipeline dry-run promised a selection that never runs.
106
+ selection=config.selection.mode if "select" in phases else "n/a",
107
+ gate_commands=tuple(config.gate.commands),
108
+ model_routing=model_routing,
109
+ )
110
+
111
+
112
+ def build_control_plan(
113
+ config: SyncmillConfig,
114
+ agents: list[str],
115
+ *,
116
+ run_id: str,
117
+ ) -> dict:
118
+ """Project the implemented strategy into Toolgraph's body-free v1 DAG.
119
+
120
+ This is an upper-bound declaration, not an execution transcript. It contains
121
+ no prompt, output, command, model selector, repository path, or checkpoint.
122
+ Missing identity mappings receive a run-local sentinel principal so the
123
+ consumer can classify the mapping failure without omitting a participant.
124
+ """
125
+ if not run_id:
126
+ raise ValueError("control plan run_id must not be empty")
127
+ toolgraph = config.integration.toolgraph
128
+ if not toolgraph.candidate_tools:
129
+ raise ValueError("control plan requires toolgraph candidate_tools")
130
+ if not agents:
131
+ raise ValueError("control plan requires at least one agent")
132
+
133
+ mapped_by_agent = {
134
+ agent_id: control_principal_id(config, agent_id) for agent_id in agents
135
+ }
136
+ principals = list(dict.fromkeys(mapped_by_agent.values()))
137
+ timeout = max(config.agent_config(agent).timeout_seconds for agent in agents)
138
+ n = len(agents)
139
+
140
+ nodes: list[dict] = [
141
+ {"id": "start", "kind": "start", "principals": [], "agent_call": False}
142
+ ]
143
+ edges: list[dict] = []
144
+ previous = "start"
145
+
146
+ def append_validator() -> None:
147
+ nonlocal previous
148
+ nodes.append(
149
+ {
150
+ "id": "verify",
151
+ "kind": "validator",
152
+ "principals": [],
153
+ "agent_call": False,
154
+ "validator_scope": "workspace_result",
155
+ }
156
+ )
157
+ edges.append(
158
+ {
159
+ "from": previous,
160
+ "to": "verify",
161
+ "kind": "always",
162
+ "decided_by": "code",
163
+ }
164
+ )
165
+ previous = "verify"
166
+
167
+ def append_agent_node(
168
+ node_id: str,
169
+ *,
170
+ invocations: int,
171
+ parallelism: int,
172
+ node_principals: list[str] | None = None,
173
+ model_fanout: bool = False,
174
+ ) -> None:
175
+ nonlocal previous
176
+ selected = principals if node_principals is None else node_principals
177
+ nodes.append(
178
+ {
179
+ "id": node_id,
180
+ "kind": "agent",
181
+ "principals": selected,
182
+ "agent_call": True,
183
+ "max_invocations": invocations,
184
+ "max_parallelism": min(parallelism, invocations),
185
+ "timeout_seconds": timeout,
186
+ }
187
+ )
188
+ edge = {
189
+ "from": previous,
190
+ "to": node_id,
191
+ "kind": "fanout" if parallelism > 1 else "always",
192
+ "decided_by": "model" if model_fanout else "code",
193
+ }
194
+ if parallelism > 1:
195
+ edge["max_fan_out"] = invocations
196
+ edges.append(edge)
197
+ previous = node_id
198
+
199
+ strategy = config.default_strategy
200
+ if strategy in {"route", "pipeline"}:
201
+ append_agent_node(strategy, invocations=n, parallelism=1)
202
+ elif strategy == "compete":
203
+ append_agent_node("compete", invocations=n, parallelism=n)
204
+ elif strategy == "council":
205
+ append_agent_node("propose", invocations=n, parallelism=n)
206
+ for index in range(config.council.rounds):
207
+ append_agent_node(f"critique-{index + 1}", invocations=n, parallelism=n)
208
+ append_agent_node("synthesize", invocations=n, parallelism=n)
209
+ elif strategy == "decompose":
210
+ # Every agent that could end up planning, not just the one that would if
211
+ # nothing were filtered. Execution takes `runnable[0]`, which is the
212
+ # first agent the circuit breaker did NOT skip, so declaring only
213
+ # `agents[0]` describes a planner that may not be the one that runs —
214
+ # the evidence would name Claude while Codex planned. The bound stays
215
+ # one invocation; this widens WHO may spend it, not how many.
216
+ #
217
+ # No `node_principals`: the default is exactly this set, already
218
+ # deduplicated. Rebuilding it by comprehension emits one entry per
219
+ # AGENT, and two agents may map to one identity — a configuration the
220
+ # agent_map allows — which the schema rejects for non-unique elements.
221
+ append_agent_node("plan", invocations=1, parallelism=1)
222
+ append_agent_node(
223
+ "subtasks",
224
+ invocations=config.decompose.max_subtasks,
225
+ parallelism=config.decompose.max_subtasks,
226
+ model_fanout=True,
227
+ )
228
+ append_agent_node("synthesize", invocations=n, parallelism=n)
229
+ else:
230
+ raise ValueError(f"unknown strategy: {strategy!r}")
231
+
232
+ if strategy in {"compete", "council", "decompose"}:
233
+ if config.gate.commands:
234
+ append_validator()
235
+ nodes.append(
236
+ {
237
+ "id": "select",
238
+ "kind": "router",
239
+ "principals": [],
240
+ "agent_call": False,
241
+ }
242
+ )
243
+ edges.append(
244
+ {
245
+ "from": previous,
246
+ "to": "select",
247
+ "kind": "join",
248
+ "decided_by": "code",
249
+ }
250
+ )
251
+ previous = "select"
252
+ if config.selection.mode == "judge":
253
+ # Same divergence as the decompose planner: `_select_winner` falls
254
+ # back to the first agent it was handed when the configured judge is
255
+ # not among them, and the breaker decides what it is handed. The
256
+ # configured judge leads the declaration so the common case reads
257
+ # first, and every agent that could stand in follows it.
258
+ configured_judge = config.selection.judge_agent
259
+ ordered = (
260
+ [configured_judge] + [a for a in agents if a != configured_judge]
261
+ if configured_judge in agents
262
+ else list(agents)
263
+ )
264
+ # Deduplicated, order preserved: two agents may map to one identity,
265
+ # and the schema rejects a node whose principals repeat.
266
+ append_agent_node(
267
+ "judge",
268
+ invocations=1,
269
+ parallelism=1,
270
+ node_principals=list(
271
+ dict.fromkeys(mapped_by_agent[agent] for agent in ordered)
272
+ ),
273
+ )
274
+
275
+ elif config.gate.commands:
276
+ append_validator()
277
+
278
+ nodes.append({"id": "end", "kind": "end", "principals": [], "agent_call": False})
279
+ edges.append(
280
+ {"from": previous, "to": "end", "kind": "success", "decided_by": "code"}
281
+ )
282
+ run_plan = build_run_plan(config, agents)
283
+ maximum_parallelism = max(
284
+ (node.get("max_parallelism", 0) for node in nodes), default=1
285
+ )
286
+ return {
287
+ "schema_version": 1,
288
+ "kind": "toolgraph.control-plan",
289
+ "run_id": run_id,
290
+ "producer": {"name": "syncmill", "version": __version__},
291
+ "principals": [
292
+ {"id": principal, "candidate_tools": list(toolgraph.candidate_tools)}
293
+ for principal in principals
294
+ ],
295
+ "nodes": nodes,
296
+ "edges": edges,
297
+ "limits": {
298
+ "maximum_agent_calls": run_plan.maximum_agent_calls,
299
+ "maximum_parallelism": maximum_parallelism,
300
+ "maximum_cycles": 0,
301
+ },
302
+ }
303
+
304
+
305
+ def build_workflow_control_plan(
306
+ config: SyncmillConfig,
307
+ workflow_nodes: list[dict[str, Any]],
308
+ *,
309
+ run_id: str,
310
+ final_node: str,
311
+ ) -> dict:
312
+ """Project a validated, body-free MillFlow DAG into control-plan v1."""
313
+ if not run_id:
314
+ raise ValueError("control plan run_id must not be empty")
315
+ if not workflow_nodes:
316
+ raise ValueError("workflow control plan requires at least one node")
317
+ toolgraph = config.integration.toolgraph
318
+ if not toolgraph.candidate_tools:
319
+ raise ValueError("control plan requires toolgraph candidate_tools")
320
+
321
+ ids = [str(row["id"]) for row in workflow_nodes]
322
+ if len(ids) != len(set(ids)):
323
+ raise ValueError("workflow control-plan node ids must be unique")
324
+ if final_node not in set(ids):
325
+ raise ValueError(f"unknown workflow final node: {final_node!r}")
326
+
327
+ agent_by_node = {str(row["id"]): str(row["agent"]) for row in workflow_nodes}
328
+ mapped_by_agent = {
329
+ agent: control_principal_id(config, agent)
330
+ for agent in dict.fromkeys(agent_by_node.values())
331
+ }
332
+ principals = list(dict.fromkeys(mapped_by_agent.values()))
333
+ plan_nodes: list[dict[str, Any]] = [
334
+ {
335
+ "id": "control:start",
336
+ "kind": "start",
337
+ "principals": [],
338
+ "agent_call": False,
339
+ }
340
+ ]
341
+ edges: list[dict[str, Any]] = []
342
+
343
+ roots = [
344
+ str(row["id"]) for row in workflow_nodes if not list(row.get("needs") or [])
345
+ ]
346
+ if len(roots) > 1:
347
+ plan_nodes.append(
348
+ {
349
+ "id": "control:fanout:roots",
350
+ "kind": "fanout",
351
+ "principals": [],
352
+ "agent_call": False,
353
+ }
354
+ )
355
+ edges.append(
356
+ {
357
+ "from": "control:start",
358
+ "to": "control:fanout:roots",
359
+ "kind": "always",
360
+ "decided_by": "code",
361
+ }
362
+ )
363
+
364
+ for row in workflow_nodes:
365
+ node_id = str(row["id"])
366
+ agent_id = str(row["agent"])
367
+ needs = [str(value) for value in row.get("needs") or []]
368
+ plan_node_id = f"workflow:{node_id}"
369
+ plan_nodes.append(
370
+ {
371
+ "id": plan_node_id,
372
+ "kind": "agent",
373
+ "principals": [mapped_by_agent[agent_id]],
374
+ "agent_call": True,
375
+ "max_invocations": int(row["max_attempts"]),
376
+ "max_parallelism": 1,
377
+ "timeout_seconds": int(row["timeout_seconds"]),
378
+ }
379
+ )
380
+ if not needs:
381
+ source = "control:fanout:roots" if len(roots) > 1 else "control:start"
382
+ edge: dict[str, Any] = {
383
+ "from": source,
384
+ "to": plan_node_id,
385
+ "kind": "fanout" if len(roots) > 1 else "always",
386
+ "decided_by": "code",
387
+ }
388
+ if len(roots) > 1:
389
+ edge["max_fan_out"] = len(roots)
390
+ edges.append(edge)
391
+ elif len(needs) == 1:
392
+ edges.append(
393
+ {
394
+ "from": f"workflow:{needs[0]}",
395
+ "to": plan_node_id,
396
+ "kind": "success",
397
+ "decided_by": "code",
398
+ }
399
+ )
400
+ else:
401
+ join_id = f"control:join:{node_id}"
402
+ plan_nodes.append(
403
+ {
404
+ "id": join_id,
405
+ "kind": "join",
406
+ "principals": [],
407
+ "agent_call": False,
408
+ }
409
+ )
410
+ for dependency in needs:
411
+ edges.append(
412
+ {
413
+ "from": f"workflow:{dependency}",
414
+ "to": join_id,
415
+ "kind": "join",
416
+ "decided_by": "code",
417
+ }
418
+ )
419
+ edges.append(
420
+ {
421
+ "from": join_id,
422
+ "to": plan_node_id,
423
+ "kind": "success",
424
+ "decided_by": "code",
425
+ }
426
+ )
427
+
428
+ plan_nodes.append(
429
+ {
430
+ "id": "control:end",
431
+ "kind": "end",
432
+ "principals": [],
433
+ "agent_call": False,
434
+ }
435
+ )
436
+ for node_id in ids:
437
+ edges.append(
438
+ {
439
+ "from": f"workflow:{node_id}",
440
+ "to": "control:end",
441
+ "kind": "failure",
442
+ "decided_by": "code",
443
+ }
444
+ )
445
+
446
+ final_source = f"workflow:{final_node}"
447
+ if config.gate.commands:
448
+ validator_id = "control:validator:final"
449
+ plan_nodes.insert(
450
+ -1,
451
+ {
452
+ "id": validator_id,
453
+ "kind": "validator",
454
+ "principals": [],
455
+ "agent_call": False,
456
+ "validator_scope": "workspace_result",
457
+ },
458
+ )
459
+ edges.extend(
460
+ [
461
+ {
462
+ "from": final_source,
463
+ "to": validator_id,
464
+ "kind": "success",
465
+ "decided_by": "code",
466
+ },
467
+ {
468
+ "from": validator_id,
469
+ "to": "control:end",
470
+ "kind": "success",
471
+ "decided_by": "code",
472
+ },
473
+ {
474
+ "from": validator_id,
475
+ "to": "control:end",
476
+ "kind": "failure",
477
+ "decided_by": "code",
478
+ },
479
+ ]
480
+ )
481
+ else:
482
+ edges.append(
483
+ {
484
+ "from": final_source,
485
+ "to": "control:end",
486
+ "kind": "success",
487
+ "decided_by": "code",
488
+ }
489
+ )
490
+
491
+ maximum_calls = sum(int(row["max_attempts"]) for row in workflow_nodes)
492
+ maximum_parallelism = min(config.workflow.max_parallel, len(workflow_nodes))
493
+ return {
494
+ "schema_version": 1,
495
+ "kind": "toolgraph.control-plan",
496
+ "run_id": run_id,
497
+ "producer": {"name": "syncmill", "version": __version__},
498
+ "principals": [
499
+ {
500
+ "id": principal,
501
+ "candidate_tools": list(toolgraph.candidate_tools),
502
+ }
503
+ for principal in principals
504
+ ],
505
+ "nodes": plan_nodes,
506
+ "edges": edges,
507
+ "limits": {
508
+ "maximum_agent_calls": maximum_calls,
509
+ "maximum_parallelism": maximum_parallelism,
510
+ "maximum_cycles": 0,
511
+ },
512
+ }
@@ -0,0 +1,14 @@
1
+ """Supported execution platforms, checked before state or process side effects."""
2
+
3
+ import sys
4
+
5
+
6
+ class UnsupportedPlatformError(RuntimeError):
7
+ """Execution requires the supported POSIX process and filesystem semantics."""
8
+
9
+
10
+ def require_supported_platform() -> None:
11
+ if sys.platform not in {"darwin", "linux"}:
12
+ raise UnsupportedPlatformError(
13
+ "syncmill supports macOS and Linux only; Windows execution is not supported"
14
+ )