agent-home-kit/bin/session-reg.py

292 lines
15 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Append-only registry of live work sessions: open/touch/done/close/pickup; check печатает ДЕЙСТВИЕ при пересечении."""
from __future__ import annotations
import argparse
import datetime as dt
import fcntl
import json
import re
from pathlib import Path
import sys as _sys; _sys.path.insert(0, str(Path(__file__).resolve().parent))
from kit import HOME # корень дома из kit.json (HOME_KIT_ROOT)
EVENTS = HOME / "state" / "sessions" / "events.jsonl"
WORD_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]+")
MSK = dt.timezone(dt.timedelta(hours=3))
LIVE_HOURS = 1.5 # окно «живой» сессии (как было до 05.09)
ORPHAN_SCAN_HOURS = 24 # сироты ищутся в суточном окне: 90 мин их скрывали (замер 05.09: 9 из 12 молчавших без сдачи — старше 90 мин)
# Пороги действия при пересечении — по замеру events.jsonl 05.09.2026 (окно 24 ч, 47 сессий, 116 рабочих пауз до следующего события той же сессии):
# p50 1.1 мин, p90 18.5, p95 26.4 → «активна» = молчит меньше 30 мин (p95 с запасом);
# пауз 60 мин и дольше, после которых сессия всё же вернулась, — 4 из 116 (3.4%) → молчание дольше 60 мин без сдачи = сирота.
# Пересмотр — тем же замером: decisions/2026-09-05-z7-session-pickup.md.
PICKUP_MIN = 30
ORPHAN_MIN = 60
def now() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def parse_ts(value: str) -> dt.datetime:
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed.replace(tzinfo=dt.timezone.utc) if parsed.tzinfo is None else parsed.astimezone(dt.timezone.utc)
def files_arg(value: str | None) -> list[str]:
return list(dict.fromkeys(x.strip() for x in (value or "").split(",") if x.strip()))
def append(kind: str, label: str, **fields: object) -> dict:
rec = {"ts": now().isoformat(timespec="seconds"), "kind": kind, "label": label}
rec.update({k: v for k, v in fields.items() if v not in (None, "", [])})
EVENTS.parent.mkdir(parents=True, exist_ok=True)
with EVENTS.open("a", encoding="utf-8") as out:
fcntl.flock(out, fcntl.LOCK_EX)
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
out.flush()
fcntl.flock(out, fcntl.LOCK_UN)
return rec
def rows() -> list[dict]:
if not EVENTS.exists():
return []
result = []
for line in EVENTS.read_text(encoding="utf-8").splitlines():
try:
row = json.loads(line)
row["_ts"] = parse_ts(row["ts"])
result.append(row)
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
continue
return result
def blank(label: str) -> dict:
return {"label": label, "opened": False, "closed": False, "subject": "", "files": [], "project": "",
"surface": "", "item": "", "done_ts": None, "done_text": "", "picked_by": "", "last": None}
def states() -> dict[str, dict]:
"""Состояние каждой метки по журналу. open дополняет поля, а не стирает их (повторный open лестницы без предмета)."""
state: dict[str, dict] = {}
for row in rows():
label = str(row.get("label", ""))
if not label:
continue
item = state.setdefault(label, blank(label))
kind = row.get("kind")
if kind == "open":
item.update({"opened": True, "closed": False})
for key in ("subject", "project", "files", "surface", "item"):
if row.get(key):
item[key] = row[key]
elif kind == "close":
item["closed"] = True
elif kind == "pickup":
item.update({"closed": True, "picked_by": str(row.get("by", ""))})
elif kind == "done":
if row.get("text"):
item["subject"] = str(row["text"]).split("→")[0].strip()[:60] or item["subject"]
item["done_text"] = str(row["text"])[:120]
if row.get("item"):
item["item"] = str(row["item"])
item["done_ts"] = row["_ts"]
elif kind == "touch":
if row.get("subject") and not item["subject"]:
item["subject"] = str(row["subject"])[:60]
if row.get("item"):
item["item"] = str(row["item"])
item["last"] = row["_ts"]
return state
def alive(state: dict[str, dict], hours: float) -> list[dict]:
cutoff = now() - dt.timedelta(hours=hours)
return sorted((x for x in state.values() if x["opened"] and not x["closed"] and x["last"] and x["last"] >= cutoff),
key=lambda x: x["last"], reverse=True)
def live(hours: float = LIVE_HOURS) -> list[dict]:
return alive(states(), hours)
def age_minutes(item: dict) -> int:
return max(0, int((now() - item["last"]).total_seconds() // 60))
def age_text(item: dict) -> str:
minutes = age_minutes(item)
if minutes < 60:
return f"{minutes} мин"
return f"{minutes // 60} ч {minutes % 60} мин"
# Служебные слова предметов: поверхность, ритуал, метки — не предмет. Без них 05.09 «дубли предметов» = 42 пары из 28 живых
# (замер: общий зачин «Проснись…», токены меток и дат давали ≥2 общих слова почти любой паре).
STOP = {"сессия", "сессии", "сессию", "сессией", "проснись", "метка", "давай", "той", "же", "для", "при", "что", "как", "это",
"маркер", "cowork", "chat", "headless", "mobile", "telegram", "проект", "проекту", "проекте", "задача", "задачи", "задачу",
"ход", "ходы", "шаг", "step", "next", "жду", "готово", "продолжай", "дальше"}
def words(subject: str) -> set[str]:
"""Содержательные слова предмета: ≥3 букв, без цифр (метки, даты, пункты сравниваются полями, не словами), без служебных."""
return {x.lower() for x in WORD_RE.findall(subject)
if len(x) >= 3 and not any(ch.isdigit() for ch in x) and x.lower() not in STOP}
def overlaps(item: dict, subject: str = "", files: list[str] | None = None) -> bool:
same_files = bool(set(files or ()) & set(item.get("files", ())))
same_words = len(words(subject) & words(str(item.get("subject", "")))) >= 2
return same_files or same_words
def tag(item: dict) -> str:
return f"{item['label']} [{item.get('project') or '—'}/{item.get('item') or '—'}]"
def relation(me: dict, other: dict) -> tuple[str, str] | None:
"""(класс, строка) для встречной сессии; None — пересечения нет.
ПОДХВАТ — тот же пункт или предмет; ПАРАЛЛЕЛЬНО — другой пункт того же проекта; СИРОТА — молчит дольше ORPHAN_MIN без сдачи."""
same_item = bool(me.get("item")) and me.get("item") == other.get("item")
same_project = bool(me.get("project")) and str(me.get("project", "")).lower() == str(other.get("project", "")).lower()
same_subject = overlaps(other, me.get("subject", ""), me.get("files"))
if not (same_item or same_project or same_subject):
return None
silent = age_minutes(other)
if silent > ORPHAN_MIN and not other.get("done_ts"):
if not (other.get("item") or same_item or same_subject):
return ("СИРОТА-БЕЗ-ПУНКТА", "")
return ("СИРОТА", f"СИРОТА: {tag(other)} молчит {silent} мин без сдачи → подхватить её пункт и отметить: "
f"python3 bin/session-reg.py pickup {other['label']} --by {me['label']}")
if silent > LIVE_HOURS * 60:
return None
if same_item or same_subject:
if other.get("done_ts"):
last = f"последняя сдача {other['done_ts'].astimezone(MSK):%H:%M} мск «{other['done_text'][:90]}»"
else:
last = f"сдач нет, предмет «{other.get('subject') or '—'}»"
pace = f"активна ({silent} мин)" if silent < PICKUP_MIN else f"пауза {silent} мин (дольше {PICKUP_MIN} — возможно встала)"
return ("ПОДХВАТ", f"ПОДХВАТ: {tag(other)} {pace}; {last} → прочитать её сдачи (events.jsonl), продолжать с её шага, не дублировать")
return ("ПАРАЛЛЕЛЬНО", other.get("item") or "—")
def purge(prefix: str) -> int:
"""Убрать из реестра ПРОБНЫЕ метки (в префиксе обязано быть «probe»): перезапись на месте под flock, инод тот же —
параллельный append дождётся замка и допишет в тот же файл."""
if "probe" not in prefix.lower():
raise SystemExit("purge только для пробных меток: в префиксе должно быть «probe»")
if not EVENTS.exists():
return 0
with EVENTS.open("r+", encoding="utf-8") as stream:
fcntl.flock(stream, fcntl.LOCK_EX)
lines = stream.read().splitlines()
kept = []
for line in lines:
try:
label = str(json.loads(line).get("label", ""))
except (ValueError, TypeError, json.JSONDecodeError):
kept.append(line); continue
if not label.startswith(prefix):
kept.append(line)
stream.seek(0); stream.truncate()
stream.write("".join(x + "\n" for x in kept)); stream.flush()
fcntl.flock(stream, fcntl.LOCK_UN)
return len(lines) - len(kept)
def print_list(hours: float) -> None:
items = live(hours)
print(f"живых сессий: {len(items)}")
for item in items:
subject = item.get("subject") or "без предмета"
names = ",".join(item.get("files", ())) or "—"
proj = f" [{item['project']}/{item.get('item') or '—'}]" if item.get("project") else ""
print(f"{item['label']}{proj}: {age_text(item)}; предмет: {subject}; файлы: {names}")
def print_check(label: str, subject: str = "", files: list[str] | None = None, project: str = "", item: str = "") -> None:
state = states()
me = dict(state.get(label) or blank(label))
for key, value in (("subject", subject), ("files", files or []), ("project", project), ("item", item)):
if value:
me[key] = value
if me.get("picked_by"):
print(f"⚠ эта сессия ({label}) подхвачена {me['picked_by']}: её пункт ведёт другая сессия — согласовать, не дублировать")
live_count = len(alive(state, LIVE_HOURS))
lines, parallel_items, orphans_without_item = [], [], 0
for other in alive(state, ORPHAN_SCAN_HOURS):
if other["label"] == label:
continue
rel = relation(me, other)
if rel is None:
continue
kind, text = rel
if kind == "ПАРАЛЛЕЛЬНО":
parallel_items.append(text)
elif kind == "СИРОТА-БЕЗ-ПУНКТА":
orphans_without_item += 1
else:
lines.append(text)
if parallel_items:
listed = ", ".join(sorted(set(parallel_items)))
lines.append(f"ПАРАЛЛЕЛЬНО: {len(parallel_items)} сессий проекта «{me.get('project') or '—'}» на других пунктах ({listed}) "
f"— общие носители через common-lock")
if orphans_without_item:
lines.append(f"молчат дольше {ORPHAN_MIN} мин без сдачи и без пункта: {orphans_without_item} (подхватывать нечего; список: session-reg.py list --alive-hours 24)")
if not lines:
print(f"пересечений нет; живых сессий {live_count}")
return
print("\n".join(lines))
print(f"живых сессий {live_count}")
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(description=__doc__)
commands = root.add_subparsers(dest="command", required=True)
op = commands.add_parser("open")
op.add_argument("label"); op.add_argument("--surface", default="")
op.add_argument("--subject", default=""); op.add_argument("--files", default=""); op.add_argument("--project", default="")
op.add_argument("--item", default="", help="пункт плана проекта (P-NN)")
touch = commands.add_parser("touch"); touch.add_argument("label"); touch.add_argument("--subject", default=""); touch.add_argument("--item", default="")
done = commands.add_parser("done"); done.add_argument("label"); done.add_argument("text"); done.add_argument("--item", default=None)
commands.add_parser("close").add_argument("label")
pick = commands.add_parser("pickup", help="подхватить сироту: закрыть её в реестре, её пункт — подхватившей")
pick.add_argument("label"); pick.add_argument("--by", required=True)
listing = commands.add_parser("list"); listing.add_argument("--alive-hours", type=float, default=LIVE_HOURS)
commands.add_parser("purge", help="убрать пробные метки по префиксу (в префиксе — «probe»)").add_argument("prefix")
check = commands.add_parser("check"); check.add_argument("label")
check.add_argument("--subject", default=""); check.add_argument("--files", default="")
check.add_argument("--project", default=""); check.add_argument("--item", default="")
return root
def main() -> int:
args = parser().parse_args()
if args.command == "open":
append("open", args.label, surface=args.surface, subject=args.subject, files=files_arg(args.files), project=args.project, item=args.item)
print(f"сессия открыта: {args.label}")
elif args.command == "touch":
append("touch", args.label, subject=args.subject, item=args.item); print(f"сессия отмечена: {args.label}")
elif args.command == "done":
append("done", args.label, text=args.text, item=args.item); print(f"результат записан: {args.label}")
elif args.command == "close":
append("close", args.label); print(f"сессия закрыта: {args.label}")
elif args.command == "pickup":
orphan = states().get(args.label) or blank(args.label)
append("pickup", args.label, by=args.by, item=orphan.get("item"))
if orphan.get("item"):
append("touch", args.by, item=orphan["item"])
print(f"сирота {args.label} подхвачена {args.by}; пункт: {orphan.get('item') or '—'}")
elif args.command == "purge":
print(f"убрано пробных записей: {purge(args.prefix)}")
elif args.command == "list":
print_list(args.alive_hours)
elif args.command == "check":
print_check(args.label, args.subject, files_arg(args.files), args.project, args.item)
return 0
if __name__ == "__main__":
raise SystemExit(main())