Files
creator/backend/kanban.py
2026-07-01 22:01:32 +02:00

749 lines
35 KiB
Python

"""Streaming kanban dataflow for the inventory phase.
Each column is a worker that pulls cards from its input `stage` (the queue), processes up to
KANBAN_BATCH at a time, and advances them to the next stage. Cards: titles → chains → blocks.
Streaming columns run continuously; barrier columns start only at QUIESCENCE of everything before
them (no active worker + empty queues). Verify columns push failures back (rework). The Chain column
re-clusters live: a chain that gains a member is marked dirty and flows back to chain_verify.
Reused from blocks.py (imported lazily-safe — kanban is only imported after blocks is loaded):
embedding clustering, `_pairs_schema`/`_cliques`, `_canonical`, research prompt + file payload.
"""
import asyncio
import json
import uuid
import database as db
import embedding
import blocks
from config import RESEARCH_GRACE, MAX_CONCURRENT_AGENTS_PER_TOPIC
from pipeline import GenContext, run_single_slot, _prompt, _timeout, _log, OK
from textkit import _norm_title, _title, _parse_selection
from jsonio import read_json_file as _json_file
KANBAN_BATCH = 5 # cards a worker pulls per package (micro-batching)
# How many packages ONE worker keeps in flight at once. A worker no longer blocks on a single
# package — it keeps pulling and dispatching until this many run concurrently, so a busy column
# fills the agent slots (the per-topic semaphore is the real cap; over-dispatch just queues cheaply).
WORKER_INFLIGHT = MAX_CONCURRENT_AGENTS_PER_TOPIC
# Stages whose processor mutates shared cross-card state and MUST run one package at a time.
# Online chain-clustering reads the whole universe + membership; parallel packages would race.
_SERIAL_STAGES = {"chain"}
_ID_COL = {"kanban_titles": "title_norm", "kanban_chains": "chain_id", "kanban_blocks": "block_id"}
CHAIN_CAP = 12 # max members per chain — caps the O(n²) pair-verification blow-up
_POLL = 0.3 # seconds between empty-queue polls
# Stage order. A card's `stage` = the column it waits in (its worker's input).
TITLE_STAGES = ["merge", "chain", "chained"] # 'chained' = consumed into a chain
CHAIN_STAGES = ["chain_verify", "naming", "naming_verify", "chain_filter", "filter_verify", "block_assemble"]
BLOCK_STAGES = ["small_blocks", "small_verify", "dependency", "dependency_verify", "main"]
DONE_CHAIN = "done_chain"
DONE_BLOCK = "done_block"
REJECTED = "rejected" # block dropped by filter_verify (off-topic / noise) — terminal, never mirrored
# Predecessor stages for each barrier (must ALL be quiescent before the barrier worker runs).
_BEFORE_CHAIN_FILTER = ["merge", "chain", "chain_verify", "naming", "naming_verify"]
_BEFORE_BLOCK = _BEFORE_CHAIN_FILTER + ["chain_filter", "filter_verify"]
_BEFORE_MAIN = ["small_blocks", "small_verify", "dependency", "dependency_verify"]
class _Flow:
"""Shared runtime state: active-task counters per stage + a wakeup event. `producers` counts the
running research agents (initial + any added live via the generate button); research counts as
done only when ALL producers have finished, so the flow stays awake while extras still search."""
def __init__(self, topic: str, work_dir):
self.topic = topic
self.work_dir = work_dir
self.active: dict[str, int] = {}
self.producers = 1 # the initial research agent
self.research_tag = 0
self.stop = False
self.wake = asyncio.Event()
self.spawn_research = None # set by run_kanban: () → coroutine that adds one more research agent
@property
def research_done(self) -> bool:
return self.producers <= 0
def add_producer(self):
self.producers += 1
self.wake.set()
def done_producer(self):
self.producers -= 1
self.wake.set()
def next_tag(self) -> int:
self.research_tag += 1
return self.research_tag
def enter(self, stage: str):
self.active[stage] = self.active.get(stage, 0) + 1
def leave(self, stage: str):
self.active[stage] = max(0, self.active.get(stage, 0) - 1)
self.wake.set()
def active_in(self, stages) -> bool:
return any(self.active.get(s, 0) > 0 for s in stages)
async def queued_in(self, table: str, stages) -> bool:
return await db.kanban_count(self.topic, table, list(stages)) > 0
# ── Research producer ────────────────────────────────────────────────────────────
async def _ingest_titles(topic: str, text: str) -> int:
"""Parse a reader file into kanban_titles (stage 'merge'). Exact dupes drop on the PK. → new count."""
n, seen = 0, set()
for record in _parse_selection(text).values():
title = _title(record)
norm = _norm_title(title)
if not norm or norm in seen:
continue
seen.add(norm)
parts = [t.strip() for t in record.split("")]
source = parts[2] if len(parts) >= 3 else ""
desc = parts[1] if len(parts) >= 2 else ""
if await db.kanban_add_title(topic, norm, title, source, desc):
n += 1
return n
RESEARCH_RUNTIME = 900 # one research agent, one round, ~15 min hard cap — the tail ingests live while it writes
_POLL_RESEARCH = 3 # seconds between live reads of a running research file
# Live registry of running flows, so the "+ research" button can attach another agent to a live run.
_active_flows: dict[str, "_Flow"] = {}
def _extract_text(raw_line: str) -> str:
"""Best-effort: pull assistant/tool text out of ONE opencode `--format json` event line.
Recursively collects every `text`/`content` string — robust to the exact event schema."""
try:
obj = json.loads(raw_line)
except Exception:
return ""
parts: list[str] = []
def _walk(o):
if isinstance(o, dict):
for k, v in o.items():
if k in ("text", "content") and isinstance(v, str):
parts.append(v)
else:
_walk(v)
elif isinstance(o, list):
for v in o:
_walk(v)
_walk(obj)
return "".join(parts)
async def _research_once(ctx: GenContext, files: dict, q: dict, folder, instructions: str, tag: str, flow: "_Flow"):
"""ONE agent searches the topic; its titles go into the merge queue LIVE. Two sources feed the
ingest: the JSON event stream (on_line → text buffer) AND the file the agent writes — whichever
the agent uses, cards stream in immediately (not only after it finishes)."""
work_dir = files["arbeit"]
caps = "files" if folder else "full"
p = work_dir / f"research-{tag}.md"
p.unlink(missing_ok=True)
stop = asyncio.Event()
buf: list[str] = [] # assistant text streamed live from the JSON events
def _on_line(raw: str): # sync, called per stdout line by the agent runner
if (t := _extract_text(raw)):
buf.append(t)
async def _drain() -> bool: # ingest from BOTH event buffer and file (idempotent, dupes drop on PK)
text = "".join(buf)
if (ft := blocks._file_payload(p)):
text += "\n" + ft
return bool(text) and await _ingest_titles(ctx.topic, text)
async def _tail(): # live-ingest loop while the agent runs
while not stop.is_set():
try:
await asyncio.wait_for(stop.wait(), timeout=_POLL_RESEARCH)
except asyncio.TimeoutError:
pass
if await _drain():
flow.wake.set() # new cards → wake the workers
tail = asyncio.create_task(_tail())
try:
await run_single_slot(
ctx, f"research-{tag}", key=f"blocks-{ctx.topic}-research-{tag}",
prompt=blocks._build_research_prompt(ctx.topic, p, instructions, q["type"], folder),
role="quick", capabilities=caps,
payload=(lambda result, p=p: blocks._file_payload(p)),
timeout=RESEARCH_RUNTIME, on_line=_on_line,
)
finally:
stop.set()
await tail
if await _drain(): # final catch-up
flow.wake.set()
_log(ctx.topic, f"Research {tag}: titles → merge queue")
async def _research(ctx: GenContext, files: dict, q: dict, folder, instructions: str, flow: _Flow):
"""The initial research producer (already counted in flow.producers=1)."""
try:
await _research_once(ctx, files, q, folder, instructions, "1", flow)
finally:
flow.done_producer()
async def _extra_research(ctx: GenContext, files: dict, q: dict, folder, instructions: str, flow: _Flow):
"""One more research agent, added live via the generate button. Keeps the flow awake until done."""
flow.add_producer()
try:
await _research_once(ctx, files, q, folder, instructions, f"x{flow.next_tag()}", flow)
finally:
flow.done_producer()
def add_research_agent(topic: str) -> bool:
"""Attach one more research agent to a running flow. → True if a run was live to attach to."""
flow = _active_flows.get(topic)
if flow is None or flow.stop or flow.spawn_research is None:
return False
asyncio.create_task(flow.spawn_research())
return True
# ── Generic worker loop ────────────────────────────────────────────────────────────
async def _quiescent(flow: _Flow, stages) -> bool:
"""True iff no worker is active in `stages` AND no card is queued in any of them (all tables).
The barrier/exit condition — must include QUEUED cards, not just active workers, or a worker
could exit in a momentary lull while an upstream worker still has work to push down."""
if not stages:
return True
if flow.active_in(stages):
return False
for tb in ("kanban_titles", "kanban_chains", "kanban_blocks"):
if await db.kanban_count(flow.topic, tb, list(stages)):
return False
return True
async def _worker(flow: _Flow, table: str, in_stage: str, process, upstream, *, barrier=False, inflight=WORKER_INFLIGHT):
"""Pull cards from `in_stage`, run `process` — keeping up to `inflight` packages running CONCURRENTLY
so a busy column fills the agent slots instead of doing one package at a time. `upstream` = all stages
before this one. A barrier worker only pulls when `upstream` is fully quiescent. ANY worker exits only
when research is done, its own queue is empty, AND `upstream` is quiescent (nothing can still arrive).
Double-pull safety: each stage has exactly ONE worker, so an in-memory `claimed` set of card-ids (held
while a package runs) is enough to keep concurrent pulls from grabbing the same cards."""
topic = flow.topic
idc = _ID_COL[table]
claimed: set[str] = set()
tasks: set[asyncio.Task] = set()
async def _run(cards):
ids = [c[idc] for c in cards]
flow.enter(in_stage)
try:
await process(cards)
except Exception as e: # one bad package must not kill the worker
_log(topic, f"worker {in_stage}: {type(e).__name__}: {e}")
finally:
flow.leave(in_stage)
for i in ids:
claimed.discard(i)
flow.wake.set()
try:
while not flow.stop:
tasks = {t for t in tasks if not t.done()}
# Fill the pipeline: pull fresh cards and dispatch until `inflight` packages run.
if not barrier or await _quiescent(flow, upstream):
while len(tasks) < inflight:
rows = await db.kanban_pull(topic, table, in_stage, KANBAN_BATCH + len(claimed))
fresh = [r for r in rows if r[idc] not in claimed][:KANBAN_BATCH]
if not fresh:
break
for r in fresh:
claimed.add(r[idc])
tasks.add(asyncio.create_task(_run(list(fresh))))
if tasks: # busy → wait for a package to finish, then refill
await asyncio.wait(tasks, timeout=_POLL, return_when=asyncio.FIRST_COMPLETED)
continue
# idle: nothing in flight and nothing pulled
up_quiet = await _quiescent(flow, upstream)
if (flow.research_done and up_quiet and not flow.active_in([in_stage])
and await db.kanban_count(topic, table, in_stage) == 0):
return # nothing left and nothing upstream can produce
await _sleep_wake(flow)
finally:
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def _sleep_wake(flow: _Flow):
try:
await asyncio.wait_for(flow.wake.wait(), timeout=_POLL)
except asyncio.TimeoutError:
pass
flow.wake.clear()
# ── Column processors ──────────────────────────────────────────────────────────────
async def _proc_merge(flow: _Flow, cards):
"""Exact dedup happened at ingest (PK). Merge just advances titles to the chain column."""
for c in cards:
await db.kanban_advance(flow.topic, "kanban_titles", c["title_norm"], "chain")
flow.wake.set()
async def _proc_chain(flow: _Flow, cards):
"""Embedding blocking: for each new title, find the most similar existing title (cosine ≥ floor).
Join its chain (or open a new one), mark the chain dirty → chain_verify. Live-growing clusters."""
topic = flow.topic
by_norm = await db.kanban_titles_by_norm(topic)
# Universe = titles already chained + the new batch (for nearest-neighbour search).
universe = [nm for nm, r in by_norm.items() if r["stage"] in ("chain", "chained")]
if len(universe) < 1:
return
texts = [f"{by_norm[nm]['title']}{by_norm[nm]['content']}" if by_norm[nm]["content"] else by_norm[nm]["title"]
for nm in universe]
sims = await asyncio.to_thread(embedding.embed_sims, texts) if (
embedding and await asyncio.to_thread(embedding.available)) else None
idx = {nm: i for i, nm in enumerate(universe)}
# existing membership
member_chain = {}
for nm in universe:
cid = await _chain_of(topic, nm)
if cid:
member_chain[nm] = cid
touched = set()
for c in cards:
nm = c["title_norm"]
target = None
if sims is not None and nm in idx:
best, bestcos = None, blocks.DEDUP_PAIR_FLOOR
for other in universe:
if other == nm or other not in member_chain and other not in idx:
continue
cos = float(sims[idx[nm]][idx[other]]) if other in idx else -1
if cos >= bestcos and other != nm:
best, bestcos = other, cos
if best is not None:
target = member_chain.get(best)
cid = target or f"c-{uuid.uuid4().hex[:12]}"
members = set(await db.kanban_chain_members(topic, cid))
if target and len(members) >= CHAIN_CAP: # neighbour's chain is full → start a fresh chain
cid = f"c-{uuid.uuid4().hex[:12]}"
members = set()
members.add(nm)
await db.kanban_set_chain_members(topic, cid, sorted(members))
await db.kanban_upsert_chain(topic, cid, "chain_verify", dirty=1)
member_chain[nm] = cid
await db.kanban_advance(topic, "kanban_titles", nm, "chained")
touched.add(cid)
flow.wake.set()
async def _chain_of(topic: str, title_norm: str) -> str | None:
return await db.kanban_member_chain(topic, title_norm)
async def _members_dicts(topic: str, members: list[str]) -> list[dict]:
by = await db.kanban_titles_by_norm(topic)
return [by[m] for m in members if m in by]
async def _proc_chain_verify(ctx: GenContext, flow: _Flow, cards):
"""Pairwise-verify the batch's chains IN PARALLEL (one agent per chain). Failures split off."""
await asyncio.gather(*[_verify_one(ctx, flow, c) for c in cards], return_exceptions=True)
flow.wake.set()
async def _verify_one(ctx: GenContext, flow: _Flow, c):
topic = flow.topic
cid = c["chain_id"]
members = await db.kanban_chain_members(topic, cid)
dicts = await _members_dicts(topic, members)
if len(dicts) <= 1:
await db.kanban_upsert_chain(topic, cid, "naming", dirty=0)
return
# Only the embedding-NEAR candidate pairs (cosine ≥ floor) — NOT all O(n²) pairs. A 12-member
# chain shrinks from 66 pairs to a handful. Transitivity (connected components) does the rest.
nm = [d["title_norm"] for d in dicts]
texts = [f"{d['title']}{d['content']}" if d["content"] else d["title"] for d in dicts]
sims = await asyncio.to_thread(embedding.embed_sims, texts) if (
embedding and await asyncio.to_thread(embedding.available)) else None
if sims is not None:
pairs = [(nm[i], nm[j]) for i in range(len(nm)) for j in range(i + 1, len(nm))
if float(sims[i][j]) >= blocks.DEDUP_PAIR_FLOOR]
else:
pairs = [(a, b) for x, a in enumerate(members) for b in members[x + 1:]]
keep_edges = await _verify_pairs(ctx, flow.work_dir, topic, cid, dicts, pairs)
groups = _components(members, keep_edges) # transitive groups over confirmed near-pairs
groups.sort(key=len, reverse=True)
main = groups[0] if groups else members
await db.kanban_set_chain_members(topic, cid, sorted(main))
await db.kanban_upsert_chain(topic, cid, "naming", dirty=0)
for g in groups[1:]: # the rest split into fresh chains
ncid = f"c-{uuid.uuid4().hex[:12]}"
await db.kanban_set_chain_members(topic, ncid, sorted(g))
await db.kanban_upsert_chain(topic, ncid, "naming", dirty=0)
def _components(members: list[str], edges) -> list[list[str]]:
"""Connected components (union-find) over confirmed pairs. Members without an edge stay alone."""
parent = {m: m for m in members}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for a, b in edges:
if a in parent and b in parent:
parent[find(a)] = find(b)
comp: dict[str, list[str]] = {}
for m in members:
comp.setdefault(find(m), []).append(m)
return list(comp.values())
async def _verify_pairs(ctx, work_dir, topic, cid, dicts, pairs):
"""Judge the candidate pairs in DEDUP_PAIRS_CHUNK packages, all packages IN PARALLEL → confirmed edges."""
by = {d["title_norm"]: d for d in dicts}
chunks = [pairs[k:k + blocks.DEDUP_PAIRS_CHUNK] for k in range(0, len(pairs), blocks.DEDUP_PAIRS_CHUNK)]
async def _chunk(ci, chunk):
path = work_dir / f"verify-{cid}-{ci}.json"
lines = "\n\n".join(
f"{j + 1}.\nA: {by[a]['title']}{by[a]['content']}\nB: {by[b]['title']}{by[b]['content']}"
for j, (a, b) in enumerate(chunk))
await run_single_slot(
ctx, f"Chain verify {cid}", key=f"blocks-{topic}-verify-{cid}-{ci}",
prompt=_prompt("Blocks-Paar-Filter", topic=topic, pairs=lines, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: blocks._pairs_schema(_json_file(p)),
timeout=_timeout("selection_mapping", len(chunk)))
verdict = blocks._pairs_schema(_json_file(path)) or {}
return [(a, b) for j, (a, b) in enumerate(chunk) if verdict.get(j + 1)]
results = await asyncio.gather(*[_chunk(ci, ch) for ci, ch in enumerate(chunks)], return_exceptions=True)
return [e for r in results if isinstance(r, list) for e in r]
async def _proc_naming(ctx: GenContext, flow: _Flow, cards):
"""Pick the best member title per chain — batch runs IN PARALLEL (one agent per chain)."""
await asyncio.gather(*[_name_one(ctx, flow, c) for c in cards], return_exceptions=True)
flow.wake.set()
async def _name_one(ctx: GenContext, flow: _Flow, c):
topic = flow.topic
cid = c["chain_id"]
members = await db.kanban_chain_members(topic, cid)
dicts = await _members_dicts(topic, members)
if len(dicts) <= 1:
await db.kanban_upsert_chain(topic, cid, "naming_verify",
main_title_norm=(members[0] if members else None), dirty=0)
return
winner = await _choose_title(ctx, flow.work_dir, topic, cid, members, dicts, "Blocks-Naming")
await db.kanban_upsert_chain(topic, cid, "naming_verify", main_title_norm=winner, dirty=0)
async def _proc_naming_verify(ctx: GenContext, flow: _Flow, cards):
"""Second judge checks each title — batch runs IN PARALLEL."""
await asyncio.gather(*[_namecheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
flow.wake.set()
async def _namecheck_one(ctx: GenContext, flow: _Flow, c):
topic = flow.topic
cid = c["chain_id"]
members = await db.kanban_chain_members(topic, cid)
dicts = await _members_dicts(topic, members)
winner = c.get("main_title_norm") or (members[0] if members else None)
if len(dicts) > 1:
winner = await _choose_title(ctx, flow.work_dir, topic, cid, members, dicts, "Blocks-Naming-Check",
current=(members.index(winner) + 1 if winner in members else 1))
await db.kanban_upsert_chain(topic, cid, "chain_filter", main_title_norm=winner, dirty=0)
async def _choose_title(ctx, work_dir, topic, cid, members, dicts, template, current=None):
by = {d["title_norm"]: d for d in dicts}
path = work_dir / f"naming-{cid}.json"
lines = "\n".join(f"{k + 1}. {by[m]['title']}{by[m]['content']}" for k, m in enumerate(members) if m in by)
kw = dict(topic=topic, members=lines, out_path=path)
if current is not None:
kw["current"] = current
await run_single_slot(
ctx, f"Naming {cid}", key=f"blocks-{topic}-naming-{cid}",
prompt=_prompt(template, **kw), role="judge", capabilities="files",
payload=lambda result, p=path: blocks._naming_schema(_json_file(p), len(members)),
timeout=_timeout("selection_mapping", len(members)))
best = blocks._naming_schema(_json_file(path), len(members))
if best is None:
rep = blocks._canonical(dicts, list(range(len(dicts))), set())
w = _norm_title(rep["title"])
return w if w in members else members[0]
return members[best - 1]
async def _proc_chain_filter(flow: _Flow, cards):
"""BARRIER. Reduce each chain to its winner → upsert a block (chain_id stable). → filter_verify."""
topic = flow.topic
by = await db.kanban_titles_by_norm(topic)
for c in cards:
cid = c["chain_id"]
members = await db.kanban_chain_members(topic, cid)
winner = c.get("main_title_norm") if c.get("main_title_norm") in members else (members[0] if members else None)
if not winner or winner not in by:
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
continue
w = by[winner]
await db.kanban_upsert_block(topic, f"b-{cid}", cid, w["title"], w["source"], w["content"], stage="filter_verify_b")
await db.kanban_upsert_chain(topic, cid, "filter_verify", dirty=0)
flow.wake.set()
async def _proc_filter_verify(ctx: GenContext, flow: _Flow, cards):
"""An agent confirms each reduced block is a valid, on-topic, self-contained concept. Off-topic /
noise / empty blocks are dropped (→ REJECTED, chain done). IN PARALLEL (one agent per block)."""
await asyncio.gather(*[_filtercheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
flow.wake.set()
async def _filtercheck_one(ctx: GenContext, flow: _Flow, c):
topic = flow.topic
cid = c["chain_id"]
bid = f"b-{cid}"
by = await db.kanban_titles_by_norm(topic)
members = await db.kanban_chain_members(topic, cid)
winner = c.get("main_title_norm") if c.get("main_title_norm") in by else (members[0] if members else None)
async def _drop():
await db.kanban_advance(topic, "kanban_blocks", bid, REJECTED)
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
async def _pass():
await db.kanban_advance(topic, "kanban_blocks", bid, "block_assemble_b")
await db.kanban_upsert_chain(topic, cid, "block_assemble", dirty=0)
if not winner or winner not in by: # nothing to verify → drop the empty chain
await _drop()
return
w = by[winner]
path = flow.work_dir / f"filtercheck-{cid}.json"
await run_single_slot(
ctx, f"Filter verify {cid}", key=f"blocks-{topic}-verify-filter-{cid}",
prompt=_prompt("Blocks-Filter-Check", topic=topic, title=w["title"], content=w["content"], out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _keep_schema(_json_file(p)),
timeout=_timeout("selection_mapping", 1))
keep = _keep_schema(_json_file(path))
await (_drop() if keep is False else _pass()) # None (parse fail) → keep, conservative
async def _proc_block(flow: _Flow, cards):
"""BARRIER. Assemble the final block row → small_blocks. (chain card consumed → done.)"""
topic = flow.topic
for c in cards:
cid = c["chain_id"]
await db.kanban_advance(topic, "kanban_blocks", f"b-{cid}", "small_blocks")
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
flow.wake.set()
def _small_schema(data, count):
"""{"small": {"1": true, ...}} → {block_index: bool} · else None."""
if not isinstance(data, dict) or not isinstance(data.get("small"), dict):
return None
out = {}
for k, v in data["small"].items():
try:
n = int(k)
except (ValueError, TypeError):
continue
if 1 <= n <= count:
out[n] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
return out or None
def _keep_schema(data):
"""{"keep": true/false} → bool · None when absent/unparseable (caller keeps on None, conservative)."""
if not isinstance(data, dict) or "keep" not in data:
return None
return str(data["keep"]).strip().casefold() in ("true", "ja", "yes", "1")
def _dep_schema(data, count):
"""{"parent": N} → 0..count (0 = standalone) · else None."""
if not isinstance(data, dict):
return None
try:
n = int(data.get("parent"))
except (ValueError, TypeError):
return None
return n if 0 <= n <= count else None
async def _proc_small(ctx: GenContext, flow: _Flow, cards):
"""Judge marks fragment-like blocks (batch). → small_verify."""
topic = flow.topic
path = flow.work_dir / f"small-{cards[0]['block_id']}.json"
lines = "\n".join(f"{i + 1}. {c['title']}{c['content']}" for i, c in enumerate(cards))
await run_single_slot(
ctx, "Small blocks", key=f"blocks-{topic}-small-{cards[0]['block_id']}",
prompt=_prompt("Blocks-Small", topic=topic, blocks=lines, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _small_schema(_json_file(p), len(cards)),
timeout=_timeout("selection_mapping", len(cards)))
verdict = _small_schema(_json_file(path), len(cards)) or {}
for i, c in enumerate(cards):
is_small = 1 if verdict.get(i + 1) else 0
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
stage="small_verify", is_small=is_small, parent_block_id=c.get("parent_block_id"))
flow.wake.set()
async def _proc_small_verify(ctx: GenContext, flow: _Flow, cards):
"""Second judge re-checks the small flag (consensus): a block stays `small` only if it was marked
small AND this judge also calls it a fragment. Disagreement → keep as a main block (conservative).
→ dependency."""
topic = flow.topic
path = flow.work_dir / f"smallcheck-{cards[0]['block_id']}.json"
lines = "\n".join(f"{i + 1}. {c['title']}{c['content']}" for i, c in enumerate(cards))
await run_single_slot(
ctx, "Small verify", key=f"blocks-{topic}-verify-small-{cards[0]['block_id']}",
prompt=_prompt("Blocks-Small", topic=topic, blocks=lines, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _small_schema(_json_file(p), len(cards)),
timeout=_timeout("selection_mapping", len(cards)))
verdict = _small_schema(_json_file(path), len(cards)) or {}
for i, c in enumerate(cards):
is_small = 1 if (c["is_small"] and verdict.get(i + 1)) else 0
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
stage="dependency", is_small=is_small, parent_block_id=c.get("parent_block_id"))
flow.wake.set()
async def _proc_dependency(ctx: GenContext, flow: _Flow, cards):
"""For each SMALL block, a judge picks its parent from the full list — batch runs IN PARALLEL."""
topic = flow.topic
parents = [b for b in await db.kanban_all_blocks(topic) if not b["is_small"] and b["stage"] != REJECTED]
plist = "\n".join(f"{i + 1}. {b['title']}" for i, b in enumerate(parents))
await asyncio.gather(*[_dep_one(ctx, flow, c, parents, plist) for c in cards], return_exceptions=True)
flow.wake.set()
async def _dep_one(ctx: GenContext, flow: _Flow, c, parents, plist):
topic = flow.topic
if not c["is_small"] or not parents:
await db.kanban_advance(topic, "kanban_blocks", c["block_id"], "dependency_verify")
return
path = flow.work_dir / f"dep-{c['block_id']}.json"
await run_single_slot(
ctx, "Dependency", key=f"blocks-{topic}-dep-{c['block_id']}",
prompt=_prompt("Blocks-Dependency", topic=topic,
small=f"{c['title']}{c['content']}", parents=plist, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _dep_schema(_json_file(p), len(parents)),
timeout=_timeout("selection_mapping", len(parents)))
pick = _dep_schema(_json_file(path), len(parents))
parent_id = parents[pick - 1]["block_id"] if pick else None
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
stage="dependency_verify", is_small=c["is_small"], parent_block_id=parent_id)
async def _proc_dependency_verify(flow: _Flow, cards):
"""A small block without a parent is demarked → becomes a main block. → main."""
topic = flow.topic
for c in cards:
is_small = c["is_small"]
if is_small and not c.get("parent_block_id"):
is_small = 0
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
stage="main", is_small=is_small, parent_block_id=c.get("parent_block_id"))
flow.wake.set()
async def _proc_main(flow: _Flow, cards):
"""BARRIER. Finalize non-small blocks → mirror into the legacy `blocks` table as consensus."""
topic = flow.topic
for c in cards:
if not c["is_small"]:
norm = _norm_title(c["title"])
await db.upsert_block(topic, norm, c["title"], c["content"], [c["source"]] if c["source"] else [])
await db.set_block_status(topic, norm, "consensus")
await db.kanban_advance(topic, "kanban_blocks", c["block_id"], DONE_BLOCK)
flow.wake.set()
# ── Orchestration ──────────────────────────────────────────────────────────────────
async def run_kanban(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str,
research: bool = True) -> bool:
"""Run the streaming inventory. Returns True when the whole flow reaches quiescence at 'main'.
research=False ("Continue"): process the EXISTING queue without searching new titles. No initial
research producer, producers=0 → research_done is true at once; workers drain the queue and exit.
The +Research button can still attach an agent later via flow.spawn_research."""
topic = ctx.topic
flow = _Flow(topic, files["arbeit"])
flow.spawn_research = lambda: _extra_research(ctx, files, q, folder, instructions, flow)
if not research:
flow.producers = 0 # continue the existing queue, search no new titles
_active_flows[topic] = flow
set_p("Kanban inventory…")
ORDER = ["merge", "chain", "chain_verify", "naming", "naming_verify", "chain_filter",
"filter_verify", "block_assemble", "small_blocks", "small_verify",
"dependency", "dependency_verify", "main"]
up = {s: ORDER[:i] for i, s in enumerate(ORDER)} # upstream = all stages before this one
barriers = {"chain_filter", "block_assemble", "main"}
specs = [
("kanban_titles", "merge", lambda cs: _proc_merge(flow, cs)),
("kanban_titles", "chain", lambda cs: _proc_chain(flow, cs)),
("kanban_chains", "chain_verify", lambda cs: _proc_chain_verify(ctx, flow, cs)),
("kanban_chains", "naming", lambda cs: _proc_naming(ctx, flow, cs)),
("kanban_chains", "naming_verify", lambda cs: _proc_naming_verify(ctx, flow, cs)),
("kanban_chains", "chain_filter", lambda cs: _proc_chain_filter(flow, cs)),
("kanban_chains", "filter_verify", lambda cs: _proc_filter_verify(ctx, flow, cs)),
("kanban_chains", "block_assemble", lambda cs: _proc_block(flow, cs)),
("kanban_blocks", "small_blocks", lambda cs: _proc_small(ctx, flow, cs)),
("kanban_blocks", "small_verify", lambda cs: _proc_small_verify(ctx, flow, cs)),
("kanban_blocks", "dependency", lambda cs: _proc_dependency(ctx, flow, cs)),
("kanban_blocks", "dependency_verify", lambda cs: _proc_dependency_verify(flow, cs)),
("kanban_blocks", "main", lambda cs: _proc_main(flow, cs)),
]
workers = [_research(ctx, files, q, folder, instructions, flow)] if research else []
for table, stage, proc in specs:
workers.append(_worker(flow, table, stage, proc, up[stage], barrier=(stage in barriers),
inflight=(1 if stage in _SERIAL_STAGES else WORKER_INFLIGHT)))
progress = asyncio.create_task(_progress(flow, set_p))
try:
await asyncio.gather(*workers, return_exceptions=True)
finally:
flow.stop = True
progress.cancel()
_active_flows.pop(topic, None)
if ctx.is_cancelled():
return False
n = await db.kanban_count(topic, "kanban_blocks", DONE_BLOCK)
_log(topic, f"Kanban: done — {n} blocks finalized")
return True
async def _progress(flow: _Flow, set_p):
while not flow.stop:
try:
counts = await db.kanban_stage_counts(flow.topic)
total = sum(counts.values())
set_p(f"Kanban: {total} cards in flow")
except Exception:
pass
await asyncio.sleep(1.0)