feat(assistant): 存取庫掃描接上 {repo} 代入點

新增 tools/scan-repos.sh:掃出工作目錄底下的存取庫,交出 {repo} 要代的
那份清單,順便對出每一個存取庫的 REPO_{HASH} 盤點頁頁名(雜湊規則與
盤點頁那一支相同,實測值一致)。

掃描起點刻意沒有預設值。給一個預設就等於猜,而猜錯的後果不是掃不到,
是在猜錯的那些目錄底下跑指令——往上一層是家目錄、再往上是整台機器,
而那一輪沒有人看得到它跑到哪裡去了。沒設就回 3 並說要設哪一個變數。
深度預設一層、上限三層;點開頭的目錄、符號連結、路徑帶空白或殼層特殊
字元的三種一律不交出去,但逐個印出來——那是真的存在卻沒被盤點到的
存取庫,只印一個總數會讀成整批都掃過了。

run-due.sh 改成兩個代入點都代:{cli} 取偵測到的 CLI 代號、{repo} 取
掃到的存取庫,兩個都帶的那一筆目標數是乘積。待辦簿那一筆的 repo 欄
有值時只代那一個,值可以是路徑也可以是 {owner}/{repo};對不出來就
印 held=,不退回全部存取庫——那一筆指名了一個目標。代入之後再驗一次
禁止字元,因為代進去的值是這一輪現場算出來的。

schedule.sh 把 JSC_ASSIST_SCAN_ROOT 與 JSC_ASSIST_SCAN_EXCLUDE 快照進
排程條目。少了掃描起點,帶 {repo} 的內建項每一輪都代不出目標,而那一輪
只印一行 held=,看起來像這一批還沒接上、不像一個變數沒進條目。

同時補兩個只有真的執行才發現得了的缺陷:

一、cron 一行的長度上限只在寫入那一刻由 crontab 自己擋,--dry-run 那一路
根本不碰它,所以預演每一次都過。實測踩過一次:整條 PATH 快照進條目那一批
預演全綠、安裝回結束碼 4。改成建好條目就量,兩個模式都印 entry_len= 與
上限,達到上限當場拒絕並指出長度是從哪幾個快照變數來的。

二、run-due.sh 截錯誤訊息用的 cut -c 數的是位元組不是字元,中文字剛好被
截在中間就在報告上留下一個替代字元。改成截完再過一次 iconv -c 丟掉那個
不完整的序列。這一種不會有人來報:亂碼不影響結束碼。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 10:36:26 +08:00
co-authored by Claude Opus 5
parent 98dd0761fa
commit e53bb23f8e
9 changed files with 434 additions and 34 deletions
+122 -24
View File
@@ -46,12 +46,16 @@
#
# --- 代不出目標不算失敗 ---
#
# {cli} 由偵測到的 CLI 代號代入,{repo} 由掃到的存取庫工作目錄代入。後者的掃描還沒做出來,
# 所以帶 {repo} 的那幾筆這一輪代不出目標。處置是印一行 held= 就跳過,**不動那一筆的
# last_run,也不加失敗次數**。
# 那一筆沒有做錯任何事:代不出目標是這一支還缺一塊,記成失敗會讓一個沒有人修得動的計數
# {cli} 由偵測到的 CLI 代號代入,{repo} 由 scan-repos.sh 掃到的存取庫工作目錄代入。
# 兩個代入點都代得出來才跑;任一個代不出來就印一行 held= 跳過,**不動那一筆的 last_run,
# 也不加失敗次數**。
# 那一筆沒有做錯任何事:代不出目標是環境還缺一塊,記成失敗會讓一個沒有人修得動的計數
# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。
# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。
#
# {repo} 代不出來最常見的成因是 JSC_ASSIST_SCAN_ROOT 沒設。那不是預設值漏填,是掃描那一支
# 刻意不猜:猜錯掃描起點的後果是在猜錯的那些目錄底下跑指令,而那一輪沒有人看得到它跑到
# 哪裡去了。所以這裡照它回的話原樣記進 held= 的理由欄,人才看得出要去設哪一個變數。
set -u
usage() {
@@ -60,6 +64,16 @@ usage() {
}
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; }
@@ -186,9 +200,52 @@ if [ -n "${JSC_ASSIST_CLIS:-}" ]; then
done
fi
# {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令,
# 而那一輪沒有人看得到它跑到哪裡去了。
REPO_READY=0
# --- {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=\)/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"
}
# --- 判斷動作是哪一種 ---
@@ -221,6 +278,25 @@ cmd_shape_ok() { # $1=指令
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
@@ -272,23 +348,42 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
_targets="$TMPD/targets.$c_id"
: >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。"
_hold=''
# 沒綁存取庫的那一筆代成掃到的每一個;綁了的只代那一個。
_repovals="$REPOS"
case "$c_action" in
*'{repo}'*)
[ "$REPO_READY" -eq 1 ] || _hold='存取庫掃描還沒做出來,{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
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
# 兩個代入點分兩段代,一段一個暫存檔。同一筆同時帶 {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))
@@ -323,9 +418,9 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
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)
[ -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" | head -n1 | cut -c1-160)"
"$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | clip 160)"
fi
done <"$_targets"
@@ -362,10 +457,13 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
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\n' \
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_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= 那幾行。"