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/memory.py ADDED
@@ -0,0 +1,1067 @@
1
+ """Shared memory layer — syncmill conventions over a memory backend.
2
+
3
+ Supervisor is the sole reader/writer. Agents never touch this directly.
4
+
5
+ Memory is *advisory*: every operation is best-effort. If the configured backend is
6
+ unavailable or misconfigured (e.g. an unsupported embedding provider), the
7
+ store degrades gracefully — operations log a warning, set ``degraded``, and
8
+ return safe defaults — so the Supervisor can still run agents. Memory is an
9
+ enhancement, not a hard dependency for executing a task.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import logging
17
+ import threading
18
+ from dataclasses import dataclass
19
+ from time import monotonic
20
+ from collections.abc import Iterable
21
+ from typing import Any, Literal
22
+ from uuid import UUID
23
+
24
+ from syncmill.config import MemoryConfig
25
+
26
+ # Module-level on purpose: memory_backend itself is stdlib+config only (every
27
+ # SDK import inside it is lazy), so this cannot re-introduce the construction
28
+ # failures that __init__'s lazy build_backend import guards against.
29
+ from syncmill.memory_backend import WriteBlockedError
30
+ from syncmill.models import Task, TaskResult
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Prefix of the per-board-run scratch namespaces (`board-run:{run_id}`), set by
35
+ # `scoped` and swept by the GC methods below. A syncmill convention (the board
36
+ # is syncmill's, not memtomem's), so declared here rather than derived from
37
+ # memtomem.constants like the agent/shared names.
38
+ BOARD_RUN_PREFIX = "board-run:"
39
+
40
+ # Ceiling on one board-run scratch collection pass. The sweep is best-effort and
41
+ # runs after an outcome or an empty claim. Unfinished namespaces remain
42
+ # collectable for the next pass; this is a cooperative maintenance budget.
43
+ _BOARD_GC_BUDGET_SECONDS = 30.0
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class BoardRunPurgeResult:
48
+ """Per-pass receipts; deferred work is not a failed purge attempt."""
49
+
50
+ successful: frozenset[str]
51
+ failed: frozenset[str]
52
+ unattempted: frozenset[str]
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SharedRead:
57
+ """One ``get_shared`` outcome, decided from that call alone (#169).
58
+
59
+ ``found`` carries the shared chunk; ``not_found`` covers a missing id, a
60
+ malformed id and a chunk outside ``shared`` alike (the privacy contract —
61
+ a private chunk's existence is never disclosed); ``unavailable`` is a
62
+ backend failure or a refused board-scoped view, never a data answer.
63
+ """
64
+
65
+ status: Literal["found", "not_found", "unavailable"]
66
+ chunk: dict | None = None
67
+
68
+
69
+ # WHY EVERY PAYLOAD BELOW LABELS ITSELF IN **BOLD** RATHER THAN WITH A `## `
70
+ # HEADING (the call sites point here):
71
+ #
72
+ # memtomem's `format_entry_block` treats heading-led content as supplying its
73
+ # own heading and skips the one syncmill passes — so a `## Task Result` payload
74
+ # produced the SAME heading on every result. Two chunks sharing a heading are
75
+ # mergeable, and the damage was not limited to the same-second window that
76
+ # motivated the unique heading: consecutive task results were packed into ONE
77
+ # chunk, the chunk ids already returned to `share_result` were re-minted (the
78
+ # provenance share then found nothing), and the tags blockquote — emitted
79
+ # BEFORE the content's own heading in that branch — landed in the previous
80
+ # entry's section, so the surviving chunk carried NO tags and `tag_filter`
81
+ # could not retrieve it at all.
82
+ #
83
+ # A heading-free payload keeps the whole entry in one section under the unique
84
+ # `## Entry …` heading, which is where the tags are correctly attributed. Note
85
+ # a bold line is also NOT enough on its own if the body carries headings of its
86
+ # own: agent output is embedded verbatim, so an agent that writes `## Summary`
87
+ # still opens a section whose heading two runs can share. That residue predates
88
+ # this change and cannot be closed without either mutating the agent's text
89
+ # (the backend contract is verbatim storage) or a chunker change upstream.
90
+
91
+ # Advisory per-agent namespace colors for register_agent's mem_agent_register-style
92
+ # metadata. Unknown agent ids map to None, which leaves any existing color as-is.
93
+ _AGENT_COLORS = {
94
+ "claude": "#D97757",
95
+ "codex": "#10A37F",
96
+ "kimi-code": "#1E80FF",
97
+ }
98
+
99
+ # How many merged-ranking rows one search_context call requests (and renders).
100
+ _CONTEXT_TOP_K = 5
101
+ #: Ceiling for a caller-widened `search_context`. A strategy that publishes N
102
+ #: artifacts and must read them all back asks for N, but `max_subtasks` is
103
+ #: free-form config, so the request is clamped: an unbounded top_k would make a
104
+ #: pathological setting issue a huge search whose rows the prompt cap then
105
+ #: discards anyway. Mirrors the gateway's MAX_TOP_K untrusted-input guard.
106
+ _CONTEXT_TOP_K_MAX = 50
107
+ #: Default UTF-8 byte budget for a formatted search_context block. It leaves
108
+ #: 200 bytes of spare context capacity below _enrich_prompt's 4,000-byte backstop;
109
+ #: neither budget includes the wrapper or the original task prompt.
110
+ _CONTEXT_MAX_BYTES = 3_800
111
+ _CONTEXT_SEPARATOR = "\n---\n"
112
+ _CONTEXT_SEPARATOR_BYTES = len(_CONTEXT_SEPARATOR.encode("utf-8"))
113
+
114
+
115
+ def _format_context_rows(
116
+ results: list[dict[str, Any]], max_bytes: int = _CONTEXT_MAX_BYTES
117
+ ) -> str:
118
+ """Format memory search rows into a single text block bounded by max_bytes.
119
+
120
+ Replaces uncoordinated static cuts with a coordinated, row-aware budget:
121
+ - Normalizes surrogates with errors='replace' to avoid UnicodeEncodeError.
122
+ - Bounds source prefix to 64 chars and computes exact UTF-8 byte length.
123
+ - Computes character-aware minimum viable bytes M_i = min(C_full, C_trunc)
124
+ where C_trunc = len(content[0].encode("utf-8")) + 3 (first char + "...").
125
+ - Enforces overflow tail-drop loop: drops lowest-ranked tail rows until
126
+ minimum budget fits within max_bytes.
127
+ - Allocates baseline M_i to each surviving row, then distributes remaining
128
+ surplus adaptively to longer rows.
129
+ - Truncates exceeding rows strictly at valid UTF-8 character boundaries and
130
+ appends '...'.
131
+ - Guarantees total rendered UTF-8 bytes <= max_bytes.
132
+
133
+ Coverage takes priority over excerpt depth: a wider window yields shorter
134
+ excerpts. The minimum is a representation bound, not a semantic guarantee.
135
+ Do not impose a larger fixed per-row floor that silently drops subtask rows.
136
+ """
137
+ if not results or max_bytes <= 0:
138
+ return ""
139
+
140
+ # Normalize source and content against lone surrogates
141
+ normalized_rows: list[tuple[str, str, int, int]] = []
142
+ for r in results:
143
+ raw_source = str(r.get("source") or "unknown")
144
+ norm_source = raw_source.encode("utf-8", errors="replace").decode("utf-8")[:64]
145
+ prefix = f"[{norm_source}] "
146
+ prefix_bytes = len(prefix.encode("utf-8"))
147
+
148
+ raw_content = str(r.get("content") or "")
149
+ norm_content = raw_content.encode("utf-8", errors="replace").decode("utf-8")
150
+ full_bytes = len(norm_content.encode("utf-8"))
151
+
152
+ if not norm_content:
153
+ min_content_bytes = 0
154
+ else:
155
+ first_char_bytes = len(norm_content[0].encode("utf-8"))
156
+ trunc_min = first_char_bytes + 3 # 1st char + "..."
157
+ min_content_bytes = min(full_bytes, trunc_min)
158
+
159
+ normalized_rows.append((prefix, norm_content, prefix_bytes, min_content_bytes))
160
+
161
+ # Tail-drop loop: drop lowest-ranked tail rows until minimum budget fits
162
+ n = len(normalized_rows)
163
+ while n > 1:
164
+ total_min = (
165
+ sum(
166
+ prefix_bytes + min_c
167
+ for _, _, prefix_bytes, min_c in normalized_rows[:n]
168
+ )
169
+ + (n - 1) * _CONTEXT_SEPARATOR_BYTES
170
+ )
171
+ if total_min <= max_bytes:
172
+ break
173
+ n -= 1
174
+
175
+ # Check if even single row cannot fit
176
+ if n == 1:
177
+ prefix, norm_content, prefix_bytes, min_content_bytes = normalized_rows[0]
178
+ if prefix_bytes + min_content_bytes > max_bytes:
179
+ return ""
180
+
181
+ surviving = normalized_rows[:n]
182
+
183
+ # Baseline allocation: give each surviving row its min_content_bytes
184
+ allocated_content = [min_c for _, _, _, min_c in surviving]
185
+ total_allocated = (
186
+ sum(
187
+ prefix_bytes + alloc
188
+ for (_, _, prefix_bytes, _), alloc in zip(surviving, allocated_content)
189
+ )
190
+ + (n - 1) * _CONTEXT_SEPARATOR_BYTES
191
+ )
192
+ surplus = max_bytes - total_allocated
193
+
194
+ # Distribute surplus adaptively to rows that have more content
195
+ full_content_bytes = [
196
+ len(content.encode("utf-8")) for _, content, _, _ in surviving
197
+ ]
198
+ unsatisfied = [i for i in range(n) if allocated_content[i] < full_content_bytes[i]]
199
+
200
+ while surplus > 0 and unsatisfied:
201
+ share = max(1, surplus // len(unsatisfied))
202
+ progress = False
203
+ new_unsatisfied = []
204
+ for i in unsatisfied:
205
+ needed = full_content_bytes[i] - allocated_content[i]
206
+ grant = min(needed, share, surplus)
207
+ if grant > 0:
208
+ allocated_content[i] += grant
209
+ surplus -= grant
210
+ progress = True
211
+ if allocated_content[i] < full_content_bytes[i]:
212
+ new_unsatisfied.append(i)
213
+ if surplus <= 0:
214
+ break
215
+ if not progress:
216
+ break
217
+ unsatisfied = new_unsatisfied
218
+
219
+ # Render each row
220
+ lines: list[str] = []
221
+ for (prefix, content, _, _), alloc_bytes in zip(surviving, allocated_content):
222
+ c_bytes = len(content.encode("utf-8"))
223
+ if c_bytes <= alloc_bytes:
224
+ lines.append(f"{prefix}{content}")
225
+ else:
226
+ # Need to truncate to fit within alloc_bytes (alloc_bytes >= min_content_bytes >= first_char + 3)
227
+ max_c_bytes = alloc_bytes - 3 # reserve 3 bytes for "..."
228
+ encoded = content.encode("utf-8")
229
+ truncated_bytes = encoded[:max_c_bytes].decode("utf-8", errors="ignore")
230
+ lines.append(f"{prefix}{truncated_bytes}...")
231
+
232
+ rendered = _CONTEXT_SEPARATOR.join(lines)
233
+ return rendered
234
+
235
+
236
+ class SyncmillMemory:
237
+ """syncmill-specific conventions over a pluggable memory backend.
238
+
239
+ Namespace layout (supplied by the selected backend):
240
+ agent-runtime:{agent_id} — private namespace per agent
241
+ shared — cross-agent knowledge base
242
+ """
243
+
244
+ def __init__(self, config: MemoryConfig):
245
+ # Memory is advisory even at construction: if the backend can't be imported
246
+ # or built, degrade instead of crashing Supervisor(config). With a None
247
+ # backend, each method's try/except below catches the AttributeError and
248
+ # returns its safe default.
249
+ self.degraded = False
250
+ self.last_error: str | None = None
251
+ self._backend_name = config.backend
252
+ self._backend = None
253
+ # When set (by `scoped`), BOTH the per-agent namespace and the shared
254
+ # namespace collapse to this one per-run scope (`board-run:{run_id}`) — so
255
+ # a board run's strategy-internal handoffs AND its episodic session
256
+ # prompt-log stay off the persistent global `shared` / `agent-runtime:*`
257
+ # namespaces. None for an ordinary memory (the default).
258
+ self._scope: str | None = None
259
+ # The REAL global ``shared`` namespace, captured by ``scoped`` so a
260
+ # board-scoped view can UNION-READ cross-run knowledge from it (D4) while its
261
+ # WRITES stay confined to the per-run scope (the leak guard). None on an
262
+ # ordinary memory — which already reads global ``shared`` as ``_shared_ns``.
263
+ self._global_shared_ns: str | None = None
264
+ # Namespace conventions come from the selected backend. The fallbacks
265
+ # match syncmill's established public layout and are unused when backend
266
+ # construction fails.
267
+ self._agent_prefix = "agent-runtime:"
268
+ self._shared_ns = "shared"
269
+ try:
270
+ from syncmill.memory_backend import build_backend
271
+
272
+ self._backend = build_backend(config)
273
+ self._agent_prefix = self._backend.agent_namespace_prefix
274
+ self._shared_ns = self._backend.shared_namespace
275
+ except Exception as exc:
276
+ self._degrade("init", exc)
277
+
278
+ def _degrade(self, where: str, exc: Exception) -> None:
279
+ """Record a non-fatal memory-backend failure and keep going."""
280
+ self.degraded = True
281
+ self.last_error = f"{type(exc).__name__}: {exc}"
282
+ logger.warning("memory unavailable during %s: %s", where, self.last_error)
283
+
284
+ def _mark_healthy(self) -> None:
285
+ """Latest-outcome semantics, the breaker's rule (`breaker.py`): a fully
286
+ successful backend operation clears a PRIOR transient degrade, so a
287
+ long-lived process (`board run --loop`, the TUI) doesn't report a
288
+ recovered backend as degraded for the rest of its lifetime. Never called
289
+ on a partially successful op (a partial share's lost provenance must
290
+ stay surfaced until a later op fully succeeds), and a construction
291
+ failure can never clear — a None backend has not succeeded at anything.
292
+ """
293
+ if self._backend is None:
294
+ return
295
+ self.degraded = False
296
+ self.last_error = None
297
+
298
+ def _agent_ns(self, agent_id: str) -> str:
299
+ """The per-agent namespace — ``agent-runtime:{agent_id}`` normally, or the
300
+ per-run scope when this is a board-scoped view. The single place the
301
+ agent-vs-scope choice is made, so every read/write/session honors it."""
302
+ return self._scope or f"{self._agent_prefix}{agent_id}"
303
+
304
+ def scoped(self, run_id: str) -> "SyncmillMemory":
305
+ """A board-scoped VIEW of this memory: both the shared namespace AND every
306
+ per-agent (session/search) namespace collapse to ``board-run:{run_id}``.
307
+
308
+ Closes the board↔``shared`` leak: ``run_item`` runs a board item through the
309
+ normal ``run`` pipeline with this view, so the strategy-internal shares
310
+ (pipeline/council/decompose) AND ``start_task_session``'s prompt-log all
311
+ write to the per-run scope — intra-run handoff still works — and NOTHING
312
+ board-enriched lands on the persistent global ``shared`` or
313
+ ``agent-runtime:*`` namespaces. The single sanitized summary that DOES reach
314
+ global ``shared`` is written separately via :meth:`share_board_result` on
315
+ the UNSCOPED memory. Shares the underlying backend; best-effort like the
316
+ parent. Scope-validation failures degrade the parent before the view exists;
317
+ later backend failures degrade the view that performs the operation. Board
318
+ runs remain advisory on memory like any other run.
319
+
320
+ THE ONE DELIBERATELY-RAISING METHOD: an invalid scope raises
321
+ ``ValueError`` (the class contract everywhere else is "degrade and
322
+ return a safe default") because the caller is about to take
323
+ load-bearing board mutations against that scope — the Supervisor wraps
324
+ it into a ``BoardError`` before any claim is taken."""
325
+ scope = f"{BOARD_RUN_PREFIX}{run_id}"
326
+ if self._backend is not None:
327
+ try:
328
+ valid = self._backend.validate_namespace(scope)
329
+ except Exception as exc:
330
+ # Validation is specified as pure/non-raising. Preserve memory's
331
+ # advisory posture if a broken backend violates that contract.
332
+ self._degrade("validate_namespace", exc)
333
+ else:
334
+ if not valid:
335
+ raise ValueError(f"invalid board-run memory namespace: {scope!r}")
336
+
337
+ view = SyncmillMemory.__new__(SyncmillMemory)
338
+ view._backend_name = self._backend_name
339
+ view._backend = self._backend
340
+ view.degraded = False
341
+ view.last_error = None
342
+ view._agent_prefix = self._agent_prefix
343
+ view._shared_ns = scope
344
+ view._scope = scope
345
+ # Remember the real global ``shared`` so search_context can UNION-READ it for
346
+ # cross-run context (D4). Writes still target the per-run ``_shared_ns`` above
347
+ # — only reads widen, so the board↔shared write leak guard is untouched.
348
+ # On an already-scoped parent `_shared_ns` IS that parent's scratch, so
349
+ # carry the parent's captured global instead — a doubly-scoped view must
350
+ # never union-read another run's scratch as "shared".
351
+ view._global_shared_ns = self._global_shared_ns or self._shared_ns
352
+ return view
353
+
354
+ # ── Agent registration ───────────────────────────────────────────────
355
+
356
+ async def register_agent(
357
+ self,
358
+ agent_id: str,
359
+ *,
360
+ description: str | None = None,
361
+ color: str | None = None,
362
+ ) -> None:
363
+ """Register agent-runtime:{agent_id} metadata and ensure ``shared`` exists.
364
+
365
+ The agent namespace gets mem_agent_register-style metadata (an
366
+ unconditional description/color upsert), so registered agents are
367
+ identifiable from memtomem's own CLI/MCP surfaces."""
368
+ # Scratch scopes are implicit-create and short-lived. Explicit metadata
369
+ # registration produces a metadata-only row that memtomem 0.2.4 cannot
370
+ # delete (#1705), so only persistent/global memory is registered.
371
+ if self._scope is not None:
372
+ return
373
+ try:
374
+ ns = self._agent_ns(agent_id)
375
+ # `None` means "not provided" → default; an explicit "" is a valid
376
+ # clear-the-field update in memtomem and must pass through verbatim.
377
+ await self._backend.register_namespace(
378
+ ns,
379
+ description=(
380
+ description if description is not None else f"Agent: {agent_id}"
381
+ ),
382
+ color=color if color is not None else _AGENT_COLORS.get(agent_id),
383
+ )
384
+ await self._backend.ensure_namespace(
385
+ self._shared_ns, description="Shared knowledge"
386
+ )
387
+ self._mark_healthy()
388
+ except Exception as exc:
389
+ self._degrade("register_agent", exc)
390
+
391
+ # ── Task session lifecycle ───────────────────────────────────────────
392
+
393
+ async def start_task_session(self, agent_id: str, task: Task) -> str | None:
394
+ """Start an episodic memory session. Returns the session id, or None
395
+ if memory is unavailable."""
396
+ try:
397
+ ns = self._agent_ns(agent_id)
398
+ session_id = await self._backend.start_session(agent_id, ns)
399
+ await self._backend.log_event("task_start", f"Prompt: {task.prompt[:200]}")
400
+ self._mark_healthy()
401
+ return session_id
402
+ except Exception as exc:
403
+ self._degrade("start_task_session", exc)
404
+ return None
405
+
406
+ async def end_task_session(
407
+ self, session_id: str | None, result: TaskResult
408
+ ) -> None:
409
+ """End the session and log the outcome."""
410
+ try:
411
+ summary = (
412
+ f"{result.agent_id}: {result.status.value} "
413
+ f"(exit={result.exit_code}, {result.duration_seconds}s)"
414
+ )
415
+ if result.error:
416
+ await self._backend.log_event("task_error", result.error[:500])
417
+ await self._backend.log_event(
418
+ "task_end",
419
+ f"exit_code={result.exit_code}, duration={result.duration_seconds}s",
420
+ )
421
+ await self._backend.end_session(summary)
422
+ self._mark_healthy()
423
+ except Exception as exc:
424
+ self._degrade("end_task_session", exc)
425
+
426
+ # ── Result sharing ───────────────────────────────────────────────────
427
+
428
+ async def share_result(
429
+ self, task: Task, result: TaskResult, *, kind: str = "task-result"
430
+ ) -> None:
431
+ """Write a successful result summary to the shared namespace.
432
+
433
+ ``kind`` is the artifact-type tag (``task-result``, ``decompose-plan``,
434
+ ``council-proposal``, …) so unified search / ``tag_filter`` can pull one
435
+ artifact class precisely; ``task:{task.id}`` cross-links every artifact
436
+ of one run (the ``board-result:{item_id}`` composite-tag precedent).
437
+
438
+ Unscoped, the write is an agent-share two-step: the result lands in the
439
+ producing agent's private ``agent-runtime:{agent_id}`` namespace first,
440
+ then is COPIED to ``shared`` with mem_agent_share provenance (a
441
+ ``shared-from=<id>`` tag + a chunk_links row where the backend supports
442
+ it). Either step failing falls back to today's direct ``shared`` write.
443
+ A board-scoped view always writes directly: both namespaces collapse to
444
+ ``board-run:{run_id}``, so a copy would be pure duplication — and the
445
+ write-leak guard stays byte-identical."""
446
+ try:
447
+ provenance_lost = False
448
+ # BOLD, never a `## ` heading — see the heading note at the top of this module.
449
+ content = (
450
+ f"**Task Result**\n\n**Prompt:** {task.prompt}\n\n"
451
+ f"**Agent:** {result.agent_id}\n\n**Output:**\n{result.output[:1000]}"
452
+ )
453
+ tags = [kind, result.agent_id, f"task:{task.id}"]
454
+ if result.files_changed:
455
+ content += f"\n\n**Files changed:** {', '.join(result.files_changed)}"
456
+ if self._scope is None:
457
+ try:
458
+ chunk_ids = await self._backend.add_returning_ids(
459
+ content, tags=tags, namespace=self._agent_ns(result.agent_id)
460
+ )
461
+ if chunk_ids:
462
+ shared_ids = [
463
+ await self._backend.share_chunk(
464
+ cid, target_namespace=self._shared_ns
465
+ )
466
+ for cid in chunk_ids
467
+ ]
468
+ # Every chunk of the result must reach `shared`.
469
+ if all(sid is not None for sid in shared_ids):
470
+ self._mark_healthy()
471
+ return
472
+ # A PARTIAL share is not a failed share: some chunks are
473
+ # already in `shared`, so the direct fallback below would
474
+ # add a second full copy beside them — inflating
475
+ # shared_stats and feeding near-duplicate rows to every
476
+ # reader. Surface the incomplete provenance instead; a
477
+ # raising share_chunk degrades the same way just below.
478
+ if any(sid is not None for sid in shared_ids):
479
+ self._degrade(
480
+ "share_result",
481
+ RuntimeError(
482
+ f"shared {sum(s is not None for s in shared_ids)}"
483
+ f" of {len(shared_ids)} chunks"
484
+ ),
485
+ )
486
+ return
487
+ # No fresh ids at all (a content-hash dedup) is a benign
488
+ # edge, not a failure: nothing reached `shared`, so the
489
+ # direct write below is the whole share and adds no
490
+ # duplicate. Memory stays healthy.
491
+ except WriteBlockedError as exc:
492
+ # The redaction guard blocked this content; the direct
493
+ # fallback runs the same guard on the same content and can
494
+ # only re-block — degrade once and stop.
495
+ self._degrade("share_result", exc)
496
+ return
497
+ except Exception as exc:
498
+ # Fall through to the direct write — a broken provenance
499
+ # path must not lose the share entirely — but SURFACE the
500
+ # loss: the private-original/link contract was not met.
501
+ self._degrade("share_result", exc)
502
+ provenance_lost = True
503
+ await self._backend.add(content, tags=tags, namespace=self._shared_ns)
504
+ # The direct write clears a PRIOR degrade only when THIS call met its
505
+ # whole contract — a provenance-lost fallback stays surfaced.
506
+ if not provenance_lost:
507
+ self._mark_healthy()
508
+ except Exception as exc:
509
+ self._degrade("share_result", exc)
510
+
511
+ async def share_board_result(
512
+ self, item_id: str, task_text: str, stripped_output: str
513
+ ) -> bool:
514
+ """Write ONE sanitized board-item summary to the GLOBAL ``shared`` namespace.
515
+
516
+ Called by ``run_item`` on the UNSCOPED memory after a board run, so this is
517
+ the single board-derived write that reaches persistent global knowledge.
518
+ The content is built from the item's task text + the agent output with the
519
+ ``syncmill-board`` block stripped (``board.strip_board_block``) — NEVER the
520
+ board-enriched prompt, decisions, or handoff — so no board-internal context
521
+ leaks into ``shared``.
522
+
523
+ **The cross-link is the SINGLE composite tag** ``board-result:{item_id}``:
524
+ memtomem ``add()`` returns ``{"file", "indexed_chunks"}`` with NO chunk id,
525
+ and ``search(tag_filter=...)`` takes a ``str`` (comma = OR), so the exact
526
+ composite tag is the retrieval key — never a ``["board-result", item_id]``
527
+ list (which ``tag_filter`` would treat as an OR of two unrelated tags).
528
+ Refuses on a SCOPED view, like every other ``shared``-facing method:
529
+ shared-only BY CONSTRUCTION, not by name — on a board-scoped view
530
+ ``_shared_ns`` IS the ``board-run:*`` scratch, so the one cross-link
531
+ meant to outlive the run would land in the namespace the next GC pass
532
+ deletes, and report success doing it.
533
+
534
+ Best-effort: returns ``True`` on a successful write, ``False`` on any backend
535
+ failure (degrading like the other writers — a board run never aborts because
536
+ the global share failed)."""
537
+ if self._scope is not None:
538
+ return False
539
+ try:
540
+ # BOLD, never a `## ` heading — see the heading note at the top of this module.
541
+ content = (
542
+ f"**Board Item Result**\n\n**Item:** {item_id}\n\n"
543
+ f"**Task:** {task_text}\n\n**Output:**\n{stripped_output[:1000]}"
544
+ )
545
+ tags = [f"board-result:{item_id}"]
546
+ add_fn = getattr(self._backend, "add_returning_ids", None)
547
+ if add_fn is not None:
548
+ await add_fn(content, tags=tags, namespace=self._shared_ns)
549
+ else:
550
+ await self._backend.add(content, tags=tags, namespace=self._shared_ns)
551
+ self._mark_healthy()
552
+ return True
553
+ except Exception as exc:
554
+ self._degrade("share_board_result", exc)
555
+ return False
556
+
557
+ # ── Context search ───────────────────────────────────────────────────
558
+
559
+ async def search_context(
560
+ self,
561
+ query: str,
562
+ agent_id: str | None = None,
563
+ *,
564
+ top_k: int | None = None,
565
+ tag_filter: str | None = None,
566
+ max_bytes: int = _CONTEXT_MAX_BYTES,
567
+ ) -> str:
568
+ """Search agent-scoped + shared memories, return as formatted text.
569
+
570
+ One unified merged-ranking search (mem_agent_search's include_shared
571
+ semantics): the agent namespace and ``shared`` are one relevance-ranked
572
+ pool — a single native union call on memtomem, an adapter-side merge on
573
+ the other backends. Returns an empty string if memory is unavailable or
574
+ nothing matches.
575
+
576
+ ``tag_filter`` targets specific artifact kinds (e.g. ``pipeline-stage``,
577
+ ``decompose-subtask``, or a comma-separated OR like
578
+ ``council-proposal,council-critique``). Backends lacking tag filter support
579
+ return an untagged search with a warning and degraded status.
580
+
581
+ ``top_k`` widens the window (clamped to ``_CONTEXT_TOP_K_MAX``) for a
582
+ caller that published N artifacts THIS run and needs to read them back:
583
+ the default 5 is smaller than ``decompose.max_subtasks`` (8), so a
584
+ default decompose could never surface three of its own subtask results.
585
+
586
+ A wider window is not a guarantee of inclusion. Tags select artifact
587
+ kinds, not this run: older rows of the same kind still compete, one
588
+ result can occupy several chunks, and the byte budget may shorten or
589
+ drop rows. Intra-run handoff remains best-effort (see #125).
590
+
591
+ ``max_bytes`` enforces a coordinated, row-aware UTF-8 byte budget across
592
+ all returned rows (default 3,800 bytes, fitting safely within the
593
+ platform/argv prompt cap without blind mid-row byte cuts).
594
+ """
595
+ try:
596
+ namespaces: list[str] = []
597
+ if agent_id:
598
+ namespaces.append(self._agent_ns(agent_id))
599
+ if self._shared_ns not in namespaces:
600
+ namespaces.append(self._shared_ns)
601
+
602
+ # D4: a board-scoped view ALSO union-reads the REAL global `shared`, so a
603
+ # board run is enriched by cross-run knowledge (prior `board-result`
604
+ # summaries, task results) — not only its per-run `board-run:{run_id}`
605
+ # scope. Reads widen; writes stay scoped (the leak guard is untouched). A
606
+ # plain (unscoped) run skips this — its `_shared_ns` IS global `shared`.
607
+ if (
608
+ self._scope is not None
609
+ and self._global_shared_ns
610
+ and self._global_shared_ns not in namespaces
611
+ ):
612
+ namespaces.append(self._global_shared_ns)
613
+
614
+ window = (
615
+ _CONTEXT_TOP_K
616
+ if top_k is None
617
+ else max(1, min(top_k, _CONTEXT_TOP_K_MAX))
618
+ )
619
+
620
+ effective_tag_filter = tag_filter
621
+ if tag_filter is not None and not getattr(
622
+ getattr(self._backend, "capabilities", None),
623
+ "supports_tag_filter",
624
+ True,
625
+ ):
626
+ effective_tag_filter = None
627
+
628
+ results = await self._backend.search_multi(
629
+ query,
630
+ namespaces=namespaces,
631
+ top_k=window,
632
+ tag_filter=effective_tag_filter,
633
+ )
634
+ self._mark_healthy()
635
+
636
+ if tag_filter is not None and effective_tag_filter is None:
637
+ self._degrade(
638
+ "search_context",
639
+ ValueError(
640
+ "backend does not support tag_filter; returned unfiltered context"
641
+ ),
642
+ )
643
+
644
+ if not results:
645
+ return ""
646
+
647
+ return _format_context_rows(results[:window], max_bytes=max_bytes)
648
+ except Exception as exc:
649
+ self._degrade("search_context", exc)
650
+ return ""
651
+
652
+ # ── Read-only shared gateway (#15) ───────────────────────────────────
653
+ #
654
+ # These three methods back the read-only MCP gateway (see ``gateway.py``).
655
+ # They are shared-scoped *structured* reads — distinct from ``search_context``
656
+ # (which merges agent+shared scopes and formats text for prompt injection).
657
+ # Each call reports its OWN outcome so the gateway can distinguish
658
+ # "unavailable" from a healthy-empty result without consulting the sticky
659
+ # ``degraded`` flag: ``search_shared``/``shared_stats`` return ``None`` on a
660
+ # genuine per-call backend failure and the concrete value on success;
661
+ # ``get_shared`` returns a ``SharedRead`` whose status separates ``found``,
662
+ # ``not_found`` and ``unavailable`` (#169 — a plain ``None`` conflated a
663
+ # storage outage with a missing chunk, so the MCP client was told "not
664
+ # found" while the backend was down).
665
+ #
666
+ # Shared-only BY CONSTRUCTION, not by name: on a board-scoped view
667
+ # ``_shared_ns`` IS the ``board-run:*`` scratch, so these readers refuse
668
+ # (return ``None`` = unavailable) rather than silently serving scratch as
669
+ # shared — a scoped view handed to ``build_gateway`` would otherwise pass
670
+ # the namespace checks while leaking board-internal content.
671
+
672
+ async def search_shared(self, query: str, top_k: int = 10) -> list[dict] | None:
673
+ """Search ONLY the shared namespace; return the raw result rows.
674
+
675
+ Returns ``None`` on backend failure, else the (possibly empty) list of
676
+ rows whose namespace is ``shared``.
677
+ """
678
+ if self._scope is not None:
679
+ return None
680
+ try:
681
+ results = await self._backend.search(
682
+ query, namespace=self._shared_ns, top_k=top_k
683
+ )
684
+ self._mark_healthy()
685
+ # Defense in depth: the namespace= arg already scopes the search, but
686
+ # filter so the shared-only guarantee is local to this method.
687
+ return [r for r in results if r.get("namespace") == self._shared_ns]
688
+ except Exception as exc:
689
+ self._degrade("search_shared", exc)
690
+ return None
691
+
692
+ async def get_shared(self, chunk_id: str) -> SharedRead:
693
+ """Read one chunk by id, but ONLY if it lives in the shared namespace.
694
+
695
+ Isolation guard for the gateway: ``MemtomemStore.get`` is not
696
+ namespace-scoped, so a guessed/leaked id could otherwise pull an
697
+ ``agent-runtime:*`` private chunk — anything not in ``shared`` is
698
+ ``not_found`` (never the chunk), indistinguishable from a missing id.
699
+ A malformed id is a client typo (``not_found``), NOT a backend failure,
700
+ so it never degrades; only a genuine store error degrades, and THAT is
701
+ reported as ``unavailable`` — decided from this call alone, never from
702
+ the sticky ``degraded`` flag. A board-scoped view is refused as
703
+ ``unavailable`` (its shared namespace is the board-run scratch).
704
+ """
705
+ if self._scope is not None:
706
+ return SharedRead("unavailable")
707
+ try:
708
+ UUID(str(chunk_id))
709
+ except (ValueError, TypeError):
710
+ return SharedRead("not_found")
711
+ try:
712
+ r = await self._backend.get(chunk_id)
713
+ self._mark_healthy()
714
+ except Exception as exc:
715
+ self._degrade("get_shared", exc)
716
+ return SharedRead("unavailable")
717
+ if r is None or r.get("namespace") != self._shared_ns:
718
+ return SharedRead("not_found")
719
+ return SharedRead("found", r)
720
+
721
+ async def shared_stats(self) -> dict | None:
722
+ """Chunk count of the shared namespace (only).
723
+
724
+ Uses the backend's namespace counter and reports the ``shared`` entry
725
+ alone — never other namespaces, so it
726
+ can't disclose the existence of agent scopes. Returns ``None`` on backend
727
+ failure, else ``{"namespace": "shared", "chunk_count": N}`` (N=0 if shared
728
+ isn't created yet).
729
+ """
730
+ if self._scope is not None:
731
+ return None
732
+ try:
733
+ count = await self._backend.count_namespace(self._shared_ns)
734
+ self._mark_healthy()
735
+ return {"namespace": self._shared_ns, "chunk_count": count}
736
+ except Exception as exc:
737
+ self._degrade("shared_stats", exc)
738
+ return None
739
+
740
+ # ── Board-run namespace GC ───────────────────────────────────────────
741
+ #
742
+ # Board SQLite owns discovery/liveness in P2. Memory only performs the known,
743
+ # prefix-reconstructed deletions and reports exactly which calls completed.
744
+
745
+ async def delete_board_runs(self, run_ids: Iterable[str]) -> set[str] | None:
746
+ """Compatibility surface returning only successful deletion IDs.
747
+
748
+ Schedulers must use purge_board_runs to distinguish failures from work
749
+ deferred by the deadline. None means backend readiness was unavailable.
750
+ """
751
+ result = await self.purge_board_runs(run_ids)
752
+ return None if result is None else set(result.successful)
753
+
754
+ async def purge_board_runs(
755
+ self, run_ids: Iterable[str]
756
+ ) -> BoardRunPurgeResult | None:
757
+ """Delete the ``board-run:{run_id}`` namespace for each given run_id.
758
+
759
+ The prefix is reconstructed HERE from bare run_ids, so this method can
760
+ only ever delete ``board-run:*`` namespaces — never ``shared`` or
761
+ ``agent-runtime:*`` (a guard by construction, not by validation).
762
+ Each ``delete_namespace`` is independent, so a per-namespace failure
763
+ degrades and CONTINUES. Returns immutable successful, failed and unattempted run-ID sets
764
+ (including idempotent zero-chunk success), or ``None``
765
+ only when the backend itself is unreachable and nothing could be tried.
766
+
767
+ Deleting the rows is not enough on a markdown-backed store: the files are
768
+ what the index is rebuilt FROM, so a backend that can also drop a
769
+ namespace's source files is asked to FIRST, and the rows go only once
770
+ that succeeded. Otherwise the next reindex resurrects the scratch
771
+ content — and, its rows now gone, stamps it with whatever namespace that
772
+ pass resolves, which is how board-internal text would reach global
773
+ ``shared``. The rows are what keep a surviving file harmless in the
774
+ meantime, so they must outlive it, not the other way round. Backends
775
+ without source files simply don't offer the method.
776
+
777
+ A backend offering ``purge_namespace`` does both halves with syncmill's
778
+ writers LOCKED OUT, which is what closes the window between them — see
779
+ that method for exactly which writers are excluded (every syncmill
780
+ write, which takes the same namespace lock) and which (a memtomem-native
781
+ write, which does not) are not. It raises rather than reporting a count when the
782
+ purge cannot be proven complete, so an unprovable purge degrades below
783
+ and the namespace stays collectable.
784
+
785
+ Backends without it fall back to the two-call sequence plus a second
786
+ source sweep. That sweep restores the no-file-outlives-its-rows
787
+ invariant and, because finding something means a writer was attached,
788
+ disqualifies the acknowledgement — a namespace with a live producer must
789
+ stay collectable rather than be tombstoned. It NARROWS the window rather
790
+ than closing it: a write landing after the sweep is still possible.
791
+
792
+ A zero-chunk delete is a genuine purge, not a partial one: memtomem
793
+ removes the namespace's metadata row whether or not it held chunks
794
+ (#1705, fixed in 0.3.9 — below syncmill's version floor, so every
795
+ version the pin admits behaves this way). So a registered-but-never-
796
+ written scratch namespace is acknowledged and stops being a candidate,
797
+ rather than being re-attempted by every later sweep.
798
+ """
799
+ # This operation owns its deadline, including backend readiness. An
800
+ # outer timeout would discard successful receipts before the supervisor
801
+ # can tombstone them and schedule failed candidates for backoff.
802
+ gc_deadline = monotonic() + _BOARD_GC_BUDGET_SECONDS
803
+ try:
804
+ async with asyncio.timeout(max(0.0, gc_deadline - monotonic())):
805
+ await self._backend.ensure_ready()
806
+ except Exception as exc:
807
+ self._degrade("delete_board_runs", exc)
808
+ return None
809
+ drop_sources = getattr(self._backend, "delete_namespace_sources", None)
810
+ purge = getattr(self._backend, "purge_namespace", None)
811
+ successful: set[str] = set()
812
+ attempted: set[str] = set()
813
+ ids = list(dict.fromkeys(rid for rid in run_ids if rid))
814
+ for run_id in ids:
815
+ if monotonic() >= gc_deadline:
816
+ logger.info(
817
+ "board-run scratch collection stopped after %.0fs; the rest "
818
+ "stay collectable for the next sweep",
819
+ _BOARD_GC_BUDGET_SECONDS,
820
+ )
821
+ break
822
+ attempted.add(run_id)
823
+ namespace = f"{BOARD_RUN_PREFIX}{run_id}"
824
+ try:
825
+ async with asyncio.timeout(max(0.0, gc_deadline - monotonic())):
826
+ if purge is not None:
827
+ # Both halves under the writer lock; raises when it cannot
828
+ # prove the namespace is gone, which the degrade below turns
829
+ # into "stays collectable".
830
+ await purge(namespace)
831
+ successful.add(run_id)
832
+ continue
833
+ # SOURCES FIRST. Rows are what make a surviving file harmless:
834
+ # while they exist, a reindex recognizes the text and leaves its
835
+ # namespace alone. Delete them first and any failure below
836
+ # strands the markdown with nothing claiming it, so the next
837
+ # reindex reads it as new content and stamps it with whatever
838
+ # namespace that pass resolves — the leak into `shared` this
839
+ # whole path exists to prevent. The reverse residue is benign:
840
+ # files gone, rows still scoped to the scratch namespace, and
841
+ # the run stays unacknowledged so a later sweep retries them.
842
+ if drop_sources is not None:
843
+ await drop_sources(namespace)
844
+ await self._backend.delete_namespace(namespace)
845
+ # Nothing locks a writer out between those two calls, and a run
846
+ # collected while still alive is a documented (narrowed, not
847
+ # closed) race. A write landing in that window creates a file
848
+ # AND its row, and the row delete above then takes the row —
849
+ # leaving exactly the stranded markdown this ordering exists to
850
+ # prevent. So sweep once more: this both restores the invariant
851
+ # (no file outlives its rows) and DETECTS that a writer was
852
+ # active, which is what disqualifies the acknowledgement below.
853
+ raced = bool(
854
+ drop_sources is not None and await drop_sources(namespace)
855
+ )
856
+ except Exception as exc:
857
+ self._degrade("delete_board_runs", exc)
858
+ continue
859
+ if raced:
860
+ # A concurrent write means the namespace may be written again a
861
+ # moment from now. Tombstoning would retire it from collection
862
+ # with a live producer still attached, so leave it collectable.
863
+ self._degrade(
864
+ "delete_board_runs",
865
+ RuntimeError(f"namespace {namespace} was written during purge"),
866
+ )
867
+ continue
868
+ # Acknowledged only once BOTH halves are done, with no sign of a
869
+ # racing writer. A namespace whose sources could not be dropped
870
+ # raised above and is likewise never acknowledged — it must stay
871
+ # collectable for a later sweep to retry rather than be tombstoned.
872
+ successful.add(run_id)
873
+ # Latest-outcome: only a sweep where EVERY attempted delete completed
874
+ # clears a prior degrade — a partial sweep's failures stay surfaced.
875
+ # `ids` guards the vacuous case: all([]) is True, so an empty sweep
876
+ # would clear a prior degrade having performed no backend operation at
877
+ # all — only ensure_ready() ran.
878
+ if ids and all(rid in successful for rid in ids):
879
+ self._mark_healthy()
880
+ return BoardRunPurgeResult(
881
+ successful=frozenset(successful),
882
+ failed=frozenset(attempted - successful),
883
+ unattempted=frozenset(set(ids) - attempted),
884
+ )
885
+
886
+ # ── Status ───────────────────────────────────────────────────────────
887
+
888
+ async def status(self) -> dict:
889
+ """Check the configured memory backend's connection status."""
890
+ if self._backend is None:
891
+ return {
892
+ "connected": False,
893
+ "error": self.last_error or "memory backend unavailable",
894
+ }
895
+ try:
896
+ info = await self._backend.status()
897
+ return {"connected": True, **info}
898
+ except Exception as e:
899
+ return {"connected": False, "error": str(e)}
900
+
901
+ # ── Lifecycle ────────────────────────────────────────────────────────
902
+
903
+ async def close(self) -> None:
904
+ try:
905
+ await self._backend.close()
906
+ except Exception as exc:
907
+ self._degrade("close", exc)
908
+
909
+
910
+ _loop: asyncio.AbstractEventLoop | None = None
911
+ # Guards the create/replace/close of the module loop. run_until_complete itself
912
+ # is NOT made thread-safe by this (a second thread would still die on "loop
913
+ # already running") — every run_async caller in src/ is on the main thread; the
914
+ # lock only prevents two racing threads from each minting a loop and leaking
915
+ # one, and serializes close against a concurrent create.
916
+ _LOOP_GUARD = threading.Lock()
917
+
918
+
919
+ def run_async(coro: Any) -> Any:
920
+ """Run an async coroutine from sync context, reusing the event loop.
921
+
922
+ Memory backends are async; the rest of syncmill is sync. A single reused loop
923
+ avoids the "Event loop is closed" error that ``asyncio.run`` triggers on
924
+ backend resources.
925
+
926
+ An interrupt is contained HERE, not left to the callers: a
927
+ ``KeyboardInterrupt`` escapes ``run_until_complete`` with the in-flight
928
+ future still pending on the REUSED loop, and any later ``run_async`` (a
929
+ ``Supervisor.close()``, a room finalizer) would then resume the stranded
930
+ fan-out — with its now-detached agent subprocesses — mid-teardown. So on an
931
+ exception the future did not itself produce, the future is cancelled and
932
+ bounded-drained (each ``aexecute`` cancellation tail kills + reaps its
933
+ child) before the interrupt propagates. ``close_event_loop`` remains the
934
+ backstop for anything scheduled outside this entry point.
935
+ """
936
+ global _loop
937
+ with _LOOP_GUARD:
938
+ if _loop is None or _loop.is_closed():
939
+ _loop = asyncio.new_event_loop()
940
+ loop = _loop
941
+ task = asyncio.ensure_future(coro, loop=loop)
942
+ try:
943
+ return loop.run_until_complete(task)
944
+ except BaseException:
945
+ # task.done() means the exception came FROM the coroutine — nothing is
946
+ # stranded. Pending means the interrupt cut run_until_complete short.
947
+ if not task.done():
948
+ task.cancel()
949
+
950
+ async def _drain() -> None:
951
+ await asyncio.wait([task], timeout=_PENDING_DRAIN_TIMEOUT_SECONDS)
952
+
953
+ with contextlib.suppress(BaseException):
954
+ # A second Ctrl-C during the drain is deliberately absorbed —
955
+ # the FIRST interrupt is re-raised right below either way.
956
+ loop.run_until_complete(_drain())
957
+ raise
958
+
959
+
960
+ #: Deadline on best-effort memory calls made from the sync orchestration path
961
+ #: (the Supervisor and the strategies). ``SyncmillMemory`` degrades on
962
+ #: *exceptions*, but a HUNG backend — an unreachable Qdrant, a stuck network
963
+ #: read — never raises: without a bound the whole run hangs between fan-outs
964
+ #: with worktrees held. The bound covers CANCELLATION-COOPERATIVE async waits
965
+ #: (see ``run_memory_bounded``) — it is not a wall-clock guarantee against
966
+ #: loop-blocking sync work or a coroutine that suppresses cancellation. Shared
967
+ #: with the rooms path (``_ROOM_MEMORY_TIMEOUT_SECONDS``) so the two surfaces
968
+ #: keep one policy.
969
+ MEMORY_CALL_TIMEOUT_SECONDS = 30.0
970
+
971
+
972
+ def run_memory_bounded(
973
+ coro: Any,
974
+ *,
975
+ default: Any = None,
976
+ memory: "SyncmillMemory | None" = None,
977
+ label: str = "memory call",
978
+ timeout: float = MEMORY_CALL_TIMEOUT_SECONDS,
979
+ ) -> Any:
980
+ """``run_async`` with the advisory-memory hang bound.
981
+
982
+ On timeout the underlying backend operation is cancelled, ``default`` is
983
+ returned, and — when ``memory`` is passed — the memory surface is marked
984
+ degraded so the CLI's yellow notice reports it, exactly like any other
985
+ best-effort memory failure.
986
+
987
+ The deadline is COOPERATIVE (``asyncio.wait_for``), not a wall-clock
988
+ guarantee: a backend coroutine that blocks the loop synchronously (an
989
+ un-offloaded sqlite/embed call) delays the timer itself, and one that
990
+ suppresses cancellation and completes has its late value returned as a
991
+ normal success (``wait_for``'s documented behavior — for an advisory read,
992
+ a late real value beats the fallback). What the bound reliably converts is
993
+ the *await-shaped* hang — an unreachable Qdrant, a stuck network read —
994
+ the common failure class on this path — while one that never reaches an
995
+ await point (or suppresses cancellation without completing) stays hung; a
996
+ hard wall-clock bound would need process/thread isolation the advisory
997
+ surface doesn't justify.
998
+ """
999
+
1000
+ async def _bounded() -> Any:
1001
+ return await asyncio.wait_for(coro, timeout)
1002
+
1003
+ try:
1004
+ return run_async(_bounded())
1005
+ except (asyncio.TimeoutError, TimeoutError) as exc:
1006
+ logger.warning("%s timed out after %ss", label, timeout)
1007
+ if memory is not None:
1008
+ memory._degrade(label, exc)
1009
+ return default
1010
+
1011
+
1012
+ #: Bound on draining cancelled tasks at loop shutdown. Each cancelled
1013
+ #: ``aexecute`` SIGKILLs its child before its first await and reaps within
1014
+ #: ``agents._REAP_TIMEOUT_SECONDS`` (5s), concurrently — 10s covers that with
1015
+ #: headroom while guaranteeing shutdown itself can never hang.
1016
+ _PENDING_DRAIN_TIMEOUT_SECONDS = 10.0
1017
+
1018
+
1019
+ def _cancel_pending_tasks(loop: asyncio.AbstractEventLoop) -> None:
1020
+ """Cancel and drain tasks left pending on the shared loop, time-bounded.
1021
+
1022
+ A SIGINT escapes ``run_until_complete`` *without* cancelling the in-flight
1023
+ future, so a Ctrl-C during a concurrent fan-out strands the gather and every
1024
+ ``aexecute`` task — with live agent subprocesses — pending on the reused
1025
+ module loop. Left there, a later ``run_async`` (e.g. ``supervisor.close()``)
1026
+ re-enters the loop and RESUMES them mid-teardown, and ``loop.close()`` would
1027
+ destroy them un-awaited, leaking the children past process exit. Cancelling
1028
+ here triggers each ``aexecute``'s cancellation tail (kill + bounded reap),
1029
+ so the subprocesses die with the loop.
1030
+ """
1031
+ pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
1032
+ if not pending:
1033
+ return
1034
+ logger.warning(
1035
+ "cancelling %d task(s) left pending on the shared event loop", len(pending)
1036
+ )
1037
+ for task in pending:
1038
+ task.cancel()
1039
+
1040
+ async def _drain() -> None:
1041
+ await asyncio.wait(pending, timeout=_PENDING_DRAIN_TIMEOUT_SECONDS)
1042
+
1043
+ loop.run_until_complete(_drain())
1044
+
1045
+
1046
+ def close_event_loop() -> None:
1047
+ """Close the shared event loop. Call once at process shutdown (the CLI
1048
+ does this in its finally blocks) so the loop's resources are released.
1049
+
1050
+ Pending tasks are cancelled and drained first (see ``_cancel_pending_tasks``
1051
+ — the Ctrl-C-mid-fan-out case), then async generators are shut down —
1052
+ closing without draining them leaves backend resources (e.g. aiosqlite
1053
+ handles) finalized against a dead loop, the exact class of error the shared
1054
+ loop exists to avoid."""
1055
+ global _loop
1056
+ with _LOOP_GUARD:
1057
+ if _loop is not None and not _loop.is_closed():
1058
+ try:
1059
+ _cancel_pending_tasks(_loop)
1060
+ except Exception:
1061
+ logger.warning("pending-task drain failed", exc_info=True)
1062
+ try:
1063
+ _loop.run_until_complete(_loop.shutdown_asyncgens())
1064
+ except Exception:
1065
+ logger.warning("event-loop asyncgen shutdown failed", exc_info=True)
1066
+ _loop.close()
1067
+ _loop = None