Skip to content

feat(kanban): report why work stopped and whether it is moving - #85266

Open
moonweave wants to merge 8 commits into
NousResearch:mainfrom
moonweave:codex/kanban-stats-rebased-20260813
Open

feat(kanban): report why work stopped and whether it is moving#85266
moonweave wants to merge 8 commits into
NousResearch:mainfrom
moonweave:codex/kanban-stats-rebased-20260813

Conversation

@moonweave

@moonweave moonweave commented Aug 13, 2026

Copy link
Copy Markdown

Four read-only additions to hermes kanban, so a consumer can tell why work stopped and whether it is moving without opening any task.

Stacked on #83348 — it needs the activity-v1 groundwork. Review that one first; this branch's diff against it is the four commits below.

What each commit adds

report why work stopped and whether it is moving_board_staleness_stats and _unresolved_run_stats. Today a board reports how many rows are blocked but nothing about whether anything is happening. These report the age of the oldest open row and the maximum run count on any unresolved row. Both are stated as peer observations, never combined into a verdict: measured on a live board the row carrying the maximum run count was scheduled, not blocked, so folding it into a blocked-cause sentence sends an operator hunting through blocked rows for a repeat that is not among them.

report every block kind that stops for a person_needs_input_stats. block_kind already distinguishes dependency, needs_input, capability and NULL, but nothing surfaced the split, so every blocked row looked the same from outside.

count only what is stopped in front of a person — a fix to the above. The first version counted rows that were blocked on other rows, which a person cannot act on.

separate a review handoff from a question for a person_awaiting_review_stats. Measured on a live board, 53 needs_input rows were really 28 waiting on a review-required: dispatch that never arrives and 25 genuinely waiting on a person. Reporting 53 as one number tells an operator to answer 53 questions when 25 is the real figure.

Verification

tests/hermes_cli/test_kanban_core_functionality.py on this branch versus the same file on clean main, compared as failure sets rather than counts:

  • clean main: 2 failed, 21 passed
  • this branch: 2 failed, 34 passed
  • new failures: 0. The same two test_gateway_dispatcher_disables_corrupt_board_without_traceback cases fail on both; they are pre-existing and untouched here.

13 new tests, all passing.

Scope of the diff

kanban_db.py gains 11 top-level definitions and touches 5 existing ones. Nothing is removed — the branch removes 23 lines in total, all of them inside the five functions below.

The five existing functions and why each is touched:

  • board_stats — where every new figure is exposed
  • _migrate_add_optional_columns — the column the activity salt lives in
  • detect_crashed_workers — calls the new _worker_log_excerpt_for_crash; the excerpt is worker stdout and is deliberately kept out of run metadata, which build_worker_context renders verbatim into the next worker's prompt
  • _dispatch_once_locked, _default_spawn — thread board through to that call, so the crash record reads the right board's worker log instead of whichever board is selected on disk

An earlier revision of this branch carried a much larger diff: it was cherry-picked from a base that predates current main, so it also reverted 91 unrelated functions and made 7 (add_comment, delete_attachment, detect_crashed_workers, …) appear deleted. That has been re-extracted onto current main and force-pushed; the tests are unchanged by the rewrite.

moonweave and others added 5 commits August 13, 2026 20:42
The activity projection pseudonymized task and event ids with plain SHA-256
over the database file path. That path is not a secret — the module header
documents the default — and `_new_task_id` draws from only 32 bits, so anyone
who knew the layout could enumerate the whole id space and invert work_ref
back to a task id in under a minute. A projection whose stated purpose is to
withhold identifiers was not withholding them.

Refs are now HMAC-SHA256 under a 256-bit secret generated once per board and
stored write-once in a new `kanban_meta` table, so the search space is the key
rather than the id. The prefix moves inside the MAC'd message for domain
separation, and the path-derived namespace is gone — it also drifted when the
same board was reached by a different path.

The salt is read back after `INSERT OR IGNORE` rather than trusting the
locally generated candidate, which is what makes racing processes converge on
one value; refs must stay stable because the consumer dedupes on them and
keeps a bounded retention window. A malformed salt raises instead of keying
HMAC with nothing: `bytes.fromhex("")` returns an empty key, which would put
the original attack back within reach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit aa2c423)
대시보드가 파이프라인 11시간 정지를 못 봤다. 라우터는 5분마다 completed를
찍었고, 진짜 원인은 아무도 읽지 않는 per-task 워커 로그 두 줄에만 있었다.
board_stats가 개수만으로 그 공백을 메운다. task id, 제목, 에러 문자열,
workspace 경로는 경계를 넘지 않는다.

blocked_causes — blocked 행을 실행 기록 유무로 쪼갠다. dispatcher는
failure_limit에 걸리면 사유 없이 자동 차단하므로 사람이 세운 행과 기계가
포기한 행이 구분되지 않았다. 실측 4개 보드 75건 중 68건이 실행 후 중단.
run 기록 유무 이상은 추론하지 않는다. machine/human 라벨은 기각했다 —
실행 기록이 없는 행은 사람이 막았을 수도 의존성 때문일 수도 있다.

크래시 시 워커 로그 발췌 — detect_crashed_workers가 기록하던 'pid N not
alive'는 하류 증상이다. 실제 원인은 로그에 있었다(Unknown skill(s)).
발췌는 event payload에만 넣는다. error_text로 가면 check_respawn_guard의
auth/quota 정규식이 우연히 걸려 태스크가 영구히 park되고, run metadata로
가면 build_worker_context가 그것을 다음 워커 프롬프트에 그대로 렌더해
주입 통로가 된다. spawn 시 로그에 run 경계를 찍어 이전 실행의 에러를
이번 원인으로 보고하지 않게 한다 — 확신에 찬 오진은 무의미한 메시지보다
나쁘다.

review 컬럼의 sdlc-review는 이 설치 어디에도 없는 유령 이름이었다. 번들
트리에도 없다. 워커는 요청한 스킬이 전부 없을 때만 예외를 던지는데 이게
유일한 요청이라 모든 review dispatch가 1초 안에 죽는다. review 상태로 간
task가 한 번도 없어 잠복해 있었다. 실제 존재하는 github-code-review로
바꿨다.

last_completion_at — 진전 신호. heartbeat, 코멘트, 재실행 루프는 모두
이벤트를 남기지만 완료를 만들 수 없다. 보드 전체 이벤트 최댓값과 열린
행의 최소 접촉시각을 먼저 시도했다가 둘 다 폐기했다. 전자는 수명 짧은
cron task에, 후자는 heartbeat 하는 멈춘 행과 60초 재실행 루프에 깨진다.

oldest_open_row_last_touch_at / open_rows — 백로그 정체. 완료만으로는
부족하다. 한 보드는 30분 전에 뭔가 끝냈고 동시에 열린 행 7건이 17시간째
손대지지 않았다. 두 사실이 반대를 가리키고 둘 다 참이라 함께 보고한다.
어떤 이벤트든 접촉으로 세므로 진전을 뜻하지 않는다.

max_unresolved_row_runs — 완료 없이 한 행에 몰린 최다 실행 횟수와 그
마지막 시작 시각. 오늘 19회/14분과 65회/66분 두 사례를 찾았고 둘 다
보이지 않았다. block_kind='dependency'는 unblock 루프 차단기가 세지 않아
상한이 없다. 시간 창은 두지 않는다 — '완료된 적 없는 행'이 이미 자기
제한이고, 창 길이는 방어할 수 없는 임의 파라미터다.

blocked_without_comment_rows는 task_comments 행이 0개인 것만 센다.
block_kind나 last_failure_error에 사유가 있을 수 있으므로 '사유 없음'이
아니다.

전부 기존 인덱스로 seek 하며 테이블 스캔이 없다. dispatcher tick마다
돌기 때문에 이건 우연이 아니라 요구사항이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1d65011)
board_stats가 needs_input만 세면 운영자 대기열의 절반만 보인다. 코어 자체
주석(kanban_db.py:110-125)이 네 종류를 정의하고 그중 셋이 사람에게 간다:
needs_input(사람의 판단·답변), capability(접근 권한·자격증명 없음, AI가
할 수 없는 동작 — genuinely human-only), 그리고 kind가 기록되지 않은 옛
차단(주석이 'generic human blocker로 취급하라'고 명시). dependency만
사람 없이 todo로 돌아가 자동 승격된다.

세 개를 합치지 않는다. 운영자가 취할 행동이 다르고 — 답하기, 대신 하기,
분류하기 — 미분류 행에 대해 source는 무엇을 기다리는지 모르기 때문이다.
합치면 안다고 주장하는 셈이다.

타입이 있는 종류와 없는 종류의 범위는 의도적으로 다르다. 태그가 있으면
그 자체가 기록된 사실이고 block_kind는 unblock_task를 넘어 살아남으므로,
모든 비종료 status를 훑어 낡은 태그가 숨지 않고 드러나게 한다. 태그가
없으면 그 행을 차단으로 만드는 건 status뿐인데, 처음 구현에서 이 구분을
놓쳐 block_kind IS NULL을 같은 넓은 범위에 걸었다. 그러면 태그 없는 모든
열린 행이 걸린다 — 평범한 todo 행이 조건을 자명하게 만족한다. 실측 40건
중 실제로 누군가를 기다리던 건 16건이고, 한 보드에서는 보고된 5건 전부가
차단된 적조차 없었다. 대시보드가 40건이 당신을 기다린다고 말하는 동안
24건은 아무도 안 기다리는 상태였을 것이다. 미분류는 blocked/triage로
좁혔다. triage는 BLOCK_RECURRENCE_LIMIT이 재차단 반복 행을 보내는 곳이고
needs_input 집단도 이미 그런 행 셋을 갖고 있다.

경과 시각의 docstring 주장도 정정했다. 'five days ago는 an hour ago와
구별된다'고 썼는데, 이 값은 질문의 나이가 아니라 그 행의 아무 종류든
마지막 이벤트다. 대기 카드에 코멘트를 달면 답을 안 해도 리셋된다 —
실측 59건 중 13건이 commented가 마지막 이벤트였고, 한 보드는 진짜 최초
질문 116.4시간 대비 115.9시간을 보고하고 있었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6de047d)
타입 태그가 붙은 행을 모든 비종료 status에서 셌다. 태그가 기록된 사실이니
행이 옮겨가도 드러나야 한다는 논리였는데, 진단으로는 맞고 이 숫자로는
틀리다. 운영자는 이걸 '지금 나를 기다리는 양'으로 읽는다.

block_kind는 unblock_task를 넘어 일부러 보존된다 — 같은 사유로 재차단되는
걸 unblock 루프 차단기가 알아보려면 필요하고, 그 차단기가 오늘 이 설치에서
발견된 폭주(14분에 19회, 66분에 65회)를 묶는 장치다. 태그를 지우는 건
회귀이므로 범위를 옮겼다.

needs_input을 달고 있다가 답을 받아 ready로 풀린 행은 실행 대기지 사람
대기가 아니다. 지금은 0건이지만 세 보드에서 다섯 행이 태그를 단 채
unblock된 이력이 있으니 구조적으로 발생한다.

자식 범위는 그대로 넓게 둔다 — 차단된 부모 뒤에 갇힌 행은 어떤 열린
status에나 있을 수 있다.

합성 검증: 차단 → needs_input_rows=1, unblock → block_kind는 'needs_input'로
보존되고 needs_input_rows=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9031bfa)
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/cron Cron scheduler and job management comp/cli CLI entry point, hermes_cli/, setup wizard labels Aug 13, 2026
`board_stats` reports `needs_input_rows` as one queue, but part of it is not
waiting on a person at all. A worker that finishes an implementation and blocks
with `needs_input` and a `review-required:` reason is asking for a review
handoff — and `review` is the only status the review dispatcher claims, so
nothing ever picks the row up.

Measured across all four live boards the day this was added: 28 of 53
needs-input rows carried that reason, and there were zero rows in `review`
anywhere. Reporting 53 tells an operator that 53 things need them when 25 do.

`needs_input_awaiting_review_rows` is a strict subset of `needs_input_rows`,
never a separate queue, so the two can be rendered together without the total
changing. It reads the latest block reason per row rather than any historical
one: a row blocked first for review and later for a real question is a question.
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

feat(kanban): report why work stopped and whether it is moving

The three new signals (blocked-cause split, per-row minimum staleness, unresolved-run peak) each fix a real blind spot, and the "counts only, no identifiers" discipline plus the reconcile asserts are well done. Observations:

  1. Overlap with feat(sessions): report that person-driven work exists, without reporting what it is #85267: the kanban_db.py/kanban.py/test changes here are identical to feat(sessions): report that person-driven work exists, without reporting what it is #85267's, which additionally adds hermes sessions activity. Both PRs are open — coordinate so the shared changes are merged once to avoid a conflict and a duplicated review.

  2. hermes_cli/kanban_db.py _worker_log_excerpt_for_crash call site in detect_crashed_workers: the helper is documented never to raise, but the argument run_started_at=row["started_at"] is evaluated outside the helper's try/except. Today the crash-scan SELECT includes started_at so this is safe, but the nearby existing code guards with row["started_at"] if "started_at" in row.keys() else None. Consider the same defensive access so a future trim of the SELECT cannot turn into a KeyError inside the dispatcher's write txn (which would stall every board, not just this task).

  3. board_stats now runs five additional aggregate queries per call, and _awaiting_review_stats does a per-row event query for each needs-input row (an N+1 over a usually small population). The query plans are documented as index-backed and this runs per dispatcher tick — fine at current scale, but if boards grow, batching the per-row event lookups into one IN query would keep the tick cheap.

  4. Minor: the _prune-style comments and the trailing-blank-line cleanups in tests/hermes_cli/test_kanban_cli.py are unrelated churn in an otherwise focused PR — consider keeping test whitespace changes out of the diff to reduce review surface.

moonweave and others added 2 commits August 17, 2026 23:21
…ounting them as asks

A blocked card is not always a live question. The recurring shape: a review
blocks, a re-review also blocks, and a third card finishes the work — while
the first two keep sitting in the operator's queue. Measured on one live
board: of four blocked rows, two were review cards already answered by a
completed final review, and the operator had no way to tell them apart from
the real asks.

Add a `supersedes` column the replacement card's creator declares, and a
`superseded_stopped_rows` aggregate reported beside the stopped-row totals —
never subtracted from them. The declaration is explicit: nothing is inferred
from titles or timing, so pre-existing rows are never retroactively claimed,
and the count undercounts real debris rather than guessing at it. The
superseded row itself is untouched — not closed, unblocked, or deleted.

Exposed on both the CLI (`kanban create --supersedes`) and the kanban_create
tool schema, because the orchestrator that actually creates replacement cards
works through the tool, not the CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@moonweave

Copy link
Copy Markdown
Author

Two follow-up commits, both extending the same stats surface this PR introduces:

c10445737 — separate builder corrections from owner asks. needs_work: / REVIEW_R<n>_HOLD reasons are reviewer-to-builder routing markers that were landing in the owner's decision count. Measured live: 3 of a board's needs-input rows were corrections the owner could do nothing about. Reported as needs_input_needs_work_rows, a strict subset — the total is untouched, count-only for the same privacy boundary as the adjacent review-handoff split.

1850c4315 — report stopped rows a later card replaced. A blocked card is not always a live question: a review blocks, a re-review blocks, a third card finishes the work, and the first two sit in the operator's queue forever. Measured live: 2 of 4 blocked rows on one board were already answered this way. Adds an opt-in supersedes column declared by the replacement card's creator (CLI flag + tool schema), and a superseded_stopped_rows aggregate reported beside the stopped-row totals, never subtracted. Nothing is inferred from titles or timing, and the superseded row itself is never touched.

Both verified with negative and positive controls in test_kanban_core_functionality.py — the debris test fails if the aggregate is pinned to 0, and an unrelated new card does not move the count.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants