Files
assist/tools/run-due.sh
T
jiantw83andClaude Opus 5 bccdbbbd24 feat(執行): 到期的內建檢查項真的跑一遍,成敗回寫待辦簿
待辦簿蓋好之後一直沒有人執行它。十六筆內建項、十五筆到期,而 last_run 與
fail_count 從建立到現在一次都沒動過——巡檢每輪把它們印出來,如此而已。

這一支只跑動作是一行指令、而且帶著 spec_key 的那幾筆。技能名那一種不跑:
叫用一整支技能會寫頁、開 PR、改檔案,而這一支跑在沒有人看的那一輪裡;唯讀
盤點指令失敗最多回一個非零碼,一支技能中途失敗可能留下寫到一半的頁。兩種
代價不同,就該分兩次判、分兩次接。使用者親手登錄的那幾筆也不跑,那幾筆沒有
經過種入那一支的檢核,動作欄想寫什麼都行。

代入點只展開得了 CLI 那一個。存取庫掃描還沒做出來,所以帶那個代入點的一律
印一行跳過,而且**執行紀錄與失敗次數一個字都不動**:代不出目標不是那一筆做
錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬,而那個計數存在的
理由正是指出「有一筆壞掉的項目每輪重試而沒人知道」。把「還沒接上」記成
「壞掉」,等於用假的壞掉把真的壞掉蓋掉。

執行前再驗一次指令形狀,不信任待辦檔的內容——那是純文字,種入之後可能被改。
擋掉金錢符號、反引號、波浪號、分號、管線與連接符號,路徑一定要落在這一輪的
根目錄底下,代完之後不得留大括號。

權限閘門看不到這一支裡面跑了什麼,檔頭把理由寫明了:閘門是可信入口的白名單、
不是沙箱,清單上每一支腳本本來就做得了它該做的事。防線改由「只跑經過審查的
清單種出來的項目」加「執行前再驗一次形狀」擋。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:58:16 +08:00

328 lines
15 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
# run-due.sh — 把到期的內建檢查項真的跑一遍,成敗回寫待辦簿。
#
# 用法:
# run-due.sh run [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}] [--dry-run]
# run-due.sh plan [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}]
#
# run 逐筆執行並回寫待辦簿。--dry-run 只印要跑什麼、不執行也不回寫
# plan 等同 run --dry-run,另取一個名字是為了讓唯讀那一路在指令列上看得出來
#
# 結束碼:
# 0 這一輪跑完了。零筆到期、每一筆都代不出目標,都算跑完
# 1 至少一支指令回非零。那一筆已經記成失敗、失敗次數加一,其餘各筆照跑
# 2 到期清單讀不到或欄位對不上,**一筆都沒跑**。判到期那一支沒跑過,或它的輸出換了格式
# 4 至少一筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆
# 5 檔案系統失敗:暫存檔寫不進去
# 6 用法錯誤:不認得的子命令或選項、選項缺值、--root 不是絕對路徑
# 同時命中好幾碼時,回報順序是 5、2、4、1:前面的蓋掉後面的。4 排在 1 前面是因為
# 「跑了但沒記到」會讓同一件事每輪重跑,比「跑了而且失敗」更需要人知道。
#
# --- 這一支只跑指令型,不叫技能也不送提醒 ---
#
# 待辦簿的動作欄有三種值:一行指令、技能名、remind。這一支只跑第一種。
# 技能名那一種不跑的理由:叫用一整支技能的代價與風險都大得多——它會寫 wiki、開 PR、改檔案,
# 而這一支跑在沒有人看的那一輪裡。唯讀盤點指令失敗最多就是回一個非零碼,那一筆記一次失敗;
# 一支技能中途失敗可能留下寫到一半的頁或開錯的 PR。兩種代價不同,就該分兩次判、分兩次接。
# remind 那一種不跑的理由更直接:它本來就沒有東西可跑,提醒怎麼送到前景是另一件待辦。
#
# --- 只跑內建項,使用者交辦的一律不碰 ---
#
# 只有 spec_key 非空的那幾筆會被執行,也就是依委派清單種入的內建項。
# 理由是那幾筆的指令經過種入那一支的檢核:路徑一定是 {root}/jsc-{domain}/ 開頭、不含金錢符號
# 與波浪號、代入點只認得三個、腳本一定在這台機器上。使用者親手登錄的那幾筆沒有經過那道關,
# 動作欄想寫什麼都行,在無人值守那一輪把它送進殼是另一回事,要另外判。
# 執行這一刻再驗一次同樣那幾項,不因為種入時驗過就省掉:待辦檔是純文字,中間可能被改。
#
# --- 權限閘門與這一支的關係,講白 ---
#
# 這一支自己執行那些指令,所以權限層只看得到 run-due.sh 這一條指令,看不到裡面跑了什麼。
# 那不是繞過閘門,是閘門本來就不是沙箱:它是一份「可信入口」的白名單,而清單上的每一支腳本
# 本來就做得了它該做的事——巡檢那一支會寫 wiki 頁,部署那一支會改整台機器的外掛。
# 防線改由三層擋:一、只跑 spec_key 非空的內建項,那幾筆的來源是版本控管、要走 PR 的委派清單;
# 二、執行前再驗一次指令形狀,不信任待辦檔的內容;三、代不出來的目標一律不跑。
# 這一段寫在這裡是因為下一個維護的人一定會問,而「為什麼不會被擋」跟「為什麼不需要被擋」
# 是兩個不同的答案,後者才是對的那一個。
#
# --- 代不出目標不算失敗 ---
#
# {cli} 由偵測到的 CLI 代號代入,{repo} 由掃到的存取庫工作目錄代入。後者的掃描還沒做出來,
# 所以帶 {repo} 的那幾筆這一輪代不出目標。處置是印一行 held= 就跳過,**不動那一筆的
# last_run,也不加失敗次數**。
# 那一筆沒有做錯任何事:代不出目標是這一支還缺一塊,記成失敗會讓一個沒有人修得動的計數
# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。
# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。
set -u
usage() {
echo 'usage: run-due.sh {run|plan} [--rows 檔案] [--root 絕對路徑] [--now epoch] [--dry-run]' >&2
exit 6
}
die() { _c=$1; shift; printf '[jsc][助理執行][ERR]:%s\n' "$*" >&2; exit "$_c"; }
note() { printf '[jsc][助理執行]:%s\n' "$*" >&2; }
warn() { printf '[jsc][助理執行][WARN]:%s\n' "$*" >&2; }
[ "$#" -ge 1 ] || usage
MODE=$1; shift
case "$MODE" in
run) DRYRUN=0 ;;
plan) DRYRUN=1 ;;
*) usage ;;
esac
ROWS=''
OPT_ROOT=''
NOW=''
while [ "$#" -gt 0 ]; do
case "$1" in
--rows) [ "$#" -ge 2 ] || usage; ROWS=$2; shift 2 ;;
--root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;;
--now) [ "$#" -ge 2 ] || usage; NOW=$2; shift 2 ;;
--dry-run) DRYRUN=1; shift ;;
*) usage ;;
esac
done
case "$OPT_ROOT" in
''|/*) ;;
*) die 6 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;;
esac
# 助理狀態目錄。判到期那一支把 rows.txt 放在這底下,兩支要對得上同一個位置。
JSC_HOME_RESOLVED=${JSC_HOME:-${HOME:-}/.jsc}
case "$JSC_HOME_RESOLVED" in
/*) ;;
*) die 6 'JSC_HOME 與 HOME 都解不出絕對路徑,找不到助理狀態目錄。' ;;
esac
[ -n "$ROWS" ] || ROWS="$JSC_HOME_RESOLVED/assistant/due/rows.txt"
[ -f "$ROWS" ] || die 2 "到期清單讀不到:$ROWS。請先跑 due.sh scan——沒跑過判定,跟「都沒到期」不是同一件事。"
TAB=$(printf '\t')
TMPD=$(mktemp -d 2>/dev/null) || die 5 '暫存目錄建不起來。'
trap 'rm -rf "$TMPD"' EXIT
[ -n "$NOW" ] || NOW=$(date -u +%s)
NOW_ISO=$(date -u -d "@$NOW" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
|| NOW_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
# --- 找同一組工具 ---
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
ROOT="$OPT_ROOT"
# 沒帶 --root 就從自己的位置往上推兩層。呼叫端該餵進來,這只是開發時跑得動的退路。
[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT=''
find_tool() { # $1=domain $2=相對路徑
for _d in "jsc-$1" "$1"; do
[ -n "$ROOT" ] && [ -f "$ROOT/$_d/$2" ] && { printf '%s' "$ROOT/$_d/$2"; return 0; }
done
return 1
}
TASKS_SH="$HERE/tasks.sh"
[ -f "$TASKS_SH" ] || TASKS_SH=$(find_tool assist tools/tasks.sh) \
|| die 2 '找不到 tasks.sh,成敗沒有地方回寫。待辦簿只有一個寫入者,缺了它這一輪不該跑。'
DUE_SH="$HERE/due.sh"
[ -f "$DUE_SH" ] || DUE_SH=$(find_tool assist tools/due.sh) || DUE_SH=''
# --- {cli} 的代入來源 ---
# 偵測到的 CLI 代號,一行一個。取不到就當成代不出來,帶 {cli} 的那幾筆這一輪跳過。
CLIS="$TMPD/clis.txt"
: >"$CLIS"
if _det=$(find_tool cli tools/detect-clis.sh); then
"$_det" 2>/dev/null | awk -F"$TAB" 'NF>0 && $1 != "" {print $1}' >"$CLIS" 2>/dev/null || : >"$CLIS"
fi
N_CLI=$(awk 'END{print NR+0}' "$CLIS")
# {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令,
# 而那一輪沒有人看得到它跑到哪裡去了。
REPO_READY=0
# --- 判斷動作是哪一種 ---
# 技能名的形狀是 jsc-{domain}:{技能名}。比對整串,不用前綴比對:一行以 jsc- 開頭的指令
# 路徑不該被當成技能名。
is_skill_name() { # $1=action
case "$1" in
jsc-*:*)
case "$1" in
*' '*|*/*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
# 執行前再驗一次指令形狀。種入那一支驗過同樣幾項,這裡不省:待辦檔是純文字,中間可能被改。
# 驗不過就當成代不出來,跳過不跑,不記失敗——這一筆的內容有問題,那是清單或待辦檔的事。
cmd_shape_ok() { # $1=指令
case "$1" in
*'$'*|*'`'*|*'~'*) BAD_WHY='指令裡有金錢符號、反引號或波浪號'; return 1 ;;
*';'*|*'|'*|*'&'*) BAD_WHY='指令裡有分號、管線或連接符號,一筆內建項只該是一行單一指令'; return 1 ;;
esac
# 路徑一定要落在這一輪的根目錄底下。字面絕對路徑是權限層唯一認得的形狀,也是唯一
# 看得出「這支腳本是不是我們自己的」的形狀。
case "$1" in
*"$ROOT"/*) ;;
*) BAD_WHY="指令裡沒有這一輪的根目錄 $ROOT"; return 1 ;;
esac
return 0
}
# --- 逐筆處理 ---
N_DUE=0; N_RUN=0; N_OK=0; N_FAIL=0; N_HELD=0; N_SKIP=0; N_WRITE_BAD=0
RC_CMD=0; RC_WRITE=0
# 欄位順序取自 due.sh 印的 rows_columns=。這裡寫死同一個順序,換了就對不上——所以先驗一次
# 欄位數,對不上一筆都不跑,而不是照舊讀進錯的欄位。
_cols=$(awk -F"$TAB" 'NF>1{print NF; exit}' "$ROWS" 2>/dev/null)
case "${_cols:-0}" in
11) ;;
0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=11 ;;
*) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 11 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。" ;;
esac
while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recur c_next c_rearm c_why c_rest; do
[ -n "${c_id:-}" ] || continue
[ "${c_verdict:-}" = "due" ] || continue
N_DUE=$((N_DUE + 1))
# 只跑內建項。使用者交辦的那幾筆沒有經過種入那一支的檢核,動作欄想寫什麼都行。
_spec=$(sed -n 's/^spec_key=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1)
if [ -z "$_spec" ]; then
N_SKIP=$((N_SKIP + 1))
printf 'skip=%s reason=不是委派清單種入的內建項,這一支不跑使用者交辦的那幾筆\n' "$c_id"
continue
fi
# 三種動作,這一輪只跑第一種。
if [ "$c_action" = "remind" ]; then
N_SKIP=$((N_SKIP + 1))
printf 'skip=%s spec=%s reason=動作是只提醒,沒有東西可跑\n' "$c_id" "$_spec"
continue
fi
if is_skill_name "$c_action"; then
N_SKIP=$((N_SKIP + 1))
printf 'skip=%s spec=%s reason=動作是技能名,叫用整支技能這一批還沒接 action=%s\n' \
"$c_id" "$_spec" "$c_action"
continue
fi
BAD_WHY=''
if ! cmd_shape_ok "$c_action"; then
N_HELD=$((N_HELD + 1))
printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$BAD_WHY" "$c_action"
continue
fi
# 代入點展開:一個目標一行,寫進暫存檔。
_targets="$TMPD/targets.$c_id"
: >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。"
_hold=''
case "$c_action" in
*'{repo}'*)
[ "$REPO_READY" -eq 1 ] || _hold='存取庫掃描還沒做出來,{repo} 代不出目標' ;;
esac
if [ -z "$_hold" ]; then
case "$c_action" in
*'{cli}'*)
if [ "$N_CLI" -eq 0 ]; then
_hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標'
else
while IFS= read -r _c; do
[ -n "$_c" ] || continue
printf '%s\n' "$(printf '%s' "$c_action" | sed "s|{cli}|$_c|g")" >>"$_targets"
done <"$CLIS"
fi ;;
*) printf '%s\n' "$c_action" >>"$_targets" ;;
esac
fi
if [ -n "$_hold" ]; then
N_HELD=$((N_HELD + 1))
printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$_hold" "$c_action"
continue
fi
# 代完之後還留著大括號就是還有認不得的代入點。原樣送進殼會跑到一個沒有人寫過的地方。
if grep -q '[{}]' "$_targets" 2>/dev/null; then
N_HELD=$((N_HELD + 1))
printf 'held=%s spec=%s reason=代完之後還留著大括號,認不得的代入點 action=%s\n' \
"$c_id" "$_spec" "$c_action"
continue
fi
_n=$(awk 'END{print NR+0}' "$_targets")
if [ "$DRYRUN" -eq 1 ]; then
N_RUN=$((N_RUN + 1))
printf 'would_run=%s spec=%s targets=%s\n' "$c_id" "$_spec" "$_n"
while IFS= read -r _t; do printf ' target=%s\n' "$_t"; done <"$_targets"
continue
fi
# 真的跑。一個目標一次,全部成功這一筆才算成功——一支 CLI 接線壞掉就是一筆要人看的發現。
N_RUN=$((N_RUN + 1))
_entry_rc=0
_first_err=''
while IFS= read -r _t; do
[ -n "$_t" ] || continue
_out=$(sh -c "$_t" </dev/null 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t"
else
_entry_rc=1
[ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | head -n1 | cut -c1-160)
printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \
"$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | head -n1 | cut -c1-160)"
fi
done <"$_targets"
# 回寫。下一次什麼時候到期由判定那一支算,這一支不自己算——兩邊各算一次就會漂移。
if [ "$_entry_rc" -eq 0 ]; then
_next=''
if [ -n "$DUE_SH" ]; then
_next=$("$DUE_SH" next --trigger "$c_trigger" --recur "$c_recur" \
--last-run "$NOW_ISO" --now "$NOW" 2>/dev/null | sed -n 's/^next_run=//p' | head -n1)
fi
if [ -n "$_next" ]; then
"$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" --next-run "$_next" >/dev/null 2>&1
else
"$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1
fi
_wrc=$?
if [ "$_wrc" -eq 0 ]; then
N_OK=$((N_OK + 1))
printf 'done=%s spec=%s targets=%s next_run=%s\n' "$c_id" "$_spec" "$_n" "${_next:--}"
else
N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1
printf 'write_failed=%s spec=%s op=done rc=%s\n' "$c_id" "$_spec" "$_wrc"
fi
else
RC_CMD=1
"$TASKS_SH" fail "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1; _wrc=$?
if [ "$_wrc" -eq 0 ]; then
N_FAIL=$((N_FAIL + 1))
printf 'failed=%s spec=%s targets=%s first_error=%s\n' "$c_id" "$_spec" "$_n" "${_first_err:--}"
else
N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1
printf 'write_failed=%s spec=%s op=fail rc=%s\n' "$c_id" "$_spec" "$_wrc"
fi
fi
done <"$ROWS"
printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s\n' \
"$([ "$DRYRUN" -eq 1 ] && echo plan || echo run)" "$ROWS" "${ROOT:--}" \
"$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI"
[ "$N_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。"
[ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。"
[ "$RC_WRITE" -ne 0 ] && warn "有 $N_WRITE_BAD 筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆,逐筆印在上面的 write_failed= 那幾行,各自帶了 tasks.sh 的結束碼。"
[ "$RC_CMD" -ne 0 ] && warn "有 $N_FAIL 筆的指令回非零,已經記成失敗、失敗次數加一。逐筆印在上面的 failed= 與 target_fail= 那幾行。"
[ "$RC_WRITE" -eq 0 ] || exit 4
[ "$RC_CMD" -eq 0 ] || exit 1
exit 0