Author SHA1 Message Date
jiantw83andClaude Opus 5 98dd0761fa docs(assistant): 被權限擋下的指令,回報要帶完整指令列
實測踩到一輪:它報「寫入監控頁失敗——系統的核准機制擋下這個寫入指令,重試
一次仍被擋下,不是 Gitea 回傳的錯誤碼」,然後照規則中止、釋放鎖、不寫心跳。
中止的行為完全正確,寫不成頁就不該假裝跑完。

但那一輪沒說是哪一支工具、哪一條指令。事後把明顯的嫌疑一個一個排除——那一支
腳本實測在無人值守下放行、暫存檔路徑落在檔案規則的範圍內、條目也帶著寫入
確認旗標——真正被擋的那一條始終沒找到。那一輪到現在還是原因不明。

問題在於「擋下」不是工具回的錯誤:沒有結束碼、沒有 stderr、工具自己的日誌上
也沒有痕跡。指令字面是唯一的證據,也是唯一能拿去對允許清單的東西,而清單
比對的是還沒展開的指令字面。所以少了那一行,事後就只能猜。

兩處都加:寫監控頁那一步的失敗分流加一條,界線那一段再加一條通則,涵蓋整輪
任何一次被擋。要求含環境變數前綴——那算指令字面的一部分,今天量過。

三份 manifest 版號從 0.2.9 升到 0.3.1。原本寫 0.2.10,同步 manifest 那一支
把它正規化掉了——那一支不收兩位數的修訂號。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:13:31 +08:00
admin f6a293ddab Merge pull request '拿排程條目記下的 CLI 清單比對,少跑一支就點名' (#38) from feat/report-cli-gap-against-entry-snapshot into develop
Reviewed-on: #38
2026-09-07 01:46:31 +00:00
jiantw83andClaude Opus 5 633f56e7ea docs(assistant): 收尾回報要讀 cli_missing 那一行
巡檢步驟五的記錄清單補上那個欄位,並寫明它是唯一看得出「少跑一支」的地方
——每一支偵測到的目標都跑過、都通過,那一輪也照樣回報都跑過了。

回報要求也寫進去:點名少了哪幾支、講明乾淨通過不等於完整通過、指出重跑排程
安裝才會重新拍快照。

三份 manifest 版號 0.2.8 升到 0.2.9。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:40:00 +08:00
jiantw83andClaude Opus 5 6cb071a3e7 feat(執行): 拿排程條目記下的 CLI 清單比對,少跑一支就點名
偵測 CLI 那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的
PATH 是安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——這台機器實測就有
一支裝在帶行程編號的多殼層目錄,重開機之後那條路徑就不在了。

少了基準的後果不是報錯,是安靜地少做一支。那一輪照樣把偵測到的每一支都跑
過、照樣全部成功、照樣回報「都跑過了」,因為它不知道該有幾支。實測那一輪報
「對 4 支 CLI 都跑過,全部成功」,而機器上裝了 5 支——報告讀起來完全正常。

所以條目多記一個變數,記下安裝當下偵測到哪幾支,作為往後每一輪的參照基準。
判法照組 PATH 那一套逐支 command -v,不另叫外部腳本:install 那一刻本來就用
同一套判過一次,再叫一支等於同一件事兩套實作,而那一支拿得到的資訊跟這裡
一樣多,多繞一層沒有換到任何東西。

讀的那一邊比對之後印 cli_missing= 並點名少了哪一支,同時明說「全部成功不代表
全部跑過」。只報不修:那條路徑本質上是暫時的,真正的解法是那支 CLI 該裝在
穩定位置,或者重跑一次排程安裝重新拍快照——兩件都不是這一支該替人決定的。

舊條目沒有那個變數時安靜通過,不誤報:那一種情況分不出「沒少」與「沒基準」,
而拿「沒基準」當缺失會讓每一台還沒重裝的機器每輪亮一次紅。

實測三種情況:基準與實際一致時不出聲;基準多一支不存在的會點名那一支;完全
沒設基準時印減號、不警告。另外拿條目實際帶的那條 PATH 在清空環境裡重現真機
情境,確實抓到少了那一支。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:40:00 +08:00
admin 0df1605a20 Merge pull request '到期清單的路徑一律由呼叫端餵進來,並拒跑過舊的清單' (#36) from fix/run-due-rows-path-must-be-passed-in into develop
Reviewed-on: #36
2026-09-07 01:02:06 +00:00
jiantw83andClaude Opus 5 4562da2bea docs(assistant): 執行那一步要把同一輪的到期清單路徑餵進去
巡檢步驟五改成帶 --rows,值取步驟一印的那個新欄位,並寫明那不是選用的、
也沒有預設值。步驟一的記錄清單跟著補上那個欄位,並註明它是空的時候代表判到期
那一步沒有產出清單,步驟五就沒有東西可動,要照實說而不是退回任何預設。

會把「為什麼不能有預設」寫進本文,是因為這個缺陷的形狀值得留下來:預設值指向
的位置在單機手動跑過一次之後就會有檔案,而那個檔案讀起來跟新的一模一樣。

三份 manifest 版號 0.2.7 升到 0.2.8。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:59:43 +08:00
jiantw83andClaude Opus 5 ccb28e2bb3 fix(執行): 到期清單的路徑一律由呼叫端餵進來,並拒跑過舊的清單
判到期與執行是兩支腳本,中間靠一個檔案交棒。判到期那一支是被巡檢用 --out
叫的,輸出寫進那一輪自己的暫存目錄;而執行那一支留了一個預設路徑,指向助理
狀態目錄底下那一份。兩邊不是同一個位置。

於是執行那一支從上線到現在,每一輪讀的都是「上一次有人手動跑判定留下的那一
份」。實測那份清單在機器上放了 67 小時,每一輪都被拿去動手,其中一輪還跑了
一筆已經被移除的待辦——那一筆的識別碼在重新種入時換過了。

而它看起來完全正常。那份舊清單產生的當下是對的,內容剛好沒變,所以每一輪的
報告都合理。錯了 67 小時才顯形。

兩處改動。判到期那一步本來只把給人看的那個檔案轉出去,機器可讀的那一份被
吞掉了,現在一起印成 due_rows_file=。執行那一支拿掉預設值:少帶那個選項就
回用法錯誤,吵一次總比安靜地對舊資料動手好。

另外加一道時效保護。判到期與執行之間隔著呼叫端,那一段有可能斷掉——判定失敗
了而那一輪照樣往下走,或呼叫端餵進上一輪的路徑。門檻取心跳的過期門檻:那是
這台機器認定「一輪跑完的紀錄還算新鮮」的長度,一份比它還舊的到期清單本來就
不可能是這一輪產生的。讀不到門檻就退回 300 秒,跟心跳那一支的預設一致。

實測:不帶選項回 6,清單不存在回 2,兩小時前的清單回 2 且訊息帶出實際秒數與
門檻,新的空清單回 0;拿真機那份 67 小時前的舊清單餵進去,確實被擋下。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:59:43 +08:00
8 changed files with 93 additions and 12 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-assist",
"version": "0.2.7",
"version": "0.3.1",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills",
"author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-assist",
"version": "0.2.7",
"version": "0.3.1",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills",
"jsc": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-assist",
"version": "0.2.7",
"version": "0.3.1",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills/",
"jsc": {
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -303,6 +303,7 @@ The delegation list holds one row per jsc skill and records whether that skill c
The six limits in `AGENTS.md`「助理的界線」 hold for all four operations. Four of them need saying out loud here:
- **This skill never judges a gate.** It maintains the heartbeat and prints what the heartbeat says. Whether a stale heartbeat blocks a skill call is decided by a hook, synchronously and offline; nothing in this skill blocks or waves through anything. 界線 2.
- **A command the permission layer refuses is reported with its full command line, wherever in the round it happened.** A refusal is not an error the tool returned: there is no exit code, no stderr, and nothing in the tool's own log. The command text is the only evidence, and it is the only thing that can be checked against the allow list — which matches the text as written, before the shell expands anything. So quote the line verbatim, prefix included, and name the step that submitted it. 界線 1 says a round never asks; this is what makes a refused round diagnosable instead of merely reported. One round wrote 「the write was refused」 and named neither the command nor the tool: every obvious suspect was ruled out afterwards and the real one was never found, so that round is still unexplained.
- **A patrol round asks nothing.** It runs from cron with nobody present, so there is no one to answer and a question hangs the round. Every branch in the patrol steps below resolves without a question: a missing source is recorded as missing, an ambiguous result is recorded verbatim, and a round that cannot proceed aborts and reports. Never call `jsc-ask:ask` from `patrol`. A command that is not on the allow list is a question too — the permission prompt is one, and it is the one nobody sees — which is why step 0 hands that round its root instead of letting it resolve one. 界線 1.
- **A patrol round rewrites the monitor page as three fixed blocks.** Read the old page back first; keep 本頁基本資料 as it stands, replace 最新一輪 whole, put this round's row on top of the summary table and cut it to 24; then put the whole page. The directory page is a separate write in a separate wiki repo, and `wiki-contents.sh` does it: that page keeps one H2 block per machine, and this machine's block is the only one that is updated. A page that could not be read is a page that does not get written — the summary table only survives if the old one came back. 界線 4.
- **A patrol round reports; it never acts on what it found.** The 待人處理 rows name an entry point for a human. The patrol does not run that entry point, does not fix a hook, does not update a plugin and does not touch a repository. 界線 3 and 界線 6.
@@ -346,7 +347,7 @@ That property holds only while nothing fakes a heartbeat. **`write` is called by
One round: read five sources, record the result, then beat. Everything before the heartbeat is read-only except the round's own scratch files. Ask nobody anything.
1. **Collect.** Run `{CURRENT}/jsc-assist/tools/patrol.sh collect --trigger 排程` (use `--trigger 手動` when a person asked for this round). That is the same split step 0 branched on: 排程 is the unattended round that read its root out of the invocation text, 手動 the round somebody asked for. Judge the exit code by the patrol.sh table. Exit 4 stands the round down — report the holder and its age from the printed `lock=busy` line, and stop; write no page and no heartbeat. Exit 5 and 6 stop the round the same way, with the code and the stderr text. Exit 0, 1 and 3 all carry on to step 2. Record `round=`, `lock_broken=`, `hash=`, `page=`, `verdict=`, `failed_sources=`, `warn_sources=`, `pending=`, every `item=` line, and the file paths `latest_file=`, `summary_file=`, `summary_row_file=`, `newpage_file=` and `contents_file=`. Completion condition: the round id, the page name and the five file paths are recorded, or the stand-down or the failure was reported and the round stopped.
1. **Collect.** Run `{CURRENT}/jsc-assist/tools/patrol.sh collect --trigger 排程` (use `--trigger 手動` when a person asked for this round). That is the same split step 0 branched on: 排程 is the unattended round that read its root out of the invocation text, 手動 the round somebody asked for. Judge the exit code by the patrol.sh table. Exit 4 stands the round down — report the holder and its age from the printed `lock=busy` line, and stop; write no page and no heartbeat. Exit 5 and 6 stop the round the same way, with the code and the stderr text. Exit 0, 1 and 3 all carry on to step 2. Record `round=`, `lock_broken=`, `hash=`, `page=`, `verdict=`, `failed_sources=`, `warn_sources=`, `pending=`, every `item=` line, and the file paths `latest_file=`, `summary_file=`, `summary_row_file=`, `newpage_file=`, `contents_file=` and `due_rows_file=` — that last one is what step 5 has to be given, and an empty value there means the judging step produced no list, so step 5 has nothing to act on and says so rather than falling back to anything. Completion condition: the round id, the page name and the five file paths are recorded, or the stand-down or the failure was reported and the round stopped.
**The status event lines come out of the same call.** `collect` drained the stream and rotated it (see 「The status event stream」 above), so record `events_total=`, `events_bad=`, `events_unpaired=`, `events_running=`, `events_rotated=` and `events_file=` alongside the rest, and read `item=D-11` for whether that source was readable at all. The 執行狀態事件 subsection of `latest_file` already carries the two detail tables — the non-`ok` events and the starts with no matching end — so never rebuild either by hand and never call `report-status.sh` yourself: a second `drain` this round would either return exit 3 or eat events that then reach no page at all.
@@ -370,7 +371,7 @@ One round: read five sources, record the result, then beat. Everything before th
**Verify the page's links before the write.** List every link the rebuilt body carries — the ones the latest-round block brought in, and any that survived in the block carried over from the old page — and run `{CURRENT}/jsc-gitea/tools/link-check.sh` over the whole list. Exit 0 is the only result that permits the write. On exit 1 report the `DEAD` lines verbatim, then run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}` and stop: a round that writes a dead link records a false trail nobody can follow back. Exits 2, 3 and 7 take the same abort, each reported by the rule B table above. A body carrying no link at all needs no call — say so in the report rather than claiming a check that never ran.
Put the whole page. Only exit 4 from the read permits creating the page instead, and then the body is the whole content of `newpage_file`, which already carries all three blocks. Exit 7 and exit 8 mean the old content is unknown: create nothing, write nothing — rebuilding a page from an unknown original throws the summary table away. On any write failure — including exit 3 with no wiki repo configured for `MONITOR`, which the patrol cannot ask about — run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. **No record, no heartbeat**, and that verdict belongs to this step alone: the round's result lives on this page, so a repo this step cannot resolve leaves the round with nowhere to be recorded. Step 4 is judged on its own terms. Completion condition: `link-check.sh` exited 0 over the body's links or the body carried none, the put or the create returned success, and the page holds exactly three blocks with the summary table at 24 rows or fewer and this round's row on top, or the abort ran and the round was reported as unrecorded with its exit code.
Put the whole page. Only exit 4 from the read permits creating the page instead, and then the body is the whole content of `newpage_file`, which already carries all three blocks. Exit 7 and exit 8 mean the old content is unknown: create nothing, write nothing — rebuilding a page from an unknown original throws the summary table away. On any write failure — including exit 3 with no wiki repo configured for `MONITOR`, which the patrol cannot ask about — run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. **When the write did not fail but was refused — the permission layer stopped the command before it ran — the report MUST carry the command line verbatim, exactly as it was submitted.** A refusal has no exit code and no stderr from the tool, so the command text is the only thing that can be held against the allow list, and the allow list matches the text as written. A report that says only 「the write was refused」 cannot be acted on: this happened, and the round that hit it named neither the command nor the tool, so every obvious suspect had to be ruled out one at a time and the actual one was never found. Quote the whole line — the environment-variable prefix included, since that counts as part of what the rule is matched against — and say which step submitted it. **No record, no heartbeat**, and that verdict belongs to this step alone: the round's result lives on this page, so a repo this step cannot resolve leaves the round with nowhere to be recorded. Step 4 is judged on its own terms. Completion condition: `link-check.sh` exited 0 over the body's links or the body carried none, the put or the create returned success, and the page holds exactly three blocks with the summary table at 24 rows or fewer and this round's row on top, or the abort ran and the round was reported as unrecorded with its exit code.
4. **Update this machine's block in `MONITOR_CONTENTS`, through `jsc-gitea/tools/wiki-contents.sh`.** That page is a directory every machine writes to, and it lives in the repo `gitea.sh wiki-repo CONTENTS` resolves — `JSC_WIKI_REPO_CONTENTS`, then `JSC_WIKI_REPO`, then exit 3, and never a fallback to `JSC_WIKI_REPO_MONITOR`. The page carries no table: it is an H1, a `>` preamble, and then one H2 block per machine — the heading is that machine's monitor page name, and the fields are one `- {name}:{value}` bullet each underneath. The script owns the read-match-write of one block, so never read this page and rebuild it by hand, never write it through `jsc-gitea:wiki`, and never rebuild it the way step 3 rebuilds the content page — every other block here belongs to a machine that is not this one, and one careless whole-page write deletes their records.
@@ -398,7 +399,7 @@ One round: read five sources, record the result, then beat. Everything before th
Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran.
5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT}`. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was.
5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was.
6. **Write the heartbeat.** Run `{CURRENT}/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code.
+7
View File
@@ -205,6 +205,7 @@ WARN=0
DUE_STATUS=fail
DUE_RC=0
DUE_MD=''
DUE_ROWS=''
DUE_NOTE=''
DUE_TASKS=''
DUE_EVENTS=''
@@ -912,6 +913,11 @@ due_scan() {
JSC_HOME="$JSC_HOME" sh "$_due" scan --out "$RD/due" >"$RD/due.out" 2>"$RD/due.err" </dev/null || _rc=$?
DUE_RC="$_rc"
DUE_MD=$(sed -n 's/^due_file=//p' "$RD/due.out" 2>/dev/null | head -n1)
# 機器可讀的那一份也要轉出去。判到期與執行是兩支腳本,中間靠這個檔案交棒;不轉出去
# 呼叫端就只能靠預設路徑猜,而那個預設指向的是上一次有人手動跑 scan 留下的檔案。
# 實測踩過:那份舊清單在機器上放了六小時,執行那一支每一輪都對它動手,跑的是一筆已經
# 被移除的待辦,而且看起來完全正常——舊清單當時是對的,內容剛好沒變。
DUE_ROWS=$(sed -n 's/^rows_file=//p' "$RD/due.out" 2>/dev/null | head -n1)
DUE_TASKS=$(sed -n 's/^tasks_due=//p' "$RD/due.out" 2>/dev/null | head -n1)
DUE_EVENTS=$(sed -n 's/^events_new=//p' "$RD/due.out" 2>/dev/null | head -n1)
case "$_rc" in
@@ -1157,6 +1163,7 @@ case "$CMD" in
printf 'due_status=%s\n' "$DUE_STATUS"
printf 'due_rc=%s\n' "$DUE_RC"
printf 'due_file=%s\n' "$DUE_MD"
printf 'due_rows_file=%s\n' "${DUE_ROWS:-}"
printf 'pending=%s\n' "$PEND_COUNT"
printf 'events_total=%s\n' "$EV_TOTAL"
printf 'events_bad=%s\n' "$EV_BAD"
+53 -3
View File
@@ -95,7 +95,19 @@ case "$JSC_HOME_RESOLVED" in
/*) ;;
*) die 6 'JSC_HOME 與 HOME 都解不出絕對路徑,找不到助理狀態目錄。' ;;
esac
[ -n "$ROWS" ] || ROWS="$JSC_HOME_RESOLVED/assistant/due/rows.txt"
# --rows 沒有預設值,一律要指定。
#
# 原本有預設,指向助理狀態目錄底下那一份。那個預設是這一支上線之後最嚴重的一個缺陷:
# 判到期那一支是被巡檢用 --out 叫的,輸出寫進那一輪自己的暫存目錄,跟這個預設不是同一個
# 位置。於是這一支每一輪讀的都是「上一次有人手動跑 scan 留下的那一份」。實測那份清單在
# 機器上放了六小時,這一支每一輪都對它動手,還跑了一筆已經被移除的待辦。
# 而它看起來完全正常——那份舊清單產生的當下是對的,內容剛好沒變。**錯了六小時才顯形。**
#
# 所以不留預設。少帶這個選項就回用法錯誤,吵一次總比安靜地對舊資料動手好。呼叫端要拿
# collect 印的 due_rows_file= 餵進來,那是同一輪產生的那一份。
if [ -z "$ROWS" ]; then
die 6 '沒有帶 --rows。這一支不猜到期清單的位置:判到期那一支的輸出寫在叫它的人指定的目錄裡,猜一個預設就會讀到別人留下的舊檔案,而舊檔案讀起來跟新的一模一樣。請把 collect 印的 due_rows_file= 餵進來。'
fi
[ -f "$ROWS" ] || die 2 "到期清單讀不到:$ROWS。請先跑 due.sh scan——沒跑過判定,跟「都沒到期」不是同一件事。"
@@ -121,6 +133,26 @@ find_tool() { # $1=domain $2=相對路徑
return 1
}
# 清單太舊就拒跑。判到期與執行之間隔著呼叫端,那一段有可能斷掉——collect 失敗了而那一輪
# 照樣往下走,或呼叫端餵進上一輪的路徑。兩種都會讓這一支對著一份不描述現況的清單動手,
# 而那正是它上線之後六小時裡在做的事。
# 門檻取心跳的過期門檻:那是這台機器認定「一輪跑完的紀錄還算新鮮」的長度,一份比它還舊的
# 到期清單本來就不可能是這一輪產生的。讀不到門檻就退回 300 秒,跟心跳那一支的預設一致。
_ttl=''
if _hb=$(find_tool hooks hooks/heartbeat.sh 2>/dev/null); then
_ttl=$("$_hb" report 2>/dev/null | sed -n 's/.*[[:space:]]ttl=\([0-9]*\).*/\1/p' | head -n1)
fi
case "${_ttl:-}" in
''|*[!0-9]*) _ttl=300 ;;
esac
_mtime=$(date -r "$ROWS" +%s 2>/dev/null) || _mtime=''
if [ -n "$_mtime" ]; then
_age=$((NOW - _mtime))
if [ "$_age" -gt "$_ttl" ]; then
die 2 "到期清單是 $_age 秒前產生的,超過門檻 $_ttl 秒,這一支不對它動手:$ROWS。一份比心跳門檻還舊的清單不可能描述這一輪,照著跑會動到已經不存在的待辦。請確認判到期那一步這一輪真的跑成功了,再把它印的路徑餵進來。"
fi
fi
TASKS_SH="$HERE/tasks.sh"
[ -f "$TASKS_SH" ] || TASKS_SH=$(find_tool assist tools/tasks.sh) \
|| die 2 '找不到 tasks.sh,成敗沒有地方回寫。待辦簿只有一個寫入者,缺了它這一輪不該跑。'
@@ -137,6 +169,23 @@ if _det=$(find_tool cli tools/detect-clis.sh); then
fi
N_CLI=$(awk 'END{print NR+0}' "$CLIS")
# 跟排程條目安裝當下記下的那一份比對。少了哪一支要點名,不是只印個數字。
#
# 為什麼要比:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的 PATH 是
# 安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——實測有一支裝在帶行程編號的多殼層目錄,
# 重開機之後那條路徑就不在了。
# 沒有基準的後果不是報錯,是安靜地少做一支:這一輪照樣把偵測到的每一支都跑過、照樣全部
# 成功、照樣回報「都跑過了」。實測那一輪報「對 4 支 CLI 都跑過,全部成功」,而機器上裝了
# 5 支——報告讀起來完全正常。
# 只報不修:那條路徑本質上是暫時的,真正的解法是那支 CLI 該裝在穩定位置,或者重跑一次
# 排程安裝重新拍一份快照。兩件都不是這一支該替人決定的。
CLI_MISSING=''
if [ -n "${JSC_ASSIST_CLIS:-}" ]; then
for _want in $JSC_ASSIST_CLIS; do
grep -qxF "$_want" "$CLIS" 2>/dev/null || CLI_MISSING="${CLI_MISSING:+$CLI_MISSING、}$_want"
done
fi
# {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令,
# 而那一輪沒有人看得到它跑到哪裡去了。
REPO_READY=0
@@ -313,10 +362,11 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
fi
done <"$ROWS"
printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s\n' \
printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s cli_missing=%s\n' \
"$([ "$DRYRUN" -eq 1 ] && echo plan || echo run)" "$ROWS" "${ROOT:--}" \
"$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI"
"$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" "${CLI_MISSING:--}"
[ -n "$CLI_MISSING" ] && warn "排程條目記著這台機器有 $JSC_ASSIST_CLIS,但這一輪只偵測到 $N_CLI 支,少了:$CLI_MISSING。帶 {cli} 代入點的那幾筆這一輪少跑了那幾支,而且**全部成功也不代表全部跑過**。成因通常是那支 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),條目帶的 PATH 是安裝當下拍的,那條路徑現在不在了。重跑一次 schedule.sh install patrol 會重新拍一份快照;那支 CLI 反覆消失就要考慮把它裝到穩定位置。"
[ "$N_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。"
[ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。"
[ "$RC_WRITE" -ne 0 ] && warn "有 $N_WRITE_BAD 筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆,逐筆印在上面的 write_failed= 那幾行,各自帶了 tasks.sh 的結束碼。"
+25 -2
View File
@@ -381,6 +381,29 @@ cli_path() {
printf '%s' "${_dirs:+$_dirs:}/usr/bin:/bin"
}
# 條目要記下安裝當下偵測到哪幾支 CLI,作為往後每一輪的參照基準。
#
# 為什麼需要這個基準:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的
# PATH 是安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——這台機器實測就有一支裝在帶行程
# 編號的多殼層目錄,重開機之後那條路徑就不在了。
# 少了基準的後果不是報錯,是安靜地少做一支:那一輪照樣把偵測到的每一支都跑過、照樣全部
# 成功、照樣回報「都跑過了」,因為它不知道該有幾支。實測那一輪報「對 4 支 CLI 都跑過,
# 全部成功」,而機器上裝了 5 支。
# 記下來之後,讀的那一邊才有東西可比,也才答得出「該不該重跑一次 install」。
# 判法照 cli_path() 那一套:逐支用 command -v 試,不叫外部腳本。兩個理由——這一支在 install
# 那一刻本來就已經用同一套判過一次(為了組 PATH),再叫一支等於同一件事兩套實作;而且那一支
# 只看 PATH,跟這裡拿得到的資訊一樣多,多繞一層沒有換到任何東西。
cli_codes() {
_codes=''
for _pair in claude:claude codex:codex copilot:copilot antigravity:agy kiro:kiro-cli; do
_code=${_pair%%:*}; _bin=${_pair#*:}
_abs=$(command -v "$_bin" 2>/dev/null) || continue
case "$_abs" in /*) ;; *) continue ;; esac
_codes="${_codes:+$_codes }$_code"
done
printf '%s' "$_codes"
}
# 條目的指令前綴:固定三個旗標,加上這一刻的環境快照。沒設定的變數直接跳過,不寫空值——
# 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。
# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁
@@ -392,7 +415,7 @@ cli_path() {
# 中止的唯一證據」。不設這個變數,兩半就都落在 CLI 自己給的代號上。
# 2026-09-04 實測:一台機器累積了 91 筆假中止,其中 90 筆是同一支被每輪叫用的技能。
env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值)
_p="PATH=$(shq "$(cli_path)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
_p="PATH=$(shq "$(cli_path)") JSC_ASSIST_CLIS=$(shq "$(cli_codes)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
_names=''
for _n in $(snapshot_names); do
eval "_v=\${$_n:-}"
@@ -494,7 +517,7 @@ trap 'rm -rf "$TMPD"' EXIT
# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡
# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。
PATROL_RESOLVED=''
ENV_PREFIX="PATH=$(shq "$(cli_path)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
ENV_PREFIX="PATH=$(shq "$(cli_path)") JSC_ASSIST_CLIS=$(shq "$(cli_codes)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
SNAPSHOT_NAMES=''
case "$CMD:$JOBS" in
install:*patrol*)