Files
hooks/hooks/heartbeat.sh
T
jiantw83 b5107563dd chore(manifest): 心跳腳本的版本號補上,檔頭改指正確的技能名
What:
- 三份 manifest 的版本一起提升。
- heartbeat.sh 檔頭引用的技能名由 status 改成 assistant。

Why:
- 上一筆加了 heartbeat.sh 卻沒有動版本號。版本不動,別的 domain 就沒有辦法用相依宣告要求「要有這支腳本的那一版」——宣告寫得出來,卻保證不了內容。助理宣告的下限本來會落在一個不含這支腳本的版本上。
- 檔頭寫的技能名是助理落地初期那一支獨立技能。助理主體把三個操作收攏成一支之後,那個名字就不存在了,照著找會找不到東西。

How:
- 版本由 sync-skill-manifest.sh 同步,三份一致。
- 這一支仍然不接線,只是被助理與閘門呼叫的工具。

Who:
助理主體實作時,從相依宣告那一側回頭抓到的兩個缺口。
2026-09-01 14:18:49 +08:00

177 lines
9.2 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
# heartbeat.sh — 助理心跳檔的讀寫工具。
#
# 助理在背景跑,前景會話看不到它。心跳檔就是它還在跑的唯一證據:助理主體與系統排程每 60 秒
# 寫一次,`jsc-assist:assistant` 與擋人訊息讀這一份,判斷助理在不在。
#
# 這支不是 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