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/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
|