feat(kanban): report why work stopped and whether it is moving - #85266
feat(kanban): report why work stopped and whether it is moving#85266moonweave wants to merge 8 commits into
Conversation
(cherry picked from commit d262840)
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)
`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.
260ed74 to
b76c9fc
Compare
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:
|
…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>
|
Two follow-up commits, both extending the same stats surface this PR introduces:
Both verified with negative and positive controls in |
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-v1groundwork. 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_statsand_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 wasscheduled, 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_kindalready distinguishesdependency,needs_input,capabilityand 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, 53needs_inputrows were really 28 waiting on areview-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.pyon this branch versus the same file on cleanmain, compared as failure sets rather than counts:main: 2 failed, 21 passedtest_gateway_dispatcher_disables_corrupt_board_without_tracebackcases fail on both; they are pre-existing and untouched here.13 new tests, all passing.
Scope of the diff
kanban_db.pygains 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 indetect_crashed_workers— calls the new_worker_log_excerpt_for_crash; the excerpt is worker stdout and is deliberately kept out of run metadata, whichbuild_worker_contextrenders verbatim into the next worker's prompt_dispatch_once_locked,_default_spawn— threadboardthrough to that call, so the crash record reads the right board's worker log instead of whichever board is selected on diskAn 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 currentmainand force-pushed; the tests are unchanged by the rewrite.