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/board.py ADDED
@@ -0,0 +1,2060 @@
1
+ """Persistent agent work-board (Phase 1) — the data foundation.
2
+
3
+ syncmill today is a single-run, stateless orchestrator: a ``Task`` is one
4
+ ephemeral prompt, gone after the run. The board adds durable, *structured* records
5
+ of a multi-agent team's work — the goal, who owns which piece, what's blocked and
6
+ why, what's done **and verified**, whether work is being duplicated, and where a
7
+ human must step in. For an orchestration harness this is the infrastructure that
8
+ makes collaboration *measurable*.
9
+
10
+ **Why SQLite, not memtomem.** memtomem stores markdown+embedding *knowledge*
11
+ chunks — no structured status/dependency fields, no state machine, no atomic
12
+ compare-and-swap. The board needs exactly those, so it gets its own SQLite store
13
+ at ``~/.syncmill/board.db`` (a flat module, gateway/monitor/session precedent —
14
+ don't pre-abstract into a subpackage). *State lives only on the board; knowledge
15
+ lives only in memtomem's ``shared``; the two cross-link by id* (the Supervisor
16
+ writes one sanitized ``board-result:{item_id}`` summary to ``shared`` after a run).
17
+
18
+ **Load-bearing, built LAZILY.** The board is the source of work and the system of
19
+ record, so — unlike memory/breaker, which degrade silently — a corrupt/unusable
20
+ board RAISES :class:`BoardError` rather than running untracked work or re-running
21
+ ``done`` items. It is disabled by default (:func:`build_board` → ``None``) and the
22
+ Supervisor builds it lazily (NOT in ``__init__``), so an enabled-but-corrupt board
23
+ can never break a plain ``syncmill run`` that never touches it.
24
+
25
+ **Agents stay pure.** Orchestrated agents NEVER touch the board. The Supervisor
26
+ mediates: it renders an item into the prompt (:func:`render_item_prompt`) and
27
+ parses an OPTIONAL ``syncmill-board`` fenced block back out of the agent's output
28
+ (:func:`parse_board_update` — its own parser, strict, degrade-to-``{}``, never
29
+ raises, in the spirit of ``DecomposeStrategy._parse_subtasks``).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import logging
36
+ import math
37
+ import os
38
+ import sqlite3
39
+ import time
40
+ import uuid
41
+ from collections.abc import Iterable
42
+ from dataclasses import asdict, dataclass, field
43
+ from enum import StrEnum
44
+ from pathlib import Path
45
+ from typing import Callable
46
+
47
+ from syncmill.artifacts import printable_name as _p
48
+ from syncmill.platform_support import require_supported_platform
49
+ from syncmill.config import BoardConfig
50
+ from syncmill.models import TaskResult, TaskStatus
51
+ from syncmill.review_candidates import ReviewItemSpec
52
+
53
+ logger = logging.getLogger(__name__)
54
+ _SQLITE_SIDECARS = ("-wal", "-shm", "-journal")
55
+
56
+
57
+ def _prepare_private_board(path: Path) -> None:
58
+ """Create/tighten POSIX board storage without a process-global umask.
59
+
60
+ The `os.name` gate below is what keeps the Unix-only `os.fchmod` here safe —
61
+ unlike the `getattr` guards elsewhere, this whole hardening block is a POSIX
62
+ permission-model decision, so the platform check has to happen anyway and a
63
+ second per-call guard under it would be unreachable. An `OSError` from the
64
+ tightening is deliberately not swallowed: it becomes a `BoardError`.
65
+ """
66
+ parent_missing = not path.parent.exists()
67
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
68
+ if os.name != "posix":
69
+ return
70
+ if parent_missing:
71
+ os.chmod(path.parent, 0o700)
72
+ fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
73
+ try:
74
+ os.fchmod(fd, 0o600)
75
+ finally:
76
+ os.close(fd)
77
+ _tighten_board_files(path)
78
+
79
+
80
+ def _tighten_board_files(path: Path) -> None:
81
+ if os.name != "posix":
82
+ return
83
+ os.chmod(path, 0o600)
84
+ for suffix in _SQLITE_SIDECARS:
85
+ try:
86
+ os.chmod(Path(f"{path}{suffix}"), 0o600)
87
+ except FileNotFoundError:
88
+ pass
89
+
90
+
91
+ #: Columns added to an existing table after its CREATE shipped. `CREATE TABLE IF
92
+ #: NOT EXISTS` is a no-op on a board that already exists, so every column added
93
+ #: later needs an explicit ALTER — keep new nullable columns here, in order.
94
+ _ADDED_COLUMNS: tuple[tuple[str, str, str], ...] = (
95
+ ("board_run_namespaces", "reclaimed_at", "REAL"),
96
+ # Purge-failure backoff (P0-6): a namespace whose purge keeps raising (a
97
+ # symlinked source, an unmounted index root, a legacy shared day file) must
98
+ # not stay the OLDEST collectible row forever — retried first every sweep,
99
+ # it consumes the whole GC budget and starves every healthy namespace
100
+ # behind it while the store grows monotonically.
101
+ ("board_run_namespaces", "failed_attempts", "INTEGER"),
102
+ ("board_run_namespaces", "next_attempt_at", "REAL"),
103
+ )
104
+
105
+
106
+ def _add_missing_columns(conn: sqlite3.Connection) -> None:
107
+ for table, column, decl in _ADDED_COLUMNS:
108
+ existing = {
109
+ row["name"] for row in conn.execute(f"PRAGMA table_info({table})")
110
+ }
111
+ if column in existing:
112
+ continue
113
+ try:
114
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
115
+ except sqlite3.OperationalError as exc:
116
+ # The check and the ALTER are two statements, so two processes
117
+ # opening an old board at once both see the column missing and both
118
+ # try to add it. The loser's failure means the column now EXISTS,
119
+ # which is all this function was asked to guarantee — anything else
120
+ # is a real error and still raises.
121
+ if "duplicate column" not in str(exc).lower():
122
+ raise
123
+
124
+
125
+ class BoardError(RuntimeError):
126
+ """A board operation failed in a way that must NOT be silently degraded.
127
+
128
+ The board is the source of work and the system of record, so — unlike memory
129
+ or the circuit breaker, which are advisory — a board failure (corrupt db, a
130
+ rejected ``done`` transition, an unknown state) surfaces. The CLI converts it
131
+ to a clean ``click.ClickException`` (the ``session.SessionLaunchError``
132
+ "no fallback — it IS the feature" contract)."""
133
+
134
+
135
+ # ── State machine + value types ──────────────────────────────────────────────
136
+
137
+
138
+ class WorkState(StrEnum):
139
+ """The work-item lifecycle: ``planned → running → (blocked | review) → done``.
140
+
141
+ ``review`` vs ``done`` is the load-bearing distinction: ``review`` means the
142
+ agent finished but the work is **not yet verified**; ``done`` means
143
+ **done AND verified** — a transition to ``done`` requires structured passing
144
+ :class:`Evidence` (or a human sign-off), enforced in :meth:`Board.transition`.
145
+ """
146
+
147
+ PLANNED = "planned"
148
+ RUNNING = "running"
149
+ BLOCKED = "blocked"
150
+ REVIEW = "review"
151
+ DONE = "done"
152
+
153
+
154
+ #: Terminal-ish states an item rests in once it leaves ``running``. Used to clear
155
+ #: the lease (a claim is consumed the moment the item stops running).
156
+ _STATES = frozenset(s.value for s in WorkState)
157
+
158
+
159
+ @dataclass
160
+ class Evidence:
161
+ """One structured proof that an item's work was verified — the thing the
162
+ ``done`` gate counts. ``kind`` is ``"gate"`` (a passing ``GateReport`` maps to
163
+ one), ``"test"``, ``"manual"`` (a human-attached ref), ``"human"`` (a
164
+ sign-off), or ``"tracegraph"`` (non-passing failure evidence imported for
165
+ review). ``passed`` is the verdict; ``ref`` names the evidence (a gate summary,
166
+ a CI url, …); ``by`` is who/what produced it; ``at`` is wall-clock."""
167
+
168
+ kind: str
169
+ ref: str
170
+ passed: bool
171
+ at: float
172
+ by: str = ""
173
+
174
+
175
+ @dataclass
176
+ class WorkItem:
177
+ """One tracked unit of team work. Lives here (not ``models.py``) because — like
178
+ ``breaker._AgentState`` — it never flows through a strategy; only the board and
179
+ the Supervisor's ``run_item`` touch it.
180
+
181
+ ``owner_agent`` is RECORD-ONLY in v1 (stamped from ``result.agent_id`` after a
182
+ run), not an assignment constraint — so ``board add`` has no ``--owner``.
183
+ """
184
+
185
+ id: str
186
+ goal: str
187
+ task: str
188
+ state: WorkState = WorkState.PLANNED
189
+ owner_agent: str | None = None
190
+ inputs: list[str] = field(default_factory=list)
191
+ outputs: list[str] = field(default_factory=list)
192
+ evidence: list[Evidence] = field(default_factory=list)
193
+ handoff: str = ""
194
+ human_required: bool = False
195
+ blocked_reason: str = ""
196
+ depends_on: list[str] = field(default_factory=list)
197
+ lease_run_id: str | None = None
198
+ claimed_at: float | None = None
199
+ created_at: float = 0.0
200
+ updated_at: float = 0.0
201
+
202
+
203
+ @dataclass
204
+ class Decision:
205
+ """A team decision that must NOT be re-litigated (Phase 2).
206
+
207
+ Decisions are orthogonal to work items: they carry no state machine, no lease —
208
+ they are durable, deterministically-injected context. ``scope`` is ``""`` for a
209
+ GLOBAL decision (applies to every item) or a goal string for a goal-scoped one;
210
+ :func:`render_item_prompt` injects the active (non-superseded) decisions that
211
+ apply to an item's goal IN FULL, never via top-k recall. A decision is retired by
212
+ being ``superseded`` (a new decision replaces it), not deleted — the record is
213
+ append-only so the history of what was decided, and when, survives."""
214
+
215
+ id: str
216
+ text: str
217
+ scope: str = ""
218
+ created_at: float = 0.0
219
+ superseded_by: str | None = None
220
+ superseded_at: float | None = None
221
+
222
+
223
+ @dataclass
224
+ class BoardStats:
225
+ """A snapshot of team-health signals (Phase 2) — see :meth:`Board.stats`. The
226
+ list-valued fields carry the offending item ids (so a caller can drill in), and
227
+ the renderer reports their counts; ``stall_seconds`` echoes the aging window the
228
+ snapshot used so the report is self-describing."""
229
+
230
+ total: int
231
+ by_state: dict[str, int]
232
+ false_done: list[str]
233
+ redispatched: list[str]
234
+ reclaimed_stale: int
235
+ blocked_aged: list[str]
236
+ human_required_aged: list[str]
237
+ dropped_handoffs: list[str]
238
+ stall_seconds: int
239
+
240
+
241
+ @dataclass(frozen=True)
242
+ class ReviewImportResult:
243
+ """Outcome of one atomic Tracegraph review-candidate import."""
244
+
245
+ imported: int
246
+ skipped: int
247
+ item_ids: tuple[str, ...]
248
+ #: Of the skipped items, how many were requested under a DIFFERENT goal
249
+ #: than the one they already carry. Goal is assigned only on first import
250
+ #: (by design, test-locked) — this count lets the CLI say so instead of a
251
+ #: bare "skipped N existing" that reads as if the new goal took effect.
252
+ goal_kept: int = 0
253
+
254
+
255
+ # ── SQLite schema ────────────────────────────────────────────────────────────
256
+
257
+ _SCHEMA = """
258
+ CREATE TABLE IF NOT EXISTS work_items (
259
+ id TEXT PRIMARY KEY,
260
+ goal TEXT NOT NULL DEFAULT '',
261
+ task TEXT NOT NULL,
262
+ state TEXT NOT NULL DEFAULT 'planned'
263
+ CHECK (state IN ('planned','running','blocked','review','done')),
264
+ owner_agent TEXT,
265
+ inputs TEXT NOT NULL DEFAULT '[]',
266
+ outputs TEXT NOT NULL DEFAULT '[]',
267
+ evidence TEXT NOT NULL DEFAULT '[]',
268
+ handoff TEXT NOT NULL DEFAULT '',
269
+ human_required INTEGER NOT NULL DEFAULT 0,
270
+ blocked_reason TEXT NOT NULL DEFAULT '',
271
+ depends_on TEXT NOT NULL DEFAULT '[]',
272
+ lease_run_id TEXT,
273
+ claimed_at REAL,
274
+ created_at REAL NOT NULL,
275
+ updated_at REAL NOT NULL
276
+ );
277
+ CREATE INDEX IF NOT EXISTS idx_work_items_state ON work_items(state);
278
+ CREATE INDEX IF NOT EXISTS idx_work_items_goal ON work_items(goal);
279
+ CREATE INDEX IF NOT EXISTS idx_work_items_owner ON work_items(owner_agent);
280
+ CREATE INDEX IF NOT EXISTS idx_work_items_lease ON work_items(lease_run_id);
281
+ -- list_recent walks this index backwards and stops at its LIMIT (#167);
282
+ -- without it a newest-first read is a full scan plus a temp B-tree sort.
283
+ CREATE INDEX IF NOT EXISTS idx_work_items_created ON work_items(created_at);
284
+
285
+ CREATE TABLE IF NOT EXISTS transitions (
286
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
287
+ item_id TEXT NOT NULL,
288
+ from_state TEXT,
289
+ to_state TEXT NOT NULL,
290
+ actor TEXT NOT NULL DEFAULT '',
291
+ note TEXT NOT NULL DEFAULT '',
292
+ at REAL NOT NULL
293
+ );
294
+ CREATE INDEX IF NOT EXISTS idx_transitions_item ON transitions(item_id);
295
+ -- stats() runs on every TUI poll tick (2s) over the append-only, never-pruned
296
+ -- transitions table; without these its two audit queries are full-table scans
297
+ -- that grow with total board history.
298
+ CREATE INDEX IF NOT EXISTS idx_transitions_redispatch
299
+ ON transitions(from_state, to_state, item_id);
300
+ CREATE INDEX IF NOT EXISTS idx_transitions_reclaim
301
+ ON transitions(to_state, note);
302
+
303
+ CREATE TABLE IF NOT EXISTS decisions (
304
+ id TEXT PRIMARY KEY,
305
+ text TEXT NOT NULL,
306
+ scope TEXT NOT NULL DEFAULT '',
307
+ created_at REAL NOT NULL,
308
+ superseded_by TEXT,
309
+ superseded_at REAL
310
+ );
311
+ CREATE INDEX IF NOT EXISTS idx_decisions_scope ON decisions(scope);
312
+ CREATE INDEX IF NOT EXISTS idx_decisions_active ON decisions(superseded_by);
313
+
314
+ -- reclaimed_at records when stale recovery took this run's lease. A reclaim
315
+ -- means "presumed dead", not "known dead", so the scratch namespace gets a grace
316
+ -- period before collection (see collectible_board_run_ids). Keep comments OUT of
317
+ -- the CREATE body: sqlite stores the statement text verbatim and re-parses it on
318
+ -- ALTER TABLE ... DROP COLUMN, where an inline comment truncates the input.
319
+ CREATE TABLE IF NOT EXISTS board_run_namespaces (
320
+ run_id TEXT PRIMARY KEY CHECK (run_id <> ''),
321
+ item_id TEXT NOT NULL,
322
+ claimed_at REAL NOT NULL,
323
+ purged_at REAL,
324
+ reclaimed_at REAL
325
+ );
326
+ CREATE INDEX IF NOT EXISTS idx_board_run_namespaces_purged
327
+ ON board_run_namespaces(purged_at, claimed_at);
328
+
329
+ -- Aggregate-then-trim rollups for `board compact` (#118): the transitions
330
+ -- audit is append-only, so trimming old rows must first fold what stats()
331
+ -- derives from them — per-item claim counts (redispatched) and the global
332
+ -- stale-reclaim count — into these tables, which stats() reads additively.
333
+ CREATE TABLE IF NOT EXISTS transition_rollups (
334
+ item_id TEXT PRIMARY KEY,
335
+ claim_count INTEGER NOT NULL DEFAULT 0
336
+ );
337
+ CREATE TABLE IF NOT EXISTS transition_counters (
338
+ name TEXT PRIMARY KEY,
339
+ value INTEGER NOT NULL DEFAULT 0
340
+ );
341
+
342
+ """
343
+
344
+ # ── scratch-ledger backfills ────────────────────────────────────────────────
345
+ #
346
+ # Past WAL/schema initialization these are the only conditional data writes in the
347
+ # open path (a DELETE-mode db still writes converting to WAL, and a pre-`reclaimed_at`
348
+ # db writes once through `_add_missing_columns` — both one-time migrations). They are
349
+ # deliberately NOT part of `_SCHEMA`: an INSERT takes the write lock at statement start whether
350
+ # or not its SELECT matches a row, so running them unconditionally made EVERY open
351
+ # a writer. That includes the per-run lease heartbeat's own connection, which then
352
+ # waits out `busy_timeout` behind a concurrent `BEGIN IMMEDIATE` and can raise —
353
+ # killing the heartbeat and letting the lease expire under a live run, whose item
354
+ # is then reclaimed and dispatched a second time. Each backfill is now preceded by
355
+ # a cheap read that decides whether the write is needed at all, so a steady-state
356
+ # open is read-only.
357
+
358
+ #: Every historical board claim recorded run_id as the transition actor. Only scan
359
+ #: that history when the ledger is empty; retained purged rows make later opens skip it.
360
+ #: The emptiness test is kept INSIDE the statement as well as in the read guard: the
361
+ #: guard makes the steady-state open read-only, but only the in-statement `NOT EXISTS`
362
+ #: is atomic — between a bare guard read and the INSERT another process can commit a
363
+ #: claim, and this migration would then resurrect historical rows beside it (which the
364
+ #: scratch GC later treats as collectible).
365
+ _BACKFILL_LEDGER_FROM_AUDIT = """
366
+ INSERT OR IGNORE INTO board_run_namespaces (run_id, item_id, claimed_at, purged_at)
367
+ SELECT actor, MIN(item_id), MIN(at), NULL
368
+ FROM transitions
369
+ WHERE from_state='planned' AND to_state='running'
370
+ AND note='claimed' AND actor <> ''
371
+ AND NOT EXISTS (SELECT 1 FROM board_run_namespaces)
372
+ GROUP BY actor
373
+ """
374
+
375
+ #: Defensive legacy/corruption backfill for a live lease whose audit row is absent.
376
+ _BACKFILL_LEDGER_FROM_LEASES = """
377
+ INSERT OR IGNORE INTO board_run_namespaces (run_id, item_id, claimed_at, purged_at)
378
+ SELECT lease_run_id, id, COALESCE(claimed_at, updated_at), NULL
379
+ FROM work_items w
380
+ WHERE lease_run_id IS NOT NULL AND lease_run_id <> ''
381
+ AND NOT EXISTS (
382
+ SELECT 1 FROM board_run_namespaces n WHERE n.run_id = w.lease_run_id
383
+ )
384
+ """
385
+
386
+
387
+ def _backfill_run_ledger(conn: sqlite3.Connection) -> None:
388
+ """Populate the scratch ledger, writing only when a read says it is needed."""
389
+ # An EMPTY ledger is not enough to justify the write: a board that has never
390
+ # been claimed has an empty ledger forever, so guarding on emptiness alone left
391
+ # every open of such a board taking the write lock on a zero-row INSERT — the
392
+ # exact starvation this function exists to remove. Require an eligible historical
393
+ # claim as well; the statement keeps its own `NOT EXISTS` for atomicity.
394
+ needs_audit_backfill = conn.execute(
395
+ "SELECT EXISTS("
396
+ " SELECT 1 FROM transitions"
397
+ " WHERE from_state='planned' AND to_state='running'"
398
+ " AND note='claimed' AND actor <> ''"
399
+ ") AND NOT EXISTS(SELECT 1 FROM board_run_namespaces)"
400
+ ).fetchone()[0]
401
+ if needs_audit_backfill:
402
+ conn.execute(_BACKFILL_LEDGER_FROM_AUDIT)
403
+ unledgered_lease = conn.execute(
404
+ "SELECT EXISTS("
405
+ " SELECT 1 FROM work_items w"
406
+ " WHERE w.lease_run_id IS NOT NULL AND w.lease_run_id <> ''"
407
+ " AND NOT EXISTS ("
408
+ " SELECT 1 FROM board_run_namespaces n WHERE n.run_id = w.lease_run_id"
409
+ " )"
410
+ ")"
411
+ ).fetchone()[0]
412
+ if unledgered_lease:
413
+ conn.execute(_BACKFILL_LEDGER_FROM_LEASES)
414
+
415
+ #: The audit note ``recover_stale_running`` stamps; ``board stats`` counts these to
416
+ #: surface duplicate-work risk (items that had to be reclaimed from a dead run).
417
+ _STALE_RECLAIM_NOTE = "stale-claim recovery"
418
+
419
+ # Column order for INSERT/row-mapping — kept in one place so the two stay in sync.
420
+ _COLUMNS = (
421
+ "id", "goal", "task", "state", "owner_agent", "inputs", "outputs", "evidence",
422
+ "handoff", "human_required", "blocked_reason", "depends_on", "lease_run_id",
423
+ "claimed_at", "created_at", "updated_at",
424
+ )
425
+
426
+
427
+ # ── Board store ──────────────────────────────────────────────────────────────
428
+
429
+
430
+ class Board:
431
+ """The SQLite-backed work-board. The dispatch + system-of-record core.
432
+
433
+ Concurrency model: opened in **autocommit** mode (``isolation_level=None``) so
434
+ writers issue an explicit ``BEGIN IMMEDIATE`` (a RESERVED lock taken up front),
435
+ making :meth:`claim` / :meth:`next_runnable` an atomic compare-and-swap — two
436
+ ``syncmill board run`` processes against one db can never both claim the same
437
+ ``planned`` item. ``busy_timeout`` lets the loser wait for the winner's commit
438
+ rather than erroring. WAL keeps readers (``board list``/``show``, the future
439
+ dashboard) non-blocking against a writer.
440
+
441
+ ``now`` is injectable (like the circuit breaker) so tests can drive the
442
+ stale-claim clock deterministically.
443
+ """
444
+
445
+ def __init__(
446
+ self,
447
+ db_path: str,
448
+ *,
449
+ now: Callable[[], float] = time.time,
450
+ lease_timeout_seconds: int = 1800,
451
+ stall_seconds: int = 3600,
452
+ busy_timeout_ms: int = 5000,
453
+ ):
454
+ require_supported_platform()
455
+ self._now = now
456
+ self._lease_timeout = max(1, lease_timeout_seconds)
457
+ self._stall_seconds = max(1, stall_seconds)
458
+ self._busy_timeout_ms = int(busy_timeout_ms)
459
+ try:
460
+ # expanduser() is INSIDE the try: it raises RuntimeError (NOT an OSError)
461
+ # when ~ can't resolve because no home dir is set (cron/systemd), so a
462
+ # narrow catch would let that escape unwrapped — and `Board.__init__`'s
463
+ # contract is that it raises ONLY BoardError (build_board's sole caller,
464
+ # `serve`, catches just that; a stray RuntimeError would crash the gateway
465
+ # and skip its memory-close finally).
466
+ path = Path(db_path).expanduser()
467
+ _prepare_private_board(path)
468
+ # isolation_level=None → autocommit; we drive BEGIN IMMEDIATE/COMMIT by
469
+ # hand for the atomic claims. row_factory=Row for name-based access.
470
+ self._conn = sqlite3.connect(str(path), isolation_level=None)
471
+ self._db_path = path.resolve()
472
+ self._conn.row_factory = sqlite3.Row
473
+ self._conn.execute(f"PRAGMA busy_timeout={int(busy_timeout_ms)}")
474
+ self._conn.execute("PRAGMA journal_mode=WAL")
475
+ self._conn.executescript(_SCHEMA)
476
+ _add_missing_columns(self._conn)
477
+ _backfill_run_ledger(self._conn)
478
+ _tighten_board_files(path)
479
+ except (sqlite3.Error, OSError, RuntimeError, ValueError) as exc:
480
+ # A corrupt / non-sqlite / unwritable / unresolvable db is fatal for a
481
+ # load-bearing store — raise rather than degrade (the whole point of the
482
+ # board). OSError covers an unwritable/missing parent dir (mkdir / connect);
483
+ # sqlite3.Error covers a corrupt or non-sqlite file; RuntimeError covers an
484
+ # unresolvable `~` (no home dir); ValueError covers a db path carrying a
485
+ # null byte, which sqlite3.connect rejects before it is ever a db error —
486
+ # `Board.__init__`'s documented contract is that it raises ONLY BoardError,
487
+ # so every way a path can be rejected has to land here. `connect()` is lazy
488
+ # (it succeeds before the first PRAGMA/schema access surfaces a corrupt
489
+ # file), so close the partial connection here rather than leaking its fd.
490
+ conn = getattr(self, "_conn", None)
491
+ if conn is not None:
492
+ try:
493
+ conn.close()
494
+ except sqlite3.Error:
495
+ pass
496
+ raise BoardError(f"failed to open board at {db_path}: {exc}") from exc
497
+
498
+ # ── writes (atomic) ──────────────────────────────────────────────────────
499
+
500
+ @property
501
+ def db_path(self) -> Path:
502
+ """Canonical path, used to open thread-local lease-renewal connections."""
503
+ return self._db_path
504
+
505
+ @property
506
+ def lease_timeout_seconds(self) -> int:
507
+ return self._lease_timeout
508
+
509
+ @property
510
+ def stall_seconds(self) -> int:
511
+ return self._stall_seconds
512
+
513
+ def new_connection(self) -> "Board":
514
+ """Open an equivalent connection in the calling thread."""
515
+ return Board(
516
+ str(self._db_path),
517
+ now=self._now,
518
+ lease_timeout_seconds=self._lease_timeout,
519
+ stall_seconds=self._stall_seconds,
520
+ busy_timeout_ms=self._busy_timeout_ms,
521
+ )
522
+
523
+ def renew_claim(self, item_id: str, run_id: str) -> bool:
524
+ """Lease-fenced heartbeat for a currently running item."""
525
+ try:
526
+ cur = self._conn.execute(
527
+ "UPDATE work_items SET claimed_at=? "
528
+ "WHERE id=? AND state=? AND lease_run_id=?",
529
+ (self._now(), item_id, WorkState.RUNNING.value, run_id),
530
+ )
531
+ return cur.rowcount == 1
532
+ except Exception as exc:
533
+ raise BoardError(f"renew claim failed: {exc}") from exc
534
+
535
+ def add(
536
+ self,
537
+ task: str,
538
+ *,
539
+ goal: str = "",
540
+ depends_on: list[str] | None = None,
541
+ human_required: bool = False,
542
+ inputs: list[str] | None = None,
543
+ item_id: str | None = None,
544
+ ) -> WorkItem:
545
+ """Insert a new ``planned`` item and return it (with an audit row)."""
546
+ now = self._now()
547
+ item = WorkItem(
548
+ id=item_id or str(uuid.uuid4()),
549
+ goal=goal,
550
+ task=task,
551
+ state=WorkState.PLANNED,
552
+ inputs=list(inputs or []),
553
+ human_required=human_required,
554
+ depends_on=list(depends_on or []),
555
+ created_at=now,
556
+ updated_at=now,
557
+ )
558
+ try:
559
+ self._conn.execute("BEGIN IMMEDIATE")
560
+ self._conn.execute(
561
+ f"INSERT INTO work_items ({','.join(_COLUMNS)}) "
562
+ f"VALUES ({','.join('?' for _ in _COLUMNS)})",
563
+ _item_to_row(item),
564
+ )
565
+ self._insert_transition(item.id, None, item.state.value, "human", "created", now)
566
+ self._conn.execute("COMMIT")
567
+ except Exception as exc:
568
+ # Broad (matching next_runnable/apply_update/transition/recover/add_decision):
569
+ # roll back the open BEGIN IMMEDIATE on ANY error so the RESERVED write lock
570
+ # is never leaked. _item_to_row's json.dumps can raise a NON-sqlite TypeError
571
+ # on an unserializable input AFTER BEGIN IMMEDIATE — a narrow `sqlite3.Error`
572
+ # catch would leak the lock and the next write fails with "cannot start a
573
+ # transaction within a transaction" (codex-found blocker).
574
+ self._safe_rollback()
575
+ raise BoardError(f"failed to add item: {exc}") from exc
576
+ return item
577
+
578
+ def import_review_candidates(
579
+ self,
580
+ specs: list[ReviewItemSpec],
581
+ *,
582
+ goal: str,
583
+ ) -> ReviewImportResult:
584
+ """Atomically insert Tracegraph findings as human-only ``review`` items.
585
+
586
+ Identity is the deterministic UUID carried by each spec. Re-importing an
587
+ existing finding never reopens or rewrites it, even after human resolution.
588
+ A UUID collision with different immutable provenance aborts the whole batch.
589
+ """
590
+ now = self._now()
591
+ imported = 0
592
+ skipped = 0
593
+ goal_kept = 0
594
+ item_ids: list[str] = []
595
+ try:
596
+ self._conn.execute("BEGIN IMMEDIATE")
597
+ for spec in specs:
598
+ item = WorkItem(
599
+ id=spec.item_id,
600
+ goal=goal,
601
+ task=spec.task,
602
+ state=WorkState.REVIEW,
603
+ inputs=list(spec.inputs),
604
+ evidence=[
605
+ Evidence(
606
+ kind="tracegraph",
607
+ ref=spec.artifact_digest,
608
+ passed=False,
609
+ at=now,
610
+ by="tracegraph",
611
+ )
612
+ ],
613
+ human_required=True,
614
+ created_at=now,
615
+ updated_at=now,
616
+ )
617
+ cur = self._conn.execute(
618
+ f"INSERT INTO work_items ({','.join(_COLUMNS)}) "
619
+ f"VALUES ({','.join('?' for _ in _COLUMNS)}) "
620
+ "ON CONFLICT(id) DO NOTHING",
621
+ _item_to_row(item),
622
+ )
623
+ if cur.rowcount == 1:
624
+ imported += 1
625
+ self._insert_transition(
626
+ item.id,
627
+ None,
628
+ WorkState.REVIEW.value,
629
+ "tracegraph-import",
630
+ "imported review candidate",
631
+ now,
632
+ )
633
+ else:
634
+ row = self._conn.execute(
635
+ "SELECT * FROM work_items WHERE id=?", (item.id,)
636
+ ).fetchone()
637
+ existing = _row_to_item(row)
638
+ if not _same_review_provenance(existing, item):
639
+ raise BoardError(
640
+ f"review-candidate provenance collision for item {item.id}"
641
+ )
642
+ skipped += 1
643
+ if existing.goal != goal:
644
+ goal_kept += 1
645
+ item_ids.append(item.id)
646
+ self._conn.execute("COMMIT")
647
+ return ReviewImportResult(imported, skipped, tuple(item_ids), goal_kept)
648
+ except BoardError:
649
+ self._safe_rollback()
650
+ raise
651
+ except Exception as exc:
652
+ self._safe_rollback()
653
+ raise BoardError(f"failed to import review candidates: {exc}") from exc
654
+
655
+ def claim(self, item_id: str, run_id: str) -> WorkItem | None:
656
+ """Atomically claim a specific ``planned`` item for ``run_id`` (CAS
657
+ ``planned → running``). Returns the claimed item, or ``None`` if it doesn't
658
+ exist or isn't ``planned`` (already claimed/done/blocked/etc.).
659
+
660
+ The atomic, every-path no-duplicate-work guard: under ``BEGIN IMMEDIATE``
661
+ the second concurrent claimer blocks until the first commits, then reads
662
+ ``running`` and returns ``None``."""
663
+ return self._claim_where("id=? AND state=?",
664
+ (item_id, WorkState.PLANNED.value), run_id)
665
+
666
+ def next_runnable(self, run_id: str, goal: str | None = None) -> WorkItem | None:
667
+ """Atomically claim the next dispatchable item for ``run_id``, or ``None``.
668
+
669
+ Dispatchable = ``planned``, not ``human_required``, and every ``depends_on``
670
+ item is ``done``. Items are considered in FIFO order (``created_at``). The
671
+ whole select-then-claim runs in one ``BEGIN IMMEDIATE`` transaction, so it
672
+ is as atomic as :meth:`claim`.
673
+
674
+ A ``goal`` scopes the claim to items with exactly that goal (the
675
+ ``board run --goal`` filter, Phase 4) — just an extra WHERE clause inside
676
+ the same transaction, so atomicity is unchanged. ``None`` (the default)
677
+ considers every item, exactly as before."""
678
+ where = "state=? AND human_required=0"
679
+ params: list = [WorkState.PLANNED.value]
680
+ if goal is not None:
681
+ where += " AND goal=?"
682
+ params.append(goal)
683
+ try:
684
+ self._conn.execute("BEGIN IMMEDIATE")
685
+ rows = self._conn.execute(
686
+ f"SELECT * FROM work_items WHERE {where} ORDER BY created_at, rowid",
687
+ params,
688
+ ).fetchall()
689
+ # LAZY, per-candidate done lookups — everything here runs under
690
+ # the BEGIN IMMEDIATE RESERVED lock, so the work must stay
691
+ # proportional to the candidates actually EXAMINED: the scan stops
692
+ # at the first satisfied item (a runnable FIFO head costs zero
693
+ # queries when it has no dependencies, one bounded query when it
694
+ # does), and each lookup asks only for that candidate's
695
+ # still-unknown dep ids — never a full done-history scan, never
696
+ # the tail's dependencies. Cached across candidates so a shared
697
+ # dep is fetched once; chunked IN lists because SQLite caps bound
698
+ # parameters (999 on older builds). Same transaction, so the
699
+ # snapshot matches what per-dep reads would see.
700
+ done_ids: set = set()
701
+ looked_up: set = set()
702
+
703
+ def _deps_done(deps: list) -> bool:
704
+ unknown = [d for d in deps if d not in looked_up]
705
+ for start in range(0, len(unknown), 500):
706
+ chunk = unknown[start : start + 500]
707
+ marks = ",".join("?" * len(chunk))
708
+ done_ids.update(
709
+ r["id"]
710
+ for r in self._conn.execute(
711
+ "SELECT id FROM work_items WHERE state=? "
712
+ f"AND id IN ({marks})",
713
+ (WorkState.DONE.value, *chunk),
714
+ )
715
+ )
716
+ looked_up.update(chunk)
717
+ return self._deps_satisfied(deps, done_ids)
718
+
719
+ chosen = next(
720
+ (r for r in rows if _deps_done(json.loads(r["depends_on"]))),
721
+ None,
722
+ )
723
+ if chosen is None:
724
+ self._conn.execute("COMMIT") # read-only; nothing to dispatch
725
+ return None
726
+ item = self._stamp_running(chosen, run_id)
727
+ self._conn.execute("COMMIT")
728
+ return item
729
+ except Exception as exc:
730
+ # Broad on purpose: a non-sqlite error mid-transaction (e.g. a corrupt
731
+ # `depends_on` JSON failing `json.loads` above → ValueError) must still
732
+ # roll back the open BEGIN IMMEDIATE — otherwise the RESERVED lock leaks
733
+ # and the next op fails with "cannot start a transaction within a
734
+ # transaction". Surfaces as BoardError, like get()/list() already do.
735
+ self._safe_rollback()
736
+ raise BoardError(f"next_runnable failed: {exc}") from exc
737
+
738
+ def apply_update(
739
+ self,
740
+ item_id: str,
741
+ parsed: dict,
742
+ result: TaskResult,
743
+ actor: str,
744
+ run_id: str | None = None,
745
+ ) -> tuple[WorkItem, bool]:
746
+ """Record a finished run back onto the item, in one transaction.
747
+
748
+ Returns ``(item, applied)``: ``applied`` is ``True`` when this run's write
749
+ took effect, ``False`` when the lease fence dropped it (below). The caller
750
+ (``run_item``) gates its follow-on global-``shared`` cross-link on
751
+ ``applied`` so a superseded run never publishes discarded output.
752
+
753
+ Merges the agent's optional parsed board block with inferred defaults —
754
+ ``result.files_changed`` ∪ ``parsed['outputs']`` → ``outputs``; a
755
+ ``GateReport`` (``result.gate``) → one :class:`Evidence` row; the run's
756
+ status + the agent's advisory ``parsed['state']`` → the next state — and
757
+ stamps ``owner_agent`` (record-only). ``blocked_reason`` is cleared on any
758
+ non-blocked resolution, so a re-run that succeeds doesn't carry a stale
759
+ reason from a prior block.
760
+
761
+ **Lease-fenced** when ``run_id`` is given (the ``run_item`` path): the
762
+ write is a compare-and-swap ``WHERE id=? AND state='running' AND
763
+ lease_run_id=?``. If a slow run's item was reclaimed by
764
+ ``recover_stale_running`` and re-dispatched to another runner while it ran,
765
+ the fence matches 0 rows → this stale write is DROPPED (it neither clobbers
766
+ the new owner's state nor reverts a verified ``done``), and the current item
767
+ is returned untouched WITH ``applied=False`` so the caller can suppress any
768
+ follow-on side effect (the global-``shared`` summary). Without ``run_id`` the
769
+ write is unfenced (for non-dispatch callers/tests) and ``applied`` is True.
770
+
771
+ **Advisory, never raises on a bad agent request** (decision #5): an agent
772
+ that requests ``state:done`` without passing evidence is CLAMPED to
773
+ ``review`` (+ an audit note), so a successful run is never turned into
774
+ ``blocked`` by an invalid advisory field. A FAILED run (agent failure or a
775
+ gate rejection) maps to ``blocked`` with the error as the reason. Only a
776
+ genuine sqlite error raises :class:`BoardError`."""
777
+ try:
778
+ self._conn.execute("BEGIN IMMEDIATE")
779
+ row = self._conn.execute(
780
+ "SELECT * FROM work_items WHERE id=?", (item_id,)
781
+ ).fetchone()
782
+ if row is None:
783
+ raise BoardError(f"no such item: {item_id}")
784
+ item = _row_to_item(row)
785
+ now = self._now()
786
+
787
+ outputs = _merge_unique(item.outputs, parsed.get("outputs", []))
788
+ outputs = _merge_unique(outputs, list(result.files_changed))
789
+
790
+ evidence = list(item.evidence)
791
+ run_evidence: list[Evidence] = [] # only THIS run's verification
792
+ if result.gate is not None:
793
+ # A passing gate is objective verification; a failing gate is the
794
+ # evidence the work did NOT verify. Agents can't self-certify done.
795
+ ev = Evidence(
796
+ kind="gate", ref=result.gate.summary(),
797
+ passed=result.gate.passed, at=now, by=result.agent_id,
798
+ )
799
+ evidence.append(ev)
800
+ run_evidence.append(ev)
801
+
802
+ handoff = parsed.get("handoff", item.handoff)
803
+ human_required = bool(parsed.get("human_required", item.human_required))
804
+ blocked_reason = "" # cleared unless we resolve to blocked (no stale reason)
805
+ requested = parsed.get("state")
806
+
807
+ if result.status == TaskStatus.COMPLETED:
808
+ # Gate the agent's advisory `done` on THIS run's verification only —
809
+ # stale passing evidence from an earlier (now-discarded) run must not
810
+ # certify the current work ("done AND verified" = the CURRENT work is
811
+ # verified). The full accumulated `evidence` is still persisted below
812
+ # and still backs the human review→done promotion via `transition`.
813
+ target, note = _resolve_completed_state(requested, run_evidence)
814
+ if target == WorkState.BLOCKED:
815
+ blocked_reason = (
816
+ str(parsed.get("blocked_reason", "")) or "agent reported blocked"
817
+ )
818
+ human_required = True # a blocked item needs a human to unblock
819
+ else:
820
+ target = WorkState.BLOCKED
821
+ blocked_reason = result.error or f"run failed (exit {result.exit_code})"
822
+ human_required = True
823
+ note = "run failed"
824
+
825
+ set_params = [
826
+ target.value, result.agent_id, json.dumps(outputs),
827
+ _dump_evidence(evidence), handoff, int(human_required),
828
+ blocked_reason, now,
829
+ ]
830
+ where, where_params = "id=?", [item_id]
831
+ if run_id is not None:
832
+ # Lease fence: only the run that still owns the running claim writes.
833
+ where += " AND state=? AND lease_run_id=?"
834
+ where_params += [WorkState.RUNNING.value, run_id]
835
+ cur = self._conn.execute(
836
+ "UPDATE work_items SET state=?, owner_agent=?, outputs=?, evidence=?, "
837
+ "handoff=?, human_required=?, blocked_reason=?, lease_run_id=NULL, "
838
+ f"claimed_at=NULL, updated_at=? WHERE {where}",
839
+ set_params + where_params,
840
+ )
841
+ if run_id is not None and cur.rowcount == 0:
842
+ # Lost lease: the item's state or lease owner changed while we ran
843
+ # (a stale reclaim + re-dispatch is the usual cause, a manual
844
+ # transition does it too).
845
+ # Drop this stale write entirely (record nothing) so we never clobber
846
+ # the new owner or revert a verified state.
847
+ self._conn.execute("COMMIT")
848
+ logger.warning(
849
+ "board: dropping stale apply_update for %s — run %s no longer "
850
+ "owns the lease (its state or lease owner changed)", item_id, run_id,
851
+ )
852
+ return self.get(item_id), False # type: ignore[return-value]
853
+ self._insert_transition(item_id, item.state.value, target.value, actor, note, now)
854
+ self._conn.execute("COMMIT")
855
+ return self.get(item_id), True # type: ignore[return-value]
856
+ except BoardError:
857
+ self._safe_rollback()
858
+ raise
859
+ except Exception as exc:
860
+ # Broad: roll back the open transaction on ANY error (incl. a ValueError
861
+ # from a corrupt row via _row_to_item), never leaking the write lock.
862
+ self._safe_rollback()
863
+ raise BoardError(f"apply_update failed: {exc}") from exc
864
+
865
+ def transition(
866
+ self,
867
+ item_id: str,
868
+ to_state: str | None,
869
+ *,
870
+ actor: str,
871
+ note: str = "",
872
+ evidence: list[Evidence] | Evidence | None = None,
873
+ human_required: bool | None = None,
874
+ blocked_reason: str | None = None,
875
+ run_id: str | None = None,
876
+ ) -> WorkItem:
877
+ """Change an item's state, with the ``done`` gate enforced.
878
+
879
+ A transition **to** ``done`` requires the item to already carry, or to be
880
+ given here, at least one passing :class:`Evidence` row OR a human sign-off
881
+ (``Evidence{kind:"human"}``); otherwise it raises :class:`BoardError` — a
882
+ bare ``board update ID --state done`` with nothing to back it is rejected.
883
+ ``evidence`` rows are appended (counting toward the gate); ``human_required``
884
+ is set when not ``None``.
885
+
886
+ ``to_state=None`` is an annotate-only update: the target is the item's CURRENT
887
+ state, resolved from THIS in-transaction read (never a stale pre-transaction
888
+ read), so a concurrent claim can't be clobbered by a stale annotate target.
889
+ The done gate only fires for an actual move to ``done``.
890
+
891
+ ``blocked_reason`` is recorded on the row only for a move TO ``blocked``
892
+ (any other target clears it, mirroring ``apply_update``); omitted, a move to
893
+ ``blocked`` keeps the reason the row already carried.
894
+
895
+ ``run_id`` lease-fences the write (the crash-block path): when given, the
896
+ UPDATE only matches while the item is still ``running`` under that lease, so a
897
+ stale crashed run can't clobber a re-dispatched owner or revert a verified
898
+ state — a 0-row match drops the write, exactly like ``apply_update``."""
899
+ if to_state is not None and to_state not in _STATES:
900
+ raise BoardError(f"unknown state: {to_state!r}")
901
+ new_rows = (
902
+ [] if evidence is None
903
+ else [evidence] if isinstance(evidence, Evidence)
904
+ else list(evidence)
905
+ )
906
+ try:
907
+ self._conn.execute("BEGIN IMMEDIATE")
908
+ row = self._conn.execute(
909
+ "SELECT * FROM work_items WHERE id=?", (item_id,)
910
+ ).fetchone()
911
+ if row is None:
912
+ raise BoardError(f"no such item: {item_id}")
913
+ item = _row_to_item(row)
914
+ target = item.state if to_state is None else WorkState(to_state)
915
+ # `running` is owned by the claim lease, never set by hand: a manual move
916
+ # INTO running from another state would mint a lease-less, undispatchable
917
+ # item (next_runnable/claim both gate on `planned`). Only a same-state
918
+ # annotate of an ALREADY-running item reaches the lease-preserving branch.
919
+ if target == WorkState.RUNNING and item.state != WorkState.RUNNING:
920
+ raise BoardError(
921
+ f"cannot move item {item_id} to 'running' by hand — it is claimed "
922
+ f"by `board run`"
923
+ )
924
+ merged_evidence = item.evidence + new_rows
925
+ if target == WorkState.DONE and not _has_verification(merged_evidence):
926
+ raise BoardError(
927
+ f"cannot mark item {item_id} done without verification — attach "
928
+ f"passing evidence (--evidence) or a human sign-off (--sign-off)"
929
+ )
930
+ now = self._now()
931
+ hr = item.human_required if human_required is None else human_required
932
+ # Leaving blocked drops the stale reason (mirrors apply_update); a move TO
933
+ # blocked takes the caller's reason when given, else keeps whatever reason
934
+ # the row already carried. Without the parameter a crash-block's cause was
935
+ # visible only in the audit note, so `board show` printed an empty
936
+ # `blocked:` line for exactly the items an operator has to triage.
937
+ if target != WorkState.BLOCKED:
938
+ reason = ""
939
+ elif blocked_reason is not None:
940
+ reason = blocked_reason
941
+ else:
942
+ reason = item.blocked_reason
943
+ where, where_params = "id=?", [item_id]
944
+ if run_id is not None:
945
+ # Lease fence: only the run that still owns the running claim writes.
946
+ where += " AND state=? AND lease_run_id=?"
947
+ where_params += [WorkState.RUNNING.value, run_id]
948
+ if target == WorkState.RUNNING:
949
+ # Same-state annotate of a RUNNING item must PRESERVE the lease —
950
+ # nulling it would let recover_stale_running treat the live item as
951
+ # abandoned and re-dispatch it (duplicate work).
952
+ cur = self._conn.execute(
953
+ "UPDATE work_items SET state=?, evidence=?, human_required=?, "
954
+ f"blocked_reason=?, updated_at=? WHERE {where}",
955
+ [target.value, _dump_evidence(merged_evidence), int(hr),
956
+ reason, now, *where_params],
957
+ )
958
+ else:
959
+ # Leaving running → the claim is consumed; clear the lease.
960
+ cur = self._conn.execute(
961
+ "UPDATE work_items SET state=?, evidence=?, human_required=?, "
962
+ "blocked_reason=?, lease_run_id=NULL, claimed_at=NULL, "
963
+ f"updated_at=? WHERE {where}",
964
+ [target.value, _dump_evidence(merged_evidence), int(hr),
965
+ reason, now, *where_params],
966
+ )
967
+ if run_id is not None and cur.rowcount == 0:
968
+ # Lost lease: the item's state or lease owner changed while we ran
969
+ # (a reclaim + re-dispatch, or simply having already left running) —
970
+ # drop this stale write, recording nothing.
971
+ self._conn.execute("COMMIT")
972
+ logger.warning(
973
+ "board: dropping stale transition for %s — run %s no longer owns "
974
+ "the lease (its state or lease owner changed)", item_id, run_id,
975
+ )
976
+ return self.get(item_id) # type: ignore[return-value]
977
+ self._insert_transition(
978
+ item_id, item.state.value, target.value, actor,
979
+ note or ("annotated" if target == item.state else ""), now,
980
+ )
981
+ self._conn.execute("COMMIT")
982
+ return self.get(item_id) # type: ignore[return-value]
983
+ except BoardError:
984
+ self._safe_rollback()
985
+ raise
986
+ except Exception as exc:
987
+ # Broad: roll back on ANY error (incl. a ValueError from a corrupt row)
988
+ # so the open transaction / write lock is never leaked.
989
+ self._safe_rollback()
990
+ raise BoardError(f"transition failed: {exc}") from exc
991
+
992
+ def recover_stale_running(self, lease_timeout: int | None = None) -> int:
993
+ """Reclaim items stuck in ``running`` past the lease back to ``planned``.
994
+
995
+ A ``kill -9`` between :meth:`claim` and :meth:`apply_update` would strand an
996
+ item in ``running`` forever (``next_runnable`` skips ``running`` items). This
997
+ is the analogue of ``cleanup_orphaned_worktrees`` /
998
+ ``session._sweep_stale_session_dirs``: an item whose ``claimed_at`` is older
999
+ than the lease (or missing) is moved back to ``planned`` with an audit row,
1000
+ so it can be redispatched. Returns the count reclaimed."""
1001
+ timeout = self._lease_timeout if lease_timeout is None else max(1, lease_timeout)
1002
+ try:
1003
+ self._conn.execute("BEGIN IMMEDIATE")
1004
+ now = self._now()
1005
+ cutoff = now - timeout
1006
+ rows = self._conn.execute(
1007
+ "SELECT id, claimed_at, lease_run_id FROM work_items WHERE state=?",
1008
+ (WorkState.RUNNING.value,),
1009
+ ).fetchall()
1010
+ reclaimed = 0
1011
+ for r in rows:
1012
+ ca = r["claimed_at"]
1013
+ if ca is not None and ca > cutoff:
1014
+ continue # still within its lease
1015
+ # Stamp WHEN the lease was taken away, in the same transaction:
1016
+ # a reclaim means "presumed dead", and the scratch collector uses
1017
+ # this to give a merely-slow run a grace period before deleting
1018
+ # the namespace it is still reading and writing.
1019
+ self._conn.execute(
1020
+ "UPDATE board_run_namespaces SET reclaimed_at=? WHERE run_id=?",
1021
+ (now, r["lease_run_id"]),
1022
+ )
1023
+ self._conn.execute(
1024
+ "UPDATE work_items SET state=?, lease_run_id=NULL, claimed_at=NULL, "
1025
+ "updated_at=? WHERE id=? AND state=?",
1026
+ (WorkState.PLANNED.value, now, r["id"], WorkState.RUNNING.value),
1027
+ )
1028
+ self._insert_transition(
1029
+ r["id"], WorkState.RUNNING.value, WorkState.PLANNED.value,
1030
+ "system", _STALE_RECLAIM_NOTE, now,
1031
+ )
1032
+ reclaimed += 1
1033
+ self._conn.execute("COMMIT")
1034
+ return reclaimed
1035
+ except Exception as exc:
1036
+ self._safe_rollback()
1037
+ raise BoardError(f"recover_stale_running failed: {exc}") from exc
1038
+
1039
+ def add_decision(
1040
+ self,
1041
+ text: str,
1042
+ *,
1043
+ scope: str = "",
1044
+ supersedes: str | None = None,
1045
+ decision_id: str | None = None,
1046
+ ) -> Decision:
1047
+ """Record a team :class:`Decision` (Phase 2), atomically retiring the one it
1048
+ supersedes (if any) in the same transaction.
1049
+
1050
+ ``scope=""`` is GLOBAL (applies to every item); a goal string scopes it to
1051
+ that goal. ``supersedes`` is the id of an ACTIVE decision IN THE SAME SCOPE
1052
+ this replaces — it is marked superseded (and so drops out of
1053
+ :meth:`active_decisions`) the moment the new one lands, so a goal never carries
1054
+ two contradictory live decisions. The superseded decision must share the new
1055
+ one's ``scope`` (a replacement retires the thing it replaces, in place) — a
1056
+ cross-scope ``supersedes`` is a caller mistake (it would silently retire an
1057
+ UNRELATED goal's decision while inserting here), so it raises :class:`BoardError`.
1058
+ Superseding a missing or already-retired decision likewise raises
1059
+ :class:`BoardError` (a stale id is a caller mistake, not a silent no-op), and in
1060
+ every error case the new decision is NOT inserted. ``text`` is required (an
1061
+ empty decision is meaningless)."""
1062
+ if not text or not text.strip():
1063
+ raise BoardError("a decision needs non-empty text")
1064
+ now = self._now()
1065
+ dec = Decision(
1066
+ id=decision_id or str(uuid.uuid4()),
1067
+ text=text.strip(),
1068
+ scope=scope,
1069
+ created_at=now,
1070
+ )
1071
+ try:
1072
+ self._conn.execute("BEGIN IMMEDIATE")
1073
+ if supersedes is not None:
1074
+ # Same-scope CAS: only retire a decision that shares the new one's
1075
+ # scope. A 0-row match means the target is missing, already retired,
1076
+ # OR scoped elsewhere — distinguish them for a clear error (and never
1077
+ # silently retire another goal's decision via a cross-scope id).
1078
+ cur = self._conn.execute(
1079
+ "UPDATE decisions SET superseded_by=?, superseded_at=? "
1080
+ "WHERE id=? AND superseded_by IS NULL AND scope=?",
1081
+ (dec.id, now, supersedes, scope),
1082
+ )
1083
+ if cur.rowcount == 0:
1084
+ row = self._conn.execute(
1085
+ "SELECT scope, superseded_by FROM decisions WHERE id=?",
1086
+ (supersedes,),
1087
+ ).fetchone()
1088
+ if row is None:
1089
+ reason = "no such decision"
1090
+ elif row["superseded_by"] is not None:
1091
+ reason = "it is already superseded"
1092
+ else:
1093
+ reason = (
1094
+ f"it is scoped to {row['scope']!r}, not {scope!r} — "
1095
+ "supersede only retires a decision in the same scope"
1096
+ )
1097
+ raise BoardError(f"cannot supersede {supersedes}: {reason}")
1098
+ self._conn.execute(
1099
+ "INSERT INTO decisions (id, text, scope, created_at, superseded_by, "
1100
+ "superseded_at) VALUES (?,?,?,?,?,?)",
1101
+ (dec.id, dec.text, dec.scope, dec.created_at, None, None),
1102
+ )
1103
+ self._conn.execute("COMMIT")
1104
+ return dec
1105
+ except BoardError:
1106
+ self._safe_rollback()
1107
+ raise
1108
+ except Exception as exc:
1109
+ # Broad (matching next_runnable/apply_update/transition/recover): roll back
1110
+ # the open BEGIN IMMEDIATE on ANY error so the RESERVED write lock is never
1111
+ # leaked — the documented txn-safety invariant, held by construction.
1112
+ self._safe_rollback()
1113
+ raise BoardError(f"add_decision failed: {exc}") from exc
1114
+
1115
+ # ── reads ────────────────────────────────────────────────────────────────
1116
+
1117
+ def get(self, item_id: str) -> WorkItem | None:
1118
+ """The item, or ``None`` if no such id.
1119
+
1120
+ A malformed id (not a uuid) is treated as not-found and returns ``None``
1121
+ WITHOUT a query — the single-coupling guard for the read-only gateway's
1122
+ ``board_get`` (mirrors ``SyncmillMemory.get_shared``): every real
1123
+ :class:`WorkItem` id is a ``uuid4``, so an unparseable id is a client typo,
1124
+ never a row, and must not reach a guessed/probing lookup."""
1125
+ if not _is_valid_item_id(item_id):
1126
+ return None
1127
+ try:
1128
+ row = self._conn.execute(
1129
+ "SELECT * FROM work_items WHERE id=?", (item_id,)
1130
+ ).fetchone()
1131
+ # _row_to_item is inside the try (and ValueError is caught) so a
1132
+ # corrupt/out-of-enum `state` from a hand-edited db surfaces as a
1133
+ # contracted BoardError, not a raw ValueError traceback.
1134
+ return _row_to_item(row) if row is not None else None
1135
+ except (sqlite3.Error, ValueError) as exc:
1136
+ raise BoardError(f"get failed: {exc}") from exc
1137
+
1138
+ def list(self, state: str | None = None, goal: str | None = None) -> list[WorkItem]:
1139
+ """All items, optionally filtered by ``state`` and/or ``goal`` (FIFO order)."""
1140
+ clauses, params = [], []
1141
+ if state is not None:
1142
+ if state not in _STATES:
1143
+ raise BoardError(f"unknown state: {state!r}")
1144
+ clauses.append("state=?")
1145
+ params.append(state)
1146
+ if goal is not None:
1147
+ clauses.append("goal=?")
1148
+ params.append(goal)
1149
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
1150
+ try:
1151
+ rows = self._conn.execute(
1152
+ f"SELECT * FROM work_items{where} ORDER BY created_at, rowid", params
1153
+ ).fetchall()
1154
+ return [_row_to_item(r) for r in rows] # ValueError (bad state) → BoardError
1155
+ except (sqlite3.Error, ValueError) as exc:
1156
+ raise BoardError(f"list failed: {exc}") from exc
1157
+
1158
+ def list_recent(
1159
+ self, *, state: str | None = None, goal: str | None = None, limit: int
1160
+ ) -> list[WorkItem]:
1161
+ """The newest ``limit`` items, NEWEST first, sorted and bounded in SQL.
1162
+
1163
+ ``created_at DESC, rowid DESC`` is exactly ``reversed`` of :meth:`list`'s
1164
+ ``created_at, rowid`` FIFO, so the order and the same-timestamp tie-break
1165
+ match ``list(reversed(self.list(...)))[:limit]`` — but only ``limit``
1166
+ rows are fetched and converted (#167: the MCP ``board_list`` tool used
1167
+ to materialize the whole board to answer ``limit=1``). Filters apply
1168
+ before the limit. ``limit`` must be an int ≥ 1; clamping a client value
1169
+ is the caller's job, a nonsensical bound is refused.
1170
+ """
1171
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
1172
+ raise BoardError(f"limit must be a positive integer, got {limit!r}")
1173
+ clauses, params = [], []
1174
+ if state is not None:
1175
+ if state not in _STATES:
1176
+ raise BoardError(f"unknown state: {state!r}")
1177
+ clauses.append("state=?")
1178
+ params.append(state)
1179
+ if goal is not None:
1180
+ clauses.append("goal=?")
1181
+ params.append(goal)
1182
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
1183
+ try:
1184
+ rows = self._conn.execute(
1185
+ f"SELECT * FROM work_items{where} "
1186
+ "ORDER BY created_at DESC, rowid DESC LIMIT ?",
1187
+ [*params, limit],
1188
+ ).fetchall()
1189
+ return [_row_to_item(r) for r in rows]
1190
+ except (sqlite3.Error, ValueError) as exc:
1191
+ raise BoardError(f"list failed: {exc}") from exc
1192
+
1193
+ def collectible_board_run_ids(self, *, limit: int = 100) -> list[str]:
1194
+ """Oldest unpurged scratch owners that no longer hold a live lease.
1195
+
1196
+ Ledger insertion and lease stamping commit in the same claim transaction,
1197
+ so this single SQLite snapshot replaces the old backend-enumeration then
1198
+ lease-read dance without opening a concurrent-claim deletion race.
1199
+
1200
+ "Holds no lease" alone is not enough for a run whose lease was taken by
1201
+ ``recover_stale_running``: a reclaim means presumed dead, and a run that
1202
+ is merely SLOW is still reading and writing its scratch namespace.
1203
+ Deleting it there silently empties a live pipeline's stage-to-stage
1204
+ handoff with nothing degraded to show for it (the delete succeeded). So a
1205
+ reclaimed run gets one further lease timeout of grace. A run still alive
1206
+ past that is the pre-existing accepted residue — its board write is
1207
+ fence-dropped anyway, memory is advisory, and the namespace is recreated
1208
+ by its next write. A run that left ``running`` normally has no
1209
+ ``reclaimed_at`` and is collectible immediately, as before.
1210
+ """
1211
+ if limit <= 0:
1212
+ return []
1213
+ now = self._now()
1214
+ grace_cutoff = now - self._lease_timeout
1215
+ try:
1216
+ rows = self._conn.execute(
1217
+ "SELECT n.run_id FROM board_run_namespaces AS n "
1218
+ "WHERE n.purged_at IS NULL "
1219
+ "AND (n.reclaimed_at IS NULL OR n.reclaimed_at <= ?) "
1220
+ # Purge-failure backoff: a row whose purge keeps failing waits
1221
+ # out its scheduled retry instead of head-of-line-blocking the
1222
+ # sweep (see record_board_run_purge_failures).
1223
+ "AND (n.next_attempt_at IS NULL OR n.next_attempt_at <= ?) "
1224
+ "AND NOT EXISTS ("
1225
+ "SELECT 1 FROM work_items AS w WHERE w.state=? "
1226
+ "AND w.lease_run_id=n.run_id) "
1227
+ "ORDER BY n.claimed_at, n.rowid LIMIT ?",
1228
+ (grace_cutoff, now, WorkState.RUNNING.value, limit),
1229
+ ).fetchall()
1230
+ return [r["run_id"] for r in rows]
1231
+ except sqlite3.Error as exc:
1232
+ raise BoardError(f"collectible_board_run_ids failed: {exc}") from exc
1233
+
1234
+ def mark_board_run_namespaces_purged(self, run_ids: Iterable[str]) -> int:
1235
+ """Tombstone successfully processed scratch namespaces, atomically."""
1236
+ ids = list(dict.fromkeys(rid for rid in run_ids if rid))
1237
+ if not ids:
1238
+ return 0
1239
+ try:
1240
+ self._conn.execute("BEGIN IMMEDIATE")
1241
+ now = self._now()
1242
+ updated = 0
1243
+ for run_id in ids:
1244
+ # A successful purge also RESETS the failure backoff: earlier
1245
+ # failures are stale history now, and carrying them into a
1246
+ # reactivated row would make its next (unrelated) failure count
1247
+ # as consecutive — inheriting quarantine-grade backoff at once.
1248
+ cur = self._conn.execute(
1249
+ "UPDATE board_run_namespaces "
1250
+ "SET purged_at=?, failed_attempts=NULL, next_attempt_at=NULL "
1251
+ "WHERE run_id=? AND purged_at IS NULL",
1252
+ (now, run_id),
1253
+ )
1254
+ updated += cur.rowcount
1255
+ self._conn.execute("COMMIT")
1256
+ return updated
1257
+ except Exception as exc:
1258
+ self._safe_rollback()
1259
+ raise BoardError(f"mark_board_run_namespaces_purged failed: {exc}") from exc
1260
+
1261
+ #: Exponential backoff for a namespace whose purge failed: first retry after
1262
+ #: the base, doubling per consecutive failure, capped. The cap keeps a
1263
+ #: permanently poisoned row (an operator-fixable condition — a symlink, an
1264
+ #: unmounted root) retried a few times a day rather than never, so a fix
1265
+ #: lands without manual intervention.
1266
+ _PURGE_BACKOFF_BASE_SECONDS = 60.0
1267
+ _PURGE_BACKOFF_CAP_SECONDS = 6 * 3600.0
1268
+ #: After this many consecutive failures the row is loudly flagged as
1269
+ #: quarantined (still retried on the capped schedule — never dropped, since
1270
+ #: only a successful purge may tombstone a namespace).
1271
+ PURGE_QUARANTINE_ATTEMPTS = 5
1272
+
1273
+ def record_board_run_purge_failures(self, run_ids: Iterable[str]) -> list[str]:
1274
+ """Schedule failed purges for exponential-backoff retry, atomically.
1275
+
1276
+ Returns the run ids that just crossed :data:`PURGE_QUARANTINE_ATTEMPTS`
1277
+ (for the caller to warn about). Failure scheduling never tombstones —
1278
+ the fail-closed purge contract (only a proven-complete purge may retire
1279
+ a namespace from collection) is untouched; this only decides WHEN the
1280
+ next attempt happens, so one poisoned row can't consume the whole GC
1281
+ budget every sweep while healthy rows behind it starve.
1282
+ """
1283
+ ids = list(dict.fromkeys(rid for rid in run_ids if rid))
1284
+ if not ids:
1285
+ return []
1286
+ quarantined: list[str] = []
1287
+ try:
1288
+ self._conn.execute("BEGIN IMMEDIATE")
1289
+ now = self._now()
1290
+ for run_id in ids:
1291
+ row = self._conn.execute(
1292
+ "SELECT failed_attempts FROM board_run_namespaces "
1293
+ "WHERE run_id=? AND purged_at IS NULL",
1294
+ (run_id,),
1295
+ ).fetchone()
1296
+ if row is None:
1297
+ continue
1298
+ attempts = int(row["failed_attempts"] or 0) + 1
1299
+ delay = min(
1300
+ self._PURGE_BACKOFF_BASE_SECONDS * (2 ** (attempts - 1)),
1301
+ self._PURGE_BACKOFF_CAP_SECONDS,
1302
+ )
1303
+ self._conn.execute(
1304
+ "UPDATE board_run_namespaces "
1305
+ "SET failed_attempts=?, next_attempt_at=? "
1306
+ "WHERE run_id=? AND purged_at IS NULL",
1307
+ (attempts, now + delay, run_id),
1308
+ )
1309
+ if attempts == self.PURGE_QUARANTINE_ATTEMPTS:
1310
+ quarantined.append(run_id)
1311
+ self._conn.execute("COMMIT")
1312
+ return quarantined
1313
+ except Exception as exc:
1314
+ self._safe_rollback()
1315
+ raise BoardError(
1316
+ f"record_board_run_purge_failures failed: {exc}"
1317
+ ) from exc
1318
+
1319
+ def reactivate_board_run_namespace(self, run_id: str) -> bool:
1320
+ """Clear a tombstone after late scoped activity (best-effort caller)."""
1321
+ try:
1322
+ cur = self._conn.execute(
1323
+ "UPDATE board_run_namespaces SET purged_at=NULL "
1324
+ "WHERE run_id=? AND purged_at IS NOT NULL",
1325
+ (run_id,),
1326
+ )
1327
+ return cur.rowcount == 1
1328
+ except sqlite3.Error as exc:
1329
+ raise BoardError(f"reactivate_board_run_namespace failed: {exc}") from exc
1330
+
1331
+ def transitions(self, item_id: str) -> list[dict]:
1332
+ """The append-only audit trail for an item (oldest first)."""
1333
+ try:
1334
+ rows = self._conn.execute(
1335
+ "SELECT from_state, to_state, actor, note, at FROM transitions "
1336
+ "WHERE item_id=? ORDER BY id",
1337
+ (item_id,),
1338
+ ).fetchall()
1339
+ except sqlite3.Error as exc:
1340
+ raise BoardError(f"transitions failed: {exc}") from exc
1341
+ return [dict(r) for r in rows]
1342
+
1343
+ def active_decisions(self, goal: str | None = None) -> list[Decision]:
1344
+ """The active (non-superseded) decisions, oldest-first (Phase 2).
1345
+
1346
+ ``goal=None`` returns every active decision regardless of scope (the full
1347
+ ``board decisions`` view). A ``goal`` string returns the decisions that APPLY
1348
+ to that goal — the GLOBAL ones (``scope=""``) plus those scoped to exactly
1349
+ that goal — which is what ``run_item`` injects into an item's prompt
1350
+ (``goal=item.goal``)."""
1351
+ clauses = ["superseded_by IS NULL"]
1352
+ params: list = []
1353
+ if goal is not None:
1354
+ clauses.append("(scope='' OR scope=?)")
1355
+ params.append(goal)
1356
+ where = " AND ".join(clauses)
1357
+ try:
1358
+ rows = self._conn.execute(
1359
+ "SELECT id, text, scope, created_at, superseded_by, superseded_at "
1360
+ f"FROM decisions WHERE {where} ORDER BY created_at, rowid",
1361
+ params,
1362
+ ).fetchall()
1363
+ return [_row_to_decision(r) for r in rows]
1364
+ except sqlite3.Error as exc:
1365
+ raise BoardError(f"active_decisions failed: {exc}") from exc
1366
+
1367
+ def stats(
1368
+ self, *, now: float | None = None, stall_seconds: int | None = None
1369
+ ) -> "BoardStats":
1370
+ """Team-health signals the board uniquely surfaces (Phase 2, read-only).
1371
+
1372
+ Computed entirely from the existing rows + the append-only ``transitions``
1373
+ audit — no new write-path instrumentation:
1374
+
1375
+ - **false_done** — items in ``done`` carrying NO passing :class:`Evidence`.
1376
+ The done-gate makes this impossible through the normal path, so a non-empty
1377
+ list is an integrity alarm (a hand-edited db / a bypassed gate).
1378
+ - **redispatched** — items claimed (``planned→running``) more than once: work
1379
+ that a stale-lease reclaim forced a second runner to redo (the materialised
1380
+ duplicate-work signal — a purely concurrent claim that the atomic CAS averts
1381
+ instantly leaves no trace by design, so it is not, and need not be, counted).
1382
+ - **reclaimed_stale** — total ``recover_stale_running`` reclaims (dead-run
1383
+ recovery events).
1384
+ - **blocked_aged / human_required_aged** — items stuck in ``blocked`` / needing
1385
+ a human, untouched longer than ``stall_seconds`` (a stalled team).
1386
+ - **dropped_handoffs** — items whose handoff baton was set but that then sat
1387
+ idle (not ``done``) past ``stall_seconds`` — a handoff nobody picked up.
1388
+
1389
+ ``now`` / ``stall_seconds`` default to the board's injected clock / configured
1390
+ window (overridable for tests). Raises :class:`BoardError` on a db error.
1391
+
1392
+ The three reads run inside ONE deferred read transaction: as separate
1393
+ autocommit statements they could straddle a concurrent ``board run``'s claim,
1394
+ so the state counts came from a pre-claim world while the audit-derived
1395
+ ``redispatched`` / ``reclaimed_stale`` came from a post-claim one — a reported
1396
+ integrity alarm describing a state that never existed."""
1397
+ t = self._now() if now is None else now
1398
+ window = self._stall_seconds if stall_seconds is None else max(1, stall_seconds)
1399
+ cutoff = t - window
1400
+ try:
1401
+ self._conn.execute("BEGIN") # deferred: a consistent read snapshot
1402
+ items = [
1403
+ _row_to_item(r)
1404
+ for r in self._conn.execute("SELECT * FROM work_items").fetchall()
1405
+ ]
1406
+ redispatched = [
1407
+ r["item_id"]
1408
+ # A re-dispatch is a CLAIM: every claim (claim/next_runnable) records a
1409
+ # planned→running transition, so >1 of those means the item was claimed,
1410
+ # reclaimed, and claimed again (work redone). Match `from_state` too: a
1411
+ # same-state running→running annotation (a `board update` on a running
1412
+ # item) ALSO has to_state='running' but is NOT a re-dispatch, so a bare
1413
+ # to_state filter would over-count it (codex-found major).
1414
+ # Live rows UNION the compacted rollups (#118): `board compact`
1415
+ # folds trimmed claim rows into transition_rollups, so the count
1416
+ # is live + rolled-up, identical before and after a compaction.
1417
+ for r in self._conn.execute(
1418
+ "SELECT item_id FROM ("
1419
+ " SELECT item_id, COUNT(*) AS c FROM transitions"
1420
+ " WHERE from_state=? AND to_state=? GROUP BY item_id"
1421
+ " UNION ALL"
1422
+ " SELECT item_id, claim_count FROM transition_rollups"
1423
+ ") GROUP BY item_id HAVING SUM(c) > 1",
1424
+ (WorkState.PLANNED.value, WorkState.RUNNING.value),
1425
+ ).fetchall()
1426
+ ]
1427
+ reclaimed_stale = self._conn.execute(
1428
+ "SELECT (SELECT COUNT(*) FROM transitions WHERE to_state=? AND note=?)"
1429
+ " + COALESCE((SELECT value FROM transition_counters"
1430
+ " WHERE name='reclaimed_stale'), 0) AS c",
1431
+ (WorkState.PLANNED.value, _STALE_RECLAIM_NOTE),
1432
+ ).fetchone()["c"]
1433
+ self._conn.execute("COMMIT")
1434
+ except Exception as exc:
1435
+ # Broad, like every other transactional method: a non-sqlite error (a
1436
+ # corrupt row's ValueError from _row_to_item) must still release the
1437
+ # open read transaction rather than leaking it onto this connection.
1438
+ self._safe_rollback()
1439
+ raise BoardError(f"stats failed: {exc}") from exc
1440
+
1441
+ by_state: dict[str, int] = {}
1442
+ false_done: list[str] = []
1443
+ blocked_aged: list[str] = []
1444
+ human_required_aged: list[str] = []
1445
+ dropped_handoffs: list[str] = []
1446
+ for it in items:
1447
+ by_state[it.state.value] = by_state.get(it.state.value, 0) + 1
1448
+ aged = it.updated_at <= cutoff
1449
+ if it.state == WorkState.DONE and not _has_verification(it.evidence):
1450
+ false_done.append(it.id)
1451
+ if it.state == WorkState.BLOCKED and aged:
1452
+ blocked_aged.append(it.id)
1453
+ if it.human_required and it.state != WorkState.DONE and aged:
1454
+ human_required_aged.append(it.id)
1455
+ if it.handoff and it.state != WorkState.DONE and aged:
1456
+ dropped_handoffs.append(it.id)
1457
+ return BoardStats(
1458
+ total=len(items),
1459
+ by_state=by_state,
1460
+ false_done=false_done,
1461
+ redispatched=redispatched,
1462
+ reclaimed_stale=int(reclaimed_stale),
1463
+ blocked_aged=blocked_aged,
1464
+ human_required_aged=human_required_aged,
1465
+ dropped_handoffs=dropped_handoffs,
1466
+ stall_seconds=window,
1467
+ )
1468
+
1469
+ def compact_transitions(
1470
+ self, *, older_than_seconds: float, now: float | None = None
1471
+ ) -> int:
1472
+ """Aggregate-then-trim the append-only ``transitions`` audit (#118).
1473
+
1474
+ Manual-only (``board compact`` — the same explicit/human deletion rule
1475
+ as retained workspaces; nothing auto-triggers this). One
1476
+ ``BEGIN IMMEDIATE`` transaction: rows older than the cutoff have their
1477
+ stats-bearing aggregates folded first — per-item ``planned→running``
1478
+ claim counts into ``transition_rollups`` and stale-reclaim rows into
1479
+ the ``reclaimed_stale`` counter, which :meth:`stats` reads additively,
1480
+ so its numbers are identical before and after — and are then deleted.
1481
+ Rows newer than the cutoff always survive (recent per-item history for
1482
+ ``board show``). The scratch-ledger backfill runs FIRST, inside the
1483
+ same transaction: it scans historical claim rows, and trimming them
1484
+ beforehand would silently disable it on an old board. Returns the
1485
+ number of rows removed; raises :class:`BoardError` on any failure
1486
+ (rolled back — the broad-rollback rule).
1487
+ """
1488
+ # A DESTRUCTIVE threshold is never clamped: a negative (or NaN) window
1489
+ # silently became 0 — "trim everything up to now", i.e. the whole audit
1490
+ # — which is exactly the nonpositive case runs.keep_last rejects. 0 is
1491
+ # still legal and means the explicit "trim all existing history".
1492
+ # `< 0` is NOT sufficient: an underflowing negative literal (-1e-999)
1493
+ # becomes signed NEGATIVE ZERO, for which `-0.0 < 0` is False — it
1494
+ # would slip through and trim as 0, i.e. the whole audit, from an input
1495
+ # the caller wrote as negative. Test the SIGN BIT instead.
1496
+ if (
1497
+ not math.isfinite(older_than_seconds)
1498
+ or math.copysign(1.0, older_than_seconds) < 0
1499
+ ):
1500
+ raise BoardError(
1501
+ f"older_than_seconds must be a finite value >= 0, got "
1502
+ f"{older_than_seconds!r}"
1503
+ )
1504
+ t = self._now() if now is None else now
1505
+ # A non-finite clock makes every finite row "old" — the same total-purge
1506
+ # bypass by another route (an injected/broken clock rather than a flag).
1507
+ if not math.isfinite(t):
1508
+ raise BoardError(f"clock must return a finite value, got {t!r}")
1509
+ cutoff = t - older_than_seconds
1510
+ try:
1511
+ self._conn.execute("BEGIN IMMEDIATE")
1512
+ _backfill_run_ledger(self._conn)
1513
+ for row in self._conn.execute(
1514
+ "SELECT item_id, COUNT(*) AS c FROM transitions"
1515
+ " WHERE from_state=? AND to_state=? AND at < ?"
1516
+ " GROUP BY item_id",
1517
+ (WorkState.PLANNED.value, WorkState.RUNNING.value, cutoff),
1518
+ ).fetchall():
1519
+ self._conn.execute(
1520
+ "INSERT INTO transition_rollups (item_id, claim_count)"
1521
+ " VALUES (?, ?)"
1522
+ " ON CONFLICT(item_id) DO UPDATE SET"
1523
+ " claim_count = claim_count + excluded.claim_count",
1524
+ (row["item_id"], row["c"]),
1525
+ )
1526
+ reclaims = self._conn.execute(
1527
+ "SELECT COUNT(*) AS c FROM transitions"
1528
+ " WHERE to_state=? AND note=? AND at < ?",
1529
+ (WorkState.PLANNED.value, _STALE_RECLAIM_NOTE, cutoff),
1530
+ ).fetchone()["c"]
1531
+ if reclaims:
1532
+ self._conn.execute(
1533
+ "INSERT INTO transition_counters (name, value)"
1534
+ " VALUES ('reclaimed_stale', ?)"
1535
+ " ON CONFLICT(name) DO UPDATE SET"
1536
+ " value = value + excluded.value",
1537
+ (reclaims,),
1538
+ )
1539
+ trimmed = self._conn.execute(
1540
+ "DELETE FROM transitions WHERE at < ?", (cutoff,)
1541
+ ).rowcount
1542
+ self._conn.execute("COMMIT")
1543
+ return int(trimmed)
1544
+ except Exception as exc:
1545
+ self._safe_rollback()
1546
+ raise BoardError(f"compact failed: {exc}") from exc
1547
+
1548
+ def close(self) -> None:
1549
+ """Close the connection (best-effort; idempotent)."""
1550
+ try:
1551
+ self._conn.close()
1552
+ except sqlite3.Error:
1553
+ pass
1554
+
1555
+ # ── internals ────────────────────────────────────────────────────────────
1556
+
1557
+ def _claim_where(self, where: str, where_params: tuple, run_id: str) -> WorkItem | None:
1558
+ try:
1559
+ self._conn.execute("BEGIN IMMEDIATE")
1560
+ row = self._conn.execute(
1561
+ f"SELECT * FROM work_items WHERE {where}", where_params
1562
+ ).fetchone()
1563
+ if row is None:
1564
+ self._conn.execute("COMMIT")
1565
+ return None
1566
+ item = self._stamp_running(row, run_id)
1567
+ self._conn.execute("COMMIT")
1568
+ return item
1569
+ except Exception as exc:
1570
+ self._safe_rollback()
1571
+ raise BoardError(f"claim failed: {exc}") from exc
1572
+
1573
+ def _stamp_running(self, row: sqlite3.Row, run_id: str) -> WorkItem:
1574
+ """Inside an open transaction: move ``row`` (a planned item) → running,
1575
+ stamping the lease. Returns the freshly-read item."""
1576
+ now = self._now()
1577
+ self._conn.execute(
1578
+ "UPDATE work_items SET state=?, lease_run_id=?, claimed_at=?, updated_at=? "
1579
+ "WHERE id=? AND state=?",
1580
+ (WorkState.RUNNING.value, run_id, now, now, row["id"], WorkState.PLANNED.value),
1581
+ )
1582
+ self._insert_transition(
1583
+ row["id"], WorkState.PLANNED.value, WorkState.RUNNING.value,
1584
+ run_id, "claimed", now,
1585
+ )
1586
+ # P2 scratch ownership is load-bearing claim metadata: a failure rolls back
1587
+ # the lease and audit with the surrounding claim transaction. One run_id owns
1588
+ # at most one item's scratch namespace (it IS `board-run:{run_id}`), so a
1589
+ # reused id is rejected — but say WHY. Left to the bare INSERT it surfaced as
1590
+ # "claim failed: UNIQUE constraint failed: board_run_namespaces.run_id",
1591
+ # which reads like board corruption rather than a caller reusing a run id.
1592
+ clash = self._conn.execute(
1593
+ "SELECT item_id FROM board_run_namespaces WHERE run_id=?", (run_id,)
1594
+ ).fetchone()
1595
+ if clash is not None:
1596
+ raise BoardError(
1597
+ f"run id {run_id!r} already owns the scratch namespace for item "
1598
+ f"{clash['item_id']} — one run id claims at most one item"
1599
+ )
1600
+ self._conn.execute(
1601
+ "INSERT INTO board_run_namespaces "
1602
+ "(run_id, item_id, claimed_at, purged_at) VALUES (?,?,?,NULL)",
1603
+ (run_id, row["id"], now),
1604
+ )
1605
+ return _row_to_item(
1606
+ self._conn.execute("SELECT * FROM work_items WHERE id=?", (row["id"],)).fetchone()
1607
+ )
1608
+
1609
+ def _deps_satisfied(self, deps: list[str], done_ids: set[str]) -> bool:
1610
+ """True iff every dependency is in the preloaded ``done`` id set (a missing
1611
+ dependency is conservatively unsatisfied — don't dispatch work whose
1612
+ declared prerequisite isn't there). Called inside the ``next_runnable``
1613
+ transaction; ``done_ids`` is read in that same transaction, so membership
1614
+ is exactly what per-dependency SELECTs would have seen."""
1615
+ return all(dep_id in done_ids for dep_id in deps)
1616
+
1617
+ def _insert_transition(
1618
+ self, item_id: str, from_state: str | None, to_state: str, actor: str,
1619
+ note: str, at: float,
1620
+ ) -> None:
1621
+ self._conn.execute(
1622
+ "INSERT INTO transitions (item_id, from_state, to_state, actor, note, at) "
1623
+ "VALUES (?,?,?,?,?,?)",
1624
+ (item_id, from_state, to_state, actor, note, at),
1625
+ )
1626
+
1627
+ def _safe_rollback(self) -> None:
1628
+ try:
1629
+ self._conn.execute("ROLLBACK")
1630
+ except sqlite3.Error:
1631
+ pass # no active transaction (e.g. BEGIN itself failed) — nothing to undo
1632
+
1633
+
1634
+ # ── row <-> dataclass mapping ────────────────────────────────────────────────
1635
+
1636
+
1637
+ def _item_to_row(item: WorkItem) -> tuple:
1638
+ return (
1639
+ item.id, item.goal, item.task, item.state.value, item.owner_agent,
1640
+ json.dumps(item.inputs), json.dumps(item.outputs), _dump_evidence(item.evidence),
1641
+ item.handoff, int(item.human_required), item.blocked_reason,
1642
+ json.dumps(item.depends_on), item.lease_run_id, item.claimed_at,
1643
+ item.created_at, item.updated_at,
1644
+ )
1645
+
1646
+
1647
+ def _row_to_item(row: sqlite3.Row) -> WorkItem:
1648
+ return WorkItem(
1649
+ id=row["id"],
1650
+ goal=row["goal"],
1651
+ task=row["task"],
1652
+ state=WorkState(row["state"]),
1653
+ owner_agent=row["owner_agent"],
1654
+ inputs=_load_list(row["inputs"]),
1655
+ outputs=_load_list(row["outputs"]),
1656
+ evidence=_load_evidence(row["evidence"]),
1657
+ handoff=row["handoff"],
1658
+ human_required=bool(row["human_required"]),
1659
+ blocked_reason=row["blocked_reason"],
1660
+ depends_on=_load_list(row["depends_on"]),
1661
+ lease_run_id=row["lease_run_id"],
1662
+ claimed_at=row["claimed_at"],
1663
+ created_at=row["created_at"],
1664
+ updated_at=row["updated_at"],
1665
+ )
1666
+
1667
+
1668
+ def _is_valid_item_id(item_id: object) -> bool:
1669
+ """True iff ``item_id`` parses as a uuid (the shape every real id has). Lets
1670
+ :meth:`Board.get` reject a malformed/guessed id as not-found before querying."""
1671
+ try:
1672
+ uuid.UUID(str(item_id))
1673
+ return True
1674
+ except (ValueError, TypeError, AttributeError):
1675
+ return False
1676
+
1677
+
1678
+ def _row_to_decision(row: sqlite3.Row) -> Decision:
1679
+ return Decision(
1680
+ id=row["id"],
1681
+ text=row["text"],
1682
+ scope=row["scope"],
1683
+ created_at=row["created_at"],
1684
+ superseded_by=row["superseded_by"],
1685
+ superseded_at=row["superseded_at"],
1686
+ )
1687
+
1688
+
1689
+ def _load_list(raw: str) -> list[str]:
1690
+ try:
1691
+ v = json.loads(raw)
1692
+ except (ValueError, TypeError):
1693
+ return []
1694
+ return [str(x) for x in v] if isinstance(v, list) else []
1695
+
1696
+
1697
+ def _dump_evidence(evidence: list[Evidence]) -> str:
1698
+ return json.dumps([asdict(e) for e in evidence])
1699
+
1700
+
1701
+ def _load_evidence(raw: str) -> list[Evidence]:
1702
+ try:
1703
+ rows = json.loads(raw)
1704
+ except (ValueError, TypeError):
1705
+ return []
1706
+ out: list[Evidence] = []
1707
+ if not isinstance(rows, list):
1708
+ return out
1709
+ for d in rows:
1710
+ if not isinstance(d, dict):
1711
+ continue
1712
+ try:
1713
+ out.append(Evidence(
1714
+ kind=str(d["kind"]), ref=str(d["ref"]), passed=bool(d["passed"]),
1715
+ at=float(d["at"]), by=str(d.get("by", "")),
1716
+ ))
1717
+ except (KeyError, TypeError, ValueError):
1718
+ continue # skip a malformed evidence row, keep the rest
1719
+ return out
1720
+
1721
+
1722
+ def _merge_unique(existing: list[str], new: list) -> list[str]:
1723
+ """``existing`` then any ``new`` not already present — order-preserving dedup."""
1724
+ out = list(existing)
1725
+ seen = set(out)
1726
+ for x in new:
1727
+ s = str(x)
1728
+ if s and s not in seen:
1729
+ seen.add(s)
1730
+ out.append(s)
1731
+ return out
1732
+
1733
+
1734
+ def _same_review_provenance(existing: WorkItem, expected: WorkItem) -> bool:
1735
+ """Whether an existing deterministic id still names the same source finding."""
1736
+ source_evidence = expected.evidence[0]
1737
+ return (
1738
+ existing.task == expected.task
1739
+ and existing.inputs == expected.inputs
1740
+ and any(
1741
+ evidence.kind == source_evidence.kind
1742
+ and evidence.ref == source_evidence.ref
1743
+ and evidence.passed is False
1744
+ and evidence.by == source_evidence.by
1745
+ for evidence in existing.evidence
1746
+ )
1747
+ )
1748
+
1749
+
1750
+ def _has_verification(evidence: list[Evidence]) -> bool:
1751
+ """The ``done`` gate predicate: at least one PASSING evidence row.
1752
+
1753
+ A human sign-off is just an ``Evidence{kind:"human", passed=True}``, so it's
1754
+ covered by ``passed`` — and a RECORDED human REJECTION (``kind=="human",
1755
+ passed=False``) deliberately does NOT satisfy the gate (the ``passed`` field is
1756
+ the verdict; the gate must honor it, not any human row regardless of outcome)."""
1757
+ return any(e.passed for e in evidence)
1758
+
1759
+
1760
+ def _resolve_completed_state(
1761
+ requested: object, evidence: list[Evidence]
1762
+ ) -> tuple[WorkState, str]:
1763
+ """Map a COMPLETED run + the agent's advisory ``state`` request → (state, note).
1764
+
1765
+ A completed run defaults to ``review`` ("finished, awaiting verification"). The
1766
+ agent's request is advisory: ``done`` is honored only when the work is
1767
+ independently verified (passing evidence / a human sign-off — i.e. a passing
1768
+ quality gate), else it CLAMPS to ``review``; ``blocked`` is honored; anything
1769
+ else (``None``/unknown/``review``/``running``/``planned``) → ``review``."""
1770
+ req = requested if isinstance(requested, str) else None
1771
+ if req == WorkState.DONE.value:
1772
+ if _has_verification(evidence):
1773
+ return WorkState.DONE, "agent completed; verified"
1774
+ return WorkState.REVIEW, "agent requested done without verification — clamped to review"
1775
+ if req == WorkState.BLOCKED.value:
1776
+ return WorkState.BLOCKED, "agent reported blocked"
1777
+ return WorkState.REVIEW, "agent completed; awaiting review"
1778
+
1779
+
1780
+ # ── prompt rendering + agent-output parsing (pure) ───────────────────────────
1781
+
1782
+ #: The fenced-block language tag agents use to (optionally) report a board update.
1783
+ BOARD_FENCE = "syncmill-board"
1784
+
1785
+ _UPDATE_INSTRUCTIONS = (
1786
+ "When you finish, you MAY append ONE fenced block reporting the board update "
1787
+ "(every field optional):\n"
1788
+ "```" + BOARD_FENCE + "\n"
1789
+ '{"state": "review", "handoff": "<note for whoever picks this up next>", '
1790
+ '"outputs": ["<artifact or file you produced>"], "human_required": false}\n'
1791
+ "```\n"
1792
+ 'Use "state": "blocked" with a "blocked_reason" if you cannot proceed, or '
1793
+ '"human_required": true if a human must step in. Requesting "done" only takes '
1794
+ "effect if the work is independently verified (a passing quality gate or a "
1795
+ 'human sign-off); otherwise it is recorded as "review".'
1796
+ )
1797
+
1798
+
1799
+ def render_item_prompt(item: WorkItem, active_decisions: list[str] | None = None) -> str:
1800
+ """Build the deterministic prompt for a board item.
1801
+
1802
+ Reuses the ``_enrich_prompt`` shape (sectioned context, the task labeled
1803
+ "Task:") plus the update-block convention. Decisions + the handoff baton are
1804
+ injected DETERMINISTICALLY here (guaranteed in the prompt), never via ``shared``
1805
+ top-k recall. ``active_decisions`` is the Phase-2 seam (v1 passes ``None``)."""
1806
+ parts: list[str] = []
1807
+ header = "You are working on a tracked work-board item."
1808
+ if item.goal:
1809
+ header += f"\nGoal: {item.goal}"
1810
+ parts.append(header)
1811
+ if active_decisions:
1812
+ parts.append(
1813
+ "Team decisions you MUST respect (do not re-litigate):\n"
1814
+ + "\n".join(f"- {d}" for d in active_decisions)
1815
+ )
1816
+ if item.inputs:
1817
+ parts.append("Inputs / prior artifacts:\n" + "\n".join(f"- {x}" for x in item.inputs))
1818
+ if item.handoff:
1819
+ parts.append(f"Handoff note from prior work on this item:\n{item.handoff}")
1820
+ parts.append(f"Task: {item.task}")
1821
+ parts.append(_UPDATE_INSTRUCTIONS)
1822
+ return "\n\n".join(parts)
1823
+
1824
+
1825
+ def parse_board_update(raw: str) -> dict:
1826
+ """Extract the optional ``syncmill-board`` fenced JSON object from ``raw``.
1827
+
1828
+ Its OWN parser (not a reuse of ``_extract_json_list``) applying the same
1829
+ robustness philosophy as ``DecomposeStrategy._parse_subtasks``: strict,
1830
+ degrade-to-``{}``, NEVER raises. No block, no closing fence, non-JSON, or a
1831
+ non-object → ``{}``. A recognised object is sanitized field-by-field
1832
+ (:func:`_sanitize_update`) — an unknown or malformed field is dropped, not
1833
+ fatal, so one bad field can't discard a valid ``state``."""
1834
+ if not raw:
1835
+ return {}
1836
+ block = _extract_board_block(raw)
1837
+ if block is None:
1838
+ return {}
1839
+ try:
1840
+ parsed = json.loads(block)
1841
+ except (ValueError, TypeError, RecursionError):
1842
+ # RecursionError (deeply-nested untrusted JSON) is a RuntimeError, not a
1843
+ # ValueError — catch it too so the "NEVER raises" contract holds and a
1844
+ # pathological board block degrades to {} like any other malformed input.
1845
+ return {}
1846
+ if not isinstance(parsed, dict):
1847
+ return {}
1848
+ return _sanitize_update(parsed)
1849
+
1850
+
1851
+ def _extract_board_block(raw: str) -> str | None:
1852
+ """The text between a ``` + BOARD_FENCE opening fence and the next ``` fence, or
1853
+ ``None`` if there is no such complete block. Deliberately requires the exact
1854
+ ``BOARD_FENCE`` tag (not a bare ```` ``` ````), so an unrelated code block in
1855
+ the agent's prose is never mistaken for a board update."""
1856
+ lines = raw.splitlines()
1857
+ start = None
1858
+ for i, line in enumerate(lines):
1859
+ s = line.strip()
1860
+ if s.startswith("```") and s[3:].strip() == BOARD_FENCE:
1861
+ start = i + 1
1862
+ break
1863
+ if start is None:
1864
+ return None
1865
+ body: list[str] = []
1866
+ for line in lines[start:]:
1867
+ s = line.strip()
1868
+ # A genuine close fence — NOT a second board OPEN fence. This mirrors
1869
+ # strip_board_block's guard, so the parser and the stripper agree on block
1870
+ # bounds; without it a 2nd ```syncmill-board would be mis-read as block 1's
1871
+ # close (and the two paths would disagree on what was a board update).
1872
+ if s.startswith("```") and s[3:].strip() != BOARD_FENCE:
1873
+ return "\n".join(body)
1874
+ body.append(line)
1875
+ return None # no closing fence → malformed → degrade
1876
+
1877
+
1878
+ def _sanitize_update(parsed: dict) -> dict:
1879
+ """Keep only the recognised update fields with correct types; drop the rest.
1880
+
1881
+ ``state``/``handoff``/``blocked_reason``: strings. ``human_required``: bool.
1882
+ ``outputs``: a list of non-empty strings, ALL-OR-NOTHING (a mixed/partial list
1883
+ is dropped, never salvaged to a subset — the ``_parse_subtasks`` lesson). The
1884
+ requested ``state`` is NOT validated here (``apply_update`` treats an unknown
1885
+ request as "no request" → review), keeping this a pure shape filter."""
1886
+ out: dict = {}
1887
+ for key in ("state", "handoff", "blocked_reason"):
1888
+ v = parsed.get(key)
1889
+ if isinstance(v, str):
1890
+ out[key] = v
1891
+ if isinstance(parsed.get("human_required"), bool):
1892
+ out["human_required"] = parsed["human_required"]
1893
+ outputs = parsed.get("outputs")
1894
+ if isinstance(outputs, list) and outputs and all(
1895
+ isinstance(s, str) and s.strip() for s in outputs
1896
+ ):
1897
+ out["outputs"] = [s.strip() for s in outputs]
1898
+ return out
1899
+
1900
+
1901
+ def strip_board_block(output: str) -> str:
1902
+ """Return ``output`` with the ``syncmill-board`` fenced block removed.
1903
+
1904
+ Used to build the sanitized ``shared`` summary — the board-internal update
1905
+ instructions/state must not leak into global memtomem knowledge. Mirrors
1906
+ :func:`_extract_board_block`'s framing (the same exact-tag open fence + the next
1907
+ ``` close); a block with no closing fence drops to end-of-output (consistent
1908
+ with parse degrading it)."""
1909
+ if not output:
1910
+ return output
1911
+ lines = output.splitlines()
1912
+ out: list[str] = []
1913
+ i, n = 0, len(lines)
1914
+ while i < n:
1915
+ s = lines[i].strip()
1916
+ if s.startswith("```") and s[3:].strip() == BOARD_FENCE:
1917
+ i += 1
1918
+ while i < n and not lines[i].strip().startswith("```"):
1919
+ i += 1
1920
+ # Consume the stop line as this block's CLOSING fence only if it isn't
1921
+ # itself a NEW board open fence — otherwise leave it for the outer loop
1922
+ # to strip as its own block. Without this, two adjacent board blocks
1923
+ # would mis-consume the second's OPEN fence as the first's close and
1924
+ # leak the second block's JSON into the sanitized share.
1925
+ if i < n:
1926
+ cs = lines[i].strip()
1927
+ if not (cs.startswith("```") and cs[3:].strip() == BOARD_FENCE):
1928
+ i += 1 # ordinary closing fence — drop it too
1929
+ continue
1930
+ out.append(lines[i])
1931
+ i += 1
1932
+ return "\n".join(out).strip()
1933
+
1934
+
1935
+ # ── human-facing rendering (CLI) ─────────────────────────────────────────────
1936
+
1937
+
1938
+ def _short(item_id: str) -> str:
1939
+ return item_id[:8]
1940
+
1941
+
1942
+ def render_board(items: list[WorkItem]) -> str:
1943
+ """One line per item for ``board list`` — id, state, goal, an abbreviated task.
1944
+
1945
+ The id is shown through ``printable_name`` too: listing reads stored rows
1946
+ without the uuid check ``Board.get`` applies, so an LF in one would forge a
1947
+ row."""
1948
+ if not items:
1949
+ return "[syncmill] board is empty"
1950
+ lines = []
1951
+ for it in items:
1952
+ flags = []
1953
+ if it.human_required:
1954
+ flags.append("human-required")
1955
+ if it.depends_on:
1956
+ flags.append(f"deps={len(it.depends_on)}")
1957
+ flag = f" ({', '.join(flags)})" if flags else ""
1958
+ goal = f"[{_p(it.goal)}] " if it.goal else ""
1959
+ task = _p(it.task)
1960
+ task = task if len(task) <= 60 else task[:57] + "..."
1961
+ lines.append(f" {_p(it.id)} {it.state.value:<8} {goal}{task}{flag}")
1962
+ return "\n".join(lines)
1963
+
1964
+
1965
+ def render_item(item: WorkItem, transitions: list[dict] | None = None) -> str:
1966
+ """A detailed multi-line view for ``board show`` / ``board add`` confirmation.
1967
+
1968
+ Every free-text field is one display line through ``printable_name``: tasks,
1969
+ handoffs and outputs come from agents and imports, and an embedded control
1970
+ or newline must neither drive the terminal nor forge a field line."""
1971
+ lines = [
1972
+ f"id: {_p(item.id)}",
1973
+ f"state: {item.state.value}",
1974
+ f"goal: {_p(item.goal) if item.goal else '-'}",
1975
+ f"task: {_p(item.task)}",
1976
+ f"owner: {_p(item.owner_agent) if item.owner_agent else '-'}",
1977
+ ]
1978
+ if item.depends_on:
1979
+ lines.append(f"depends: {', '.join(_p(d) for d in item.depends_on)}")
1980
+ if item.inputs:
1981
+ lines.append(f"inputs: {', '.join(_p(i) for i in item.inputs)}")
1982
+ if item.outputs:
1983
+ lines.append(f"outputs: {', '.join(_p(o) for o in item.outputs)}")
1984
+ if item.handoff:
1985
+ lines.append(f"handoff: {_p(item.handoff)}")
1986
+ if item.human_required:
1987
+ lines.append("human: REQUIRED")
1988
+ if item.blocked_reason:
1989
+ lines.append(f"blocked: {_p(item.blocked_reason)}")
1990
+ if item.evidence:
1991
+ lines.append("evidence:")
1992
+ for e in item.evidence:
1993
+ mark = "pass" if e.passed else "fail"
1994
+ by = f" (by {_p(e.by)})" if e.by else ""
1995
+ lines.append(f" - [{_p(e.kind)}/{mark}] {_p(e.ref)}{by}")
1996
+ if transitions:
1997
+ lines.append("history:")
1998
+ for t in transitions:
1999
+ # Raw audit rows, unlike the item's own WorkState.
2000
+ frm = _p(str(t["from_state"])) if t.get("from_state") else "·"
2001
+ to = _p(str(t["to_state"]))
2002
+ note = f" — {_p(str(t['note']))}" if t.get("note") else ""
2003
+ actor = _p(str(t.get("actor") or "?"))
2004
+ lines.append(f" {frm} → {to} [{actor}]{note}")
2005
+ return "\n".join(lines)
2006
+
2007
+
2008
+ def render_decision(decision: Decision) -> str:
2009
+ """A one-line ``board decide`` confirmation: id, scope, and the decision text,
2010
+ each through ``printable_name`` so none can start a line of its own."""
2011
+ scope = f"[{_p(decision.scope)}] " if decision.scope else "[global] "
2012
+ return f" {_p(decision.id)} {scope}{_p(decision.text)}"
2013
+
2014
+
2015
+ def render_decisions(decisions: list[Decision]) -> str:
2016
+ """The active-decisions view for ``board decisions`` (one line per decision)."""
2017
+ if not decisions:
2018
+ return "[syncmill] no active decisions"
2019
+ return "\n".join(render_decision(d) for d in decisions)
2020
+
2021
+
2022
+ def render_board_stats(stats: BoardStats) -> str:
2023
+ """A human-readable ``board stats`` report (also reused by the gateway tool).
2024
+
2025
+ Counts are always shown; the offending item ids are appended only when non-empty
2026
+ so a healthy board stays terse. The ids come from raw work-item,
2027
+ transition and rollup rows and are shown through ``printable_name``."""
2028
+ lines = [f"items: {stats.total}"]
2029
+ if stats.by_state:
2030
+ breakdown = " ".join(
2031
+ f"{state}={stats.by_state[state]}" for state in sorted(stats.by_state)
2032
+ )
2033
+ lines.append(f" by state: {breakdown}")
2034
+ lines.append(f" reclaimed (stale-lease): {stats.reclaimed_stale}")
2035
+
2036
+ def _signal(label: str, ids: list[str]) -> None:
2037
+ shown = ", ".join(_p(str(i)) for i in ids)
2038
+ lines.append(f" {label}: {len(ids)}" + (f" ({shown})" if ids else ""))
2039
+
2040
+ _signal("redispatched (work redone)", stats.redispatched)
2041
+ _signal("false-done (done, unverified)", stats.false_done)
2042
+ _signal(f"blocked > {stats.stall_seconds}s", stats.blocked_aged)
2043
+ _signal(f"human-required > {stats.stall_seconds}s", stats.human_required_aged)
2044
+ _signal(f"dropped handoffs > {stats.stall_seconds}s", stats.dropped_handoffs)
2045
+ return "\n".join(lines)
2046
+
2047
+
2048
+ def build_board(config: BoardConfig) -> Board | None:
2049
+ """The board described by ``config``, or ``None`` when disabled
2050
+ (``enabled=False``, the default) — so the disabled path never opens a file and
2051
+ plain ``syncmill run`` is unchanged. Mirrors ``build_breaker``/``build_gate``,
2052
+ except the board is LOAD-BEARING (a corrupt db raises :class:`BoardError`, it
2053
+ does not degrade)."""
2054
+ if not config.enabled:
2055
+ return None
2056
+ return Board(
2057
+ config.db_path,
2058
+ lease_timeout_seconds=config.lease_timeout_seconds,
2059
+ stall_seconds=config.stall_seconds,
2060
+ )