
    i                      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
mZmZ ddlmZ ddlmZmZmZ h dZh dZd	Zd
ZdZdZdZdZddZddZe G d d                      Ze G d d                      Ze G d d                      Ze G d d                      Z dZ! e"            Z#de$d<   ddd!Z%ddd"Z&dd%Z'ej(        dd&            Z)dd(Z*dd)Z+dddd*dddd+d,dddd-ddAZ,ddCZ-ddFZ.dddd,ddGddLZ/ddNZ0ddQZ1ddRZ2ddSZ3ddTZ4ddUZ5ddWZ6ddYZ7dd[Z8dd]Z9	 ddd^ddcZ:ddddddddiZ;ddjZ<ddddkddlZ=ddmZ>eddnddqZ?eddnddrZ@ddsZAddddtddvZBddwddyZCddzZDdd{ZEdd}ZFddZGdZHdZIe G d d                      ZJddZKddddZLddddZMddZNddZOeHdddZPddZQddZRded,deHdddZSddZTddZUddeHdddddZVddZWddZXddZYdddddZZ	 dddZ[ddddZ\dddddZ]ddddZ^ddddZ_ddddZ`ddZaddddZbd dZcddZdddddńZeddǄZfddȄZgddɄZhdS (  ay  SQLite-backed Kanban board for multi-profile collaboration.

The board lives at ``$HERMES_HOME/kanban.db`` (profile-agnostic on purpose:
multiple profiles on the same machine all see the same board, which IS the
coordination primitive).

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.
    )annotationsN)	dataclassfield)Path)AnyIterableOptional>   donetodoreadytriageblockedrunningarchived>   dirscratchworktreei  
      i   i    i   returnr   c                 (    ddl m}   |             dz  S )z?Return the path to ``kanban.db`` inside the active HERMES_HOME.r   get_hermes_homez	kanban.dbhermes_constantsr   r   s    9/home/ubuntu/.hermes/hermes-agent/hermes_cli/kanban_db.pykanban_db_pathr   @   s'    000000?{**    c                 .    ddl m}   |             dz  dz  S )zDReturn the directory under which ``scratch`` workspaces are created.r   r   kanban
workspacesr   r   s    r   workspaces_rootr"   F   s,    000000?x',66r   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"<   ed(d'            ZdS ))Taskz1In-memory view of a row from the ``tasks`` table.stridtitleOptional[str]bodyassigneestatusintpriority
created_by
created_atOptional[int]
started_atcompleted_atworkspace_kindworkspace_path
claim_lockclaim_expirestenantNresultidempotency_keyr   spawn_failures
worker_pidlast_spawn_errormax_runtime_secondslast_heartbeat_atcurrent_run_idworkflow_template_idcurrent_step_keyzOptional[list]skillsrowsqlite3.Rowr   'Task'c                V   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         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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|v r|d         nd dd|v r|d         nd d|S )NrB   c                0    g | ]}|t          |          S  )r%   ).0ss     r   
<listcomp>z!Task.from_row.<locals>.<listcomp>|   s#    #@#@#@qa#@CFF#@#@#@r   r&   r'   r)   r*   r+   r-   r.   r/   r1   r2   r3   r4   r5   r6   r7   r8   r9   r:   r   r;   r<   r=   r>   r?   r@   rA   rH   )setkeysjsonloads
isinstancelist	Exception)clsrC   rM   skills_valueparseds        r   from_rowzTask.from_rows   s   388::'+tH$CM22fd++ A#@#@F#@#@#@L $ $ $#$s %
 %
 %
4yy%
g,,%
 V%
 __	%

 x==%
 __%
 <((%
 <((%
 <((%
 ^,,%
 /00%
 /00%
 <((%
 o..%
 %-$4$43x==$%
  %-$4$43x==$!%
" 7H46O6OC 122UY#%
$ 5E4L4L3/00RS%%
& -9D,@,@s<((d'%
( 9Kd8R8RS!344X\)%
, /Dt.K.K)**QU-%
2 -@4,G,G'((T3%
8 *:T)A)A$%%t9%
> 0F/M/M*++SW?%
D ,>+E+E&''4E%
H  <I%
 %	
s   ;A- -A<;A<)rC   rD   r   rE   )__name__
__module____qualname____doc____annotations__r8   r9   r:   r;   r<   r=   r>   r?   r@   rA   rB   classmethodrV   rH   r   r   r$   r$   P   s        ;;GGGJJJKKKMMMOOO!!!!     F    %)O))))N $J$$$$&*****)-----'+++++$(N((((*.....&*****
 "F!!!!0
 0
 0
 [0
 0
 0
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&   r%   task_idr(   profilestep_keyr+   r5   r0   r6   r;   r=   r>   r1   ended_atoutcomesummaryOptional[dict]metadataerrorrC   rD   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 )Nrf   r&   r_   r`   ra   r+   r5   r6   r;   r=   r>   r1   rb   rc   rd   rg   rH   )rN   rO   rR   r,   )rS   rC   metas      r   rV   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)rC   rD   r   rh   )rW   rX   rY   rZ   r[   r\   rV   rH   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&   r%   r_   authorr)   r/   N)rW   rX   rY   r[   rH   r   r   rl   rl      s=         GGGLLLKKKIIIOOOOOr   rl   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&   r%   r_   kindre   payloadr/   Nr0   run_id)rW   rX   rY   r[   rr   rH   r   r   ro   ro      sS         GGGLLLIIIOOO F      r   ro   aY  
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,
    claim_lock           TEXT,
    claim_expires        INTEGER,
    tenant               TEXT,
    result               TEXT,
    idempotency_key      TEXT,
    spawn_failures       INTEGER NOT NULL DEFAULT 0,
    worker_pid           INTEGER,
    last_spawn_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
);

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
);

-- 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,
    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_tasks_tenant          ON tasks(tenant);
CREATE INDEX IF NOT EXISTS idx_tasks_idempotency     ON tasks(idempotency_key);
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_events_run            ON task_events(run_id, id);
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_notify_task           ON kanban_notify_subs(task_id);
zset[str]_INITIALIZED_PATHSdb_pathOptional[Path]sqlite3.Connectionc                $   | pt                      }|j                            dd           t          |                                          }|t
          v}t          j        t          |          dd          }t          j        |_	        |
                    d           |
                    d           |
                    d           |rC|                    t                     t          |           t
                              |           |S )	a  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.
    Tparentsexist_okNr   )isolation_leveltimeoutzPRAGMA journal_mode=WALzPRAGMA synchronous=NORMALzPRAGMA foreign_keys=ON)r   parentmkdirr%   resolvers   sqlite3connectRowrow_factoryexecuteexecutescript
SCHEMA_SQL_migrate_add_optional_columnsadd)rt   pathresolved
needs_initconns        r   r   r   s  s     &n&&DKdT2224<<>>""H!33J?3t99dBGGGD{DLL*+++LL,---LL)*** ) 	:&&&%d+++x(((Kr   c                H   | pt                      }|j                            dd           t          |                                          }t
                              |           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.
    Trx   N)
r   r}   r~   r%   r   rs   discard
contextlibclosingr   )rt   r   r   s      r   init_dbr     s     &n&&DKdT2224<<>>""H x(((		GDMM	*	*                Ks   	BBBr   Nonec                V   d |                      d          D             }d|vr|                      d           d|vr|                      d           d|vr*|                      d           |                      d	           d
|vr|                      d           d|vr|                      d           d|vr|                      d           d|vr|                      d           d|vr|                      d           d|vr|                      d           d|vr|                      d           d|vr|                      d           d|vr|                      d           d |                      d          D             }d|vr*|                      d           |                      d            |                      d!                                          d"u}|r-t          |           5  |                      d#                                          }|D ]}|d$         pt	          t          j                              }|                      d%|d&         |d'         |d(         |d)         |d         |d         |d         |f          }|                      d*|j        |d&         f          }|j        d+k    r;|                      d,t	          t          j                              |j        f           	 d"d"d"           n# 1 swxY w Y   d-}	|	D ]\  }
}|                      d.||
f           d"S )/zAdd 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 namerH   rI   rC   s     r   	<setcomp>z0_migrate_add_optional_columns.<locals>.<setcomp>  s    LLLCCKLLLr   zPRAGMA table_info(tasks)r7   z(ALTER TABLE tasks ADD COLUMN tenant TEXTr8   z(ALTER TABLE tasks ADD COLUMN result TEXTr9   z1ALTER TABLE tasks ADD COLUMN idempotency_key TEXTzJCREATE INDEX IF NOT EXISTS idx_tasks_idempotency ON tasks(idempotency_key)r:   zFALTER TABLE tasks ADD COLUMN spawn_failures INTEGER NOT NULL DEFAULT 0r;   z/ALTER TABLE tasks ADD COLUMN worker_pid INTEGERr<   z2ALTER TABLE tasks ADD COLUMN last_spawn_error TEXTr=   z8ALTER TABLE tasks ADD COLUMN max_runtime_seconds INTEGERr>   z6ALTER TABLE tasks ADD COLUMN last_heartbeat_at INTEGERr?   z3ALTER TABLE tasks ADD COLUMN current_run_id INTEGERr@   z6ALTER TABLE tasks ADD COLUMN workflow_template_id TEXTrA   z2ALTER TABLE tasks ADD COLUMN current_step_key TEXTrB   z(ALTER TABLE tasks ADD COLUMN skills TEXTc                    h | ]
}|d          S r   rH   r   s     r   r   z0_migrate_add_optional_columns.<locals>.<setcomp>  s    UUUss6{UUUr   zPRAGMA table_info(task_events)rr   z1ALTER TABLE task_events ADD COLUMN run_id INTEGERzDCREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id)zFSELECT name FROM sqlite_master WHERE type='table' AND name='task_runs'NzSELECT 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 NULLr1   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*   r5   r6   zKUPDATE tasks SET current_run_id = ? WHERE id = ? AND current_run_id IS NULL   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 = ?)r   fetchone	write_txnfetchallr,   time	lastrowidrowcount)r   colsev_cols
runs_existinflightrC   startedcurupd_EVENT_RENAMESoldnews               r   r   r     s   
 ML4<<0J#K#KLLLDt?@@@t?@@@$$HIII(	
 	
 	
 t##T	
 	
 	
 4FGGG%%IJJJD((OPPP$&&MNNNt##JKKKT))MNNN%%IJJJt 	?@@@ VUdll3S&T&TUUUGwHIII)	
 	
 	
 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	
 	
 	
 	

 
s   )DK<<L L c              #     K   |                      d           	 | V  |                      d           dS # t          $ r |                      d            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.
    zBEGIN IMMEDIATECOMMITROLLBACKN)r   rR   )r   s    r   r   r      su       	LL"###



 	X	    Z   s	   4 !Ar%   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_   )secrets	token_hexrH   r   r   _new_task_idr   6  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gethostnamerR   osgetpid)r   hosts     r   _claimer_idr   C  sf    MMM!!##0y   ""RY[["""s    ,,r   rH   F)r)   r*   r.   r3   r4   r7   r-   ry   r   r9   r=   rB   r'   r)   r(   r*   r.   r3   r4   r7   r-   r,   ry   Iterable[str]r   boolr9   r=   r0   rB   Optional[Iterable[str]]c                  |r|                                 st          d          |t          vr't          dt          t                     d|          t	          d |	D                       }	d}|g }t                      }|D ]o}|st          |                                           }|s)d|v rt          d|d          ||v rE|                    |           |                    |           p|}|r3| 	                    d	|f          
                                }|r|d
         S t          t          j                              }t          d          D ]}t                      }	 t          |           5  |
rd}nd}|	rt!          | |	          }|r%t          dd                    |                     | 	                    dd                    dt%          |	          z            z   dz   |	                                          }t)          d |D                       rd}|
r9|	r7t!          | |	          }|r%t          dd                    |                     | 	                    d||                                 |||||||||||rt          |          nd|t+          j        |          ndf           |	D ]}| 	                    d||f           t/          | |d||t1          |	          ||rt1          |          ndd           ddd           n# 1 swxY w Y   |c S # t2          j        $ r |dk    r Y w xY wt7          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workspace_kind must be one of z, got c              3     K   | ]}||V  	d S NrH   rI   ps     r   	<genexpr>zcreate_task.<locals>.<genexpr>  s'      ,,!!,A,,,,,,r   N,z!skill name cannot contain comma: zA (pass a list of separate names instead of a comma-joined string)zhSELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived' ORDER BY created_at DESC LIMIT 1r&      r   r   zunknown parent task(s): , z&SELECT status FROM tasks WHERE id IN (?)c              3  .   K   | ]}|d          dk    V  dS r+   r
   NrH   rI   rs     r   r   zcreate_task.<locals>.<genexpr>  s+      CCq{f4CCCCCCr   r   ag  
                    INSERT INTO tasks (
                        id, title, body, assignee, status, priority,
                        created_by, created_at, workspace_kind, workspace_path,
                        tenant, idempotency_key, max_runtime_seconds, skills
                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    DINSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)created)r*   r+   ry   r7   rB   r   unreachable)strip
ValueErrorVALID_WORKSPACE_KINDSsortedtuplerL   r%   r   appendr   r   r,   r   ranger   r   _find_missing_parentsjoinlenr   anyrN   dumps_append_eventrQ   r   IntegrityErrorRuntimeError)r   r'   r)   r*   r.   r3   r4   r7   r-   ry   r   r9   r=   rB   skills_listcleanedseenrJ   r   rC   nowattemptr_   initial_statusmissingrowspids                              r   create_taskr   Q  s   R  . .,---222&V4I-J-J & &!& &
 
 	
 ,,w,,,,,G (,K 	! 	!A q66<<>>D d{{ X X X X   t||HHTNNNNN4      ll/ 	
 

 (** 	  	t9
dikk

C 88 L L..J	4 C C  4%-NN%,N 4"7g"F"F" ^",-\		RYHZHZ-\-\"]"]]#|| "%((3W+=">">?ADE#    #(**	 
 CCdCCCCC 4-3N  Zg Z3D'BBG Z()XDIIgDVDV)X)XYYY   & "&&'4GQ/000T3>3J
;///PT  2 #  CLL^g    $,"0#'=="(7B"L${"3"3"3 	  qC C C C C C C C C C C C C C CH NNN% 	 	 	!||H		
 }
%
%%s7   -L/<FL L/ L$	$L/'L$	(L//M	M		list[str]c                    t          |          }|sg S d                    dt          |          z            }|                     d| d|                                          }d |D             fd|D             S )Nr   r   z"SELECT id FROM tasks WHERE id IN (r   c                    h | ]
}|d          S )r&   rH   r   s     r   r   z(_find_missing_parents.<locals>.<setcomp>  s    %%%1qw%%%r   c                    g | ]}|v|	S rH   rH   )rI   r   presents     r   rK   z)_find_missing_parents.<locals>.<listcomp>	  s#    333!!7"2"2A"2"2"2r   )rQ   r   r   r   r   )r   ry   placeholdersr   r   s       @r   r   r     s    7mmG 	88C#g,,.//L<<<\<<<  hjj 	 &%%%%G3333w3333r   r_   Optional[Task]c                    |                      d|f                                          }|rt                              |          nd S )Nz SELECT * FROM tasks WHERE id = ?)r   r   r$   rV   r   r_   rC   s      r   get_taskr     s@    
,,9G:
F
F
O
O
Q
QC!$.4==$.r   )r*   r+   r7   include_archivedlimitr+   r   r   
list[Task]c                  d}g }||dz  }|                     |           |G|t          vr$t          dt          t                               |dz  }|                     |           ||dz  }|                     |           |s|dk    r|dz  }|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 = ?r   z AND status != 'archived'z' ORDER BY priority DESC, created_at ASCz LIMIT c                B    g | ]}t                               |          S rH   )r$   rV   r   s     r   rK   zlist_tasks.<locals>.<listcomp>-  s$    +++DMM!+++r   )r   VALID_STATUSESr   r   r,   r   r   )	r   r*   r+   r7   r   r   queryparamsr   s	            r   
list_tasksr    s    ,EF$$h''Nf^6L6LNNOOO""f""f -* 4 4,,	66E ('3u::'''<<v&&//11D++d++++r   r`   c                p   t          |           5  |                     d|f                                          }|s	 ddd           dS |d         |d         dk    rt          d| d          |                     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.
    z1SELECT status, claim_lock FROM tasks WHERE id = ?NFr5   r+   r   zcannot reassign zS: currently running (claimed). Wait for completion or reclaim the stale lock first.z*UPDATE tasks SET assignee = ? WHERE id = ?assignedr*   T)r   r   r   r   r   )r   r_   r`   rC   s       r   assign_taskr  0  sN    
4  ll?'
 

(** 	  	        |(S]i-G-GG7 G G G   	AGWCUVVVdGZ*g1FGGG                 s   -B+
AB++B/2B/	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   z%SELECT status FROM tasks WHERE id = ?r+   r
   zBUPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'linkedr}   child)r   r   r   r   _would_cycler   r   r   )r   r  r  r   parent_statuss        r   
link_tasksr  J  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 )r  NrH   r   s     r   r   z_would_cycle.<locals>.<genexpr>{  s&      11qQz]111111r   F)rL   popr   r   r   extend)r   r  r  r   stacknoder   s          r   r  r  h  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    cd d d            S # 1 swxY w Y   d S )Nz;DELETE FROM task_links WHERE parent_id = ? AND child_id = ?unlinkedr	  r   )r   r   r   r   )r   r  r  r   s       r   unlink_tasksr    s    	4 
  
 llI!
 
 < 	h
$x88   |a
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
 s   ?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 r  rH   r   s     r   rK   zparent_ids.<locals>.<listcomp>  s    )))qAkN)))r   r   r   r   r_   r   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 )r  rH   r   s     r   rK   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&   r8   rH   r   s     r   rK   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   rm   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 requiredz SELECT 1 FROM tasks WHERE id = ?unknown task zQINSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)	commented)rm   r   r   )
r   r   r,   r   r   r   r   r   r   r   )r   r_   rm   r)   r   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_   rm   r)   r/   )r&   r_   rm   r)   r/   )rl   r   s     r   rK   z!list_comments.<locals>.<listcomp>  s\     	 	 	  	wiLX;6	
 	
 	
	 	 	r   r  r  s      r   list_commentsr,    sN    <<O	
  hjj 		 	 	 	 	 	r   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 ASCrq   r&   r_   rp   r/   rr   r&   r_   rp   rq   r/   rr   )	r   r   rN   rO   rR   r   ro   rM   r,   )r   r_   r   outr   rq   s         r   list_eventsr1    s   <<U	
  hjj 	 C 
 
	23I,Hdj9...DGG 	 	 	GGG	

T7)vY\?,4,@,@Qx[E\AhK(((bf  		
 		
 		
 		
 Js   $AA%$A%rr   rp   rq   re   rr   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ensure_asciiNz[INSERT INTO task_events (task_id, run_id, kind, payload, created_at) VALUES (?, ?, ?, ?, ?))r,   r   rN   r   r   )r   r_   rp   rq   rr   r   pls          r   r   r     sh     dikk

C4;	EG%	0	0	0	0BLL	!	&$C(    r   )rd   rg   rf   r+   rc   rd   rg   rf   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
        Fr4  z3UPDATE tasks SET current_run_id = NULL WHERE id = ?)r,   r   r   r   rN   r   )
r   r_   rc   rd   rg   rf   r+   r   rC   rr   s
             r   _end_runr9    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 )Nr8  r?   )r   r   r,   r   s      r   _current_run_idr;  @  sS    
,,7' hjj  *-P5E1FP3s#$%%%DPr   )rd   rg   rf   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*   NrA   z
        INSERT INTO task_runs (
            task_id, profile, step_key,
            status, outcome,
            summary, error, metadata,
            started_at, ended_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        Fr4  r   )r,   r   r   r   rN   r   r   )r   r_   rc   rd   rg   rf   r   trowr`   ra   r   s              r   _synthesize_ended_runr>  G  s    0 dikk

C<<C	
  hjj 	 #'0d:DG+/9t&''TH
,,	 WhWU8@JDJxe4444d	
 C" s}!"""r   c                   d}t          |           5  |                     d                                          }|D ]z}|d         }|                     d|f                                          }t          d |D                       r.|                     d|f           t	          | |dd           |d	z  }{	 ddd           n# 1 swxY w Y   |S )
zPromote ``todo`` tasks to ``ready`` when all parents are ``done``.

    Returns the number of tasks promoted.  Safe to call inside or outside
    an existing transaction; it opens its own IMMEDIATE txn.
    r   z*SELECT id FROM tasks WHERE status = 'todo'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k    V  dS r   rH   r   s     r   r   z"recompute_ready.<locals>.<genexpr>  s+      ::Q1X;&(::::::r   zBUPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'r   Nr   )r   r   r   allr   )r   r   	todo_rowsrC   r_   ry   s         r   recompute_readyrC  ~  s>    H	4  LL8
 

(** 	  	 	C$iGll' 
	 
 hjj  ::'::::: XJ   dGZ>>>A		              & Os   B%CCC)ttl_secondsclaimerrD  rE  c               P   t          t          j                              }|pt                      }|t          |          z   }t          |           5  |                     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).
    zBSELECT 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   NzNSELECT assignee, max_runtime_seconds, current_step_key FROM tasks WHERE id = ?z
            INSERT INTO task_runs (
                task_id, profile, step_key, status,
                claim_lock, claim_expires, max_runtime_seconds,
                started_at
            ) VALUES (?, ?, ?, 'running', ?, ?, ?, ?)
            r*   rA   r=   z0UPDATE tasks SET current_run_id = ? WHERE id = ?claimed)lockexpiresrr   r2  )
r,   r   r   r   r   r   r   r   r   r   )r   r_   rD  rE  r   rH  rI  staler   r=  run_currr   s               r   
claim_taskrL    s    dikk

C#kmmDC$$$G	4 G' G'
 PJ
 
 (** 	  	U+, 	LL c% 01223
 
 
 ll	 7C)
 
 <1GG' G' G' G' G' G' G' G'L ||&J
 
 (**	 	
 ,, $(2Z  d,0:'((d/3=*++
 
$ ">W	
 	
 	
 	'9g@@	
 	
 	
 	

 g&&OG' G' G' G' G' G' G' G' G' G' G' G' G' G' G' G' G' G's   BF$B*FF"F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   Nz3UPDATE task_runs SET claim_expires = ? WHERE id = ?TF)r,   r   r   r   r   r   r;  )r   r_   rD  rE  rI  rH  r   rr   s           r   heartbeat_claimrN    s<    $)++[!1!11G#kmmD	4  llEgt$
 

 <1$T733F!If%                            s   AB;-B;;B?B?c                   t          t          j                              }d}t          |           5  |                     d|f                                          }|D ]i}|                     d|d         f           t          | |d         ddd|d                    }t          | |d         dd	|d         i|
           |dz  }j	 ddd           n# 1 swxY w Y   |S )zReset any ``running`` task whose claim has expired.

    Returns the number of stale claims reclaimed.  Safe to call often.
    r   zmSELECT id, claim_lock FROM tasks WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?UPDATE tasks SET status = 'ready', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND status = 'running'r&   	reclaimedzstale_lock=r5   )rc   r+   rg   
stale_lockr2  r   N)r,   r   r   r   r   r9  r   )r   r   rQ  rJ  rC   rr   s         r   release_stale_claimsrS    sb   
 dikk

CI	4  [F
 
 (**	 	
  	 	CLL6 T	   c$i#K7C$577  F
 c$is<01   
 NII#	              0 s   BCCC)r8   rd   rf   r8   c          	     \   t          t          j                              }t          |           5  |                     d|||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	}t          | |d|rt          |          nd
|pdd|           ddd           n# 1 swxY w Y   t          |            dS )u  Transition ``running|ready -> done`` and record ``result``.

    Accepts a task that's 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 don't 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.
    ah  
            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')
            r   NF	completedr
   )rc   r+   rd   rf   )rc   rd   rf    r   i  )
result_lenrd   r2  T)r,   r   r   r   r   r9  r>  r   
splitlinesr   r   rC  )	r   r_   r8   rd   rf   r   r   rr   
ev_summarys	            r   complete_taskrZ  9  s   * dikk

C	4 /
 /
ll
 S'"
 
 <1!/
 /
 /
 /
 /
 /
 /
 /
" '&2GG	
 
 
 >w>(>f>*g##*#6F!	  F ")!4gg&GR
AKSZ%%''2244Q7==QS
';-3:c&kkk%-  	
 	
 	
 	
Q/
 /
 /
 /
 /
 /
 /
 /
 /
 /
 /
 /
 /
 /
 /
b D4s   &D#B#DDD)reasonr[  c               :   t          |           5  |                     d|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``.a  
            UPDATE tasks
               SET status       = 'blocked',
                   claim_lock   = NULL,
                   claim_expires= NULL,
                   worker_pid   = NULL
             WHERE id = ?
               AND status IN ('running', 'ready')
            r   NFr   rc   r+   rd   )rc   rd   r[  r2  T)r   r   r   r9  r>  r   )r   r_   r[  r   rr   s        r   
block_taskr^    s=    
4  ll J
 
 <1        'i
 
 
 >f>*g!  F
 	dGY60B6RRRR;                 s   $BABBBc           	        t          t          j                              }t          |           5  |                     d|f                                          }|r3|d         r+|                     d|t          |d                   f           |                     d|f          }|j        dk    r	 ddd           dS t          | |dd           	 ddd           d	S # 1 swxY w Y   dS )
u  Transition ``blocked -> ready``.

    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.
    zDSELECT current_run_id FROM tasks WHERE id = ? AND status = 'blocked'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
                z\UPDATE tasks SET status = 'ready', current_run_id = NULL WHERE id = ? AND status = 'blocked'r   NF	unblockedT)r,   r   r   r   r   r   r   )r   r_   r   rJ  r   s        r   unblock_taskra    sl    dikk

C	4  RJ
 
 (** 	  	U+, 	LL c% 01223
 
 
 ll2J
 

 <1/       0 	dG[$7773                 s   BC?CC#&C#c                   t          |           5  |                     d|f          }|j        dk    r	 d d d            dS t          | |ddd          }t	          | |dd |           	 d d d            d	S # 1 swxY w Y   d S )
NzUPDATE tasks SET status = 'archived',     claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND status != 'archived'r   FrQ  z#task archived with run still activer]  r   r2  T)r   r   r   r9  r   )r   r_   r   rr   s       r   archive_taskrc    s   	4  ll4 J	
 
 <1        '9
 
 

 	dGZfEEEE%                 s   $A7)A77A;>A;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|           )uj  Resolve (and create if needed) the workspace for a task.

    - ``scratch``: a fresh dir under ``$HERMES_HOME/kanban/workspaces/<id>/``.
    - ``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 HERMES_HOME-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   task z! has non-absolute workspace_path z"; workspace paths must be absoluteTrx   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: )
r3   r4   r   
expanduseris_absoluter   r&   r"   r~   cwd)rd  rp   r   s      r   resolve_workspacerj    sB   " +)Dy 	, T())4466A==??  QDG Q Q*Q Q Q    !!DG+A	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   r   
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   r   r%   )r   r_   r   s      r   set_workspace_pathrm  )  s     
4 
 
>YY 	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s   &AA
A   i    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<   dS )DispatchResultz&Outcome of a single ``dispatch`` pass.r   r,   rQ  r   )default_factoryzlist[tuple[str, str, str]]spawnedr   skipped_unassignedcrashedauto_blocked	timed_outN)rW   rX   rY   rZ   rQ  r[   r   r   rQ   rr  rs  rt  ru  rv  rH   r   r   rp  rp  B  s         00IH*/%*E*E*EGEEEEB$)E$$?$?$?????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Br   rp  r   c                >   | r| dk    rdS 	 t          t          d          r"t          j        t          |           d           n)# t          $ r Y dS t
          $ r Y dS t          $ r Y dS w xY wt          j        dk    r	 t          dt          |            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 nw xY wdS )uY  Return True if ``pid`` is still running on this host.

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

    **Zombie handling (Linux):** ``os.kill(pid, 0)`` 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 additionally peek at ``/proc/<pid>/status``
    and treat ``State: Z`` as dead. On other POSIX or on Windows the
    zombie check is a no-op.
    r   FkillTlinuxz/proc/z/statusr   zState:Zr   r   N)hasattrr   rx  r,   ProcessLookupErrorPermissionErrorOSErrorsysplatformopen
startswithsplitFileNotFoundError)r   flines      r   
_pid_aliver  S  s   "  #((u	2v 	!GCHHa      uu   tt   uu |w	0s3xx000#66 !  Dx00 $**S!"4"4Q"777#(        	               "?G< 	 	 	 D		
 4s_   7A 
A*	A*	A*)A*>!D  9C4D  %C4(D  4C88D  ;C8<D   DD)noter  c               ~   t          t          j                              }t          |           5  |                     d||f          }|j        dk    r	 ddd           dS 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).
    zJUPDATE tasks SET last_heartbeat_at = ? WHERE id = ? AND status = 'running'r   NFz7UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?	heartbeatr  r2  T)r,   r   r   r   r   r;  r   )r   r_   r  r   r   rr   s         r   heartbeat_workerr    sB     dikk

C	4 
 
ll2'N
 

 <1
 
 
 
 
 
 
 
 !w//LLIf   	';",VTNN	
 	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
& 4s   %B2"AB22B69B6)	signal_fnc                  ddl }g }t          t          j                              }t                                          dd          d          d}|                     d                                          }|D ]}|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,	  ||
|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   |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   zSELECT id, worker_pid, started_at, max_runtime_seconds, claim_lock FROM tasks WHERE status = 'running' AND max_runtime_seconds IS NOT NULL   AND started_at IS NOT NULL AND worker_pid IS NOT NULLr5   rV  r1   r=   r;   r&   Frx  r   g      ?TzUPDATE tasks SET status = 'ready', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, last_heartbeat_at = NULL WHERE id = ? AND status = 'running')r   elapsed_secondslimit_secondssigkillrv  zelapsed z
s > limit rJ   rc   r+   rg   rf   r2  )signalr,   r   r   r  r   r   r  r{  r   rx  SIGTERMr|  r~  r   r  sleepSIGKILLr   r   r9  r   r   )r   r  r  rv  r   host_prefixr   rC   rH  elapsedr   tidkilledrx  _r   rq   rr   s                     r   enforce_max_runtimer    sH   " MMMI
dikk

C ]]((a003666K<<	B 
 hjj 	  :& :&< &B{++ 	C-...S234444#l#$$$i %1yyr6**4BGG 	 S&.))))&0    2YY    !# E
3# Dfn---!FF*G4   D t__ 	& 	&,,6  C |q  '*7||%(-B)C%D%D%	  "#'_S\\__SEZA[=\=\___$	   #{GF      %%%1	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	& 	&2 s7   D++D?>D?	FF10F1B/I>>J	J	secondsc                    t          |           5  |                     d|t          |          nd|f          }ddd           n# 1 swxY w Y   |j        dk    S )zKSet or clear the per-task max_runtime_seconds. Returns True on
    success.z5UPDATE tasks SET max_runtime_seconds = ? WHERE id = ?Nr   )r   r   r,   r   )r   r_   r  r   s       r   set_max_runtimer    s     
4 
 
llC$0S\\\dGD
 

 
 
 
 
 
 
 
 
 
 
 
 
 
 

 <1s   *AA
A
c                   g }t          |           5  |                     d                                          }t                                          dd          d          d}|D ] }|d         pd}|                    |          s#t          |d                   r9|                     d|d	         f          }|j        dk    rt          | |d	         d
d
dt          |d                    dt          |d                   |d         d          }t          | |d	         d
t          |d                   |d         d|           |                    |d	                    	 ddd           n# 1 swxY w Y   |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).
    z`SELECT id, worker_pid, claim_lock FROM tasks WHERE status = 'running' AND worker_pid IS NOT NULLr   r   r   r5   rV  r;   rP  r&   rt  zpid z
 not alive)r   rE  r  r2  N)r   r   r   r   r  r  r  r   r9  r,   r   r   )r   rt  r   r  rC   rH  r   rr   s           r   detect_crashed_workersr    s    G	4 "* "*||B
 
 (** 	 %,,S!44Q7::: 	* 	*C|$*D??;// #l+,, ,,6 T	 C |q  !#d)%iCS%6!7!7CCC"3|#455#&|#4 	   #d)YL 122s<?PQQ!   
 s4y)))9	*"* "* "* "* "* "* "* "* "* "* "* "* "* "* "*F Ns   EE66E:=E:failure_limitr  c          
     p   d}t          |           5  |                     d|f                                          }|rt          |d                   dz   nd}||k    rb|                     d||dd         |f           t	          | |dd|dd         d	|i
          }t          | |d||dd         d|           d}n_|                     d||dd         |f           t	          | |dd|dd         d	|i
          }t          | |d|dd         |d|           ddd           n# 1 swxY w Y   |S )zRelease the claim, increment the failure counter, maybe auto-block.

    Returns True when the task was auto-blocked (N failures exceeded),
    False when it was just released back to ``ready`` for another try.
    Fz-SELECT spawn_failures FROM tasks WHERE id = ?r:   r   zUPDATE tasks SET status = 'blocked', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, spawn_failures = ?, last_spawn_error = ? WHERE id = ? AND status IN ('running', 'ready')Ni  r   failuresr  )r  rg   r2  TzUPDATE tasks SET status = 'ready', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, spawn_failures = ?, last_spawn_error = ? WHERE id = ? AND status = 'running'spawn_failed)rg   r  )r   r   r   r,   r9  r   )r   r_   rg   r  r   rC   r  rr   s           r   _record_spawn_failurer  E  s    G	4 + +ll;gZ
 

(** 	 69?3s+,--11a}$$LLB 5#;0   g!)DSDk$h/	  F gy%dsd<<   
 GGLL6 5#;0   g&~DSDk$h/	  F g~+8<<   O+ + + + + + + + + + + + + + +X Ns   DD++D/2D/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 = ?rr  r   r2  )r   r   r,   r;  r   )r   r_   r   rr   s       r   _set_worker_pidr    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 )z3Reset the failure counter after a successful spawn.zIUPDATE tasks SET spawn_failures = 0, last_spawn_error = NULL WHERE id = ?N)r   r   )r   r_   s     r   _clear_spawn_failuresr    s    	4 
 
J	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s   599)spawn_fnrD  dry_run	max_spawnr  r  r  c          	        t                      }t          |           |_        t          |           |_        t          |           |_        t          |           |_        | 	                    d          
                                }d}|D ]}	|	||k    r n|	d         s!|j                            |	d                    7|r*|j                            |	d         |	d         df           ct          | |	d         |          }
|
~	 t          |
          }nS# t           $ rF}t#          | |
j        d| |	          }|r|j                            |
j                   Y d}~d}~ww xY wt)          | |
j        t+          |                     ||nt,          }	  ||
t+          |                    }|r#t/          | |
j        t1          |                     t3          | |
j                   |j                            |
j        |
j        pdt+          |          f           |d
z  }# t           $ rQ}t#          | |
j        t+          |          |	          }|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 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) -> 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.

    ``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub.
    zsSELECT id, assignee FROM tasks WHERE status = 'ready' AND claim_lock IS NULL ORDER BY priority DESC, created_at ASCr   Nr*   r&   rV  )rD  zworkspace: r  r   )rp  rS  rQ  r  rt  r  rv  rC  r   r   r   rs  r   rr  rL  rj  rR   r  r&   ru  rm  r%   _default_spawnr  r,   r  r*   )r   r  rD  r  r  r  r8   
ready_rowsrr  rC   rG  	workspaceexcauto_spawnr   s                   r   dispatch_oncer    s   4 F+D11F+D11FN*400F%d++FO	1  hjj	 
 G &7 &7 W	%9%9E: 	%,,SY777 	N!!3t9c*or"BCCCT3t9+FFF?		)'22II 	 	 	(gj"5"5"5+  D  7#**7:666HHHH	 	4S^^<<<%1~	7&#i..11C <gj#c((;;;!$
333N!!7:w/?/E2s9~~"VWWWqLGG 	7 	7 	7(gj#c((+  D  7#**7:666	7 Ms2   	D
E)#<E$$E)BH++
J5AJJlog_path	max_bytesc                l   	 |                                  sdS |                                 j        |k    rdS |                     | j        dz             }	 |                                 r|                                 n# t          $ r Y nw xY w|                     |           dS # t          $ r Y dS w xY w)u   Rotate ``<log>`` to ``<log>.1`` if it exceeds ``max_bytes``.

    Single-generation rotation — one old file kept, newer one replaces it.
    Keeps disk usage bounded while still giving the user a chance to grab
    the prior run's output.
    Nz.1)existsstatst_sizewith_suffixsuffixunlinkr~  rename)r  r  rotateds      r   _rotate_worker_logr    s       	F==??"i//F&&x'=>>	~~ !    	 	 	D	        s?   B% B% B% (A> =B% >
BB% 
BB% %
B32B3r  c           	     J   ddl }| j        st          d| j         d          d| j         }t	          t
          j                  }| j        r
| j        |d<   | j        |d<   ||d<   | j        |d	<   d
d| j        ddg}| j        r)| j        D ]!}|r|dk    r|	                    d|g           "|	                    dd|g           ddl
m}  |            dz  dz  }|                    dd           || j         dz  }	t          |	t                     t          |	d          }
	 |                    |t
          j                            |          r|nd|j        |
|j        |d          }n1# t*          $ r$ |
                                 t/          d          w xY w|j        S )aj  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.
    r   Nrf  z has no assigneezwork kanban task HERMES_TENANTHERMES_KANBAN_TASKHERMES_KANBAN_WORKSPACEHERMES_PROFILEhermesz-pz--skillszkanban-workerchatz-qr   r    logsTrx   .logab)ri  stdinstdoutstderrenvstart_new_sessionzv`hermes` executable not found on PATH. Install Hermes Agent or activate its venv before running the kanban dispatcher.)
subprocessr*   r   r&   dictr   environr7   rB   r  r   r   r~   r  DEFAULT_LOG_ROTATE_BYTESr  Popenr   isdirDEVNULLSTDOUTr  closer   r   )rd  r  r  promptr  cmdskr   log_dirr  log_fprocs               r   r  r    s+    = <::::;;;***F
rz

C{ +#{O $C%.C!"
 !MC 	dm 	OC( { -+ 	- 	-B -bO++

J+,,,JJf   
 100000o(*V3GMM$M...DG))))Hx!9::: 4  E
W]]955?		4$$"   
 
  
 
 
^
 
 	

 8Os   %AE- -.Fg      N@)intervalr  r  
stop_eventon_tickr  floatc                   ddl }ddl}|                                fd}|                                |                                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   )rL   )_signum_framer  s     r   _handlezrun_daemon.<locals>._handleu  s    r   )SIGINTr  )r  r  )r|   )r  	threadingro   current_threadmain_threadgetattrr   r~  is_setr   r   r   r  rR   	traceback	print_excwait)r  r  r  r  r  r  r  r  sig_namesigr   resr  s      `         r   
run_daemonr  `  s3    MMM__&&
    
 !!Y%:%:%<%<<<- 	 	H&(D11CMM#w////"G,   D  !! *	"#GII.. $#'"/                 "GCLLLL    D 	" 	" 	"!!!!!	" 	)))# !! * * * * *sl   %A<<BB) D 	C(D (C,,D /C,0D 6D D 
DD DD "D87D8c                   t          | |          }|st          d|           t          fdCd}g }|                    d	|j         d
|j                    |                    d           |                    d|j        pd            |                    d|j                    |j        r|                    d|j                    |                    d|j	         d|j
        pd            |                    d           |j        rl|j                                        rS|                    d           |                     ||j        t                               |                    d           d t          | |          D             }t          |          t           k    r-t          |          t           z
  }|t            d         }|dz   }nd}|}d}|r|                    d           |r4|                    d| d|dk    rdnd dt          |           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&|                    d$ ||
j                              |
j        rP	 t7          j        |
j        d%d&'          }|                    d( ||           d)           n# t:          $ r Y nw xY w|                    d           g|                     d*|f                                          }d+ |D             }|rd%}|D ]}t          | |          }|r|j        d,k    r!d- t          | |          D             }|                     d. d&/           |r|d         nd}
|s|                    d0           d&}|                    d1|            g }|
D|
j        r=|
j                                        r$|                     ||
j                             n@|j!        r$|                     ||j!                             n|                    d2           |
W|
j        rP	 t7          j        |
j        d%d&'          }|                    d( ||           d)           n# t:          $ r Y nw xY w|"                    |           |                    d           |j        r|                     d3|j        |f                                          }|r|                    d4|j                    |D ]}t%          j        dt%          j        tG          |d5                                       }|d6         pd                                $                                }|r|d         dd7         nd8}|                    d9|d:          d |d;          d!| d<|            |                    d           tK          | |          }t          |          tL          k    r(t          |          tL          z
  }|tL           d         }nd}|}|r|                    d=           |r4|                    d| d>|dk    rdnd dt          |           d           |D ]}t%          j        dt%          j        |j'                            }|                    d?|j(         d@| dA           |                     ||j        tR                               |                    d           dB*                    |          +                                dBz   S )Da2  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&  rJ   r(   r   r,   r   r%   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.rV  Nu   … [truncated, z chars omitted])r   r   )rJ   r   s     r   _capz"build_worker_context.<locals>._cap  sY     	2GGIIq66U??H%yMc!ffunMMMMMr   z# Kanban task z: rV  z
Assignee: z(unassigned)z
Status:   z
Tenant:   zWorkspace: z @ z(unresolved)z## Bodyc                     g | ]}|j         	|S r   )rb   r   s     r   rK   z(build_worker_context.<locals>.<listcomp>  s    OOOq
8N8N8N8Nr   Nr   r   z## Prior attempts on this taskz_(z earlier attemptz omitted; showing most recent z)_z%Y-%m-%d %H:%Mz	(unknown)z### Attempt u    — z (r   r   z	_error_: FT)r5  	sort_keysz_metadata_: ``r  c                    g | ]
}|d          S r  rH   r   s     r   rK   z(build_worker_context.<locals>.<listcomp>  s    666Q!K.666r   r
   c                (    g | ]}|j         d k    |S )rU  )rc   r   s     r   rK   z(build_worker_context.<locals>.<listcomp>	  s$    PPP!qyK7O7OA7O7O7Or   c                    | j         S r   )r1   )r   s    r   <lambda>z&build_worker_context.<locals>.<lambda>	  s    AL r   )key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 @rb   rd      z(no summary)z- r&   r'   z): z## Comment threadz earlier commentz**z** (z):
)rJ   r(   r   r,   r   r%   ),r   r   _CTX_MAX_FIELD_BYTESr   r&   r'   r*   r+   r7   r3   r4   r)   r   _CTX_MAX_BODY_BYTES	list_runsr   _CTX_MAX_PRIOR_ATTEMPTS	enumerater   strftime	localtimer1   r`   rc   rd   rg   rf   rN   r   rR   r   r   sortr8   r  r,   rX  r,  _CTX_MAX_COMMENTSr/   rm   _CTX_MAX_COMMENT_BYTESr   rstrip)r   r_   rd  r  lines	all_prioromittedshownfirst_shown_idxoffsetrunidxtsr`   rc   meta_strparent_rowsr  wrote_headerr   ptruns
body_lines	role_rowsrC   rJ   firstall_comments	omitted_cshown_ccs                                  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^^___	LLy TY__&& YTT$)%899:::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LL2ah22B222333LLaf&<==>>>LL99U""$$t++s$   >>M==
N
	N
>U
UUr  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 statusnr+   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_secondsr   )r   r,   
setdefaultr   r   )r   r(  rC   r)  
oldest_rowr   oldest_ready_ages          r   board_statsr.  W	  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                @   t          t          j                              }| j        r|t          | j                  z
  nd}| j        r|t          | j                  z
  nd}| j        r0t          | j                  t          | j        p| j                  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   r/   r1   r2   )rd  r   age_since_createdage_since_startedtime_to_completes        r   task_ager6  {	  s    
dikk

C6:oOc$/22224&*o?c$/""""4 
 	'DT_%G!H!HHH"& 
  10$4  r   )	thread_iduser_idr  chat_idr7  r8  c          
         t          t          j                              }t          |           5  |                     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, created_at)
            VALUES (?, ?, ?, ?, ?, ?)
            rV  N)r,   r   r   r   )r   r_   r  r9  r7  r8  r   s          r   add_notify_subr;  	  s     dikk

C	4 
 

 hb'3G	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s   AA #A 
list[dict]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 rH   )r  r   s     r   rK   z$list_notify_subs.<locals>.<listcomp>	  s    """DGG"""r   r  r  s      r   list_notify_subsr?  	  sf     ||@7*
 

(** 	 ||>??HHJJ""T""""r   )r7  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 = ?rV  r   )r   r   r   )r   r_   r  r9  r7  r   s         r   remove_notify_subrA  	  s     
4 
 
llAhb9
 

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 <!s   9= =)r7  kindsrB  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.
    zqSELECT last_event_id FROM kanban_notify_subs WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?rV  Nr   last_event_idz7SELECT * FROM task_events WHERE task_id = ? AND id > ? zAND kind IN (r   r   z) zORDER BY id ASCrq   r&   r_   rp   r/   rr   r/  )r   r   r,   rQ   r   r   r  r   rN   rO   rR   r   ro   rM   max)r   r_   r  r9  r7  rB  rC   cursor	kind_listqr   r   r0  max_idr   rq   s                   r   unseen_events_for_subrK  	  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
new_cursorc          	         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 = ?rV  )r   r   r,   )r   r_   r  r9  r7  rL  s         r   advance_notify_cursorrN  	  s     
4 
 
S__gx)/rJ	
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s   +AAAi ' )older_than_secondsrO  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   r   r   )r   rO  cutoffr   s       r   	gc_eventsrR  
  s     $6 7 77F	4 
 
llJI
 

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 s| q!!!s    A$$A(+A(c                r   ddl m}  |            dz  dz  }|                                sdS t          j                    | z
  }d}|                                D ]]}	 |                                r6|                                j        |k     r|                                 |dz  }N# t          $ r Y Zw xY w|S )zDelete 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.r   r   r    r  r   )
r   r   r  r   iterdiris_filer  st_mtimer  r~  )rO  r   r  rQ  removedr   s         r   gc_worker_logsrX  
  s     100000o(*V3G>> qY[[--FG__  	yy{{ qvvxx0699


1 	 	 	H	Ns   A
B''
B43B4c                :    ddl m}  |            dz  dz  |  dz  S )zmReturn the path to a worker's log file. The file may not exist
    (task never spawned, or log already GC'd).r   r   r    r  r  r   )r_   r   s     r   worker_log_pathrZ  6
  s<     100000?x'&0g3C3C3CCCr   )
tail_bytesr[  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).Nzutf-8replace)encodingerrorsrb   
)r_  )rZ  r  	read_textr  r  r  seektellreadlineendswithreaddecoder~  )r_   r[  r   sizer  probepartialdatas           r   read_worker_logrm  =
  su    7##D;;== t>>79>EEEyy{{"$ 	j  tj()))
 **,,''.. "16688t3C3CFF5MMM6688D	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 {{79{555   tts<   D4  )D4 )BDD4 DD4 DD4 4
EEc                    	 t          j                    dz  dz  } n# t          $ r g cY S w xY w|                                 sg S g }	 t	          |                                           D ]H}|                                s|dz                                  r|                    |j                   In# t          $ r |cY S w xY w|S )uX  Return the set of named profiles discovered on disk.

    Reads ``~/.hermes/profiles/`` directly so this module has no import
    dependency on ``hermes_cli.profiles`` (which pulls in a large chunk
    of the CLI startup path). Only returns directories that contain a
    ``config.yaml`` — a bare dir without config isn't a real profile.
    z.hermesprofileszconfig.yaml)
r   homerR   is_dirr   rT  rU  r   r   r~  )rp  namesentrys      r   list_profiles_on_diskrt  _
  s    y{{Y&3   			;;== 	EDLLNN++ 	) 	)E<<>> %..00 )UZ(((		)
    Ls    ++A*B2 2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'  r&  r*   r+   c                H    g | ]}||v                      |i           d S ))r   on_diskcounts)get)rI   r   rx  rw  s     r   rK   z#known_assignees.<locals>.<listcomp>
  sM        	 wjjr**	
 	
  r   )rL   rt  r   r,   r+  r   rM   )r   rC   rr  rx  rw  s      @@r   known_assigneesrz  y
  s     '))**G )+F||	$  N N
 ADCH#j/2..s8}==7S///00E        r   T)include_activer{  	list[Run]c                   d}|g}|s|dz  }|dz  }|                      ||                                          }d |D             S )zReturn 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).
    z)SELECT * FROM task_runs WHERE task_id = ?z AND ended_at IS NOT NULLz  ORDER BY started_at ASC, id ASCc                B    g | ]}t                               |          S rH   )r^   rV   r   s     r   rK   zlist_runs.<locals>.<listcomp>
  s"    ***CLLOO***r   r  )r   r_   r{  rI  r   r   s         r   r  r  
  sa     	4A 	F )	((	++A<<6""++--D**T****r   Optional[Run]c                    |                      dt          |          f                                          }|rt                              |          nd S )Nz$SELECT * FROM task_runs WHERE id = ?)r   r,   r   r^   rV   )r   rr   rC   s      r   get_runr  
  sL    
,,.V hjj  !$-3<<-r   c                    |                      d|f                                          }|rt                              |          ndS )zEReturn the currently-open run for ``task_id`` (``ended_at IS NULL``).z_SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1Nr   r   r^   rV   r   s      r   
active_runr  
  sK    
,,	+	
  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 1Nr  r   s      r   
latest_runr  
  sK    
,,	4	
  hjj	 
 !$-3<<-r   )r   r   r   )rt   ru   r   rv   )rt   ru   r   r   )r   rv   r   r   )r   rv   )r   r%   )r   rv   r'   r%   r)   r(   r*   r(   r.   r(   r3   r%   r4   r(   r7   r(   r-   r,   ry   r   r   r   r9   r(   r=   r0   rB   r   r   r%   )r   rv   ry   r   r   r   )r   rv   r_   r%   r   r   )r   rv   r*   r(   r+   r(   r7   r(   r   r   r   r0   r   r   )r   rv   r_   r%   r`   r(   r   r   )r   rv   r  r%   r  r%   r   r   )r   rv   r  r%   r  r%   r   r   )r   rv   r_   r%   r   r   )r   rv   r_   r%   r   r!  )
r   rv   r_   r%   rm   r%   r)   r%   r   r,   )r   rv   r_   r%   r   r)  )r   rv   r_   r%   r   r-  )r   rv   r_   r%   rp   r%   rq   re   rr   r0   r   r   )r   rv   r_   r%   rc   r%   rd   r(   rg   r(   rf   re   r+   r(   r   r0   )r   rv   r_   r%   r   r0   )r   rv   r_   r%   rc   r%   rd   r(   rg   r(   rf   re   r   r,   )r   rv   r   r,   )
r   rv   r_   r%   rD  r,   rE  r(   r   r   )
r   rv   r_   r%   rD  r,   rE  r(   r   r   )r   rv   r_   r%   r8   r(   rd   r(   rf   re   r   r   )r   rv   r_   r%   r[  r(   r   r   )r   rv   r_   r%   r   r   )rd  r$   r   r   )r   rv   r_   r%   r   rk  r   r   )r   r0   r   r   )r   rv   r_   r%   r  r(   r   r   )r   rv   r   r   )r   rv   r_   r%   r  r0   r   r   )
r   rv   r_   r%   rg   r%   r  r,   r   r   )r   rv   r_   r%   r   r,   r   r   )r   rv   r_   r%   r   r   )r   rv   rD  r,   r  r   r  r0   r  r,   r   rp  )r  r   r  r,   r   r   )rd  r$   r  r%   r   r0   )r  r  r  r0   r  r,   r   r   )r   rv   r_   r%   r   r%   )r   rv   r   r  )rd  r$   r   r  )r   rv   r_   r%   r  r%   r9  r%   r7  r(   r8  r(   r   r   )r   rv   r_   r(   r   r<  )r   rv   r_   r%   r  r%   r9  r%   r7  r(   r   r   )r   rv   r_   r%   r  r%   r9  r%   r7  r(   rB  r   r   rC  )r   rv   r_   r%   r  r%   r9  r%   r7  r(   rL  r,   r   r   )r   rv   rO  r,   r   r,   )rO  r,   r   r,   )r_   r%   r   r   )r_   r%   r[  r0   r   r(   )r   r   )r   rv   r   r<  )r   rv   r_   r%   r{  r   r   r|  )r   rv   rr   r,   r   r  )r   rv   r_   r%   r   r  )irZ   
__future__r   r   rN   r   r   r   r  r   dataclassesr   r   pathlibr   typingr   r   r	   r   r   DEFAULT_CLAIM_TTL_SECONDSr  r  r  r  r  r   r"   r$   r^   rl   ro   r   rL   rs   r[   r   r   r   contextmanagerr   r   r   r   r   r   r  r  r  r  r  r  r   r$  r(  r,  r1  r   r9  r;  r>  rC  rL  rN  rS  rZ  r^  ra  rc  rj  rm  DEFAULT_SPAWN_FAILURE_LIMITr  rp  r  r  r  r  r  r  r  r  r  r  r  r  r$  r.  r6  r;  r?  rA  rK  rN  rR  rX  rZ  rm  rt  rz  r  r  r  r  rH   r   r   <module>r     s	    & # " " " " "      				   



  ( ( ( ( ( ( ( (       * * * * * * * * * * WVV666  $    " " " + + + +7 7 7 7 S
 S
 S
 S
 S
 S
 S
 S
l 2
 2
 2
 2
 2
 2
 2
 2
j         ! ! ! ! ! ! ! !u
x  #suu  $ $ $ $    <    ,v
 v
 v
 v
r    *
' 
' 
' 
'# # # #$ " $#$( %))-&*k& k& k& k& k& k&\
4 
4 
4 
4/ / / / #  ", , , , , ,>   4
 
 
 
<   .       * * * *) ) ) )2 2 2 2&' ' ' ',   "   8 #	 !     : "# 6 6 6 6 6 6rQ Q Q Q "#0# 0# 0# 0# 0# 0#n   J 1!V' V' V' V' V' V'z 1!     >   L !!#H H H H H H^ !	$ $ $ $ $ $N$ $ $ $N   4:8 :8 :8 :8z
 
 
 
$    +  C C C C C C C C , , , ,f 	$ $ $ $ $ $T W W W W W Wt   0 0 0 0p 59 9 9 9 9 9xR R R R*
 
 
 
 0#4M M M M M M`   .R R R Rv #44* 4* 4* 4* 4* 4*vu, u, u, u,x! ! ! !H   8  $!
 
 
 
 
 
0 8<	# 	# 	# 	# 	#$  $     .  $%). . . . . .n  $
 
 
 
 
 
, <J" " " " " "$ "0     4D D D D 26     D   4       V  	+ + + + + +*. . . .. . . .. . . . . .r   