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