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 (``_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.
THE READ AND THE WRITE ARE ONE CARD EACH (``_store_single_card``), not the
whole-document read-modify-write the other verbs still run. See that module
for the measurement; the cycle here is read one row -> append -> compare-and-
set one row, retried on a lost race.
"""
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 _default_agent, _task_not_found, _utc_now_iso
from ._store_errors import RevisionConflictError
from ._store_single_card import (
CAS_ATTEMPTS,
read_card_or_raise,
write_card_or_raise,
)
from ._store_target import resolve_store_target
tasks_path = _resolved_store(store)
# THE STORE THE ROW LIVES IN, not the local sidecar path above. This is the
# identity the whole-document read and write both keyed on (both resolved
# it themselves and ignored ``store``); the one-card path names it once.
target = resolve_store_target(None)
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):
# ONE CARD IN, ONE CARD OUT. This verb used to run the whole-document
# read-modify-write cycle: export every card (plus a COUNT(*)
# cross-check), append to one, write the document back naming the one
# it touched. Measured 2026-09-02 on the live primary (6,542 cards):
# 2.7 s per comment, of which the one-row work was 3 ms. The export is
# the right guard for a caller that PRODUCES a whole document; this
# verb never does, so it now reads one row and writes one row through
# the same guards (see _store_single_card).
#
# COMPARE-AND-SET, RETRIED. The row's revision at read time is handed
# back with the write; a card written by another agent in between
# refuses BEFORE anything is dropped, and the loop re-reads and
# re-applies. Appending to an activity log is the one mutation that is
# provably safe to replay, which is why this verb can retry where a
# status change could not.
for attempt in range(1, CAS_ATTEMPTS + 1):
card, revision = read_card_or_raise(target, task_id)
# See `_task._is_tombstoned`: a deleted card's row is retained
# forever but must behave as ABSENT here.
if card is None or _task._is_tombstoned(card):
raise _task_not_found(task_id)
comments = card.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.
card["last_activity"] = entry["ts"]
try:
write_card_or_raise(target, card, expected_revision=revision)
except RevisionConflictError:
if attempt == CAS_ATTEMPTS:
raise
continue
break
owner = card.get("agent") or card.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 (card.get("collaborators") or []) if isinstance(c, str) and c
]
persistent_subscribers = [
s for s in (card.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