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>
This commit is contained in:
2026-09-07 10:53:00 +08:00
co-authored by Claude Opus 5
parent 205da9b61f
commit 465bd9d40a
8 changed files with 216 additions and 36 deletions
+162 -23
View File
@@ -8,8 +8,9 @@
# [--created {ISO 時間}] [--last-run {ISO 時間}] [--fail-count {數字}]
# [--now {epoch 秒}]
#
# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期。
# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。
# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期與逾期。
# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。
# overdue 只判逾期,唯讀:一份快照都不推進,一個事件都不比。狀態查詢那一路走這一個。
# next 純算:給一組欄位算出 next_run,不讀待辦簿、不碰快照、不發事件。
#
# 結束碼:
@@ -17,7 +18,8 @@
# 1 至少一個狀態來源存在卻讀不到。**結果照樣印得出來**,只是那一個來源本輪不判事件、
# 快照那一段原樣留著,呼叫端要把它標成警示
# 2 欄位值不合法或算不出來:trigger 或 recur 解不開、時間字串解不出 epoch、間隔寫錯、
# --now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,其餘各筆照判
# due 欄的截止時間解不開、--now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,
# 其餘各筆照判
# 3 快照或事件計數換不上去。算是算出來了,但下一輪會拿同一份舊快照再比一次,於是同一批
# 事件會被判第二次。這一碼要吵出來,不能當成成功
# 4 待辦簿目錄不存在或零筆,這一輪沒有任何一筆可判。不是失敗,但呼叫端要知道「沒判過」
@@ -139,8 +141,13 @@
#
# --- 這一支不判什麼 ---
#
# 不判逾期(due 欄有沒有過),不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。
# 它只回答兩個問題:本輪有哪些新事件、哪幾筆現在到期。做不做、怎麼做、失敗怎麼記,都在呼叫端。
# 不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。
# 它只回答三個問題:本輪有哪些新事件、哪幾筆現在到期、哪幾筆逾期了。做不做、怎麼做、失敗怎麼記,
# 都在呼叫端。
#
# 到期與逾期分兩欄,不合成一個判定:到期是「該跑了」,逾期是「due 欄那個截止時間過了」。
# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的交辦
# 可能早就逾期,卻因為排定點還沒到而判成「還沒到」。合成一個值就會把這兩種情況混成同一格。
#
# 環境變數:
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜
@@ -195,8 +202,9 @@ note() { printf '[jsc][助理到期判定]:%s\n' "$1" >&2; }
usage() {
cat >&2 <<'EOF'
usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run]
due.sh events [--now epoch 秒]
usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run]
due.sh events [--now epoch 秒]
due.sh overdue [--now epoch 秒]
due.sh next --trigger at:...|after:... --recur once|every:間隔
[--created ISO 時間] [--last-run ISO 時間] [--fail-count 數字]
[--now epoch 秒]
@@ -869,15 +877,51 @@ kv_get() { # $1=檔案 $2=鍵
# 不寫成 key=value:cron 式子與標題裡有空白,key=value 的那一行沒辦法只靠空白切回來,切錯就是
# recur 只剩「cron:0」那半截,而那半截看起來還很像一個完整的值。值一律折過,裡面不會有定位
# 字元也不會有換行,所以定位字元切得準。
row_out() { # 十一個欄位,順序如上
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
row_out() { # 十二個欄位,順序如上
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$(flat "$1")" "$(flat "$2")" "$(flat "$3")" "$(flat "$4")" "$(flat "$5")" \
"$(flat "$6")" "$(flat "$7")" "$(flat "$8")" "$(flat "$9")" \
"$(flat "${10}")" "$(flat "${11}")" >>"$ROWS"
"$(flat "${10}")" "$(flat "${11}")" "$(flat "${12}")" >>"$ROWS"
}
# 逾期判定。到期與逾期是兩件事:到期是「該跑了」,逾期是「截止時間過了」。
# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的
# 交辦事項可能早就逾期,卻因為排定點還沒到而判成「還沒到」。兩個判定各自獨立,一欄一個值。
#
# 回傳三種值:due 欄是空的印減號、還沒過印 no、過了印逾期的秒數。
# 印秒數不印 yes 的理由是「逾期一分鐘」與「逾期三個月」在報告上要看得出差別,而呼叫端
# 只要判「是不是數字」就分得出有沒有逾期。
# due 欄解不開的一律印 bad 並算成欄位不合法:一個解不開的截止時間跟沒有截止時間,
# 在畫面上長得一模一樣,而前者是登錄時就填錯了。
# 逾期多久,給人看的寫法。機器可讀的那幾處一律留秒數:秒數比得出大小、也不必解析。
# 頁面上只印秒數讀不出輕重——「528462 秒」要算過才知道是六天,而報告的用途正是讓人一眼
# 分出「逾期一分鐘」與「逾期三個月」。
dur_human() { # $1=秒數
_ds=$(( $1 / 86400 )); _hs=$(( ($1 % 86400) / 3600 )); _ms=$(( ($1 % 3600) / 60 ))
if [ "$_ds" -gt 0 ]; then printf '%s 天 %s 小時' "$_ds" "$_hs"
elif [ "$_hs" -gt 0 ]; then printf '%s 小時 %s 分' "$_hs" "$_ms"
else printf '%s 分' "$_ms"; fi
}
overdue_of() { # $1=due 欄的值;回傳值印在 stdout
case "$1" in
''|'-') printf '%s' '-'; return 0 ;;
esac
_de=$(iso_to_epoch "$1") || _de=''
case "${_de:-}" in
''|*[!0-9]*) printf '%s' 'bad'; return 1 ;;
esac
if [ "$NOW" -gt "$_de" ]; then
printf '%s' "$((NOW - _de))"
else
printf '%s' 'no'
fi
return 0
}
scan_tasks() {
T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0
T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
true >"$ROWS" 2>/dev/null || { RC_FS=5; die 5 "逐筆判定的輸出檔寫不進去:$ROWS。"; }
if [ ! -d "$TASKS_DIR" ]; then
RC_EMPTY=4
@@ -898,11 +942,28 @@ scan_tasks() {
_cr=$(kv_get "$_f" created)
_lr=$(kv_get "$_f" last_run)
_fc=$(kv_get "$_f" fail_count)
_du=$(kv_get "$_f" due)
# 逾期照每一筆都判,連 paused 那幾筆也判。到期判定跳過 paused 是對的(那是人設的,
# 助理判它到期只會讓人以為它還在跑),但截止時間過了這件事不會因為有人按了暫停就消失
# ——一筆被暫停又逾期的交辦,正好是最容易被忘掉的那一種。
# done 那幾筆不算逾期:做完了就沒有截止時間可以過。
_ov='-'
if [ "$_st" != done ]; then
_ov=$(overdue_of "$_du") || { T_DUEBAD=$(( T_DUEBAD + 1 )); RC_FIELD=2; }
case "$_ov" in
''|*[!0-9]*) ;;
*) T_OVERDUE=$(( T_OVERDUE + 1 ))
OVERDUE_LINES="${OVERDUE_LINES}$(printf '%s\t%s\t%s\t%s\t%s' \
"$(flat "$_id")" "$(flat "$_st")" "$(flat "$_du")" "$_ov" "$(flat "$_ti")")
" ;;
esac
fi
if [ "$_st" != pending ]; then
# done 與 paused 一律不判。paused 是人設的,助理判它到期只會讓人以為它還在跑。
# done 與 paused 一律不判到期。
T_SKIP=$(( T_SKIP + 1 ))
row_out "$_id" skip "$_st" "$_kd" "$_ac" '' '' '' '' 'state 不是 pending,不判到期' "$_ti"
row_out "$_id" skip "$_st" "$_kd" "$_ac" '' '' '' '' 'state 不是 pending,不判到期' "$_ti" "$_ov"
continue
fi
@@ -914,7 +975,7 @@ scan_tasks() {
unwired) T_UNWIRED=$(( T_UNWIRED + 1 )) ;;
esac
row_out "$_id" "$R_VERDICT" "$_st" "$_kd" "$_ac" "$_tg" "$_rc" \
"$R_NEXT" "$R_REARM" "$R_WHY" "$_ti"
"$R_NEXT" "$R_REARM" "$R_WHY" "$_ti" "$_ov"
done
[ "$T_TOTAL" -eq 0 ] && {
RC_EMPTY=4
@@ -931,7 +992,7 @@ compose_md() {
printf '事件靠比對狀態快照偵測:每一輪讀那幾支產生者留下的狀態,跟上一輪的快照比,有差別就算事件發生。快照在 `%s`,事件計數在 `%s`。\n\n' \
"$PREV" "$SEEN"
printf '**兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期之內鎖上又合併、一個工作階段在同一個週期之內開又關,這一節都看不到——看到的只有兩張快照的差別。往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。\n\n'
printf '這一節不判逾期(`due` 欄有沒有過),也不執行任何一筆。到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n'
printf '到期與逾期是兩件事,這一節兩件都判:到期是「該跑了」,逾期是「`due` 欄那個截止時間過了」。一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期;一筆有截止時間的交辦可能早就逾期,卻因為排定點還沒到而判成還沒到。這一節不執行任何一筆——到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n'
printf '| 項目 | 內容 |\n'
printf '| --- | --- |\n'
printf '| 判定時間 | %s |\n' "$(epoch_to_iso "$NOW")"
@@ -943,10 +1004,12 @@ compose_md() {
printf '| 本輪新事件 | %s 種 |\n' "$EV_NEW"
printf '| 待辦簿筆數 | %s |\n' "$T_TOTAL"
printf '| 到期 | %s |\n' "$T_DUE"
printf '| 逾期 | %s |\n' "$T_OVERDUE"
printf '| 還沒到 | %s |\n' "$T_WAIT"
printf '| 判不了(欄位不合法) | %s |\n' "$T_INVALID"
printf '| 判不了(來源還沒接線) | %s |\n' "$T_UNWIRED"
printf '| 不判(state 不是 pending) | %s |\n' "$T_SKIP"
[ "$T_DUEBAD" -gt 0 ] && printf '| `due` 欄解不開 | %s |\n' "$T_DUEBAD"
printf '\n#### 狀態來源\n\n'
printf '| 來源 | 狀態 | 筆數 | 說明 |\n'
@@ -975,19 +1038,36 @@ compose_md() {
printf '本輪沒有新事件。\n'
fi
printf '\n#### 逾期\n\n'
if [ "$T_OVERDUE" -gt 0 ]; then
printf '**這幾筆的截止時間過了。逾期每一輪都提,不因為提過就不再提**——助理只提醒,不代為執行,也不自己把它移出這一節。\n\n'
printf '| id | state | 截止時間 | 逾期多久 | 標題 |\n'
printf '| --- | --- | --- | ---: | --- |\n'
printf '%s' "$OVERDUE_LINES" | while IFS="$(printf '\t')" read -r _oi _os _od _oa _ot; do
[ -n "$_oi" ] || continue
printf '| `%s` | %s | %s | %s | %s |\n' "$_oi" "$_os" "$(cell "$_od")" "$(dur_human "$_oa")" "$(cell "${_ot:--}")"
done
printf '\n被暫停的那幾筆也算在裡面:截止時間過了這件事不會因為有人按了暫停就消失,而一筆被暫停又逾期的交辦正好是最容易被忘掉的那一種。\n'
else
printf '沒有逾期的待辦。`due` 欄是空的那幾筆沒有截止時間,永遠不會逾期。\n'
fi
if [ "$T_DUEBAD" -gt 0 ]; then
printf '\n有 %s 筆的 `due` 欄解不開,上表 `overdue` 那一欄是 `bad`。**一個解不開的截止時間跟沒有截止時間,在畫面上長得一模一樣**,而前者是登錄的時候就填錯了:重新登錄一筆,`id` 由建立時間與標題算出來,改欄位值算不回同一個 id。\n' "$T_DUEBAD"
fi
printf '\n#### 逐筆判定\n\n'
if [ -s "$ROWS" ]; then
printf '| id | 判定 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n'
printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n'
printf '| id | 判定 | 逾期 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n'
printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n'
# 這一段交給 awk 一次做完,不用殼的 while read 逐列切。殼的 read 拿定位字元當分隔時會
# 把連續的定位字元併成一個——定位字元算 IFS 的空白字元——於是空欄位整個消失,後面每一欄
# 都往前挪一格,印出來的表格看起來完整,值卻全錯位。awk 的 -F'\t' 不併,空欄位就是空欄位。
awk -F'\t' '
function d(s) { return s == "" ? "-" : s }
function esc(s) { gsub(/\|/, "\\|", s); return s }
NF >= 11 && $1 != "" {
printf "| `%s` | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \
$1, $2, esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10))
NF >= 12 && $1 != "" {
printf "| `%s` | %s | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \
$1, $2, d($12), esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10))
}' "$ROWS"
else
printf '待辦簿零筆,或目錄不存在。這一輪沒有任何一筆可判——「沒判過」不等於「都沒到期」。\n'
@@ -1042,9 +1122,9 @@ cmd_scan() {
# stdout 上的 task= 那幾行是給人看的。trigger、recur、why、title 四欄都可能帶空白,所以這
# 幾行切不回欄位——要逐筆取值的呼叫端讀 rows_file 那個定位字元分隔的檔案,不要切這幾行。
if [ -s "$ROWS" ]; then
awk -F'\t' 'NF >= 11 && $1 != "" {
printf "task=%s verdict=%s state=%s kind=%s action=%s next_run=%s rearm_at=%s trigger=%s recur=%s why=%s title=%s\n", \
$1, $2, $3, $4, $5, $8, $9, $6, $7, $10, $11
awk -F'\t' 'NF >= 12 && $1 != "" {
printf "task=%s verdict=%s state=%s kind=%s action=%s next_run=%s rearm_at=%s trigger=%s recur=%s overdue=%s why=%s title=%s\n", \
$1, $2, $3, $4, $5, $8, $9, $6, $7, $12, $10, $11
}' "$ROWS"
fi
printf 'tasks_total=%s\n' "$T_TOTAL"
@@ -1053,8 +1133,18 @@ cmd_scan() {
printf 'tasks_invalid=%s\n' "$T_INVALID"
printf 'tasks_unwired=%s\n' "$T_UNWIRED"
printf 'tasks_skipped=%s\n' "$T_SKIP"
printf 'tasks_overdue=%s\n' "$T_OVERDUE"
printf 'tasks_due_bad=%s\n' "$T_DUEBAD"
# 逾期的那幾筆逐筆點名,不只印一個數字。一份只有數字的回報讀不出「是哪一筆過期了」,
# 而那正是唯一有用的資訊;逾期的處置一律是人接手,人要知道接哪一筆。
if [ -n "$OVERDUE_LINES" ]; then
printf '%s' "$OVERDUE_LINES" | while IFS="$(printf '\t')" read -r _oi _os _od _oa _ot; do
[ -n "$_oi" ] || continue
printf 'overdue_row=%s state=%s due=%s overdue_secs=%s title=%s\n' "$_oi" "$_os" "$_od" "$_oa" "$_ot"
done
fi
printf 'rows_file=%s\n' "$ROWS"
printf 'rows_columns=id verdict state kind action trigger recur next_run rearm_at why title\n'
printf 'rows_columns=id verdict state kind action trigger recur next_run rearm_at why title overdue\n'
printf 'due_file=%s\n' "$MD"
printf 'prev_file=%s\n' "$PREV"
printf 'seen_file=%s\n' "$SEEN"
@@ -1062,6 +1152,53 @@ cmd_scan() {
finish_rc
}
# 只判逾期,一律唯讀。
#
# 為什麼另開一個子命令:逾期判定跟事件與到期判定不同,它一個狀態都不必比、一份快照都不必推進
# ——只要把每一筆的 due 欄跟現在比一下。狀態查詢那一路每次都想知道「有沒有逾期的」,走 scan
# 就會推進快照,同一輪之後的事件判定會全部落空;走 events 又判不到逾期。
# 讓呼叫端自己拿 due 欄去比更糟:那就是第二套判定,跟這一支的規則會漂移,而漂移的那一天
# 兩邊都說自己是對的。
cmd_overdue() {
while [ "$#" -gt 0 ]; do
case "$1" in
--now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;;
*) usage ;;
esac
done
resolve_now
T_TOTAL=0; T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
if [ ! -d "$TASKS_DIR" ]; then
RC_EMPTY=4
note "待辦簿目錄不存在($TASKS_DIR),這一輪沒有任何一筆可判。「沒判過」不等於「都沒逾期」。"
else
for _f in "$TASKS_DIR"/*; do
[ -f "$_f" ] || continue
T_TOTAL=$(( T_TOTAL + 1 ))
_id=$(kv_get "$_f" id); [ -n "$_id" ] || _id=$(basename "$_f")
_st=$(kv_get "$_f" state); [ -n "$_st" ] || _st=pending
_ti=$(kv_get "$_f" title)
_du=$(kv_get "$_f" due)
[ "$_st" = done ] && continue
_ov=$(overdue_of "$_du") || { T_DUEBAD=$(( T_DUEBAD + 1 )); RC_FIELD=2; }
case "$_ov" in
''|*[!0-9]*) continue ;;
esac
T_OVERDUE=$(( T_OVERDUE + 1 ))
printf 'overdue_row=%s state=%s due=%s overdue_secs=%s human=%s title=%s\n' \
"$_id" "$_st" "$_du" "$_ov" "$(dur_human "$_ov")" "$_ti"
done
[ "$T_TOTAL" -eq 0 ] && { RC_EMPTY=4; note "待辦簿目錄在($TASKS_DIR),但零筆。"; }
fi
printf 'now=%s\n' "$(epoch_to_iso "$NOW")"
printf 'tasks_total=%s\n' "$T_TOTAL"
printf 'tasks_overdue=%s\n' "$T_OVERDUE"
printf 'tasks_due_bad=%s\n' "$T_DUEBAD"
note '這是唯讀判定:一份快照都沒推進,一筆待辦都沒改。逾期的處置一律是人接手。'
cleanup_tmp
finish_rc
}
cmd_events() {
while [ "$#" -gt 0 ]; do
case "$1" in
@@ -1149,6 +1286,7 @@ FIRST_RUN=0
EV_NAMES=''
EV_NEW=0
T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0
T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
CMD="${1:-}"
[ -n "$CMD" ] || usage
@@ -1156,6 +1294,7 @@ shift
case "$CMD" in
scan) cmd_scan "$@" ;;
events) cmd_events "$@" ;;
overdue) cmd_overdue "$@" ;;
next) cmd_next "$@" ;;
*) usage ;;
esac