feat(patrol): 一輪巡檢落地,心跳改由巡檢跑完才寫

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

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

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

Who:
助理落地的第四塊。排程那一輪留下的問題在這裡解掉了。
This commit is contained in:
2026-09-01 15:06:10 +08:00
parent 7343def16e
commit 2b7176a644
7 changed files with 957 additions and 73 deletions
+739
View File
@@ -0,0 +1,739 @@
#!/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
+96 -20
View File
@@ -2,23 +2,43 @@
# schedule.sh — 助理系統排程的安裝、移除與查現況(供 jsc-assist:assistant 呼叫)。
#
# 用法:
# schedule.sh install [heartbeat|patrol|all] [--dry-run] [--cli {代號}] [--patrol-cmd {指令}]
# schedule.sh install [patrol|all] [--dry-run] [--cli {代號}] [--patrol-cmd {指令}] [--period {分鐘}]
# schedule.sh remove [heartbeat|patrol|all] [--dry-run]
# schedule.sh status [--dry-run]
#
# 工作代號省略時一律是 heartbeat。心跳每 60 秒寫一次,巡檢每 15 分鐘跑一輪。
# 巡檢那一筆要自己指名(`patrol` 或 `all`)才會裝——巡檢本體還沒實作,裝了每一輪都會失敗。
# 工作代號省略時一律是 patrol。這一版只裝巡檢那一筆,心跳由巡檢跑完那一輪自己寫。
#
# 結束碼:
# 0 成功:install 條目寫進去也回讀得到、排程服務在跑;remove 移除完成,或本來就沒裝;
# status 印完現況(有沒有裝都算成功,看 installed 欄)
# 1 install 寫進去了,但排程服務沒在跑——條目不會被執行。WSL 預設不啟動 cron,這一碼
# 多半就是它。呼叫端一律照實講「排程裝了但不會執行」,不可以宣稱會定時執行
# 2 找不到 jsc-hooks 的 hooks/heartbeat.sh。心跳沒有東西可跑,整支停下
# 2 找不到 jsc-hooks 的 hooks/heartbeat.sh。心跳門檻查不到,週期算不出來,整支停下
# 3 這台機器沒有可用的排程機制:認不得作業系統,或 crontab 與 schtasks 都找不到
# 4 排程操作失敗:讀不到現有排程(且失敗原因不是「沒有排程」)、寫入或刪除回非 0
# 5 回讀驗證失敗:寫入回 0 但條目不在,或移除回 0 但條目還在,又或其他人的條目數量對不上
# 6 用法錯誤:不認得的子命令、不認得的工作代號、缺參數,或判不出要用哪一支 CLI 跑巡檢
# 6 用法錯誤:不認得的子命令、不認得的工作代號、缺參數、判不出要用哪一支 CLI 跑巡檢,
# 或 --period 給的週期塞不進心跳的過期門檻
#
# --- 排程只叫巡檢,心跳由巡檢寫 ---
#
# 舊版排程每分鐘直接呼叫 heartbeat.sh write。那樣心跳新鮮只證明 cron 活著:巡檢整個壞掉、
# 一輪都沒跑成,心跳照樣新鮮,靠心跳判定的閘門照樣放行,沒有任何訊號。
# 現在排程只叫巡檢,巡檢把結果寫上監控頁之後才寫那一次心跳。心跳新鮮於是等於「上一輪巡檢
# 真的做完了,而且結果記下來了」。
# 所以 `install heartbeat` 直接回 6,不給裝:裝了就是有第二個寫心跳的人,心跳的意思立刻回到
# 舊版。舊機器上留著的那一筆 heartbeat 條目,`install patrol` 會順手清掉,並在輸出印
# legacy_removed=1——不清的話它每分鐘照樣寫,這次改動等於白做。
# `remove` 與 `status` 仍然認得 heartbeat 這個代號,就是為了清掉與看得到那一筆舊條目。
#
# --- 巡檢週期怎麼定 ---
#
# 心跳的更新頻率現在等於巡檢週期,所以週期一定要塞得進心跳的過期門檻,不然心跳永遠是過期。
# 門檻不寫死,改讀 `heartbeat.sh report` 的 `ttl` 欄——那是這台機器實際生效的值。
# 週期取「漏掉一輪還算新鮮、漏掉兩輪就過期」:2 × 週期 × 60 < 門檻,再往下取一個能整除一小時
# 的分鐘數,排程間隔才規律。門檻預設 300 秒時算出來是每 2 分鐘一輪。
# 要拉長巡檢週期就先把門檻調大(`JSC_ASSISTANT_HEARTBEAT_TTL`,單位秒),週期會跟著變長:
# 門檻 1800 秒算出每 12 分鐘一輪。週期與門檻兩個數字綁在一起算,不會再各走各的。
#
# --- 只動自己那一筆 ---
#
@@ -51,6 +71,7 @@
# 假的 crontab 驗濾除邏輯時才設
# JSC_ASSIST_PATROL_CMD 巡檢要跑的指令,優先於 --patrol-cmd 以外的所有推斷
# JSC_CLI 目前是哪一支 CLI,決定巡檢預設指令
# JSC_ASSISTANT_HEARTBEAT_TTL 心跳過期門檻,單位秒。巡檢週期由它算出來
set -u
MARK_PREFIX='# jsc-assist:assistant'
@@ -63,13 +84,14 @@ TASK_PREFIX='jsc-assist-assistant'
DRYRUN=0
CLI=''
PATROL_CMD="${JSC_ASSIST_PATROL_CMD:-}"
PERIOD=''
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
SCRIPT_DIR="${SCRIPT_DIR:-.}"
usage() {
cat >&2 <<'EOF'
usage: schedule.sh install [heartbeat|patrol|all] [--dry-run] [--cli 代號] [--patrol-cmd 指令]
usage: schedule.sh install [patrol|all] [--dry-run] [--cli 代號] [--patrol-cmd 指令] [--period 分鐘]
schedule.sh remove [heartbeat|patrol|all] [--dry-run]
schedule.sh status [--dry-run]
EOF
@@ -155,10 +177,31 @@ count_lines() { _n=$(grep -c '' "$1" 2>/dev/null); [ -n "$_n" ] || _n=0; printf
# 數管線進來的行數,語意同 count_lines。
count_stdin() { _n=$(grep -c '' 2>/dev/null); [ -n "$_n" ] || _n=0; printf '%s' "$_n"; }
# 這台機器實際生效的心跳過期門檻。取自 heartbeat.sh report 的 ttl 欄,不自己重算:
# 門檻的唯一來源是那支腳本,兩邊各算一次就會漂移。讀不到就退回 300。
heartbeat_ttl() {
_t=$("$HEARTBEAT" report </dev/null 2>/dev/null | tr ' ' '\n' | sed -n 's/^ttl=//p' | head -n1)
case "$_t" in ''|*[!0-9]*) _t=300 ;; esac
[ "$_t" -gt 0 ] || _t=300
printf '%s' "$_t"
}
# 由門檻算出巡檢週期,單位分鐘。條件是 2 × 週期 × 60 < 門檻:漏掉一輪還算新鮮,漏掉兩輪
# 才過期。再往下取一個能整除一小時的分鐘數,`*/N` 的間隔才規律。
period_for_ttl() { # $1=門檻秒數
_max=$(( ($1 - 1) / 120 ))
[ "$_max" -lt 1 ] && _max=1
_p=1
for _d in 1 2 3 4 5 6 10 12 15 20 30 60; do
[ "$_d" -le "$_max" ] && _p="$_d"
done
printf '%s' "$_p"
}
spec_of() {
case "$1" in
heartbeat) printf '* * * * *' ;;
patrol) printf '*/15 * * * *' ;;
patrol) printf '*/%s * * * *' "$PERIOD" ;;
esac
}
@@ -170,10 +213,10 @@ patrol_command() {
[ -n "$_cli" ] || _cli="${JSC_CLI:-}"
[ -n "$_cli" ] || { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && _cli=claude; }
case "$_cli" in
claude) printf 'claude -p "/jsc-assist:patrol"' ;;
codex) printf "codex exec '\$patrol'" ;;
claude) printf 'claude -p "/jsc-assist:assistant 跑一輪巡檢"' ;;
codex) printf "codex exec '\$assistant 跑一輪巡檢'" ;;
copilot) printf 'copilot -p "跑一輪助理巡檢"' ;;
antigravity) printf 'agy -p "/jsc-assist:patrol"' ;;
antigravity) printf 'agy -p "/jsc-assist:assistant 跑一輪巡檢"' ;;
kiro) printf 'kiro-cli -p "跑一輪助理巡檢"' ;;
*) return 1 ;;
esac
@@ -214,7 +257,7 @@ CMD="${1:-}"
shift
case "$CMD" in install|remove|status) ;; *) usage ;; esac
JOBS='heartbeat'
JOBS='patrol'
if [ "$#" -gt 0 ]; then
case "$1" in
heartbeat) JOBS='heartbeat'; shift ;;
@@ -228,15 +271,36 @@ while [ "$#" -gt 0 ]; do
--dry-run) DRYRUN=1; shift ;;
--cli) [ "$#" -ge 2 ] || usage; CLI="$2"; shift 2 ;;
--patrol-cmd) [ "$#" -ge 2 ] || usage; PATROL_CMD="$2"; shift 2 ;;
--period) [ "$#" -ge 2 ] || usage; PERIOD="$2"; shift 2 ;;
*) usage ;;
esac
done
# 心跳那一筆不給裝。理由見檔頭「排程只叫巡檢,心跳由巡檢寫」:多一個寫心跳的人,心跳的
# 意思立刻退回舊版。install all 只裝巡檢那一筆。
case "$CMD:$JOBS" in
install:heartbeat)
die 6 '心跳那一筆不裝了。心跳改由巡檢跑完那一輪自己寫,排程只叫巡檢:請跑 `schedule.sh install patrol`。舊機器上留著的 heartbeat 條目,install patrol 會順手清掉。' ;;
install:*heartbeat*) JOBS='patrol' ;;
esac
HEARTBEAT=$(heartbeat_sh) \
|| die 2 '找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳沒有東西可跑。請先安裝 jsc-hooks 0.3.7 以上。'
|| die 2 '找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳的過期門檻查不到,巡檢週期算不出來。請先安裝 jsc-hooks 0.3.7 以上。'
MECH=$(mechanism) \
|| die 3 "這台機器沒有可用的排程機制(作業系統:$(os_kind),找不到 $CRONTAB_CMD 或 schtasks)。"
# 巡檢週期與心跳門檻綁在一起算。--period 給的值一樣要通過同一條式子,否則裝出來的排程
# 會讓心跳永遠過期,而且沒有人看得出來是週期設錯。
TTL=$(heartbeat_ttl)
if [ -n "$PERIOD" ]; then
case "$PERIOD" in ''|*[!0-9]*) usage ;; esac
[ "$PERIOD" -ge 1 ] || usage
[ $(( 2 * PERIOD * 60 )) -lt "$TTL" ] \
|| die 6 "巡檢週期 $PERIOD 分鐘塞不進心跳的過期門檻 $TTL 秒(要 2 × 週期 × 60 < 門檻)。心跳現在由巡檢寫,週期比門檻長的話心跳永遠是過期。請改短週期,或先把 JSC_ASSISTANT_HEARTBEAT_TTL 調大。"
else
PERIOD=$(period_for_ttl "$TTL")
fi
# 巡檢指令在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡跑,
# 判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。
PATROL_RESOLVED=''
@@ -253,11 +317,16 @@ trap 'rm -rf "$TMPD"' EXIT
schtasks_install() {
_rc=0
# 舊版的 heartbeat 任務一併刪掉,理由同 crontab 那一邊。刪不掉不算失敗,往下照裝。
_legacy=0
if schtasks /Query /TN "$(task_name heartbeat)" >/dev/null 2>&1; then
schtasks /Delete /TN "$(task_name heartbeat)" /F >/dev/null 2>&1 && _legacy=1
fi
for _job in $JOBS; do
_tn=$(task_name "$_job")
case "$_job" in
heartbeat) _mo=1; _run="sh \"$HEARTBEAT\" write" ;;
patrol) _mo=15; _run=$(patrol_command) || die 6 '判不出要用哪一支 CLI 跑巡檢,請帶 --cli 或 --patrol-cmd。' ;;
patrol) _mo="$PERIOD"; _run="$PATROL_RESOLVED" ;;
*) continue ;;
esac
_tr="cmd /c $_run <NUL >> \"$LOG\" 2>&1"
if [ "$DRYRUN" -eq 1 ]; then
@@ -268,8 +337,10 @@ schtasks_install() {
schtasks /Create /TN "$_tn" /SC MINUTE /MO "$_mo" /F /TR "$_tr" >/dev/null 2>&1 \
|| die 4 "schtasks 建立 $_tn 失敗。"
schtasks /Query /TN "$_tn" >/dev/null 2>&1 || die 5 "schtasks 建立 $_tn 回 0,卻查不到這個任務。"
printf 'installed=%s task=%s\n' "$_job" "$_tn"
_rc=0
done
printf 'ttl=%s period=%s legacy_removed=%s log=%s\n' "$TTL" "$PERIOD" "$_legacy" "$LOG"
return "$_rc"
}
@@ -291,7 +362,8 @@ schtasks_remove() {
}
schtasks_status() {
printf 'mechanism=schtasks service=%s log=%s\n' "$(service_state)" "$LOG"
printf 'mechanism=schtasks service=%s ttl=%s period=%s log=%s\n' \
"$(service_state)" "$TTL" "$PERIOD" "$LOG"
for _job in heartbeat patrol; do
_tn=$(task_name "$_job")
if schtasks /Query /TN "$_tn" >/dev/null 2>&1; then
@@ -311,7 +383,9 @@ crontab_install() {
cp "$_cur" "$_new"
# 先濾掉自己這幾個工作的舊條目,再追加新的。重跑不會疊成兩筆,別人的條目原樣留著。
for _job in $JOBS; do
# 舊版的 heartbeat 條目一併清掉:留著它每分鐘照樣寫心跳,心跳就退回「只證明 cron 活著」。
_legacy=$(cron_lines_for "$_new" heartbeat | count_stdin)
for _job in $JOBS heartbeat; do
grep -vF "$(marker_of "$_job")" "$_new" >"$TMPD/f" 2>/dev/null || true
mv "$TMPD/f" "$_new"
done
@@ -325,8 +399,8 @@ crontab_install() {
for _job in $JOBS; do
printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job")"
done
printf 'dryrun=crontab action=write others_kept=%s total_lines=%s\n' \
"$_others" "$(count_lines "$_new")"
printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")"
printf -- '--- 寫回後的 crontab ---\n'
cat "$_new"
return 0
@@ -350,7 +424,8 @@ crontab_install() {
for _job in $JOBS; do
printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job")"
done
printf 'others_kept=%s log=%s\n' "$_kept" "$LOG"
printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s log=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_kept" "$LOG"
return 0
}
@@ -402,7 +477,8 @@ crontab_remove() {
crontab_status() {
_cur="$TMPD/cur"; cron_read "$_cur"
printf 'mechanism=crontab service=%s log=%s\n' "$(service_state)" "$LOG"
printf 'mechanism=crontab service=%s ttl=%s period=%s log=%s\n' \
"$(service_state)" "$TTL" "$PERIOD" "$LOG"
for _job in heartbeat patrol; do
_line=$(cron_lines_for "$_cur" "$_job")
if [ -n "$_line" ]; then