Source code for scitex_cards._store_lifecycle

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Card LIFECYCLE verbs — the state transitions of an existing card.

Split out of ``_store`` (PURE MOVE — no behaviour change), which re-exports
every name below so ``from ._store import complete_task`` keeps working:

    complete_task   done + ``_log_meta.completed_{at,by}`` (idempotent).
    resolve_task    blocked → done, blocker cleared, audit comment.
    reopen_task     done → blocked/``operator-decision`` (the Resolve→Undo).
    reassign_task   atomic owner change (agent+assignee+scope in lock-step).
    delete_task     remove + scrub inbound refs (returns the Undo payload).
    restore_task    the Delete→Undo partner.

The shared helpers (``_read_write_doc`` / ``_utc_now_iso`` / ``_default_agent``
/ ``TaskNotFoundError``) stay in ``_store`` and are imported HERE inside the
function bodies — a deferred import, because ``_store`` imports this module at
module level to re-export its verbs and a top-level import back would cycle.
Same pattern the code already used for ``from . import _model``.
"""

from __future__ import annotations

from pathlib import Path

from ._comment_ids import stamp_comment_id
from ._model import _save_doc_unlocked, _store_lock
from ._store_events import _emit_card_event, _emit_unblock_for_dependents
from ._store_list import _resolved_store
from ._touch import touch_last_activity

#: The ONLY status that means "this work was delivered". ``failed`` and
#: ``cancelled`` are terminal too, but they are NOT completions — a card can
#: stop without having shipped, and the throughput surfaces must not conflate
#: the two.
COMPLETED_STATUS = "done"

#: The ``_log_meta`` keys :func:`complete_task` stamps. They are the SOLE
#: input to the throughput/timeline aggregates (``_django/handlers/fleet/
#: timing.py``, ``_django/handlers/timeline.py``), which never consult
#: ``status`` — so leaving them on a non-``done`` card reports work that was
#: not delivered.
COMPLETION_STAMP_KEYS = ("completed_at", "completed_by")


def clear_completion_stamp(task: dict) -> bool:
    """Drop ``_log_meta.completed_{at,by}`` from ``task``. True if anything went.

    Call this from ANY transition that takes a card OUT of ``done``. The stamp
    is what the throughput surfaces believe; the status is what the sweeps
    believe. Move one without the other and the card becomes two different
    facts to two different readers — completed to the timeline, open to the
    digest — which is how 5 cards on the live board came to be counted as
    delivered work while still nagging their owners (2026-07-14).

    Keeping this as a named helper rather than two inline ``pop`` calls is the
    point: the next person to add an un-complete transition should find an
    obvious thing to call, not have to REMEMBER an invariant.
    """
    meta = task.get("_log_meta")
    if not isinstance(meta, dict):
        return False
    cleared = False
    for key in COMPLETION_STAMP_KEYS:
        if meta.pop(key, None) is not None:
            cleared = True
    if not meta:
        task.pop("_log_meta", None)
    return cleared


[docs] def complete_task( store: str | Path | None = None, task_id: str | None = None, *, by: str | None = None, entry_points=None, # hook-bypass: line-limit ) -> dict: """Mark ``task_id`` as ``done`` and stamp ``_log_meta.completed_{at,by}``. Idempotent per ``GITIGNORED/QUESTIONS.md`` #3: re-completing a ``done`` task is a no-op (timestamps stay frozen from the first completion). Pass ``by=`` to override the ``$SCITEX_CARDS_AGENT_ID`` → ``$USER`` → ``"unknown"`` precedence chain. Returns the (post-mutation) task mapping. Raises ------ TaskNotFoundError If no task matches ``task_id``. """ from . import _task # hook-bypass: line-limit — verb-module split still queued from ._store import _default_agent, _read_write_doc, _task_not_found, _utc_now_iso if not task_id: raise TypeError("complete_task() requires a non-empty task_id") resolved = _resolved_store(store) result: dict | None = None transitioned = False with _store_lock(resolved): doc, tasks = _read_write_doc(resolved) # `not _task._is_tombstoned(task)`: a tombstoned row is retained on # disk forever but must behave as ABSENT — completing a deleted # card would silently resurrect it. for task in tasks: if task.get("id") == task_id and not _task._is_tombstoned(task): if task.get("status") == "done": # Idempotent: don't refresh the stamp, just return. # No unblock emit — re-completing changed nothing. return dict(task) task["status"] = "done" # CLEAR THE GATE WITH THE STATUS, or the document we are about # to save is INVALID and _validate_tasks refuses the whole save. # # A done card still naming an unresolved blocker is incoherent: # either the gate was cleared, or the card is not done. The # validator says exactly that, and `resolve_task` has always # cleared the blocker for this reason. `complete_task` never # learned it, so the two closing verbs disagreed and this one # produced a document that could not be written back. # # Measured on the live */15 reconcile cron, 2026-08-01: # TaskValidationError: task 'ci-runner-gitconfig-lock-collision' # has blocker 'operator-decision' but status is 'done' # That card was legitimately blocked on an operator decision and # its pull request merged anyway — real data, not corruption. # Because validation covers the WHOLE document, that one card # stopped the sweep from closing ANY card. task.pop("blocker", None) log_meta = task.get("_log_meta") if not isinstance(log_meta, dict): log_meta = {} task["_log_meta"] = log_meta now = _utc_now_iso() log_meta["completed_at"] = now log_meta["completed_by"] = _default_agent(by) # A COMPLETION IS AN ACT, and every reconciler orders acts by # `last_activity`. Stamping only `completed_at` made the newest # possible change to a card invisible to the field that decides # which copy wins — see `_touch` for the 3-host measurement. # Same `now`, so the completion stamp and the activity stamp # cannot straddle a tick and disagree about when this happened. touch_last_activity(task, now) _save_doc_unlocked(doc, resolved, tasks=tasks, touched_ids=[task_id]) result = dict(task) transitioned = True break if result is None: raise _task_not_found(task_id) # Active-unblock DRIVE (ADR-0009) — OUTSIDE the file lock (the emit # re-loads the store + may comment on dependents, which take the # same lock). Only on a real pending→done transition. if transitioned: _emit_unblock_for_dependents(resolved, task_id, by=by) # C5: a completion emits a canonical `completed` card-event (the # chosen mapping — complete_task → `completed`, NOT also a # `status_changed`, to avoid double-firing). Fail-soft, post- # persist, only on a real transition (idempotent re-complete # returned early above and emits nothing). Actor = resolved # completer. (hook-bypass: line-limit) _emit_card_event( "completed", task_id, actor=_default_agent(by), store=resolved, entry_points=entry_points, ) return result
[docs] def delete_task( # hook-bypass: line-limit — verb-module split still queued store: str | Path | None = None, task_id: str | None = None, ) -> dict: """TOMBSTONE a task + scrub references to it. Returns the lossless payload the client can pass to ``restore_task`` for Undo. 2026-07-21 P0 (third board wipe) — operator ruling 一度書いたものは 消えない, "a written card never disappears": this NO LONGER physically removes the row. It marks it in place — ``status`` flips to ``cancelled``, ``_log_meta.deleted_at`` (+ ``deleted_by``) records when and who — and the row is retained forever (see :func:`_task._is_tombstoned`). Physical removal is IMPOSSIBLE through this, the normal API; a genuine purge is a deliberate admin verb, not this one. The board v3 Delete-with-Undo flow uses this via ``handlers/crud.py``; exposing the same operation here lets MCP agents do the same delete + later undo without round-tripping HTTP. Reads (``list_tasks`` / ``get_task`` / ``set_edge`` / every other lookup) treat a tombstoned row as ABSENT by default, so board behaviour is unchanged. Returns ``{"removed": <full pre-tombstone task dict>, "refs": [<refs scrubbed>]}`` where each ref is the id of another task whose depends_on / blocks / parent pointed at the deleted task (the client passes ``removed`` back to ``restore_task`` to lossless-revert). """ from . import _model, _task from ._store import _default_agent, _read_write_doc, _task_not_found, _utc_now_iso tasks_path = _resolved_store(store) if not task_id: raise ValueError("delete_task: 'task_id' is required") with _model._store_lock(tasks_path): doc, tasks = _read_write_doc(tasks_path) target = _task._find_live_task(tasks, task_id) if target is None: raise _task_not_found(task_id) original = dict(target) # pre-tombstone snapshot: the Undo payload refs: list[str] = [] for t in tasks: if t is target: continue mutated = False if isinstance(t.get("depends_on"), list) and task_id in t["depends_on"]: t["depends_on"] = [d for d in t["depends_on"] if d != task_id] if not t["depends_on"]: t.pop("depends_on", None) mutated = True if isinstance(t.get("blocks"), list) and task_id in t["blocks"]: t["blocks"] = [b for b in t["blocks"] if b != task_id] if not t["blocks"]: t.pop("blocks", None) mutated = True if t.get("parent") == task_id: t.pop("parent", None) mutated = True if mutated: refs.append(t.get("id")) # TOMBSTONE in place — never a physical removal. `tasks` still # contains `target`, so this is an ordinary upsert-by-id write, not # the `deleted_ids` path (that path stays reserved for a future, # deliberate admin purge; see `_db_mirror`). actor = _default_agent(None) now = _utc_now_iso() target["status"] = "cancelled" # CLEAR THE GATE WITH THE STATUS — the same rule `complete_task` learned # on 2026-08-01, which this verb had not. A card that is `cancelled` # while still naming an unresolved blocker is incoherent, and # `_validate_tasks` refuses it: # # TaskValidationError: task 'a' has blocker 'dependency' but status is # 'cancelled'; set status: blocked or remove the blocker field # # So DELETING ANY BLOCKED CARD THAT NAMES ITS GATE FAILED OUTRIGHT — and # because validation covers the WHOLE document, that one card stopped # every other write in the same save. Found by the restore/undo test # below, which deletes a realistically-blocked card rather than a bare # one; the previous fixtures only ever tombstoned cards with no blocker, # so the hole sat behind a test that could not reach it. target.pop("blocker", None) log_meta = target.get("_log_meta") if not isinstance(log_meta, dict): log_meta = {} target["_log_meta"] = log_meta log_meta["deleted_at"] = now log_meta["deleted_by"] = actor comments = target.setdefault("comments", []) comments.append( stamp_comment_id( { "author": actor, "ts": now, "text": ( "[TOMBSTONED via delete_task] status -> cancelled, " "_log_meta.deleted_at stamped. Row retained (never " "physically removed); restore_task is the Undo." ), } ) ) target["last_activity"] = now # NAMES EVERY CARD IT TOUCHED, WHICH IS MORE THAN ONE. This verb # tombstones `task_id` AND scrubs inbound references to it from every # card in `refs` — so its intent genuinely spans several rows, unlike # the other lifecycle verbs. # # `touched_ids=[task_id]` would have been the obvious conversion and it # would have SILENTLY DISCARDED THE REF SCRUBS, leaving dangling # references to a tombstoned card — a new defect introduced by the fix # for an old one. The ids are already collected because the Undo payload # returns them; naming intent here costs nothing extra. _model._save_doc_unlocked( doc, tasks_path, tasks=tasks, touched_ids=[task_id, *refs] ) return {"removed": original, "refs": refs}
[docs] def restore_task( store: str | Path | None = None, task: dict | None = None, refs: list[str] | None = None, ) -> dict: """Undo a ``delete_task``: UN-TOMBSTONE the row back to its pre-delete state (or, for a row with no tombstone at all — legacy/never-deleted — re-insert it, the original pre-tombstone-era behaviour). Idempotent on a duplicate id that is NOT a tombstone — raises ``ValueError`` (use ``update_task`` to mutate; this verb is the Delete-Undo partner only). A tombstoned row is exactly what this verb expects to find and reverses in place. """ from . import _model, _task from ._store import _read_write_doc, _utc_now_iso tasks_path = _resolved_store(store) if not isinstance(task, dict) or not task.get("id"): raise ValueError("restore_task: 'task' must be a dict with 'id'") tid = task["id"] with _model._store_lock(tasks_path): doc, tasks = _read_write_doc(tasks_path) # The row we write is a COPY, stamped as a fresh act. `delete_task` # advances `last_activity` when it tombstones (see its body), so a # restore that reused the caller's pre-delete snapshot verbatim would # be strictly OLDER than the tombstone it reverses. On a second host # the reconciler would then read the tombstone as the later act and # re-delete the card — an Undo that undoes itself. We do not mutate # the caller's dict: the Undo payload stays replayable. restored = dict(task) touch_last_activity(restored, _utc_now_iso()) existing = next((t for t in tasks if t.get("id") == tid), None) if existing is not None: if not _task._is_tombstoned(existing): raise ValueError(f"restore_task: id {tid!r} already present") # UN-TOMBSTONE in place: replace with the caller's pre-delete # snapshot, at the SAME list position (an ordinary upsert). tasks[tasks.index(existing)] = restored else: # No row at all — a legacy pre-tombstone-era delete, or an # admin purge. Fall back to the original append behaviour. tasks.append(restored) # NAMES THE ONE CARD IT TOUCHED. Both branches above write exactly `tid` # -- un-tombstoning replaces that row in place, the fallback appends it # -- so nothing else in the document is this verb's intent. _model._save_doc_unlocked(doc, tasks_path, tasks=tasks, touched_ids=[tid]) # refs are descriptive (the client passes them through so callers can # see which tasks had been mutated; we don't reverse-apply them since # the depends_on / blocks values were just stripped, not stored). return {"task": task, "refs": list(refs or [])}
[docs] def resolve_task( store: str | Path | None = None, task_id: str | None = None, actor: str | None = None, *, entry_points=None, # hook-bypass: line-limit ) -> dict: """Flip a task from ``status=blocked`` (typically ``blocker=operator- decision``) to ``done`` and clear the blocker. Appends an audit comment naming the actor. Idempotent on already-resolved tasks (re-resolves are no-ops, just log a "noop" comment). """ from . import _model, _task # hook-bypass: line-limit from ._store import TaskNotFoundError, _default_agent, _read_write_doc, _utc_now_iso if not task_id: raise ValueError("resolve_task: 'task_id' is required") who = _default_agent(actor) tasks_path = _resolved_store(store) with _model._store_lock(tasks_path): doc, tasks = _read_write_doc(tasks_path) target = _task._find_live_task(tasks, task_id) if target is None: raise TaskNotFoundError(f"resolve_task: unknown id {task_id!r}") was_done = target.get("status") == "done" prior_status = target.get("status") # C5: capture for the event target["status"] = "done" target.pop("blocker", None) now = _utc_now_iso() # Stamp even on the already-done noop path: this verb ALWAYS appends a # comment, so it always changes the card, and a change that skips the # activity stamp is exactly the lie this invariant exists to prevent. touch_last_activity(target, now) comments = target.setdefault("comments", []) comments.append( stamp_comment_id( { "author": who, "ts": now, "text": ( "[resolve (noop — already done)]" if was_done else "[RESOLVED via mcp.resolve_task] flipped status='blocked'->done, blocker cleared." # noqa: E501 # hook-bypass: line-limit ), } ) ) _model._save_doc_unlocked( doc, tasks_path, tasks=tasks, touched_ids=[task_id] ) # Active-unblock DRIVE (ADR-0009) — resolving a blocker card to done # can free its dependents too. Outside the lock; skip the noop # (already-done) path. Handler token-dedupe keeps it idempotent. if not was_done: _emit_unblock_for_dependents(tasks_path, task_id, by=who) # C5: a resolve is a status flip TO done. Per the project mapping # the resolve path emits `status_changed` {from,to:done} (the # `completed` event is reserved for complete_task / a done flip via # update_task). Fail-soft, post-persist, skip the noop path. # (hook-bypass: line-limit) _emit_card_event( "status_changed", task_id, actor=who, extra={"from": prior_status, "to": "done"}, store=tasks_path, entry_points=entry_points, ) return {"task_id": task_id, "actor": who, "task": dict(target)}
[docs] def reopen_task( store: str | Path | None = None, task_id: str | None = None, by: str | None = None, ) -> dict: """Un-resolve a task — flip ``status=done`` back to ``blocked`` with ``blocker=operator-decision`` (the original LOUD halo state). Used by the board v3 Resolve→Undo loop. ALSO CLEARS ``_log_meta.completed_{at,by}``. Un-completing a card that keeps its completion stamp is not a reopen — it is a card that is open and completed at the same time, and the stamp is the half that gets believed: ``_django/handlers/fleet/timing.py`` and ``timeline.py`` aggregate throughput *solely* on ``completed_at``, never on ``status``. So a stamped-but-open card is counted as delivered work forever, while simultaneously nagging its owner as backlog. (2026-07-14: found 5 such cards on the live board — one of them ``sac-keystone``, whose status had just been corrected from a mistaken ``done`` to ``cancelled``. The STATUS was fixed; the STAMP was not, so the false completion survived the correction. A lie outlives its retraction if it is written in two places and you only fix one.) """ from . import _model, _task # hook-bypass: line-limit from ._store import TaskNotFoundError, _default_agent, _read_write_doc, _utc_now_iso if not task_id: raise ValueError("reopen_task: 'task_id' is required") who = _default_agent(by) tasks_path = _resolved_store(store) with _model._store_lock(tasks_path): doc, tasks = _read_write_doc(tasks_path) target = _task._find_live_task(tasks, task_id) if target is None: raise TaskNotFoundError(f"reopen_task: unknown id {task_id!r}") target["status"] = "blocked" target["blocker"] = "operator-decision" cleared = clear_completion_stamp(target) now = _utc_now_iso() # An UN-completion is as much an act as a completion. Without this the # reopen is invisible to timestamp comparison and a reconciler happily # re-applies the done copy from the other host, undoing the reopen. touch_last_activity(target, now) comments = target.setdefault("comments", []) text = ( "[REOPENED via mcp.reopen_task] flipped status='done'->blocked, " "blocker=operator-decision restored." ) if cleared: text += " Cleared _log_meta.completed_{at,by} — the card is no longer completed." # noqa: E501 # hook-bypass: line-limit comments.append(stamp_comment_id({"author": who, "ts": now, "text": text})) _model._save_doc_unlocked( doc, tasks_path, tasks=tasks, touched_ids=[task_id] ) return {"task_id": task_id, "by": who, "task": dict(target)}
# reassign_task now lives in _store_reassign, beside the bulk reassign_all: # ownership is one responsibility and was split across two modules, with the # module named for it holding only half. Re-exported here so every existing # import path (notably _store) keeps resolving unchanged. from ._store_reassign import reassign_task # noqa: E402,F401 __all__ = [ "complete_task", "delete_task", "reassign_task", "reopen_task", "resolve_task", "restore_task", ] # EOF