
    L0&j                      U d Z ddlmZ ddlZddlZddlZddlZddlZddlZddl	Z	ddl
Z
ddlZddlZddlZddlZddlZddlmZmZ ddlmZmZ ddlmZ ddlmZmZmZ ddlmZ  ej        e          Z h d	Z!d
dhZ"h dZ# e$d  e            D                       Z%ej&        dk    Z'dZ(dZ)dddZ*dZ+dZ,ddZ-ddZ.dZ/dZ0dZ1dZ2dZ3dZ4 edd           Z5d!e6d"<   ej7        dd%            Z8 ej9        d&          Z:dd(Z;dd*Z<dd+Z=dd,Z>dd-Z?dd.Z@dd0ZAddd2ZBddd4ZCddd5ZDddd6ZEddd7ZFddd9ZGddd:ZHddd;ZIdd<ZJddd>ZKddddddd?ddGZLddddddHddIZMdJdKddNZNdJdOddQZOe G dR dS                      ZPe G dT dU                      ZQe G dV dW                      ZRe G dX dY                      ZSe G dZ d[                      ZTd\ZU eV            ZWd]e6d^<    ejX                    ZYd_ZZd`Z[ddaZ\dddZ]ej7        dde            Z^ddiZ_ddjZ` G dk dlea          ZbddnZcddoZd	 dddpddrZeej7        	 dddpdds            Zf	 dddpddtZgddyZhddzZid{d|d}d~dZjddZkddZlddZmej7        dd            ZnddZoddZpddZqddddddddddddddddd
dddddZrddZsddZtdddddddddZude6d<   dddddddddd	ddZvddZwddZxddZyddZzddZ{ddZ|ddZ}ddĄZ~ddƄZddddǜdd̈́ZddτZdd҄ZddӄZddՄZ	 ddd֜ddۄZdddddܜddZddZddddddZddZ	 dddZdddddZdddddZdddddZddddZdddddZdddddZddZ ej9        d          ZddZ G d de          ZddddddddZddZddZddZddZdZd	Zdd
ZddZddZddZdddddZdddddZddddddZddZdddddddZddJddd Zdd!Zdd"Zdd#Zddpdd%Zdd'Zddddd(Zd)ZeZd*Zd+ZdZ ej9        d,ej                  ZdZd-Zd.Z ej9        d/ej                  Ze G d0 d1                      Zd2ZdZi Zd3e6d4<   dd7Zdd9Zdd;Zdd<Zdddd?Zddd@ddBZddddCZdZdddDddFZĐddHZŐddIZdddddJddNZddOd dPZȐddQZɐddRZeZːddSZ̐ddTZ͐ddUZdddddedddddV
dd[Zϐd+d\dd`ZАdddcZѐddfZefddiZӐd	djZԐd
dkZՐddlZ֐ddmZאddoZؐddpZِddqZڐd	drZېddtZܐddvZddpddxZސdydedddzdd}Zߐdd~ZddZddZddZddddddZ	 dddZddddZdddddZdddddZddddZddddZddddZdddd dZddpddZdddd!dZd	dZd"dZdJdddd#dZd$dZd%dZddZd&dZdS ('  u   SQLite-backed Kanban board for multi-profile, multi-project collaboration.

In a fresh install the board lives at ``<root>/kanban.db`` where
``<root>`` is the **shared Hermes root** (the parent of any active
profile). Profiles intentionally collapse onto a shared board: it IS
the cross-profile coordination primitive. A worker spawned with
``hermes -p <profile>`` joins the same board as the dispatcher that
claimed the task. The same applies to ``<root>/kanban/workspaces/`` and
``<root>/kanban/logs/``.

**Multiple boards (projects):** users can create additional boards to
separate unrelated streams of work (e.g. one per project / repo / domain).
Each board is a directory under ``<root>/kanban/boards/<slug>/`` with
its own ``kanban.db``, ``workspaces/``, and ``logs/``. All boards share
the profile's Hermes home but are otherwise isolated: a worker spawned
for a task on board ``atm10-server`` sees only that board's tasks,
cannot enumerate other boards, and its dispatcher ticks don't touch
other boards' DBs.

The first (and for single-project users, only) board is ``default``.
For back-compat its on-disk DB is ``<root>/kanban.db`` (not
``boards/default/kanban.db``), so installs that predate the boards
feature keep working with zero migration. See :func:`kanban_db_path`.

Board resolution order (highest precedence first, all optional):

* ``board=`` argument passed directly to :func:`connect` / :func:`init_db`
  (explicit — used by the CLI ``--board`` flag and the dashboard
  ``?board=...`` query param).
* ``HERMES_KANBAN_BOARD`` env var (used by the dispatcher to pin workers
  to the board their task lives on — workers cannot see other boards).
* ``HERMES_KANBAN_DB`` env var (pins the DB file path directly — legacy
  override still honoured; highest precedence when the file path itself
  is what the caller wants to force).
* ``<root>/kanban/current`` — a one-line text file holding the slug of
  the "currently selected" board. Written by ``hermes kanban boards
  switch <slug>``. When absent, the active board is ``default``.

In standard installs ``<root>`` is ``~/.hermes``. In Docker / custom
deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g.
``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Legacy env-var
overrides still work:

* ``HERMES_KANBAN_DB`` — pin the database file path directly.
* ``HERMES_KANBAN_WORKSPACES_ROOT`` — pin the workspaces root directly.
* ``HERMES_KANBAN_HOME`` — pin the umbrella root that anchors kanban
  paths. Useful for tests and unusual deployments.

The dispatcher injects ``HERMES_KANBAN_DB``,
``HERMES_KANBAN_WORKSPACES_ROOT``, and ``HERMES_KANBAN_BOARD`` into
worker subprocess env so workers converge on the exact DB the
dispatcher used to claim their task — even under unusual symlink or
Docker layouts.

Schema is intentionally small: tasks, task_links, task_comments,
task_events.  The ``workspace_kind`` field decouples coordination from git
worktrees so that research / ops / digital-twin workloads work alongside
coding workloads.  See ``docs/hermes-kanban-v1-spec.pdf`` for the full
design specification.

Concurrency strategy: WAL mode + ``BEGIN IMMEDIATE`` for write
transactions + compare-and-swap (CAS) updates on ``tasks.status`` and
``tasks.claim_lock``.  SQLite serializes writers via its WAL lock, so at
most one claimer can win any given task.  Losers observe zero affected
rows and move on -- no retry loops, no distributed-lock machinery.
The CAS coordination is **per-board** — each board is a separate DB,
so multi-board installs get the same atomicity guarantees without any
new locking.
    )annotationsN)
ContextVarToken)	dataclassfieldPath)AnyIterableOptional)get_toolset_names>	   donetodoreadyreviewtriageblockedrunningarchived	scheduledr   r   >   dirscratchworktreec              #  >   K   | ]}|                                 V  d S N)casefold).0names     9/home/ubuntu/.hermes/hermes-agent/hermes_cli/kanban_db.py	<genexpr>r    g   s*      PPDPPPPPP    win32i  i  ttl_secondsOptional[int]returnintc                   | t          dt          |                     S t          j                            dd                                          }|r+	 t          |          }n# t          $ r d}Y nw xY w|dk    r|S t          S )a5  Return the effective claim TTL, honoring the kanban env override.

    Explicit call-site values win. Otherwise a positive integer from
    ``HERMES_KANBAN_CLAIM_TTL_SECONDS`` overrides the built-in default.
    Invalid or non-positive env values fall back silently so existing
    installs keep working.
    N   HERMES_KANBAN_CLAIM_TTL_SECONDS r   )maxr&   osenvirongetstrip
ValueErrorDEFAULT_CLAIM_TTL_SECONDS)r#   rawparseds      r   _resolve_claim_ttl_secondsr4   }   s     1c+&&'''
*..:B
?
?
E
E
G
GC
 	XXFF 	 	 	FFF	A::M$$s   A% %A43A4   K   c                     t           j                            dd                                          } | r+	 t	          |           }n# t
          $ r d}Y nw xY w|dk    r|S t          S )a-  Return the crash-detection grace period in seconds.

    Reads ``HERMES_KANBAN_CRASH_GRACE_SECONDS`` from the environment;
    falls back to ``DEFAULT_CRASH_GRACE_SECONDS`` when absent, empty,
    non-integer, or negative. A value of 0 restores immediate-reclaim
    behaviour (useful for tests).
    !HERMES_KANBAN_CRASH_GRACE_SECONDSr*   r   )r,   r-   r.   r/   r&   r0   DEFAULT_CRASH_GRACE_SECONDSr2   r3   s     r   _resolve_crash_grace_secondsr<      sx     *..<b
A
A
G
G
I
IC
 	XXFF 	 	 	FFF	Q;;M&&   A AAc                     t           j                            dd                                          } | r+	 t	          |           }n# t
          $ r d}Y nw xY w|dk    r|S t          S )u  Return the rate-limit requeue cooldown in seconds.

    Reads ``HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS`` from the environment;
    falls back to ``DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS`` when absent, empty,
    non-integer, or negative. A value of 0 disables the cooldown (re-spawn on
    the next tick) — useful for tests that want to assert the task becomes
    spawnable again immediately.
    )HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDSr*   r9   r   )r,   r-   r.   r/   r&   r0   #DEFAULT_RATE_LIMIT_COOLDOWN_SECONDSr;   s     r   $_resolve_rate_limit_cooldown_secondsrA      s~     *..3R egg   	XXFF 	 	 	FFF	Q;;M..r=   
   i   i    i   default$hermes_kanban_current_board_override)rC   zContextVar[str | None]_CURRENT_BOARD_OVERRIDEslugstrc              #     K   t                               |           }	 dV  t                               |           dS # t                               |           w xY w)z>Temporarily pin the active board for the current context only.N)rE   setreset)rF   tokens     r   scoped_current_boardrL      s^        7::4@@E-%%e,,,,,%%e,,,,s	   > Az^[a-z0-9][a-z0-9\-_]{0,63}$Optional[str]c                    | dS t          |                                                                           }|sdS t                              |          st          d| d          |S )z>Lowercase + strip a slug; validate; return ``None`` for empty.Nzinvalid board slug zc: must be 1-64 chars, lowercase alphanumerics / hyphens / underscores, not starting with '-' or '_')rG   r/   lower_BOARD_SLUG_REmatchr0   )rF   ss     r   _normalize_board_slugrS      s    |tD		!!A t"" 
S$ S S S
 
 	
 Hr!   r	   c                     t           j                            dd                                          } | r!t	          |                                           S ddlm}  |            S )a  Return the shared Hermes root that anchors the kanban board.

    Resolution order:

    1. ``HERMES_KANBAN_HOME`` env var when set and non-empty (explicit
       override for tests and unusual deployments).
    2. ``get_default_hermes_root()``, which already returns ``<root>``
       when ``HERMES_HOME`` is ``<root>/profiles/<name>``, and returns
       ``HERMES_HOME`` directly for Docker / custom deployments.

    The kanban board is shared across profiles **by design** (see the
    module docstring). Resolving the kanban paths through the active
    profile's ``HERMES_HOME`` would silently fork the board per profile,
    which breaks the dispatcher / worker handoff.
    HERMES_KANBAN_HOMEr*   r   get_default_hermes_root)r,   r-   r.   r/   r	   
expanduserhermes_constantsrW   )overriderW   s     r   kanban_homer[     sg      z~~2B77==??H +H~~((***888888""$$$r!   c                 *    t                      dz  dz  S )ua  Return ``<root>/kanban/boards`` — the parent of non-default board dirs.

    ``default`` is intentionally NOT under this directory — its DB lives at
    ``<root>/kanban.db`` for back-compat with pre-boards installs. This
    function returns the directory where *additional* named boards live,
    used by :func:`list_boards` to enumerate them.
    kanbanboardsr[    r!   r   boards_rootra     s     ==8#h..r!   c                 *    t                      dz  dz  S )zReturn the path to ``<root>/kanban/current``.

    One-line text file written by ``hermes kanban boards switch <slug>``
    to persist the user's board selection across CLI invocations. Absent
    by default (meaning: active board is ``default``).
    r]   currentr_   r`   r!   r   current_board_pathrd   *  s     ==8#i//r!   c                    t                                           pd                                } | r4	 t          |           }|rt	          |          r|S n# t
          $ r Y nw xY wt          j                            dd                                          }|r4	 t          |          }|rt	          |          r|S n# t
          $ r Y nw xY w	 t                      }|	                                r^|
                    d                                          }|r4	 t          |          }|rt	          |          r|S n# t
          $ r Y nw xY wn# t          $ r Y nw xY wt          S )u`  Return the active board slug, honouring the resolution chain.

    Order (highest precedence first):

    1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
       spawn, or manually for ad-hoc overrides).
    2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
       switch``), but only when that board still exists.
    3. ``DEFAULT_BOARD`` (``"default"``).

    A malformed or stale slug at any step falls through to the next layer
    with a best-effort warning — the dispatcher must never crash because a
    user hand-edited a file or removed a board directory.
    r*   HERMES_KANBAN_BOARDutf-8encoding)rE   r.   r/   rS   board_existsr0   r,   r-   rd   exists	read_textOSErrorDEFAULT_BOARD)scopednormedenvfvals        r   get_current_boardrt   4  s    &))++1r88::F 	*622F ,v..  	 	 	D	 *...
3
3
9
9
;
;C
 	*3//F ,v..  	 	 	D	  88:: 	++w+//5577C 2377F &,v"6"6 &%!   D   sZ   !A 
A! A!!B< <
C	C	AE !D= <E =
E
E 	E

E 
EEc                    t          |           }|st          d          t                      }|j                            dd           |                    |dz   d           |S )uK  Persist ``slug`` as the active board. Returns the file written.

    Writes ``<root>/kanban/current``. The caller should validate the slug
    exists first (via :func:`board_exists`) — this function does not —
    so that ``hermes kanban boards switch <typo>`` returns an error
    instead of silently pointing at nothing.
    board slug is requiredTparentsexist_ok
rg   rh   )rS   r0   rd   parentmkdir
write_text)rF   rp   paths      r   set_current_boardr   d  sm     #4((F 31222DKdT222OOFTMGO444Kr!   Nonec                 j    	 t                                                       dS # t          $ r Y dS w xY w)zLRemove ``<root>/kanban/current`` so the active board reverts to ``default``.N)rd   unlinkFileNotFoundErrorr`   r!   r   clear_current_boardr   u  sG    ##%%%%%   s    $ 
22boardc                P    t          |           pt          }t                      |z  S )u  Return the on-disk directory for ``board``.

    ``default`` is ``<root>/kanban/boards/default/`` **for metadata only**
    (board.json + workspaces/ + logs/). Its DB file stays at
    ``<root>/kanban.db`` for back-compat — see :func:`kanban_db_path`.

    All other boards live at ``<root>/kanban/boards/<slug>/`` with
    everything inside that directory including the ``kanban.db``.
    )rS   rn   ra   r   rF   s     r   	board_dirr   }  s%     !''8=D==4r!   boolc                    t          |           pt          }|t          k    rdS t          |          }|dz                                  p|dz                                  S )u  Return True if the board has persisted metadata or a DB on disk.

    ``default`` is considered to always exist — its DB is created
    on first :func:`connect` and there's no way for it to be missing
    in a configuration where the kanban feature is usable at all.
    T
board.json	kanban.db)rS   rn   r   rk   )r   rF   ds      r   rj   rj     s]     !''8=D}t$A$$&&D1{?*B*B*D*DDr!   c                F   t           j                            dd                                          }|r!t	          |                                          S t          |           }|t                      }|t          k    rt                      dz  S t          |          dz  S )u^  Return the path to the ``kanban.db`` for ``board``.

    Resolution (highest precedence first):

    1. ``HERMES_KANBAN_DB`` env var — pins the path directly. Honoured for
       back-compat and for the dispatcher→worker handoff (defense in
       depth: dispatcher injects this into worker env so workers are
       immune to any path-resolution disagreement).
    2. When ``board`` arg is None, the active board from
       :func:`get_current_board` is used.
    3. Board ``default`` → ``<root>/kanban.db`` (back-compat path).
       Other boards → ``<root>/kanban/boards/<slug>/kanban.db``.
    HERMES_KANBAN_DBr*   Nr   r,   r-   r.   r/   r	   rX   rS   rt   rn   r[   r   r   rZ   rF   s      r   kanban_db_pathr     s     z~~0"55;;==H +H~~((*** ''D| ""}}}{**T??[((r!   c                L   t           j                            dd                                          }|r!t	          |                                          S t          |           }|t                      }|t          k    rt                      dz  dz  S t          |          dz  S )u  Return the directory under which ``scratch`` workspaces are created.

    Anchored per-board so workspaces don't leak between projects.
    ``HERMES_KANBAN_WORKSPACES_ROOT`` pins the path directly (highest
    precedence) — the dispatcher injects this into worker env.

    ``default`` keeps the legacy path ``<root>/kanban/workspaces/`` so
    that existing scratch workspaces from before the boards feature are
    preserved. Other boards use ``<root>/kanban/boards/<slug>/workspaces/``.
    HERMES_KANBAN_WORKSPACES_ROOTr*   Nr]   
workspacesr   r   s      r   workspaces_rootr     s     z~~=rBBHHJJH +H~~((*** ''D| ""}}}x',66T??\))r!   c                L   t           j                            dd                                          }|r!t	          |                                          S t          |           }|t                      }|t          k    rt                      dz  dz  S t          |          dz  S )uO  Return the directory under which task file attachments are stored.

    Mirrors :func:`worker_logs_dir` / :func:`workspaces_root`: anchored
    per-board so attachments don't leak between projects. Each task gets
    its own ``<root>/.../attachments/<task_id>/`` subdirectory.

    ``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest
    precedence) for tests and unusual deployments.

    ``default`` uses ``<root>/kanban/attachments/``; other boards use
    ``<root>/kanban/boards/<slug>/attachments/``.

    Workers (which run with full file-tool access) read attached files
    by the absolute path surfaced in :func:`build_worker_context`. On the
    local terminal backend — the default for kanban — that path resolves
    directly. Remote backends (Docker/Modal) need this directory mounted;
    see the kanban docs.
    HERMES_KANBAN_ATTACHMENTS_ROOTr*   Nr]   attachmentsr   r   s      r   attachments_rootr     s    & z~~>CCIIKKH +H~~((*** ''D| ""}}}x'-77T??]**r!   task_idc                (    t          |          | z  S )z?Return the per-task attachment directory ``<root>/<task_id>/``.r   )r   r   r   s     r   task_attachments_dirr     s    %(((722r!   c                    t          |           }|t                      }|t          k    rt                      dz  dz  S t	          |          dz  S )uD  Return the directory under which per-task worker logs are written.

    ``default`` keeps the legacy path ``<root>/kanban/logs/``. Other
    boards use ``<root>/kanban/boards/<slug>/logs/``. Logs follow the
    board — makes ``hermes kanban log`` unambiguous even when multiple
    boards have tasks with the same id.
    Nr]   logs)rS   rt   rn   r[   r   r   s     r   worker_logs_dirr     sQ     !''D| ""}}}x'&00T??V##r!   c                R    t          |           pt          }t          |          dz  S )zReturn the path to ``board.json`` for ``board``.

    Stores display metadata (display name, description, icon, color,
    created_at). The on-disk slug is the canonical identity; this file
    is purely for presentation in the CLI / dashboard.
    r   )rS   rn   r   r   s     r   board_metadata_pathr     s'     !''8=DT??\))r!   c                    d                     d |                     dd                              d          D                       p| S )u   Turn a slug into a reasonable default display name.

    ``atm10-server`` → ``Atm10 Server``. Users can override via
    ``board.json`` but the default should look presentable in the
    dashboard without any follow-up editing.
     c              3  B   K   | ]}||                                 V  d S r   )
capitalize)r   parts     r   r    z._default_board_display_name.<locals>.<genexpr>  s2      \\$W[\DOO%%\\\\\\r!   _-)joinreplacesplit)rF   s    r   _default_board_display_namer     sG     88\\$,,sC2H2H2N2Ns2S2S\\\\\d`ddr!   dictc           	        t          |           pt          }|t          |          ddddddd}	 t          |          }|                                rWt          j        |                    d                    }t          |t                    r||d<   |
                    |           n# t          t
          j        f$ r Y nw xY wt          t          |                    |d<   |S )	u8  Return ``board.json`` contents (or synthesized defaults).

    Never raises — a missing / malformed ``board.json`` falls back to a
    synthesised entry so the dashboard always has something to render.
    Includes the canonical ``slug`` and ``db_path`` so the caller
    doesn't need to reconstruct them.
    r*   NF)rF   r   descriptioniconcolordefault_workdir
created_atr   rg   rh   rF   db_path)rS   rn   r   r   rk   jsonloadsrl   
isinstancer   updaterm   JSONDecodeErrorrG   r   )r   rF   metapr2   s        r   read_board_metadatar     s     !''8=D+D11	 	D
%%88:: 	!*Q[['[::;;C#t$$ ! #FC   T)*   ...//DOKs   A:B+ +CC)r   r   r   r   r   r   r   r   r   r   r   Optional[bool]r   c                  t          |           pt          }t          |          }|                    dd           |3t	          |                                          pt          |          |d<   |t	          |          |d<   |t	          |          |d<   |t	          |          |d<   |t          |          |d<   ||rt	          |          nd|d<   |                    d	          s#t          t          j
                              |d	<   t          |          }	|	j                            d
d
           |	                    t          j        |dd          dz   d           t	          t#          |                    |d<   |S )zCreate / update ``board.json`` for ``board``.

    Preserves any existing fields not mentioned in the call. Sets
    ``created_at`` on first write. Returns the resulting metadata dict.
    r   Nr   r   r   r   r   r   r   Trw      F)indentensure_asciirz   rg   rh   )rS   rn   r   poprG   r/   r   r   r.   r&   timer   r{   r|   r}   r   dumpsr   )
r   r   r   r   r   r   r   rF   r   r~   s
             r   write_board_metadatar   2  s    !''8=Dt$$D 	HHY4yy((M,G,M,MV!+..]4yyVE

W>>Z":I"S#o"6"6"6t88L!! . --\t$$DKdT222OO
4666=     ...//DOKr!   r   r   r   r   r   c                   t          |           }|st          d          t          ||||||          }t          |           |S )u
  Create a new board directory + DB + metadata. Idempotent.

    Returns the resulting metadata. Raises :class:`ValueError` for a
    malformed slug; returns the existing metadata (not an error) if the
    board already exists — matching ``mkdir -p`` semantics.
    rv   r   r   )rS   r0   r   init_db)rF   r   r   r   r   r   rp   r   s           r   create_boardr   ^  sf     #4((F 31222'  D &Kr!   T)include_archivedr   
list[dict]c                   g }t                      }|                    t          t                               |                    t                     t                      }|                                rt          |                                d           D ]}|                                s|j	        }	 t          |          }n# t          $ r Y ;w xY w|r||v rF|dz                                  }|dz                                  }|s|syt          |          }	|	                    d          r| s|                    |	           |                    |           |S )a  Enumerate all boards that exist on disk.

    Always includes ``default`` (even when the ``boards/default/``
    metadata dir doesn't exist, because its DB is at the legacy path).
    Other boards are discovered by scanning ``boards/`` for subdirectories
    that either contain a ``kanban.db`` or a ``board.json``.

    Returns a list of metadata dicts, sorted with ``default`` first and
    the rest alphabetically.
    c                4    | j                                         S r   )r   rO   )r   s    r   <lambda>zlist_boards.<locals>.<lambda>  s    !&,,.. r!   keyr   r   r   )rI   appendr   rn   addra   is_dirsortediterdirr   rS   r0   rk   r.   )
r   entriesseenrootchildrF   rp   has_dbhas_metar   s
             r   list_boardsr   }  sy    GUUD NN&}55666HH]==D{{}} DLLNN0H0HIII 	 	E<<>> :D.t44    Vt^^k)1133F,4466H h &v..Dxx
## ,< NN4   HHVNs   7C
CC)archiver   c               >   t          |           }|st          d          |t          k    rt          d          t          |          }|                                st          d|d          t                      |k    rt                       t                              t          |dz  
                                                     |rt                      dz  }|                    dd           t          t          j                              }|| d	| z  }d
}|                                r&|| d	| d	| z  }|d
z  }|                                &|                    |           |dt          |          dS ddl} |j        |           |dddS )u  Remove or archive a board.

    ``archive=True`` (default) moves the board's directory to
    ``<root>/kanban/boards/_archived/<slug>-<timestamp>/`` so the data
    is recoverable. ``archive=False`` deletes the directory outright.

    The ``default`` board cannot be removed — raises :class:`ValueError`.
    Returns a summary dict describing what happened (``{"slug", "action",
    "new_path"}``).
    rv   z%the 'default' board cannot be removedzboard z does not existr   	_archivedTrw   r   r(   r   )rF   actionnew_pathr   Ndeletedr*   )rS   r0   rn   r   rk   rt   r   _INITIALIZED_PATHSdiscardrG   resolvera   r|   r&   r   renameshutilrmtree)	rF   r   rp   r   archive_roottstargetsuffixr   s	            r   remove_boardr     s    #4((F 31222@AAA&A88:: =;&;;;<<< f$$
 sAO#<#<#>#>??@@@ E"}}{24$7776 0 0B 0 00mmoo 	!v$=$=$=$=V$=$==FaKF mmoo 	 	
*#f++NNNa)DDDr!   c                     e Zd ZU dZded<   ded<   ded<   ded<   ded<   d	ed
<   ded<   d	ed<   ded<   ded<   ded<   ded<   ded<   ded<   ded<   dZded<   dZded<   dZded<   dZd	ed<   dZ	ded<   dZ
ded<   dZded<   dZded<   dZded<   dZded <   dZded!<   dZd"ed#<   dZded$<   dZded%<   d&Zd'ed(<   dZded)<   dZded*<   ed0d/            ZdS )1Taskz1In-memory view of a row from the ``tasks`` table.rG   idtitlerM   bodyassigneestatusr&   priority
created_byr   r$   
started_atcompleted_atworkspace_kindworkspace_path
claim_lockclaim_expirestenantNbranch_nameresultidempotency_keyr   consecutive_failures
worker_pidlast_failure_errormax_runtime_secondslast_heartbeat_atcurrent_run_idworkflow_template_idcurrent_step_keyzOptional[list]skillsmodel_overridemax_retriesFr   	goal_modegoal_max_turns
session_idrowsqlite3.Rowr%   'Task'c           	        t          |                                          }d }d|v rW|d         rO	 t          j        |d                   }t	          |t
                    rd |D             }n# t          $ r d }Y nw xY w | d&i d|d         d|d         d|d         d|d         d|d         d|d         d	|d	         d
|d
         d|d         d|d         d|d         d|d         dd|v r|d         nd d|d         d|d         dd|v r|d         nd dd|v r|d         nd dd|v r|d         nd dd|v r|d         nd|v r|d         nddd|v r|d         nd dd|v r|d         nd|v r|d         nd dd|v r|d         nd dd|v r|d         nd dd|v r|d         nd dd|v r|d         nd dd|v r|d         nd d|d d |v r|d          r|d          nd d!d!|v r|d!         nd d"d"|v r|d"         rt          |d"                   nd#d$d$|v r|d$         r|d$         nd d%d%|v r|d%         nd S )'Nr  c                0    g | ]}|t          |          S r`   )rG   )r   rR   s     r   
<listcomp>z!Task.from_row.<locals>.<listcomp>.  s#    #@#@#@qa#@CFF#@#@#@r!   r   r   r   r   r   r   r   r   r   r   r   r   r  r   r   r   r  r  r  spawn_failuresr   r  r  last_spawn_errorr  r  r	  r
  r  r  r  r  Fr  r  r`   )rI   keysr   r   r   list	Exceptionr   )clsr  r  skills_valuer3   s        r   from_rowzTask.from_row%  s5   388::'+tH$CM22fd++ A#@#@F#@#@#@L $ $ $#$s >
 >
 >
4yy>
g,,>
 V>
 __	>

 x==>
 __>
 <((>
 <((>
 <((>
 ^,,>
 /00>
 /00>
 /<t.C.CM**>
 <((>
 o..>
  %-$4$43x==$!>
" %-$4$43x==$#>
$ 7H46O6OC 122UY%>
( 0F/M/M*++
 0@4/G/Gc*++Q3>
6 -9D,@,@s<((d7>
: .BT-I-I())1Ct1K1Kc,--QU?>
D /Dt.K.K)**QUE>
J -@4,G,G'((TK>
P *:T)A)A$%%tQ>
V 0F/M/M*++SWW>
\ ,>+E+E&''4]>
`  <a>
b 5E4L4LQTUeQf4L3/00lpc>
f '4t&;&;M""g>
l +6*=*=#kBR*=S%&&&X]m>
r *:T)A)AcJZF[)A$%%aes>
x &2T%9%9L!!ty>
 >	
s   ;A- -A<;A<)r  r  r%   r  )__name__
__module____qualname____doc____annotations__r  r  r  r  r  r  r  r  r	  r
  r  r  r  r  r  r  r  classmethodr  r`   r!   r   r   r     s        ;;GGGJJJKKKMMMOOO!!!!    !%K%%%% F    %)O)))) !"!!!! $J$$$$ )-,,,,)-----'+++++$(N((((*.....&*****
 "F!!!!$(N(((( "&K%%%% I %)N(((( !%J$$$$I
 I
 I
 [I
 I
 I
r!   r   c                      e Zd ZU dZded<   ded<   ded<   ded<   ded	<   ded
<   ded<   ded<   ded<   ded<   ded<   ded<   ded<   ded<   ded<   ded<   edd            ZdS )Runur  In-memory view of a ``task_runs`` row.

    A run is one attempt to execute a task — created on claim, closed
    on complete/block/crash/timeout/spawn_failure/reclaim. Multiple runs
    per task when retries happen. Carries the claim machinery, PID,
    heartbeat, and the structured handoff summary that downstream workers
    read via ``build_worker_context``.
    r&   r   rG   r   rM   profilestep_keyr   r   r$   r   r  r  r  r   ended_atoutcomesummaryOptional[dict]metadataerrorr  r  r%   'Run'c           	        	 |d         rt          j        |d                   nd }n# t          $ r d }Y nw xY w | di dt          |d                   d|d         d|d         d|d         d|d         d|d         d|d         d	|d	         d
|d
         d|d         dt          |d                   d|d         t          |d                   nd d|d         d|d         d|d|d         S )Nr.  r   r   r(  r)  r   r   r   r  r  r  r   r*  r+  r,  r/  r`   )r   r   r  r&   )r  r  r   s      r   r  zRun.from_row  s   	25j/K4:c*o...tDD 	 	 	DDD	s 
 
 
3t9~~~
	NN
 	NN
 __	

 x==
 <((
 o..
 <((
 !$$9 : :
 ""566
 3|,---
 /2*o.Ic#j/***t
 	NN
 	NN
 T
  g,,!
 	
s   $' 66N)r  r  r%   r0  )r   r!  r"  r#  r$  r%  r  r`   r!   r   r'  r'  r  s           GGGLLLKKK    &&&&$$$$OOO
 
 
 [
 
 
r!   r'  c                  B    e Zd ZU ded<   ded<   ded<   ded<   ded<   dS )	Commentr&   r   rG   r   authorr   r   N)r   r!  r"  r$  r`   r!   r   r3  r3    s=         GGGLLLKKKIIIOOOOOr!   r3  c                  d    e Zd ZU dZded<   ded<   ded<   ded<   ded	<   ded
<   ded<   ded<   dS )
Attachmentz<In-memory view of a row from the ``task_attachments`` table.r&   r   rG   r   filenamestored_pathrM   content_typesizeuploaded_byr   N)r   r!  r"  r#  r$  r`   r!   r   r6  r6    sg         FFGGGLLLMMMIIIOOOOOr!   r6  c                  P    e Zd ZU ded<   ded<   ded<   ded<   ded<   d	Zd
ed<   d	S )Eventr&   r   rG   r   kindr-  payloadr   Nr$   run_id)r   r!  r"  r$  r@  r`   r!   r   r=  r=    sS         GGGLLLIIIOOO F      r!   r=  u  
CREATE TABLE IF NOT EXISTS tasks (
    id                   TEXT PRIMARY KEY,
    title                TEXT NOT NULL,
    body                 TEXT,
    assignee             TEXT,
    status               TEXT NOT NULL,
    priority             INTEGER DEFAULT 0,
    created_by           TEXT,
    created_at           INTEGER NOT NULL,
    started_at           INTEGER,
    completed_at         INTEGER,
    workspace_kind       TEXT NOT NULL DEFAULT 'scratch',
    workspace_path       TEXT,
    branch_name          TEXT,
    claim_lock           TEXT,
    claim_expires        INTEGER,
    tenant               TEXT,
    result               TEXT,
    idempotency_key      TEXT,
    -- Unified consecutive-failure counter. Incremented on spawn
    -- failure, timeout, or crash; reset only on successful completion.
    -- The circuit breaker in _record_task_failure trips when this
    -- exceeds DEFAULT_FAILURE_LIMIT consecutive non-successes.
    consecutive_failures INTEGER NOT NULL DEFAULT 0,
    worker_pid           INTEGER,
    -- Short excerpt of the most recent failure's error text.
    last_failure_error   TEXT,
    max_runtime_seconds  INTEGER,
    last_heartbeat_at    INTEGER,
    -- Pointer into task_runs for the currently-active run (NULL if no
    -- run is in-flight). Denormalised for cheap reads.
    current_run_id       INTEGER,
    -- Forward-compat for v2 workflow routing. In v1 the kernel writes
    -- these when the task is opted into a template but otherwise ignores
    -- them; the dispatcher doesn't consult them for routing yet.
    workflow_template_id TEXT,
    current_step_key     TEXT,
    -- Force-loaded skills for the worker on this task, stored as JSON.
    -- Appended to the dispatcher's built-in `--skills kanban-worker`.
    -- NULL or empty array = no extras.
    skills               TEXT,
    -- Per-task model override. When set, the dispatcher passes -m <model>
    -- to the worker, overriding the profile's default model. NULL = use
    -- the profile default.
    model_override       TEXT,
    -- Per-task override for the consecutive-failure circuit breaker.
    -- The value is the failure count at which the breaker trips — e.g.
    -- ``max_retries=1`` blocks on the first failure. NULL (the common
    -- case) falls through to the dispatcher-level ``kanban.failure_limit``
    -- config and then ``DEFAULT_FAILURE_LIMIT``.
    max_retries          INTEGER,
    -- When 1, the dispatched worker runs in a Ralph-style goal loop: an
    -- auxiliary judge re-evaluates the worker's response against the
    -- card title/body after each turn and feeds a continuation prompt
    -- back into the SAME session until the judge agrees the work is done
    -- or ``goal_max_turns`` is exhausted. NULL/0 = classic single-shot
    -- worker (the default).
    goal_mode            INTEGER NOT NULL DEFAULT 0,
    -- Goal-loop turn budget for ``goal_mode`` workers. NULL = use the
    -- goals-engine default.
    goal_max_turns       INTEGER,
    -- Originating chat/agent session id when the task was created from
    -- inside an agent loop that propagated ``HERMES_SESSION_ID``. NULL
    -- for tasks created from the CLI, dashboard, or any path that doesn't
    -- set the env var. Indexed so per-session list queries stay cheap on
    -- larger boards.
    session_id           TEXT
);

CREATE TABLE IF NOT EXISTS task_links (
    parent_id  TEXT NOT NULL,
    child_id   TEXT NOT NULL,
    PRIMARY KEY (parent_id, child_id)
);

CREATE TABLE IF NOT EXISTS task_comments (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id    TEXT NOT NULL,
    author     TEXT NOT NULL,
    body       TEXT NOT NULL,
    created_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS task_events (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id    TEXT NOT NULL,
    run_id     INTEGER,
    kind       TEXT NOT NULL,
    payload    TEXT,
    created_at INTEGER NOT NULL
);

-- Historical attempt record. Each time the dispatcher claims a task, a
-- new row is created here; claim state, PID, heartbeat, runtime cap,
-- and structured summary all live on the run, not the task. Multiple
-- rows per task id when the task was retried after crash/timeout/block.
-- v2 of the kanban schema will use ``step_key`` to drive per-stage
-- workflow routing; in v1 the column is nullable and unused (kernel
-- ignores it).
CREATE TABLE IF NOT EXISTS task_runs (
    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id             TEXT NOT NULL,
    profile             TEXT,
    step_key            TEXT,
    status              TEXT NOT NULL,
    -- status: running | done | blocked | crashed | timed_out | failed | released
    claim_lock          TEXT,
    claim_expires       INTEGER,
    worker_pid          INTEGER,
    max_runtime_seconds INTEGER,
    last_heartbeat_at   INTEGER,
    started_at          INTEGER NOT NULL,
    ended_at            INTEGER,
    outcome             TEXT,
    -- outcome: completed | blocked | crashed | timed_out | spawn_failed |
    --          gave_up | reclaimed | (null while still running)
    summary             TEXT,
    metadata            TEXT,
    error               TEXT
);

-- Files attached to a task (PDFs, images, source documents). The blob
-- lives on disk under ``attachments_root(board)/<task_id>/<stored_name>``;
-- this row carries metadata + the absolute ``stored_path`` so the
-- dashboard can list/download and ``build_worker_context`` can surface
-- the absolute path to the worker (which has full file-tool access). See
-- #35338.
CREATE TABLE IF NOT EXISTS task_attachments (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id      TEXT NOT NULL,
    filename     TEXT NOT NULL,
    stored_path  TEXT NOT NULL,
    content_type TEXT,
    size         INTEGER NOT NULL DEFAULT 0,
    uploaded_by  TEXT,
    created_at   INTEGER NOT NULL
);

-- Subscription from a gateway source (platform + chat + thread) to a
-- task. The gateway's kanban-notifier watcher tails task_events and
-- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to
-- the original requester so human-in-the-loop workflows close the loop.
CREATE TABLE IF NOT EXISTS kanban_notify_subs (
    task_id       TEXT NOT NULL,
    platform      TEXT NOT NULL,
    chat_id       TEXT NOT NULL,
    thread_id     TEXT NOT NULL DEFAULT '',
    user_id       TEXT,
    notifier_profile TEXT,
    created_at    INTEGER NOT NULL,
    last_event_id INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (task_id, platform, chat_id, thread_id)
);

CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status);
CREATE INDEX IF NOT EXISTS idx_tasks_status          ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_links_child           ON task_links(child_id);
CREATE INDEX IF NOT EXISTS idx_links_parent          ON task_links(parent_id);
CREATE INDEX IF NOT EXISTS idx_comments_task         ON task_comments(task_id, created_at);
CREATE INDEX IF NOT EXISTS idx_events_task           ON task_events(task_id, created_at);
CREATE INDEX IF NOT EXISTS idx_runs_task             ON task_runs(task_id, started_at);
CREATE INDEX IF NOT EXISTS idx_runs_status           ON task_runs(status);
CREATE INDEX IF NOT EXISTS idx_attachments_task      ON task_attachments(task_id, created_at);
CREATE INDEX IF NOT EXISTS idx_notify_task           ON kanban_notify_subs(task_id);
zset[str]r   s   SQLite format 3 i c                     t           j                            dd                                          } | r+	 t	          |           }n# t
          $ r d}Y nw xY w|dk    r|S t          S )a%  Return the SQLite busy timeout for Kanban connections.

    Kanban is the shared cross-profile dispatch bus, so worker stampedes are
    expected.  A long busy timeout lets SQLite serialize writers via WAL rather
    than surfacing transient ``database is locked`` failures during bursts.
    HERMES_KANBAN_BUSY_TIMEOUT_MSr*   r   )r,   r-   r.   r/   r&   r0   DEFAULT_BUSY_TIMEOUT_MSr;   s     r   _resolve_busy_timeout_msrD    sx     *..8"
=
=
C
C
E
EC
 	XXFF 	 	 	FFF	A::M""r=   r~   sqlite3.Connectionc                    t                      }t          j        t          |           d|dz            }|                    d|            |S )z=Open a Kanban SQLite connection with consistent lock waiting.Ng     @@)isolation_leveltimeoutzPRAGMA busy_timeout=)rD  sqlite3connectrG   execute)r~   busy_timeout_msconns      r   _sqlite_connectrN    sY    .00O?D		&(  D 	LL999:::Kr!   c              #    K   | j                             dd           |                     | j        dz             }|                    d          }	 t
          rYddl}|                    d           t          |d          }t          |d          } ||	                                |d	           n1ddl
}|                    |	                                |j                   dV  	 t
          rYddl}|                    d           t          |d          }t          |d
          } ||	                                |d	           n1ddl
}|                    |	                                |j                   |                                 dS # |                                 w xY w# 	 t
          rYddl}|                    d           t          |d          }t          |d
          } ||	                                |d	           n1ddl
}|                    |	                                |j                   |                                 w # |                                 w xY wxY w)a  Serialize first-connect WAL/schema/integrity setup across processes.

    ``_INIT_LOCK`` only protects threads inside one Python process. During a
    dispatcher burst, many worker processes can all hit a fresh/legacy board at
    once and each process has an empty ``_INITIALIZED_PATHS`` cache. This file
    lock keeps header validation, integrity probing, WAL activation, and
    additive migrations single-file/single-writer across the whole host while
    leaving normal post-init DB usage concurrent under SQLite WAL.
    Trw   z
.init.lockza+br   NlockingLK_LOCKr(   LK_UNLCK)r{   r|   	with_namer   open_IS_WINDOWSmsvcrtseekgetattrfilenofcntlflockLOCK_EXLOCK_UNclose)r~   	lock_pathhandlerV  rP  	lock_moderZ  unlock_modes           r   _cross_process_init_lockrc    s:      	KdT222ty<788I^^E""F  	8MMM KKNNNfi00G	22IGFMMOOY2222LLLKK777	 
<A!&)44%fj99a8888FMMOOU];;;LLNNNNNFLLNNNN	 
<A!&)44%fj99a8888FMMOOU];;;LLNNNNFLLNNNNs3   BF( (BF F%(I)*BI;I)I&&I)databytesoffsetc                    t          |           |dz   k     rdS | |         }| |dz            }| |dz            }t                              | |dz   |dz            d          }|dv o|dk    o|dv od	|cxk     od
k    nc S )z9Return True for a TLS record header at ``data[offset:]``.   Fr(   r      big>               >   r   r(   r   ri     r   i H  )lenr&   
from_bytes)rd  rf  r9  majorminorlengths         r   _looks_like_tls_record_atru    s    
4yy6A:u<L!E!E^^D!FQJ!67??F00 	 TM	 33	  %	r!   c           	     J   	 |                                  }n# t          $ r Y dS t          $ r Y dS w xY w|j        dk    rdS 	 |                     d          5 }|                    d          }ddd           n# 1 swxY w Y   n# t          $ r Y dS w xY w|                    t                    rdS d}|                    d          rt          |d          rd}nt          |d          rd	}t          j
        d
|  | d|dd                             d                     )a  Fail early with an actionable error for non-SQLite Kanban DB files.

    ``sqlite3.connect()`` creates missing and zero-byte files, so those are
    allowed. Existing non-empty files must have the SQLite header before we
    hand them to SQLite/WAL setup. This keeps corrupted page-0 failures from
    being collapsed into a generic PRAGMA error and lets the gateway's corrupt
    board handling identify the board by fingerprint.
    Nr   rb@   r*   s   SQLitrh  z. (TLS record header detected at byte offset 5)z. (TLS record header detected at byte offset 0)z2file is not a database: invalid SQLite header for z; first_32=    r   )statr   rm   st_sizerT  read
startswith_SQLITE_HEADERru  rI  DatabaseErrorhex)r~   rz  r`  head	signatures        r   _validate_sqlite_headerr    s   yy{{      |qYYt__ 	#;;r??D	# 	# 	# 	# 	# 	# 	# 	# 	# 	# 	# 	# 	# 	# 	#   ~&& Ix   E%>tQ%G%G ED			"4	+	+ ED	

	<	<	< 	<'+CRCy}}S'9'9	< 	<  sF    
1	11B A9-B 9A==B  A=B 
BBc                  $     e Zd ZdZd	 fdZ xZS )
KanbanDbCorruptErrora  Raised when an existing kanban DB file fails integrity checks.

    Fail-closed guard against silent recreation of a corrupt board file,
    which would otherwise destroy the user's tasks. Carries both the
    original path and the timestamped backup we made before refusing.
    r   r	   backup_pathOptional[Path]reasonrG   c           	         || _         || _        || _        |t          |          nd}t	                                          d| d| d| d           d S )Nz<backup failed>z&Refusing to open corrupt kanban DB at : z . Original preserved; backup at .)r   r  r  rG   super__init__)selfr   r  r  
backup_str	__class__s        r   r  zKanbanDbCorruptError.__init__  s    &)4)@S%%%FW
;W ; ; ; ;-7; ; ;	
 	
 	
 	
 	
r!   )r   r	   r  r  r  rG   r   r!  r"  r#  r  __classcell__r  s   @r   r  r    sG         
 
 
 
 
 
 
 
 
 
r!   r  r  c                *   |                                  }|j        }|j        }t          j                    }	 |                    d          5 t          fdd          D ]}|                    |           	 ddd           n# 1 swxY w Y   n# t          $ r Y dS w xY w|	                                dd         }|| d| dz  }|j        |k    rdS |
                                s(	 t          j        ||           n# t          $ r Y dS w xY wdD ]}}|||z   z  }	|	j        |k    s|	
                                s*||j        |z   z  }
|
j        |k    s|

                                rW	 t          j        |	|
           n# t          $ r Y zw xY w|S )	u{  Copy a corrupt DB (and its WAL/SHM sidecars) to a content-addressed backup.

    The backup filename is deterministic in the main DB's sha256, so repeated
    quarantines of the same corrupt bytes (gateway restarts, dispatcher retries,
    multi-profile fleets all hitting the same shared DB) reuse one backup
    instead of amplifying disk usage by N. If the corrupt bytes actually
    change between attempts — e.g. a partial repair or further damage — the
    fingerprint changes and a separate backup is preserved.

    Returns the backup path of the main DB file, or ``None`` if the copy
    itself failed (the caller still raises loudly in that case).

    Writes are confined to the original DB's parent directory. The backup
    basename is derived purely from ``path.name`` and a content hash, never
    from caller-supplied directory segments — no traversal is possible.
    rw  c                 .                          d          S )Ni   )r|  )r`  s   r   r   z$_backup_corrupt_db.<locals>.<lambda>1  s    fkk+&>&> r!   r!   N   z	.corrupt.z.bak)z-walz-shm)r   r{   r   hashlibsha256rT  iterr   rm   	hexdigestrk   r   copy2)r~   resolvedr{   	base_namedigestchunkrK   	candidater   sidecarsidecar_backupr`  s              @r   _backup_corrupt_dbr    s@   ( ||~~H_FI^F]]4   	%F>>>>DD % %e$$$$%	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	%    ttss#EI;;;;;;I6!!t 	L9---- 	 	 	44	" 
 
I./>V##7>>+;+;#9>F#:; F**n.C.C.E.E*	L.1111 	 	 	D	sY   B ,B:B B

B B
B 
B B ,D 
DD-F
FFc                   	 |                                  }n# t          $ r Y dS w xY w	 |                                r|                                j        dk    rdS n# t          $ r Y dS w xY wt          |          t          v rdS d}	 t          |          }	 |                    d          	                                }|
                                 n# |
                                 w xY w|r |d         pd                                dk    rd|r|d         nd}n0# t          j        $ r  t          j        $ r}d| }Y d}~nd}~ww xY w|dS t          |          }t!          |||          )	u  Run ``PRAGMA integrity_check`` on an existing non-empty DB file.

    Opens the probe in read/write mode so SQLite can recover or
    checkpoint a healthy WAL/hot-journal DB before we declare it
    corrupt. If the file is malformed, copy it (and any WAL/SHM
    sidecars) to a timestamped backup and raise
    :class:`KanbanDbCorruptError` so callers cannot silently recreate
    the schema on top of a damaged DB.

    Transient lock/busy errors (``sqlite3.OperationalError``) are NOT
    treated as corruption; they propagate raw so the caller sees a
    normal lock failure and no spurious ``.corrupt`` backup is made.

    No-op for missing files, zero-byte files (treated as fresh), and
    paths already proven healthy this process (cache hit).

    Path-trust note: ``path`` arrives via :func:`connect`, which itself
    resolves it from an explicit ``db_path`` argument, the
    :func:`kanban_db_path` env-var chain, or the kanban-home default —
    all sources Hermes treats as user-controlled-but-trusted on the
    user's own machine. We additionally resolve the path here and
    confine all filesystem writes to its parent directory so any
    accidental ``..`` segments are collapsed before any I/O happens.
    Nr   zPRAGMA integrity_checkr*   okzintegrity_check returned z<no row>zsqlite refused to open file: )r   rm   rk   rz  r{  rG   r   rN  rK  fetchoner^  rO   rI  OperationalErrorr  r  r  )r~   r  r  prober  excbackups          r   _guard_existing_db_is_healthyr  M  s   6<<>>      	HMMOO$;q$@$@F %A   
8}}*** F7))	-- 899BBDDCKKMMMMEKKMMMM 	Ss1v|**,,44R31NQJRRF#     7 7 76667~))F
x
8
88sM    
%%1A 
A+*A+	D  'C  D  C++4D   E>EEr   r   c                  | | }nt          |          }|j                            dd           t          |          5  t	          |           t          |           t          |                                          }t          |          }	 t          j
        |_        t          5  ddlm}  ||d|j         d	           |                    d
           |                    d           |                    d           |                    d           |                    d           |t"          v}|rC|                    t&                     t)          |           t"                              |           ddd           n# 1 swxY w Y   n## t,          $ r |                                  w xY wddd           n# 1 swxY w Y   |S )u  Open (and initialize if needed) the kanban DB.

    WAL mode is enabled on every connection; it's a no-op after the first
    time but keeps the code robust if the DB file is ever re-created.

    The first connection to a given path auto-runs :func:`init_db` so
    fresh installs and test harnesses that construct `connect()`
    directly don't have to remember a separate init step. Subsequent
    connections skip the schema check via a module-level path cache.

    Path resolution:

    * ``db_path`` explicit → used as-is (legacy callers, tests).
    * ``board`` explicit → resolves to that board's DB.
    * Neither → :func:`kanban_db_path` resolves via
      ``HERMES_KANBAN_DB`` env → ``HERMES_KANBAN_BOARD`` env →
      ``<root>/kanban/current`` → ``default``.
    Nr   Trw   r   )apply_wal_with_fallbackzkanban.db ())db_labelzPRAGMA synchronous=FULLzPRAGMA wal_autocheckpoint=100zPRAGMA foreign_keys=ONzPRAGMA secure_delete=ONzPRAGMA cell_size_check=ON)r   r{   r|   rc  r  r  rG   r   rN  rI  Rowrow_factory
_INIT_LOCKhermes_stater  r   rK  r   executescript
SCHEMA_SQL_migrate_add_optional_columnsr   r  r^  )r   r   r~   r  rM  r  
needs_inits          r   rJ  rJ    sk   . E***KdT222	!$	'	' - - 	 %%% 	&d+++t||~~&&t$$#	&{D 5 5 A@@@@@''7QTY7Q7Q7QRRRR 6777<===5666 6777 8999%-??
 5 &&z2221$777&**8444=5 5 5 5 5 5 5 5 5 5 5 5 5 5 5>  	 	 	JJLLL	W- - - - - - - - - - - - - - -\ KsP   AGF)CF	=F	F	FF	FG F55GGGc             #     K   t          | |          }	 |V  	 |                                 dS # t          $ r Y dS w xY w# 	 |                                 w # t          $ r Y w w xY wxY w)u  Open a kanban DB connection and guarantee it is closed on exit.

    Use this instead of ``with kb.connect() as conn:`` — sqlite3's
    built-in connection context manager only commits/rollbacks the
    transaction; it does NOT close the file descriptor. In long-lived
    processes (gateway, dashboard) that route every kanban operation
    through ``connect()`` (e.g. ``run_slash`` dispatching ``/kanban …``
    commands, ``decompose_task_endpoint`` calling
    ``kanban_decompose.decompose_task``), the unclosed connections
    accumulate as open FDs to ``kanban.db`` and ``kanban.db-wal``. After
    enough operations the process hits the kernel FD limit and dies
    with ``[Errno 24] Too many open files``.

    See #33159 for the production incident.

    The ``connect()`` function itself remains unchanged so callers that
    intentionally manage the connection lifetime (tests, long-lived
    callers) continue to work.
    )r   r   N)rJ  r^  r  )r   r   rM  s      r   connect_closingr    s      2 7%000D


	JJLLLLL 	 	 	DD		JJLLLL 	 	 	D	s;   A 0 
>>A(AA(
A%"A($A%%A(c                  | | }nt          |          }|j                            dd           t          |                                          }t
          5  t                              |           ddd           n# 1 swxY w Y   t          j	        t          |                    5  	 ddd           n# 1 swxY w Y   |S )u  Create the schema if it doesn't exist; return the path used.

    Kept as a public entry point so CLI ``hermes kanban init`` and the
    daemon have something explicit to call. Unlike :func:`connect`'s
    first-time auto-init (which caches by path), ``init_db`` always
    re-runs the migration pass. Callers that know the on-disk schema
    may have drifted — tests that write legacy event kinds directly,
    external tools that upgrade an old DB file — can call this to
    force re-migration.
    Nr   Trw   )r   r{   r|   rG   r   r  r   r   
contextlibclosingrJ  )r   r   r~   r  s       r   r   r     s6    E***KdT2224<<>>""H 
 - -""8,,,- - - - - - - - - - - - - - -		GDMM	*	*                Ks$   BBB-B;;B?B?rM  tablecolumnddlc                    	 |                      d| d|            dS # t          j        $ r/}dt          |                                          v rY d}~dS  d}~ww xY w)a8  Run ``ALTER TABLE <table> ADD COLUMN <ddl>``, idempotent across races.

    Returns ``True`` when the column was actually added by this call.
    Swallows ``duplicate column name`` errors so a concurrent connection
    that ran the same migration first does not crash the dispatcher tick
    (issue #21708).
    ALTER TABLE z ADD COLUMN Tzduplicate column nameNF)rK  rI  r  rG   rO   )rM  r  r  r  r  s        r   _add_column_if_missingr    s|    <E<<s<<===t#   "c#hhnn&6&66655555s    A#AAAc                   d |                      d          D             }d|vrt          | ddd           d|vrt          | ddd           d|vrt          | ddd	           d
|vrt          | dd
d           d |                      d          D             }d|vr-t          | ddd          }|rd|v r|                      d           d|vrt          | ddd           d|vr-t          | ddd          }|rd|v r|                      d           d|vrt          | ddd           d|vrt          | ddd           d|vrt          | ddd           d|vrt          | ddd           d|vrt          | ddd            d!|vrt          | dd!d"           d#|vrt          | dd#d$           d%|vr|                      d&           d'|vrt          | dd'd(           d)|vrt          | dd)d*           d+|vrt          | dd+d,           |                      d-           |                      d.           |                      d/           d0 |                      d1          D             }d2|vrt          | d3d2d4           |                      d5           |                      d6                                          d7u}|r5d8 |                      d9          D             }d:|vrt          | d;d:d<           |                      d=                                          d7u}|r-t          |           5  |                      d>                                          }|D ]}|d?         pt          t          j                              }	|                      d@|dA         |dB         |dC         |dD         |d         |d         |d         |	f          }
|                      dE|
j        |dA         f          }|j        dFk    r;|                      dGt          t          j                              |
j        f           	 d7d7d7           n# 1 swxY w Y   dH}|D ]\  }}|                      dI||f           t          |            d7S )JzAdd columns that were introduced after v1 release to legacy DBs.

    Called by ``init_db`` so opening an old DB is always safe.
    c                    h | ]
}|d          S r   r`   r   r  s     r   	<setcomp>z0_migrate_add_optional_columns.<locals>.<setcomp>-      LLLCCKLLLr!   zPRAGMA table_info(tasks)r   tasksztenant TEXTr  zresult TEXTr  zbranch_name TEXTr  zidempotency_key TEXTc                    h | ]
}|d          S r  r`   r  s     r   r  z0_migrate_add_optional_columns.<locals>.<setcomp>A  r  r!   r  z/consecutive_failures INTEGER NOT NULL DEFAULT 0r  zCUPDATE tasks SET consecutive_failures = COALESCE(spawn_failures, 0)r  zworker_pid INTEGERr  zlast_failure_error TEXTr  z6UPDATE tasks SET last_failure_error = last_spawn_errorr  zmax_runtime_seconds INTEGERr  zlast_heartbeat_at INTEGERr	  zcurrent_run_id INTEGERr
  zworkflow_template_id TEXTr  zcurrent_step_key TEXTr  zskills TEXTr  zmax_retries INTEGERr  z0ALTER TABLE tasks ADD COLUMN model_override TEXTr  z$goal_mode INTEGER NOT NULL DEFAULT 0r  zgoal_max_turns INTEGERr  zsession_id TEXTz<CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant)zJCREATE INDEX IF NOT EXISTS idx_tasks_idempotency ON tasks(idempotency_key)zDCREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id)c                    h | ]
}|d          S r  r`   r  s     r   r  z0_migrate_add_optional_columns.<locals>.<setcomp>  s    UUUss6{UUUr!   zPRAGMA table_info(task_events)r@  task_eventszrun_id INTEGERzDCREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id)zOSELECT name FROM sqlite_master WHERE type='table' AND name='kanban_notify_subs'Nc                    h | ]
}|d          S r  r`   r  s     r   r  z0_migrate_add_optional_columns.<locals>.<setcomp>  s)     
 
 
CK
 
 
r!   z%PRAGMA table_info(kanban_notify_subs)notifier_profilekanban_notify_subsznotifier_profile TEXTzFSELECT name FROM sqlite_master WHERE type='table' AND name='task_runs'zSELECT id, assignee, claim_lock, claim_expires, worker_pid,        max_runtime_seconds, last_heartbeat_at, started_at FROM tasks WHERE status = 'running' AND current_run_id IS NULLr   aV  
                    INSERT INTO task_runs (
                        task_id, profile, status,
                        claim_lock, claim_expires, worker_pid,
                        max_runtime_seconds, last_heartbeat_at,
                        started_at
                    ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?)
                    r   r   r   r   zKUPDATE tasks SET current_run_id = ? WHERE id = ? AND current_run_id IS NULLr(   z_UPDATE task_runs SET status = 'reclaimed',     outcome = 'reclaimed', ended_at = ? WHERE id = ?))r   promoted)r   reprioritized)spawn_auto_blockedgave_upz.UPDATE task_events SET kind = ? WHERE kind = ?)
rK  r  r  	write_txnfetchallr&   r   	lastrowidrowcount_rebuild_drifted_tables)rM  colsaddedev_colsnotify_table_existsnotify_cols
runs_existinflightr  startedcurupd_EVENT_RENAMESoldnews                  r   r  r  (  s   
 ML4<<0J#K#KLLLDttWhFFFttWhFFFD  tWm=OPPP$$',.D	
 	
 	
 ML4<<0J#K#KLLLD T))&"=	
 
  	%--LLU   4tWl<PQQQ4''&'/1J
 
  	'4//LLH   D(('02O	
 	
 	
 $&&'.0K	
 	
 	
 t##'+-E	
 	
 	
 T))'13N	
 	
 	
 %%'-/F	
 	
 	
 t 	tWhFFFD   	tWm=RSSSt##GHHH$ 	';(N	
 	
 	
 t##'+-E	
 	
 	
 4
 	'<):	
 	
 	
 	LLOPPPLLT   	LLN   VUdll3S&T&TUUUGwt]H>NOOO
 	LL	%  
 ,,Y hjj  
 
#'<<0W#X#X
 
 
 [00"*,>@W   P hjjJ  *t__ )	 )	||F 
 hjj    " "l+?s49;;/?/?ll D	3z?C4EO,c,.?12C8K4L	 * ll>]CI. 
 <1$$LL' TY[[))3=9	  ;")	 )	 )	 )	 )	 )	 )	 )	 )	 )	 )	 )	 )	 )	 )	^N # 
 
S<#J	
 	
 	
 	

 D!!!!!s   (DP;;P?P?)zCREATE TABLE task_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, run_id INTEGER, kind TEXT NOT NULL, payload TEXT, created_at INTEGER NOT NULL))z@CREATE INDEX idx_events_task ON task_events(task_id, created_at)z6CREATE INDEX idx_events_run ON task_events(run_id, id))zCREATE TABLE task_comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL))zDCREATE INDEX idx_comments_task ON task_comments(task_id, created_at))ab  CREATE TABLE task_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, profile TEXT, step_key TEXT, status TEXT NOT NULL, claim_lock TEXT, claim_expires INTEGER, worker_pid INTEGER, max_runtime_seconds INTEGER, last_heartbeat_at INTEGER, started_at INTEGER NOT NULL, ended_at INTEGER, outcome TEXT, summary TEXT, metadata TEXT, error TEXT))z<CREATE INDEX idx_runs_task ON task_runs(task_id, started_at)z1CREATE INDEX idx_runs_status ON task_runs(status))a,  CREATE TABLE kanban_notify_subs ( task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL, thread_id TEXT NOT NULL DEFAULT '', user_id TEXT, notifier_profile TEXT, created_at INTEGER NOT NULL, last_event_id INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (task_id, platform, chat_id, thread_id)))z;CREATE INDEX idx_notify_task ON kanban_notify_subs(task_id))r  task_comments	task_runsr  c                v   |                      d| d                                          }|sdS |dk    r>t          d |D             d          }|duo|d         pd                                d	k    S t          d
 |D             d          }|dS |d         pd                                d	k    o|d          S )zGTrue when ``table`` still carries the legacy (pre-AUTOINCREMENT) shape.PRAGMA table_info(r  Fr  c              3  2   K   | ]}|d          dk    |V  dS )r   last_event_idNr`   r   cs     r   r    z%_table_has_drifted.<locals>.<genexpr>Q  s0      DD!qyO'C'CA'C'C'C'CDDr!   Ntyper*   INTEGERc              3  2   K   | ]}|d          dk    |V  dS )r   r   Nr`   r  s     r   r    z%_table_has_drifted.<locals>.<genexpr>T  s0      88ai4&7&71&7&7&7&788r!   pk)rK  r  nextupper)rM  r  infoleiid_cols        r   _table_has_driftedr  K  s    <<5U55566??AAD u$$$DDtDDDdKK$KCK$52#<#<#>#>)#KK88d888$??F~u%2,,..);LtMMr!   c                   	  fdt           D             }|sdS                      d           	 |D ]i}t           |         \  }}d                      d| d          D             }t                              d|                                d| d	| d
                                |           d                      d| d          D             	|dk    rF	fd|D             }d                    |          }                     d| d| d| d| d
	           nE	fd|D             }d                    |          }                     d| d| d| d| d
	                                d| d
           |D ]}                     |           k                     d           dS # t
          $ r. 	                      d           n# t          j        $ r Y nw xY w w xY w)u
  Rebuild any kanban table whose column types drifted from SCHEMA_SQL.

    Old boards crash the gateway notifier (``int(None)`` on a NULL id in
    ``unseen_events_for_sub``) and never match the ``id > cursor`` filter, so
    every kanban notification is silently lost (#35096). Each affected table is
    rebuilt with the standard SQLite pattern — CREATE new → INSERT shared
    columns → DROP old → RENAME — recreating its indexes too (DROP TABLE takes
    them down). The legacy TEXT ids are dropped (they aren't valid integers);
    AUTOINCREMENT assigns fresh ones and ``last_event_id`` cursors reset to 0,
    so the first post-migration tick replays a task's event history once —
    the safe failure mode for a feature that was already fully broken.

    The whole pass runs in one transaction so an interruption can't leave a
    table half-renamed, and under ``connect()``'s init locks so nothing races
    it. Idempotent: a correctly-typed DB skips every table and returns without
    opening a transaction.
    c                4    g | ]}t          |          |S r`   )r  )r   trM  s     r   r  z+_rebuild_drifted_tables.<locals>.<listcomp>l  s)    HHHQ,>tQ,G,GHqHHHr!   NBEGIN IMMEDIATEc                    g | ]
}|d          S r  r`   r  s     r   r  z+_rebuild_drifted_tables.<locals>.<listcomp>t      WWWa&	WWWr!   r  r  z7kanban migration: rebuilding %s to match current schemar  z RENAME TO _legacyc                    h | ]
}|d          S r  r`   r  s     r   r  z*_rebuild_drifted_tables.<locals>.<setcomp>x  r  r!   r  c                (    g | ]}|v |d k    |S )r  r`   r   r  new_colss     r   r  z+_rebuild_drifted_tables.<locals>.<listcomp>{  s+    XXXh1CWCW!CWCWCWr!   , zINSERT INTO  (z, last_event_id) SELECT z3, COALESCE(CAST(last_event_id AS INTEGER), 0) FROM c                (    g | ]}|v |d k    |S r   r`   r  s     r   r  z+_rebuild_drifted_tables.<locals>.<listcomp>  s&    MMMh199!999r!   z	) SELECT z FROM zDROP TABLE COMMITROLLBACK)_REBUILD_SPECSrK  _logr  r   r  rI  r  )
rM  driftedr  
create_sql
index_sqlsold_colssharedcols_csv	index_sqlr  s
   `        @r   r  r  Z  s   $ IHHH.HHHG LL"###" 	( 	(E%3E%:"J
WW4<<8UU8U8U8U+V+VWWWHIIOQVWWWLLHHH%HHHIIILL$$$WW4<<8UU8U8U8U+V+VWWWH,,,XXXXXXXX99V,,+5 + +H + +&+ +!+ + +    NMMMXMMM99V,,=5 = =H = =&= =.3= = =   LL5u555666' ( (	Y''''(X   	LL$$$$' 	 	 	D	s0   FF4 4
G,?GG,G'$G,&G''G,c                   	 |                      d                                          }|dS |d         }|sdS |}|                      d                                          d         }t          j                            |          }t          |d          5 }|                    d           |                    d          }ddd           n# 1 swxY w Y   t          |          dk     rdS t          
                    |d	          }|dk    rdS ||z  }	|	|k     r*t          j        d
| d| d|	 d||	z
   d| d| d          dS # t          j        $ r  t          $ r Y dS w xY w)zRead the SQLite header page_count and compare against actual file size.

    Raises sqlite3.DatabaseError if the file is shorter than the header claims
    (torn-extend corruption).
    zPRAGMA database_listNr   zPRAGMA page_sizer   rw     ro  rj  z-torn-extend detected: page count mismatch on z: header claims z pages, file has z pages (missing z pages, file_size=z, page_size=r  )rK  r  r,   r~   getsizerT  rW  r|  rp  r&   rq  rI  r  r  )
rM  r  path_strr~   	page_size	file_sizerr   header_bytesheader_page_countactual_pagess
             r   _check_file_length_invariantr    s   ll122;;==;Fq6 	FLL!344==??B	GOOD))	$ 	%FF2JJJ66!99L	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% |q  FNN<??!!F I-+++'A A A!2A A(A A .<A A '	A A 5>	A A A   ,+        sL   )E 
E AE +CE CE CE .!E 5E E%$E%c              #     K   |                      d           	 | V  |                      d           t          |            dS # t          $ r. 	 |                      d           n# t          j        $ r Y nw xY w w xY w)a  Context manager for an IMMEDIATE write transaction.

    Use for any multi-statement write (creating a task + link, claiming a
    task + recording an event, etc.).  A claim CAS inside this context is
    atomic -- at most one concurrent writer can succeed.

    The explicit ROLLBACK on exception is wrapped in try/except so that
    a SQLite auto-rollback (which leaves no active transaction) does not
    shadow the original exception with a spurious rollback error.
    r  r  r  N)rK  r  r  rI  r  )rM  s    r   r  r    s       	LL"###+


 	X 	%T*****    	LL$$$$' 	 	 	 D		
 	s/   A 
A;A$#A;$A63A;5A66A;c                 0    dt          j        d          z   S )a  Generate a short, URL-safe task id.

    4 hex bytes = ~4.3B possibilities. At 10k tasks the collision
    probability is ~1.2e-5; at 100k it's ~1.2e-3. Previously we used 2
    hex bytes (65k possibilities) which hit the birthday paradox hard:
    ~5% collision probability at 1k tasks, ~50% at 10k. Callers that
    care about idempotency should pass ``idempotency_key`` to
    :func:`create_task` rather than rely on id uniqueness.
    t_ro  )secrets	token_hexr`   r!   r   _new_task_idr!    s     '#A&&&&r!   c                     ddl } 	 |                                 pd}n# t          $ r d}Y nw xY w| dt          j                     S )z:Return a ``host:pid`` string that identifies this claimer.r   Nunknown:)socketgethostnamer  r,   getpid)r%  hosts     r   _claimer_idr)    sf    MMM!!##0y   ""RY[["""s    ,,r   c                ,    | dS ddl m}  ||           S )zHLowercase-assignee normalization for Kanban rows (dashboard/CLI parity).Nr   normalize_profile_name)hermes_cli.profilesr,  )r   r,  s     r   _canonical_assigneer.    s0    t::::::!!(+++r!   r   r`   F)r   r   r   r   r   r  r   r   rx   r   r  r  r  r  r  r  initial_statusr  r   r   r   r   r   r   r  r   r   rx   Iterable[str]r   r  r  r  Optional[Iterable[str]]r  r  r  r/  r  c               >
   t          |          }|r|                                st          d          |t          vr$t          dt	          t                               |t
          vr't          dt	          t
                     d|          |#t          |                                          pd}|r|dk    rt          d          t          d |
D                       }
d}|g }t                      }g }|D ]}|st          |                                          }|s)d	|v rt          d
|d          |	                                t          v r|                    |           q||v rv|                    |           |                    |           |rKd                    d |D                       }t          |          dk    rdnd}t          | d| d          |}|r3|                     d|f                                          }|r|d         S t#          t%          j                              }|K|dv rG|r|nt'                      }t)          |          } |                     d          }!|!rt          |!          }t-          d          D ]m}"t/                      }#	 t1          |           5  |dk    r<d}$|
r7t3          | |
          }%|%r%t          dd                    |%                     n|rd}$nd}$|
rt3          | |
          }%|%r%t          dd                    |%                     |                     dd	                    dt          |
          z            z   dz   |
                                          }&t7          d |&D                       rd }$|r9|
r7t3          | |
          }%|%r%t          dd                    |%                     |                     d!|#|                                |||$|	||||||||t#          |          nd|t9          j        |          nd|t#          |          nd|rdnd"|t#          |          nd|f           |
D ]}'|                     d#|'|#f           t=          | |#d$||$t?          |
          |||rt?          |          ndtA          |          pdd%           ddd           n# 1 swxY w Y   |#c S # tB          j"        $ r |"dk    r Y kw xY wtG          d&          )'u  Create a new task and optionally link it under parent tasks.

    Returns the new task id.  Status is ``ready`` when there are no
    parents (or all parents already ``done``), otherwise ``todo``.
    If ``triage=True``, status is forced to ``triage`` regardless of
    parents — a specifier/triager is expected to promote the task to
    ``todo`` once the spec is fleshed out.

    If ``idempotency_key`` is provided and a non-archived task with the
    same key already exists, returns the existing task's id instead of
    creating a duplicate. Useful for retried webhooks / automation that
    should not double-write.

    ``max_runtime_seconds`` caps how long a worker may run before the
    dispatcher SIGTERMs (then SIGKILLs after a grace window) and
    re-queues the task. ``None`` means no cap (default).

    ``skills`` is an optional list of skill names to force-load into
    the worker when dispatched. Stored as JSON; the dispatcher passes
    each name to ``hermes --skills ...`` alongside the built-in
    ``kanban-worker``. Use this to pin a task to a specialist skill
    (e.g. ``skills=["translation"]`` so the worker loads the
    translation skill regardless of the profile's default config).
    ztitle is requiredzinitial_status must be one of zworkspace_kind must be one of z, got Nr   z1branch_name is only valid for worktree workspacesc              3     K   | ]}||V  	d S r   r`   r   r   s     r   r    zcreate_task.<locals>.<genexpr>D  s'      ,,!!,A,,,,,,r!   ,z!skill name cannot contain comma: zA (pass a list of separate names instead of a comma-joined string)r  c              3  4   K   | ]}t          |          V  d S r   )repr)r   ns     r   r    zcreate_task.<locals>.<genexpr>h  s(      >>1tAww>>>>>>r!   r(   zis a toolset namezare toolset namesr   z, not skill name(s). Put toolsets in the assignee profile's `toolsets:` config instead of per-task skills. Skills are named skill bundles (e.g. `kanban-worker`, `blogwatcher`); toolsets are runtime capabilities (e.g. `web`, `browser`, `terminal`).zhSELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived' ORDER BY created_at DESC LIMIT 1r   >   r   r   r   r   r   zunknown parent task(s): r   r   z&SELECT status FROM tasks WHERE id IN (?r  c              3  .   K   | ]}|d          dk    V  dS )r   r   Nr`   r   rs     r   r    zcreate_task.<locals>.<genexpr>  s+      CCq{f4CCCCCCr!   r   a  
                    INSERT INTO tasks (
                        id, title, body, assignee, status, priority,
                        created_by, created_at, workspace_kind, workspace_path,
                        branch_name, tenant, idempotency_key, max_runtime_seconds,
                        skills, max_retries, goal_mode, goal_max_turns, session_id
                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    r   DINSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)created)r   r   rx   r   r  r  r  unreachable)$r.  r/   r0   VALID_INITIAL_STATUSESr   VALID_WORKSPACE_KINDSrG   tuplerI   r   KNOWN_TOOLSET_NAMESr   r   r   rp  rK  r  r&   r   rt   r   r.   ranger!  r  _find_missing_parentsr  anyr   r   _append_eventr  r   rI  IntegrityErrorRuntimeError)(rM  r   r   r   r   r   r   r  r   r   rx   r   r  r  r  r  r  r  r/  r  r   skills_listcleanedr   toolset_typosrR   r   quotednounr  now
board_slug
board_metaboard_defaultattemptr   task_statusmissingrowspids(                                           r   create_taskrX    s   ` #8,,H . .,---333MV4J-K-KMM
 
 	
 222&V4I-J-J & &!& &
 
 	
 +&&,,..6$ N~33LMMM,,w,,,,,G (,K $& 	! 	!A q66<<>>D d{{ X X X X   }}"555$$T***t||HHTNNNNN4     		YY>>>>>>>F*-m*<*<*A*A&&GZD D DD D D D     ll/ 	
 

 (** 	  	t9
dikk

C .4G"G"G#<UU):)<)<
(44
"'899 	0 //N 88 [ [..Y	4 R R "Y.."+K ^"7g"F"F" ^",-\		RYHZHZ-\-\"]"]] 1"*KK")K 1"7g"F"F" ^",-\		RYHZHZ-\-\"]"]]#|| "%((3W+=">">?ADE#    #(**	 
 CCdCCCCC 1*0K  Zg Z3D'BBG Z()XDIIgDVDV)X)XYYY   # "&&#'4G4S/000Y]3>3J
;///PT,7,CK(((&-A/=/IN+++t"'  > #  CLL^g    $,"-#'=="('27B"L${"3"3"3%))__%< 	  KR R R R R R R R R R R R R R Rf NNN% 	 	 	!||H		
 }
%
%%s7   3S3HS$S3$S(	(S3+S(	,S33TT	list[str]c                    t          |          }|sg S d                    dt          |          z            }|                     d| d|                                          }d |D             fd|D             S )Nr5  r9  "SELECT id FROM tasks WHERE id IN (r  c                    h | ]
}|d          S r  r`   r;  s     r   r  z(_find_missing_parents.<locals>.<setcomp>  s    %%%1qw%%%r!   c                    g | ]}|v|	S r`   r`   )r   r   presents     r   r  z)_find_missing_parents.<locals>.<listcomp>  s#    333!!7"2"2A"2"2"2r!   )r  r   rp  rK  r  )rM  rx   placeholdersrV  r^  s       @r   rE  rE    s    7mmG 	88C#g,,.//L<<<\<<<  hjj 	 &%%%%G3333w3333r!   Optional[Task]c                    |                      d|f                                          }|rt                              |          nd S )Nz SELECT * FROM tasks WHERE id = ?)rK  r  r   r  rM  r   r  s      r   get_taskrc  	  s@    
,,9G:
F
F
O
O
Q
QC!$.4==$.r!   zcreated_at ASC, id ASCzcreated_at DESC, id DESCzpriority DESC, created_at ASCzpriority ASC, created_at ASCzstatus ASC, created_at ASCzassignee ASC, created_at ASCztitle ASC, id ASCz+started_at DESC NULLS LAST, created_at DESC)r>  zcreated-descr   zpriority-descr   r   r   updateddict[str, str]VALID_SORT_ORDERS)	r   r   r   r  r   limitorder_byr
  r  r   rg  rh  r
  r  
list[Task]c       	        ~   d}
g }|'|
dz  }
|                     t          |                     |G|t          vr$t          dt	          t                               |
dz  }
|                     |           ||
dz  }
|                     |           ||
dz  }
|                     |           ||
dz  }
|                     |           |	|
dz  }
|                     |	           |s|d	k    r|
d
z  }
|y|                                                                }|t          vr6t          dt	          t                                                               |
dt          |          z  }
n|
dz  }
|r|
dt          |           z  }
| 
                    |
|                                          }d |D             S )NzSELECT * FROM tasks WHERE 1=1z AND assignee = ?zstatus must be one of z AND status = ?z AND tenant = ?z AND session_id = ?z AND workflow_template_id = ?z AND current_step_key = ?r   z AND status != 'archived'zorder_by must be one of z
 ORDER BY z' ORDER BY priority DESC, created_at ASCz LIMIT c                B    g | ]}t                               |          S r`   )r   r  r;  s     r   r  zlist_tasks.<locals>.<listcomp>E	  s$    +++DMM!+++r!   )r   r.  VALID_STATUSESr0   r   r/   rO   rf  r  r&   rK  r  )rM  r   r   r   r  r   rg  rh  r
  r  queryparamsrV  s                r   
list_tasksro  	  s    ,EF$$)(33444''Nf^6L6LNNOOO""f""f&&j!!!'00*+++#,,&''' -* 4 4,,>>##))++,,,M62C2H2H2J2J+K+KMM   	;/9;;;:: ('3u::'''<<v&&//11D++d++++r!   r(  c                   t          |          }t          |           5  |                     d|f                                          }|s	 ddd           dS |d         |d         dk    rt	          d| d          |d	         |k    r|                     d
||f           n|                     d||f           t          | |dd	|i           	 ddd           dS # 1 swxY w Y   dS )zAssign or reassign a task.  Returns True on success.

    Refuses to reassign a task that's currently running (claim_lock set).
    Reassign after the current run completes if needed.
    z;SELECT status, claim_lock, assignee FROM tasks WHERE id = ?NFr   r   r   zcannot reassign zS: currently running (claimed). Wait for completion or reclaim the stale lock first.r   z_UPDATE tasks SET assignee = ?, consecutive_failures = 0, last_failure_error = NULL WHERE id = ?z*UPDATE tasks SET assignee = ? WHERE id = ?assignedT)r.  r  rK  r  rI  rG  )rM  r   r(  r  s       r   assign_taskrr  H	  s    "'**G	4  llIG:
 

(** 	  	        |(S]i-G-GG7 G G G   z?g%% LL9'"    LLEQXGYZZZdGZ*g1FGGG/                 s   -CA9CC#&C#	parent_idchild_idc           	     2   ||k    rt          d          t          |           5  t          | ||g          }|r%t          dd                    |                     t	          | ||          rt          d| d| d          |                     d||f           |                     d|f                                          d	         }|d
k    r|                     d|f           t          | |d||d           d d d            d S # 1 swxY w Y   d S )Nza task cannot depend on itselfzunknown task(s): r  zlinking z -> z would create a cycler=  %SELECT status FROM tasks WHERE id = ?r   r   BUPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'linkedr{   r   )r0   r  rE  r   _would_cyclerK  r  rG  )rM  rs  rt  rU  parent_statuss        r   
link_tasksr|  m	  s   H9:::	4 
 
'y(.CDD 	GE71C1CEEFFFi22 	I9II(III   	R!	
 	
 	

 3i\
 

(**X F""LLT   	(H 844	
 	
 	
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s   CDDDc                ,   t                      }|g}|r|                                }||k    rdS ||v r#|                    |           |                     d|f                                          }|                    d |D                        |dS )zReturn True if adding parent->child creates a cycle.

    A cycle exists iff ``parent_id`` is already a descendant of
    ``child_id`` via existing parent->child links.  We walk downward
    from ``child_id`` and check whether we reach ``parent_id``.
    Tz3SELECT child_id FROM task_links WHERE parent_id = ?c              3  &   K   | ]}|d          V  dS )rt  Nr`   r;  s     r   r    z_would_cycle.<locals>.<genexpr>	  s&      11qQz]111111r!   F)rI   r   r   rK  r  extend)rM  rs  rt  r   stacknoderV  s          r   rz  rz  	  s     55DJE
 
2yy{{944<<||AD7
 

(** 	 	11D111111  
2 5r!   c           	         t          |           5  |                     d||f          }|j        rt          | |d||d           |j        dk    }d d d            n# 1 swxY w Y   |rt	          |            |S )Nz;DELETE FROM task_links WHERE parent_id = ? AND child_id = ?unlinkedry  r   )r  rK  r  rG  recompute_ready)rM  rs  rt  r  removeds        r   unlink_tasksr  	  s    	4 
# 
#llI!
 
 < 	h
$x88   ,"
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
#  
 	Ns   A AA #A c                l    |                      d|f                                          }d |D             S )NFSELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_idc                    g | ]
}|d          S rs  r`   r;  s     r   r  zparent_ids.<locals>.<listcomp>	  s    )))qAkN)))r!   rK  r  rM  r   rV  s      r   
parent_idsr  	  sA    <<P	
  hjj 	 *)D))))r!   c                l    |                      d|f                                          }d |D             S )NzESELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_idc                    g | ]
}|d          S )rt  r`   r;  s     r   r  zchild_ids.<locals>.<listcomp>	  s    (((aAjM(((r!   r  r  s      r   	child_idsr  	  sA    <<O	
  hjj 	 )(4((((r!   list[tuple[str, Optional[str]]]c                l    |                      d|f                                          }d |D             S )zDReturn ``(parent_id, result)`` for every done parent of ``task_id``.z
        SELECT t.id AS id, t.result AS result
        FROM tasks t
        JOIN task_links l ON l.parent_id = t.id
        WHERE l.child_id = ? AND t.status = 'done'
        ORDER BY t.completed_at ASC
        c                .    g | ]}|d          |d         fS )r   r  r`   r;  s     r   r  z"parent_results.<locals>.<listcomp>	  s%    111qQtWak"111r!   r  r  s      r   parent_resultsr  	  sE    <<	 

	 	 hjj 	 21D1111r!   r4  c           
        |r|                                 st          d          |r|                                 st          d          t          t          j                              }t	          |           5  |                     d|f                                          st          d|           |                     d||                                 |                                 |f          }t          | |d|t          |          d           t          |j	        pd          cd d d            S # 1 swxY w Y   d S )	Nzcomment body is requiredzcomment author is required SELECT 1 FROM tasks WHERE id = ?unknown task QINSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)	commented)r4  rp  r   )
r/   r0   r&   r   r  rK  r  rG  rp  r  )rM  r   r4  r   rO  r  s         r   add_commentr  	  s}     5tzz|| 53444 7 75666
dikk

C	4 ' '||.

 

(**	8 6W66777ll"fllnndjjllC8
 

 	dG[VCPTII2V2VWWW3=%A&&' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' 's   :B1D88D<?D<list[Comment]c                l    |                      d|f                                          }d |D             S )NzESELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at ASCc           
     r    g | ]4}t          |d          |d         |d         |d         |d                   5S )r   r   r4  r   r   )r   r   r4  r   r   )r3  r;  s     r   r  z!list_comments.<locals>.<listcomp>	  s\     	 	 	  	wiLX;6	
 	
 	
	 	 	r!   r  r  s      r   list_commentsr  	  sN    <<O	
  hjj 		 	 	 	 	 	r!   )r9  r:  r;  r7  r8  r9  r:  r;  c                  |r|                                 st          d          |r|                                 st          d          t          t          j                              }t	          |           5  |                     d|f                                          st          d|           |                     d||                                 ||t          |          ||f          }t          | |d|                                 t          |          |d           t          |j        pd          cd	d	d	           S # 1 swxY w Y   d	S )
a
  Record a file attachment for a task. Returns the new attachment id.

    The caller is responsible for writing the blob to ``stored_path``
    first (under :func:`task_attachments_dir`); this only persists the
    metadata row and appends an ``attached`` event.
    zattachment filename is requiredz"attachment stored_path is requiredr  r  zINSERT INTO task_attachments (task_id, filename, stored_path, content_type, size, uploaded_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)attached)r7  r:  byr   N)	r/   r0   r&   r   r  rK  r  rG  r  )	rM  r   r7  r8  r9  r:  r;  rO  r  s	            r   add_attachmentr  
  s      <8>>++ <:;;; ?k//11 ?=>>>
dikk

C	4 ' '||.

 

(**	8 6W66777ll+   D			
 
 	!))3t99KPP		
 	
 	
 3=%A&&3' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' 's   :CE		EElist[Attachment]c                l    |                      d|f                                          }d |D             S )NzPSELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, id ASCc                    g | ]K}t          |d          |d         |d         |d         |d         |d         pd|d         |d         	          LS )
r   r   r7  r8  r9  r:  r   r;  r   r   r   r7  r8  r9  r:  r;  r   )r6  r;  s     r   r  z$list_attachments.<locals>.<listcomp>;
  sx         	wiLz]-(>*6a-(		
 		
 		
  r!   r  r  s      r   list_attachmentsr  6
  sN    <<Z	
  hjj 	     r!   attachment_idOptional[Attachment]c                    |                      d|f                                          }|d S t          |d         |d         |d         |d         |d         |d         pd|d	         |d
                   S )Nz+SELECT * FROM task_attachments WHERE id = ?r   r   r7  r8  r9  r:  r   r;  r   r  )rK  r  r6  )rM  r  r<  s      r   get_attachmentr  J
  s    57G	 	hjj  	ytT7):m$~&vY^!m$\?	 	 	 	r!   c                   t          |           5  t          | |          }|	 ddd           dS |                     d|f           t          | |j        dd|j        i           ddd           n# 1 swxY w Y   	 t          |j                  }|                                r|	                                 n# t          $ r Y nw xY w|S )a  Delete an attachment row and its on-disk blob. Returns the removed row.

    Returns ``None`` when no row matched. The blob is removed best-effort
    (a missing file is not an error); the metadata row is the source of
    truth for whether an attachment "exists".
    Nz)DELETE FROM task_attachments WHERE id = ?attachment_removedr7  )r  r  rK  rG  r   r7  r	   r8  is_filer   rm   )rM  r  attr   s       r   delete_attachmentr  \
  s:    
4 
 
T=11;
 
 
 
 
 
 
 
 	@=BRSSS#+3j#,5O	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
!!99;; 	HHJJJ   Js(   A25A22A69A6><B; ;
CClist[Event]c                   |                      d|f                                          }g }|D ]}	 |d         rt          j        |d                   nd }n# t          $ r d }Y nw xY w|                    t          |d         |d         |d         ||d         d|                                v r|d         t          |d                   nd                      |S )	NzKSELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASCr?  r   r   r>  r   r@  r   r   r>  r?  r   r@  )	rK  r  r   r   r  r   r=  r  r&   )rM  r   rV  outr<  r?  s         r   list_eventsr  t
  s   <<U	
  hjj 	 C 
 
	23I,Hdj9...DGG 	 	 	GGG	

T7)vY\?,4,@,@Qx[E\AhK(((bf  		
 		
 		
 		
 Js   $AA%$A%r@  r>  r?  r-  r@  c                   t          t          j                              }|rt          j        |d          nd}|                     d|||||f           dS )a2  Record an event row.  Called from within an already-open txn.

    ``run_id`` is optional: pass the current run id so UIs can group
    events by attempt. For events that aren't scoped to a single run
    (task created/edited/archived, dependency promotion) leave it None
    and the row carries NULL.
    Fr   Nz[INSERT INTO task_events (task_id, run_id, kind, payload, created_at) VALUES (?, ?, ?, ?, ?))r&   r   r   r   rK  )rM  r   r>  r?  r@  rO  pls          r   rG  rG  
  sh     dikk

C4;	EG%	0	0	0	0BLL	!	&$C(    r!   )r,  r/  r.  r   r+  r,  r/  r.  c               v   t          t          j                              }|                     d|f                                          }|r|d         sdS t          |d                   }	|                     d|p|||||rt	          j        |d          nd||	f           |                     d|f           |	S )a  Close the currently-active run for ``task_id`` and clear the pointer.

    ``outcome`` is the semantic result (completed / blocked / crashed /
    timed_out / spawn_failed / gave_up / reclaimed). ``status`` is the
    run-row status (usually just ``outcome``, but callers can pass it
    explicitly). Returns the closed run_id or ``None`` if no active run
    existed (e.g. a CLI user calling ``hermes kanban complete`` on a
    task that was never claimed).
    -SELECT current_run_id FROM tasks WHERE id = ?r	  Na  
        UPDATE task_runs
           SET status        = ?,
               outcome       = ?,
               summary       = ?,
               error         = ?,
               metadata      = ?,
               ended_at      = ?,
               claim_lock    = NULL,
               claim_expires = NULL,
               worker_pid    = NULL
         WHERE id = ?
           AND ended_at IS NULL
        Fr  z3UPDATE tasks SET current_run_id = NULL WHERE id = ?)r&   r   rK  r  r   r   )
rM  r   r+  r,  r/  r.  r   rO  r  r@  s
             r   _end_runr  
  s    & dikk

C
,,7' hjj   c*+ t%&''FLL	 g8@JDJxe4444d	
  2 	LL=z   Mr!   c                    |                      d|f                                          }|r|d         rt          |d                   nd S )Nr  r	  )rK  r  r&   rb  s      r   _current_run_idr  
  sS    
,,7' hjj  *-P5E1FP3s#$%%%DPr!   )r,  r/  r.  c               `   t          t          j                              }|                     d|f                                          }|r|d         nd}|r|d         nd}	|                     d|||	|||||rt	          j        |d          nd||f
          }
t          |
j        pd          S )	a  Insert a zero-duration, already-closed run row.

    Used when a terminal transition happens on a task that was never
    claimed (CLI user calling ``hermes kanban complete <ready-task>
    --summary X``, or dashboard "mark done" on a ready task). Without
    this, the handoff fields (summary / metadata / error) would be
    silently dropped: ``_end_run`` is a no-op because there's no
    current run.

    The synthetic run has ``started_at == ended_at == now`` so it
    shows up in attempt history as "instant" and doesn't skew elapsed
    stats. Caller is responsible for leaving ``current_run_id`` NULL
    (or for clearing it elsewhere in the same txn) since this
    function does NOT touch the tasks row.
    z9SELECT assignee, current_step_key FROM tasks WHERE id = ?r   Nr  z
        INSERT INTO task_runs (
            task_id, profile, step_key,
            status, outcome,
            summary, error, metadata,
            started_at, ended_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        Fr  r   )r&   r   rK  r  r   r   r  )rM  r   r+  r,  r/  r.  rO  trowr(  r)  r  s              r   _synthesize_ended_runr  
  s    0 dikk

C<<C	
  hjj 	 #'0d:DG+/9t&''TH
,,	 WhWU8@JDJxe4444d	
 C" s}!"""r!   c                    |                      d|f                                          }t          |          o|d         dk    S )u  Return True when ``task_id`` is sticky-blocked by an explicit
    worker/operator ``kanban_block`` call (#28712).

    A ``blocked`` status can come from two very different sources:

    * **Worker- or operator-initiated** — a worker called
      ``kanban_block(reason="review-required: ...")`` (or somebody ran
      ``hermes kanban block <id>``).  This is a deliberate handoff that
      should stay blocked until an operator unblocks it.  The block tool
      emits a ``"blocked"`` event row in ``task_events``.

    * **Circuit-breaker** — ``_record_task_failure`` tripped after
      repeated crashes / spawn failures / timeouts.  This emits
      ``"gave_up"``, *not* ``"blocked"``, and is meant to recover
      automatically once the underlying conditions change (e.g. parents
      finish, transient infra error clears).

    The cheapest signal that distinguishes the two is the most recent
    ``"blocked"`` / ``"unblocked"`` event for the task.  If the most
    recent one is ``"blocked"`` (or there is a ``"blocked"`` event and
    no ``"unblocked"`` event has fired since), the task is sticky and
    ``recompute_ready`` must *not* auto-promote it.

    Returns ``False`` when there is no such event at all (e.g. the task
    was set to ``status='blocked'`` by the circuit breaker or by direct
    DB manipulation) — preserves the pre-#28712 auto-recover semantics
    for that path.
    zlSELECT kind FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked') ORDER BY id DESC LIMIT 1r>  r   )rK  r  r   rb  s      r   _has_sticky_blockr    sM    : ,,	# 

	 
 hjj  991V	11r!   failure_limitc                   |t           }d}t          |           5  |                     d                                          }|D ]}|d         }|d         }|dk    rt	          | |          r)|                     d|f                                          }t          d |D                       r|dk    r^t          |d	         pd          }|d
         }	|	t          |	          nt          |          }
||
k    r|                     d|f           n|                     d|f           t          | |dd           |dz  }	 ddd           n# 1 swxY w Y   |S )u  Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.

    Returns the number of tasks promoted.  Safe to call inside or outside
    an existing transaction; it opens its own IMMEDIATE txn.

    ``blocked`` tasks are also considered for promotion (so a task
    blocked purely by a parent dependency unblocks itself when the
    parent completes), *except* in two cases:

    1. The most recent block event was a worker-initiated
       ``kanban_block`` — those stay blocked until an explicit
       ``kanban_unblock`` (#28712).

    2. The task's ``consecutive_failures`` has reached the effective
       failure limit.  This prevents infinite retry loops when a task
       repeatedly exhausts its iteration budget: without this guard the
       counter would reset on every recovery cycle and the circuit
       breaker could never trip (#35072).

    The effective failure limit resolves in the same order as the
    circuit breaker in ``_record_task_failure`` so the two never
    disagree about when a task is permanently blocked:

      1. per-task ``max_retries`` if set
      2. caller-supplied ``failure_limit`` (the dispatcher passes the
         ``kanban.failure_limit`` config value through ``dispatch_once``)
      3. ``DEFAULT_FAILURE_LIMIT``
    Nr   zcSELECT id, status, consecutive_failures, max_retries FROM tasks WHERE status IN ('todo', 'blocked')r   r   r   zYSELECT t.status FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?c              3  *   K   | ]}|d          dv V  dS )r   r   r   Nr`   r4  s     r   r    z"recompute_ready.<locals>.<genexpr>w  s,      HH11X;"66HHHHHHr!   r  r  zEUPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'blocked'zBUPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'r  r(   )DEFAULT_FAILURE_LIMITr  rK  r  r  allr&   rG  )rM  r  r  	todo_rowsr  r   
cur_statusrx   failures
task_limiteffective_limits              r   r  r  A  s   > -H	4 1 1LL=
 
 (** 	  ,	 ,	C$iGXJY&&+<T7+K+K&
 ll' 
	 
 hjj  HHHHHHH **  #3'=#>#C!DDH!$]!3J+5+AJ // $  ?22 LL> 
    LL\ 
   dGZ>>>AY,	1 1 1 1 1 1 1 1 1 1 1 1 1 1 1d Os   D(EEE)r#   claimerr  c                  t          t          j                              }|pt                      }|t          |          z   }t	          |           5  |                     d|f                                          }|r9|                     d|f           t          | |dddi           	 ddd           dS |                     d|f                                          }|r3|d         r+|                     d	|t          |d                   f           |                     d
||||f          }	|	j        dk    r	 ddd           dS |                     d|f                                          }
|                     d||
r|
d         nd|
r|
d         nd|||
r|
d         nd|f          }|j	        }|                     d||f           t          | |d|||d|           t          | |          cddd           S # 1 swxY w Y   dS )zAtomically transition ``ready -> running``.

    Returns the claimed ``Task`` on success, ``None`` if the task was
    already claimed (or is not in ``ready`` status).
    zSELECT 1 FROM task_links l JOIN tasks p ON p.id = l.parent_id WHERE l.child_id = ? AND p.status NOT IN ('done', 'archived') LIMIT 1rw  claim_rejectedr  parents_not_doneNzBSELECT current_run_id FROM tasks WHERE id = ? AND status = 'ready'r	  av  
                UPDATE task_runs
                   SET status = 'reclaimed', outcome = 'reclaimed',
                       summary = COALESCE(summary, 'invariant recovery on re-claim'),
                       ended_at = ?,
                       claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
                 WHERE id = ? AND ended_at IS NULL
                a?  
            UPDATE tasks
               SET status        = 'running',
                   claim_lock    = ?,
                   claim_expires = ?,
                   started_at    = COALESCE(started_at, ?)
             WHERE id = ?
               AND status = 'ready'
               AND claim_lock IS NULL
            r(   NSELECT assignee, max_runtime_seconds, current_step_key FROM tasks WHERE id = ?
            INSERT INTO task_runs (
                task_id, profile, step_key, status,
                claim_lock, claim_expires, max_runtime_seconds,
                started_at
            ) VALUES (?, ?, ?, 'running', ?, ?, ?, ?)
            r   r  r  0UPDATE tasks SET current_run_id = ? WHERE id = ?claimed)lockexpiresr@  r  )r&   r   r)  r4   r  rK  r  rG  r  r  rc  )rM  r   r#   r  rO  r  r  undonestaler  r  run_curr@  s                r   
claim_taskr    sN    dikk

C#kmmD.{;;;G	4 `' `' T J	
 

 (** 	  
	LL4
  
 g/-.   3`' `' `' `' `' `' `' `'< PJ
 
 (** 	  	U+, 	LL c% 01223
 
 
 ll	 7C)
 
 <1y`' `' `' `' `' `' `' `'~ ||&J
 
 (**	 	
 ,, $(2Z  d,0:'((d/3=*++
 
$ ">W	
 	
 	
 	'9g@@	
 	
 	
 	

 g&&A`' `' `' `' `' `' `' `' `' `' `' `' `' `' `' `' `' `'s!   AG?7BG?B*G??HHc                  t          t          j                              }|pt                      }|t          |          z   }t	          |           5  |                     d||||f          }|j        dk    r	 ddd           dS |                     d|f                                          }|                     d||r|d         nd|r|d         nd|||r|d         nd|f          }	|	j        }
|                     d	|
|f           t          | |d
|||
dd|
           t          | |          cddd           S # 1 swxY w Y   dS )u  Atomically transition ``review -> running``.

    Returns the claimed ``Task`` on success, ``None`` if the task was
    already claimed (or is not in ``review`` status).

    Unlike ``claim_task`` (which handles ``ready -> running``), this
    does NOT check parent dependencies — the task already passed that
    gate on its original ``todo -> ready -> running`` transition.

    Creates a new run entry so the review agent's lifecycle is tracked
    independently from the original worker run.
    a@  
            UPDATE tasks
               SET status        = 'running',
                   claim_lock    = ?,
                   claim_expires = ?,
                   started_at    = COALESCE(started_at, ?)
             WHERE id = ?
               AND status = 'review'
               AND claim_lock IS NULL
            r(   Nr  r  r   r  r  r  r  r   )r  r  r@  source_statusr  )r&   r   r)  r4   r  rK  r  r  r  rG  rc  )rM  r   r#   r  rO  r  r  r  r  r  r@  s              r   claim_review_taskr    s   & dikk

C#kmmD.{;;;G	4 2' 2'll	 7C)
 
 <12' 2' 2' 2' 2' 2' 2' 2'  ||&J
 
 (**	 	
 ,, $(2Z  d,0:'((d/3=*++
 
$ ">W	
 	
 	
 	'9g&( (		
 	
 	
 	
 g&&e2' 2' 2' 2' 2' 2' 2' 2' 2' 2' 2' 2' 2' 2' 2' 2' 2' 2's   'D>B+D>>EEc                  t          t          j                              t          |          z   }|pt                      }t	          |           5  |                     d|||f          }|j        dk    r8t          | |          }||                     d||f           	 ddd           dS 	 ddd           dS # 1 swxY w Y   dS )zExtend a running claim.  Returns True if we still own it.

    Workers that know they'll exceed 15 minutes should call this every
    few minutes to keep ownership.
    zYUPDATE tasks SET claim_expires = ? WHERE id = ? AND status = 'running' AND claim_lock = ?r(   N3UPDATE task_runs SET claim_expires = ? WHERE id = ?TF)r&   r   r4   r)  r  rK  r  r  )rM  r   r#   r  r  r  r  r@  s           r   heartbeat_claimr  Y  s=    $)++!;K!H!HHG#kmmD	4  llEgt$
 

 <1$T733F!If%                            s   AB;-B;;B?B?	signal_fnc                  t          t          j                              }d}t                                          dd          d          d}|                     d|f                                          }|D ]}|d         pd}|                    |          }|d         }	|	duo|t          |	          z
  t          k    }
|r4|d	         r+t          |d	                   r|
s|t                      z   }t          |           5  |                     d
||d         |d         |f          }|j        dk    r	 ddd           t          | |d                   }||                     d||f           t          | |d         ddt          |d	                   |d         t          |d                   ||d         t          |d                   ndd|           ddd           n# 1 swxY w Y   t          |d	         |d         |          }t          |           5  |                     d|d         |d         |f          }|j        dk    r	 ddd           t          | |d         ddd|d          |          }|d         |d	         t          |d	                   ndt          |d                   |d         t          |d                   nd||t!          |
          d}|                    |           t          | |d         d||           |dz  }ddd           n# 1 swxY w Y   |S )uh  Reset any ``running`` task whose claim has expired.

    A stale-by-TTL claim whose host-local worker PID is still alive is
    *extended* (with a ``claim_extended`` event) instead of being
    reclaimed. Reclaiming a live worker mid-flight produces the spawn-
    then-immediately-reclaim loop seen on slow models that spend longer
    than ``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM
    call (#23025): no tool calls means no ``kanban_heartbeat``, even
    though the subprocess is healthy.

    Backstop (#29747 gap 3): if the worker's PID is still alive but its
    ``last_heartbeat_at`` is stale by more than
    ``DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS`` (1h), the worker has
    been making no observable progress and we reclaim anyway — even if
    ``_pid_alive`` is still true. This catches the wedged-in-a-logic-loop
    case where the process is technically running but accomplishing
    nothing. ``_touch_activity`` (run_agent.py) bridges chunk-level
    liveness into ``last_heartbeat_at`` via #31752, so any genuinely
    active worker keeps its heartbeat fresh as a side effect of normal
    API traffic. ``enforce_max_runtime`` and ``detect_crashed_workers``
    remain the upper bounds for genuinely wedged or dead workers.

    Returns the number of stale claims actually reclaimed (live-pid
    extensions don't count). Safe to call often.
    r   r$  r(   zSELECT id, claim_lock, worker_pid, claim_expires, last_heartbeat_at FROM tasks WHERE status = 'running' AND claim_expires IS NOT NULL   AND claim_expires < ?r   r*   r  Nr  zUPDATE tasks SET claim_expires = ? WHERE id = ? AND status = 'running'   AND claim_lock IS ?   AND claim_expires IS NOT NULL   AND claim_expires < ?r   r  claim_extended	pid_aliver   )r  r  r   claim_expires_wasclaim_expires_nowr  r  r  zUPDATE tasks SET status = 'ready', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND status = 'running' AND claim_lock IS ? AND claim_expires IS NOT NULL AND claim_expires < ?	reclaimedzstale_lock=r+  r   r/  r.  )
stale_lockr  r   r  rO  
host_localheartbeat_stale)r&   r   r)  r   rK  r  r}  )DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS
_pid_aliver4   r  r  r  rG  _terminate_reclaimed_workerr  r   r   )rM  r  rO  r  host_prefixr  r  r  r  hbr  new_expiresr  r@  terminationr?  s                   r   release_stale_claimsr  x  s   < dikk

CI ]]((a003666KLL	" 
  hjj 
  ^ ^< &B__[11
$% dN Ls2ww"KK 	
 (	L!(	 3|,--(	 $	(	  : < <<K4    ll.
 !#d)S->D  <1$$              )s4y99%LLM$f-   #d)%5"-&)#l*;&<&<&),&7-0_1E-F-F-8  ##67C  $7 8999!%  "   #                             B 1s<0I
 
 
 t__ %	 %	,,F TC-s3 C |q  %	 %	 %	 %	 %	 %	 %	 c$i#K7C$577$	  F ",/ <(4 L)***:>!$S%9!:!: ./; /0111AE(#'#8#8 G NN;'''c$i   
 NIK%	 %	 %	 %	 %	 %	 %	 %	 %	 %	 %	 %	 %	 %	 %	L s2   3G,BG,,G0	3G0	&2L3%CL33L7	:L7	)r  r  r  c          	     8   |                      d|f                                          }|sdS |d         dk    r
|d         dS |d         }t          |d         ||          }t          |           5  |                      d	||f          }|j        d
k    r	 ddd           dS t          | |dd|rd| nd| |          }d||d}	|	                    |           t          | |d|	|           ddd           n# 1 swxY w Y   t          | |           dS )a!  Operator-driven reclaim: release the claim and reset to ``ready``.

    Unlike :func:`release_stale_claims` which only acts on tasks whose
    ``claim_expires`` has passed, this function reclaims immediately
    regardless of TTL. Intended for the dashboard/CLI recovery flow
    when an operator wants to abort a running worker without waiting
    for the TTL to expire (e.g. after seeing a hallucination warning).

    Returns True if a reclaim happened, False if the task isn't in a
    reclaimable state (not running, or doesn't exist).
    z=SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?Fr   r   r   Nr  r  zUPDATE tasks SET status = 'ready', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND status IN ('running', 'ready', 'blocked') AND claim_lock IS ?r(   r  zmanual_reclaim: zmanual_reclaim lock=r  T)manualr  	prev_lockr  )	rK  r  r  r  r  r  r   rG  _clear_failure_counter)
rM  r   r  r  r  r  r  r  r@  r?  s
             r   reclaim_taskr    s   $ ,,G	
  hjj   u
8}	!!c,&7&?uL!I-L9	  K 
4 
 
ll" i 
 
 <1
 
 
 
 
 
 
 
 '/5 8+6+++7I77 
 
 
 "
 

 	{###';	
 	
 	
 	
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
D 4)))4s   3%C?%AC??DD)reclaim_firstr  r  c               t    |rt          | ||pd           	 t          | ||          S # t          $ r Y dS w xY w)a  Reassign a task, optionally reclaiming a stuck running worker first.

    This is the recovery path for "this profile's model is broken, try
    a different one". If ``reclaim_first`` is True, any active claim is
    released (via :func:`reclaim_task`) before the reassign happens;
    otherwise the function refuses to reassign a currently-running task
    and returns False (caller can retry with ``reclaim_first=True``).

    Returns True if the reassign landed. ``profile`` may be ``None`` to
    unassign entirely.
    reassign)r  F)r  rr  rI  )rM  r   r(  r  r  s        r   reassign_taskr  G  sb    &  AT76+?Z@@@@4'222    uus   ) 
77completing_task_idclaimed_idstuple[list[str], list[str]]c                t   d |pg D             }|sg g fS t                      }g }|D ]0}||vr*|                    |           |                    |           1|                     d|f                                          }|g |fS |d         }d                    dgt          |          z            }	|                     d|	 dt          |                                                    }
d	 |
D             }t          t          | |                    }g }g }|D ]}|
                    |          }||                    |           /|r||k    r|                    |           M||k    r|                    |           i||v r|                    |           |                    |           ||fS )
u  Partition ``claimed_ids`` into (verified, phantom).

    A card is "verified" iff a row exists in ``tasks`` AND at least one
    of the following holds:

    * ``created_by`` matches the completing task's ``assignee`` profile
      (the common case: worker A spawns a card via ``kanban_create``,
      which stamps ``created_by=A``).
    * ``created_by`` matches the completing task's id (edge case where
      a worker passed its own task id as the ``created_by`` value).
    * The card is linked as a ``task_links.child`` of the completing
      task — i.e. the worker explicitly called ``kanban_create`` with
      ``parents=[<current_task>]``. This accepts cards created through
      the dashboard/CLI by a different principal but then attached to
      the completing task by the worker.

    ``phantom`` returns ids that either don't exist at all, or exist
    but don't satisfy any of the three trust conditions. The caller
    decides what to do with each bucket; this helper never mutates.
    c                    g | ]D}t          |                                          #t          |                                          ES r`   )rG   r/   )r   xs     r   r  z)_verify_created_cards.<locals>.<listcomp>  s9    MMM!c!ffllnnMs1vv||~~MMMr!   'SELECT assignee FROM tasks WHERE id = ?Nr   r5  r9  z.SELECT id, created_by FROM tasks WHERE id IN (r  c                ,    i | ]}|d          |d         S )r   r   r`   r;  s     r   
<dictcomp>z)_verify_created_cards.<locals>.<dictcomp>  s"    444!QtWao444r!   )rI   r   r   rK  r  r   rp  rB  r  r  r.   )rM  r   r  r  r   orderedcidr  completing_assigneer_  rV  foundlinked_childrenverifiedphantomr   s                   r   _verify_created_cardsr  f  s   2 NM(9rMMMG 2vUUDG    d??HHSMMMNN3
,,14F3H hjj  {7{j/ 88SECLL011L<<HHHHg  hjj 	 54t444E !$Id4F$G$G H HOHG    YYs^^
NN3 	 :1D#D#DOOC    ---OOC    O##OOC    NN3Wr!   z\bt_[a-f0-9]{8,}\btextc                   |sg S t                               |          }|sg S t                      }g }|D ]0}||vr*|                    |           |                    |           1d                    dgt          |          z            }|                     d| dt          |                    	                                }d |D             fd|D             S )ue  Regex-scan free-form text for ``t_<hex>`` references; return the
    ones that don't exist in ``tasks``.

    Used as a non-blocking advisory check on completion summaries. An
    empty return means "no suspicious references found" — either the
    text had no IDs at all, or every ID it mentioned resolves to a real
    task. Duplicates are deduped.
    r5  r9  r[  r  c                    h | ]
}|d          S r  r`   r;  s     r   r  z._scan_prose_for_phantom_ids.<locals>.<setcomp>  s    &&&A$&&&r!   c                    g | ]}|v|	S r`   r`   )r   mexistings     r   r  z/_scan_prose_for_phantom_ids.<locals>.<listcomp>  s#    333!(!2!2A!2!2!2r!   )
_TASK_ID_PROSE_REfindallrI   r   r   r   rp  rK  rB  r  )	rM  r  matchesr   uniquer  r_  rV  r  s	           @r   _scan_prose_for_phantom_idsr    s      	''--G 	UUDF  D==HHQKKKMM!88SECKK/00L<<<\<<<f  hjj 	 '&&&&H3333v3333r!   c                  $     e Zd ZdZd fdZ xZS )HallucinatedCardsErroraO  Raised by ``complete_task`` when ``created_cards`` contains ids
    that don't exist or weren't created by the completing worker.

    The phantom list is attached as ``.phantom`` for callers that want
    structured access. Kept as ``ValueError`` subclass so existing
    tool-error handlers treat it as a recoverable user error.
    r  rY  r   rG   c                    t          |          | _        || _        t                                          dd                    |                      d S )Nz`completion blocked: claimed created_cards that do not exist or were not created by this worker: r  )r  r  r   r  r  r   )r  r  r   r  s      r   r  zHallucinatedCardsError.__init__  s_    G}}"4H3799W3E3EH H	
 	
 	
 	
 	
r!   )r  rY  r   rG   r  r  s   @r   r  r    sG         
 
 
 
 
 
 
 
 
 
r!   r  )r  r,  r.  created_cardsexpected_run_idr  r  r   c                  t          t          j                              }|rt          | ||          \  }|rt          |           5  t	          | |d||s|r8|p|pd                                                                d         dd         ndd           ddd           n# 1 swxY w Y   t          ||          ng t          |           5  ||                     d|||f          }	n'|                     d|||t          |          f          }	|	j	        d	k    r	 ddd           d
S t          | |dd||n||          }
|
|s|s|rt          | |d||n||          }
||n|pd}|r4|                                                                d         dd         nd}|rt          |          nd|pdd}r|d<   t          |t                    rD|                    d          }t          |t           t"          f          rd |D             }|r||d<   t	          | |d||
           ddd           n# 1 swxY w Y   d                    t'          d||g                    }|r^t)          | |          }fd|D             }|r>t          |           5  t	          | |d|dd|
           ddd           n# 1 swxY w Y   t+          | |           t-          |            t/          | |           dS )u  Transition ``running|ready -> done`` and record ``result``.

    Accepts a task that is merely ``ready`` too, so a manual CLI
    completion (``hermes kanban complete <id>``) works without requiring
    a claim/start/complete sequence.

    ``summary`` and ``metadata`` are stored on the closing run (if any)
    and surfaced to downstream children via :func:`build_worker_context`.
    When ``summary`` is omitted we fall back to ``result`` so single-run
    callers do not have to pass both. ``metadata`` is a free-form dict
    (e.g. ``{"changed_files": [...], "tests_run": [...]}``) — workers
    are encouraged to use it for structured handoff facts.

    ``created_cards`` is an optional list of task ids the completing
    worker claims to have created. Each id is verified against
    ``tasks.created_by``. If any id is phantom (does not exist or was
    not created by this worker's assignee profile), completion is blocked
    with a ``HallucinatedCardsError`` and a
    ``completion_blocked_hallucination`` event is emitted so the rejected
    attempt is auditable. When all ids verify, they are recorded on the
    ``completed`` event payload.

    After a successful completion, ``summary`` and ``result`` are scanned
    for prose references like ``t_deadbeefcafe`` that do not resolve.
    Any suspected phantom references are recorded as a
    ``suspected_hallucinated_references`` event. This pass is advisory
    and never blocks.
     completion_blocked_hallucinationr*   r   N   )phantom_cardsverified_cardssummary_previewa  
                UPDATE tasks
                   SET status       = 'done',
                       result       = ?,
                       completed_at = ?,
                       claim_lock   = NULL,
                       claim_expires= NULL,
                       worker_pid   = NULL
                 WHERE id = ?
                   AND status IN ('running', 'ready', 'blocked')
                a  
                UPDATE tasks
                   SET status       = 'done',
                       result       = ?,
                       completed_at = ?,
                       claim_lock   = NULL,
                       claim_expires= NULL,
                       worker_pid   = NULL
                 WHERE id = ?
                   AND status IN ('running', 'ready', 'blocked')
                   AND current_run_id = ?
                r(   F	completedr   )r+  r   r,  r.  r+  r,  r.    )
result_lenr,  r%  	artifactsc                    g | ]Y}t          |t                    t          |                                          8t          |                                          ZS r`   )r   rG   r/   r4  s     r   r  z!complete_task.<locals>.<listcomp>u  sb     % % %'(Jq#<N<N%SVWXSYSYS_S_SaSa%FFLLNN% % %r!   r  r   c                6    g | ]}|t                    v|S r`   rI   )r   r   r%  s     r   r  z!complete_task.<locals>.<listcomp>  s+    PPPa1C<O<O3O3O3O3O3Or!   !suspected_hallucinated_referencescompletion_summary)phantom_refssourceT)r&   r   r  r  rG  r/   
splitlinesr  rK  r  r  r  rp  r   r   r.   r  rB  r   filterr  r  r  _cleanup_workspace)rM  r   r  r,  r.  r  r   rO  r$  r  r@  
ev_summarycompleted_payloadmd_artifactscleaned_artifacts	scan_textr1  r%  s                    @r   complete_taskr;    s   L dikk

C  (='=)
 )
%  	A4  '#E)6*8 !(&+1&W44";;==HHJJ1MdsdSS!%                  )@@@	A  	4 Q
 Q
",,
 g& CC ,, gs?';';< C <1CQ
 Q
 Q
 Q
 Q
 Q
 Q
 Q
D '&2GG	
 
 
 >w>(>f>*g##*#6F!	  F ")!4gg&GR
AKSZ%%''2244Q7==QS
)/6#f+++Q!)T#
 #
  	A2@./ h%% 	G#<<44L,u66 G% %,8% % %! % G5F%k2';	
 	
 	
 	
[Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
l w&78899I 24CC QPPP<PPP 		4  '#F(4"6  "                  4)))DtW%%%4s?   	AB((B,/B,AI2DII
I$KKKr   c                N   	 |                      d          }n# t          $ r Y dS w xY wg }t          j                            dd                                          }|rZ	 |                    t          |                                                               d                     n# t          $ r Y nw xY w	 t                      }n# t          $ r d}Y nw xY w|	 |                    |dz  dz                       d                     n# t          $ r Y nw xY w	 |dz  dz                       d          }n# t          $ r d}Y nw xY w|	 t          |                                          }n# t          $ r g }Y nw xY w|D ]f}	 |                                sn# t          $ r Y %w xY w	 |                    |dz                       d                     W# t          $ r Y cw xY w|D ]2}||k    r		 |                    |          r d	S ## t          $ r Y /w xY wdS )
u  Return True iff *p* is a strict descendant of a kanban-managed scratch root.

    A managed root is exclusively a ``workspaces/`` directory — never the
    broader kanban home, a board root, or sibling subtrees like ``logs/`` or
    ``boards/<slug>/`` itself. Allowed roots:

    * ``HERMES_KANBAN_WORKSPACES_ROOT`` when set (worker-side override
      injected by the dispatcher).
    * ``<kanban_home>/kanban/workspaces`` — legacy default-board scratch root.
    * ``<kanban_home>/kanban/boards/<slug>/workspaces`` for each board slug
      that currently exists on disk.

    The check requires strict descendancy: a path equal to one of these
    roots is NOT managed (deleting the workspaces root would wipe every
    task's scratch dir at once), and a path that resolves to ``<kanban_home>
    /kanban`` itself, ``<kanban_home>/kanban/logs``, or
    ``<kanban_home>/kanban/boards/<slug>`` is rejected because those
    subtrees hold Hermes' own DB, metadata, and logs, not task workspaces.

    Used by :func:`_cleanup_workspace` to refuse to ``shutil.rmtree`` paths
    outside Hermes-managed storage. A board ``default_workdir`` pointing at a
    real source tree can otherwise pair with ``workspace_kind='scratch'`` and
    cause task completion to delete user data (#28818).
    F)strictr   r*   Nr]   r   r^   T)r   rm   r,   r-   r.   r/   r   r	   rX   r[   r  r   r   is_relative_tor0   )	r   p_absrootsrZ   homeboards_parentr   entryr   s	            r   _is_managed_scratch_pathrD    s   2			''   uuEz~~=rBBHHJJH 	LLh2244<<E<JJKKKK 	 	 	D	}}   	LL$/L8AAAOOPPPP 	 	 	D		!!H_x7@@@NNMM 	! 	! 	! MMM	!$}446677     	 	 <<>> ! !   HLL%,"6!?!?u!?!M!MNNNN   H  D==	##D)) tt 	 	 	H	5s    
''!AB* *
B76B7;C
 
CC /D 
DD!D> >EE!E5 5FFF""
F/.F/3,G  
G-,G-<H
H"!H"c                   	 |                      d|f                                          }|sdS |d         }|d         }|dk    s|st          | |           dS |                      d|f                                          }|rt                              d||           dS ddl}t          |          }|                                rYt          |          r. |j	        |d	
           t                              d|           nt          
                    d||           t          | |           t          | |           dS # t          $ r Y dS w xY w)uZ  Remove a task's scratch workspace dir and kill its stale tmux session.

    Called from :func:`complete_task` after the DB transaction commits.
    Best-effort — any error is swallowed so cleanup never blocks task completion.
    Only ``scratch`` workspaces are removed; ``worktree`` and ``dir`` workspaces
    are intentionally preserved.
    =SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?Nr   r   r   SELECT 1 FROM task_links l JOIN tasks t ON t.id = l.child_id WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived', 'failed', 'cancelled') LIMIT 1z[Deferring scratch workspace cleanup for task %s: active children still need workspace at %sr   Tignore_errorszRemoved scratch workspace: %szRefusing to remove out-of-scratch workspace for task %s: %s (workspace_kind='scratch' but path is outside any kanban-managed workspaces root))rK  r  _try_cleanup_parent_workspacesr
  debugr   r	   r   rD  r   warning_cleanup_worker_tmuxr  )rM  r   r  r>  r~   _active_childrenr   wps           r   r5  r5    s   :llKJ
 
 (** 	  	F!"23!"239D +4999F  << J
 
 (** 	  	JJ=  
 F$ZZ99;; 	 (++ 	b5555

:B????6 R	   	T7+++ 	'tW55555   s$   +E (E AE "B E 
EEc                6   	 |                      d|f                                          }|D ]\  }|                      d|f                                          }|r|d         dk    s|d         sD|                      d|f                                          }|rpddl}t	          |d                   }|                                r=t          |          r. |j        |d	
           t          	                    d||           dS # t          $ r Y dS w xY w)aM  Clean up parent scratch workspaces now that *task_id* completed.

    When a parent task's cleanup was deferred because it had active children,
    this function is called after each child completes.  If all children of a
    parent are now done/archived/failed/cancelled, the parent's scratch
    workspace is removed (#33774).
    z3SELECT parent_id FROM task_links WHERE child_id = ?rF  r   r   r   rG  r   NTrH  z9Deferred cleanup: removed parent %s scratch workspace: %s)rK  r  r  r   r	   r   rD  r   r
  rK  r  )rM  r   rx   rs  r  activer   rO  s           r   rJ  rJ  4  so   ,,AJ
 
 (** 	 $ 	g 	gLY,,O  hjj   #./9<<CHXDY<\\   hjj   MMMc*+,,Byy{{ g7;; gb5555

VXacefff-	g 	g.    s   DD
 

DDc                   	 |                      d|f                                          }|r|d         sdS |d         }d| }t          j        ddd|dd	gd
d
d          }|j                                        dk    r8t          j        ddd|gd
d           t                              d|           dS dS # t          $ r Y dS w xY w)zAKill the tmux session associated with a task's assignee, if dead.r  r   Nzswarm-tmuxz
list-panesz-tz-Fz#{pane_dead}Trh  )capture_outputr  rH  1zkill-session)rT  rH  zKilled stale tmux session: %s)	rK  r  
subprocessrunstdoutr/   r
  rK  r  )rM  r   r  r   sessionr  s         r   rM  rM  \  s   ll5z
 

(** 	  	#j/ 	FJ%8%%n\4$GdA
 
 
 :$$Nw7#Q    JJ6@@@@@ %$    s   3B9 A>B9 9
CCz.scratch_tip_shownu   scratch workspaces are ephemeral — they're deleted when the task completes. Use --workspace worktree: (git worktree) or --workspace dir:/abs/path (existing dir) to preserve worker output.c                 .    t                      t          z  S )z<Path to the per-install scratch-workspace-tip sentinel file.)r[   _SCRATCH_TIP_SENTINEL_NAMEr`   r!   r   _scratch_tip_sentinel_pathr\    s    ==555r!   c                 f    	 t                                                      S # t          $ r Y dS w xY w)u   True iff the scratch-workspace tip has already been emitted on this
    install. Best-effort — any error means we re-emit, which is the safer
    failure mode for a help message.F)r\  rk   rm   r`   r!   r   _scratch_tip_shownr^    sA    )++22444   uus   " 
00c                     	 t                      } | j                            dd           |                     d           dS # t          $ r Y dS w xY w)zTouch the sentinel so future scratch workspaces stay silent.

    Best-effort: a failure here just means the tip might appear once more,
    which is preferable to crashing dispatch over a help message.
    Trw   )ry   N)r\  r{   r|   touchrm   r~   s    r   _mark_scratch_tip_shownrb    si    )++$666

D
!!!!!   s   A A 
AAc                b   |pddk    rdS t                      rdS 	 t                              dt          |           t	          |           5  t          | |ddt          i           ddd           n# 1 swxY w Y   n# t          $ r Y nw xY wt                       dS # t                       w xY w)a  Emit the first-use scratch-workspace tip exactly once per install.

    Called from the dispatcher right after a scratch workspace is
    materialized. No-op for ``worktree`` / ``dir`` workspaces (they're
    preserved by design) and no-op after the sentinel exists.
    r   Nzkanban: %s (task %s)tip_scratch_workspacemessage)r^  r
  rL  _SCRATCH_TIP_MESSAGEr  rG  r  rb  )rM  r   r   s      r   _maybe_emit_scratch_tiprg    s    	#)	11 "+-A7KKKt__ 	 	g601  	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
     	 !!!!!!!!!sM   0A> A2&A> 2A66A> 9A6:A> =B >
BB 
BB B.)r,  r.  c          
     2   ||n|}t          |           5  |                     d|f                                          }|r|d         dk    r	 ddd           dS |                     d||f           |                     d|f                                          }|rt          |d                   nd}|t	          | |d	||
          }nF|                     d||f           |,|                     dt          j        |d          |f           |r4|                                                                d         dd         nd}	t          | |dddg|dgng z   |rt          |          nd|	pdd|           ddd           n# 1 swxY w Y   dS )z?Backfill the user-visible result for an already completed task.Nrv  r   r   Fz(UPDATE tasks SET result = ? WHERE id = ?z
            SELECT id FROM task_runs
             WHERE task_id = ?
               AND outcome = 'completed'
             ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC
             LIMIT 1
            r   r'  r(  z-UPDATE task_runs SET summary = ? WHERE id = ?z.UPDATE task_runs SET metadata = ? WHERE id = ?r  r   r)  r*   editedr  r,  r.  )fieldsr*  r,  r  T)r  rK  r  r&   r  r   r   r/   r3  rG  rp  )
rM  r   r  r,  r.  handoff_summaryr  rW  r@  r6  s
             r   edit_completed_task_resultrl    s{    ")!4gg&O	4 5
 5
ll3gZ
 

(** 	  	c(mv--5
 5
 5
 5
 5
 5
 5
 5
 	6W	
 	
 	
 ll J	
 	
 (** 	 $'0SYD>*g#'!	  FF LL? &)   #DZu===vF   'O!!##..003DSD99$& 	 	'8 y)'/';
||E .4:c&kkk%-  	
 	
 	
 	
U5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
l 4s   9FD$FFF)r  r   c                  t          |           5  ||                     d|f          }n%|                     d|t          |          f          }|j        dk    r	 ddd           dS t	          | |dd|          }||rt          | |d|          }t          | |dd	|i|
           	 ddd           dS # 1 swxY w Y   dS )z"Transition ``running -> blocked``.Na6  
                UPDATE tasks
                   SET status       = 'blocked',
                       claim_lock   = NULL,
                       claim_expires= NULL,
                       worker_pid   = NULL
                 WHERE id = ?
                   AND status IN ('running', 'ready')
                a`  
                UPDATE tasks
                   SET status       = 'blocked',
                       claim_lock   = NULL,
                       claim_expires= NULL,
                       worker_pid   = NULL
                 WHERE id = ?
                   AND status IN ('running', 'ready')
                   AND current_run_id = ?
                r(   Fr   r+  r   r,  r+  r,  r  r  T)r  rK  r&   r  r  r  rG  )rM  r   r  r   r  r@  s         r   
block_taskrp    sr    
4 , ,",, 
 CC ,,	 #o../ C <1;, , , , , , , ,< 'i
 
 
 >f>*g!  F
 	dGY60B6RRRRY, , , , , , , , , , , , , , , , , ,s   AB8)AB88B<?B<)r  forcedry_runactorrq  rr  tuple[bool, Optional[str]]c          
     0   |                      d|f                                          }|dd| dfS |d         }|dvrdd| d|d	fS |sR|                      d
|f                                          }d |D             }	|	rddd                    |	           dfS |rdS t	          |           5  |                      d|f          }
|
j        dk    rdd| dfcddd           S t          | |d|||d           ddd           n# 1 swxY w Y   dS )a  Manually promote a `todo` or `blocked` task to `ready`.

    Mirrors the automatic promotion done by ``recompute_ready`` but
    drives it from a deliberate operator action with an audit-trail
    entry. Refuses to promote if any parent dep is not in a terminal
    state (`done`/`archived`) unless ``force=True``. Does NOT change
    assignee or claim state. Returns ``(True, None)`` on success and
    ``(False, reason)`` if refused. ``dry_run=True`` validates the
    promotion would succeed without mutating state.
    rv  NFtask z
 not foundr   )r   r   z is z-; promote only applies to 'todo' or 'blocked'z_SELECT t.id, t.status FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?c                2    g | ]}|d          dv|d         S )r   r  r   r`   r4  s     r   r  z promote_task.<locals>.<listcomp>o  s6     
 
 
{"666 dG666r!   z!unsatisfied parent dependencies: r  z (use --force to override))TNzPUPDATE tasks SET status = 'ready' WHERE id = ? AND status IN ('todo', 'blocked')r(   z  status changed during promotionpromoted_manual)rs  r  forced)rK  r  r  r   r  r  rG  )rM  r   rs  r  rq  rr  r  r  rx   unsatisfiedr  s              r   promote_taskr{  H  s(   & ,,/' hjj  {1g11111XJ,,,#G # # # # #
 	

  ,,# J	
 

 (** 	
 
$
 
 
  	F99[))F F F 
  z	4 
 
ll=J
 

 <1K'KKKK
 
 
 
 
 
 
 
 	v??		
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 :s   2*D)DDDc           	     L   t          t          j                              }t          |           5  |                     d|f                                          }|r3|d         r+|                     d|t          |d                   f           |                     d|f                                          }|rdnd}|                     d||f          }|j        dk    r	 d	d	d	           d
S t          | |d|dk    rd|ind	           	 d	d	d	           dS # 1 swxY w Y   d	S )u  Transition ``blocked``/``scheduled`` -> ready or todo.

    Defensively closes any stale ``current_run_id`` pointer before flipping
    status. In the common path (``block_task`` closed the run already) this
    is a no-op. If a future or external write left the pointer dangling,
    the leaked run is closed as ``reclaimed`` inside the same txn so the
    runs invariant (``current_run_id IS NULL`` ⇔ run row in terminal
    state) holds for the rest of this function's lifetime.
    zTSELECT current_run_id FROM tasks WHERE id = ? AND status IN ('blocked', 'scheduled')r	  au  
                UPDATE task_runs
                   SET status = 'reclaimed', outcome = 'reclaimed',
                       summary = COALESCE(summary, 'invariant recovery on unblock'),
                       ended_at = ?,
                       claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
                 WHERE id = ? AND ended_at IS NULL
                zqSELECT 1 FROM task_links l JOIN tasks p ON p.id = l.parent_id WHERE l.child_id = ? AND p.status != 'done' LIMIT 1r   r   zUPDATE tasks SET status = ?, current_run_id = NULL, consecutive_failures = 0, last_failure_error = NULL WHERE id = ? AND status IN ('blocked', 'scheduled')r(   NF	unblockedr   T)r&   r   r  rK  r  r  rG  )rM  r   rO  r  undone_parents
new_statusr  s          r   unblock_taskr    s    dikk

C	4 * *bJ
 
 (** 	  	U+, 	LL c% 01223
 
 
" B J	
 

 (** 	  .:VV7
llB !	
 
 <1K* * * * * * * *L 	';&0G&;&;Xz""	
 	
 	
 U* * * * * * * * * * * * * * * * * *s   B2D/DD D)r   r   r   r4  c               v   |#|                                 st          d          t          |          }t          |           5  |                     d|f                                          }|	 ddd           dS dg}g }g }	|q|                                 |d         pdk    rQ|                    d           |                    |                                            |	                    d           |O|pd|d	         pdk    r?|                    d
           |                    |           |	                    d	           |M||d         pdk    r?|                    d           |                    |           |	                    d           |                    |           |                     dd                    |           dt          |                    }
|
j	        dk    r	 ddd           dS |	ry|rw|                                 rc|                     d||                                 dd                    |	          z   dz   t          t          j                              f           t          | |d|	rd|	ind           ddd           n# 1 swxY w Y   t          |            dS )uD  Flesh out a triage task and promote it to ``todo``.

    Atomically updates ``title`` / ``body`` / ``assignee`` (when provided)
    and transitions ``status: triage -> todo`` in a single write txn. Returns
    False when the task is missing or not in the ``triage`` column — callers
    should surface that as "nothing to specify" rather than an error.

    ``todo`` (not ``ready``) is the correct landing column: ``recompute_ready``
    promotes parent-free / parent-done todos to ``ready`` on the next
    dispatcher tick, which keeps the normal parent-gating behaviour intact
    for specified tasks that happen to have open parents.

    ``author`` is recorded on an audit comment only when at least one of
    ``title`` / ``body`` / ``assignee`` actually changed — avoids noisy
    comment spam for status-only promotions.
    Nztitle cannot be blankzJSELECT title, body, assignee FROM tasks WHERE id = ? AND status = 'triage'Fstatus = 'todo'r   r*   z	title = ?r   zbody = ?r   assignee = ?UPDATE tasks SET r  z# WHERE id = ? AND status = 'triage'r(   r  u   Specified — updated z and promoted to todo.	specifiedchanged_fieldsT)r/   r0   r.  r  rK  r  r   r   rB  r  r&   r   rG  r  )rM  r   r   r   r   r4  r  setsrn  r  r  s              r   specify_triage_taskr    sa   2 0111"8,,H	4 5
 5
<<XJ
 
 (** 	 5
 5
 5
 5
 5
 5
 5
 5
 --$&8G3D3J!K!KKK$$$MM%++--(((!!'***&1A1GR H HKK
###MM$!!&)))H*1E1M$N$NKK'''MM(###!!*---gll2		$ 2 2 2&MM
 

 <1;5
 5
 5
 5
 5
 5
 5
 5
<  	f 	 	 LL& LLNN,ii//0./ 	$$   	2@J~..d		
 	
 	
a5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
 5
v D4s    -J>E5J BJJ#&J#)r4  auto_promoteroot_assigneechildrenr  Optional[list[str]]c                 $ |sdS |t          |          }t          |          D ] \  }}t          |t                    st	          d| d          |                    d          }t          |t                    r|                                st	          d| d          |                    d          pg }	t          |	t                    st	          d| d          |	D ]_}
t          |
t                    r|
dk     s|
t          |          k    rt	          d| d	|
 d
          |
|k    rt	          d| d          `"dgt          |          z  $d t          t          |                    D             }t          |          D ]J\  }}|                    d          pg D ]-}||                             |           $|xx         dz  cc<   .K$fdt          t          |                    D             }d}|rW|                                }|dz  }||         D ]3}$|xx         dz  cc<   $|         dk    r|                    |           4|W|t          |          k    rt	          d          t          t          j                              }g }t          |           5  |                     d|f                                          }|	 ddd           dS |d         dk    r	 ddd           dS |d         }|d         pd}|d         }t          |          D ]\  }}t%                      }|d                                         }|                    d          }t          |                    d                    }|                    d          p|}|                    d          r|                    d          }n||k    r|}nd}|                     d||t          |t                    r|nd||||||pdf	           t'          | |d|pd|d           |                    |           t          |          D ]\\  }}|                    d          pg D ]?}||         }||         } |                     d|| f           t'          | | d|| d           @]|D ]}!|                     d|!|f           d g}"g }#|*|"                    d!           |#                    |           |#                    |           |                     d"d#                    |"           d$t+          |#                     |rY|                                rE|                     d%||                                d&d#                    |          z   d'z   |f           t'          | |d(||d)           ddd           n# 1 swxY w Y   |rt-          |            |S )*u<  Fan a triage task out into child tasks and promote the root to ``todo``.

    The root task stays alive and becomes the parent of every child —
    when all children reach ``done``, the root promotes to ``ready`` and
    its assignee (typically the orchestrator profile) wakes back up to
    judge completion or spawn more work.

    ``children`` is a list of dicts, each shaped like::

        {
            "title": "...",
            "body": "...",                     # optional
            "assignee": "profile-name",        # optional, None -> default fallback
            "parents": [0, 2],                 # indices into this same children list
        }

    Returns the list of created child task ids (in input order) on
    success. Returns ``None`` when:
      - The root task does not exist
      - The root task is not in ``triage``
      - A cycle would result (caller built a bad graph)

    Validation of titles/assignees happens inside the same write_txn as
    the inserts so a malformed entry aborts the whole decomposition
    cleanly (no orphan children).
    Nzchild[z] is not a dictr   z].title is requiredrx   z].parents must be a listr   z
].parents[z$] is not a valid index into childrenz ] cannot list itself as a parentc                    g | ]}g S r`   r`   r   r   s     r   r  z)decompose_triage_task.<locals>.<listcomp>b  s    >>>AR>>>r!   r(   c                ,    g | ]}|         d k    |S )r   r`   )r   _i_in_degs     r   r  z)decompose_triage_task.<locals>.<listcomp>g  s'    DDDR72;!3C3Cb3C3C3Cr!   z6cyclic dependency detected in decomposed children listzQSELECT id, status, tenant, workspace_kind, workspace_path FROM tasks WHERE id = ?r   r   r   r   r   r   r   r   zINSERT INTO tasks (id, title, body, assignee, status, workspace_kind,  workspace_path, tenant, created_at, created_by) VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)
decomposerr>  )r  from_decompose_ofr=  rx  ry  r  r  r  r  z WHERE id = ?r  zDecomposed into z,. Root will wake when all children complete.
decomposed)r  r  )r.  	enumerater   r   r0   r.   rG   r/   r  r&   rp  rD  r   r   r   r  rK  r  r!  rG  r   rB  r  )%rM  r   r  r  r4  r  idxr   r   parents_idxr   _adjr  _c_p_queue_seen_node_nbrO  r  root_rowr   root_ws_kindroot_ws_pathnew_idr   r   child_ws_kindchild_ws_pathp_idxrs  rt  r
  r  rn  r  s%                                       @r   decompose_triage_taskr  !  s   F  t +M::  )) Q Q
U%&& 	<:c:::;;;		'""%%% 	@U[[]] 	@>c>>>???ii	**0b+t,, 	ECcCCCDDD 	Q 	QAa%% Q!s8}}2D2D SSSSASSS   Cxx !O#!O!O!OPPP 	Q cCMM!G>>s8}})=)=>>>DH%%  B66)$$* 	 	BHOOBBKKK1KKKK	 EDDD5X//DDDFE
 #


; 	# 	#CCLLLALLLs|q  c"""  # HQRRR dikk

CI	4 x
 x
<<&J
 
 (**	 	
 x
 x
 x
 x
 x
 x
 x
 x
 H))x
 x
 x
 x
 x
 x
 x
 x
 (#
   01>Y 01 $H-- &	% &	%JC!^^F'N((**E99V$$D*599Z+@+@AAH "II&677G<Myy)** % %		*: ; ;,.. , $LL=
 &tS11;DDt!!+|
  " fi-GLL   V$$$$ $H-- 	 	JC9--3  %e,	$S>$)  
 (H(8<<   "  	 	CLL g    ""$KK'''MM-(((g>		$>>>&MM	
 	
 	
  	fllnn 	LL& LLNN&ii	**+DE    	'<&!. 	
 	
 	
ex
 x
 x
 x
 x
 x
 x
 x
 x
 x
 x
 x
 x
 x
 x
~  s   1-W+WJ:WWWc                "   t          |           5  |                     d|f          }|j        dk    r	 d d d            dS t          | |ddd          }t	          | |dd |           d d d            n# 1 swxY w Y   t          |            d	S )
NzUPDATE tasks SET status = 'archived',     claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND status != 'archived'r(   Fr  z#task archived with run still activern  r   r  T)r  rK  r  r  rG  r  )rM  r   r  r@  s       r   archive_taskr     s(   	4 F Fll4 J	
 
 <1F F F F F F F F '9
 
 

 	dGZfEEEE#F F F F F F F F F F F F F F F* D4s   $A5(A55A9<A9c                
   t          |           5  |                     d|f                                          }|r|d         dk    r	 ddd           dS |                     d||f           |                     d|f           |                     d|f           |                     d	|f           |                     d
|f           |                     d|f          }|j        dk    cddd           S # 1 swxY w Y   dS )a  Permanently remove an already-archived task and its related rows.

    Safety guard: only archived tasks can be deleted. Active / blocked / done
    tasks must be explicitly archived first so accidental data loss requires a
    second deliberate action.
    rv  r   r   NF:DELETE FROM task_links WHERE parent_id = ? OR child_id = ?+DELETE FROM task_comments WHERE task_id = ?)DELETE FROM task_events WHERE task_id = ?'DELETE FROM task_runs WHERE task_id = ?0DELETE FROM kanban_notify_subs WHERE task_id = ?DELETE FROM tasks WHERE id = ?r(   )r  rK  r  r  )rM  r   r  r  s       r   delete_archived_taskr    s    
4 ! !ll3J
 
 (** 	  	c(mz11! ! ! ! ! ! ! ! 	Hg	
 	
 	
 	BWJOOO@7*MMM>
KKKG'TTTll;gZHH|q !! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! !s   9C8BC88C<?C<c                   t          |           5  |                     d|f          }|j        dk    r	 ddd           dS |                     d||f           |                     d|f           |                     d|f           |                     d|f           |                     d	|f           ddd           n# 1 swxY w Y   t          |            d
S )af  Hard-delete a task and cascade to all related rows.

    Because the schema does not use ``ON DELETE CASCADE`` foreign keys,
    we explicitly delete from child tables first, then the task row.
    This keeps the operation atomic (single ``write_txn``).

    Returns ``True`` if the task existed and was deleted, ``False``
    if the task was not found.
    r  r(   NFr  r  r  r  r  T)r  rK  r  r  )rM  r   r  s      r   delete_taskr  4  s[    
4 U Ull;gZHH<1U U U U U U U U 	QT[]dSefffBWJOOO@7*MMM>
KKKG'TTTU U U U U U U U U U U U U U U D4s   $CA4CCCtaskc                  | j         pd}|dk    r| j        r[t          | j                                                  }|                                s t          d| j         d| j        d          nt          |          | j        z  }|                    dd           |S |dk    r| j        st          d| j         d	          t          | j                                                  }|                                s t          d| j         d| j        d
          |                    dd           |S |dk    r| j        st          j	                    dz  | j        z  S t          | j                                                  }|                                s t          d| j         d| j        d          |S t          d|           )u  Resolve (and create if needed) the workspace for a task.

    - ``scratch``: a fresh dir under ``<board-root>/workspaces/<id>/``,
      where ``<board-root>`` is the active board's root. The path is the
      same for the dispatcher and every profile worker, so handoff is
      path-stable.
    - ``dir:<path>``: the path stored in ``workspace_path``.  Created
      if missing.  MUST be absolute — relative paths are rejected to
      prevent confused-deputy traversal where ``../../../tmp/attacker``
      resolves against the dispatcher's CWD instead of a meaningful
      root.  Users who want a kanban-root-relative workspace should
      compute the absolute path themselves.
    - ``worktree``: a git worktree at ``workspace_path``.  Not created
      automatically in v1 -- the kanban-worker skill documents
      ``git worktree add`` as a worker-side step.  Returns the intended path.

    Persist the resolved path back to the task row via ``set_workspace_path``
    so subsequent runs reuse the same directory.
    r   rv  z! has non-absolute workspace_path z"; workspace paths must be absoluter   Trw   r   z- has workspace_kind=dir but no workspace_pathzR; use an absolute path (relative paths are ambiguous against the dispatcher's CWD)r   z
.worktreesz  has non-absolute worktree path z; use an absolute pathzunknown workspace_kind: )
r   r   r	   rX   is_absoluter0   r   r   r|   cwd)r  r   r>  r   s       r   resolve_workspacer  O  sG   ( +)Dy 	7 T())4466A==??  QDG Q Q*Q Q Q    e,,,tw6A	t,,,u}}" 	NNNN   $%%0022}} 	O O O&O O O  
 	
t,,,z" 	78::,tw66$%%0022}} 	A A A&A A A   
666
7
77r!   
Path | strc                    t          |           5  |                     dt          |          |f           d d d            d S # 1 swxY w Y   d S )Nz0UPDATE tasks SET workspace_path = ? WHERE id = ?)r  rK  rG   )rM  r   r~   s      r   set_workspace_pathr    s     
4 
 
>YY 	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s   &AA
Ac                  t          |           5  |g}d}|'|dz  }|                    t          |                     |                     ||          }|j        dk    r	 ddd           dS t          | |dd|          }||rt          | |d|          }t          | |dd	|i|
           	 ddd           dS # 1 swxY w Y   dS )a+  Park a task in ``scheduled`` so it is waiting on time, not human input.

    ``scheduled`` tasks are intentionally not dispatchable; an external cron,
    human action, or automation can later call ``unblock_task`` to re-gate them
    to ``ready`` (or ``todo`` if parents are still incomplete).
    a'  
            UPDATE tasks
               SET status       = 'scheduled',
                   claim_lock   = NULL,
                   claim_expires= NULL,
                   worker_pid   = NULL
             WHERE id = ?
               AND status IN ('todo', 'ready', 'running', 'blocked')
        Nz AND current_run_id = ?r(   Fr   rn  ro  r  r  T)r  r   r&   rK  r  r  r  rG  )rM  r   r  r   rn  sqlr  r@  s           r   schedule_taskr    sj    
4  $I &,,CMM#o..///ll3''<1!       " '
 
 

 >f>*g#  F
 	dG[8V2DVTTTT;                 s   AB=.AB==CCr   i    r(   z\b(quota|rate[\s_\-]?limit|429|403|auth\w*|unauthorized|forbidden|billing|subscription|access[\s_]denied|permission[\s_]denied|invalid[\s_]api[\s_]key)\bi,  iQ z-https?://github\.com/[^/\s]+/[^/\s]+/pull/\d+c                     e Zd ZU dZdZded<   dZded<    ee          Z	ded<   	  ee          Z
d	ed
<   	  ee          Zd	ed<   	  ee          Zd	ed<   	  ee          Zded<   	  ee          Zd	ed<   	  ee          Zd	ed<   	  ee          Zd	ed<   	  ee          Zd	ed<   	  ee          Zded<   	  ee          Zd	ed<   dS )DispatchResultz&Outcome of a single ``dispatch`` pass.r   r&   r  r  )default_factoryzlist[tuple[str, str, str]]spawnedrY  skipped_unassignedauto_assigned_defaultskipped_nonspawnablezlist[tuple[str, str, int]]skipped_per_profile_cappedcrashedauto_blocked	timed_outr  zlist[tuple[str, str]]respawn_guardedrate_limitedN)r   r!  r"  r#  r  r$  r  r   r  r  r  r  r  r  r  r  r  r  r  r  r`   r!   r   r  r    s        00IH*/%*E*E*EGEEEEB$)E$$?$?$?????L',uT'B'B'BBBBB5
 ',eD&A&A&AAAAAJ >CUSW=X=X=XXXXX" t444G4444B#eD999L9999E 5666I6666BuT222E22222-2U4-H-H-HOHHHH<
 $eD999L9999J Jr!   r  iX  z'dict[int, tuple[int, float]]'_recent_worker_exitsrW  
raw_statusc                n   | r| dk    rdS t          j                     }t          |          |ft          t          |           <   t          t                    t          dz  k    rM|t
          z
  fdt                                          D             D ]}t                              |d           t          t                    t          k    rdt          t                                          d           }|dt          |          dz           D ]"\  }}t                              |d           !dS dS )zRecord a reaped child's exit status for later classification.

    Called from the reap loop in ``dispatch_once``. Safe to call many
    times; duplicate pids overwrite (pids can cycle, latest wins).
    r   Nr   c                ,    g | ]\  }\  }}|k     |S r`   r`   )r   r   _sr  cutoffs       r   r  z'_record_worker_exit.<locals>.<listcomp>O  s&    TTT:1gr1VQr!   c                    | d         d         S )Nr(   r`   )kvs    r   r   z%_record_worker_exit.<locals>.<lambda>T  s    beAh r!   r   )	r   r&   r  rp  _RECENT_WORKER_EXITS_MAX_RECENT_WORKER_EXIT_TTL_SECONDSitemsr   r   )rW  r  rO  _pidr	  r   r  s         @r   _record_worker_exitr  B  s;     #((
)++C&)*oos%;S"
  #;q#@@@66TTTT)=)C)C)E)ETTT 	1 	1D $$T40000
  #;;;-3355;N;NOOO2W!223 	1 	1GD! $$T40000	 <;	1 	1r!   'tuple[str, Optional[int]]'c                d   t                               t          |                     }|dS |\  }}	 t          j        |          r/t          j        |          }|dk    rdS |t          k    rd|fS d|fS t          j        |          rdt          j        |          fS n# t          $ r Y nw xY wdS )u  Classify a recently-reaped worker by pid.

    Returns ``(kind, code)`` where ``kind`` is one of:

    * ``"clean_exit"`` — ``WIFEXITED`` with ``WEXITSTATUS == 0``. When the
      task is still ``running`` in the DB, this is a protocol violation
      (worker exited without calling ``kanban_complete`` / ``kanban_block``)
      and should be auto-blocked immediately — retrying will just loop.
    * ``"rate_limited"`` — ``WIFEXITED`` with status
      ``KANBAN_RATE_LIMIT_EXIT_CODE``. The worker bailed because the
      provider rate-limited / exhausted quota, NOT because the task failed.
      ``detect_crashed_workers`` releases the task back to ``ready`` without
      counting a failure, so a long quota window can't trip the breaker.
    * ``"nonzero_exit"`` — ``WIFEXITED`` with non-zero status. Real error.
    * ``"signaled"`` — ``WIFSIGNALED`` (OOM killer, SIGKILL, etc). Real crash.
    * ``"unknown"`` — pid was not in the reap registry (either reaped by
      something else, or died between reap tick and liveness check). Fall
      back to existing crashed-counter behavior.

    ``code`` is the exit status (for ``clean_exit`` / ``rate_limited`` /
    ``nonzero_exit``) or the signal number (for ``signaled``), or ``None``
    for ``unknown``.
    N)r#  Nr   )
clean_exitr   r  nonzero_exitsignaled)
r  r.   r&   r,   	WIFEXITEDWEXITSTATUSKANBAN_RATE_LIMIT_EXIT_CODEWIFSIGNALEDWTERMSIGr  )rW  rC  r2   r   codes        r   _classify_worker_exitr  Y  s    0 !$$SXX..E}  FC< 	*>#&&Dqyy((222&--"D))># 	2C 0 011	2   s#   .B  "B  1B  5)B   
B-,B-'list[int]'c                    g } t           j        dk    rt	 	 	 t          j        dt           j                  \  }}n# t          $ r Y n1w xY w|dk    rn&t          ||           |                     |           an# t          $ r Y nw xY w| S )zReap all zombie children of this process without blocking.

    Returns the list of reaped PIDs. Safe to call when there are no
    children (returns []). No-op on Windows.
    ntTr9   r   )r,   r   waitpidWNOHANGChildProcessErrorr  r   r  )reapedrW  r   s      r   reap_worker_zombiesr    s     F	w$	#"$*R"<"<KC(   E!88#C000c"""#  	 	 	D	Ms1   A7 "9 A7 
AA7 A0A7 7
BBc                   | r| dk    rdS ddl m}  |t          |                     sdS t          j        dk    r	 t          dt          |            ddd	          5 }|D ]E}|                    d
          r.d|                    dd          d         v r ddd           dS  nFddd           n# 1 swxY w Y   n# t          t          t          f$ r Y nw xY wt          j        dk    r	 t          j        ddddt          t          |                     gt          j        t          j        ddd          }|j        dk    rdS d|j        pd                                v rdS n"# t          t          j        t(          f$ r Y nw xY wdS )u  Return True if ``pid`` is still running on this host.

    Cross-platform: uses ``OpenProcess`` + ``WaitForSingleObject`` on
    Windows (via ``gateway.status._pid_exists``) and ``os.kill(pid, 0)``
    on POSIX. Returns False for falsy PIDs or on any OS error.

    **DO NOT** use ``os.kill(pid, 0)`` directly on Windows — Python's
    Windows ``os.kill`` treats ``sig=0`` as ``CTRL_C_EVENT`` (bpo-14484)
    and will broadcast it to the target's console group, potentially
    killing unrelated processes.

    **Zombie handling:** the existence check succeeds against zombie
    processes (post-exit, pre-reap) because the process table entry
    still exists. A worker that exits without being reaped by its
    parent would stay "alive" to the dispatcher forever. Dispatcher
    workers are started via ``start_new_session=True`` + intentional
    Popen handle abandonment, so init reaps them quickly — but during
    the window between exit and reap, we'd otherwise see stale "alive"
    signals. On Linux we peek at ``/proc/<pid>/status`` and treat
    ``State: Z`` as dead. On macOS we ask ``ps`` for the BSD ``stat``
    field and treat values containing ``Z`` as dead.
    r   F)_pid_existslinuxz/proc/z/statusr<  rg   rh   zState:Zr$  r(   Ndarwinpsz-ozstat=-pT)rX  stderrr  rH  checkr*   )gateway.statusr  r&   sysplatformrT  r}  r   r   PermissionErrorrm   rV  rW  rG   PIPEDEVNULL
returncoderX  r/   SubprocessErrorTimeoutError)rW  r  rr   lineprocs        r   r  r    s>   .  #((u******;s3xx   u |w	0s3xx000#HHH A  Dx00 $**S!"4"4Q"777#(        	               "?G< 	 	 	 D		
 
	!	!	>tWdCCMM:!!)  D !##ut{(b//1111u 23\B 	 	 	D	 4sZ   #C  9B4C  %B4(C  4B88C  ;B8<C   CC.AE( E( (FFr   dict[str, Any]c               0   ddl }| rt          |           ndddddd}| r| dk    s|s|S t                                          dd          d          d}t	          |                              |          s|S d|d<   ||n"t          t          d	          rt          j        nd}||S d|d
<   	  |t          |           |j	                   n# t          t          f$ r |cY S w xY wt          d          D ].}t          |           s	d|d<   |c S t          j        d           /t          |           rO	 t!          |d|j	                  } |t          |           |           d|d<   n# t          t          f$ r |cY S w xY wt          |            |d<   |S )z<Best-effort host-local worker termination for reclaim paths.r   NF)prev_pidr  termination_attempted
terminatedsigkillr$  r(   Tr  killr  rB   r        ?SIGKILLr  )signalr&   r)  r   rG   r}  hasattrr,   r  SIGTERMProcessLookupErrorrm   rD  r  r   sleeprX  )	rW  r   r  r
  r  r  r  r   _sigkills	            r   r  r    s    MMM !$-CHHH!& D  #((*( ]]((a003666Kz??%%k22 D!-992v&&0D 	 |$(D	 !SXXv~&&&&(    2YY  # 	!%DKKK
3# 	 vy&.AAHDS8$$$"DOO"G, 	 	 	KKK	 (__,DKs$   /C C$#C$54E* *F ?F )noter   r  c          	        t          t          j                              }t          |           5  ||                     d||f          }n&|                     d||t          |          f          }|j        dk    r	 ddd           dS |t          |          nt          | |          }||                     d||f           t          | |d|rd|ind|	           ddd           n# 1 swxY w Y   d
S )a  Record a ``heartbeat`` event + touch ``last_heartbeat_at``.

    Called by long-running workers as a liveness signal orthogonal to
    the PID check. A worker that forks a long-lived child (train loop,
    video encode, web crawl) can have its Python still alive while the
    actual work process is stuck; periodic heartbeats catch that.

    Returns True on success, False if the task is not in a state that
    should be heartbeating (not running, or claim expired).
    NzJUPDATE tasks SET last_heartbeat_at = ? WHERE id = ? AND status = 'running'zaUPDATE tasks SET last_heartbeat_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ?r(   Fz7UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?	heartbeatr  r  T)r&   r   r  rK  r  r  rG  )rM  r   r  r   rO  r  r@  s          r   heartbeat_workerr    s   " dikk

C	4 
 
",,6g CC ,,Mgs?334 C
 <1
 
 
 
 
 
 
 
" *     w// 	
 LLIf   	';",VTNN	
 	
 	
 	
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
< 4s   AC,AC,,C03C0c                  ddl }g }t          t          j                              }t                                          dd          d          d}|                     d                                          }|D ]j}|d         pd}|                    |          s#|t          |d                   z
  }	|	t          |d	                   k     rUt          |d
                   }
|d         }d}||n"t          t          d          rt          j
        nd}|	  ||
|j                   n# t          t          f$ r Y nw xY wt          d          D ]'}t          |
          s nt          j        d           (t          |
          r=	 t#          |d|j                  } ||
|           d}n# t          t          f$ r Y nw xY wt%          |           5  |                     d|f          }|j        dk    r|
t          |	          t          |d	                   |d}t)          | |dddt          |	           dt          |d	                    d|          }t+          | |d||           |                    |           ddd           n# 1 swxY w Y   |j        dk    r@t/          | |dt          |	           dt          |d	                    dddd|
|d           l|S )uR  Terminate workers whose per-task ``max_runtime_seconds`` has elapsed.

    Sends SIGTERM, waits a short grace window, then SIGKILL. Emits a
    ``timed_out`` event and drops the task back to ``ready`` so the next
    dispatcher tick re-spawns it — unless the spawn-failure circuit
    breaker has already given up, in which case the task stays blocked
    where ``_record_spawn_failure`` parked it.

    Runs host-local: only tasks claimed by this host are candidates
    (same reasoning as ``detect_crashed_workers``). ``signal_fn`` is a
    test hook; defaults to ``os.kill`` on POSIX.
    r   Nr$  r(   a\  SELECT t.id, t.worker_pid,        COALESCE(r.started_at, t.started_at) AS active_started_at,        t.max_runtime_seconds, t.claim_lock FROM tasks t LEFT JOIN task_runs r ON r.id = t.current_run_id WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL   AND COALESCE(r.started_at, t.started_at) IS NOT NULL   AND t.worker_pid IS NOT NULLr   r*   active_started_atr  r  r   Fr  rB   r  r	  TUPDATE tasks SET status = 'ready', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, last_heartbeat_at = NULL WHERE id = ? AND status = 'running')rW  elapsed_secondslimit_secondsr  r  zelapsed z
s > limit rR   r  r  )rW  r  )r/  r+  release_claimend_runevent_payload_extra)r
  r&   r   r)  r   rK  r  r}  r  r,   r  r  r  rm   rD  r  r  rX  r  r  r  rG  r   _record_task_failure)rM  r  r
  r  rO  r  rV  r  r  elapsedrW  tidkilledr  r   r  r  r?  r@  s                      r   enforce_max_runtimer   F  s   " MMMI
dikk

C ]]((a003666K<<	)	 	 hjj 	  M M< &B{++ 	 C 34555S234444#l#$$$i %1yyr6**4BGG 	 S&.))))&0    2YY    !# E
3# &vy&.IIHDh'''!FF*G4   D t__ 	& 	&,,6  C |q  '*7||%(-B)C%D%D%	  "#'_S\\__SEZA[=\=\___$	   #{GF      %%%1	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	&< <1 c[W[[SAV=W9X9X[[[##,/F$C$C    s7   D++D?>D?	$F..GGB/JJ	J	)stale_timeout_secondsr  r!  c               @   |dk    rg S t          t          j                              }t                                          dd          d          d}g }|                     d                                          }|D ]}|d         |t          |d                   z
  }||k     r+|d         }	|	|t          |	          z
  nd}
|
|
t          k     rW|d         }|d	         }|d
         pd}t          |||          }t          |           5  |                     d|f          }|j	        dk    r	 ddd           t          |          |	t          |	          nd|
t          |
          nd||rt          |          ndd}|
                    |           t          | |dd|
dt          |
           dnddt          |           dz   |          }t          | |d||           |                    |           ddd           n# 1 swxY w Y   |S )u  Reclaim ``running`` tasks that show no progress (heartbeat) within the
    staleness window.

    A task is considered stale when BOTH of these hold:

    1. It has been running for longer than ``stale_timeout_seconds``
       (measured from the active run's ``started_at``, falling back to
       ``tasks.started_at`` on older runs).
    2. Its ``last_heartbeat_at`` is older than
       ``_STALE_HEARTBEAT_GAP_SECONDS`` (or NULL — never sent a heartbeat).

    On reclaim the task is reset to ``ready``, the run is closed with
    ``outcome='stale'``, and the host-local worker (if still running) is
    terminated.

    Only considers ``status='running'`` tasks. Blocked tasks are never
    candidates.  Returns the list of reclaimed task IDs.

    ``stale_timeout_seconds=0`` disables the check entirely (returns ``[]``
    immediately).  ``signal_fn`` is a test hook; defaults to ``os.kill``
    on POSIX.
    r   r$  r(   zSELECT t.id, t.worker_pid, t.last_heartbeat_at, t.claim_lock,        COALESCE(r.started_at, t.started_at) AS active_started_at FROM tasks t LEFT JOIN task_runs r ON r.id = t.current_run_id WHERE t.status = 'running'r  Nr  r  r   r   r*   r  r  )r  r  heartbeat_age_secondstimeout_secondsrW  r  zno heartbeat for zs zno heartbeat everz after z	s runningr  r  )r&   r   r)  r   rK  r  _STALE_HEARTBEAT_GAP_SECONDSr  r  r  r   r  rG  r   )rM  r!  r  rO  r  r  rV  r  r  last_hbhb_agerW  r  r  r  r  r?  r@  s                     r   detect_stale_runningr(    s   8 !!	 dikk

C ]]((a003666KI<<	%  hjj 	  <" <""#+C 34555***)*)0)<#G$$$&+G"G"G,$i< &B 2
 
 
 t__ %	" %	",,6  C |q  %	" %	" %	" %	" %	" %	" %	" $'w<<$+$7CLLLT $*#5CKKK4#8#&0s3xxxD
 
G NN;'''c ) 8F7777,5c'll555	6
 !	 	 	F c7GF    S!!!K%	" %	" %	" %	" %	" %	" %	" %	" %	" %	" %	" %	" %	" %	" %	"b s   $HCHH	H	
error_textc                    t          j        dd| dd                   }t          j        dd|          }|                                                                S )zNormalize an error message for grouping identical failures.

    Strips host-specific details (PIDs, timestamps) so that errors
    with the same root cause produce the same fingerprint.
    z\bpid \d+\bzpid NNP   z\b\d{10,}\bz<TS>)resubrO   r/   )r)  fps     r   _error_fingerprintr/  5  sL     
CRC	9	9B		+	+B88::r!   c                   g }g }g }t          |           5  |                     d                                          }t                                          dd          d          d}|D ] }|d         pd}|                    |          s#d|                                v r|d         nd}|)t                      }	t          j                    |z
  |	k     rnt          |d	                   rt          |d	                   }
t          |
          \  }}d
}|dk    rd}d}d}|
|d         |d}nd|dk    rd
}d}d|
 d}d}|
|d         |d}nEd
}|dk    r	d|
 d| }n|dk    r	d|
 d| }nd|
 d}d}|
|d         d}||dk    r
||d<   ||d<   |                     d|d         f          }|j        dk    r|rdnd}t          | |d         |||t          |                    }t          | |d         |||            |rC|                     d!|dd"         |d         f           |                    |d                    |                    |d                    |                    |d         |
|d         ||f           "	 ddd           n# 1 swxY w Y   g }|ri }|D ]3\  }}}}}t#          |          }|                    |d          dz   ||<   4|D ]k\  }}
}}}t#          |          }| o|                    |d          d#k    }t'          | ||d|s|rdndd
d
|
|d$          }|r|                    |           l|t(          _        |t(          _        |S )%u  Reclaim ``running`` tasks whose worker PID is no longer alive.

    Appends a ``crashed`` event and drops the task back to ``ready``.
    Different from ``release_stale_claims``: this checks liveness
    immediately rather than waiting for the claim TTL.

    Only considers tasks claimed by *this host* — PIDs from other hosts
    are meaningless here. The host-local check is enough because
    ``_default_spawn`` always runs the worker on the same host as the
    dispatcher (the whole design is single-host).

    When the reap registry shows the worker exited cleanly (rc=0) but
    the task was still ``running`` in the DB, treat it as a protocol
    violation (worker answered conversationally without calling
    ``kanban_complete`` / ``kanban_block``) and trip the circuit breaker
    on the first occurrence — retrying a worker whose CLI keeps
    returning 0 without a terminal transition just loops forever.

    When the reap registry shows the worker exited with the rate-limit
    sentinel (``KANBAN_RATE_LIMIT_EXIT_CODE``), the worker bailed on a
    provider quota wall, NOT a task failure. Such tasks are released back
    to ``ready`` WITHOUT counting a failure (so a long quota window can't
    trip the breaker) and stamped with a quota-blocker error so
    ``check_respawn_guard`` defers their respawn until the window clears.
    The ids are returned via the ``_last_rate_limited`` function attribute
    (the public return stays the crashed-only ``list[str]``).
    zlSELECT id, worker_pid, claim_lock, started_at FROM tasks WHERE status = 'running' AND worker_pid IS NOT NULLr$  r(   r   r   r*   r   Nr  Fr  Tuc   worker exited cleanly (rc=0) without calling kanban_complete or kanban_block — protocol violationprotocol_violation)rW  r  	exit_coder  zpid uI    exited rate-limited (quota wall) — requeued without counting a failurer  z exited with code r  z killed by signal z
 not aliver  )rW  r  r#  	exit_kindr2  zUPDATE tasks SET status = 'ready', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND status = 'running'r   r  r  z4UPDATE tasks SET last_failure_error = ? WHERE id = ?  ri  )r/  r+  r  r  r  r  )r  rK  r  r)  r   r}  r  r<   r   r  r&   r  r  r  r   rG  r   r/  r.   r  detect_crashed_workers_last_auto_blocked_last_rate_limited)rM  r  r  crash_detailsrV  r  r  r  r   gracerW  r>  r  rate_limited_exitr1  r)  
event_kindevent_payloadr  _run_outcomer@  r  
_fp_countsr   err_textr.  r  r  is_systemictrippeds                                 r   r5  r5  @  s6   8 G L <>M	4 q q||B
 
 (** 	 %,,S!44Q7::: k	 k	C|$*D??;//  /;chhjj.H.H\**dJ%4669;;+e33#l+,, c,'((C.s33JD$ %|##
 &*"M  2
"<0!%! !
 '' &+"$(!;3 ; ; ;  ,
"<0!%! ! &+">))!E!E!Et!E!EJJZ''!E!E!Et!E!EJJ!7!7!7!7J&
(+L8I J J#	(9(915M+.15M+.,,6 T	 C |q   2CQ~~	!#d)($!-00	   #d)Z!!   
 %  LLN#DSD)3t95   !''D	2222NN3t9---!((TC\):+Z9  Qk	q q q q q q q q q q q q q q qx !L )%'
$1 	7 	7 Aq!Q#H--B'^^B22Q6JrNNAN 	) 	)=Cg1:#J//B&& /NN2q))Q.  +c !$6P+PaaD#,/G$D$D  G  )##C(((
 1=- 1=-Ns   I7JJ!J)r  r  r  r  r  r  r  c                  |t           }d}t          |           5  |                     d|f                                          }	|		 ddd           dS t	          |	d                   dz   }
|	d         }d|	                                v r|	d         nd}|t	          |          }d}nt	          |          }d	}|
|k    r|r"|                     d
|
|dd         |f           n!|                     d|
|dd         |f           d}|r"t          | |dd|dd         |
|||d          }|
|||dd         |d}|r|                    |           t          | |d||           d}n|r"|                     d|
|dd         |f           n!|                     d|
|dd         |f           |r>t          | ||||dd         d|
i          }t          | |||dd         |
d|           ddd           n# 1 swxY w Y   |S )u=  Record a non-success outcome (spawn_failed / crashed / timed_out)
    and maybe trip the circuit breaker.

    Unified replacement for the old spawn-only ``_record_spawn_failure``.
    Every path that ends a task with a non-success outcome funnels
    through here so the ``consecutive_failures`` counter and the
    auto-block threshold stay consistent.

    Returns True when the task was auto-blocked (counter reached
    ``failure_limit``), False when it was just updated in place.

    Modes:

    * ``release_claim=True, end_run=True`` — spawn-failure path.
      Caller has a running task with an open run; this transitions
      it back to ``ready`` (or ``blocked`` when the breaker trips),
      releases the claim, and closes the run with ``outcome=<outcome>``.

    * ``release_claim=False, end_run=False`` — timeout/crash path.
      Caller has ALREADY flipped the task to ``ready`` and closed the
      run with the appropriate outcome. This just increments the
      counter; if the breaker trips, the task is re-transitioned
      ``ready → blocked`` and a ``gave_up`` event is emitted.

    ``event_payload_extra`` merges into the ``gave_up`` event payload
    when the breaker trips, so callers can include outcome-specific
    context (e.g. pid on crash, elapsed on timeout).

    Resolution order for the effective threshold:
      1. per-task ``max_retries`` if set (nothing else overrides)
      2. caller-supplied ``failure_limit`` (gateway passes the config
         value from ``kanban.failure_limit``; tests pass fixed values)
      3. ``DEFAULT_FAILURE_LIMIT``
    NFzHSELECT consecutive_failures, status, max_retries FROM tasks WHERE id = ?r  r(   r   r  r  
dispatcherzUPDATE tasks SET status = 'blocked', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, consecutive_failures = ?, last_failure_error = ? WHERE id = ? AND status IN ('running', 'ready')r4  zUPDATE tasks SET status = 'blocked', consecutive_failures = ?, last_failure_error = ? WHERE id = ? AND status IN ('ready', 'running')r  )r  trigger_outcomer  limit_sourcer  )r  r  rE  r/  rD  r  TzUPDATE tasks SET status = 'ready', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, consecutive_failures = ?, last_failure_error = ? WHERE id = ? AND status = 'running'zNUPDATE tasks SET consecutive_failures = ?, last_failure_error = ? WHERE id = ?r  )r/  r  )	r  r  rK  r  r&   r  r  r   rG  )rM  r   r/  r+  r  r  r  r  r   r  r  r  task_overrider  rE  r@  r?  s                    r   r  r    s   Z -G	4 e ell&(/z
 
 (** 	 ;e e e e e e e e s1233a7]

 #0388::"="=C4 	 $!-00O!LL!-00O'L&& F uTcT{G4    F uTcT{G4	   F !'%i+$,+2+:(4	 	
 
 
 %#2 ,tt#* G # 42333gy'&    GG  : uTcT{G4    :uTcT{G4  
  !'#G+((3	   '7#DSDkx@@!   Ce e e e e e e e e e e e e e eN Ns   -G:FG::G>G>r  c          	     .    t          | ||d|dd          S )Nspawn_failedT)r+  r  r  r  )r  )rM  r   r/  r  s       r   _record_spawn_failurerJ    s.      gu#   r!   c           
     T   t          |           5  |                     dt          |          |f           t          | |          }|%|                     dt          |          |f           t	          | |ddt          |          i|           ddd           dS # 1 swxY w Y   dS )zRecord the spawned child's pid + emit a ``spawned`` event.

    The event's payload carries the pid so a human reading ``hermes kanban
    tail`` can correlate log lines with OS-level traces without opening
    the drawer.
    z,UPDATE tasks SET worker_pid = ? WHERE id = ?Nz0UPDATE task_runs SET worker_pid = ? WHERE id = ?r  rW  r  )r  rK  r&   r  rG  )rM  r   rW  r@  s       r   _set_worker_pidrL    s    
4 R R:XXw	
 	
 	
 !w//LLBS6"   	dGYC0A&QQQQR R R R R R R R R R R R R R R R R Rs   B BB!$B!c                    t          |           5  |                     d|f           ddd           dS # 1 swxY w Y   dS )u  Reset the unified consecutive-failures counter.

    Called from ``complete_task`` on successful completion — a fresh
    success means the task + profile combination is working and any
    past failures are history. NOT called on spawn success anymore:
    a successful spawn proves the worker could start but says nothing
    about whether the run will succeed, so we need to let timeouts and
    crashes accumulate across spawn boundaries.
    zQUPDATE tasks SET consecutive_failures = 0, last_failure_error = NULL WHERE id = ?N)r  rK  )rM  r   s     r   r  r    s     
4 
 
5J	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s   599c                   |                      d|f                                          }|dS t          t          j                              }t	                      }|                      d|f                                          }|8|d         dk    r,|dk    rdS |d         }||t          |          z
  |k     rdS dS |d	         }|rt
                              |          rd
S |t          z
  }|                      d||f                                          rdS |t          z
  }	|                      d||	f          	                                D ]-}
|
d         r#t                              |
d                   r dS .dS )u3	  Return a guard reason if ``task_id`` should NOT be re-spawned, else None.

    Called per ready task in ``dispatch_once`` before any claim attempt.
    Returning a reason defers the spawn this tick; the task stays in
    ``ready`` and gets another chance on the next dispatcher tick.

    Checks in priority order:

    ``"rate_limit_cooldown"``
        The task's most recent run ended with the ``rate_limited`` outcome
        (a worker bailed on a provider quota wall via the EX_TEMPFAIL
        sentinel) within ``_resolve_rate_limit_cooldown_seconds()``. The
        quota almost certainly hasn't reset yet, so defer the respawn until
        the cooldown elapses — then allow a cheap probe. This is checked
        BEFORE ``blocker_auth`` because the rate-limit requeue stamps a
        quota-flavored ``last_failure_error`` that would otherwise match the
        auth-blocker regex and park the task forever (the rate-limit path
        never increments ``consecutive_failures``, so the breaker can't free
        it). Once the cooldown elapses the task falls through and respawns.

    ``"blocker_auth"``
        The task's last failure error matches a quota / authentication
        pattern. Retrying immediately is unlikely to help (rate limits
        reset on a timer; auth needs human action), so we defer to the
        next tick. The existing ``consecutive_failures`` counter still
        trips the auto-block circuit breaker after ``failure_limit``
        consecutive failures, so a persistent auth error eventually
        blocks via the normal path — but a transient 429 gets a few
        ticks of recovery first.

    ``"recent_success"``
        A completed run exists within ``_RESPAWN_GUARD_SUCCESS_WINDOW``
        seconds.  Useful work already succeeded for this task; wait for
        human review rather than immediately re-spawning.

    ``"active_pr"``
        A GitHub PR URL appears in a recent task comment (within
        ``_RESPAWN_GUARD_PR_WINDOW`` seconds).  A prior worker already
        opened a PR; re-spawning risks a duplicate PR on the same task.

    Stale / dead claim locks are NOT a guard reason — they are handled
    by ``release_stale_claims`` and ``detect_crashed_workers`` which
    reset the task to ``ready`` only after verifying the lock is
    genuinely dead (no live PID on this host).
    z1SELECT last_failure_error FROM tasks WHERE id = ?NzqSELECT outcome, ended_at FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT 1r+  r  r   r*  rate_limit_cooldownr  blocker_authzVSELECT id FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ?recent_successzDSELECT body FROM task_comments WHERE task_id = ? AND created_at >= ?r   	active_pr)rK  r  r&   r   rA   _RESPAWN_BLOCKER_REsearch_RESPAWN_GUARD_SUCCESS_WINDOW_RESPAWN_GUARD_PR_WINDOWr  _RESPAWN_GUARD_PR_URL_RE)rM  r   r  rO  rl_cooldown
latest_runr*  errr  	pr_cutoffr  s              r   check_respawn_guardr\    s   \ ,,;	
  hjj  {t
dikk

C 788K	) 

	 
 hjj  	y!^33! 4j)S3x==%8K$G$G(( t "
#C
 "))#.. ~ 00F||	H	&  hjj	 
   ..I\\N	)  hjj  V9 	1886CC 	;;4r!   c                    |                      d                                          }|sdS 	 ddlm} n# t          $ r Y dS w xY w|D ]} ||d                   r dS dS )u  Return True iff there is at least one ready+assigned+unclaimed task
    whose assignee maps to a real Hermes profile.

    Used by the gateway- and CLI-embedded dispatchers' health telemetry to
    decide whether ``0 spawned`` is a "stuck" condition (real spawnable
    work waiting) or a "correctly idle" condition (only control-plane
    lanes like ``orion-cc`` / ``orion-research`` waiting on terminals
    that pull tasks via ``claim_task`` directly).

    Falls back to "any ready+assigned" if ``profile_exists`` is not
    importable (e.g. partial install) — preserves the old behavior so
    the warning still fires in degraded environments.
    znSELECT DISTINCT assignee FROM tasks WHERE status = 'ready' AND assignee IS NOT NULL     AND claim_lock IS NULLFr   profile_existsTr   rK  r  r-  r_  r  rM  rV  r_  r  s       r   has_spawnable_readyrb  P  s     <<	%  hjj	 	
  u6666666   tt   >#j/** 	44	5   4 
AAc                    |                      d                                          }|sdS 	 ddlm} n# t          $ r Y dS w xY w|D ]} ||d                   r dS dS )u*  Return True iff there is at least one review+assigned+unclaimed task
    whose assignee maps to a real Hermes profile.

    Mirror of :func:`has_spawnable_ready` for the review column —
    used by the health telemetry to decide whether the dispatcher
    should have spawned a review agent.
    zoSELECT DISTINCT assignee FROM tasks WHERE status = 'review' AND assignee IS NOT NULL     AND claim_lock IS NULLFr   r^  Tr   r`  ra  s       r   has_spawnable_reviewre  p  s     <<	%  hjj	 	
  u6666666   tt  >#j/** 	44	5rc  )
spawn_fnr#   rr  	max_spawnmax_in_progressr  r!  r   default_assigneemax_in_progress_per_profilerg  rh  ri  rj  c       
   
        t                       t                      }t          |           |_        t	          | |          |_        t          |           |_        t          t          dg           }|r|j	        
                    |           t          t          dg           }|r|j        
                    |           t          |           |_        t          | |          |_        d}|:t!          |                     d                                          d                   }|                     d                                          }|F|rD|                     d                                          d         }||k    r|S ||z
  }|||k    r|}d}t)          |
t                     r|
dk    r|
nd}i }|6|                     d	          D ] }t!          |d
                   ||d         <   !|	pd                                pd}d}|r2	 ddlm} t1           ||                    }n# t2          $ r d}Y nw xY w|D ]}|||z   |k    r n|d         }|s|r|r|s	 t5          |           5  |                     d||d         f           t7          | |d         d|dd           ddd           n# 1 swxY w Y   nT# t2          $ rG t8                              d||d         d           |j                            |d                    Y w xY w|}|j                             |d                    n"|j                            |d                    	 ddlm} n# t2          $ r d}Y nw xY w|- ||          s"|j!                            |d                    f|A|"                    |d          }||k    r%|j#                            |d         ||f           tI          | |d                   }|g|j%                            |d         |f           |sAt5          |           5  t7          | |d         dd|i           ddd           n# 1 swxY w Y   (|rE|j&                            |d         |df           ||r|"                    |d          dz   ||<   otO          | |d         |          }|	 tQ          ||          }nT# t2          $ rG} tS          | |j*        d|  |          }!|!r|j	                            |j*                   Y d} ~ d} ~ ww xY wtW          | |j*        tY          |                     t[          | |j*        |j.                   ||nt^          }"	 ddl0}#	 |#1                    |"          }$d|$j2        v r |"|tY          |          |          }%n |"|tY          |                    }%n0# tf          th          f$ r  |"|tY          |                    }%Y nw xY w|%r#tk          | |j*        t!          |%                     |j&                            |j*        |j6        pdtY          |          f           |dz  }|-|j6        r&|"                    |j6        d          dz   ||j6        <   V# t2          $ rQ} tS          | |j*        tY          |           |          }!|!r|j	                            |j*                   Y d} ~ d} ~ ww xY w|                     d                                          }&|&D ]}|||z   |k    r n|d         s!|j                            |d                    :	 ddlm} n# t2          $ r d}Y nw xY w|2 ||d                   s!|j!                            |d                    |r*|j&                            |d         |d         df           to          | |d         |          }|	 tQ          ||          }nT# t2          $ rG} tS          | |j*        d|  |          }!|!r|j	                            |j*                   Y d} ~ .d} ~ ww xY wtW          | |j*        tY          |                     t[          | |j*        |j.                   dg|_8        ||nt^          }"	 ddl0}#	 |#1                    |"          }$d|$j2        v r |"|tY          |          |          }%n |"|tY          |                    }%n0# tf          th          f$ r  |"|tY          |                    }%Y nw xY w|%r#tk          | |j*        t!          |%                     |j&                            |j*        |j6        pdtY          |          f           |dz  }s# t2          $ rQ} tS          | |j*        tY          |           |          }!|!r|j	                            |j*                   Y d} ~ d} ~ ww xY w|S ) u  Run one dispatcher tick.

    Steps:
      1. Reclaim stale running tasks (TTL expired).
      2. Reclaim stale running tasks (no recent heartbeat).
      3. Reclaim crashed running tasks (host-local PID no longer alive).
      3. Promote todo -> ready where all parents are done.
      4. For each ready task with an assignee, atomically claim and call
         ``spawn_fn(task, workspace_path, board) -> Optional[int]``. The
         return value (if any) is recorded as ``worker_pid`` so subsequent
         ticks can detect crashes before the TTL expires.

    Spawn failures are counted per-task. After ``failure_limit`` consecutive
    failures the task is auto-blocked with the last error as its reason —
    prevents the dispatcher from thrashing forever on an unfixable task.

    ``max_spawn`` is a **live concurrency cap**, not a per-tick spawn budget:
    it counts tasks already in ``status='running'`` plus this tick's spawns
    against the limit. So ``max_spawn=4`` means "at most 4 workers running
    at any time across the whole board" — matching the gateway's stated
    intent ("limit concurrent kanban tasks"). With a per-tick interpretation
    a 60-second tick interval could grow concurrency by N every minute on a
    busy board and accumulate without bound.

    ``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub.
    ``board`` pins workspace/log/db resolution for this tick to a specific
    board. When omitted, the current-board resolution chain is used.
    )r!  r6  r7  rG  r   Nz3SELECT COUNT(*) FROM tasks WHERE status = 'running'zsSELECT id, assignee FROM tasks WHERE status = 'ready' AND claim_lock IS NULL ORDER BY priority DESC, created_at ASCzmSELECT assignee, COUNT(*) AS n FROM tasks WHERE status = 'running' AND assignee IS NOT NULL GROUP BY assigneer8  r   r*   Fr^  TzRUPDATE tasks SET assignee = ? WHERE id = ? AND (assignee IS NULL OR assignee = '')r   rq  zkanban.default_assignee)r   r2  z?kanban dispatch: failed to apply default_assignee=%r to task %s)exc_infor  r  r(   )r#   r   zworkspace: r   ztSELECT id, assignee FROM tasks WHERE status = 'review' AND claim_lock IS NULL ORDER BY priority DESC, created_at ASCzsdlc-review)9r  r  r  r  r(  r  r5  r  rX  r  r  r  r   r  r  r  r&   rK  r  r  r   r/   r-  r_  r   r  r  rG  r
  rK  r  r   r  r  r.   r  r\  r  r  r  r  rJ  r   r  rG   rg  r   _default_spawninspectr  
parameters	TypeErrorr0   rL  r   r  r  )'rM  rf  r#   rr  rg  rh  r  r!  r   ri  rj  r  _crash_auto_blocked_crash_rate_limitedrunning_count
ready_rowsin_progress	remainingr  _per_profile_cap_per_profile_runningprow_default_assignee_default_assignee_resolved_per  row_assigneer_  rc   guard_reasonr  	workspacer  auto_spawnrn  sigrW  review_rowss'                                          r   dispatch_oncer    s   X F+D11F'$9  FL ,D11FN " 4b   8""#6777 " 4b   8""#6777*400F%d-HHHFO MLLE hjj
 
 	1  hjj	  "z"llA
 

(**Q /))M#k1		I 5 5!IG 	.44'!++ 32   ,.#LL 
 
 	D 	DD
 69c^^ j!122 */R6688@D!& 
.		.AAAAAA)-cc2C.D.D)E)E&& 	. 	. 	. *.&&&	.  i7 i7 ]W%<	%I%IE: *	 ! %?   !!&t__   LL!J!2CI >  
 * $c$i0A.G!" !"                 % ! ! !

)-s4y4 #   
 188TCCC !  1,33CI>>>>)00T;;;	"::::::: 	" 	" 	"!NNN	"%nn\.J.J% '..s4y999 '*..|Q??G***188Yg6    +4T;;#"))3t9l*CDDD  t__  !c$i):!<0                
  
	N!!3t9lB"?@@@
  ++(,,\1==A %\2 T3t9+FFF?		)'???II 	 	 	(gj"5"5"5+  D  7#**7:666HHHH	 	4S^^<<<gj'2HIII%1~%	7 NNN6''//cn,, &#i..FFFCC &#i..99Cz* 6 6 6fWc)nn556 <gj#c((;;; N!!7:w/?/E2s9~~"VWWWqLG  +0@+(,,W-=qAAAE %W%56  	7 	7 	7(gj#c((+  D  7#**7:666	7" ,,	1  hjj	 
  ;7 ;7 ]W%<	%I%IE: 	%,,SY777	"::::::: 	" 	" 	"!NNN	"%nnS_.M.M%'..s4y999 	N!!3t9c*or"BCCC#D#d)MMM?		)'???II 	 	 	(gj"5"5"5+  D  7#**7:666HHHH	 	4S^^<<<gj'2HIII (%1~	7NNN6''//cn,, &#i..FFFCC &#i..99Cz* 6 6 6fWc)nn556 <gj#c((;;;N!!7:w/?/E2s9~~"VWWWqLGG 	7 	7 	7(gj#c((+  D  7#**7:666	7 Ms-  H- -H<;H<$K3:J9-K9J=	=K J=	KALLM&&M54M55QQ 	#Q 	S
T0)<T++T0=ZAWZ*X ZXBZ
[2!A[--[2]  ]/.]/.`  
a
<aa&f+Ac?>f?*d,)f+d,,A#f
g,Ag''g,minimumvaluer
   r  c               j    	 t          |           }n# t          t          f$ r |cY S w xY w||k    r|n|S r   )r&   rp  r0   )r  rC   r  r3   s       r   _positive_intr    sQ    Uz"   w&&66G3s    ((
kanban_cfgtuple[int, int]c                0   | 9	 ddl m}  |                                d          pi } n# t          $ r i } Y nw xY wt	          | pi                     d          t
          d          }t	          | pi                     d          t          d          }||fS )	a,  Return ``(rotate_bytes, backup_count)`` for worker log rotation.

    Defaults preserve the historical behavior: rotate at 2 MiB and keep one
    backup generation (``.log.1``). Operators with long-running workers can
    raise either value from ``config.yaml`` without changing dispatcher code.
    Nr   )load_configr]   worker_log_rotate_bytesr(   r  worker_log_backup_count)hermes_cli.configr  r.   r  r  DEFAULT_LOG_ROTATE_BYTESDEFAULT_LOG_BACKUP_COUNT)r  r  	max_bytesbackup_counts       r   worker_log_rotation_configr    s     	555555%+--++H55;JJ 	 	 	JJJ			r899   I
 !		r899   L
 l""s   %* 99log_path
generationc                B    |                      | j        d| z             S )Nr  )with_suffixr   )r  r  s     r   _rotated_log_pathr  2  s&    2Bj2B2B BCCCr!   r  r  c                   	 |                                  sdS |                                 j        |k    rdS t          |t          d          }|dk    r|                                  dS t          | |          }	 |                                 r|                                 n# t          $ r Y nw xY wt          |dz
  dd          D ]^}t          | |          }|                                 s'	 |	                    t          | |dz                        O# t          $ r Y [w xY w| 	                    t          | d                     dS # t          $ r Y dS w xY w)a  Rotate ``<log>`` when it exceeds ``max_bytes``.

    ``backup_count=1`` preserves the legacy single-generation behavior:
    ``<log>`` moves to ``<log>.1`` and any previous ``.1`` is replaced.
    Higher values shift older generations up to ``backup_count``.
    Nr   r  r(   r9   )
rk   rz  r{  r  r  r   r  rm   rD  r   )r  r  r  oldestr  srcs         r   _rotate_worker_logr  6  s      	F==??"i//F$$
 
 

 1OOF"8\::	}}   	 	 	D	q 0!R88 	 	J#Hj99C::<< 

,XzA~FFGGGG   )(A6677777   si   E E 1E *E ;(B$ #E $
B1.E 0B11>E 0&DE 
D$!E #D$$&E 
EEc                      t           j        ddgS )z3Return the interpreter-bound Hermes CLI invocation.-mzhermes_cli.main)r  
executabler`   r!   r   _module_hermes_argvr  a  s    
 ND"344r!   c                    t           j                            |           }t           j                            |          r|nt           j                            |          S )z>Return an absolute filesystem path for a resolved Hermes shim.)r,   r~   rX   isabsabspath)r~   expandeds     r   _absolute_hermes_pathr  i  sB    w!!$''Hw}}X..M88BGOOH4M4MMr!   c                L   t           j                            |           }|                    d          ppt           j                            |          pQt          t           j                            |                    p%d|v p!t          t          j        d|                    S )zDReturn true when a command override is an explicit path, not a name.~\z
^[A-Za-z]:)	r,   r~   rX   r}  r  r   dirnamer,  rQ   )r  r  s     r   _looks_like_pathr  o  s    w!!%((HC   	37==""	3))**	3 8	3 1122r!   c                P    |                                                      d          S )zEReturn true for Windows shell/batch shims that should not be argv[0].)z.cmdz.bat)rO   endswithra  s    r   _is_windows_batch_shimr  {  s    ::<<  !1222r!   commandc                     t           r%t          j                                       d         r gS t          j                            d          pd}d |                    d          D             } fd|D             S )z:Return executable names to try for an unqualified command.r(   PATHEXTz.COM;.EXE;.BAT;.CMDc                    g | ]}||S r`   r`   )r   exts     r   r  z&_path_search_names.<locals>.<listcomp>  s    111CS1C111r!   ;c                    g | ]}|z   S r`   r`   )r   r  r  s     r   r  z&_path_search_names.<locals>.<listcomp>  s    ***cGcM***r!   )rU  r,   r~   splitextr-   r.   r   )r  r2   extss   `  r   _path_search_namesr    s     "'**733A6 y
*..
#
#
<'<C11399S>>111D****T****r!   c                   t           j                            dd          }|                    t           j                  D ]}|r|dk    rt           j                            |          }t          |           D ]n}t           j                            ||          }t           j        	                    |          sBt          st          j        |t           j                  r|c c S odS )aZ  Resolve a bare command from PATH without implicit current-dir search.

    ``shutil.which`` follows platform search behavior. On Windows that can
    include the current directory before PATH for bare names, which is not a
    safe dispatcher primitive. This resolver only considers explicit PATH
    entries and skips empty / ``.`` entries.
    PATHr*   r  N)r,   r-   r.   r   pathsepr~   rX   r  r   isfilerU  accessX_OK)r  path_envraw_dir	directoryr   r  s         r   _safe_which_no_cwdr    s     z~~fb))H>>"*-- 	! 	! 	'S..G&&w//	&w// 	! 	!DY55I7>>),,  !bi	27;; !      !		! 4r!   c                j    t           rt          |           rt                      S t          |           gS )a4  Return argv for a resolved Hermes executable path.

    Windows batch shims (`.cmd` / `.bat`) are not safe as argv[0] for
    worker launches because the argument vector includes task-derived
    values. Prefer the interpreter-bound module form whenever the resolved
    executable is only a shell shim.
    )rU  r  r  r  ra  s    r   _hermes_path_argvr    s9      %-d33 %"$$$!$''((r!   c                    ddl } t          j                            dd                                          }|rLt          |          rt          |          S t          |          }|rt          |          S t                      S t          rt          d          n | j
        d          }|rt          |          S t                      S )u  Resolve the ``hermes`` invocation as argv parts for ``Popen``.

    Tries in order:

    1. ``$HERMES_BIN`` — explicit operator override. Path-like values are
       normalized to absolute paths; bare command names keep normal PATH
       semantics and never prefer a same-directory file before ``PATH``.
    2. ``shutil.which("hermes")`` — the console-script shim, normalized to
       an absolute path. On Windows, ``which`` can return a relative
       ``.\hermes.CMD`` when the current directory is on ``PATH``; directly
       launching batch shims is also unsafe with task-derived argv. The
       dispatcher therefore falls back to the interpreter-bound module form
       for implicit ``.cmd`` / ``.bat`` shims.
    3. ``sys.executable -m hermes_cli.main`` — fallback for setups where
       Hermes is launched from a venv and the ``hermes`` shim is not on
       the dispatcher's ``$PATH`` (cron, systemd ``User=`` services,
       launchd jobs, detached processes, etc.). Goes through the running
       interpreter so the result is independent of ``$PATH``.

    Mirrors ``gateway.run._resolve_hermes_bin`` for the same reason. Kept
    local (not imported from gateway) because ``hermes_cli`` sits below
    ``gateway`` in the dependency order.
    r   N
HERMES_BINr*   hermes)r   r,   r-   r.   r/   r  r  r  r  rU  which)r   env_binresolved_env_bin
hermes_bins       r   _resolve_hermes_argvr    s    0 MMMjnn\2..4466G %G$$ 	.$W----g66 	7$%5666"$$$1<X#H---,&,xBXBXJ - ,,,   r!   hermes_homec                P   ddl m} | r ||           n|                                dz  }|dz  }|                                sdS |dz  dz  dz                                  rd	S 	 |                    d
          D ]}|                                r d	S n# t          $ r Y nw xY wdS )u  True if the bundled ``kanban-worker`` skill resolves for the home the
    spawned worker will run under.

    The dispatcher injects ``--skills kanban-worker`` into every worker. When
    the worker activates a profile (``hermes -p <name>``), its ``SKILLS_DIR``
    becomes ``<profile_home>/skills`` — which on many profiles does NOT contain
    the bundled skill (it ships in the *default* root home, not every
    profile-scoped skills dir). Preloading a missing skill is fatal at CLI
    startup (``ValueError: Unknown skill(s): kanban-worker``), aborting the
    worker before the agent loop runs. Gate the flag on actual resolvability;
    the kanban lifecycle contract is still injected via ``KANBAN_GUIDANCE``, so
    omitting the flag only drops the supplementary pattern library.
    r   r   z.hermesr  Fdevopskanban-workerzSKILL.mdTzkanban-worker/SKILL.md)pathlibr	   rA  r   r  rglobrm   )r  _Pathbaseskills_rootskill_mds        r   _kanban_worker_skill_availabler    s     &%%%%% "-L555::<<)3KD/K u 	h0:=FFHH t#))*BCC 	 	H!! tt	    5s   &,B B 
B#"B#current_timeoutc                d   | dS 	 t          |           }n# t          t          f$ r Y dS w xY w|dk    rdS t          d|t          z
            }	 |r.t          t          |                                                    nd}n# t          t          f$ r d}Y nw xY w||k    rdS t          |          S )ak  Return a worker-scoped TERMINAL_TIMEOUT override, if needed.

    Kanban's ``max_runtime_seconds`` bounds the whole worker attempt. The
    terminal tool has its own default timeout via ``TERMINAL_TIMEOUT``; when
    the worker runtime is longer, raise only the child process default so a
    long command is not killed by the generic terminal default first.
    Nr   r(   )r&   rp  r0   r+   %KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDSrG   r/   )r  r  runtimedesiredr  s        r   _worker_terminal_timeout_envr    s     "t)**z"   tt!||t!WDDEEG8GN3s?++1133444Qz"   7tw<<s    ++2B BBr  c          
        ddl }| j        st          d| j         d          ddlm}  || j                  }d| j         }t          t          j                  }ddlm	} 	  ||          |d<   n# t          $ r Y nw xY w| j        r
| j        |d	<   | j        |d
<   ||d<   | j        r
| j        |d<   | j        t          | j                  |d<   | j        r
| j        |d<   | j        r0d|d<   | j        $t          t%          | j                            |d<   t'          | j        |                    d                    }	|	|	|d<   t'          | j        |                    d                    }
|
|
|d<   t          t-          |                    |d<   t          t/          |                    |d<   t1          |          pt3                      }||d<   ||d<   g t5                      d|d}t7          |                    d                    r|                    ddg           | j        r)| j        D ]!}|r|dk    r|                    d|g           "| j        r|                    d| j        g           |                    dd|g           t?          |          }|                     d d !           || j         d"z  }tC                      \  }}tE          |||           tG          |d#          }	  |j$        |t          j%        &                    |          r|nd|j'        ||j(        |d tR          r|j*        nd$          }n1# t          $ r$ |+                                 tY          d%          w xY w|j-        S )&a|  Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess.

    Returns the spawned child's PID so the dispatcher can detect crashes
    before the claim TTL expires. The child's completion is still observed
    via the ``complete`` / ``block`` transitions the worker writes itself;
    the PID check is a safety net for crashes, OOM kills, and Ctrl+C.

    ``board`` pins the child's kanban context to that board: the child's
    ``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_BOARD`` / workspaces_root env
    vars all resolve to the same board the dispatcher claimed the task
    from. Workers cannot accidentally see other boards.
    r   Nrv  z has no assigneer+  zwork kanban task )resolve_profile_envHERMES_HOMEHERMES_TENANTHERMES_KANBAN_TASKHERMES_KANBAN_WORKSPACEHERMES_KANBAN_BRANCHHERMES_KANBAN_RUN_IDHERMES_KANBAN_CLAIM_LOCKrU  HERMES_KANBAN_GOAL_MODEHERMES_KANBAN_GOAL_MAX_TURNSTERMINAL_TIMEOUTTERMINAL_MAX_FOREGROUND_TIMEOUTr   r   r   rf   HERMES_PROFILEr  z--accept-hooksz--skillsr  r  chatz-qTrw   .logab)r  stdinrX  r  rq   start_new_sessioncreationflagszv`hermes` executable not found on PATH. Install Hermes Agent or activate its venv before running the kanban dispatcher.).rV  r   r0   r   r-  r,  r   r,   r-   r  r   r   r  r	  rG   r   r  r  r&   r  r  r.   r   r   rS   rt   r  r  r  r  r  r   r|   r  r  rT  Popenr~   isdirr  STDOUTrU  CREATE_NO_WINDOWr^  rI  rW  )r  r  r   rV  r,  profile_argpromptrq   r  terminal_timeoutforeground_timeoutresolved_boardcmdsklog_dirr  rotate_bytesr  log_fr   s                       r   rm  rm    s|   $ = <::::;;;::::::((77K***F
rz

C 87777700==M   
 	 { +#{O $C%.C!" 7&*&6"#&&)$*=&>&>"# :*./&' ~ P),%&*25c$:M6N6N2O2OC./3 "##  #"25 122  %1C-. ".u"="="=>>C+.U/K/K/K+L+LC'( +511H5F5H5HN!/C
 (C			 	C. &cggm&<&<== 2

J0111 { -+ 	- 	-B -bO++

J+,,, 0

D$-.///JJf    E***GMM$M...DG))))H!;!=!=L,x|<<< 4  E
zW]]955?		4$$"9DK*55!	
 	
 	
  
 
 
^
 
 	

 8Os   $A3 3
B ?B  AM .N g      N@)intervalrg  r  
stop_eventon_tickr  floatc                   ddl }ddl} |j                    fd} |j                     |j                    u rGdD ]D}t          ||d          }	|	/	 |                     |	|           -# t          t          f$ r Y @w xY wE                                s	 t          j
        t                                5 }
t          |
||          }ddd           n# 1 swxY w Y   |	  ||           n# t          $ r Y nw xY wn(# t          $ r ddl}|                                 Y nw xY w                    |                                            dS dS )aO  Run the dispatcher in a loop until interrupted.

    Calls :func:`dispatch_once` every ``interval`` seconds. Exits cleanly
    on SIGINT / SIGTERM so ``hermes kanban daemon`` is systemd-friendly.
    ``stop_event`` (a :class:`threading.Event`) and ``on_tick`` (a
    callable receiving the :class:`DispatchResult`) are test hooks.
    r   Nc                0                                      d S r   r.  )_signum_framer  s     r   _handlezrun_daemon.<locals>._handle  s    r!   )SIGINTr  )rg  r  )rH  )r
  	threadingr=  current_threadmain_threadrX  r0   rm   is_setr  r  rJ  r  r  	traceback	print_excwait)r  rg  r  r  r  r
  r  r	  sig_namer  rM  resr  s      `         r   
run_daemonr    s2    MMM$Y_&&
    
  y!!%:Y%:%<%<<<- 	 	H&(D11CMM#w////"G,   D  !! *	"#GII.. $#'"/                 "GCLLLL    D 	" 	" 	"!!!!!	" 	)))# !! * * * * *sl   A--B B D :CD CD  C!D 'C3 2D 3
D =D ?D  D "D)(D)c                   t          | |          }|st          d|           t          fdNd}g }|                    d	|j         d
|j                    |                    d           |                    d|j        pd            |                    d|j                    |j        r|                    d|j                    |                    d|j	         d|j
        pd            |j        t          |j        t          j                            d                    }|pt          j                            d          }|                    d|j         d           |r|                    d| d           |j        r|                    d|j                    |                    d           |j        rl|j                                        rS|                    d           |                     ||j        t&                               |                    d           t)          | |          }|r|                    d           |                    d           |D ]n}|j        rt-          d|j        dz   dz            nd}	|	rd|	 d nd}
|j        r
d|j         nd}|                    d!|j         d"| |
 d#|j         d"           o|                    d           d$ t5          | |          D             }t7          |          t8          k    r-t7          |          t8          z
  }|t8           d         }|dz   }nd}|}d}|r|                    d%           |r4|                    d&| d'|dk    rdnd d(t7          |           d)           t;          |          D ]e\  }}||z   }t=          j        d*t=          j         |j!                            }|j"        pd+}|j#        p|j        }|                    d,| d-| d.| d| d/	           |j$        r<|j$                                        r#|                     ||j$                             |j%        r?|j%                                        r&|                    d0 ||j%                              |j&        rP	 tO          j(        |j&        d1d23          }|                    d4 ||           d"           n# tR          $ r Y nw xY w|                    d           g| *                    d5|f          +                                }d6 |D             }|rd1}|D ]}t          | |          }|r|j        d7k    r!d8 t5          | |          D             }|,                    d9 d2:           |r|d         nd}|s|                    d;           d2}|                    d<|            g }|D|j$        r=|j$                                        r$|                     ||j$                             n@|j-        r$|                     ||j-                             n|                    d=           |W|j&        rP	 tO          j(        |j&        d1d23          }|                    d4 ||           d"           n# tR          $ r Y nw xY w|.                    |           |                    d           |j        r| *                    d>|j        |f          +                                }|r|                    d?|j                    |D ]}t=          j        d*t=          j         t_          |d@                                       }|dA         pd                                0                                } | r| d         ddB         ndC}!|                    dD|dE          d-|dF          d.| dG|!            |                    d           tc          | |          }"t7          |"          td          k    r(t7          |"          td          z
  }#|"td           d         }$nd}#|"}$|$r|                    dH           |#r4|                    d&|# dI|#dk    rdnd d(t7          |$           d)           |$D ]}%t=          j        d*t=          j         |%j3                            }|%j4        pd5                    d"d          }&|                    dJ|& dK| dL           |                     ||%j        tl                               |                    d           dM7                    |          8                                dMz   S )Oa2  Return the full text a worker should read to understand its task.

    Order:
      1. Task title (mandatory).
      2. Task body (optional opening post, capped at 8 KB).
      3. Prior attempts on THIS task (most recent ``_CTX_MAX_PRIOR_ATTEMPTS``
         shown; older attempts collapsed into a one-line summary).
         Each attempt's ``summary`` / ``error`` / ``metadata`` capped at
         ``_CTX_MAX_FIELD_BYTES`` each.
      4. Structured handoff results of every done parent task. Prefers
         ``run.summary`` / ``run.metadata`` when the parent was executed
         via a run; falls back to ``task.result`` for older data. Same
         per-field cap.
      5. Cross-task role history for the assignee (most recent 5
         completed runs on other tasks).
      6. Comment thread (most recent ``_CTX_MAX_COMMENTS`` shown, older
         collapsed).

    All caps exist so worker prompts stay bounded even on pathological
    boards (retry-heavy tasks, comment storms). The per-field char cap
    prevents a single 1 MB summary from dominating context.
    r  rR   rM   rg  r&   r%   rG   c                    | sdS |                                  } t          |           |k    r| S | d|         dt          |           |z
   dz   S )z;Truncate a string to `limit` chars with a visible ellipsis.r*   Nu   … [truncated, z chars omitted])r/   rp  )rR   rg  s     r   _capz"build_worker_context.<locals>._cap  sY     	2GGIIq66U??H%yMc!ffunMMMMMr!   z# Kanban task r  r*   z
Assignee: z(unassigned)z
Status:   z
Tenant:   zWorkspace: z @ z(unresolved)Nr  zMax runtime: zTerminal timeout: z
Branch:   z## Bodyz## Attachmentsz`Files attached to this task. Read them with the file/terminal tools at the absolute paths below:r(   i  i   r   r  z KBz- ``u    → `c                     g | ]}|j         	|S r   )r*  r;  s     r   r  z(build_worker_context.<locals>.<listcomp>Y  s    OOOq
8N8N8N8Nr!   z## Prior attempts on this taskz_(z earlier attemptz omitted; showing most recent z)_z%Y-%m-%d %H:%Mz	(unknown)z### Attempt u    — r  r  z	_error_: FT)r   	sort_keysz_metadata_: `r  c                    g | ]
}|d          S r  r`   r;  s     r   r  z(build_worker_context.<locals>.<listcomp>  s    666Q!K.666r!   r   c                (    g | ]}|j         d k    |S )r'  )r+  r;  s     r   r  z(build_worker_context.<locals>.<listcomp>  s$    PPP!qyK7O7OA7O7O7Or!   c                    | j         S r   )r   )r<  s    r   r   z&build_worker_context.<locals>.<lambda>  s    AL r!   )r   reversez## Parent task resultsz### z(no result recorded)zSELECT t.id, t.title, r.summary, r.ended_at FROM task_runs r JOIN tasks t ON r.task_id = t.id WHERE r.profile = ? AND r.task_id != ?   AND r.outcome = 'completed' ORDER BY r.ended_at DESC LIMIT 5z## Recent work by @r*  r,  r#  z(no summary)z- r   r   z): z## Comment threadz earlier commentzcomment from worker `z` at r$  rz   )rR   rM   rg  r&   r%   rG   )9rc  r0   _CTX_MAX_FIELD_BYTESr   r   r   r   r   r   r   r   r  r  r,   r-   r.   r  r   r/   _CTX_MAX_BODY_BYTESr  r:  r+   r9  r7  r8  	list_runsrp  _CTX_MAX_PRIOR_ATTEMPTSr  r   strftime	localtimer   r(  r+  r,  r/  r.  r   r   r  rK  r  sortr  r  r&   r3  r  _CTX_MAX_COMMENTSr   r4  r   _CTX_MAX_COMMENT_BYTESr   rstrip)'rM  r   r  r  linesr  effective_terminal_timeoutr   r  size_kbsize_strctype	all_prioromittedshownfirst_shown_idxrf  rW  r  r   r(  r+  meta_strparent_rowsr  wrote_headerrW  ptruns
body_lines	role_rowsr  rR   firstall_comments	omitted_cshown_cr  safe_authors'                                          r   build_worker_contextr>    s
   . D'""D 4222333,@ N N N N N E	LL9$'99TZ99:::	LL	LL?dm=~??@@@	LL+dk++,,,{ 1/$+//000	LL^t2^^t7J7\n^^___+7$JNN-..
 
 &6%[HZ9[9["@T%=@@@AAA% 	MLLK.HKKKLLL 64$"244555	LLy TY__&& YTT$)%899:::R #411K %&&&1	
 	
 	
  	Y 	YC;>8Jc!cho$6777G,3;(G((((H/2/?G+)+++RELLWs|WWeWXWWS_WWWXXXXR POIdG44OOOI
9~~///i..#::22334!A+ 5666 	LL?W ? ?W\\ccr ? ?03E

? ? ?   %U++ 	 	KFC!F*C/1O1OPPBk0[Gk/SZGLLMMM'MMWMMMMMNNN{ 0s{0022 0TT#+..///y <SY__.. <:ci::;;;| #z#,UVZ[[[HLL!Bh!B!B!BCCCC    DLL
 ,,P	
  hjj  76+666J  	 	C$$$B f,,PPys33PPPDII00$I???!+$q''tC $5666#LL&&&$&J3;3;3D3D3F3F!!$$s{"3"34444 :!!$$ry//2222!!"89993<#z#,UVZ[[[H%%&Gdd8nn&G&G&GHHHH    DLL$$$LL } LL/
 ]G$
 
 (** 	  		LL>t}>>???  R R]$dnSZ5I5I&J&J  ^)r0022==??&';!TcT

^P#d)PP#g,PP"PPPPQQQQLL
 !w//L
<,,,%%(99	 11223	 ())) 	LLAY A AyA~~2 A A03GA A A    	 	A/1M1MNNB 8>r223;;KLLHHH2HHHIIILLaf&<==>>>LL99U""$$t++s$   7>S66
TT;>Z::
[[c                   i }|                      d          D ] }t          |d                   ||d         <   !i }|                      d          D ]:}t          |d                   |                    |d         i           |d         <   ;|                      d                                          }t          t	          j                              }|r |d         |t          |d                   z
  nd}||||d	S )
zPer-status + per-assignee counts, plus the oldest ``ready`` age in
    seconds (the clearest staleness signal for a router or HUD).
    zRSELECT status, COUNT(*) AS n FROM tasks WHERE status != 'archived' GROUP BY statusr8  r   SELECT assignee, status, COUNT(*) AS n FROM tasks WHERE status != 'archived' AND assignee IS NOT NULL GROUP BY assignee, statusr   z>SELECT MIN(created_at) AS ts FROM tasks WHERE status = 'ready'r   N)	by_statusby_assigneeoldest_ready_age_secondsrO  )rK  r&   
setdefaultr  r   )rM  rA  r  rB  
oldest_rowrO  oldest_ready_ages          r   board_statsrG    s0    !#I||	5  1 1 $'s3x==	#h-  -/K||	$  S S
 FIS]]s:33CMBBH hjj  dikk

C 	A$T*6 
s:d#$$	$	$<@  "$4	  r!   c                   | dS t          | t                    r| S t          | t                    rt          |           S t          |                                           }|sdS 	 t          |          S # t
          $ r Y nw xY w	 ddlm} |                    |                    dd                    }t          |	                                          S # t
          t          f$ r Y dS w xY w)zNormalise a timestamp to unix epoch seconds.

    Accepts ints (pass-through), numeric strings, and ISO-8601 strings.
    Returns ``None`` for ``None`` / empty values.
    Nr   )datetimer  z+00:00)r   r&   r  rG   r/   r0   rI  fromisoformatr   	timestamprm   )rs   rR   rI  dts       r   	_to_epochrM    s	    {t#s 
#u 3xxCA t1vv   %%%%%%##AIIc8$<$<==2<<>>"""    tts%   &A5 5
BBAC C+*C+c                    t          t          j                              }t          | j                  }t          | j                  }t          | j                  }|||z
  nd}|||z
  nd}|||p|z
  nd}|||dS )zEReturn age metrics for a single task. All values are seconds or None.N)created_age_secondsstarted_age_secondstime_to_complete_seconds)r&   r   rM  r   r   r   )r  rO  r  r  _coage_since_createdage_since_startedtime_to_completes           r   task_agerV  $  s    
dikk

C	4?	#	#B	4?	#	#B
D%
&
&C$&Nb$&NbOrxR   10$4  r!   )	thread_iduser_idr  r  chat_idrW  rX  r  c                  t          t          j                              }t          |           5  |                     d||||pd|||f           |r|                     d|||||pdf           ddd           dS # 1 swxY w Y   dS )zRegister a gateway source that wants terminal-state notifications
    for ``task_id``. Idempotent on (task, platform, chat, thread).z
            INSERT OR IGNORE INTO kanban_notify_subs
                (task_id, platform, chat_id, thread_id, user_id, notifier_profile, created_at)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            r*   a  
                UPDATE kanban_notify_subs
                   SET notifier_profile = ?
                 WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?
                   AND (notifier_profile IS NULL OR notifier_profile = '')
                N)r&   r   r  rK  )rM  r   r  rY  rW  rX  r  rO  s           r   add_notify_subr[  :  s     dikk

C	4  
 hb'CSUXY	
 	
 	
  	 LL "7HgyBO                   s   ?A<<B B c                    |*|                      d|f                                          }n'|                      d                                          }d |D             S )Nz2SELECT * FROM kanban_notify_subs WHERE task_id = ?z SELECT * FROM kanban_notify_subsc                ,    g | ]}t          |          S r`   )r   r;  s     r   r  z$list_notify_subs.<locals>.<listcomp>g  s    """DGG"""r!   r  r  s      r   list_notify_subsr^  ^  sf     ||@7*
 

(** 	 ||>??HHJJ""T""""r!   )rW  c                   t          |           5  |                     d||||pdf          }d d d            n# 1 swxY w Y   |j        dk    S )NzcDELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?r*   r   )r  rK  r  )rM  r   r  rY  rW  r  s         r   remove_notify_subr`  j  s     
4 
 
llAhb9
 

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 <!s   9= =)rW  kindsra  tuple[int, list[Event]]c               `   |                      d||||pdf                                          }|dg fS t          |d                   }|rt          |          nd}d|r+dd                    d	t          |          z            z   d
z   ndz   dz   }	||g}
|r|
                    |           |                      |	|
                                          }g }|}|D ]}	 |d         rt          j	        |d                   nd}n# t          $ r d}Y nw xY w|                    t          |d         |d         |d         ||d         d|                                v r|d         t          |d                   nd                     t          |t          |d                             }||fS )a  Return ``(new_cursor, events)`` for a given subscription.

    Only events with ``id > last_event_id`` are returned. The subscription's
    cursor is NOT advanced here; call :func:`advance_notify_cursor` after
    the gateway has successfully delivered the notifications.
    qSELECT last_event_id FROM kanban_notify_subs WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?r*   Nr   r  z7SELECT * FROM task_events WHERE task_id = ? AND id > ? zAND kind IN (r5  r9  z) zORDER BY id ASCr?  r   r   r>  r   r@  r  )rK  r  r&   r  r   rp  r  r  r   r   r  r   r=  r  r+   )rM  r   r  rY  rW  ra  r  cursor	kind_listqrn  rV  r  max_idr<  r?  s                   r   unseen_events_for_subri  {  s    ,,	O	(GY_"5  hjj	 
 {"u_%&&F$.U$IAFOW?SXXcC	NN&:;;;dBBUW	Y
	 
 !&)F !i   <<6""++--DCF 
+ 
+	23I,Hdj9...DGG 	 	 	GGG	

5w)1V9,(0AFFHH(<(<8AXC($$$^b
 
 
 	 	 	
 VS4\\**3;s   $DDDtuple[int, int, list[Event]]c                  t          |           5  |                     d||||pdf                                          }|ddg fcddd           S t          |d                   }t	          | |||||          \  }}	|	s||g fcddd           S |                     dt          |          ||||pdt          |          f           |||	fcddd           S # 1 swxY w Y   dS )a  Atomically claim unseen notification events for one subscription.

    Returns ``(old_cursor, new_cursor, events)``. When events are returned,
    ``kanban_notify_subs.last_event_id`` has already been advanced to
    ``new_cursor`` inside a ``BEGIN IMMEDIATE`` transaction. That makes the
    notifier's read/claim step single-owner across multiple gateway watcher
    processes pointed at the same board DB: concurrent watchers serialize on
    SQLite's writer lock, and only the first process sees and claims a given
    event range.

    Callers should send the claimed events, then either leave the cursor at
    ``new_cursor`` on success or call :func:`rewind_notify_cursor` if delivery
    failed before any terminal unsubscribe removed the row.
    rd  r*   Nr   r  )r   r  rY  rW  ra  UPDATE kanban_notify_subs SET last_event_id = ? WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? AND last_event_id = ?)r  rK  r  r&   ri  )
rM  r   r  rY  rW  ra  r  
old_cursor
new_cursoreventss
             r   claim_unseen_events_for_subrp    s   . 
4 . .llShb9
 
 (**	 	
 ;a8. . . . . . . . _-..
2
 
 

F  	.z2-%. . . . . . . .& 	$ __gx)/r3z??[		
 	
 	
 :v-3. . . . . . . . . . . . . . . . . .s   5C3C<CC"Crn  c          	         t          |           5  |                     dt          |          ||||pdf           d d d            d S # 1 swxY w Y   d S )NztUPDATE kanban_notify_subs SET last_event_id = ? WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?r*   )r  rK  r&   )rM  r   r  rY  rW  rn  s         r   advance_notify_cursorrr    s     
4 
 
S__gx)/rJ	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s   +AAAclaimed_cursorrm  c                   t          |           5  |                     dt          |          ||||pdt          |          f          }ddd           n# 1 swxY w Y   |j        dk    S )zUndo a notification claim when delivery fails.

    The CAS guard only rewinds if no later notifier advanced the row after our
    claim. This keeps retry behavior for transient send failures without
    clobbering newer progress.
    rl  r*   Nr   )r  rK  r&   r  )rM  r   r  rY  rW  rs  rm  r  s           r   rewind_notify_cursorru    s      
4 	
 	
ll$ J(GY_"N##	
 
	
 	
 	
 	
 	
 	
 	
 	
 	
 	
 	
 	
 	
 	
 	
 <!s   9AAAi ' )older_than_secondsrv  c               
   t          t          j                              t          |          z
  }t          |           5  |                     d|f          }ddd           n# 1 swxY w Y   t          |j        pd          S )zDelete task_events rows older than ``older_than_seconds`` for tasks
    in a terminal state (``done`` or ``archived``). Returns the number of
    rows deleted. Running / ready / blocked tasks keep their full event
    history.zwDELETE FROM task_events WHERE created_at < ? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done', 'archived'))Nr   )r&   r   r  rK  r  )rM  rv  r  r  s       r   	gc_eventsrx    s     $6 7 77F	4 
 
llJI
 

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 s| q!!!s    A$$A(+A()rv  r   c                f   t          |          }|                                sdS t          j                    | z
  }d}|                                D ]]}	 |                                r6|                                j        |k     r|                                 |dz  }N# t          $ r Y Zw xY w|S )uH  Delete worker log files older than ``older_than_seconds``. Returns
    the number of files removed. Kept separate from ``gc_events`` because
    log files live on disk, not in SQLite. Scoped to ``board`` (defaults
    to the active board) — per-board isolation means deleting logs from
    board A cannot touch board B's logs.r   r   r(   )	r   rk   r   r   r  rz  st_mtimer   rm   )rv  r   r  r  r  r   s         r   gc_worker_logsr{  "  s     E***G>> qY[[--FG__  	yy{{ qvvxx0699


1 	 	 	H	Ns   A
B!!
B.-B.c               .    t          |          |  dz  S )uR  Return the path to a worker's log file. The file may not exist
    (task never spawned, or log already GC'd).

    When ``board`` is None, resolves via the active board (env var →
    current-board file → default). The dispatcher always passes the
    board explicitly to avoid any resolution ambiguity when multiple
    boards exist.r   r  )r   r   s     r   worker_log_pathr}  >  s#     '''W*:*:*:::r!   )
tail_bytesr   r~  c                  t          | |          }|                                sdS 	 ||                    dd          S |                                j        }t          |d          5 }||k    r|                    ||z
             |                                }|                                }|	                    d          s-|                                |k    r|                    |           |
                                }ddd           n# 1 swxY w Y   |                    dd          S # t          $ r Y dS w xY w)	zRead the worker log for ``task_id``. Returns None if the file
    doesn't exist. If ``tail_bytes`` is set, only the last N bytes are
    returned (useful for the dashboard drawer which shouldn't page megabytes).r   Nrg   r   )ri   errorsrw     
)r  )r}  rk   rl   rz  r{  rT  rW  tellreadliner  r|  decoderm   )	r   r~  r   r~   r:  rr   r  partialrd  s	            r   read_worker_logr  I  sz    7%000D;;== t>>79>EEEyy{{"$ 	j  tj()))
 **,,''.. "16688t3C3CFF5MMM6688D	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 {{79{555   tts<   D6 )D6 +BDD6 DD6 DD6 6
EEc                     	 ddl m}   |             }|dz  }n# t          $ r g cY S w xY wt                      }|                                r|                    d           |                                r|	 t          |                                          D ]H}|                                s|dz  	                                r|                    |j
                   In# t          $ r Y nw xY wt          |          S )a  Return the set of assignee/profile names discovered on disk.

    Includes:
    - named profiles under ``<default-root>/profiles/<name>/config.yaml``
    - the implicit ``default`` profile when the default Hermes root exists

    Reads profile paths directly so this module has no import dependency on
    ``hermes_cli.profiles`` (which pulls in a large chunk of the CLI startup
    path).
    r   rV   profilesrC   zconfig.yaml)rY   rW   r  rI   rk   r   r   r   r   r  r   rm   )rW   default_rootprofiles_dirnamesrC  s        r   list_profiles_on_diskr  l  s:   <<<<<<..00#j0   			 eeE 		) 	 4 4 6 677 * *||~~ M)2244 *IIej)))	*
  	 	 	D	 %==s    ''6A*C! !
C.-C.c                `   t          t                                i |                     d          D ]:}t          |d                                       |d         i           |d         <   ;t          t                                                    z            }fd|D             S )a  Return every assignee name known to the board or on disk.

    Each entry is ``{"name": str, "on_disk": bool, "counts": {status: n}}``.
    A name is included when it's a configured profile on disk OR when
    any non-archived task has it as the assignee. Used by:

    - ``hermes kanban assignees`` for the terminal.
    - The dashboard assignee dropdown (so a fresh profile appears in
      the picker even before it's been given any task).
    - Router-profile heuristics ("who's overloaded?") without scanning
      the whole board.
    r@  r8  r   r   c                H    g | ]}||v                      |i           d S ))r   on_diskcounts)r.   )r   r   r  r  s     r   r  z#known_assignees.<locals>.<listcomp>  sM        	 wjjr**	
 	
  r!   )rI   r  rK  r&   rD  r   r  )rM  r  r  r  r  s      @@r   known_assigneesr    s     '))**G )+F||	$  N N
 ADCH#j/2..s8}==7S///00E        r!   )include_active
state_type
state_namer  r  r  	list[Run]c               &   |du |du z  rt          d          ||dvrt          d          d}|g}|s|dz  }||d| dz  }|                    |           |d	z  }|                     ||                                          }d
 |D             S )a  Return all runs for ``task_id`` in start order.

    ``include_active=True`` (default) includes the currently-running
    attempt if any. Set False to return only closed runs (useful for
    "how many prior attempts have there been?" checks).

    When ``state_type`` and ``state_name`` are set, restrict to rows
    where that column equals ``state_name`` (``state_type`` is
    ``status`` or ``outcome``). Both must be passed together.
    Nz:state_type and state_name must both be set or both omitted)r   r+  z(state_type must be 'status' or 'outcome'z)SELECT * FROM task_runs WHERE task_id = ?z AND ended_at IS NOT NULLz AND z = ?z  ORDER BY started_at ASC, id ASCc                B    g | ]}t                               |          S r`   )r'  r  r;  s     r   r  zlist_runs.<locals>.<listcomp>  s"    ***CLLOO***r!   )r0   r   rK  r  )rM  r   r  r  r  rg  rn  rV  s           r   r!  r!    s    $ 	dzT12 WUVVV222GHHH3A 	F )	((	%Z%%%%j!!!	++A<<6""++--D**T****r!   Optional[Run]c                    |                      dt          |          f                                          }|rt                              |          nd S )Nz$SELECT * FROM task_runs WHERE id = ?)rK  r&   r  r'  r  )rM  r@  r  s      r   get_runr    sL    
,,.V hjj  !$-3<<-r!   c                    |                      d|f                                          }|rt                              |          ndS )zDReturn the most recent run regardless of outcome (active or closed).zSSELECT * FROM task_runs WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT 1N)rK  r  r'  r  rb  s      r   rY  rY    sK    
,,	4	
  hjj	 
 !$-3<<-r!   c                l    |                      d|f                                          }|r|d         ndS )ub  Return the latest non-null ``task_runs.summary`` for ``task_id``.

    The kanban-worker skill writes its handoff to ``task_runs.summary``
    via ``complete_task(summary=...)``; ``tasks.result`` is left empty
    unless the caller passes ``result=`` explicitly. Dashboards and CLI
    "show" views need this value to surface what a worker actually did
    — without it, ``tasks.result`` is NULL and the task looks like a
    no-op even when the run completed.

    Picks the most recent run by ``ended_at`` (falling back to ``id``
    for ties or unfinished rows). Returns None if no run has a summary.
    zSELECT summary FROM task_runs WHERE task_id = ? AND summary IS NOT NULL AND summary != '' ORDER BY COALESCE(ended_at, started_at) DESC, id DESC LIMIT 1r,  N)rK  r  rb  s      r   latest_summaryr    sG     ,,	H 

	 
 hjj  !*3y>>d*r!   task_idsc                    t          |          }|si S d                    d |D                       }|                     d| d|                                          }d |D             S )u  Batch-fetch latest non-null summaries for a list of task ids.

    Used by the dashboard board endpoint to attach ``latest_summary`` to
    every card in a single SQL query, avoiding the N+1 pattern of
    calling :func:`latest_summary` per task. Returns a dict mapping
    ``task_id`` → summary string, omitting tasks with no summary.

    Approach: a window function picks the newest non-null-summary row
    per ``task_id``; works against SQLite ≥ 3.25 (default on every
    supported platform).
    r5  c              3     K   | ]}d V  dS )r9  Nr`   r  s     r   r    z#latest_summaries.<locals>.<genexpr>  s"      --AC------r!   aD  
        SELECT task_id, summary FROM (
            SELECT task_id, summary,
                   ROW_NUMBER() OVER (
                       PARTITION BY task_id
                       ORDER BY COALESCE(ended_at, started_at) DESC, id DESC
                   ) AS rn
              FROM task_runs
             WHERE task_id IN (zZ)
               AND summary IS NOT NULL AND summary != ''
        ) WHERE rn = 1
        c                ,    i | ]}|d          |d         S )r   r,  r`   r;  s     r   r  z$latest_summaries.<locals>.<dictcomp>!  s"    5551AiL!I,555r!   )r  r   rK  r  )rM  r  idsr_  rV  s        r   latest_summariesr     s     x..C 	88-------L<<	 !-	 	 	 	  hjj 	 655555r!   r   )r#   r$   r%   r&   )r%   r&   )rF   rG   )rF   rM   r%   rM   )r%   r	   )r%   rG   )rF   rG   r%   r	   )r%   r   )r   rM   r%   r	   )r   rM   r%   r   )r   rG   r   rM   r%   r	   )rF   rG   r%   rG   )r   rM   r%   r   )r   rM   r   rM   r   rM   r   rM   r   rM   r   r   r   rM   r%   r   )rF   rG   r   rM   r   rM   r   rM   r   rM   r   rM   r%   r   )r   r   r%   r   )rF   rG   r   r   r%   r   )r~   r	   r%   rE  )r~   r	   )rd  re  rf  r&   r%   r   )r~   r	   r%   r   )r~   r	   r%   r  )r   r  r   rM   r%   rE  )r   r  r   rM   )r   r  r   rM   r%   r	   )
rM  rE  r  rG   r  rG   r  rG   r%   r   )rM  rE  r%   r   )rM  rE  r  rG   r%   r   )rM  rE  )r   rM   r%   rM   ),rM  rE  r   rG   r   rM   r   rM   r   rM   r   rG   r   rM   r  rM   r   rM   r   r&   rx   r0  r   r   r  rM   r  r$   r  r1  r  r$   r  r   r  r$   r/  rG   r  rM   r   rM   r%   rG   )rM  rE  rx   r0  r%   rY  )rM  rE  r   rG   r%   r`  )rM  rE  r   rM   r   rM   r   rM   r  rM   r   r   rg  r$   rh  rM   r
  rM   r  rM   r%   ri  )rM  rE  r   rG   r(  rM   r%   r   )rM  rE  rs  rG   rt  rG   r%   r   )rM  rE  rs  rG   rt  rG   r%   r   )rM  rE  r   rG   r%   rY  )rM  rE  r   rG   r%   r  )
rM  rE  r   rG   r4  rG   r   rG   r%   r&   )rM  rE  r   rG   r%   r  )rM  rE  r   rG   r7  rG   r8  rG   r9  rM   r:  r&   r;  rM   r%   r&   )rM  rE  r   rG   r%   r  )rM  rE  r  r&   r%   r  )rM  rE  r   rG   r%   r  )rM  rE  r   rG   r>  rG   r?  r-  r@  r$   r%   r   )rM  rE  r   rG   r+  rG   r,  rM   r/  rM   r.  r-  r   rM   r%   r$   )rM  rE  r   rG   r%   r$   )rM  rE  r   rG   r+  rG   r,  rM   r/  rM   r.  r-  r%   r&   )rM  rE  r   rG   r%   r   )rM  rE  r  r&   r%   r&   )
rM  rE  r   rG   r#   r$   r  rM   r%   r`  )
rM  rE  r   rG   r#   r$   r  rM   r%   r   )rM  rE  r%   r&   )rM  rE  r   rG   r  rM   r%   r   )rM  rE  r   rG   r(  rM   r  r   r  rM   r%   r   )rM  rE  r   rG   r  r0  r%   r  )rM  rE  r  rG   r%   rY  )rM  rE  r   rG   r  rM   r,  rM   r.  r-  r  r1  r   r$   r%   r   )r   r	   r%   r   )rM  rE  r   rG   r%   r   )r%   r   )rM  rE  r   rG   r   rM   r%   r   )rM  rE  r   rG   r  rG   r,  rM   r.  r-  r%   r   )
rM  rE  r   rG   r  rM   r   r$   r%   r   )rM  rE  r   rG   rs  rG   r  rM   rq  r   rr  r   r%   rt  )rM  rE  r   rG   r   rM   r   rM   r   rM   r4  rM   r%   r   )rM  rE  r   rG   r  rM   r  r   r4  rM   r  r   r%   r  )r  r   r   rM   r%   r	   )rM  rE  r   rG   r~   r  r%   r   )rW  r&   r  r&   r%   r   )rW  r&   r%   r  )r%   r  )rW  r$   r%   r   )rW  r$   r   rM   r%   r  )
rM  rE  r   rG   r  rM   r   r$   r%   r   )rM  rE  r%   rY  )rM  rE  r!  r&   r%   rY  )r)  rG   r%   rG   )rM  rE  r   rG   r/  rG   r+  rG   r  r&   r  r   r  r   r  r-  r%   r   )
rM  rE  r   rG   r/  rG   r  r&   r%   r   )rM  rE  r   rG   rW  r&   r%   r   )rM  rE  r   rG   r%   rM   )rM  rE  r%   r   )rM  rE  r#   r$   rr  r   rg  r$   rh  r$   r  r&   r!  r&   r   rM   ri  rM   rj  r$   r%   r  )r  r
   rC   r&   r  r&   r%   r&   )r  r-  r%   r  )r  r	   r  r&   r%   r	   )r  r	   r  r&   r  r&   r%   r   )r%   rY  )r~   rG   r%   rG   )r  rG   r%   r   )r~   rG   r%   r   )r  rG   r%   rY  )r  rG   r%   rM   )r~   rG   r%   rY  )r  rM   r%   r   )r  r$   r  rM   r%   rM   )r  r   r  rG   r   rM   r%   r$   )r  r  rg  r$   r  r&   r%   r   )rM  rE  r   rG   r%   rG   )rM  rE  r%   r   )r%   r$   )r  r   r%   r   )rM  rE  r   rG   r  rG   rY  rG   rW  rM   rX  rM   r  rM   r%   r   )rM  rE  r   rM   r%   r   )rM  rE  r   rG   r  rG   rY  rG   rW  rM   r%   r   )rM  rE  r   rG   r  rG   rY  rG   rW  rM   ra  r1  r%   rb  )rM  rE  r   rG   r  rG   rY  rG   rW  rM   ra  r1  r%   rj  )rM  rE  r   rG   r  rG   rY  rG   rW  rM   rn  r&   r%   r   )rM  rE  r   rG   r  rG   rY  rG   rW  rM   rs  r&   rm  r&   r%   r   )rM  rE  rv  r&   r%   r&   )rv  r&   r   rM   r%   r&   )r   rG   r~  r$   r   rM   r%   rM   )rM  rE  r%   r   )rM  rE  r   rG   r  r   r  rM   r  rM   r%   r  )rM  rE  r@  r&   r%   r  )rM  rE  r   rG   r%   r  )rM  rE  r  r0  r%   re  )r#  
__future__r   r  r  r   r,   r,  r  r   rI  rV  r  r  loggingr   contextvarsr   r   dataclassesr   r   r  r	   typingr
   r   r   toolsetsr   	getLoggerr   r
  rl  r@  rA  	frozensetrC  r  rU  r1   r  r4   r:   r  r<   rA   r"  r&  r  r   r'  rn   rE   r$  contextmanagerrL   compilerP   rS   r[   ra   rd   rt   r   r   r   rj   r   r   r   r   r   r   r   r   r   r   r   r   r   r'  r3  r6  r=  r  rI   r   RLockr  r~  rC  rD  rN  rc  ru  r  rI  r  r  r  rJ  r  r   r  r  r	  r  r  r  r  r!  r)  r.  rX  rE  rc  rf  ro  rr  r|  rz  r  r  r  r  r  r  r  r  r  r  r  rG  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r0   r  r;  rD  r5  rJ  rM  r[  rf  r\  r^  rb  rg  rl  rp  r{  r  r  r  r  r  r  r  r  r  r  DEFAULT_SPAWN_FAILURE_LIMITr  r  r  
IGNORECASErS  rU  r@   rV  rW  r  r  r  r  r  r  r  r  r  r  r   r%  r(  r/  r5  r  rJ  rL  r  _clear_spawn_failuresr\  rb  re  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  rm  r  r>  rG  rM  rV  r[  r^  r`  ri  rp  rr  ru  rx  r{  r}  r  r  r  r!  r  rY  r  r  r`   r!   r   <module>r     s  D D DL # " " " " "       				 				        



       ) ) ) ) ) ) ) ) ( ( ( ( ( ( ( (       * * * * * * * * * * & & & & & &w"" nmm#Y/ 666 iPP<M<M<O<OPPPPP lg% $  -4 )% % % % %: !  ! ' ' ' '&/ / / /6   " " "  2<**3 3 3      - - - - :;;   % % % %./ / / /0 0 0 0- - - -`   "            E E E E E) ) ) ) )2* * * * *,+ + + + +<3 3 3 3 3
$ $ $ $ $ * * * * *e e e e    J !%#%)) ) ) ) ) )^ !%%)     > -1 ) ) ) ) ) )X 04 ,E ,E ,E ,E ,E ,Ef R
 R
 R
 R
 R
 R
 R
 R
j 2
 2
 2
 2
 2
 2
 2
 2
j         
 
 
 
 
 
 
 
 ! ! ! ! ! ! ! !e
X  #suu  $ $ $ $Y_
'! # # # #$    - - - -`           F
 
 
 
 
< 
 
 
&3 3 3 3l79 79 79 79v #J  J J J J J JZ "       F #       <   &g" g" g" g"p	A) )XN N N N9 9 9 9x$ $ $ $N + + + +D
' 
' 
' 
'# # # #, , , , " $#$(!% %))-&*!%$(# $-m& m& m& m& m& m&`
4 
4 
4 
4/ / / / (./3*. <	% 	%  	 	 	 	 #   $""*.&*1, 1, 1, 1, 1, 1,h   J
 
 
 
<   .   ** * * *) ) ) )2 2 2 2&' ' ' ',   6 #'!%.' .' .' .' .' .'b   (   $   0   8 #	 !     : "# 6 6 6 6 6 6rQ Q Q Q "#0# 0# 0# 0# 0# 0#n#2 #2 #2 #2N 48T T T T T~ "&!o' o' o' o' o' o'l "&!H' H' H' H' H' H'^ "&!     D G G G G G G\ !B B B B B BT        >H H H H\ BJ455 4 4 4 4B
 
 
 
 
Z 
 
 
, !!#-1%)s s s s s stH H H HVB B B BJ% % % %P   \ 2 J 6 6 6 6
      " " " "D "#@ @ @ @ @ @N !%)4 4 4 4 4 4z !C C C C C CL5 5 5 5x  " X X X X X XB !\ \ \ \ \ \~   4! ! ! !4   6 =A =8 =8 =8 =8 =8 =8@
 
 
 
 !%)* * * * * *h  3  +   )+ % !bj" M   !%  '* # !  &2:4M   2J 2J 2J 2J 2J 2J 2J 2Jz #&  79  9 9 9 91 1 1 1.( ( ( (V   .= = = =H 	5 5 5 5 5 5x %)0 0 0 0 0 0l n n n n n nj  $  "#	t t t t t tn   A A A AT *.W W W W W WB       R R R R*
 
 
 
& / r r r rj   @   8 !%#%)4!"&*15C C C C C CL ?@ 4 4 4 4 4 4# # # # #6D D D D 1( ( ( ( (V5 5 5 5N N N N	 	 	 	3 3 3 3
+ + + +   ,
) 
) 
) 
)&! &! &! &!R       F   D  	k k k k k kh #44* 4* 4* 4* 4* 4*vZ, Z, Z, Z,B! ! ! !H   8   8  $!&*! ! ! ! ! !J 8<	# 	# 	# 	# 	#$  $     .  $%). . . . . .n  $%)0. 0. 0. 0. 0. 0.r  $
 
 
 
 
 
.  $     D <J" " " " " "$ "0     8 =A ; ; ; ; ; ; 26     F       F       V   $ $ +  +  +  +  +  +F. . . .. . . .+ + + +,!6 !6 !6 !6 !6 !6r!   