feat(patrol): 一輪巡檢落地,心跳改由巡檢跑完才寫
What: - 新增 tools/patrol.sh,三個子命令:collect 收集、finish 收尾、abort 中止。這一輪的巡檢項目是四項純讀取:使用統計、版本落差與重啟閘門、階段鎖與工作包鎖、心跳自述。 - 心跳的寫入從排程移到巡檢收尾。排程只呼叫巡檢,不再直接寫心跳。 - 排程週期由心跳門檻推導,不再寫死。install heartbeat 這個工作代號改為拒絕。 Why: - 排程直接寫心跳的話,心跳新鮮只證明排程活著。巡檢整個壞掉、每輪都失敗,心跳照樣新鮮,閘門照樣放行,而且沒有任何錯誤訊息——這是無聲失效,是最難發現的一種。 - 改成巡檢寫,心跳新鮮才等於上一輪真的跑完了。閘門判的才是工作訊號,不是行程存活訊號。 - 門檻是讀取端的設定,心跳檔裡不存它。所以週期與門檻各寫死一個數字一定會撞:門檻五分鐘、巡檢十五分鐘,心跳永遠是過期的。 How: - 週期取「滿足漏掉一輪還算新鮮、漏掉兩輪才過期」的最大值,並且要能整除一小時。要拉長巡檢週期就調大門檻,週期自動跟著長,兩個數字不會各走各的。 - 心跳只看「這一輪有沒有把結果記下來」,不看四項的成敗。四項有失敗但監控頁寫成了就寫心跳,頁上判定標警示;頁寫不成就中止,一定不寫,讓心跳自己過期。頁每輪都寫失敗卻照樣寫心跳,等於把這次要修掉的缺陷原封不動搬過去。 - 整輪拿一把目錄鎖,搶不到就讓開,不排隊也不並行。並行兩輪會在同一頁附兩節,還會互相蓋掉用量快照。鎖逾時可被下一輪搶走,被搶走的那一輪收尾時對不上就不寫心跳——它沒跑到底,不該蓋章。 - 任一項失敗不影響其餘項目。失敗要在監控頁上看得出來是失敗,不是沒資料;來源是空的則明寫「那是零次,不是故障」。 - 版本盤點照抄腳本原字。抄到查詢失敗就寫查詢失敗,不改寫成相符、也不自己補查遠端版本——查不到是沒有證據,不是版本沒問題。 Who: 助理落地的第四塊。排程那一輪留下的問題在這裡解掉了。
This commit is contained in:
+96
-20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user