Source code for scitex_cards._task

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Store constants, error types, and the ``Task`` model.

The BASE module of the ``_model`` split (see GITIGNORED/REFACTORING.md):
it imports nothing else from this package, so ``_deadlines`` / ``_validate``
/ ``_model`` can all import from it without a cycle. Pure move.
"""

from __future__ import annotations

# Valid task statuses. ``goal`` marks a north-star objective (rendered gold);
# the rest are ordinary execution states.
# ``pending`` was ABOLISHED 2026-07-10 by operator directive: "pending という
# タスクがある。存在してはならない状態である。" A card in ``pending`` carried NO
# decision — it was the dataclass default, so 406 of 1100 cards (37%) had silently
# accumulated there and rotted. Every open card must now state its disposition:
# ``in_progress`` (being worked), ``blocked`` (+ a blocker naming the gate),
# or ``deferred`` (can be worked, consciously not now).
ABOLISHED_STATUSES: dict[str, str] = {
    "pending": (
        "status 'pending' was abolished 2026-07-10 — a card must carry a "
        "decision. Choose: 'in_progress' if you are working it, 'blocked' "
        "with a blocker naming the gate, or 'deferred' if it can wait."
    ),
}

VALID_STATUSES: tuple[str, ...] = (  # hook-bypass: line-limit
    "goal",
    "in_progress",
    "blocked",
    "done",
    "deferred",
    "failed",
    # ``cancelled`` = GitHub "closed as not planned": a TERMINAL/closed
    # state distinct from ``done`` (completed successfully) and ``failed``
    # (attempted, did not succeed). A cancelled card is CLOSED — it drops
    # out of every open/actionable/stale/backlog view exactly like ``done``
    # (see TERMINAL_STATUSES in _throughput.py, the is_overdue closed-set
    # below, and _LIVENESS_NONRUNNABLE in handlers/graph.py). It does NOT
    # satisfy a dependency: a cancelled upstream leaves dependents blocked,
    # so RESOLVED_STATUSES in _runnable.py stays {"done", "goal"}.
    "cancelled",
)

# Valid task kinds — north-star pillars #1 (compute state) + #4 (operator
# pain "where am I the blocker"). A row with ``kind: compute`` represents
# an external compute job whose status is updated by an automated writer
# (see tasks/proj-scitex-cards-compute-state-deps/README.md). A row with
# ``kind: decision`` represents an operator/agent decision that other tasks
# can ``depends_on`` — when the decision-node's status flips to ``done``
# (the decision is made) the dependents auto-unblock via the existing dep-
# graph wire (no new machinery; the per-task adr.md is its body, 1:1).
# Other tasks use ``kind: task`` (the default, can be omitted). Extensible
# to ``"ci"`` etc. when task #15 wires GH-Actions rows.
#
# Closed validated set — fail-loud on unknown values per ADR-0002
# (a2a `2c7a431d`) and ADR-0003 (this PR; extending to "decision").
VALID_KINDS: tuple[str, ...] = (
    "task",
    "compute",
    "decision",
    # ``status`` — a non-actionable status-tracking card (e.g. the q-*
    # quality-CI status rows, one per fleet package). Carries one-liner
    # status notes (audit-debt counts, green flags) rather than a real
    # Card body. Per board card ``scitex-cards-relocate-q-status-tracking``
    # + lead a2a ``60a1a93d`` (operator direction): proceeding with
    # option (b) — keep the rows on the board but mark them with this
    # axis so the board's filter UI (separate frontend PR) can hide them
    # from the actionable default lens. ORTHOGONAL to ``blocker`` /
    # ``status`` (the row-status enum); the validator does NOT cross-imply
    # any compute-field constraints — ``kind: status`` is just a flag.
    "status",
)


# Valid `blocker` values — operator TG 9522 + 9524, lead a2a
# `4691b114` / `c839c59b` / `2bd37bd2` / `554435df`. The operator's exact
# pain: "I cannot tell what is waiting on ME." A blocked task can be stuck
# on different things; each gets a different signal on the board.
#
# Operator's enumeration (verbatim, TG 9524):
#   compute            (計算リソース)      — waiting on a kind=compute row to finish
#   dep                (依存)              — waiting on another task (explicit form of the implicit  # noqa: E501
#                                            dep-edge case; useful when the dep is the *concept*  # noqa: E501
#                                            even if no edge id is known yet)
#   operator-decision  (ユーザー判断)      — waiting on the operator to decide; this is the LOUD  # noqa: E501
#                                            variant the operator opens the UI to find. Usually  # noqa: E501
#                                            paired with kind=decision rows but the enums are  # noqa: E501
#                                            ORTHOGONAL (a kind=task can also be blocker=  # noqa: E501
#                                            operator-decision if it's waiting on a decision that  # noqa: E501
#                                            hasn't been promoted to its own kind=decision node  # noqa: E501
#                                            yet).
#   agent-wait         (他エージェント待ち) — waiting on a specific agent action (e.g. "lead to  # noqa: E501
#                                            write the ADR-0007 entry"). Distinct from `dep`  # noqa: E501
#                                            because the blocker is a *human/agent action*, not  # noqa: E501
#                                            a graph-edge dep.
#
# Closed validated set per ADR-0004 (this PR) — same fail-loud pattern as
# VALID_KINDS / VALID_STATUSES: an unknown value raises with the bad value
# and the valid set in the error message. Extensible by editing this tuple
# — closed-in-the-typo sense, open-in-the-variant sense.
#
# Allowed ONLY when `status == "blocked"`: setting a `blocker` on a non-
# blocked row is a config error (the row isn't blocked, so naming a blocker
# is meaningless). Validator raises with "set status: blocked or remove the
# blocker field" — same shape as the compute-fields-only-on-kind=compute
# rule from ADR-0002.
VALID_BLOCKERS: tuple[str, ...] = (
    "compute",
    # ``"dependency"`` is the canonical spelling per operator co-design
    # (TG 9667, lead a2a `6d9b6073`). ``"dep"`` is the legacy alias from
    # ADR-0004's first cut; the validator accepts BOTH during a
    # deprecation window and normalizes on write (`_normalize_blocker`).
    # Once existing stores are swept, ``"dep"`` drops out.
    "dependency",
    "dep",
    "operator-decision",
    "agent-wait",
    # ``"none"`` is the explicit "no specific blocker named" value
    # (vs the soft-degrade case where the field is absent on a blocked
    # row). Lets the operator set blocker:none in a Resolve flow to
    # mean "I looked, no blocker" — distinct from "we haven't named
    # one yet." Operator co-design TG 9667.
    "none",
)


# Canonical → legacy alias normalization for the blocker enum.
# Used by Task.from_dict to flip incoming ``"dep"`` → ``"dependency"``
# on read, so the in-memory dataclass always carries the canonical
# spelling. The validator still accepts both spellings (deprecation
# window); only the dataclass normalizes.
_BLOCKER_ALIASES: dict[str, str] = {
    "dep": "dependency",
}


[docs] class TaskValidationError(ValueError): """Raised when a task store fails structural validation."""
class StaleStoreError(RuntimeError): """The store changed between your read and your write — reload and retry. Raised by :func:`save_tasks` when the caller passed ``expected_generation`` and the on-disk store no longer matches it. Writing anyway would silently discard every mutation the other writer made since your read (the 2026-07-10 bulk-migration incident: raw ``load_tasks → mutate → save_tasks`` scripts ate two concurrent sac writes because nothing tied the write to the read it was based on). """ class StoreShrinkRefusedError(RuntimeError): """A doc-level write would remove rows the store already has. Refused. THE THIRD BOARD WIPE (P0, 2026-07-21): the ``tasks`` table went 2170 -> 18 because a full-suite pytest run's ambient environment happened to name the LIVE store, and a stale/replacement document missing almost every row was persisted straight over it. The snapshot rail already refused to SNAPSHOT a collapsed count (2026-07-19, ``_cli/_db.py``'s ``_SHRINK_REFUSAL_RATIO`` guard) — this is that guard's sibling on the LIVE WRITE path, which no earlier incident had covered. Operator ruling — 一度書いたものは消えない, "a written card never disappears" — sets the bar at ANY row disappearing, not a ratio: a doc-level write is refused the moment it is missing even ONE id the store currently has (and that id is not named in the write's ``deleted_ids``, the one legitimate single-card removal path — see :func:`_is_tombstoned` for why a normal ``delete_task`` no longer even uses that path). There is no small-store exemption, because there is nothing to be generous about: growing a store from zero never removes anything, so the check never fires on it. Remedy: if the shrink is genuinely intentional (a deliberate bulk archive or migration, never an ordinary card write), re-run the call with ``allow_shrink=True``. """ #: ``_log_meta`` key marking a row as TOMBSTONED — removed via #: :func:`scitex_cards._store_lifecycle.delete_task`, never a physical #: SQL ``DELETE``. See :func:`_is_tombstoned`. TOMBSTONE_KEY = "deleted_at" def _is_tombstoned(task: dict) -> bool: """True if ``task`` was removed via :func:`delete_task`'s tombstone. 2026-07-21 P0 (third board wipe) replaced physical delete with an in-place mark, per operator ruling 一度書いたものは消えない ("a written card never disappears"): the row survives forever, its ``status`` flips to ``cancelled``, and ``_log_meta.deleted_at`` records when. Presence of ``deleted_at`` — not merely ``status == "cancelled"`` — is the marker: a card can be legitimately cancelled by a human/agent without ever being deleted, and conflating the two would hide real cancelled work from the board. """ log_meta = task.get("_log_meta") return bool(isinstance(log_meta, dict) and log_meta.get(TOMBSTONE_KEY)) def _find_live_task(tasks: list[dict], task_id: str) -> dict | None: """First NON-tombstoned task with id == ``task_id``, else ``None``. THE shared "does this id exist" lookup for every ordinary write verb (complete / resolve / reopen / reassign / rescore / comment / update / delete / set_edge / set_collaborator / set_subscriber) operating on the raw read-modify-write ``tasks`` list. A tombstoned row is retained on disk forever (see :func:`_is_tombstoned`) but must behave as ABSENT to all of them — mutating a deleted card would silently resurrect it. ``restore_task`` is the ONE verb whose job is exactly that resurrection, so it does its own raw (tombstone- INCLUSIVE) scan instead of calling this helper. """ for t in tasks: if t.get("id") == task_id and not _is_tombstoned(t): return t return None # --------------------------------------------------------------------------- # Task dataclass — SINGLE schema source (ADR-0007, quality-hygiene PR) # --------------------------------------------------------------------------- # # The dataclass IS the canonical schema. It feeds: # - the validator (`_validate_tasks`) # - the board UI render contract (ADR-0006 — every card field maps to one # dataclass attribute) # - the Gitea field-map (HANDOFF.md — every dataclass field maps to a # Gitea-issue field via label / milestone / assignee / body) # - the future README-frontmatter pivot (HANDOFF.md SSoT-layout) # # Heuristic pinned in HANDOFF.md: ANY schema evolution touches the dataclass # FIRST; validator + adapters follow mechanically. Two-sources-of-truth is # what this dataclass is collapsing. # # Back-compat: existing dict-style consumers (handlers/graph.py, _store.py, # the MCP layer) keep working — `Task.from_dict()` reads any historical # task shape, `Task.to_dict()` round-trips to a dict the existing writers # can consume. The migration to attribute-style access is incremental per # the operator's "no big-bang" rule. from dataclasses import dataclass, field # noqa: E402 from dataclasses import fields as _dc_fields # noqa: E402 @dataclass(slots=True) class Task: """Canonical task shape — the single schema source for scitex-cards. Field layout follows the operator's co-design (TG 9667, lead a2a `6d9b6073`): the operator's named fields come first (`id` / `title` / `task` / `project` / `host` / `created_at` / `goal`), then the UI-driving + workflow fields (`status` / `agent` / `last_activity` / `blocker` / `pr_url` / `issue_url`), then the graph-wiring fields preserved from pre-PR-#52 (`depends_on` / `blocks` / `parent` / `priority` / `note` / `comments`), then the kind discriminator + compute metadata (ADR-0002 / 0003), then the legacy shared-fleet additive fields (`scope` / `assignee` / `_log_meta`). Construction: prefer :meth:`Task.from_dict` for loading from a YAML row — it handles legacy spellings (e.g. `blocker: "dep"` → canonical `"dependency"`), missing fields (filled with the dataclass default), and ignores unknown keys defensively (so a forward-compat YAML with a future field doesn't crash an older loader). Persistence: :meth:`to_dict` round-trips to the dict shape the ruamel writer in :func:`save_tasks` expects. Fields with default values (None / empty list / empty dict) are OMITTED from the dict so the YAML stays compact. Required fields (id, title) are always emitted. See ADR-0007 in ``docs/adr/`` for the rationale + the migration plan from the legacy dict-style API. """ # --- operator's core fields (TG 9667) ---------------------------------- id: str title: str # `task` is the operator's "1-line current task" — the BIG text on the # board card. Distinct from `title` (which is the short scannable label) # and from `note` (which is free-form markdown detail). Optional during # the deprecation window so legacy rows that only carry `title` keep # loading; the FE falls back `task or title` when rendering the BIG # text. Once dogfooded, agents start populating `task` and the FE prefers # it. task: str | None = None project: str | None = None # directory / repo basename # `repo` = the git repository slug the card's work lands in (e.g. # ``scitex-cards``). Used by add_task / list_tasks and historically rode # ``**extras`` without a dataclass field — a confirmed latent bug: a row # carrying ``repo`` survived on disk but never round-tripped through the # Task dataclass (from_dict dropped it as an unknown key). Promoted to a # first-class OPTIONAL field in the SQLite-migration S0 (RFC #348 Q4); # pure-additive, defaults None so to_dict omits it when unset. repo: str | None = None host: str | None = None # where the work happens (operator co-design TG 9667) created_at: str | None = None # ISO-8601 UTC; emit at insert goal: str | None = None # WHY (parent-goal text); rendered as 🎯 line on card # --- deadline / scheduled (P4, lead approved 2026-06-12) -------------- # Both ISO-8601 (date "2026-06-15" or datetime "2026-06-15T18:00+09:00"). # `deadline` = when the task MUST be done; `scheduled` = when work # should START. Mirrors org-mode DEADLINE: / SCHEDULED: lines and # Gitea's `due_date`. Validator rejects empty strings and rejects # `deadline < scheduled` (deadline cannot precede start). FE prefers # the field over the existing title-parsed date when both are # present; absent field → fall back to title parse (back-compat). # See ADR-0007 follow-up + the P4 design a2a. # # A DEADLINE IS A VIEW, NEVER A NOTIFIER. It drives the `overdue` # filter (:func:`is_overdue`), the board date-pill / sort, and the # org export — and NOTHING else. No sweep, digest or nudge reads # `deadline` / `deadlines`: the delivery surface (`_reminders`, # `_stale_active`, `_backlog_triage`, `_delivery/*`) keys ONLY on # `last_activity` (falling back to `created_at`). A deadline # arriving — recurring or not — fires nothing. And a RECURRING # deadline never even goes overdue (the repeater rolls the next # occurrence into the future), so it reaches NEITHER rail. To BE # nudged, keep the card open and owned: the stale-active sweep # nudges the owner of any in_progress/blocked card untouched past # its threshold, and the backlog sweep does the same for untouched # `deferred` cards. # (hook-bypass: line-limit — board_v3.html refactor still queued.) deadline: str | None = None scheduled: str | None = None # P4 PR3 (lead-approved 2026-06-12) — multiple deadlines. When set, # `deadline` must be UNSET (mutual exclusion); the loader computes a # synthetic `deadline = <min next-occurrence>` so the existing FE # date-pill / sort / overdue paths keep working canonically. Each # entry follows the same wire shape as `deadline`: ISO-8601 with an # optional " +Nu" / " ++Nu" org repeater suffix. Empty list rejected # (use the absent form). See `_parse_deadline_or_raise` for the # accepted forms. deadlines: list[str] | None = None # --- lead-added: drives UI color + blocker views (TG 9667) ------------- status: str = "deferred" # current canonical = VALID_STATUSES (7-value); # the operator's 4-value enum (working/waiting/done/blocked) # is mapped IN THE FE renderer for now, not in the # schema. See ADR-0007 Consequences for the # deferred 7→4 schema migration. agent: str | None = None # owning agent (distinct from `assignee` legacy field) # `group` is the logical CLUSTER OF AGENTS this task belongs to — # the parallelism-engine dispatcher (TRACK 1, lead a2a `74db4f2d`, # 2026-06-14) uses it to ask "what's runnable now in group <G>" # so the operator's "independent (dep-free) tasks run concurrently # across groups" model works. Free-form non-empty string when # present; absent = ungrouped. Distinct concept from `_groups.py`'s # project-cluster `Group` dataclass (that's a VIEWER aggregation # for the board's column collapser; this is a DISPATCH concept on # the task itself). group: str | None = None last_activity: str | None = ( None # ISO-8601 UTC; recency drives green/amber/red coloring ) blocker: str | None = ( None # one of VALID_BLOCKERS or absent; only on status=blocked ) pr_url: str | None = None # optional GH/Gitea PR link issue_url: str | None = None # optional GH/Gitea issue link # --- graph wiring (preserved from pre-#52) ----------------------------- depends_on: list[str] = field(default_factory=list) blocks: list[str] = field(default_factory=list) parent: str | None = None priority: int | None = None note: str | None = None comments: list[dict] = field(default_factory=list) # --- roles & notification (P1, ADR-0009) ------------------------------- # `collaborators` = agents/humans involved beyond the single `agent` # (assignee); `subscribers` = the notify list (default = creator + # collaborators, always unsubscribable). PERSISTENT fields: previously # collaborators were recomputed from comment authors at event-time and # subscribers did not exist. Absent / None → empty list (back-compat). collaborators: list[str] = field(default_factory=list) subscribers: list[str] = field(default_factory=list) # `created_by` = the USER (agent or human; user.kind=agent) who created # the card, captured at insert by add_task from the same author chain # comment authorship resolves ($SCITEX_CARDS_AGENT_ID → $USER → "unknown"). # Back-compat: ABSENT on legacy rows — readers fall back to the earliest # comment author, else "—". Optional non-empty string when present. # (hook-bypass: line-limit — _model.py split still queued.) created_by: str | None = None # --- kind discriminator + compute metadata (ADR-0002 / 0003) ----------- kind: str | None = None # one of VALID_KINDS or absent (defaults to "task") job_id: str | None = None command: str | None = None started_at: str | None = None finished_at: str | None = None # --- standing / parked cards (raised by scitex-writer 2026-07-13) ------ # `parked` = WHY this card is deliberately standing, in free text. Its # PRESENCE (non-empty) exempts the card from the backlog nudge and from # auto-expiry. Its ABSENCE means an untouched deferred card is exactly what # the sweep takes it for: abandoned. # # It is a REASON, never a boolean, and that IS the design. The backlog sweep # fires on `deferred` + untouched, a predicate that cannot separate a card # nobody got to from a north-star umbrella whose real work lives in its # children. For the umbrella the nudge is unanswerable BY CONSTRUCTION — # there is nothing to start and no gate to clear, so "untouched" is its # steady state. Both escapes available before this field were lies: flip it # to `in_progress` (an umbrella is not work in flight), or leave periodic # no-op comments purely to bump `last_activity` (gaming the sweep, and it # buries the real history under noise). # # A bare boolean would have been a MUTE BUTTON, and a mute button is how an # alarm dies: everything gets muted, and then the sweep stops catching the # abandoned cards it exists for. Requiring the reason makes a card PAY for # its exemption by saying why, in writing, where the next reader sees it. # Whitespace-only is NOT a park (see :func:`_backlog_triage.park_reason`): # a park with no stated reason is precisely the abandonment the sweep should # still catch. # # Parking hides a card from the NUDGE, never from the BOARD. `list_tasks` # and every view are untouched, so this cannot silently remove work from the # view someone relies on. parked: str | None = None # --- legacy shared-fleet additive fields (Phase-1 SSoT) --------------- scope: str | None = None assignee: str | None = ( None # legacy; `agent` is the operator-co-designed replacement ) _log_meta: dict | None = None # opaque writer-side event stamps @classmethod def from_dict(cls, d: dict) -> "Task": """Construct from a stored task dict. - Unknown keys are silently dropped (forward-compat). - Missing keys fill with the dataclass default. - Legacy blocker spellings (e.g. ``"dep"``) normalize to canonical (``"dependency"``) — see ``_BLOCKER_ALIASES``. Does NOT raise on schema violations — that's :func:`_validate_tasks`'s job. Defensive construction so legacy / forward / partial rows can always be read; validation is a separate check. """ valid_names = {f.name for f in _dc_fields(cls)} # `comments` default needs the list factory so legacy missing-comments # rows construct cleanly (None would break list-of-mapping invariants # downstream). kwargs: dict[str, object] = {} for k, v in d.items(): if k not in valid_names: continue if k == "blocker" and isinstance(v, str): v = _BLOCKER_ALIASES.get(v, v) kwargs[k] = v # comments / depends_on / blocks / collaborators / subscribers: # replace None with the empty default so downstream code can iterate # without isinstance(.., None) checks. for list_field in ( "comments", "depends_on", "blocks", "collaborators", "subscribers", ): if kwargs.get(list_field) is None: kwargs.pop(list_field, None) return cls(**kwargs) # type: ignore[arg-type] def to_dict(self) -> dict: """Round-trip to a plain dict suitable for the ruamel writer. Fields with default values (None / empty list / empty dict) are OMITTED so the YAML stays compact. The validator-REQUIRED fields (`id`, `title`, `status`) always emit — including when `status` equals the `"pending"` default — because a row missing `status` would fail `_validate_tasks` on the next load. Required fields survive the to_dict-then-from_dict round-trip even at defaults. """ result: dict[str, object] = {} for f in _dc_fields(self): value = getattr(self, f.name) # Always-emit: validator-required fields. if f.name in ("id", "title", "status"): result[f.name] = value continue # Default-equal values are omitted (keeps YAML compact). default = ( f.default if f.default is not f.default_factory else f.default_factory() ) # type: ignore[misc] if value == default: continue # Empty containers: omit so the YAML stays compact. if isinstance(value, (list, dict)) and not value: continue result[f.name] = value return result