一键导入
kanban-dispatcher-stalled
kanban dispatcher cron이 안 돌거나 dead worker reclaim 실패를 4가지 fail mode로 진단
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
kanban dispatcher cron이 안 돌거나 dead worker reclaim 실패를 4가지 fail mode로 진단
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
name: kanban-dispatcher-stalled description: kanban dispatcher cron이 안 돌거나 dead worker reclaim 실패를 4가지 fail mode로 진단 title: Kanban Dispatcher Stalled — 진단 스킬 type: skill space: growth tags: [skill, kanban, dispatcher, cron, launchd, diagnostics] created: 2026-06-01 updated: 2026-06-01 links:
kanban dispatcher가 안 돌거나 dead worker를 못 reclaim할 때 빠르게 진단하는 스킬. 4가지 fail mode를 차례로 검증.
다음 중 하나라도 해당되면 이 skill 사용:
jobs.json 등록은 됐는데 launchd plist가 없거나 unload됨.
# 1a. launchd에 트리거 plist가 떠있는지
launchctl list | grep -i '{{AGENT_NAME_LOWER}}\|kanban\|cron'
# 기대: ai.{{AGENT_NAME_LOWER}}.gateway 또는 ai.{{AGENT_NAME_LOWER}}.cron-runner 같은 plist label
# (quartz / n8n / kanban-dashboard / fswatch / nas-mount는 무관 — 별개 service)
# 1b. plist 파일 자체 존재 확인
ls -la ~/Library/LaunchAgents/ai.{{AGENT_NAME_LOWER}}.*.plist 2>/dev/null
ls -la ~/Library/LaunchAgents/com.{{AGENT_NAME_LOWER}}.*.plist 2>/dev/null
# 1c. gateway plist 내용 확인
plutil -p ~/Library/LaunchAgents/ai.{{AGENT_NAME_LOWER}}.gateway.plist 2>/dev/null
Fix: plist 부재 → {{AGENT_NAME_LOWER}}-update-checker skill 또는 수동 작성 후 launchctl load. gateway plist가 cron runner 역할도 해야 함 (embedded design).
script 파일이 jobs.json의 command path와 일치하지 않음.
# 2a. script 존재 확인
ls -la ~/.{{AGENT_NAME_LOWER}}/scripts/dispatch_once_*.py
# 2b. jobs.json의 script path와 비교
python3 -c "
import json
with open('~/.{{AGENT_NAME_LOWER}}/cron/jobs.json') as f:
jobs = json.load(f)
for j in jobs.get('jobs', []):
if 'dispatcher' in j.get('name','').lower():
print(f' {j[\"name\"]:35} | script={j.get(\"script\",\"?\")} | state={j.get(\"state\",\"?\")}')
"
Fix: script missing → memory의 옛 path가 outdated. ~/.{{AGENT_NAME_LOWER}}/source/claude-code/... → ~/.{{AGENT_NAME_LOWER}}/source/{{AGENT_NAME_LOWER}}-agent/... 같은 root 변경이 있었을 수 있음.
jobs.json의 last_status=ok가 거짓말. last_run_at은 며칠 전.
# 3a. last_run_at staleness
python3 -c "
import json
from datetime import datetime, timezone
with open('~/.{{AGENT_NAME_LOWER}}/cron/jobs.json') as f:
jobs = json.load(f)
now = datetime.now(timezone.utc)
for j in jobs.get('jobs', []):
if 'dispatcher' in j.get('name','').lower():
lra = j.get('last_run_at')
if lra:
age_h = (now - datetime.fromisoformat(lra)).total_seconds() / 3600
tag = 'STALE' if age_h > 1 else 'OK'
print(f' {j[\"name\"]:35} | last_run={age_h:.1f}h ago | last_status={j.get(\"last_status\")} | {tag}')
else:
print(f' {j[\"name\"]:35} | never ran')
"
판단: last_run_at > 1h stale → dispatcher 안 도는 중. last_status=ok는 stale marker (silent fail이 jobs.json update 안 함).
fake worker_pid (작은 정수) 가 in_progress task에 남아있어 reclaim 로직을 혼란시킴.
# 4a. in_progress task + fake worker_pid
python3 -c "
import sqlite3
from pathlib import Path
DB = Path.home() / '.{{AGENT_NAME_LOWER}}' / 'state' / '{{AGENT_NAME_LOWER}}_tasks.db'
conn = sqlite3.connect(str(DB))
for r in conn.execute(\"SELECT id, status, board, worker_pid, claim_expires, title FROM tasks WHERE status='in_progress'\"):
pid = r[3] or 0
flag = 'FAKE' if pid and pid < 1000 else 'real?'
print(f' {r[0]} | board={r[2]} | pid={pid} ({flag}) | expires={r[4]} | {r[5][:40]}')
fake worker_pid (99999, 39483 같은 작은 정수) — autotest 잔재. os.kill(pid, 0)도 fail → silent
# 4b. 즉시 수동 reclaim
python3 -c "
import sqlite3
from pathlib import Path
DB = Path.home() / '.{{AGENT_NAME_LOWER}}' / 'state' / '{{AGENT_NAME_LOWER}}_tasks.db'
conn = sqlite3.connect(str(DB))
n = conn.execute(\"UPDATE tasks SET status='todo', worker_pid=NULL, claim_expires=NULL WHERE status='in_progress'\").rowcount
conn.commit()
print(f'reclaimed: {n}')
모든 cron/interval job이 enabled=true인데 1.5일+ 동안 spawn 0회. last_run_at은 stale, last_status=ok는 거짓말, next_run_at이 null. launchd는 정상 작동 — get_due_jobs()가 recurring job의 null next_run을 자동 복구 안 함.
Mode 3과의 차이: Mode 3는 jobs.json의 last_run_at이 stale. Mode 5는 last_run_at stale + next_run_at이 null. cron output을 보면 "dispatcher 자체는 도는데 due list에 못 들어감" 패턴.
# 1) jobs.json 직접 read — enabled=true인 모든 job의 last/next state
python3 -c "
import json
with open('~/.{{AGENT_NAME_LOWER}}/cron/jobs.json') as f:
d = json.load(f)
for j in d.get('jobs', []):
if not j.get('enabled', True): continue
last = (j.get('last_run_at') or 'null')[:19]
nxt = (j.get('next_run_at') or 'null')[:19]
print(f' {j[\"name\"]:35} last={last:19} next={nxt:19}')
"
# 2) cron output dir의 최신 파일 timestamp vs 현재 시각
ls -lt ~/.{{AGENT_NAME_LOWER}}/cron/output/*/ 2>/dev/null | head -30
# 3) date 기준 staleness (1.5일+ vs 1분)
date '+%Y-%m-%d %H:%M:%S %Z'
판정 기준:
next_run_at=null이고 last_run_at이 1시간+ stale → Mode 5 발동next_run_at=null이어도 last_run_at이 1분 이내 → false positive (실행 중)enabled=false면 대상 아님 (의도적 비활성)Step 1: jobs.py 영구 fix (prevention)
get_due_jobs() 진입 시 next_run이 falsy이고 schedule.kind in ("cron", "interval")이면 compute_next_run(schedule, now)로 자동 복구 + disk writeback. oneshot recovery만 있던 기존 분기를 recurring 분기로 확장.
# cron/jobs.py get_due_jobs() — 추가 분기
if not next_run:
schedule = job.get("schedule", {})
schedule_kind = schedule.get("kind")
if schedule_kind in ("cron", "interval"):
recovered_next = compute_next_run(schedule, now.isoformat())
if not recovered_next:
continue
logger.warning(
"Job '%s' had no next_run_at; recovering recurring run to %s",
job.get("name", job["id"]), recovered_next,
)
else:
recovered_next = _recoverable_oneshot_run_at(
schedule, now, last_run_at=job.get("last_run_at"),
)
if not recovered_next:
continue
Step 2: jobs.json 즉시 patch (recovery)
5개 enabled cron/interval job의 next_run_at을 now-5s로 일괄 patch → 다음 60초 tick에서 due → mark_job_run이 compute 결과로 자동 재계산.
from cron.jobs import load_jobs, save_jobs, _{{AGENT_NAME_LOWER}}_now
from datetime import timedelta
now = _{{AGENT_NAME_LOWER}}_now()
jobs = load_jobs()
for j in jobs:
if (j.get('enabled')
and j.get('next_run_at') is None
and j.get('schedule', {}).get('kind') in ('cron', 'interval')):
j['next_run_at'] = (now - timedelta(seconds=5)).isoformat()
save_jobs(jobs)
enabled=false는 patch skipkind=once는 oneshot recovery (별도 로직, 영향 없음)compute_next_run이 next_run_at을 미래 시각으로 재계산 → disk에 영구 writelast_run_at 갱신, next_run_at이 compute 결과로 재계산 (예: 0 */6 * * * → 6시간 후, */1 * * * * → 1분 후)daily/hourly 등 sparse schedule은 더 오래)cron/output/{board}/YYYY-MM-DD_HH-MM-SS.md)장기 verification (gateway restart 후): jobs.py fix가 in-memory → 다음 restart 시 disk에서 reload되며 영구 적용. 재발 방지 코드도 영구 동작.
last_run_at이 stale이지만 cron output 파일이 최근에 있을 수 있음 (다른 trigger 경로: 수동 실행, 다른 dispatcher)last_status=ok는 dispatcher가 한 번이라도 정상 종료한 적이 있다는 marker. 현재 spawn 안 되는 상태와 무관integrations-board-dispatcher처럼 jobs.json에 entry 자체가 없는 case는 mode 5로 못 잡힘 — mode 1 (plist 부재) 또는 mode 2 (script 부재)로 진단. jobs.json의 jobs[] length가 5 미만이면 별도 fix 필요get_due_jobs()의 recurring 분기 부재가 root cause. 단순 disk patch만으로는 prevention 안 됨, jobs.py fix + restart 필요 (mode 5)last_status=ok는 stale marker일 수 있음 — 현재 spawn 안 되는 상태와 무관. last_run_at staleness와 함께 봐야 진실kind=once job의 next_run_at=null은 mode 5가 아니라 oneshot recovery 로직이 별도 — kind in ("cron", "interval")인 recurring job만 mode 5 대상# V1. 모든 dispatcher의 last_run_at < 5분
# V2. launchctl list에 cron runner plist 존재
# V3. in_progress task 0개 (또는 모두 worker_pid가 실제 PID + claim_expires future)
# V4. cron output/{board}-dispatcher/에 1분 이내 새 .md 파일
# V5. mode 5 fix 후: patch 60~180초 안에 3/5+ job spawn 확인, next_run_at이 compute_next_run 결과로 미래 시각으로 재계산됨
# V6. mode 5 prevention: gateway restart 후 jobs.py fix disk reload 확인 (다음 tick에서 null next_run이 자동 복구되는지)
Root cause: cron/scheduler.py:tick() processes for job in due_jobs: sequentially. If an LLM-based cron job (one with no script field) gets stuck in run_job() — for example, hitting the task_qa_gate neuron's contract phase — it blocks ALL subsequent jobs in the same tick. With the gateway's 60s tick interval, a single stuck LLM job can stall the dispatcher for 15-26 minutes.
Pattern: cron-runner log shows 0 new === ISO === blocks for 10+ minutes. Gateway log shows "QA gate FAILED: phase=contract" followed by no further cron activity for minutes.
# Check cron-runner fire frequency in last 5 min
grep -E '=== 2026-' ~/.{{AGENT_NAME_LOWER}}/logs/cron-runner/$(date +%Y-%m-%d).log \
| awk -F'[T:.]' '{print $2":"$3}' | tail -5
# 0 entries in 5+ min = likely Mode 6 stall
Also checked by harmony check Layer 3.5b.
UID_NUM=$(id -u)
launchctl kickstart -k gui/${UID_NUM}/ai.{{AGENT_NAME_LOWER}}.gateway
# Gateway restarts, missed jobs fast-forward, normal tick resumes in 60-90s
Option A (recommended — disable redundant LLM jobs):
If the LLM-based cron job duplicates work already done by a script-based job, disable it:
# Mark enabled: false in jobs.json for the blocking job
j['enabled'] = False; j['state'] = 'paused'
# Gateway reads jobs.json fresh each tick — no restart needed
Option B (systemic — script jobs before LLM jobs):
scheduler.py:tick() was patched (2026-06-10) to process script-based jobs FIRST:
_script_jobs = [j for j in due_jobs if j.get("script")]
_llm_jobs = [j for j in due_jobs if not j.get("script")]
for job in _script_jobs + _llm_jobs:
Option C (watchdog): Harmony check Layer 3.5b detects 0 fires in 5 min.
{{AGENT_NAME_LOWER}}_cron_watchdog.sh auto-kickstarts if 0 fires + gateway uptime > 5 min.
After fix: cron-runner fires every 60s without gaps > 2 min.
Gateway log shows NO long gaps between Running job entries for script-based jobs.
{{AGENT_NAME}} brain signal architecture — event bus, signal processor, awareness reporter, brain monitor
TDD-PDCA plan for building a self-replicating branching agent in {{AGENT_NAME}}. 단일 에이전트가 스스로 서브에이전트를 분기하고, 결과를 수렴시키는 구조.
Integrate {{AGENT_NAME}} with ACP (Agent Client Protocol) agents. Reverse-engineered from goose's Rust ACP provider implementation.
Maintain .{{AGENT_NAME_LOWER}} as an Obsidian vault — graph connectivity overhaul (P-layer mesh, skill clusters, SEO article web), broken link detection, backlink density verification via Obsidian CLI, filename deduplication, bidirectional linking, auto-generated orphan management, cron output graph bloat prevention, and Obsidian compatibility.
AI Trend Collection & Filtering — collect GitHub trending repos, score via 5-axis philosophy filter, evaluate, apply, and retire.
RSS 피드 모니터링 → SEO 기사 수집·분석·트렌드 리포트