Source code for scitex_cards._store_comment
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""``comment_task`` — the card's Issue-activity log (append-only).
Split out of ``_store`` (PURE MOVE — no behaviour change), which re-exports
``comment_task`` so ``from ._store import comment_task`` keeps working. It sits
in its OWN module rather than with the lifecycle verbs because the two together
exceed the 512-line cap: a comment is not a state transition — it is the card's
conversation, and it carries its own fan-out (the ``card-message`` bus dispatch
with the owner / collaborators / subscribers snapshot, PLUS the canonical
``commented`` card-event).
The shared helpers (``_read_write_doc`` / ``_utc_now_iso`` / ``_default_agent``
/ ``TaskNotFoundError``) stay in ``_store`` and are imported inside the function
body — deferred, because ``_store`` imports this module at module level to
re-export the verb and a top-level import back would cycle.
"""
from __future__ import annotations
from pathlib import Path
from ._comment_ids import stamp_comment_id
from ._store_events import _emit_card_event
from ._store_list import _resolved_store
[docs]
def comment_task(
store: str | Path | None = None,
task_id: str | None = None,
text: str | None = None,
by: str | None = None,
kind: str | None = None,
entry_points=None,
) -> dict:
"""Append an entry to ``task.comments[]`` (the established Issue-
activity-log shape from skill 30, Gitea-compatible field).
`by` overrides the $SCITEX_CARDS_AGENT_ID → $USER precedence used by
add_task / complete_task.
`kind` is an optional feedback-ring / event tag (e.g. ``push`` /
``done`` / ``card-message``) stamped onto the entry so the board can
render "how the card was routed" (operator 2026-06-17). Lenient: the
model only requires ``text``, so the extra key round-trips cleanly.
`entry_points` is forwarded to :func:`scitex_cards._hooks.dispatch_event`
for the ``card-message`` bus emit below: an explicit iterable of
entry-point-shaped objects to receive the event instead of the ones
discovered from packaging metadata. ``None`` (the default) uses the
real installed plugins. This is the in-process injection seam used by
in-process consumers and by no-mock tests (PA-306-compliant) that
observe the emitted event via a real fake handler.
"""
from . import _model, _task
from ._store import TaskNotFoundError, _default_agent, _read_write_doc, _utc_now_iso
tasks_path = _resolved_store(store)
if not task_id:
raise ValueError("comment_task: 'task_id' is required")
if not text or not str(text).strip():
raise ValueError("comment_task: 'text' is required")
author = _default_agent(by)
entry = stamp_comment_id(
{
"author": author,
"ts": _utc_now_iso(),
"text": str(text),
}
)
if kind:
entry["kind"] = str(kind)
with _model._store_lock(tasks_path):
doc, tasks = _read_write_doc(tasks_path)
# See `_task._is_tombstoned`: a deleted card's row is retained
# forever but must behave as ABSENT here.
target = _task._find_live_task(tasks, task_id)
if target is None:
raise TaskNotFoundError(f"task id {task_id!r} not found in {tasks_path}")
comments = target.setdefault("comments", [])
# Pre-append snapshot of comment authors — forms the
# `collaborators` list of the card-message event below.
prior_authors = [
c.get("author")
for c in comments
if isinstance(c, dict) and isinstance(c.get("author"), str)
]
comments.append(entry)
# A comment IS activity. Without this stamp, an actively-discussed
# card reads as "untouched" to every staleness signal (idle_guard,
# list-stale, digests) — found 2026-07-10 when the idle guard kept
# flagging a card that had received progress comments minutes earlier.
target["last_activity"] = entry["ts"]
# NAMES THE ONE CARD IT TOUCHED. Without this the write re-asserts every
# card in the document as it looked at THIS function's read time, so a
# comment on card A silently reverts another agent's committed change to
# card B — measured on the live board 2026-08-10, three times, once a
# confirmed loss of a completion that had reported success.
#
# comment_task is the first verb converted because it is the one that was
# measured, and because a comment is the most obviously-single-card write
# in the package: appending to one card's activity log cannot legitimately
# rewrite anything else.
_model._save_doc_unlocked(
doc, tasks_path, tasks=tasks, touched_ids=[task_id]
)
owner = target.get("agent") or target.get("assignee")
# Persistent role lists (ADR-0009) — captured under the lock so
# the bus emit below works off a consistent snapshot.
persistent_collaborators = [
c for c in (target.get("collaborators") or []) if isinstance(c, str) and c
]
persistent_subscribers = [
s for s in (target.get("subscribers") or []) if isinstance(s, str) and s
]
# card-message bus emit (lead a2a `1e8e33d0`, 2026-06-14) — done
# OUTSIDE the file lock so a slow bus handler can't extend the
# lock-hold and starve other writers. Comment is already on disk;
# bus errors are caught + logged so an external handler failure
# (e.g. SAC unreachable) never bubbles up to the producer.
try:
from . import _hooks
collaborators: list[str] = []
seen: set[str] = set()
if owner:
seen.add(owner)
seen.add(author)
for a in list(prior_authors) + persistent_collaborators:
if a and a not in seen:
collaborators.append(a)
seen.add(a)
# Effective notify list (ADR-0009): the card's explicit
# subscribers if any, else default to owner + collaborators.
# P2's consumer fans the card-message to these. (Creator-auto-
# subscribe is a later phase — needs an author param on add_task.)
subscribers: list[str] = []
sub_seen: set[str] = set()
candidate_subs = persistent_subscribers or (
([owner] if owner else []) + collaborators
)
for s in candidate_subs:
if s and s not in sub_seen:
subscribers.append(s)
sub_seen.add(s)
_hooks.dispatch_event(
{
"kind": "card-message",
"card_id": task_id,
"author": author,
"body": str(text),
"owner": owner,
"collaborators": collaborators,
"subscribers": subscribers,
"created_at": entry["ts"],
},
entry_points=entry_points,
)
except Exception: # noqa: BLE001 — bus must not break comment_task
import logging
logging.getLogger(__name__).warning(
"comment_task: card-message bus dispatch failed for %r",
task_id,
exc_info=True,
)
# C5: ALSO emit the canonical `commented` card-event — the foundation
# path, IN ADDITION to the legacy `card-message` dispatch above (NOT a
# replacement; any double-notify is C4's dedup concern). Fail-soft,
# post-persist; reuses the comment's own ts so a downstream timeline
# can correlate. `extra` carries the comment body (no stable comment-
# id exists on the entry shape, so body is the available payload).
# (hook-bypass: line-limit)
_emit_card_event(
"commented",
task_id,
actor=author,
ts=entry["ts"],
extra={"body": entry["text"]},
store=tasks_path, # hook-bypass: line-limit
entry_points=entry_points,
)
# Liveness heartbeat: the comment author just touched the store.
# Fail-soft; reuses the already-resolved actor (no second identity path).
from ._liveness import _heartbeat
_heartbeat(author, tasks_path)
return {"task_id": task_id, "comment": entry}
__all__ = ["comment_task"]
# EOF