釋出:到期的內建檢查項真的跑一遍,成敗回寫待辦簿 #27

Merged
admin merged 3 commits from develop into master 2026-09-04 06:08:58 +00:00
6 changed files with 352 additions and 8 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-assist", "name": "jsc-assist",
"version": "0.2.2", "version": "0.2.3",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-assist", "name": "jsc-assist",
"version": "0.2.2", "version": "0.2.3",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills", "skills": "./skills",
"jsc": { "jsc": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-assist", "name": "jsc-assist",
"version": "0.2.2", "version": "0.2.3",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills/", "skills": "./skills/",
"jsc": { "jsc": {
File diff suppressed because one or more lines are too long
+21 -4
View File
@@ -75,6 +75,7 @@ Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` sta
| the system scheduler | `{CURRENT}/jsc-assist/tools/schedule.sh` | | the system scheduler | `{CURRENT}/jsc-assist/tools/schedule.sh` |
| the task book, the only writer there is | `{CURRENT}/jsc-assist/tools/tasks.sh` | | the task book, the only writer there is | `{CURRENT}/jsc-assist/tools/tasks.sh` |
| the built-in check items, reconciled against the delegation list | `{CURRENT}/jsc-assist/tools/seed-tasks.sh` | | the built-in check items, reconciled against the delegation list | `{CURRENT}/jsc-assist/tools/seed-tasks.sh` |
| the due built-in check items, actually run | `{CURRENT}/jsc-assist/tools/run-due.sh` |
| the heartbeat | `{CURRENT}/jsc-hooks/hooks/heartbeat.sh` | | the heartbeat | `{CURRENT}/jsc-hooks/hooks/heartbeat.sh` |
| the status event stream | `{CURRENT}/jsc-hooks/tools/report-status.sh` | | the status event stream | `{CURRENT}/jsc-hooks/tools/report-status.sh` |
| the wiki, through `jsc-gitea:wiki` | `{CURRENT}/jsc-gitea/tools/gitea.sh` | | the wiki, through `jsc-gitea:wiki` | `{CURRENT}/jsc-gitea/tools/gitea.sh` |
@@ -262,7 +263,7 @@ The delegation list holds one row per jsc skill and records whether that skill c
**A `probe` that cannot be substituted falls back to `remind` and is reported, never dropped.** The path is not `{root}/jsc-{domain}/...`, the command carries a `$` or a `~`, a brace other than the three known holes survived, the root could not be resolved, or the script is not on this machine: each prints `probe_bad=` and the entry is still seeded, as a reminder. Not seeding it would mean removing it on `apply`, so one mistyped cell upstream would delete an entry carrying its own `last_run` and `fail_count` history. **A `probe` that cannot be substituted falls back to `remind` and is reported, never dropped.** The path is not `{root}/jsc-{domain}/...`, the command carries a `$` or a `~`, a brace other than the three known holes survived, the root could not be resolved, or the script is not on this machine: each prints `probe_bad=` and the entry is still seeded, as a reminder. Not seeding it would mean removing it on `apply`, so one mistyped cell upstream would delete an entry carrying its own `last_run` and `fail_count` history.
**`{root}` is substituted here; `{cli}` and `{repo}` are deliberately left in the value.** The CLI list has to be detected and the repositories have to be scanned, and neither is known at seeding time. Expanding into several entries instead would give one `spec_key` several files — the reconcile treats that as `dup=` and refuses to touch any of them — and would go stale the moment a CLI is installed or a repository cloned, with re-expansion costing the history it just protected. So the hole stays, and one rule pays for it: **an `action` containing a brace is not yet a runnable command and must never be executed as written.** Nothing executes an `action` today — `tasks.sh` stores it, `due.sh` prints it, `status` and the monitor page print it — so the rule is aimed at whoever wires execution up later: substitute `{cli}` with each detected CLI token and `{repo}` with each scanned repository working directory, run once per target, and let the targets' results together count as this entry's one success or failure. **`{root}` is substituted here; `{cli}` and `{repo}` are deliberately left in the value.** The CLI list has to be detected and the repositories have to be scanned, and neither is known at seeding time. Expanding into several entries instead would give one `spec_key` several files — the reconcile treats that as `dup=` and refuses to touch any of them — and would go stale the moment a CLI is installed or a repository cloned, with re-expansion costing the history it just protected. So the hole stays, and one rule pays for it: **an `action` containing a brace is not yet a runnable command and must never be executed as written.** `run-due.sh` is what executes one, and it is the only thing that does: `tasks.sh` stores the value, `due.sh` prints it, `status` and the monitor page print it. That tool substitutes `{cli}` from `jsc-cli/tools/detect-clis.sh` and runs once per target, with the targets' results together counting as the entry's one success or failure. `{repo}` has no source yet — the repository scan is not built — so an entry carrying that hole is held, reported, and left with its `last_run` and `fail_count` untouched. **Holding is not failing**: an entry that cannot be given a target did nothing wrong, and recording it as a failure grows a counter nobody can bring down by fixing that entry, which is exactly what that counter exists to make visible.
**A `pending` row stays a reminder but is never reported as an ordinary one.** The reason lives in the report, as a `pending=` line, and nothing is written into the task file. Putting the marker in the title would change the `id` and cut that entry's history, and the drift check compares `kind`, `action`, `trigger` and `recur` but not the title, so a stale marker would never be caught; adding a sixteenth key would change the task book's fixed storage format for a piece of upstream prose the task book has no way to edit later. `status` runs `plan`, so `pending=`, `held=` and `drift=` all reach a human in the same place. **A `pending` row stays a reminder but is never reported as an ordinary one.** The reason lives in the report, as a `pending=` line, and nothing is written into the task file. Putting the marker in the title would change the `id` and cut that entry's history, and the drift check compares `kind`, `action`, `trigger` and `recur` but not the title, so a stale marker would never be caught; adding a sixteenth key would change the task book's fixed storage format for a piece of upstream prose the task book has no way to edit later. `status` runs `plan`, so `pending=`, `held=` and `drift=` all reach a human in the same place.
@@ -284,6 +285,19 @@ The delegation list holds one row per jsc skill and records whether that skill c
| 5 | Filesystem failure — the scratch directory or a scratch file could not be written | Report it with the path; the reconcile could not even be computed | | 5 | Filesystem failure — the scratch directory or a scratch file could not be written | Report it with the path; the reconcile could not even be computed |
| 6 | Usage error — an unknown subcommand or option, a `--root` that is not absolute, or an `--allow-cond` value that is not `jsc-{domain}:{skill}` | A defect in the call. Correct it and run it once more | | 6 | Usage error — an unknown subcommand or option, a `--root` that is not absolute, or an `--allow-cond` value that is not `jsc-{domain}:{skill}` | A defect in the call. Correct it and run it once more |
## run-due.sh exit codes
| Code | Meaning | What to do |
| --- | --- | --- |
| 0 | The round's due entries were dealt with. Zero due entries, and every entry held for want of a target, are both this code | Carry on, and carry the `done=`, `failed=`, `held=`, `skip=` and `write_failed=` counts into the report |
| 1 | At least one entry's command returned non-zero. That entry is already recorded as failed and its failure count went up; the rest still ran | Report every `failed=` and `target_fail=` line with the command and the first line of its output. This is a finding about the thing that was checked, not a fault in the round |
| 2 | The due list could not be read, or its column count is not the one this tool knows, so **nothing ran** | Report it and say the judging step has not run, or its output format changed. Never read this as "nothing was due" |
| 4 | At least one write-back to `tasks.sh` failed. The command ran but the task book did not record it, so the same entry runs again next round | Say that out loud with the `write_failed=` lines and their `tasks.sh` codes — a command that runs every round and is never recorded is the failure mode this code exists to name |
| 5 | Filesystem failure — a scratch file could not be written | Report it with the path |
| 6 | Usage error — an unknown sub-command or option, a missing option value, or a `--root` that is not absolute | A defect in the call. Correct it and run it once more |
**A held entry is not a failed entry.** `held=` covers an entry whose `{cli}` or `{repo}` could not be given a target, and one whose command no longer passes the shape check. Its `last_run` and `fail_count` are left exactly as they were, and the report has to keep that distinction: an entry nobody could give a target to did nothing wrong, and letting its failure count climb buries the entries that really are broken under ones that are merely unwired.
## Boundaries ## Boundaries
The six limits in `AGENTS.md`「助理的界線」 hold for all four operations. Four of them need saying out loud here: The six limits in `AGENTS.md`「助理的界線」 hold for all four operations. Four of them need saying out loud here:
@@ -292,6 +306,7 @@ The six limits in `AGENTS.md`「助理的界線」 hold for all four operations.
- **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 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 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. - **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.
- **Running the due built-in items is not an exception to that.** Those entries are the assistant's own scheduled work, put there by a reconcile against a delegation list that goes through review; a 待人處理 row is a finding about somebody else's machine state. The first is a round doing the job it was given, the second is a round deciding what somebody else's job is. Step 5 keeps the line where it belongs by running only read-only probe commands from the list, never the skill named by an `invoke` row and never anything a person entered by hand — and the moment a command that writes appears in that column, this paragraph is the one that has to be re-argued, not quietly widened.
- **Only a human-initiated operation reconciles the built-in items; an unattended round reports the difference and stops there.** `start` runs `seed-tasks.sh apply`, because somebody asked for it and is there to read what it added and removed. `status` runs `seed-tasks.sh plan`, which writes nothing. `patrol` runs neither: removing a check entry destroys that entry's `last_run` and `fail_count` history, and 界線 5 keeps destructive cleanup with the human — a round that deletes a row at three in the morning because the list was mid-sync leaves nobody able to see that the row ever existed. The consequence is worth stating: on a machine nobody starts or inspects, a list change reaches the task book only at the next `start`. 界線 3 and 界線 5. - **Only a human-initiated operation reconciles the built-in items; an unattended round reports the difference and stops there.** `start` runs `seed-tasks.sh apply`, because somebody asked for it and is there to read what it added and removed. `status` runs `seed-tasks.sh plan`, which writes nothing. `patrol` runs neither: removing a check entry destroys that entry's `last_run` and `fail_count` history, and 界線 5 keeps destructive cleanup with the human — a round that deletes a row at three in the morning because the list was mid-sync leaves nobody able to see that the row ever existed. The consequence is worth stating: on a machine nobody starts or inspects, a list change reaches the task book only at the next `start`. 界線 3 and 界線 5.
- **`stop` clearing the heartbeat and removing the schedule is not a breach of 界線 5「不刪除狀態檔」.** That limit protects state that records work — the task book, worktrees, wiki pages — from a background process nobody is watching. The heartbeat records one fact only, "the last patrol round finished", and the schedule entry is what keeps rounds running, so a `stop` that leaves either behind leaves a lie behind. Clearing both is the whole job of `stop`, and they are the only deletions any operation here performs, both of them entries this skill installed itself. `stop` touches nothing under `tasks/`, nobody else's cron entry, no worktree and no wiki page. Do not "restore" this limit later by taking either removal out of `stop`. - **`stop` clearing the heartbeat and removing the schedule is not a breach of 界線 5「不刪除狀態檔」.** That limit protects state that records work — the task book, worktrees, wiki pages — from a background process nobody is watching. The heartbeat records one fact only, "the last patrol round finished", and the schedule entry is what keeps rounds running, so a `stop` that leaves either behind leaves a lie behind. Clearing both is the whole job of `stop`, and they are the only deletions any operation here performs, both of them entries this skill installed itself. `stop` touches nothing under `tasks/`, nobody else's cron entry, no worktree and no wiki page. Do not "restore" this limit later by taking either removal out of `stop`.
@@ -377,13 +392,15 @@ 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. 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. **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. 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.
6. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all five sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name, the link-check verdict for each of the two writes — passed, skipped for a body with no link, or refused with its exit code and its `DEAD` lines — and the directory entry as `updated`, `added`, or not written with the exit code and the reason; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL. 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. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all five sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name, the link-check verdict for each of the two writes — passed, skipped for a body with no link, or refused with its exit code and its `DEAD` lines — and the directory entry as `updated`, `added`, or not written with the exit code and the reason; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL.
**The event numbers get their own line, and the unpaired starts get their own list.** Print `events_total=` and `events_bad=` as this round's event count and its non-`ok` count, then every non-`ok` event with its `kind`, `name`, `status`, `exit` and `detail`, then — separately, never folded into the same list — every start with no matching end, by `name` and `session`. A non-zero `events_unpaired=` is the round's most important finding: each row is a skill run that started and never reached its closing step. Say `events_rotated=` too when it is `rotated` or `failed`. When `item=D-11` failed, say the source could not be read rather than reporting zero events — zero read events and zero existing events look identical in a report and mean opposite things. **The event numbers get their own line, and the unpaired starts get their own list.** Print `events_total=` and `events_bad=` as this round's event count and its non-`ok` count, then every non-`ok` event with its `kind`, `name`, `status`, `exit` and `detail`, then — separately, never folded into the same list — every start with no matching end, by `name` and `session`. A non-zero `events_unpaired=` is the round's most important finding: each row is a skill run that started and never reached its closing step. Say `events_rotated=` too when it is `rotated` or `failed`. When `item=D-11` failed, say the source could not be read rather than reporting zero events — zero read events and zero existing events look identical in a report and mean opposite things.
Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all five items appear in the report, the event count, the non-`ok` count and the unpaired starts are stated, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on. **Then report step 5 in its own block**, because it is the only place the task book's own work is accounted for: how many entries were due, how many ran, how many came back clean and how many failed, then every `failed=` row by name with the first line of its command's output, every `held=` row with what could not be given a target, and every `write_failed=` row as an entry that ran without being recorded. Say plainly that a held entry's history was left untouched. Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all five items appear in the report, the event count, the non-`ok` count and the unpaired starts are stated, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on.
## status ## status
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env sh
# run-due.sh — 把到期的內建檢查項真的跑一遍,成敗回寫待辦簿。
#
# 用法:
# run-due.sh run [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}] [--dry-run]
# run-due.sh plan [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}]
#
# run 逐筆執行並回寫待辦簿。--dry-run 只印要跑什麼、不執行也不回寫
# plan 等同 run --dry-run,另取一個名字是為了讓唯讀那一路在指令列上看得出來
#
# 結束碼:
# 0 這一輪跑完了。零筆到期、每一筆都代不出目標,都算跑完
# 1 至少一支指令回非零。那一筆已經記成失敗、失敗次數加一,其餘各筆照跑
# 2 到期清單讀不到或欄位對不上,**一筆都沒跑**。判到期那一支沒跑過,或它的輸出換了格式
# 4 至少一筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆
# 5 檔案系統失敗:暫存檔寫不進去
# 6 用法錯誤:不認得的子命令或選項、選項缺值、--root 不是絕對路徑
# 同時命中好幾碼時,回報順序是 5、2、4、1:前面的蓋掉後面的。4 排在 1 前面是因為
# 「跑了但沒記到」會讓同一件事每輪重跑,比「跑了而且失敗」更需要人知道。
#
# --- 這一支只跑指令型,不叫技能也不送提醒 ---
#
# 待辦簿的動作欄有三種值:一行指令、技能名、remind。這一支只跑第一種。
# 技能名那一種不跑的理由:叫用一整支技能的代價與風險都大得多——它會寫 wiki、開 PR、改檔案,
# 而這一支跑在沒有人看的那一輪裡。唯讀盤點指令失敗最多就是回一個非零碼,那一筆記一次失敗;
# 一支技能中途失敗可能留下寫到一半的頁或開錯的 PR。兩種代價不同,就該分兩次判、分兩次接。
# remind 那一種不跑的理由更直接:它本來就沒有東西可跑,提醒怎麼送到前景是另一件待辦。
#
# --- 只跑內建項,使用者交辦的一律不碰 ---
#
# 只有 spec_key 非空的那幾筆會被執行,也就是依委派清單種入的內建項。
# 理由是那幾筆的指令經過種入那一支的檢核:路徑一定是 {root}/jsc-{domain}/ 開頭、不含金錢符號
# 與波浪號、代入點只認得三個、腳本一定在這台機器上。使用者親手登錄的那幾筆沒有經過那道關,
# 動作欄想寫什麼都行,在無人值守那一輪把它送進殼是另一回事,要另外判。
# 執行這一刻再驗一次同樣那幾項,不因為種入時驗過就省掉:待辦檔是純文字,中間可能被改。
#
# --- 權限閘門與這一支的關係,講白 ---
#
# 這一支自己執行那些指令,所以權限層只看得到 run-due.sh 這一條指令,看不到裡面跑了什麼。
# 那不是繞過閘門,是閘門本來就不是沙箱:它是一份「可信入口」的白名單,而清單上的每一支腳本
# 本來就做得了它該做的事——巡檢那一支會寫 wiki 頁,部署那一支會改整台機器的外掛。
# 防線改由三層擋:一、只跑 spec_key 非空的內建項,那幾筆的來源是版本控管、要走 PR 的委派清單;
# 二、執行前再驗一次指令形狀,不信任待辦檔的內容;三、代不出來的目標一律不跑。
# 這一段寫在這裡是因為下一個維護的人一定會問,而「為什麼不會被擋」跟「為什麼不需要被擋」
# 是兩個不同的答案,後者才是對的那一個。
#
# --- 代不出目標不算失敗 ---
#
# {cli} 由偵測到的 CLI 代號代入,{repo} 由掃到的存取庫工作目錄代入。後者的掃描還沒做出來,
# 所以帶 {repo} 的那幾筆這一輪代不出目標。處置是印一行 held= 就跳過,**不動那一筆的
# last_run,也不加失敗次數**。
# 那一筆沒有做錯任何事:代不出目標是這一支還缺一塊,記成失敗會讓一個沒有人修得動的計數
# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。
# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。
set -u
usage() {
echo 'usage: run-due.sh {run|plan} [--rows 檔案] [--root 絕對路徑] [--now epoch] [--dry-run]' >&2
exit 6
}
die() { _c=$1; shift; printf '[jsc][助理執行][ERR]:%s\n' "$*" >&2; exit "$_c"; }
note() { printf '[jsc][助理執行]:%s\n' "$*" >&2; }
warn() { printf '[jsc][助理執行][WARN]:%s\n' "$*" >&2; }
[ "$#" -ge 1 ] || usage
MODE=$1; shift
case "$MODE" in
run) DRYRUN=0 ;;
plan) DRYRUN=1 ;;
*) usage ;;
esac
ROWS=''
OPT_ROOT=''
NOW=''
while [ "$#" -gt 0 ]; do
case "$1" in
--rows) [ "$#" -ge 2 ] || usage; ROWS=$2; shift 2 ;;
--root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;;
--now) [ "$#" -ge 2 ] || usage; NOW=$2; shift 2 ;;
--dry-run) DRYRUN=1; shift ;;
*) usage ;;
esac
done
case "$OPT_ROOT" in
''|/*) ;;
*) die 6 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;;
esac
# 助理狀態目錄。判到期那一支把 rows.txt 放在這底下,兩支要對得上同一個位置。
JSC_HOME_RESOLVED=${JSC_HOME:-${HOME:-}/.jsc}
case "$JSC_HOME_RESOLVED" in
/*) ;;
*) die 6 'JSC_HOME 與 HOME 都解不出絕對路徑,找不到助理狀態目錄。' ;;
esac
[ -n "$ROWS" ] || ROWS="$JSC_HOME_RESOLVED/assistant/due/rows.txt"
[ -f "$ROWS" ] || die 2 "到期清單讀不到:$ROWS。請先跑 due.sh scan——沒跑過判定,跟「都沒到期」不是同一件事。"
TAB=$(printf '\t')
TMPD=$(mktemp -d 2>/dev/null) || die 5 '暫存目錄建不起來。'
trap 'rm -rf "$TMPD"' EXIT
[ -n "$NOW" ] || NOW=$(date -u +%s)
NOW_ISO=$(date -u -d "@$NOW" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
|| NOW_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
# --- 找同一組工具 ---
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
ROOT="$OPT_ROOT"
# 沒帶 --root 就從自己的位置往上推兩層。呼叫端該餵進來,這只是開發時跑得動的退路。
[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT=''
find_tool() { # $1=domain $2=相對路徑
for _d in "jsc-$1" "$1"; do
[ -n "$ROOT" ] && [ -f "$ROOT/$_d/$2" ] && { printf '%s' "$ROOT/$_d/$2"; return 0; }
done
return 1
}
TASKS_SH="$HERE/tasks.sh"
[ -f "$TASKS_SH" ] || TASKS_SH=$(find_tool assist tools/tasks.sh) \
|| die 2 '找不到 tasks.sh,成敗沒有地方回寫。待辦簿只有一個寫入者,缺了它這一輪不該跑。'
DUE_SH="$HERE/due.sh"
[ -f "$DUE_SH" ] || DUE_SH=$(find_tool assist tools/due.sh) || DUE_SH=''
# --- {cli} 的代入來源 ---
# 偵測到的 CLI 代號,一行一個。取不到就當成代不出來,帶 {cli} 的那幾筆這一輪跳過。
CLIS="$TMPD/clis.txt"
: >"$CLIS"
if _det=$(find_tool cli tools/detect-clis.sh); then
"$_det" 2>/dev/null | awk -F"$TAB" 'NF>0 && $1 != "" {print $1}' >"$CLIS" 2>/dev/null || : >"$CLIS"
fi
N_CLI=$(awk 'END{print NR+0}' "$CLIS")
# {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令,
# 而那一輪沒有人看得到它跑到哪裡去了。
REPO_READY=0
# --- 判斷動作是哪一種 ---
# 技能名的形狀是 jsc-{domain}:{技能名}。比對整串,不用前綴比對:一行以 jsc- 開頭的指令
# 路徑不該被當成技能名。
is_skill_name() { # $1=action
case "$1" in
jsc-*:*)
case "$1" in
*' '*|*/*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
# 執行前再驗一次指令形狀。種入那一支驗過同樣幾項,這裡不省:待辦檔是純文字,中間可能被改。
# 驗不過就當成代不出來,跳過不跑,不記失敗——這一筆的內容有問題,那是清單或待辦檔的事。
cmd_shape_ok() { # $1=指令
case "$1" in
*'$'*|*'`'*|*'~'*) BAD_WHY='指令裡有金錢符號、反引號或波浪號'; return 1 ;;
*';'*|*'|'*|*'&'*) BAD_WHY='指令裡有分號、管線或連接符號,一筆內建項只該是一行單一指令'; return 1 ;;
esac
# 路徑一定要落在這一輪的根目錄底下。字面絕對路徑是權限層唯一認得的形狀,也是唯一
# 看得出「這支腳本是不是我們自己的」的形狀。
case "$1" in
*"$ROOT"/*) ;;
*) BAD_WHY="指令裡沒有這一輪的根目錄 $ROOT"; return 1 ;;
esac
return 0
}
# --- 逐筆處理 ---
N_DUE=0; N_RUN=0; N_OK=0; N_FAIL=0; N_HELD=0; N_SKIP=0; N_WRITE_BAD=0
RC_CMD=0; RC_WRITE=0
# 欄位順序取自 due.sh 印的 rows_columns=。這裡寫死同一個順序,換了就對不上——所以先驗一次
# 欄位數,對不上一筆都不跑,而不是照舊讀進錯的欄位。
_cols=$(awk -F"$TAB" 'NF>1{print NF; exit}' "$ROWS" 2>/dev/null)
case "${_cols:-0}" in
11) ;;
0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=11 ;;
*) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 11 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。" ;;
esac
while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recur c_next c_rearm c_why c_rest; do
[ -n "${c_id:-}" ] || continue
[ "${c_verdict:-}" = "due" ] || continue
N_DUE=$((N_DUE + 1))
# 只跑內建項。使用者交辦的那幾筆沒有經過種入那一支的檢核,動作欄想寫什麼都行。
_spec=$(sed -n 's/^spec_key=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1)
if [ -z "$_spec" ]; then
N_SKIP=$((N_SKIP + 1))
printf 'skip=%s reason=不是委派清單種入的內建項,這一支不跑使用者交辦的那幾筆\n' "$c_id"
continue
fi
# 三種動作,這一輪只跑第一種。
if [ "$c_action" = "remind" ]; then
N_SKIP=$((N_SKIP + 1))
printf 'skip=%s spec=%s reason=動作是只提醒,沒有東西可跑\n' "$c_id" "$_spec"
continue
fi
if is_skill_name "$c_action"; then
N_SKIP=$((N_SKIP + 1))
printf 'skip=%s spec=%s reason=動作是技能名,叫用整支技能這一批還沒接 action=%s\n' \
"$c_id" "$_spec" "$c_action"
continue
fi
BAD_WHY=''
if ! cmd_shape_ok "$c_action"; then
N_HELD=$((N_HELD + 1))
printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$BAD_WHY" "$c_action"
continue
fi
# 代入點展開:一個目標一行,寫進暫存檔。
_targets="$TMPD/targets.$c_id"
: >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。"
_hold=''
case "$c_action" in
*'{repo}'*)
[ "$REPO_READY" -eq 1 ] || _hold='存取庫掃描還沒做出來,{repo} 代不出目標' ;;
esac
if [ -z "$_hold" ]; then
case "$c_action" in
*'{cli}'*)
if [ "$N_CLI" -eq 0 ]; then
_hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標'
else
while IFS= read -r _c; do
[ -n "$_c" ] || continue
printf '%s\n' "$(printf '%s' "$c_action" | sed "s|{cli}|$_c|g")" >>"$_targets"
done <"$CLIS"
fi ;;
*) printf '%s\n' "$c_action" >>"$_targets" ;;
esac
fi
if [ -n "$_hold" ]; then
N_HELD=$((N_HELD + 1))
printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$_hold" "$c_action"
continue
fi
# 代完之後還留著大括號就是還有認不得的代入點。原樣送進殼會跑到一個沒有人寫過的地方。
if grep -q '[{}]' "$_targets" 2>/dev/null; then
N_HELD=$((N_HELD + 1))
printf 'held=%s spec=%s reason=代完之後還留著大括號,認不得的代入點 action=%s\n' \
"$c_id" "$_spec" "$c_action"
continue
fi
_n=$(awk 'END{print NR+0}' "$_targets")
if [ "$DRYRUN" -eq 1 ]; then
N_RUN=$((N_RUN + 1))
printf 'would_run=%s spec=%s targets=%s\n' "$c_id" "$_spec" "$_n"
while IFS= read -r _t; do printf ' target=%s\n' "$_t"; done <"$_targets"
continue
fi
# 真的跑。一個目標一次,全部成功這一筆才算成功——一支 CLI 接線壞掉就是一筆要人看的發現。
N_RUN=$((N_RUN + 1))
_entry_rc=0
_first_err=''
while IFS= read -r _t; do
[ -n "$_t" ] || continue
_out=$(sh -c "$_t" </dev/null 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t"
else
_entry_rc=1
[ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | head -n1 | cut -c1-160)
printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \
"$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | head -n1 | cut -c1-160)"
fi
done <"$_targets"
# 回寫。下一次什麼時候到期由判定那一支算,這一支不自己算——兩邊各算一次就會漂移。
if [ "$_entry_rc" -eq 0 ]; then
_next=''
if [ -n "$DUE_SH" ]; then
_next=$("$DUE_SH" next --trigger "$c_trigger" --recur "$c_recur" \
--last-run "$NOW_ISO" --now "$NOW" 2>/dev/null | sed -n 's/^next_run=//p' | head -n1)
fi
if [ -n "$_next" ]; then
"$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" --next-run "$_next" >/dev/null 2>&1
else
"$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1
fi
_wrc=$?
if [ "$_wrc" -eq 0 ]; then
N_OK=$((N_OK + 1))
printf 'done=%s spec=%s targets=%s next_run=%s\n' "$c_id" "$_spec" "$_n" "${_next:--}"
else
N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1
printf 'write_failed=%s spec=%s op=done rc=%s\n' "$c_id" "$_spec" "$_wrc"
fi
else
RC_CMD=1
"$TASKS_SH" fail "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1; _wrc=$?
if [ "$_wrc" -eq 0 ]; then
N_FAIL=$((N_FAIL + 1))
printf 'failed=%s spec=%s targets=%s first_error=%s\n' "$c_id" "$_spec" "$_n" "${_first_err:--}"
else
N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1
printf 'write_failed=%s spec=%s op=fail rc=%s\n' "$c_id" "$_spec" "$_wrc"
fi
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' \
"$([ "$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_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 的結束碼。"
[ "$RC_CMD" -ne 0 ] && warn "有 $N_FAIL 筆的指令回非零,已經記成失敗、失敗次數加一。逐筆印在上面的 failed= 與 target_fail= 那幾行。"
[ "$RC_WRITE" -eq 0 ] || exit 4
[ "$RC_CMD" -eq 0 ] || exit 1
exit 0