syncmill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/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
|
+
)
|