feat(heartbeat): 新增助理心跳的寫入、判定與回報

What:
- 新增 hooks/heartbeat.sh,四個子命令:write 寫心跳、check 判定新鮮、report 印現況、clear 清除。
- README 的 hooks 表補一列,事件欄註明不接線;環境變數表補上心跳門檻那一個。

Why:
- 助理是背景行程,別人要知道它還在不在跑,唯一的依據就是它留下的心跳。閘門要判、status 技能要印、巡檢要記,三邊都需要同一份判定。
- 判定散在三個地方一定會漂移,狀態跟訊息就會對不上。所以判定只寫一份,check 與 report 共用同一個探測函式。

How:
- 新鮮的判準是「檔案存在,而且時間戳距現在小於門檻」。門檻預設 300 秒,是心跳週期的五倍,一次網路或磁碟卡頓不會誤判;環境變數可以覆寫,壞值退回預設而不報錯——變數打錯字不該讓判定整個歪掉。
- 絕不看 pid 存活。五支 CLI 與容器裡的行程互相看不到彼此的 pid,看了也證明不了什麼,pid 只當擋人訊息的線索。
- check 用結束碼分四種狀態:新鮮、過期、不存在、時間戳壞掉。前三種的處置各不相同,擋人訊息要說的話也不一樣;第四種既不是「跑過停了」也不是「沒啟動過」,併進任何一邊都會讓訊息說錯話,而且絕不能退回判成新鮮。
- 寫入走暫存檔再更名。直接覆寫的話,剛好讀到寫一半的檔案會少掉時間戳,助理活著卻被判成壞了。
- 這一支不接線,只是被助理與閘門呼叫的工具。接線是後續獨立的一步,先接會在心跳還沒跑起來時就擋死整組技能。

Who:
助理落地的第一塊:先有心跳,閘門才判得動,主體才有東西可寫。
This commit is contained in:
2026-09-01 14:08:14 +08:00
parent 9de64ead7e
commit b0e352ae15
2 changed files with 178 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env sh
# heartbeat.sh — 助理心跳檔的讀寫工具。
#
# 助理在背景跑,前景會話看不到它。心跳檔就是它還在跑的唯一證據:助理主體與系統排程每 60 秒
# 寫一次,`jsc-assist:status` 與擋人訊息讀這一份,判斷助理在不在。
#
# 這支不是 hook,不接在任何事件上,只被工具端呼叫,所以它擋不到任何人。它只做判定,擋不擋
# 由呼叫端自己決定——助理不參與閘門判定,只負責維持心跳。
#
# 用法(四個子命令都不讀標準輸入,理由見下方「不讀標準輸入」):
# heartbeat.sh write 寫入心跳檔,四個欄位一次寫齊,目錄不存在就建。由助理主體與系統
# 排程呼叫。
# heartbeat.sh check 判定心跳新不新鮮。什麼都不印,結果只在結束碼;要細節請跑 report。
# heartbeat.sh report 印一行心跳現況,格式見下方「report 輸出格式」。
# heartbeat.sh clear 刪除心跳檔。由助理的 stop 呼叫。檔案不存在也算成功。
#
# 結束碼:
# 0 check 判定新鮮(state=fresh);write 寫成功;clear 清完,檔案已經不在;report 印完
# 1 check:心跳檔在、ts 也讀得到,但距現在已達門檻(state=stale)。助理跑過,現在停了。
# 訊息要叫人去查助理為什麼停
# 2 保留給「腳本沒跑起來」:`. lib.sh` 載入失敗時 sh 自己回這一碼(見最下方註)。判定路徑
# 刻意不用 2,兩者才分得開
# 3 check:心跳檔不存在(state=absent)。助理從沒啟動過。處置與 1 不同,訊息要叫人去啟動
# 4 check:心跳檔在、ts 卻讀不出來(state=invalid,缺鍵、空值或不是數字)。檔案壞了,不是
# 助理停了。呼叫端一律當成不新鮮處置,絕不可以退回當成新鮮
# 5 檔案系統操作失敗:write 寫不進去(磁碟滿、權限壞、目錄建不起來),或 clear 刪不掉、
# 檔案還在。這是嚴重狀況——助理沒有心跳就會被自己那道閘門擋掉,所以一定要吵出來,
# 不能安靜當成成功
# 6 用法錯誤:不認得的子命令,或一個子命令都沒給。刻意不與上面任何一種正常狀態共用碼,
# 共用了呼叫端就分不出「助理沒在跑」與「這支腳本被叫錯」
#
# --- 只看 ts,絕不看 pid 存活 ---
#
# 新鮮的判準只有一條:心跳檔存在,而且 ts 距現在小於門檻秒數。pid 一律不拿來判定。
# 一台機器上五支 CLI 各自是獨立行程,助理也可能跑在容器裡,彼此看不到對方的 pid:拿
# `kill -0` 去問,看不到的行程一律回失敗,活著的助理會被判成停了;pid 還會被回收,別人的
# 行程剛好接到同一個號碼,就反過來把停掉的助理判成還在跑。兩種誤判都不會報錯,查起來也沒有
# 線索。pid 只寫進檔案當擋人訊息的線索,讓人自己去查那個行程。
#
# --- 門檻為什麼是 300 ---
#
# 心跳週期是 60 秒,門檻取五倍。一次網路或磁碟卡頓讓某一拍沒寫成,後面還有四拍補得回來,
# 不會誤判成助理停了。門檻用環境變數 JSC_ASSISTANT_HEARTBEAT_TTL 覆寫,單位是秒;值不是
# 正整數就退回 300——環境變數打錯字不該讓判定整個歪掉。
#
# --- 不讀標準輸入 ---
#
# 這支是被工具端呼叫的腳本,四個子命令一律不讀 stdin。理由與 restart-gate.sh 的子命令相同:
# read_stdin 在標準輸入是管線又沒人關閉時會一直等,工具端呼叫就整支卡死。
#
# --- 狀態檔格式 ---
#
# $JSC_HOME/assistant/heartbeat(JSC_HOME 未設定時為 ~/.jsc),純文字 key=value,一行一欄位,
# 順序不拘,不認得的鍵一律忽略:
# ts={epoch 秒數} 寫入當下的時間。判定只看這一欄
# pid={行程 id} 寫入者的行程 id。只當擋人訊息的線索
# cli={CLI 代號} 寫入者是哪一支 CLI,取自 cli_name()
# session={id} 寫入者的工作階段 id,取自 session_id()
# 寫入走「先寫暫存檔、再改名」:改名是原子的,讀的那一端永遠讀到完整的一份。直接覆寫的話,
# 剛好讀到寫一半的檔案會少掉 ts,判定就從 fresh 掉成 invalid,助理明明活著卻被說成壞了。
#
# --- report 輸出格式 ---
#
# 固定一行,鍵的順序固定,欄位以空白分隔。鍵一個都不會少,缺值就只留鍵名,呼叫端不必判斷
# 有沒有這一欄。路徑擺最後,路徑含空白時才不會把後面的欄位吃掉:
# state={fresh|stale|invalid|absent} ts={epoch} age={秒} ttl={秒} pid={} cli={} session={} file={路徑}
# state 的四種值與 check 的結束碼一一對應:fresh=0、stale=1、absent=3、invalid=4。
# 例(心跳新鮮):
# state=fresh ts=1756684800 age=42 ttl=300 pid=31415 cli=claude session=a1b2c3 file=/root/.jsc/assistant/heartbeat
# 例(心跳檔不存在):
# state=absent ts= age= ttl=300 pid= cli= session= file=/root/.jsc/assistant/heartbeat
# ts 不是數字時 ts 與 age 兩欄都印空的:那個值是垃圾,原樣印出來會夾帶空白把欄位切歪。
#
# 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。載入失敗時 sh 會就地結束並回
# 2。這一點的後果與 restart-gate.sh 不同:那支接在 PreToolUse 上,回 2 等於無聲擋下每一次
# 技能呼叫;這支沒接任何 hook,回 2 只會讓呼叫端收到「心跳判不出來」,擋不到任何人。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
# 這支永遠不讀標準輸入,但 session_id() 會去看 STDIN_JSON。先設成空字串,讓它直接走環境
# 變數那條路,不會因為變數沒定義而拿到不確定的值。
STDIN_JSON=""
STATE_DIR="$JSC_HOME/assistant"
STATE="$STATE_DIR/heartbeat"
DEFAULT_TTL=300
# 門檻秒數。環境變數不是正整數就退回預設值,理由見檔頭「門檻為什麼是 300」。
ttl() {
_t="${JSC_ASSISTANT_HEARTBEAT_TTL:-}"
case "$_t" in
''|*[!0-9]*) printf '%s' "$DEFAULT_TTL"; return 0 ;;
esac
if [ "$_t" -gt 0 ] 2>/dev/null; then printf '%s' "$_t"; else printf '%s' "$DEFAULT_TTL"; fi
}
# 從心跳檔取一個欄位;檔案讀不到或欄位不存在就不輸出。
field() { # $1=鍵名
[ -f "$STATE" ] && [ -r "$STATE" ] || return 0
sed -n "s/^$1=//p" "$STATE" 2>/dev/null | head -n1
}
# 判定心跳狀態,印出「{state}<TAB>{ts}<TAB>{age}」,後兩欄在 absent 與 invalid 時留空。
# check 與 report 共用這一份:兩邊各判一次就會漂移,狀態與訊息對不上。
probe() {
if [ ! -f "$STATE" ] || [ ! -r "$STATE" ]; then
printf 'absent\t\t\n'; return 0
fi
_ts=$(field ts)
case "$_ts" in
''|*[!0-9]*) printf 'invalid\t\t\n'; return 0 ;;
esac
# 去掉開頭的 0:POSIX 算術把 08 當八進位,會直接報錯,錯完 age 是空的,判定就整條歪掉。
while :; do
case "$_ts" in 0?*) _ts=${_ts#0} ;; *) break ;; esac
done
_age=$(( $(now_epoch) - _ts ))
# age 是負的代表 ts 在未來,那是時鐘偏移,不是助理停了,照樣算新鮮。
if [ "$_age" -lt "$(ttl)" ]; then _st=fresh; else _st=stale; fi
printf '%s\t%s\t%s\n' "$_st" "$_ts" "$_age"
}
usage() {
printf 'usage: heartbeat.sh {write|check|report|clear}\n' >&2
exit 6
}
case "${1:-}" in
write)
# 心跳檔的位置被目錄或別的東西佔住時要當場失敗。`mv` 遇到目標是目錄會把暫存檔搬進去,
# 搬得成功、心跳檔卻永遠不存在,寫的那一端拿到 0,讀的那一端說助理沒啟動過。
if [ -e "$STATE" ] && [ ! -f "$STATE" ]; then
printf '[jsc][助理心跳][ERR]:%s 不是一般檔案,心跳寫不進去。\n' "$STATE" >&2
exit 5
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
_tmp="$STATE.tmp.$$"
# stderr 先轉走再開檔:順序反過來的話,開檔失敗的訊息是 sh 自己印的,那時 stderr 還沒
# 轉走,會漏到呼叫端的畫面上,蓋掉下面那句講得清楚的錯誤訊息。
if ! printf 'ts=%s\npid=%s\ncli=%s\nsession=%s\n' \
"$(now_epoch)" "$$" "$(cli_name)" "$(session_id)" 2>/dev/null > "$_tmp"; then
rm -f "$_tmp" 2>/dev/null
printf '[jsc][助理心跳][ERR]:寫不進 %s,助理這一拍沒有心跳。\n' "$STATE" >&2
exit 5
fi
if ! mv -f "$_tmp" "$STATE" 2>/dev/null; then
rm -f "$_tmp" 2>/dev/null
printf '[jsc][助理心跳][ERR]:換不上 %s,助理這一拍沒有心跳。\n' "$STATE" >&2
exit 5
fi
exit 0 ;;
check)
case "$(probe | cut -f1)" in
fresh) exit 0 ;;
stale) exit 1 ;;
absent) exit 3 ;;
*) exit 4 ;;
esac ;;
report)
_p=$(probe)
printf 'state=%s ts=%s age=%s ttl=%s pid=%s cli=%s session=%s file=%s\n' \
"$(printf '%s' "$_p" | cut -f1)" \
"$(printf '%s' "$_p" | cut -f2)" \
"$(printf '%s' "$_p" | cut -f3)" \
"$(ttl)" "$(field pid)" "$(field cli)" "$(field session)" "$STATE"
exit 0 ;;
clear)
rm -f "$STATE" 2>/dev/null
# 刪不掉就要講出來:檔案還在,別人讀到的心跳會說助理還在跑。
if [ -e "$STATE" ]; then
printf '[jsc][助理心跳][ERR]:刪不掉 %s,心跳檔還在。\n' "$STATE" >&2
exit 5
fi
exit 0 ;;
*) usage ;;
esac