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/tracer.py ADDED
@@ -0,0 +1,548 @@
1
+ """Body-free OTLP trace export for Tracegraph files and Phoenix streaming.
2
+
3
+ A **retrospective recorder**: strategies append completed :class:`SpanRecord`\\ s
4
+ right after each fan-out/attempt returns (the shared-loop boundary where memory
5
+ I/O already sits), never live span context managers — a live tracer would have
6
+ to penetrate the agent layer and break agent purity. The approximation is safe
7
+ by tracegraph's design: a span's ``start`` is derived as ``end −
8
+ duration_seconds`` and timestamps are never promoted to causality there (they
9
+ are only a prerank tiebreak); causality rides ``graph.node.parent_id`` (the one
10
+ logical cause) and span ``links`` (additional consumed causes, fan-in).
11
+
12
+ The emitted document is pinned byte-level by tracegraph's T1 generators
13
+ (``examples/syncmill_otlp_traces.py`` + the goldens under
14
+ ``tests/fixtures/syncmill/``): protobuf-JSON attribute encoding (``intValue``
15
+ carries the int64 as a *string*; bool checked before int — bool is an int
16
+ subclass), span ``name`` as a stable structural label (agent ids / phases /
17
+ indices — never uuids/timestamps, or every tracegraph ``diff`` reads as
18
+ divergence), flat ``parentSpanId`` containment under the run root, and error
19
+ states as OTLP ``status`` with a bounded message (``timeout`` |
20
+ ``exit_code=N`` | ``gate: <summary>`` — never raw agent output). tracegraph
21
+ consumes exactly three attributes (``openinference.span.kind``,
22
+ ``graph.node.id``, ``graph.node.parent_id``); everything syncmill-specific is
23
+ additive ``syncmill.*`` from a hard allowlist the flush enforces (prompts,
24
+ output, patch bodies, and file paths are forbidden by contract).
25
+
26
+ ``OTLPSpanAdapter`` raises on dangling parents/duplicate spanIds/partial
27
+ documents, so the canonical file exporter buffers everything and lands the
28
+ file with one atomic write (temp + ``os.replace``, the breaker/board recipe).
29
+ The optional Phoenix sink tees the same completed records through a bounded
30
+ background batcher; it never replaces the file. Everything degrades (the
31
+ memory idiom): observability failures never abort orchestration, and
32
+ :func:`build_tracer` returns ``None`` when disabled so the off path is
33
+ byte-identical to before.
34
+
35
+ A flat top-level module (gateway/monitor/session/board/report precedent).
36
+ Imports ``config``, plus one constant from ``report`` — the run-dir ownership
37
+ marker, which both producers must write identically (``report`` never imports
38
+ back, so the edge is acyclic). ``strategies/base`` imports the record/Protocol
39
+ from here, keeping the graph acyclic in that direction too.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import contextlib
45
+ import json
46
+ import logging
47
+ import os
48
+ import re
49
+ import secrets
50
+ import tempfile
51
+ import time
52
+ from collections.abc import Callable, Mapping
53
+ from dataclasses import dataclass, field
54
+ from pathlib import Path
55
+ from typing import Protocol, runtime_checkable
56
+
57
+ from syncmill.config import TraceConfig, normalize_phoenix_endpoint
58
+
59
+ # One constant, not the module: retention owns the run-dir marker, and both
60
+ # producers must write the same one. (report does not import tracer — the
61
+ # Supervisor wires them together — so this edge stays acyclic.)
62
+ from syncmill.report import RUN_DIR_SENTINEL
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+ TRACE_FILE = "trace.otlp.json"
67
+ SCHEMA_VERSION = 1 # int — the ecosystem-wide contract version
68
+
69
+ # Bare-suffix mirror of tracegraph's SYNCMILL_ALLOWLIST (T1 generators). The
70
+ # flush prefixes ``syncmill.`` and drops anything outside this set, so a span
71
+ # builder can never leak a body-shaped attribute into the artifact.
72
+ _ALLOWLIST = frozenset(
73
+ {
74
+ "schema_version",
75
+ "run_id",
76
+ "strategy",
77
+ "phase",
78
+ "agent_id",
79
+ "attempt",
80
+ "round",
81
+ "status",
82
+ "exit_code",
83
+ "gate.passed",
84
+ "worktree_slot",
85
+ "winner",
86
+ "files_changed_count",
87
+ "tool.key",
88
+ "tool.sequence",
89
+ "telemetry.source",
90
+ "telemetry.complete",
91
+ }
92
+ )
93
+
94
+ # The three attribute keys tracegraph actually consumes (OTLPSpanAdapter).
95
+ _KIND_KEY = "openinference.span.kind"
96
+ _NODE_KEY = "graph.node.id"
97
+ _PARENT_KEY = "graph.node.parent_id"
98
+
99
+ _ROOT_NODE = "run"
100
+
101
+ # Defensive upper bound on an OTLP status message. Messages come only from our
102
+ # own bounded builders ("timeout" | "exit_code=N" | "gate: <summary>"), but a
103
+ # gate summary interpolates operator-configured command strings, so cap the
104
+ # length at flush to keep a pathological config out of the artifact.
105
+ _MAX_STATUS_MESSAGE = 200
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ToolgraphTraceEvidence:
110
+ """Body-free Toolgraph decision reference understood by Tracegraph."""
111
+
112
+ artifact_digest: str
113
+ graph_generation: int
114
+ verdict: str
115
+
116
+ def __post_init__(self) -> None:
117
+ if not re.fullmatch(r"sha256:[0-9a-f]{64}", self.artifact_digest):
118
+ raise ValueError("invalid Toolgraph preflight artifact digest")
119
+ if (
120
+ isinstance(self.graph_generation, bool)
121
+ or not isinstance(self.graph_generation, int)
122
+ or self.graph_generation < 0
123
+ ):
124
+ raise ValueError("invalid Toolgraph graph generation")
125
+ if not re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", self.verdict):
126
+ raise ValueError("invalid Toolgraph preflight verdict")
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class SpanRecord:
131
+ """One completed span, recorded retrospectively by a strategy.
132
+
133
+ ``name`` is the stable diff/AHU label (agent ids/phases/indices only —
134
+ never uuids); ``node_id`` must be unique within the trace; ``node_parent``
135
+ is the ONE logical cause; ``links`` are additional consumed causes
136
+ (node_ids, resolved to spanIds at flush). ``attrs`` carries bare-suffix
137
+ ``syncmill.*`` keys (e.g. ``"phase"``, ``"gate.passed"``) — the tracer
138
+ prefixes and allowlist-filters them.
139
+ """
140
+
141
+ name: str
142
+ kind: str # OpenInference kind, e.g. AGENT/CHAIN/TOOL/RETRIEVER/GUARDRAIL
143
+ node_id: str
144
+ node_parent: str | None
145
+ links: tuple[str, ...] = ()
146
+ start: float = 0.0 # unix seconds
147
+ end: float = 0.0
148
+ status: str = "completed" # completed | failed | timeout | cancelled
149
+ error: str | None = None # bounded summary — never raw output
150
+ attrs: Mapping[str, str | int | bool] = field(default_factory=dict)
151
+ toolgraph_evidence: ToolgraphTraceEvidence | None = None
152
+
153
+
154
+ def run_span(
155
+ *,
156
+ strategy: str,
157
+ status: str,
158
+ start: float,
159
+ end: float,
160
+ toolgraph_evidence: ToolgraphTraceEvidence | None = None,
161
+ ) -> SpanRecord:
162
+ """The ``syncmill.run`` root span — recorded by the Supervisor in its
163
+ ``finally``, once the final status is known."""
164
+ return SpanRecord(
165
+ name="syncmill.run",
166
+ kind="CHAIN",
167
+ node_id=_ROOT_NODE,
168
+ node_parent=None,
169
+ start=start,
170
+ end=end,
171
+ status=status,
172
+ attrs={"strategy": strategy, "agent_id": "supervisor", "status": status},
173
+ toolgraph_evidence=toolgraph_evidence,
174
+ )
175
+
176
+
177
+ @runtime_checkable
178
+ class Tracer(Protocol):
179
+ """The recorder seam strategies receive (``Strategy.execute(tracer=)``).
180
+
181
+ ``record`` must never raise out (the strategy is mid-orchestration);
182
+ ``flush`` writes the artifact and returns its path, or ``None`` + a
183
+ warning on failure — callers own the degrade.
184
+ """
185
+
186
+ trace_id: str
187
+
188
+ def record(self, span: SpanRecord) -> None: ...
189
+
190
+ def flush(self) -> Path | None: ...
191
+
192
+
193
+ def _attr(key: str, value: str | int | bool) -> dict:
194
+ """Protobuf-JSON attribute encoding (byte-contract with the T1 goldens)."""
195
+ if isinstance(value, bool): # bool first — bool is an int subclass
196
+ wrapped: dict = {"boolValue": value}
197
+ elif isinstance(value, int):
198
+ wrapped = {"intValue": str(value)} # protobuf-JSON encodes int64 as string
199
+ else:
200
+ wrapped = {"stringValue": str(value)}
201
+ return {"key": key, "value": wrapped}
202
+
203
+
204
+ def _span_attributes(
205
+ span: SpanRecord,
206
+ run_id: str,
207
+ *,
208
+ warn_on_drop: bool = True,
209
+ ) -> dict[str, str | int | bool]:
210
+ """Return the single body-free attribute view shared by every sink."""
211
+ attributes: dict[str, str | int | bool] = {
212
+ _KIND_KEY: span.kind,
213
+ _NODE_KEY: span.node_id,
214
+ }
215
+ if span.node_parent is not None:
216
+ attributes[_PARENT_KEY] = span.node_parent
217
+ attributes["syncmill.schema_version"] = SCHEMA_VERSION
218
+ attributes["syncmill.run_id"] = run_id
219
+ for key, value in span.attrs.items():
220
+ if key in {"schema_version", "run_id"}:
221
+ continue
222
+ if key not in _ALLOWLIST:
223
+ if warn_on_drop:
224
+ logger.warning(
225
+ "trace attr %r is outside the syncmill.* allowlist; dropped",
226
+ key,
227
+ )
228
+ continue
229
+ attributes[f"syncmill.{key}"] = value
230
+ evidence = span.toolgraph_evidence
231
+ if evidence is not None:
232
+ attributes.update(
233
+ {
234
+ "toolgraph.preflight.artifact_digest": evidence.artifact_digest,
235
+ "toolgraph.graph_generation": evidence.graph_generation,
236
+ "toolgraph.preflight.verdict": evidence.verdict,
237
+ }
238
+ )
239
+ return attributes
240
+
241
+
242
+ class FileTracer:
243
+ """Buffers span records; ``flush()`` emits one OTLP protobuf-JSON document.
244
+
245
+ ``id_factory`` is the deterministic test seam — it supplies the 32-hex
246
+ trace id first, then one 16-hex span id per span (in recording order).
247
+ """
248
+
249
+ def __init__(
250
+ self,
251
+ run_id: str,
252
+ out_dir: Path,
253
+ *,
254
+ id_factory: Callable[[int], str] | None = None,
255
+ ) -> None:
256
+ self._run_id = run_id
257
+ self._out_dir = out_dir
258
+ self._ids = id_factory or (lambda nbytes: secrets.token_hex(nbytes))
259
+ self.trace_id: str = self._ids(16)
260
+ self._spans: list[SpanRecord] = []
261
+ self.degraded = False
262
+ self.last_error: str | None = None
263
+
264
+ def record(self, span: SpanRecord) -> None:
265
+ """Append one completed span. Never raises out — recording is
266
+ observability, and a broken tracer must not abort orchestration."""
267
+ try:
268
+ self._spans.append(span)
269
+ except Exception as exc: # pragma: no cover - defensive
270
+ self._degrade(f"could not record span: {exc}")
271
+
272
+ def flush(self) -> Path | None:
273
+ """Resolve ids, build the document, and atomically write the artifact.
274
+
275
+ All-or-nothing by contract: tracegraph's ``OTLPSpanAdapter`` raises on
276
+ partial documents, dangling ``parentSpanId``, and duplicate spanIds —
277
+ so ids are validated here and the write is temp + ``os.replace``.
278
+ """
279
+ try:
280
+ return self._write(self._build_document())
281
+ except (OSError, ValueError, RuntimeError) as exc:
282
+ self._degrade(f"trace flush failed: {exc}")
283
+ return None
284
+
285
+ # ── internals ────────────────────────────────────────────────────────────
286
+
287
+ def _build_document(self) -> dict:
288
+ span_ids: dict[str, str] = {}
289
+ for span in self._spans:
290
+ if span.node_id in span_ids:
291
+ raise ValueError(f"duplicate span node_id: {span.node_id!r}")
292
+ span_ids[span.node_id] = self._ids(8)
293
+ root_span_id = span_ids.get(_ROOT_NODE)
294
+ out = []
295
+ for span in self._spans:
296
+ attrs = []
297
+ if span.node_parent is not None:
298
+ # An unresolved logical parent makes the whole document
299
+ # rejectable (tracegraph's adapter raises on it) — fail the
300
+ # flush all-or-nothing rather than write a lossy artifact and
301
+ # report it "ok".
302
+ if span.node_parent not in span_ids:
303
+ raise ValueError(
304
+ f"span {span.node_id!r} has unresolved "
305
+ f"graph.node.parent_id {span.node_parent!r}"
306
+ )
307
+ attrs.extend(
308
+ _attr(key, value)
309
+ for key, value in _span_attributes(span, self._run_id).items()
310
+ )
311
+ doc: dict = {
312
+ "traceId": self.trace_id,
313
+ "spanId": span_ids[span.node_id],
314
+ "name": span.name,
315
+ "startTimeUnixNano": str(int(span.start * 1e9)),
316
+ "endTimeUnixNano": str(int(span.end * 1e9)),
317
+ "attributes": attrs,
318
+ }
319
+ # Flat containment: every non-root span parents on the run root
320
+ # (causality rides graph.node.parent_id, never parentSpanId). A
321
+ # missing root must not dangle — the adapter raises on that.
322
+ if span.node_id != _ROOT_NODE and root_span_id is not None:
323
+ doc["parentSpanId"] = root_span_id
324
+ if span.status in {"failed", "timeout", "cancelled"}:
325
+ message = span.error or span.status
326
+ doc["status"] = {
327
+ "code": "STATUS_CODE_ERROR",
328
+ "message": message[:_MAX_STATUS_MESSAGE],
329
+ }
330
+ links = []
331
+ for target in span.links:
332
+ span_id = span_ids.get(target)
333
+ if span_id is None:
334
+ # A dangling link would make the adapter reject the whole
335
+ # document — fail the flush all-or-nothing rather than emit
336
+ # a silently-lossy trace (the degrade path, same as an
337
+ # unresolved logical parent above).
338
+ raise ValueError(
339
+ f"span {span.node_id!r} links to unrecorded node {target!r}"
340
+ )
341
+ links.append({"traceId": self.trace_id, "spanId": span_id})
342
+ if links:
343
+ doc["links"] = links
344
+ out.append(doc)
345
+ return {"resourceSpans": [{"scopeSpans": [{"spans": out}]}]}
346
+
347
+ def _write(self, document: dict) -> Path:
348
+ self._out_dir.mkdir(parents=True, exist_ok=True)
349
+ # A trace-only run (reporting off) creates this dir itself, and the same
350
+ # retention sweep prunes it — so it has to carry the same ownership
351
+ # marker the reporter writes, or it could never be collected.
352
+ with contextlib.suppress(OSError):
353
+ (self._out_dir / RUN_DIR_SENTINEL).write_text("")
354
+ path = self._out_dir / TRACE_FILE
355
+ fd, tmp = tempfile.mkstemp(dir=str(self._out_dir), suffix=".tmp")
356
+ try:
357
+ with os.fdopen(fd, "w") as f:
358
+ json.dump(document, f)
359
+ os.replace(tmp, path)
360
+ except BaseException:
361
+ try:
362
+ os.unlink(tmp)
363
+ except OSError:
364
+ pass
365
+ raise
366
+ return path
367
+
368
+ def _degrade(self, msg: str) -> None:
369
+ self.degraded = True
370
+ self.last_error = msg
371
+ logger.warning("tracer degraded: %s", msg)
372
+
373
+
374
+ class TeeTracer:
375
+ """Durable file trace plus a best-effort Phoenix streaming sidecar."""
376
+
377
+ def __init__(
378
+ self,
379
+ file_tracer: FileTracer,
380
+ stream: object | None,
381
+ *,
382
+ missing_stream_status: str = "error",
383
+ ) -> None:
384
+ self._file = file_tracer
385
+ self._stream = stream
386
+ self._missing_stream_status = missing_stream_status
387
+ self.trace_id = file_tracer.trace_id
388
+ self._recorded = 0
389
+ self.stream_receipt: dict | None = None
390
+
391
+ def record(self, span: SpanRecord) -> None:
392
+ self._file.record(span)
393
+ self._recorded += 1
394
+ if self._stream is None:
395
+ return
396
+ try:
397
+ self._stream.record(span) # type: ignore[attr-defined]
398
+ except Exception:
399
+ self._stream = None
400
+ logger.warning(
401
+ "Phoenix stream record failed; continuing with file trace",
402
+ exc_info=True,
403
+ )
404
+
405
+ def flush(self) -> Path | None:
406
+ # The durable artifact is canonical and must not wait on the network.
407
+ path = self._file.flush()
408
+ if self._stream is None:
409
+ self.stream_receipt = {
410
+ "backend": "phoenix",
411
+ "protocol": "otlp-http/protobuf",
412
+ "status": self._missing_stream_status,
413
+ "exported_span_count": 0,
414
+ "dropped_span_count": self._recorded,
415
+ }
416
+ return path
417
+ try:
418
+ self.stream_receipt = self._stream.flush() # type: ignore[attr-defined]
419
+ except Exception:
420
+ logger.warning(
421
+ "Phoenix stream flush failed; continuing with file trace", exc_info=True
422
+ )
423
+ self.stream_receipt = {
424
+ "backend": "phoenix",
425
+ "protocol": "otlp-http/protobuf",
426
+ "status": "error",
427
+ "exported_span_count": 0,
428
+ "dropped_span_count": self._recorded,
429
+ }
430
+ return path
431
+
432
+
433
+ def flush_trace(tracer: Tracer) -> dict:
434
+ """Flush + digest into the envelope ``trace`` block. Never raises.
435
+
436
+ ``{"status": "ok", protocol, trace_id, artifact, digest}`` on success —
437
+ the exact conditional-required set of the envelope schema's ``traceBlock``
438
+ — or ``{"status": "error"}`` when the flush or digest failed.
439
+ """
440
+ # Local import: report.py imports config/models/monitor only, so this is
441
+ # acyclic — reusing sha256_digest keeps the digest form from drifting.
442
+ from syncmill.report import sha256_digest
443
+
444
+ try:
445
+ path = tracer.flush()
446
+ if path is None:
447
+ return {"status": "error"}
448
+ block = {
449
+ "status": "ok",
450
+ "protocol": "otlp-json-file",
451
+ "trace_id": tracer.trace_id,
452
+ "artifact": TRACE_FILE, # relative to the envelope's directory
453
+ "digest": sha256_digest(path.read_bytes()),
454
+ }
455
+ stream = getattr(tracer, "stream_receipt", None)
456
+ if isinstance(stream, dict):
457
+ block["stream"] = stream
458
+ return block
459
+ except Exception as exc:
460
+ logger.warning("trace block build failed: %s", exc)
461
+ return {"status": "error"}
462
+
463
+
464
+ def _phoenix_endpoint(cfg: TraceConfig) -> str:
465
+ raw = (
466
+ cfg.endpoint
467
+ or os.environ.get("PHOENIX_COLLECTOR_ENDPOINT")
468
+ or "http://127.0.0.1:6006/v1/traces"
469
+ )
470
+ return normalize_phoenix_endpoint(raw)
471
+
472
+
473
+ def build_tracer(
474
+ cfg: TraceConfig,
475
+ *,
476
+ run_id: str,
477
+ fallback_dir: str,
478
+ run_start: float | None = None,
479
+ ) -> Tracer | None:
480
+ """The tracer, or ``None`` when tracing is disabled (the default) — the
481
+ ``build_gate``/``build_breaker`` pattern: the disabled path records nothing,
482
+ touches no filesystem, and behavior is byte-identical to before.
483
+
484
+ ``fallback_dir`` is the report ``runs_dir`` — ``dir=""`` (the default)
485
+ means "beside the run envelope", and only the caller sees ``ReportConfig``.
486
+ Body inclusion remains warn-only here (once per run), rather than in a
487
+ config validator that would fire on every construction.
488
+ """
489
+ if not cfg.enabled:
490
+ return None
491
+ if cfg.exporter not in {"file", "otlp-http"}:
492
+ logger.warning(
493
+ "unknown trace exporter %r; using the file exporter", cfg.exporter
494
+ )
495
+ if cfg.include_output:
496
+ logger.warning(
497
+ "trace include_output is not supported in v1 (spans are body-free "
498
+ "by contract); ignoring"
499
+ )
500
+ try:
501
+ out_dir = Path(cfg.dir or fallback_dir).expanduser() / run_id
502
+ except RuntimeError as exc: # unresolvable ~ (no-HOME cron/systemd)
503
+ logger.warning("trace dir could not be resolved: %s; tracing disabled", exc)
504
+ return None
505
+ file_tracer = FileTracer(run_id, out_dir)
506
+ if cfg.exporter != "otlp-http":
507
+ return file_tracer
508
+
509
+ stream = None
510
+ missing_stream_status = "error"
511
+ try:
512
+ from syncmill.phoenix_exporter import PhoenixSpanSink
513
+
514
+ project_name = (
515
+ cfg.project_name.strip()
516
+ or os.environ.get("PHOENIX_PROJECT_NAME", "").strip()
517
+ or "syncmill"
518
+ )
519
+ stream = PhoenixSpanSink(
520
+ run_id=run_id,
521
+ trace_id=file_tracer.trace_id,
522
+ run_start=run_start if run_start is not None else time.time(),
523
+ endpoint=_phoenix_endpoint(cfg),
524
+ project_name=project_name,
525
+ batch_delay_seconds=cfg.batch_delay_seconds,
526
+ timeout_seconds=cfg.timeout_seconds,
527
+ attribute_factory=lambda span: _span_attributes(
528
+ span,
529
+ run_id,
530
+ warn_on_drop=False,
531
+ ),
532
+ )
533
+ except ModuleNotFoundError:
534
+ missing_stream_status = "unavailable"
535
+ logger.warning(
536
+ "Phoenix trace streaming requires syncmill[phoenix] "
537
+ "(or `uv sync --extra phoenix`); continuing with the file trace"
538
+ )
539
+ except Exception as exc:
540
+ logger.warning(
541
+ "Phoenix trace streaming could not start: %s; continuing with the file trace",
542
+ exc,
543
+ )
544
+ return TeeTracer(
545
+ file_tracer,
546
+ stream,
547
+ missing_stream_status=missing_stream_status,
548
+ )