Files
assist/tools/schedule.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

523 lines
21 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
# schedule.sh — 助理系統排程的安裝、移除與查現況(供 jsc-assist:assistant 呼叫)。
#
# 用法:
# schedule.sh install [patrol|all] [--dry-run] [--cli {代號}] [--patrol-cmd {指令}] [--period {分鐘}]
# schedule.sh remove [heartbeat|patrol|all] [--dry-run]
# schedule.sh status [--dry-run]
#
# 工作代號省略時一律是 patrol。這一版只裝巡檢那一筆,心跳由巡檢跑完那一輪自己寫。
#
# 結束碼:
# 0 成功:install 條目寫進去也回讀得到、排程服務在跑;remove 移除完成,或本來就沒裝;
# status 印完現況(有沒有裝都算成功,看 installed 欄)
# 1 install 寫進去了,但排程服務沒在跑——條目不會被執行。WSL 預設不啟動 cron,這一碼
# 多半就是它。呼叫端一律照實講「排程裝了但不會執行」,不可以宣稱會定時執行
# 2 找不到 jsc-hooks 的 hooks/heartbeat.sh。心跳門檻查不到,週期算不出來,整支停下
# 3 這台機器沒有可用的排程機制:認不得作業系統,或 crontab 與 schtasks 都找不到
# 4 排程操作失敗:讀不到現有排程(且失敗原因不是「沒有排程」)、寫入或刪除回非 0
# 5 回讀驗證失敗:寫入回 0 但條目不在,或移除回 0 但條目還在,又或其他人的條目數量對不上
# 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 分鐘一輪。週期與門檻兩個數字綁在一起算,不會再各走各的。
#
# --- 只動自己那一筆 ---
#
# 每一筆條目行尾都帶固定標記「# jsc-assist:assistant {工作代號}」,安裝與移除都靠它比對。
# 安裝先用 `grep -vF` 濾掉自己這幾個工作的舊條目,再把新條目追加上去,整份寫回;**絕不**
# 把 crontab 當空的重寫。`crontab -l` 在沒有任何排程時會回非 0,錯誤訊息才分得出是「沒有
# 排程」還是「權限不足」——後者當成空的寫回去,會把使用者整份排程刪光,所以這裡讀不懂
# 錯誤訊息就直接回 4,不猜。
# 寫回之後還會數行數:其他人的條目一行都不能少,少了就回 5。
#
# --- 排程服務沒在跑,等於沒裝 ---
#
# 條目寫進去不代表會被執行。WSL 預設不啟動 cron,要 `sudo service cron start`,而且重開
# WSL 之後要再啟動一次。安裝完一律檢查服務在不在跑,沒跑就回 1 並講清楚,不可以只回報
# 「排程已建立」。
#
# --- 排程是非互動環境 ---
#
# 條目一律接 `</dev/null`,跑的東西讀不到標準輸入,卡不住。heartbeat.sh 本來就不讀 stdin,
# 這一條是給巡檢那一筆用的:助理跳出權限詢問就會整輪卡死,等於排程沒跑。
#
# --- log 放哪裡 ---
#
# $JSC_HOME/assistant/schedule.log(JSC_HOME 未設定時為 ~/.jsc)。刻意放在專案外面:寫進
# 任何存取庫都會多出未追蹤檔,污染別人的變更盤點。
#
# 環境變數:
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc
# JSC_ASSIST_CRONTAB_CMD crontab 執行檔,預設 crontab。crontab 不在標準路徑,或要用
# 假的 crontab 驗濾除邏輯時才設
# JSC_ASSIST_PATROL_CMD 巡檢要跑的指令,優先於 --patrol-cmd 以外的所有推斷
# JSC_CLI 目前是哪一支 CLI,決定巡檢預設指令
# JSC_ASSISTANT_HEARTBEAT_TTL 心跳過期門檻,單位秒。巡檢週期由它算出來
set -u
MARK_PREFIX='# jsc-assist:assistant'
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
STATE_DIR="$JSC_HOME/assistant"
LOG="$STATE_DIR/schedule.log"
CRONTAB_CMD="${JSC_ASSIST_CRONTAB_CMD:-crontab}"
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 [patrol|all] [--dry-run] [--cli 代號] [--patrol-cmd 指令] [--period 分鐘]
schedule.sh remove [heartbeat|patrol|all] [--dry-run]
schedule.sh status [--dry-run]
EOF
exit 6
}
die() { # $1=結束碼 $2=訊息
printf '[jsc][助理排程][ERR]:%s\n' "$2" >&2
exit "$1"
}
note() { printf '[jsc][助理排程]:%s\n' "$1" >&2; }
# 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序比照 jsc-hooks lib.sh 的
# jsc_gitea_sh():先環境變數,再開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。
heartbeat_sh() {
if [ -n "${JSC_HOOKS_HOOKS:-}" ] && [ -f "$JSC_HOOKS_HOOKS/heartbeat.sh" ]; then
printf '%s\n' "$JSC_HOOKS_HOOKS/heartbeat.sh"; return 0
fi
_root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}"
for _c in "$_root/../hooks/hooks/heartbeat.sh" "$_root/../jsc-hooks/hooks/heartbeat.sh"; do
[ -f "$_c" ] && { (CDPATH= cd -- "$(dirname -- "$_c")" && printf '%s/heartbeat.sh\n' "$(pwd)"); return 0; }
done
_c=$(ls -d "$_root"/../../jsc-hooks/*/hooks/heartbeat.sh \
"$_root"/../../hooks/*/hooks/heartbeat.sh \
"$HOME"/.claude/plugins/cache/*/jsc-hooks/*/hooks/heartbeat.sh 2>/dev/null \
| sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
_c=$(command -v heartbeat.sh 2>/dev/null || true)
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
os_kind() {
case "$(uname -s 2>/dev/null)" in
Linux) printf 'linux' ;;
Darwin) printf 'darwin' ;;
MINGW*|MSYS*|CYGWIN*|Windows_NT) printf 'windows' ;;
*) printf 'unknown' ;;
esac
}
# 這台機器要用哪一種排程機制。Linux、WSL 與 macOS 都用 crontab;macOS 若要改用 launchd
# 請自行改寫,本腳本不代為產生 plist。
mechanism() {
case "$(os_kind)" in
windows) command -v schtasks >/dev/null 2>&1 && { printf 'schtasks'; return 0; } ;;
linux|darwin) command -v "$CRONTAB_CMD" >/dev/null 2>&1 && { printf 'crontab'; return 0; } ;;
esac
return 1
}
# 排程服務在不在跑。回 running、stopped 或 unknown。
service_state() {
case "$(os_kind)" in
darwin)
# macOS 的 cron 由 launchd 隨用隨起,查不到行程不代表沒在跑,一律回 unknown。
printf 'unknown'; return 0 ;;
windows)
printf 'unknown'; return 0 ;;
esac
if command -v pgrep >/dev/null 2>&1; then
if pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1; then
printf 'running'
else
printf 'stopped'
fi
return 0
fi
if command -v ps >/dev/null 2>&1; then
if ps -e 2>/dev/null | grep -qE '[ /](cron|crond)$'; then printf 'running'; else printf 'stopped'; fi
return 0
fi
printf 'unknown'
}
marker_of() { printf '%s %s' "$MARK_PREFIX" "$1"; }
# 數行數。用 grep -c '' 不用 wc -l:最後一行沒有換行時 wc -l 會少數一行。
# grep 數到 0 會回非 0,數字照樣印得出來,所以只在完全沒有輸出時才補 0。
count_lines() { _n=$(grep -c '' "$1" 2>/dev/null); [ -n "$_n" ] || _n=0; printf '%s' "$_n"; }
# 數管線進來的行數,語意同 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 '*/%s * * * *' "$PERIOD" ;;
esac
}
# 巡檢要跑的指令。優先序:--patrol-cmd 或 JSC_ASSIST_PATROL_CMD > 依 CLI 代號推斷。
# 判不出 CLI 就回非 0,由主流程回 6,不猜——猜錯會每 15 分鐘跑一支不存在的執行檔。
patrol_command() {
[ -n "$PATROL_CMD" ] && { printf '%s' "$PATROL_CMD"; return 0; }
_cli="$CLI"
[ -n "$_cli" ] || _cli="${JSC_CLI:-}"
[ -n "$_cli" ] || { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && _cli=claude; }
case "$_cli" in
claude) printf 'claude -p "/jsc-assist:assistant 跑一輪巡檢"' ;;
codex) printf "codex exec '\$assistant 跑一輪巡檢'" ;;
copilot) printf 'copilot -p "跑一輪助理巡檢"' ;;
antigravity) printf 'agy -p "/jsc-assist:assistant 跑一輪巡檢"' ;;
kiro) printf 'kiro-cli -p "跑一輪助理巡檢"' ;;
*) return 1 ;;
esac
}
# 組出一筆 crontab 條目。`%` 在 crontab 是換行符號,一律跳脫。
cron_entry() { # $1=工作代號
_spec=$(spec_of "$1")
case "$1" in
heartbeat) _cmd="JSC_CLI=cron JSC_SESSION_ID=schedule '$HEARTBEAT' write" ;;
patrol) _cmd="JSC_CLI=cron JSC_SESSION_ID=schedule $PATROL_RESOLVED" ;;
esac
printf '%s %s </dev/null >>%s 2>&1 %s' \
"$_spec" "$_cmd" "'$LOG'" "$(marker_of "$1")" | sed 's/%/\\%/g'
}
task_name() { printf '%s-%s' "$TASK_PREFIX" "$1"; }
# 讀現有的 crontab 到 $1。沒有任何排程時 crontab -l 會回非 0,那算正常;讀不懂的錯誤
# 一律回 4,不當成空的——當成空的寫回去會把使用者整份排程刪光。
cron_read() { # $1=輸出檔
_err="$TMPD/err"
if "$CRONTAB_CMD" -l >"$1" 2>"$_err"; then return 0; fi
if [ ! -s "$_err" ] || grep -qiE 'no crontab|沒有 crontab' "$_err"; then
: >"$1"; return 0
fi
die 4 "讀不到現有排程,原因不是「沒有排程」:$(tr '\n' ' ' <"$_err")"
}
cron_lines_for() { # $1=crontab 檔 $2=工作代號;印出該工作的條目
grep -F "$(marker_of "$2")" "$1" 2>/dev/null || true
}
# --- 參數解析 ---
CMD="${1:-}"
[ -n "$CMD" ] || usage
shift
case "$CMD" in install|remove|status) ;; *) usage ;; esac
JOBS='patrol'
if [ "$#" -gt 0 ]; then
case "$1" in
heartbeat) JOBS='heartbeat'; shift ;;
patrol) JOBS='patrol'; shift ;;
all) JOBS='heartbeat patrol'; shift ;;
esac
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--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 以上。'
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=''
case "$CMD:$JOBS" in
install:*patrol*)
PATROL_RESOLVED=$(patrol_command) \
|| die 6 '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。' ;;
esac
TMPD=$(mktemp -d) || die 4 '建不出暫存目錄。'
trap 'rm -rf "$TMPD"' EXIT
# --- schtasks(Windows)---
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
patrol) _mo="$PERIOD"; _run="$PATROL_RESOLVED" ;;
*) continue ;;
esac
_tr="cmd /c $_run <NUL >> \"$LOG\" 2>&1"
if [ "$DRYRUN" -eq 1 ]; then
printf 'dryrun=schtasks job=%s task=%s interval=%s cmd=%s\n' "$_job" "$_tn" "$_mo" "$_tr"
continue
fi
# /F 只覆蓋同名任務,也就是本腳本自己那一筆,不影響別人的排程。
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"
}
schtasks_remove() {
for _job in $JOBS; do
_tn=$(task_name "$_job")
if [ "$DRYRUN" -eq 1 ]; then
printf 'dryrun=schtasks job=%s task=%s action=delete\n' "$_job" "$_tn"
continue
fi
if ! schtasks /Query /TN "$_tn" >/dev/null 2>&1; then
note "$_job 本來就沒有排程,不用移除。"
continue
fi
schtasks /Delete /TN "$_tn" /F >/dev/null 2>&1 || die 4 "schtasks 刪除 $_tn 失敗。"
schtasks /Query /TN "$_tn" >/dev/null 2>&1 && die 5 "schtasks 刪除 $_tn 回 0,任務卻還在。"
done
return 0
}
schtasks_status() {
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
printf 'job=%s installed=yes task=%s\n' "$_job" "$_tn"
else
printf 'job=%s installed=no task=%s\n' "$_job" "$_tn"
fi
done
return 0
}
# --- crontab(Linux、WSL、macOS)---
crontab_install() {
_cur="$TMPD/cur"; _new="$TMPD/new"
cron_read "$_cur"
cp "$_cur" "$_new"
# 先濾掉自己這幾個工作的舊條目,再追加新的。重跑不會疊成兩筆,別人的條目原樣留著。
# 舊版的 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
_others=$(count_lines "$_new")
for _job in $JOBS; do
cron_entry "$_job" >>"$_new"
printf '\n' >>"$_new"
done
if [ "$DRYRUN" -eq 1 ]; then
for _job in $JOBS; do
printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job")"
done
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
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
"$CRONTAB_CMD" "$_new" >/dev/null 2>"$TMPD/err" \
|| die 4 "crontab 寫入失敗:$(tr '\n' ' ' <"$TMPD/err")"
_chk="$TMPD/chk"; cron_read "$_chk"
for _job in $JOBS; do
[ -n "$(cron_lines_for "$_chk" "$_job")" ] \
|| die 5 "crontab 寫入回 0,卻讀不到 $_job 的條目。"
[ "$(cron_lines_for "$_chk" "$_job" | count_stdin)" -eq 1 ] \
|| die 5 "$_job 的條目不只一筆,排程會重複執行。"
done
_kept=$(grep -vF "$MARK_PREFIX" "$_chk" 2>/dev/null | count_stdin)
[ "$_kept" -eq "$_others" ] \
|| die 5 "別人的排程條目從 $_others 筆變成 $_kept 筆,寫回不完整。"
for _job in $JOBS; do
printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job")"
done
printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s log=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_kept" "$LOG"
return 0
}
crontab_remove() {
_cur="$TMPD/cur"; _new="$TMPD/new"
cron_read "$_cur"
_hit=0
cp "$_cur" "$_new"
for _job in $JOBS; do
if [ -n "$(cron_lines_for "$_new" "$_job")" ]; then
_hit=$((_hit + 1))
else
note "$_job 本來就沒有排程,不用移除。"
continue
fi
grep -vF "$(marker_of "$_job")" "$_new" >"$TMPD/f" 2>/dev/null || true
mv "$TMPD/f" "$_new"
done
if [ "$_hit" -eq 0 ]; then
printf 'removed=0 others_kept=%s\n' "$(count_lines "$_cur")"
return 0
fi
if [ "$DRYRUN" -eq 1 ]; then
printf 'dryrun=crontab action=remove jobs=%s removed=%s\n' "$JOBS" "$_hit"
printf -- '--- 寫回後的 crontab ---\n'
cat "$_new"
return 0
fi
"$CRONTAB_CMD" "$_new" >/dev/null 2>"$TMPD/err" \
|| die 4 "crontab 寫入失敗:$(tr '\n' ' ' <"$TMPD/err")"
_chk="$TMPD/chk"; cron_read "$_chk"
for _job in $JOBS; do
[ -z "$(cron_lines_for "$_chk" "$_job")" ] \
|| die 5 "crontab 刪除回 0,$_job 的條目卻還在。"
done
_before=$(grep -vF "$MARK_PREFIX" "$_cur" 2>/dev/null | count_stdin)
_after=$(grep -vF "$MARK_PREFIX" "$_chk" 2>/dev/null | count_stdin)
[ "$_before" -eq "$_after" ] \
|| die 5 "別人的排程條目從 $_before 筆變成 $_after 筆,移除動到了不該動的東西。"
printf 'removed=%s others_kept=%s\n' "$_hit" "$_after"
return 0
}
crontab_status() {
_cur="$TMPD/cur"; cron_read "$_cur"
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
printf 'job=%s installed=yes entry=%s\n' "$_job" "$_line"
else
printf 'job=%s installed=no entry=\n' "$_job"
fi
done
return 0
}
# --- 主流程 ---
case "$CMD" in
install)
case "$MECH" in
crontab) crontab_install ;;
schtasks) schtasks_install ;;
esac
[ "$DRYRUN" -eq 1 ] && exit 0
_svc=$(service_state)
if [ "$_svc" = stopped ]; then
printf 'service=stopped\n'
die 1 '排程條目寫進去了,但 cron 服務沒在跑,條目一次都不會被執行。WSL 預設不啟動 cron:請跑 `sudo service cron start`,而且重開 WSL 之後要再啟動一次。'
fi
printf 'service=%s\n' "$_svc"
[ "$_svc" = unknown ] && note '判不出排程服務在不在跑,請自行確認條目真的會被執行。'
exit 0 ;;
remove)
case "$MECH" in
crontab) crontab_remove ;;
schtasks) schtasks_remove ;;
esac
exit 0 ;;
status)
case "$MECH" in
crontab) crontab_status ;;
schtasks) schtasks_status ;;
esac
exit 0 ;;
esac