Files
assist/tools/patrol.sh
T
jiantw83 2b7176a644 feat(patrol): 一輪巡檢落地,心跳改由巡檢跑完才寫
What:
- 新增 tools/patrol.sh,三個子命令:collect 收集、finish 收尾、abort 中止。這一輪的巡檢項目是四項純讀取:使用統計、版本落差與重啟閘門、階段鎖與工作包鎖、心跳自述。
- 心跳的寫入從排程移到巡檢收尾。排程只呼叫巡檢,不再直接寫心跳。
- 排程週期由心跳門檻推導,不再寫死。install heartbeat 這個工作代號改為拒絕。

Why:
- 排程直接寫心跳的話,心跳新鮮只證明排程活著。巡檢整個壞掉、每輪都失敗,心跳照樣新鮮,閘門照樣放行,而且沒有任何錯誤訊息——這是無聲失效,是最難發現的一種。
- 改成巡檢寫,心跳新鮮才等於上一輪真的跑完了。閘門判的才是工作訊號,不是行程存活訊號。
- 門檻是讀取端的設定,心跳檔裡不存它。所以週期與門檻各寫死一個數字一定會撞:門檻五分鐘、巡檢十五分鐘,心跳永遠是過期的。

How:
- 週期取「滿足漏掉一輪還算新鮮、漏掉兩輪才過期」的最大值,並且要能整除一小時。要拉長巡檢週期就調大門檻,週期自動跟著長,兩個數字不會各走各的。
- 心跳只看「這一輪有沒有把結果記下來」,不看四項的成敗。四項有失敗但監控頁寫成了就寫心跳,頁上判定標警示;頁寫不成就中止,一定不寫,讓心跳自己過期。頁每輪都寫失敗卻照樣寫心跳,等於把這次要修掉的缺陷原封不動搬過去。
- 整輪拿一把目錄鎖,搶不到就讓開,不排隊也不並行。並行兩輪會在同一頁附兩節,還會互相蓋掉用量快照。鎖逾時可被下一輪搶走,被搶走的那一輪收尾時對不上就不寫心跳——它沒跑到底,不該蓋章。
- 任一項失敗不影響其餘項目。失敗要在監控頁上看得出來是失敗,不是沒資料;來源是空的則明寫「那是零次,不是故障」。
- 版本盤點照抄腳本原字。抄到查詢失敗就寫查詢失敗,不改寫成相符、也不自己補查遠端版本——查不到是沒有證據,不是版本沒問題。

Who:
助理落地的第四塊。排程那一輪留下的問題在這裡解掉了。
2026-09-01 15:06:10 +08:00

740 lines
33 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env sh
# patrol.sh — 助理巡檢一輪的收攏與收口(供 jsc-assist:assistant 的 patrol 操作呼叫)。
#
# 用法:
# patrol.sh collect [--out {目錄}] [--trigger {排程|事件|手動}]
# patrol.sh finish --round {輪次代號} [--out {目錄}] [--dry-run]
# patrol.sh abort --round {輪次代號} [--out {目錄}]
#
# collect 帶了 --out,finish 與 abort 就要帶同一個目錄,不然換不到本輪的用量快照。
#
# collect 讀四項來源、組出監控頁要附加的那一節、把鎖拿在手上。
# finish 在監控頁寫成功之後才呼叫:寫心跳、換上用量快照、放掉鎖。
# abort 在監控頁沒寫成時呼叫:只放掉鎖,不寫心跳。
#
# 結束碼(三個子命令共用一張表,同一碼在不同子命令的成因寫在同一列):
# 0 collect:四項全部讀到底(含「來源在、沒有資料」);finish:心跳寫好、快照換上、
# 鎖放掉;abort:鎖放掉,本來就沒鎖也算
# 1 collect:部分成功——至少一項失敗,也至少一項有結果。**結果照樣印得出來,呼叫端
# 照樣要把這一節寫上監控頁**,只是本輪判定要標成警示
# 2 finish:找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳沒有東西可寫。collect 不會回這
# 一碼——心跳讀不到只是 D-09 這一項失敗,另外三項照跑
# 3 collect:四項全部失敗,一項資料都沒有。這一節還是要寫上監控頁,本輪判定標成異常
# 4 上一輪還在跑,本輪讓開(collect),或鎖已經不在自己手上(finish、abort)。這不是
# 失敗,是刻意讓開:不寫心跳、不寫監控頁,下一輪再來
# 5 檔案系統失敗:鎖建不起來或放不掉、暫存檔寫不進去、快照換不上,或 heartbeat.sh write
# 回非 0。心跳沒寫成就是沒寫成,一律吵出來
# 6 用法錯誤:不認得的子命令、缺 --round、參數缺值
#
# --- 心跳為什麼由這裡寫,不由排程直接寫 ---
#
# 排程每分鐘直接呼叫 heartbeat.sh write 的話,心跳新鮮只證明 cron 活著。巡檢整個壞掉、
# 一項資料都讀不到、監控頁一頁都沒寫成,心跳照樣新鮮,靠心跳判定的閘門照樣放行,沒有
# 任何訊號。所以心跳改由巡檢寫:跑完一輪、而且結果真的記下來了,才寫那一次心跳。
#
# --- 心跳寫不寫,只看結果有沒有記下來 ---
#
# 四項的成敗不決定心跳。四項全失敗但監控頁寫成了,那一輪還是跑完了,證據也留下來了,
# 心跳照寫,頁上判定是異常,看頁的人自己判斷。反過來,監控頁沒寫成就是這一輪沒有結果,
# 心跳一定不寫:讓它自己過期,就是「巡檢在空轉」的唯一訊號。
# 所以寫心跳一定是獨立的 finish,時序上排在監控頁寫成之後,不與 collect 綁在一起。
#
# --- 上一輪還沒跑完,下一輪被叫起來 ---
#
# 一輪巡檢包含一次 CLI 呼叫與兩次 wiki 寫入,跑過一個排程週期是有可能的。所以整輪拿一把
# 鎖:$JSC_HOME/assistant/patrol.lock 是目錄,mkdir 是原子操作,搶不到就是別人在跑。
# 搶不到的那一輪回 4 直接讓開,不排隊、不並行——並行的兩輪會在同一頁附加兩節,還會互相
# 蓋掉用量快照。
# 鎖會逾時自動搶回來,門檻取心跳門檻(heartbeat.sh report 的 ttl 欄):上一輪跑得比門檻
# 還久,它本來就已經維持不住心跳新鮮了,讓新的一輪接手才對。被搶回來的那一輪,finish 會
# 拿 --round 比對出鎖不是自己的,回 4 且不寫心跳。
# 搶回來這件事會記在監控頁上(lock_broken=1),不會安靜發生。
#
# --- 這四項都是純讀取 ---
#
# D-01 技能與呼叫鏈使用統計 jsc-log 的 tools/usage-stats.sh
# D-04 版本落差與重啟閘門 jsc-hooks 的 version-guard.sh report、restart-gate.sh report
# D-07 SDLC 階段鎖與工作包鎖 $JSC_HOME/sessions/*.stage、$JSC_HOME/wp/*.pr
# D-09 心跳與閘門狀態自述 jsc-hooks 的 heartbeat.sh report
# 四項各自獨立:一項的來源不見了、或回非 0,只讓那一項標成失敗,其餘三項照跑、照記。
# 四項都不呼叫別的技能、不寫程式碼存取庫、不做決策。
#
# --- version-guard.sh report 的既有缺陷照實記 ---
#
# 這支腳本在部分機器上對每一個 domain 都回「查詢失敗」,recommend 跟著回 unverifiable。
# 巡檢照抄第四欄原字,不自己補查遠端版本、不把「查詢失敗」寫成「相符」或「最新」。
# 查不到就是沒有證據,寫成別的字等於幫一個既有缺陷蓋章。
#
# --- collect 的輸出 ---
#
# stdout 是 key=value,一行一個鍵,供呼叫端逐行取值。監控頁要用的 markdown 不印在
# stdout,改寫成檔案再把路徑印出來:那一段有好幾百字,經過對話重打一次只會多錯字。
# round= 本輪代號,finish 與 abort 要原樣帶回
# lock= acquired
# lock_broken= 0 或 1。1 代表上一輪的鎖逾時被搶回來
# hash= 監控頁雜湊,來源是 {主機名}/{登入帳號};算不出來時為空
# page= MONITOR_{HASH};hash 為空時為空
# host= user= at= 主機名、登入帳號、本輪時間
# item= 一項一行,欄位 status(ok、empty、fail)、rc、note
# verdict= 正常、警示、異常
# failed_sources= 讀不到的來源路徑,以「、」分隔;全部讀得到就是「無」
# tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,只供目錄頁那一列用
# section_file= 要附加到 MONITOR_{HASH} 的那一節
# newpage_file= MONITOR_{HASH} 不存在時要建的整頁內容
# contents_file= MONITOR_CONTENTS 那一列的欄位值
#
# 環境變數:
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc
# JSC_ASSIST_HEARTBEAT_SH heartbeat.sh 路徑覆寫;找不到並排存取庫時才設
# JSC_ASSIST_VERSION_GUARD_SH version-guard.sh 路徑覆寫
# JSC_ASSIST_RESTART_GATE_SH restart-gate.sh 路徑覆寫
# JSC_ASSIST_USAGE_STATS_SH usage-stats.sh 路徑覆寫
# JSC_ASSIST_HASH_ID hash-id 路徑覆寫
# JSC_ASSIST_PATROL_LOCK_TTL 鎖的逾時秒數;未設定時取心跳門檻,取不到就用 300
set -u
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
STATE_DIR="$JSC_HOME/assistant"
LOCK="$STATE_DIR/patrol.lock"
PREV_SNAP="$STATE_DIR/usage-prev.tsv"
RD="$STATE_DIR/patrol"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
SCRIPT_DIR="${SCRIPT_DIR:-.}"
TRIGGER=''
ROUND=''
DRYRUN=0
LOCK_BROKEN=0
FAILED_SOURCES=''
OK_COUNT=0
FAIL_COUNT=0
WARN=0
usage() {
cat >&2 <<'EOF'
usage: patrol.sh collect [--out 目錄] [--trigger 排程|事件|手動]
patrol.sh finish --round 輪次代號 [--out 目錄] [--dry-run]
patrol.sh abort --round 輪次代號 [--out 目錄]
EOF
exit 6
}
die() { # $1=結束碼 $2=訊息
printf '[jsc][助理巡檢][ERR]:%s\n' "$2" >&2
exit "$1"
}
# 找一支別的 domain 的腳本。搜尋順序比照 schedule.sh 的 heartbeat_sh():先環境變數覆寫,
# 再開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。
find_tool() { # $1=domain 短名 $2=domain 內相對路徑 $3=環境變數覆寫值(可為空)
if [ -n "$3" ]; then
[ -f "$3" ] && { printf '%s\n' "$3"; return 0; }
return 1
fi
_root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}"
for _c in "$_root/../$1/$2" "$_root/../jsc-$1/$2"; do
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
done
_c=$(ls -d "$_root"/../../jsc-"$1"/*/"$2" \
"$_root"/../../"$1"/*/"$2" \
"$HOME"/.claude/plugins/cache/*/jsc-"$1"/*/"$2" 2>/dev/null | sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
fmt_ts() { # $1=epoch 秒數;轉成當地時間字串,轉不動就原樣印
date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null && return 0
date -r "$1" '+%Y-%m-%d %H:%M' 2>/dev/null && return 0
printf '%s\n' "$1"
}
mtime_of() { # $1=檔案;印出修改時間,取不到印「-」
_t=$(stat -c %Y "$1" 2>/dev/null) || _t=''
[ -n "$_t" ] || _t=$(stat -f %m "$1" 2>/dev/null) || _t=''
[ -n "$_t" ] || { printf '-'; return 0; }
fmt_ts "$_t" | tr -d '\n'
}
# markdown 表格欄位裡的 `|` 會把欄切開,一律跳脫;換行壓成空白。
cell() { printf '%s' "$1" | tr '\n' ' ' | sed 's/|/\\|/g'; }
# 記一個讀不到的來源。同一輪多項失敗就串起來,供監控頁「讀不到的來源」那一列用。
add_failed_source() { # $1=路徑或來源名稱
if [ -z "$FAILED_SOURCES" ]; then FAILED_SOURCES="$1"; else FAILED_SOURCES="$FAILED_SOURCES、$1"; fi
}
# 記一列待人處理。助理只提醒,不代為執行。
add_pending() { # $1=要處理什麼 $2=來源子節 $3=建議入口
printf '| %s | %s | %s |\n' "$(cell "$1")" "$(cell "$2")" "$(cell "$3")" >>"$RD/pend.md"
}
# --- 鎖 ---
lock_ttl() { # 鎖的逾時秒數
_t="${JSC_ASSIST_PATROL_LOCK_TTL:-}"
case "$_t" in ''|*[!0-9]*) _t='' ;; esac
[ -n "$_t" ] && { printf '%s' "$_t"; return 0; }
[ -n "${HEARTBEAT_TTL:-}" ] && { printf '%s' "$HEARTBEAT_TTL"; return 0; }
printf '300'
}
lock_round() { sed -n 's/^round=//p' "$LOCK/info" 2>/dev/null | head -n1; }
lock_age() {
_s=$(sed -n 's/^started=//p' "$LOCK/info" 2>/dev/null | head -n1)
case "$_s" in ''|*[!0-9]*) printf '999999'; return 0 ;; esac
printf '%s' "$(( $(date +%s) - _s ))"
}
# 搶鎖。搶到回 0,別人在跑回 4,建不起來回 5。
lock_acquire() {
mkdir -p "$STATE_DIR" 2>/dev/null || die 5 "建不出助理狀態目錄 $STATE_DIR。"
if mkdir "$LOCK" 2>/dev/null; then
printf 'round=%s\npid=%s\nstarted=%s\n' "$ROUND" "$$" "$(date +%s)" >"$LOCK/info" 2>/dev/null \
|| die 5 "鎖建起來了,卻寫不進 $LOCK/info。"
return 0
fi
_age=$(lock_age); _ttl=$(lock_ttl)
if [ "$_age" -lt "$_ttl" ]; then
printf 'lock=busy holder=%s age=%s ttl=%s\n' "$(lock_round)" "$_age" "$_ttl"
die 4 "上一輪巡檢還在跑(輪次 $(lock_round),已經跑了 $_age 秒,未達 $_ttl 秒門檻),本輪讓開。"
fi
# 逾時搶回來。上一輪跑得比心跳門檻還久,它已經維持不住心跳新鮮了,讓新的一輪接手。
rm -rf "$LOCK" 2>/dev/null
mkdir "$LOCK" 2>/dev/null || die 5 "上一輪的鎖逾時,卻搶不回來:$LOCK。"
printf 'round=%s\npid=%s\nstarted=%s\n' "$ROUND" "$$" "$(date +%s)" >"$LOCK/info" 2>/dev/null \
|| die 5 "鎖搶回來了,卻寫不進 $LOCK/info。"
LOCK_BROKEN=1
WARN=1
return 0
}
# 放鎖。鎖不在自己手上就回 4,絕不硬放——那會把正在跑的那一輪的鎖拆掉。
lock_release() {
[ -d "$LOCK" ] || { printf 'lock=absent\n'; return 0; }
_h=$(lock_round)
[ "$_h" = "$ROUND" ] || die 4 "鎖不在本輪手上(鎖的輪次是 ${_h:-空值},本輪是 $ROUND),不動它,也不寫心跳。"
rm -rf "$LOCK" 2>/dev/null || die 5 "鎖放不掉:$LOCK。"
printf 'lock=released\n'
return 0
}
# --- D-01 技能與呼叫鏈使用統計 ---
# 把 usage-stats.sh 的「次數<TAB>名稱」轉成監控頁的列,順便算出本輪增量。
# 增量要有上一輪的累計快照才算得出來;沒有快照的第一輪一律寫「-」,不拿累計冒充本輪。
usage_rows() { # $1=類別(技能、呼叫鏈) $2=快照鍵(skill、chain) $3=資料檔
while IFS=' ' read -r _cnt _name; do
[ -n "$_name" ] || continue
printf '%s\t%s\t%s\n' "$2" "$_name" "$_cnt" >>"$RD/usage-next.tsv"
_prev=''
[ -f "$PREV_SNAP" ] && _prev=$(awk -F'\t' -v k="$2" -v n="$_name" \
'$1 == k && $2 == n { print $3; exit }' "$PREV_SNAP" 2>/dev/null)
if [ -n "$_prev" ]; then _delta=$(( _cnt - _prev )); else _delta='-'; fi
printf '| %s | %s | %s | %s |\n' "$(cell "$_name")" "$1" "$_delta" "$_cnt"
done <"$3"
}
d01() {
D01_STATUS=fail; D01_RC=0; D01_NOTE=''
: >"$RD/usage-next.tsv"
{
printf '### 技能與呼叫鏈使用統計\n\n'
printf '資料出自 `%s` 與 `%s`,由 `jsc-log` 的 `tools/usage-stats.sh` 聚合。\n\n' \
"\$JSC_HOME/usage/skills.jsonl" "\$JSC_HOME/usage/chains.jsonl"
} >"$RD/d01.md"
if ! _us=$(find_tool log tools/usage-stats.sh "${JSC_ASSIST_USAGE_STATS_SH:-}"); then
D01_NOTE='找不到 jsc-log 的 tools/usage-stats.sh'
D01_RC=127
add_failed_source 'jsc-log/tools/usage-stats.sh'
printf '**這一項失敗**:%s。這一輪沒有使用統計,不是「零次」。\n' "$D01_NOTE" >>"$RD/d01.md"
add_pending '技能用量讀不到,jsc-log 沒裝或版本太舊' '技能與呼叫鏈使用統計' '/jsc-cli:doctor'
return 0
fi
_rc1=0; _rc2=0
"$_us" skills >"$RD/d01.skills" 2>"$RD/d01.err" || _rc1=$?
"$_us" chains >"$RD/d01.chains" 2>>"$RD/d01.err" || _rc2=$?
if [ "$_rc1" -ne 0 ] || [ "$_rc2" -ne 0 ]; then
D01_RC=$(( _rc1 > _rc2 ? _rc1 : _rc2 ))
D01_NOTE="usage-stats.sh 回非 0(skills=$_rc1、chains=$_rc2)"
add_failed_source "$_us"
printf '**這一項失敗**:%s。訊息:%s\n' "$D01_NOTE" "$(cell "$(cat "$RD/d01.err" 2>/dev/null)")" >>"$RD/d01.md"
add_pending '使用統計腳本回非 0' '技能與呼叫鏈使用統計' '/jsc-log:stats'
return 0
fi
{
printf '| 對象 | 類別 | 本輪次數 | 累計次數 |\n'
printf '| --- | --- | ---: | ---: |\n'
usage_rows '技能' skill "$RD/d01.skills"
usage_rows '呼叫鏈' chain "$RD/d01.chains"
} >"$RD/d01.rows"
_n=$(grep -c '^| ' "$RD/d01.rows" 2>/dev/null); [ -n "$_n" ] || _n=0
if [ "$_n" -le 2 ]; then
D01_STATUS=empty
printf '來源檔讀得到,本輪一筆用量都沒有。hook 還沒記過任何一次呼叫就是這個狀態,那是「零次」,不是故障。\n' >>"$RD/d01.md"
return 0
fi
D01_STATUS=ok
cat "$RD/d01.rows" >>"$RD/d01.md"
if [ ! -f "$PREV_SNAP" ]; then
printf '\n第一輪沒有上一輪的累計快照,「本輪次數」一律寫「-」,不拿累計冒充本輪。\n' >>"$RD/d01.md"
fi
return 0
}
# --- D-04 版本落差與重啟閘門 ---
d04() {
D04_STATUS=fail; D04_RC=0; D04_NOTE=''
{
printf '### 版本落差與重啟閘門\n\n'
printf '資料出自 `version-guard.sh report` 與 `restart-gate.sh report`。\n\n'
} >"$RD/d04.md"
_vg=''; _rg=''
find_tool hooks hooks/version-guard.sh "${JSC_ASSIST_VERSION_GUARD_SH:-}" >"$RD/vg" 2>/dev/null \
&& _vg=$(cat "$RD/vg")
find_tool hooks hooks/restart-gate.sh "${JSC_ASSIST_RESTART_GATE_SH:-}" >"$RD/rg" 2>/dev/null \
&& _rg=$(cat "$RD/rg")
if [ -z "$_vg" ] && [ -z "$_rg" ]; then
D04_NOTE='找不到 jsc-hooks 的 version-guard.sh 與 restart-gate.sh'
D04_RC=127
add_failed_source 'jsc-hooks/hooks/version-guard.sh、jsc-hooks/hooks/restart-gate.sh'
printf '**這一項失敗**:%s。這一輪沒有版本證據,不是「版本都相符」。\n' "$D04_NOTE" >>"$RD/d04.md"
add_pending '版本與重啟閘門讀不到,jsc-hooks 沒裝或版本太舊' '版本落差與重啟閘門' '/jsc-cli:doctor'
return 0
fi
_part=0
# 版本比對表。第四欄照 version-guard.sh 原字抄,不改寫、不補查。
if [ -n "$_vg" ]; then
_rc=0
"$_vg" report >"$RD/d04.vg" 2>"$RD/d04.err" </dev/null || _rc=$?
if [ "$_rc" -ne 0 ]; then
_part=1; D04_RC="$_rc"
D04_NOTE="version-guard.sh report 回 $_rc"
add_failed_source "$_vg"
printf '**版本比對失敗**:`version-guard.sh report` 回 %s。訊息:%s\n\n' \
"$_rc" "$(cell "$(cat "$RD/d04.err" 2>/dev/null)")" >>"$RD/d04.md"
else
{
printf '| domain | 本機版本 | 應有版本 | 判定 |\n'
printf '| --- | --- | --- | --- |\n'
} >>"$RD/d04.md"
_unver=0; _rows=0
while IFS=' ' read -r _c1 _c2 _c3 _c4; do
[ -n "$_c1" ] || continue
case "$_c1" in
behind) printf '| (合計) | - | - | 落後 %s 個 |\n' "$(cell "$_c2")" >>"$RD/d04.md"; continue ;;
noregistry)
printf '| (無註冊檔) | - | - | 查不到本機已裝的 plugin:%s |\n' "$(cell "$_c2")" >>"$RD/d04.md"
_unver=1; continue ;;
esac
_rows=$(( _rows + 1 ))
printf '| %s | %s | %s | %s |\n' "$(cell "$_c1")" "$(cell "$_c2")" "$(cell "$_c3")" "$(cell "$_c4")" >>"$RD/d04.md"
[ "$_c4" = '查詢失敗' ] && _unver=$(( _unver + 1 ))
done <"$RD/d04.vg"
[ "$_rows" -eq 0 ] && printf '| (無 domain) | - | - | 這台機器一個 jsc plugin 都沒查到 |\n' >>"$RD/d04.md"
printf '\n判定欄照 `version-guard.sh report` 第四欄原字抄。抄到「查詢失敗」就寫「查詢失敗」,不改寫成「相符」或「最新」,也不自己補查遠端版本——查不到是沒有證據,不是版本沒問題。\n\n' >>"$RD/d04.md"
if [ "$_unver" -gt 0 ]; then
WARN=1
printf '**本輪有 %s 列查不到遠端版本。** 同一支腳本在擋人那條路徑查得到遠端版本,report 這條查不到,這是既有缺陷,不是這台機器的網路問題。\n\n' "$_unver" >>"$RD/d04.md"
add_pending 'version-guard.sh report 查不到遠端版本,版本落差本輪無證據' '版本落差與重啟閘門' '/jsc-cli:doctor'
fi
fi
else
_part=1
add_failed_source 'jsc-hooks/hooks/version-guard.sh'
printf '**版本比對失敗**:找不到 `version-guard.sh`。\n\n' >>"$RD/d04.md"
fi
# 重啟閘門。一行一支還沒重啟的 CLI;一行都沒有就是都放下了。
if [ -n "$_rg" ]; then
_rc=0
"$_rg" report >"$RD/d04.rg" 2>>"$RD/d04.err" </dev/null || _rc=$?
if [ "$_rc" -ne 0 ]; then
_part=1; D04_RC="$_rc"
add_failed_source "$_rg"
printf '**重啟閘門讀不到**:`restart-gate.sh report` 回 %s。\n' "$_rc" >>"$RD/d04.md"
else
{
printf '| CLI | 重啟閘門 | 升起時間 |\n'
printf '| --- | --- | --- |\n'
} >>"$RD/d04.md"
_up=0
while read -r _cli _rest; do
[ -n "$_cli" ] || continue
_up=$(( _up + 1 ))
_at=$(printf '%s' "$_rest" | sed -n 's/.*at=\([^ ]*\).*/\1/p')
printf '| %s | 已升起 | %s |\n' "$(cell "$_cli")" "$(cell "${_at:--}")" >>"$RD/d04.md"
add_pending "$_cli 的重啟閘門還升著,那一支要重新啟動" '版本落差與重啟閘門' '重開該支 CLI'
done <"$RD/d04.rg"
if [ "$_up" -eq 0 ]; then
printf '| (無) | 未升起 | - |\n' >>"$RD/d04.md"
else
WARN=1
fi
fi
else
_part=1
add_failed_source 'jsc-hooks/hooks/restart-gate.sh'
printf '**重啟閘門讀不到**:找不到 `restart-gate.sh`。\n' >>"$RD/d04.md"
fi
if [ "$_part" -eq 1 ]; then
D04_STATUS=fail
[ -n "$D04_NOTE" ] || D04_NOTE='兩份來源有一份讀不到'
else
D04_STATUS=ok
fi
return 0
}
# --- D-07 SDLC 階段鎖與工作包鎖 ---
d07() {
D07_STATUS=fail; D07_RC=0; D07_NOTE=''
_sess="$JSC_HOME/sessions"; _wp="$JSC_HOME/wp"
{
printf '### SDLC 階段鎖與工作包鎖現況\n\n'
printf '資料出自 `%s` 與 `%s`。只讀狀態,不做判定。\n\n' \
"\$JSC_HOME/sessions/{sid}.stage" "\$JSC_HOME/wp/*.pr"
} >"$RD/d07.md"
_bad=0
if [ -d "$_sess" ] && [ ! -r "$_sess" ]; then _bad=1; add_failed_source "$_sess"; fi
if [ -d "$_wp" ] && [ ! -r "$_wp" ]; then _bad=1; add_failed_source "$_wp"; fi
if [ "$_bad" -eq 1 ]; then
D07_NOTE='狀態目錄讀不到(權限)'
D07_RC=13
printf '**這一項失敗**:%s。這一輪沒有階段鎖與工作包鎖的證據,不是「沒有鎖」。\n' "$D07_NOTE" >>"$RD/d07.md"
add_pending 'SDLC 狀態目錄讀不到,權限要修' 'SDLC 階段鎖與工作包鎖現況' '/jsc-cli:setup'
return 0
fi
{
printf '| 工作階段 | 階段 | 必要標籤 | 上鎖時的模型 | 登記時間 |\n'
printf '| --- | --- | --- | --- | --- |\n'
} >>"$RD/d07.md"
_sn=0
for _f in "$_sess"/*.stage; do
[ -f "$_f" ] && [ -r "$_f" ] || continue
_sn=$(( _sn + 1 ))
_sid=$(basename "$_f" .stage)
_stage=$(cut -f1 "$_f" 2>/dev/null | head -n1)
_req=$(cut -f2 "$_f" 2>/dev/null | head -n1)
_mdl=$(cut -f3 "$_f" 2>/dev/null | head -n1)
printf '| %s | %s | %s | %s | %s |\n' \
"$(cell "$_sid")" "$(cell "${_stage:--}")" "$(cell "${_req:--}")" \
"$(cell "${_mdl:--}")" "$(mtime_of "$_f")" >>"$RD/d07.md"
done
[ "$_sn" -eq 0 ] && printf '| (無) | - | - | - | - |\n' >>"$RD/d07.md"
{
printf '\n| 存取庫 | 工作包 | PR | 上鎖時間 |\n'
printf '| --- | --- | --- | --- |\n'
} >>"$RD/d07.md"
_wn=0
for _f in "$_wp"/*.pr; do
[ -f "$_f" ] && [ -r "$_f" ] || continue
_wn=$(( _wn + 1 ))
_repo=$(sed -n 's/^repo=//p' "$_f" 2>/dev/null | head -n1)
_idx=$(sed -n 's/^index=//p' "$_f" 2>/dev/null | head -n1)
_wpn=$(sed -n 's/^wp=//p' "$_f" 2>/dev/null | head -n1)
_lk=$(sed -n 's/^locked=//p' "$_f" 2>/dev/null | head -n1)
printf '| %s | %s | %s | %s |\n' \
"$(cell "${_repo:--}")" "$(cell "${_wpn:--}")" \
"$(cell "${_repo:-?}#${_idx:-?}")" "$(cell "${_lk:--}")" >>"$RD/d07.md"
done
[ "$_wn" -eq 0 ] && printf '| (無) | - | - | - |\n' >>"$RD/d07.md"
printf '\n`.stage` 沒有存取庫欄位,`.pr` 沒有歸屬工作階段欄位,兩欄一律留「-」,不從分支名或目錄名回推——那兩個都會被改。登記時間取檔案的修改時間。\n' >>"$RD/d07.md"
if [ "$_sn" -eq 0 ] && [ "$_wn" -eq 0 ]; then D07_STATUS=empty; else D07_STATUS=ok; fi
return 0
}
# --- D-09 心跳與閘門狀態自述 ---
d09() {
D09_STATUS=fail; D09_RC=0; D09_NOTE=''
HEARTBEAT_STATE='讀不到'
{
printf '### 心跳與閘門狀態\n\n'
printf '資料出自 `heartbeat.sh report`。這一欄讀到的是**上一輪巡檢**寫的心跳:心跳改由巡檢寫,本輪那一次要等監控頁寫成之後才寫。\n\n'
} >"$RD/d09.md"
if ! _hb=$(find_tool hooks hooks/heartbeat.sh "${JSC_ASSIST_HEARTBEAT_SH:-}"); then
D09_NOTE='找不到 jsc-hooks 的 hooks/heartbeat.sh'
D09_RC=127
add_failed_source 'jsc-hooks/hooks/heartbeat.sh'
printf '**這一項失敗**:%s。心跳判不出來,不代表助理沒在跑,也不代表在跑。\n' "$D09_NOTE" >>"$RD/d09.md"
add_pending '心跳腳本找不到,jsc-hooks 沒裝或版本低於 0.3.7' '心跳與閘門狀態' '/jsc-cli:deploy'
return 0
fi
_rc=0
"$_hb" report >"$RD/d09.line" 2>"$RD/d09.err" </dev/null || _rc=$?
if [ "$_rc" -ne 0 ]; then
D09_RC="$_rc"
D09_NOTE="heartbeat.sh report 回 $_rc"
add_failed_source "$_hb"
printf '**這一項失敗**:%s。訊息:%s\n' "$D09_NOTE" "$(cell "$(cat "$RD/d09.err" 2>/dev/null)")" >>"$RD/d09.md"
add_pending '心跳腳本回非 0,心跳判不出來' '心跳與閘門狀態' '/jsc-assist:assistant status'
return 0
fi
_line=$(cat "$RD/d09.line" 2>/dev/null)
# 欄位以空白分隔,路徑擺最後。拆成一行一欄再取,不用貪婪比對——貪婪會抓到後面同名的鍵。
_get() { printf '%s' "$_line" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n1; }
_st=$(printf '%s' "$_line" | sed -n 's/^state=\([^ ]*\).*/\1/p')
_ts=$(_get ts); _age=$(_get age); _ttl=$(_get ttl)
_pid=$(_get pid); _cli=$(_get cli); _sid=$(_get session)
_file=$(printf '%s' "$_line" | sed -n 's/.*file=//p')
HEARTBEAT_TTL="$_ttl"
case "$_st" in
fresh) HEARTBEAT_STATE='新鮮' ;;
stale) HEARTBEAT_STATE='過期'; WARN=1 ;;
invalid) HEARTBEAT_STATE='心跳檔損壞'; WARN=1 ;;
absent) HEARTBEAT_STATE='不存在'; WARN=1 ;;
*) HEARTBEAT_STATE="判不出(state=${_st:-空值})"; WARN=1 ;;
esac
{
printf '| 項目 | 內容 |\n'
printf '| --- | --- |\n'
printf '| 心跳 | %s |\n' "$(cell "$HEARTBEAT_STATE")"
if [ -n "$_ts" ]; then
printf '| 上次心跳 | %s,距這次巡檢 %s 秒 |\n' "$(fmt_ts "$_ts" | tr -d '\n')" "$(cell "${_age:--}")"
else
printf '| 上次心跳 | - |\n'
fi
printf '| 過期門檻 | %s 秒 |\n' "$(cell "${_ttl:--}")"
printf '| cli | %s |\n' "$(cell "${_cli:--}")"
printf '| session | %s |\n' "$(cell "${_sid:--}")"
printf '| pid | %s。只給要找行程的人參考,不參與判定 |\n' "$(cell "${_pid:--}")"
printf '| 心跳檔 | `%s` |\n' "$(cell "${_file:--}")"
printf '\n心跳的判準只看 `ts` 距現在有沒有超過門檻,不看 pid 存活:五支 CLI 與容器裡的行程互相看不到彼此的 pid。閘門的判定留在 hook,助理只維持心跳,不參與判定。\n'
printf '\n心跳新鮮代表上一輪巡檢跑完了,而且結果記上監控頁了。它不代表那一輪四項都成功——四項的成敗看本節上面的「本輪判定」。\n'
} >>"$RD/d09.md"
case "$_st" in
fresh|stale|invalid|absent) D09_STATUS=ok ;;
*) D09_STATUS=fail; D09_NOTE='report 印不出認得的 state' ;;
esac
[ "$_st" = absent ] && D09_STATUS=empty
return 0
}
# --- 待辦簿筆數(只供目錄頁那一列用)---
count_tasks() {
TASKS_TOTAL=0; TASKS_FAILING=0
_d="$STATE_DIR/tasks"
[ -d "$_d" ] && [ -r "$_d" ] || return 0
for _f in "$_d"/*; do
[ -f "$_f" ] || continue
TASKS_TOTAL=$(( TASKS_TOTAL + 1 ))
_fc=$(sed -n 's/^fail_count=//p' "$_f" 2>/dev/null | head -n1)
case "$_fc" in ''|*[!0-9]*) _fc=0 ;; esac
[ "$_fc" -gt 0 ] && TASKS_FAILING=$(( TASKS_FAILING + 1 ))
done
return 0
}
# --- 組出監控頁那一節 ---
tally() { # $1=項目狀態
case "$1" in
fail) FAIL_COUNT=$(( FAIL_COUNT + 1 )) ;;
*) OK_COUNT=$(( OK_COUNT + 1 )) ;;
esac
}
compose() {
_v='正常'
[ "$WARN" -eq 1 ] && _v='警示'
[ "$FAIL_COUNT" -gt 0 ] && _v='警示'
[ "$OK_COUNT" -eq 0 ] && _v='異常'
VERDICT="$_v"
{
printf '## 巡檢 %s\n\n' "$AT"
printf '| 項目 | 內容 |\n'
printf '| --- | --- |\n'
printf '| 巡檢時間 | %s |\n' "$AT"
printf '| 觸發方式 | %s |\n' "$TRIGGER"
printf '| 本輪判定 | %s |\n' "$VERDICT"
printf '| 本輪項目 | 四項:D-01 使用統計、D-04 版本與重啟閘門、D-07 階段鎖與工作包鎖、D-09 心跳自述。成功 %s 項、失敗 %s 項 |\n' "$OK_COUNT" "$FAIL_COUNT"
printf '| 讀不到的來源 | %s |\n' "$(cell "${FAILED_SOURCES:-無}")"
if [ "$LOCK_BROKEN" -eq 1 ]; then
printf '| 鎖 | 上一輪的鎖逾時,本輪搶回來了。上一輪沒跑完,那一輪不會寫心跳 |\n'
fi
printf '\n'
cat "$RD/d09.md"; printf '\n'
cat "$RD/d01.md"; printf '\n'
printf '### hook 執行期錯誤\n\n'
printf '**這一輪不做這一項。** D-02 hook 錯誤巡檢還沒實作,這一節沒有資料不代表沒有 hook 錯誤。要現在查就跑 `/jsc-hooks:hooks-install` 的錯誤掃描,或直接跑 `jsc-hooks` 的 `tools/scan-hook-errors.sh`。\n\n'
cat "$RD/d04.md"; printf '\n'
cat "$RD/d07.md"; printf '\n'
printf '### 待辦簿到期與逾期\n\n'
printf '**這一輪只數筆數,不逐筆判到期。** 本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。逐筆的到期與逾期判定還沒實作,要看逐筆內容就跑 `/jsc-assist:assistant status`。\n\n' \
"$TASKS_TOTAL" "$TASKS_FAILING"
printf '### 待人處理\n\n'
printf '助理只提醒,不代為執行。這一節列的是本輪要人接手的項目。\n\n'
printf '| 項目 | 來源子節 | 建議入口 |\n'
printf '| --- | --- | --- |\n'
if [ -s "$RD/pend.md" ]; then cat "$RD/pend.md"; else printf '| (無) | - | - |\n'; fi
} >"$RD/section.md"
# 監控頁不存在時要建的整頁內容。基本資料建頁時寫一次,之後不再更動。
{
printf '# 助理巡檢 — %s/%s\n\n' "$HOST" "$USER_NAME"
printf '> 由 `jsc-assist` 維護。這是監控頁 `%s`。\n' "$PAGE"
printf '> 這頁是這台機器的巡檢軌跡:一次巡檢附加一節,節標題帶時間戳,舊的節一個字都不動。\n'
printf '> 附加是刻意的。助理的寫入是背景行為,覆寫錯了沒人在現場,軌跡被抹掉也看不出斷在哪一輪。\n'
printf '> 目錄頁 `MONITOR_CONTENTS` 只更新自己那一列,寫入語意與這頁不同,不要混用。\n\n'
printf '```mermaid\nflowchart LR\n'
printf ' A[巡檢一輪] --> B[收攏四項結果]\n'
printf ' B --> C[附加一節,節標題帶時間戳]\n'
printf ' C --> D[既有的節原樣保留]\n'
printf ' D --> E[回頭更新 MONITOR_CONTENTS 自己那一列]\n'
printf ' E --> F[最後才寫心跳]\n'
printf '```\n\n'
printf '## 本頁基本資料\n\n'
printf '建頁時寫一次,之後不再更動。\n\n'
printf '| 項目 | 內容 |\n'
printf '| --- | --- |\n'
printf '| 主機 | %s |\n' "$(cell "$HOST")"
printf '| 帳號 | %s |\n' "$(cell "$USER_NAME")"
printf '| 雜湊來源 | `%s/%s` |\n' "$(cell "$HOST")" "$(cell "$USER_NAME")"
printf '| 狀態檔根目錄 | `$JSC_HOME/assistant/`(`$JSC_HOME` 未設定就退回 `~/.jsc`) |\n\n'
cat "$RD/section.md"
} >"$RD/newpage.md"
{
printf 'page=%s\n' "$PAGE"
printf 'host=%s\n' "$HOST"
printf 'user=%s\n' "$USER_NAME"
printf 'heartbeat=%s\n' "$HEARTBEAT_STATE"
printf 'last_patrol=%s\n' "$AT"
printf 'tasks_total=%s\n' "$TASKS_TOTAL"
printf 'tasks_failing=%s\n' "$TASKS_FAILING"
printf 'row=| [[%s]] | %s | %s | %s | %s | %s | %s |\n' \
"$PAGE" "$HOST" "$USER_NAME" "$HEARTBEAT_STATE" "$AT" "$TASKS_TOTAL" "$TASKS_FAILING"
} >"$RD/contents.tsv"
return 0
}
# --- 參數解析 ---
CMD="${1:-}"
[ -n "$CMD" ] || usage
shift
case "$CMD" in collect|finish|abort) ;; *) usage ;; esac
OUT=''
while [ "$#" -gt 0 ]; do
case "$1" in
--out) [ "$#" -ge 2 ] || usage; OUT="$2"; shift 2 ;;
--trigger) [ "$#" -ge 2 ] || usage; TRIGGER="$2"; shift 2 ;;
--round) [ "$#" -ge 2 ] || usage; ROUND="$2"; shift 2 ;;
--dry-run) DRYRUN=1; shift ;;
*) usage ;;
esac
done
[ -n "$OUT" ] && RD="$OUT"
case "$CMD" in
collect)
[ -n "$TRIGGER" ] || { if [ "${JSC_CLI:-}" = cron ]; then TRIGGER='排程'; else TRIGGER='手動'; fi; }
case "$TRIGGER" in 排程|事件|手動) ;; *) usage ;; esac
ROUND="$(date +%s)-$$"
HOST=$(hostname 2>/dev/null || uname -n 2>/dev/null || printf 'unknown')
USER_NAME="${USER:-$(id -un 2>/dev/null || printf 'unknown')}"
AT=$(date '+%Y-%m-%d %H:%M')
# 先讀心跳。門檻要先拿到,鎖的逾時才有依據。
mkdir -p "$RD" 2>/dev/null || die 5 "建不出巡檢暫存目錄 $RD。"
: >"$RD/pend.md"
HEARTBEAT_TTL=''
d09
lock_acquire
d01
d04
d07
count_tasks
tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_STATUS"
HASH=''
if _hi=$(find_tool gitea tools/hash-id "${JSC_ASSIST_HASH_ID:-}"); then
HASH=$("$_hi" "$HOST/$USER_NAME" 2>/dev/null) || HASH=''
fi
if [ -n "$HASH" ]; then PAGE="MONITOR_$HASH"; else PAGE=''; fi
compose
printf 'round=%s\n' "$ROUND"
printf 'lock=acquired\n'
printf 'lock_broken=%s\n' "$LOCK_BROKEN"
printf 'hash=%s\n' "$HASH"
printf 'page=%s\n' "$PAGE"
printf 'host=%s\n' "$HOST"
printf 'user=%s\n' "$USER_NAME"
printf 'at=%s\n' "$AT"
printf 'item=D-01 status=%s rc=%s note=%s\n' "$D01_STATUS" "$D01_RC" "$D01_NOTE"
printf 'item=D-04 status=%s rc=%s note=%s\n' "$D04_STATUS" "$D04_RC" "$D04_NOTE"
printf 'item=D-07 status=%s rc=%s note=%s\n' "$D07_STATUS" "$D07_RC" "$D07_NOTE"
printf 'item=D-09 status=%s rc=%s note=%s\n' "$D09_STATUS" "$D09_RC" "$D09_NOTE"
printf 'verdict=%s\n' "$VERDICT"
printf 'failed_sources=%s\n' "${FAILED_SOURCES:-無}"
printf 'tasks_total=%s\n' "$TASKS_TOTAL"
printf 'tasks_failing=%s\n' "$TASKS_FAILING"
printf 'section_file=%s\n' "$RD/section.md"
printf 'newpage_file=%s\n' "$RD/newpage.md"
printf 'contents_file=%s\n' "$RD/contents.tsv"
[ "$OK_COUNT" -eq 0 ] && exit 3
[ "$FAIL_COUNT" -gt 0 ] && exit 1
exit 0 ;;
finish)
[ -n "$ROUND" ] || usage
[ -d "$LOCK" ] || die 4 "本輪的鎖已經不在($LOCK),不寫心跳。鎖多半是逾時被下一輪搶走了。"
_h=$(lock_round)
[ "$_h" = "$ROUND" ] \
|| die 4 "鎖不在本輪手上(鎖的輪次是 ${_h:-空值},本輪是 $ROUND),不寫心跳。上一輪跑太久被搶走了,這一輪的結果不算數。"
_hb=$(find_tool hooks hooks/heartbeat.sh "${JSC_ASSIST_HEARTBEAT_SH:-}") \
|| die 2 '找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳沒有東西可寫。請先安裝 jsc-hooks 0.3.7 以上。'
if [ "$DRYRUN" -eq 1 ]; then
printf 'dryrun=1 heartbeat_cmd=%s write snapshot=%s -> %s lock=%s\n' \
"$_hb" "$RD/usage-next.tsv" "$PREV_SNAP" "$LOCK"
exit 0
fi
_rc=0
"$_hb" write >/dev/null 2>"$RD/finish.err" </dev/null || _rc=$?
[ "$_rc" -eq 0 ] \
|| die 5 "heartbeat.sh write 回 $_rc,心跳沒寫成:$(tr '\n' ' ' <"$RD/finish.err" 2>/dev/null)"
printf 'heartbeat=written\n'
# 快照要等這一輪真的收口才換上。半途失敗就換掉的話,下一輪的「本輪次數」會少算。
if [ -f "$RD/usage-next.tsv" ]; then
cp "$RD/usage-next.tsv" "$PREV_SNAP" 2>/dev/null || die 5 "用量快照換不上:$PREV_SNAP。"
printf 'snapshot=promoted\n'
else
printf 'snapshot=skipped\n'
fi
lock_release
exit 0 ;;
abort)
[ -n "$ROUND" ] || usage
printf 'heartbeat=not-written\n'
lock_release
exit 0 ;;
esac