Files
assist/tools/schedule.sh
T
jiantw83 7343def16e feat(schedule): 助理的系統排程接線
What:
- 新增 tools/schedule.sh,三個子命令:install 安裝、remove 移除、status 查現況。兩筆工作分別是每分鐘的心跳與每十五分鐘的巡檢。
- 助理主體的 start 接上安裝、stop 接上移除,行為清單與 README 跟著更新。

Why:
- 心跳要有人定時寫,不然啟動之後過幾分鐘就自己過期,助理看起來像沒在跑。
- 排程這件事有標準的輸入與輸出,準則要求下放腳本,技能只描述何時呼叫。

How:
- 條目行尾帶固定標記,安裝先濾掉自己的舊條目再追加。絕不覆寫整份排程,別人的條目一行都不碰;寫完回讀驗證,出現兩筆或別人的行數對不上就報錯。
- 裝完檢查排程服務在不在跑。這台機器是 WSL,預設不啟動 cron,裝了條目卻一次都不會執行——不檢查就會宣稱一件沒發生的事。
- 條目一律帶 </dev/null。排程是非互動環境,任何等輸入的東西都會把整筆卡死。
- 輸出寫到 JSC_HOME 底下,不寫進任何專案目錄,免得多出未追蹤檔污染別人的變更盤點。
- 巡檢那一筆預設不裝,要明著指定才會裝。巡檢本體還沒實作,裝了只會每十五分鐘失敗一次;巡檢指令也不猜 CLI,判不出來就停下,猜錯的代價是每十五分鐘跑一支不存在的執行檔。
- stop 先移除排程再清心跳,順序不能反。反過來的話清完下一分鐘排程就補寫一次,stop 等於騙人。

Who:
助理落地的第三塊。這裡留下一個要在巡檢那一輪解掉的問題:心跳目前由排程直接寫,所以心跳新鮮只證明排程活著,不證明助理做了事。往後應該改由巡檢跑完那一輪去寫,心跳才等於工作訊號。限制已寫進技能內文與說明文件。
2026-09-01 14:34:12 +08:00

447 lines
16 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 [heartbeat|patrol|all] [--dry-run] [--cli {代號}] [--patrol-cmd {指令}]
# schedule.sh remove [heartbeat|patrol|all] [--dry-run]
# schedule.sh status [--dry-run]
#
# 工作代號省略時一律是 heartbeat。心跳每 60 秒寫一次,巡檢每 15 分鐘跑一輪。
# 巡檢那一筆要自己指名(`patrol` 或 `all`)才會裝——巡檢本體還沒實作,裝了每一輪都會失敗。
#
# 結束碼:
# 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 跑巡檢
#
# --- 只動自己那一筆 ---
#
# 每一筆條目行尾都帶固定標記「# 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,決定巡檢預設指令
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:-}"
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 指令]
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"; }
spec_of() {
case "$1" in
heartbeat) printf '* * * * *' ;;
patrol) printf '*/15 * * * *' ;;
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:patrol"' ;;
codex) printf "codex exec '\$patrol'" ;;
copilot) printf 'copilot -p "跑一輪助理巡檢"' ;;
antigravity) printf 'agy -p "/jsc-assist:patrol"' ;;
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='heartbeat'
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 ;;
*) usage ;;
esac
done
HEARTBEAT=$(heartbeat_sh) \
|| die 2 '找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳沒有東西可跑。請先安裝 jsc-hooks 0.3.7 以上。'
MECH=$(mechanism) \
|| die 3 "這台機器沒有可用的排程機制(作業系統:$(os_kind),找不到 $CRONTAB_CMD 或 schtasks)。"
# 巡檢指令在這裡就解出來。放進 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
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。' ;;
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,卻查不到這個任務。"
_rc=0
done
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 log=%s\n' "$(service_state)" "$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"
# 先濾掉自己這幾個工作的舊條目,再追加新的。重跑不會疊成兩筆,別人的條目原樣留著。
for _job in $JOBS; 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 others_kept=%s total_lines=%s\n' \
"$_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 'others_kept=%s log=%s\n' "$_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 log=%s\n' "$(service_state)" "$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