現行紀錄只記「被叫用」,欄位是 ts、cli、session、skill,沒有成敗也沒有 結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。hook 成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。 事件流走本機檔案,不直接寫 wiki。hook 每次提示都跑,網路寫入會拖垮宿主 CLI;失敗的 hook 自我回報還會疊出迴圈,既有的錯誤回報因此不接在失敗的 hook 上,這裡沿用同一條線。助理巡檢時排空、彙整、寫頁。 hook 端用 EXIT trap 接,一支只加一行。這幾支的 exit 點很多,階段閘門一支 就有五十幾個;逐點改要動到每一條判定路徑,而那些路徑正是閘門的判準,為了 加一行紀錄去動閘門,風險遠大於收益。trap 涵蓋每一條離開路徑,含中途失敗。 狀態預設由結束碼推,推不出來的由 hook 自己覆寫。相依版本檢查與兩道閘門有 這種情形:antigravity 走 deny JSON、kiro 只印警告,兩者擋下時結束碼都是 0, 單看結束碼會把擋下記成放行。 技能的 start 由既有的技能用量 hook 順手發,不必改任何技能文件。end 只能由 技能自己在收尾步驟寫——hook 觸發時技能的實際工作還在後面的模型輪次,看不到 成敗。有 start 沒有配對的 end,就是那一輪中止了。
178 lines
9.3 KiB
Bash
Executable File
178 lines
9.3 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"
|
||
hook_trace "heartbeat ${1:-}"
|
||
|
||
# 這支永遠不讀標準輸入,但 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
|