What: - 三份 manifest 的版本一起提升。 - heartbeat.sh 檔頭引用的技能名由 status 改成 assistant。 Why: - 上一筆加了 heartbeat.sh 卻沒有動版本號。版本不動,別的 domain 就沒有辦法用相依宣告要求「要有這支腳本的那一版」——宣告寫得出來,卻保證不了內容。助理宣告的下限本來會落在一個不含這支腳本的版本上。 - 檔頭寫的技能名是助理落地初期那一支獨立技能。助理主體把三個操作收攏成一支之後,那個名字就不存在了,照著找會找不到東西。 How: - 版本由 sync-skill-manifest.sh 同步,三份一致。 - 這一支仍然不接線,只是被助理與閘門呼叫的工具。 Who: 助理主體實作時,從相依宣告那一側回頭抓到的兩個缺口。
177 lines
9.2 KiB
Bash
Executable File
177 lines
9.2 KiB
Bash
Executable File
#!/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
|