Files
assist/tools/run-due.sh
T
jiantw83andClaude Opus 5 465bd9d40a feat(assistant): 逾期判定接上來,逾期與連續失敗每一輪都提
逾期本來沒實作,監控頁上就寫著「上面的判定只講到期,不講逾期」。

到期與逾期分兩欄,不合成一個判定:到期是「該跑了」,逾期是「due 欄那個
截止時間過了」。一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期;
一筆有截止時間的交辦可能早就逾期,卻因為排定點還沒到而判成還沒到。
合成一個值就把兩種情況混成同一格。

due.sh 多第十二欄 overdue,值是減號(沒有截止時間)、no、逾期的秒數,
或 bad。bad 算成欄位不合法回 2——一個解不開的截止時間跟沒有截止時間
在畫面上長得一模一樣,而前者是登錄的時候就填錯了。done 那幾筆不算逾期;
被暫停的那幾筆照算,因為暫停中止的是到期判定,不是日曆,而一筆被暫停
又逾期的交辦正好是最容易被忘掉的那一種。

另加唯讀子命令 due.sh overdue 給狀態查詢那一路走。走 scan 會推進快照,
那一輪之後的事件判定會全部落空;走 events 又判不到逾期;讓呼叫端自己
拿 due 欄去比更糟,那是第二套判定,漂移的那一天兩邊都說自己對。

patrol.sh 在逾期或連續失敗筆數大於 0 時各列一筆待人處理,每一輪都列。
兩種都不會自己好:截止時間過了不會因為過更久就不逾期,連續失敗的項目
每一輪都會再試一次然後再失敗一次。「提過了」不是「處理過了」,而助理
不替人按暫停,那個狀態留給人設。

監控頁那一節逐筆點名連續失敗的那幾筆——id、失敗幾次、最後一次執行、
標題。原本只印一個總數,而失敗次數這個欄位存在的理由是指出「有一筆壞掉
的項目每輪重試而沒人知道」,不說出是哪一筆,那個理由只完成一半。

目錄頁刻意不加欄位。那一頁一台機器一個區塊、各自那一輪寫,新加一條只有
跑到新版的機器寫得出來,讀的人分不出「這台沒有逾期」與「這台還沒寫這
一條」——理由與當初把「本輪非 ok 事件數」擋在那一頁外面的一樣。

欄位數從 11 變 12,run-due.sh 那道欄位數檢核跟著改成 12。那道檢核擋的
不是第 12 欄本身,是「兩支不是同一版」——版本不同的時候,前 11 欄的
順序也不能假設還是原來那樣。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:53:00 +08:00

476 lines
25 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} 由 scan-repos.sh 掃到的存取庫工作目錄代入。
# 兩個代入點都代得出來才跑;任一個代不出來就印一行 held= 跳過,**不動那一筆的 last_run,
# 也不加失敗次數**。
# 那一筆沒有做錯任何事:代不出目標是環境還缺一塊,記成失敗會讓一個沒有人修得動的計數
# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。
# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。
#
# {repo} 代不出來最常見的成因是 JSC_ASSIST_SCAN_ROOT 沒設。那不是預設值漏填,是掃描那一支
# 刻意不猜:猜錯掃描起點的後果是在猜錯的那些目錄底下跑指令,而那一輪沒有人看得到它跑到
# 哪裡去了。所以這裡照它回的話原樣記進 held= 的理由欄,人才看得出要去設哪一個變數。
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"; }
# 取第一行、截到指定長度。cut 數的是位元組不是字元,中文字剛好被切成一半時尾巴就變成
# 替代字元——實測踩到:一行中文錯誤訊息截在第 200 個位元組,報告上留下一個亂碼字。
# 截完再過一次 iconv -c 把那個不完整的序列丟掉。iconv 不在這台機器上就退回原樣,
# 不自己拼一套 UTF-8 邊界判定:那是在錯誤訊息這條路上加一個會出錯的新東西。
clip() { # $1=位元組上限;讀標準輸入
_raw=$(head -n1 | cut -c"1-$1")
_cln=$(printf '%s' "$_raw" | iconv -c -f UTF-8 -t UTF-8 2>/dev/null)
if [ -n "$_cln" ]; then printf '%s' "$_cln"; else printf '%s' "$_raw"; fi
}
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
# --rows 沒有預設值,一律要指定。
#
# 原本有預設,指向助理狀態目錄底下那一份。那個預設是這一支上線之後最嚴重的一個缺陷:
# 判到期那一支是被巡檢用 --out 叫的,輸出寫進那一輪自己的暫存目錄,跟這個預設不是同一個
# 位置。於是這一支每一輪讀的都是「上一次有人手動跑 scan 留下的那一份」。實測那份清單在
# 機器上放了六小時,這一支每一輪都對它動手,還跑了一筆已經被移除的待辦。
# 而它看起來完全正常——那份舊清單產生的當下是對的,內容剛好沒變。**錯了六小時才顯形。**
#
# 所以不留預設。少帶這個選項就回用法錯誤,吵一次總比安靜地對舊資料動手好。呼叫端要拿
# collect 印的 due_rows_file= 餵進來,那是同一輪產生的那一份。
if [ -z "$ROWS" ]; then
die 6 '沒有帶 --rows。這一支不猜到期清單的位置:判到期那一支的輸出寫在叫它的人指定的目錄裡,猜一個預設就會讀到別人留下的舊檔案,而舊檔案讀起來跟新的一模一樣。請把 collect 印的 due_rows_file= 餵進來。'
fi
[ -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
}
# 清單太舊就拒跑。判到期與執行之間隔著呼叫端,那一段有可能斷掉——collect 失敗了而那一輪
# 照樣往下走,或呼叫端餵進上一輪的路徑。兩種都會讓這一支對著一份不描述現況的清單動手,
# 而那正是它上線之後六小時裡在做的事。
# 門檻取心跳的過期門檻:那是這台機器認定「一輪跑完的紀錄還算新鮮」的長度,一份比它還舊的
# 到期清單本來就不可能是這一輪產生的。讀不到門檻就退回 300 秒,跟心跳那一支的預設一致。
_ttl=''
if _hb=$(find_tool hooks hooks/heartbeat.sh 2>/dev/null); then
_ttl=$("$_hb" report 2>/dev/null | sed -n 's/.*[[:space:]]ttl=\([0-9]*\).*/\1/p' | head -n1)
fi
case "${_ttl:-}" in
''|*[!0-9]*) _ttl=300 ;;
esac
_mtime=$(date -r "$ROWS" +%s 2>/dev/null) || _mtime=''
if [ -n "$_mtime" ]; then
_age=$((NOW - _mtime))
if [ "$_age" -gt "$_ttl" ]; then
die 2 "到期清單是 $_age 秒前產生的,超過門檻 $_ttl 秒,這一支不對它動手:$ROWS。一份比心跳門檻還舊的清單不可能描述這一輪,照著跑會動到已經不存在的待辦。請確認判到期那一步這一輪真的跑成功了,再把它印的路徑餵進來。"
fi
fi
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")
# 跟排程條目安裝當下記下的那一份比對。少了哪一支要點名,不是只印個數字。
#
# 為什麼要比:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的 PATH 是
# 安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——實測有一支裝在帶行程編號的多殼層目錄,
# 重開機之後那條路徑就不在了。
# 沒有基準的後果不是報錯,是安靜地少做一支:這一輪照樣把偵測到的每一支都跑過、照樣全部
# 成功、照樣回報「都跑過了」。實測那一輪報「對 4 支 CLI 都跑過,全部成功」,而機器上裝了
# 5 支——報告讀起來完全正常。
# 只報不修:那條路徑本質上是暫時的,真正的解法是那支 CLI 該裝在穩定位置,或者重跑一次
# 排程安裝重新拍一份快照。兩件都不是這一支該替人決定的。
CLI_MISSING=''
if [ -n "${JSC_ASSIST_CLIS:-}" ]; then
for _want in $JSC_ASSIST_CLIS; do
grep -qxF "$_want" "$CLIS" 2>/dev/null || CLI_MISSING="${CLI_MISSING:+$CLI_MISSING、}$_want"
done
fi
# --- {repo} 的代入來源 ---
# 掃到的存取庫工作目錄,一行一條。掃描規則、掃描起點與排除清單全在 scan-repos.sh 那一支,
# 這裡不自己再判一次:兩邊各有一套掃描規則就會漂移,而漂移的那一天沒有人會收到通知。
# 兩份:REPOS 是路徑清單,給沒綁存取庫的那幾筆代成每一個目標;REPOMAP 多帶一欄
# {owner}/{repo},給綁了存取庫的那幾筆對出它綁的是哪一條路徑。
REPOS="$TMPD/repos.txt"
REPOMAP="$TMPD/repomap.txt"
: >"$REPOS"; : >"$REPOMAP"
REPO_WHY=''
REPO_SCAN='-'
SCAN_SH="$HERE/scan-repos.sh"
[ -f "$SCAN_SH" ] || SCAN_SH=$(find_tool assist tools/scan-repos.sh) || SCAN_SH=''
if [ -z "$SCAN_SH" ]; then
REPO_WHY='找不到 scan-repos.sh,{repo} 沒有代入來源'
REPO_SCAN='missing'
else
"$SCAN_SH" list --root "$ROOT" >"$TMPD/scan.out" 2>"$TMPD/scan.err"
_src=$?
REPO_SCAN="rc$_src"
case "$_src" in
# 0 是全部都對得出盤點頁頁名,1 是有幾個對不出來或路徑被拒——後者交出來的那幾條照用,
# 對不出頁名不影響唯讀盤點跑得動。其餘各碼一律當成沒有來源,並把它印的理由帶上。
0|1)
sed -n 's/^repo=\([^ ]*\) slug=\([^ ]*\).*/\1'"$TAB"'\2/p' "$TMPD/scan.out" >"$REPOMAP"
cut -f1 "$REPOMAP" >"$REPOS" ;;
*) REPO_WHY=$(sed -n 's/^\[jsc\]\[助理掃描\]\[ERR\]://p' "$TMPD/scan.err" 2>/dev/null | clip 200)
[ -n "$REPO_WHY" ] || REPO_WHY="存取庫掃描回結束碼 $_src,{repo} 代不出目標" ;;
esac
# 掃描那一支的逐項判定行被導進暫存檔,所以外面看不到。原樣轉出來:被拒的那幾個存取庫是
# 這一輪「掃到但沒交出去」的名單,那份名單不能只留在暫存檔裡。
sed -n 's/^\(rejected=\|unnamed=\|excluded=\|skipped_link=\|not_a_repo=\)/repo_scan_note=\1/p' \
"$TMPD/scan.out" "$TMPD/scan.err" 2>/dev/null
fi
N_REPOS=$(awk 'END{print NR+0}' "$REPOS")
if [ -z "$REPO_WHY" ] && [ "$N_REPOS" -eq 0 ]; then
REPO_WHY='存取庫掃描一個都沒掃到,{repo} 代不出目標'
fi
# 這一筆綁的存取庫對出一條掃到的路徑。綁的值可以是工作目錄路徑,也可以是 {owner}/{repo}——
# 待辦簿的欄位說明只寫「這一筆綁哪一個存取庫」,兩種寫法都有人會填,所以兩種都認。
# 對不出來一律回失敗,不退回「全部存取庫」:那一筆指名了一個目標,代成全部就是跑了
# 十九個沒有人要它跑的地方。
repo_path_of() { # $1=綁定值
awk -F"$TAB" -v want="$1" '$1==want || $2==want {print $1; found=1; exit} END{exit !found}' "$REPOMAP"
}
# --- 判斷動作是哪一種 ---
# 技能名的形狀是 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
}
# 把一個代入點換成清單裡的每一個值,一個值一行。清單是空的又剛好命中代入點,那一行就
# 整行消失——所以呼叫端要先確定清單非空,別靠這裡回錯:目標數變成 0 的那一筆會被當成
# 「沒有東西要跑」而算成功,而它其實一次都沒跑。
expand_over() { # $1=代入點 $2=值清單檔 $3=輸入檔 $4=輸出檔
: >"$4" 2>/dev/null || return 1
while IFS= read -r _line; do
[ -n "$_line" ] || continue
case "$_line" in
*"$1"*)
while IFS= read -r _v; do
[ -n "$_v" ] || continue
printf '%s\n' "$(printf '%s' "$_line" | sed "s|$1|$_v|g")" >>"$4"
done <"$2" ;;
*) printf '%s\n' "$_line" >>"$4" ;;
esac
done <"$3"
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
12) ;;
0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=12 ;;
*) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 12 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。第 12 欄是逾期判定,跟這一支要跑什麼無關,但欄位數不對就代表兩支不是同一版,那時候前 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=''
# 沒綁存取庫的那一筆代成掃到的每一個;綁了的只代那一個。
_repovals="$REPOS"
case "$c_action" in
*'{repo}'*)
if [ -n "$REPO_WHY" ]; then
_hold="$REPO_WHY"
else
_bind=$(sed -n 's/^repo=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1)
if [ -n "$_bind" ]; then
if _bp=$(repo_path_of "$_bind"); then
_repovals="$TMPD/repoval.$c_id"
printf '%s\n' "$_bp" >"$_repovals" 2>/dev/null || die 5 "暫存檔寫不進去:$_repovals。"
else
_hold="這一筆綁的存取庫「$_bind」這一輪沒掃到,代不出目標"
fi
fi
fi ;;
esac
case "$c_action" in
*'{cli}'*) [ "$N_CLI" -gt 0 ] || _hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標' ;;
esac
if [ -z "$_hold" ]; then
# 兩個代入點分兩段代,一段一個暫存檔。同一筆同時帶 {repo} 與 {cli} 時,目標數是兩邊
# 的乘積——三個存取庫乘五支 CLI 就是十五個目標,一個目標跑一次。
printf '%s\n' "$c_action" >"$TMPD/x0.$c_id"
expand_over '{repo}' "$_repovals" "$TMPD/x0.$c_id" "$TMPD/x1.$c_id" \
|| die 5 "暫存檔寫不進去:$TMPD/x1.$c_id。"
expand_over '{cli}' "$CLIS" "$TMPD/x1.$c_id" "$_targets" \
|| die 5 "暫存檔寫不進去:$_targets。"
# 代入之後再驗一次禁止字元。種入那一支與上面的 cmd_shape_ok 驗的是還沒代入的字面,
# 代進去的值是這一輪現場算出來的——路徑與 CLI 代號都有可能帶進新的字元,而帶進來的
# 那一刻就是送進殼的前一刻。
if grep -q '[$`;|&~]' "$_targets" 2>/dev/null; then
_hold='代入之後出現金錢符號、反引號、分號、管線、連接符號或波浪號,代入來源有問題'
: >"$_targets"
fi
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" | clip 160)
printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \
"$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | clip 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 cli_missing=%s repos=%s repo_scan=%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" "${CLI_MISSING:--}" \
"$N_REPOS" "$REPO_SCAN"
[ -n "$REPO_WHY" ] && note "存取庫清單這一輪是空的:$REPO_WHY。帶 {repo} 代入點的那幾筆全部跳過,逐筆印在上面的 held= 那幾行。"
[ "$REPO_SCAN" = 'rc1' ] && note "存取庫掃描回 1:掃到的存取庫裡有幾個對不出 REPO_{HASH} 盤點頁頁名,或路徑帶了空白與殼層特殊字元而被拒。交出來的那幾條照用,被拒的那幾個這一輪沒有被盤點到——理由逐個印在掃描那一支的 rejected= 與 unnamed= 那幾行。"
[ -n "$CLI_MISSING" ] && warn "排程條目記著這台機器有 $JSC_ASSIST_CLIS,但這一輪只偵測到 $N_CLI 支,少了:$CLI_MISSING。帶 {cli} 代入點的那幾筆這一輪少跑了那幾支,而且**全部成功也不代表全部跑過**。成因通常是那支 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),條目帶的 PATH 是安裝當下拍的,那條路徑現在不在了。重跑一次 schedule.sh install patrol 會重新拍一份快照;那支 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