From b0e352ae15f8535b522821e514e4fc83fa90bfb6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 14:08:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(heartbeat):=20=E6=96=B0=E5=A2=9E=E5=8A=A9?= =?UTF-8?q?=E7=90=86=E5=BF=83=E8=B7=B3=E7=9A=84=E5=AF=AB=E5=85=A5=E3=80=81?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E8=88=87=E5=9B=9E=E5=A0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: - 新增 hooks/heartbeat.sh,四個子命令:write 寫心跳、check 判定新鮮、report 印現況、clear 清除。 - README 的 hooks 表補一列,事件欄註明不接線;環境變數表補上心跳門檻那一個。 Why: - 助理是背景行程,別人要知道它還在不在跑,唯一的依據就是它留下的心跳。閘門要判、status 技能要印、巡檢要記,三邊都需要同一份判定。 - 判定散在三個地方一定會漂移,狀態跟訊息就會對不上。所以判定只寫一份,check 與 report 共用同一個探測函式。 How: - 新鮮的判準是「檔案存在,而且時間戳距現在小於門檻」。門檻預設 300 秒,是心跳週期的五倍,一次網路或磁碟卡頓不會誤判;環境變數可以覆寫,壞值退回預設而不報錯——變數打錯字不該讓判定整個歪掉。 - 絕不看 pid 存活。五支 CLI 與容器裡的行程互相看不到彼此的 pid,看了也證明不了什麼,pid 只當擋人訊息的線索。 - check 用結束碼分四種狀態:新鮮、過期、不存在、時間戳壞掉。前三種的處置各不相同,擋人訊息要說的話也不一樣;第四種既不是「跑過停了」也不是「沒啟動過」,併進任何一邊都會讓訊息說錯話,而且絕不能退回判成新鮮。 - 寫入走暫存檔再更名。直接覆寫的話,剛好讀到寫一半的檔案會少掉時間戳,助理活著卻被判成壞了。 - 這一支不接線,只是被助理與閘門呼叫的工具。接線是後續獨立的一步,先接會在心跳還沒跑起來時就擋死整組技能。 Who: 助理落地的第一塊:先有心跳,閘門才判得動,主體才有東西可寫。 --- README.md | 2 + hooks/heartbeat.sh | 176 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100755 hooks/heartbeat.sh diff --git a/README.md b/README.md index 80536d9..6e24274 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 | `hooks/deny.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 產出各 CLI 認得的阻擋輸出,訊息從參數或標準輸入進。claude、codex、copilot 訊息寫 stderr 並回 exit 2;antigravity 印 stdout 的 `{"decision":"deny","reason":"..."}` 並固定回 0——那支 CLI 的結束碼語意兩邊文件都沒寫,靠結束碼會變成「判定擋下、CLI 照樣放行」的無聲失效,所以 stdout 只准有那一行;kiro 擋不下技能叫用,改印警告後回 0;認不得的代號走 stderr 加 2 這個保守預設 | | `hooks/version-guard.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 技能使用前的版本前置檢查,擋兩種情況,兩種都擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給;技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列,這裡不重寫第二套):一是本機**實際載入**版本落後遠端發佈版本,二是技能所屬 plugin 的 manifest 在 `jsc.requires` 宣告的相依 plugin 版本落後——相依那一項讀 `installPath` 底下那份 `plugin.json`,逐項比對相依 plugin 的本機實際載入版本,訊息講明哪一個 plugin、需要哪一版、目前哪一版、怎麼補。相依檢查排在遠端比對之前,全部讀本機檔案,離線也判得動;判定邏輯自己實作,不呼叫 `jsc-cli/tools/check-requires.sh`,免得 hook 散落到別的 domain,也免得跟已宣告相依 `jsc-hooks` 的 `jsc-cli` 做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。兩種都只擋確定落後:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本、解不出安裝路徑、讀不到 manifest、manifest 沒有 `jsc.requires`、讀不到相依 plugin 的本機載入版本也一律放行。遠端版本快取在 `$JSC_HOME/version-cache/{CLI 代號}/{domain}`,一支 CLI 一份;舊路徑 `$JSC_HOME/version-cache/{domain}` 會在第一次讀取時複製到新路徑。逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-cli:models`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-gitea:wiki`——共 7 項,兩種擋人情況共用同一份,相依落後不另立短清單;清單的唯一來源是 `hooks/version-guard.sh` 的檔頭,那裡一項一個理由 | | `hooks/restart-gate.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 部署後強制重啟閘門:`$JSC_HOME/restart-required.d/{CLI 代號}` 一支 CLI 一份,當前 CLI 那份存在時擋下 jsc 技能呼叫,並印出要重新啟動哪一支 CLI(技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列);別支 CLI 那幾份不影響這一支。狀態檔由 `jsc-cli:deploy` 在 install 或 update 收尾時經 `restart-gate.sh require {install|update} [{domain}...]` 寫入當前 CLI 那一份,在下一個工作階段開始時由 `session-timer.sh` 呼叫 `restart-gate.sh clear` 只清除那一份。判定看檔案在不在:狀態檔讀不到、CLI 代號取不到、技能名取不到都放行(理由與 `version-guard.sh` 一致,只擋確定違規)。舊格式的單一檔案 `$JSC_HOME/restart-required` 存在時一律擋,`clear` 會一併刪掉它(過渡相容,詳見下面「部署後重啟狀態檔」)。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`——部署後還要寫得完技能組異動報告與工作日誌,hook 壞掉也要修得回來,整批擋下去這些規則會互相打死。清單認技能名不認呼叫鏈,後三支是為了讓前七支走得完才補進來的:`deploy` 要問模式、報告寫完要開 PR。另有唯讀子指令 `report`,一支 CLI 一行印出每一份狀態檔的內容,看得出還有哪幾支沒重啟。逃生門 `JSC_RESTART_GATE=off` | +| `hooks/heartbeat.sh` | 不接線,由 `jsc-assist` 的助理主體、系統排程與 `status` 技能呼叫 | 助理心跳檔 `$JSC_HOME/assistant/heartbeat` 的讀寫工具,純文字 key=value,欄位 `ts`、`pid`、`cli`、`session`。四個子命令:`write` 寫入四個欄位(目錄不存在就建,先寫暫存檔再改名,讀的那一端永遠讀到完整的一份)、`check` 判定新不新鮮(什麼都不印,結果只在結束碼)、`report` 印一行現況供 `status` 技能與擋人訊息取用、`clear` 刪除心跳檔(由助理的 `stop` 呼叫,檔案不存在也算成功)。新鮮的判準只有一條:心跳檔存在、而且 `ts` 距現在小於門檻秒數,門檻預設 300(心跳週期 60 秒的五倍,一次卡頓不會誤判),可用 `JSC_ASSISTANT_HEARTBEAT_TTL` 覆寫。**絕不看 pid 存活**:五支 CLI 與容器裡的行程互相看不到彼此的 pid,問了會把活著的判成停了,pid 又會被回收,反過來把停掉的判成還在跑,兩種誤判都不報錯;pid 只當擋人訊息的線索。`check` 的結束碼分四種讓呼叫端各自處置:0 新鮮、1 過期(跑過但停了)、3 心跳檔不存在(從沒啟動過)、4 檔案在但 `ts` 讀不出來(檔案壞了);`write` 與 `clear` 的檔案系統失敗回 5,用法錯誤回 6。四個子命令都不讀標準輸入——這支不是 hook,是被工具端呼叫的腳本,讀了會在管線沒人關閉時整支卡死。逐碼意義與 `report` 的欄位順序見腳本檔頭 | | `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 | | `hooks/comment-scope.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 程式碼註解不得夾帶文件相關資訊與審查流程痕跡,共三種模式。`prompt`:在每次提示注入規則摘要(禁止項與白名單各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。掃描時機每個 CLI 不同——claude 逐檔即時(PostToolUse)、codex 每輪結束(`notify`)、kiro 每輪提示送出時(`userPromptSubmit`,掃的是上一輪寫的檔)、copilot 與 antigravity 只有工作階段結束時由 `tools/jsc-wrap.sh` 收尾掃一次。兩種掃描模式都只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入,檔案已經寫好了)。markdown、純文字、資料檔與二進位檔一律跳過。只實作可用樣式判定的項目,專案代號、客戶名稱這類判不出來的交給 `/jsc-review:code-review`。規則正文的唯一來源在 `jsc-review` 的 `references/comment-scope.md`,本存取庫不留副本。逃生門 `JSC_COMMENT_SCOPE=off` | | `hooks/lang-guard.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 所有非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字,共三種模式。`prompt`:在每次提示注入規則摘要(適用範圍與自我檢查各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。接線位置與掃描時機跟 `comment-scope.sh` 完全一樣,見下面那張表。偵測三項:簡體字(字表在 `hooks/simplified.txt`,讀不到就安靜跳過這一項)、亂碼(U+FFFD 替代字元與雙重編碼殘骸)、非 UTF-8 編碼(用 `iconv` 判定,沒有 `iconv` 就跳過)。三項都掃整個檔案、不只掃註解行,`.md` 與純文字檔照掃——那些正是「非程式碼輸出」的主場,這兩點跟 `comment-scope.sh` 刻意不同。掃描深度仍只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入)。二進位檔(只認 NUL 位元組)與 `*.lock`、`*.min.js`、`*.map` 這類產生檔跳過;`hooks/simplified.txt`、`hooks/ste100-guard.sh`、`hooks/lang-guard.sh` 也跳過,那三份檔案裡的簡體字與亂碼樣本是被討論的對象,不是被使用。規則正文的唯一來源在 `jsc-meta` 的 `references/ste100.md`。逃生門 `JSC_LANG_GUARD=off` | @@ -217,6 +218,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in | `JSC_LANG_GUARD` | 設 `off` 完全略過繁中與編碼檢查(`lang-guard.sh` 三種模式都直接結束) | 啟用檢查 | | `JSC_WRITE_GUARD` | 設 `off` 完全略過寫入與提交閘門(`write-guard.sh` 三種模式都直接結束) | 啟用閘門 | | `JSC_WRITE_GUARD_TTL` | `write-guard.sh review` 判定「稽核技能還在跑」的時效秒數 | 預設 900 | +| `JSC_ASSISTANT_HEARTBEAT_TTL` | `hooks/heartbeat.sh check` 判定心跳新鮮的門檻秒數。值不是正整數就退回預設值 | 預設 300(心跳週期 60 秒的五倍) | | `JSC_READONLY` | 設 `1` 時 `tools/wire-cli.sh` 只准 `status` 與 `smoke`,`purge` 與接線一律拒絕並回 exit 6 | 四個用法都可執行 | | `JSC_CHANGED_FILE` | 非 Claude CLI 要掃描的檔案路徑,代替 stdin JSON 的 `file_path`,供 `comment-scope.sh` 與 `lang-guard.sh` 使用 | 安靜降級,不掃描 | | `JSC_TOOL_COMMAND` | 非 Claude CLI 要判定的 Bash 指令字串,代替 stdin JSON 的 `command`,供 `write-guard.sh commit` 使用 | 安靜降級,不判定 | diff --git a/hooks/heartbeat.sh b/hooks/heartbeat.sh new file mode 100755 index 0000000..522cab8 --- /dev/null +++ b/hooks/heartbeat.sh @@ -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}{ts}{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