Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98dd0761fa | ||
|
|
f6a293ddab | ||
|
|
633f56e7ea | ||
|
|
6cb071a3e7 | ||
|
|
0df1605a20 | ||
|
|
4562da2bea | ||
|
|
ccb28e2bb3 | ||
|
|
06c5f3aaaf | ||
|
|
a050404a77 | ||
|
|
ebce7fc0c3 | ||
|
|
cc5faaff21 | ||
|
|
ffe9a20178 | ||
|
|
2c0718ee19 | ||
|
|
044676b1fc | ||
|
|
6dbf2f1a22 | ||
|
|
3784cb51be | ||
|
|
dc7c164d9a | ||
|
|
7d40c9dded | ||
|
|
bccdbbbd24 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-assist",
|
"name": "jsc-assist",
|
||||||
"version": "0.2.2",
|
"version": "0.3.1",
|
||||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-assist",
|
"name": "jsc-assist",
|
||||||
"version": "0.2.2",
|
"version": "0.3.1",
|
||||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"jsc": {
|
"jsc": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-assist",
|
"name": "jsc-assist",
|
||||||
"version": "0.2.2",
|
"version": "0.3.1",
|
||||||
"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
@@ -71,10 +71,11 @@ Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` sta
|
|||||||
|
|
||||||
| What it does | Path to run |
|
| What it does | Path to run |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| one patrol round | `{CURRENT}/jsc-assist/tools/patrol.sh` |
|
| one patrol round, and joining the monitor page's three blocks | `{CURRENT}/jsc-assist/tools/patrol.sh` |
|
||||||
| 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,14 +285,29 @@ 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:
|
||||||
|
|
||||||
- **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.
|
- **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 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`.
|
||||||
|
|
||||||
@@ -331,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.
|
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.
|
**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.
|
||||||
|
|
||||||
@@ -341,15 +357,21 @@ One round: read five sources, record the result, then beat. Everything before th
|
|||||||
|
|
||||||
3. **Rebuild `MONITOR_{HASH}` from its three blocks.** Hand it to `jsc-gitea:wiki` with page type `MONITOR`, so the repo is the one `gitea.sh wiki-repo MONITOR` resolves — `JSC_WIKI_REPO_MONITOR`, then `JSC_WIKI_REPO`, then exit 3. This is the content page and it stays in that repo; only the directory page of step 4 moved to the `CONTENTS` repo, and neither chain ever falls back to the other. Read the page back first, then build the new body out of what came back plus this round's files, in this order and with nothing else on the page:
|
3. **Rebuild `MONITOR_{HASH}` from its three blocks.** Hand it to `jsc-gitea:wiki` with page type `MONITOR`, so the repo is the one `gitea.sh wiki-repo MONITOR` resolves — `JSC_WIKI_REPO_MONITOR`, then `JSC_WIKI_REPO`, then exit 3. This is the content page and it stays in that repo; only the directory page of step 4 moved to the `CONTENTS` repo, and neither chain ever falls back to the other. Read the page back first, then build the new body out of what came back plus this round's files, in this order and with nothing else on the page:
|
||||||
|
|
||||||
|
**The three blocks are joined by `{CURRENT}/jsc-assist/tools/patrol.sh rebuild --old {the file the old page was read into}`, never by hand.** It prints `rebuilt_file=` — put that file. It also prints `old_format=`, `blocks=`, `summary_rows=` and `old_rows_kept=`; carry those into the report. Exit 6 means an argument was rejected or the old-page file is not there, exit 5 a scratch file could not be written; both take step 3's abort row.
|
||||||
|
|
||||||
|
Joining them by hand is what this replaces, and the reason is worth keeping. The join is a fixed shape, so leaving it to the caller meant a fresh agent picked its own tools for it every round: one round reached for `awk`, was refused by the permission gate twice, worked around it because somebody was there to watch, and said in its own report that the same refusal would hang an unattended round — with the round lock already taken. The scratch directory had accumulated 124 files by then, named `newpage.md`, `new-page.md`, `new_page.md`, `page-new.md` and so on: the same join, redone every round, under a different set of names. **A step with one correct shape does not belong in a prompt.** What the script joins:
|
||||||
|
|
||||||
| Block | Where it comes from |
|
| Block | Where it comes from |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 本頁基本資料 | the old page, byte for byte from its heading to the line before 最新一輪. Never rewritten, never re-derived |
|
| 本頁基本資料 | the old page, byte for byte from its heading to the line before 最新一輪. Never rewritten, never re-derived — the script does not even normalise its trailing blank lines |
|
||||||
| 最新一輪 | the whole content of `latest_file`, replacing the old block entirely |
|
| 最新一輪 | the whole content of `latest_file`, replacing the old block entirely |
|
||||||
| 近 24 輪摘要 | `summary_file`, which already holds the heading, the five-column table header (`巡檢時間`、`本輪判定`、`各項成敗`、`待人處理`、`警示來源`) and this round's row; then the old table's data rows in their old order underneath, cut so the table holds at most 24 rows |
|
| 近 24 輪摘要 | `summary_file`, which already holds the heading, the five-column table header (`巡檢時間`、`本輪判定`、`各項成敗`、`待人處理`、`警示來源`) and this round's row; then the old table's data rows in their old order underneath, cut so the table holds at most 24 rows |
|
||||||
|
|
||||||
|
An old-format page — per-round sections stacked up, no 最新一輪 heading — is detected by the script itself, which keeps the basic-data block, drops the stacked sections, starts the table with this round's row, and prints `old_format=yes converted=1`. Report that the page was converted.
|
||||||
|
|
||||||
**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.
|
**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. An old-format page — per-round sections stacked up, no summary table — has no rows to carry over: keep its `本頁基本資料` block, drop the stacked sections, let the table start with this round's row, and say in the report that the page was converted. 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.
|
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.
|
||||||
|
|
||||||
@@ -377,13 +399,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} --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. **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
|
||||||
|
|
||||||
|
|||||||
+61
-1
@@ -205,6 +205,7 @@ WARN=0
|
|||||||
DUE_STATUS=fail
|
DUE_STATUS=fail
|
||||||
DUE_RC=0
|
DUE_RC=0
|
||||||
DUE_MD=''
|
DUE_MD=''
|
||||||
|
DUE_ROWS=''
|
||||||
DUE_NOTE=''
|
DUE_NOTE=''
|
||||||
DUE_TASKS=''
|
DUE_TASKS=''
|
||||||
DUE_EVENTS=''
|
DUE_EVENTS=''
|
||||||
@@ -237,6 +238,7 @@ usage() {
|
|||||||
usage: patrol.sh collect [--out 目錄] [--trigger 排程|事件|手動]
|
usage: patrol.sh collect [--out 目錄] [--trigger 排程|事件|手動]
|
||||||
patrol.sh finish --round 輪次代號 [--out 目錄] [--dry-run]
|
patrol.sh finish --round 輪次代號 [--out 目錄] [--dry-run]
|
||||||
patrol.sh abort --round 輪次代號 [--out 目錄]
|
patrol.sh abort --round 輪次代號 [--out 目錄]
|
||||||
|
patrol.sh rebuild --old 舊頁檔 [--out 目錄]
|
||||||
EOF
|
EOF
|
||||||
exit 6
|
exit 6
|
||||||
}
|
}
|
||||||
@@ -911,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=$?
|
JSC_HOME="$JSC_HOME" sh "$_due" scan --out "$RD/due" >"$RD/due.out" 2>"$RD/due.err" </dev/null || _rc=$?
|
||||||
DUE_RC="$_rc"
|
DUE_RC="$_rc"
|
||||||
DUE_MD=$(sed -n 's/^due_file=//p' "$RD/due.out" 2>/dev/null | head -n1)
|
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_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)
|
DUE_EVENTS=$(sed -n 's/^events_new=//p' "$RD/due.out" 2>/dev/null | head -n1)
|
||||||
case "$_rc" in
|
case "$_rc" in
|
||||||
@@ -1078,7 +1085,7 @@ compose() {
|
|||||||
CMD="${1:-}"
|
CMD="${1:-}"
|
||||||
[ -n "$CMD" ] || usage
|
[ -n "$CMD" ] || usage
|
||||||
shift
|
shift
|
||||||
case "$CMD" in collect|finish|abort) ;; *) usage ;; esac
|
case "$CMD" in collect|finish|abort|rebuild) ;; *) usage ;; esac
|
||||||
|
|
||||||
OUT=''
|
OUT=''
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
@@ -1087,9 +1094,11 @@ while [ "$#" -gt 0 ]; do
|
|||||||
--trigger) [ "$#" -ge 2 ] || usage; TRIGGER="$2"; shift 2 ;;
|
--trigger) [ "$#" -ge 2 ] || usage; TRIGGER="$2"; shift 2 ;;
|
||||||
--round) [ "$#" -ge 2 ] || usage; ROUND="$2"; shift 2 ;;
|
--round) [ "$#" -ge 2 ] || usage; ROUND="$2"; shift 2 ;;
|
||||||
--dry-run) DRYRUN=1; shift ;;
|
--dry-run) DRYRUN=1; shift ;;
|
||||||
|
--old) [ "$#" -ge 2 ] || usage; OLD_PAGE="$2"; shift 2 ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
OLD_PAGE="${OLD_PAGE:-}"
|
||||||
[ -n "$OUT" ] && RD="$OUT"
|
[ -n "$OUT" ] && RD="$OUT"
|
||||||
|
|
||||||
case "$CMD" in
|
case "$CMD" in
|
||||||
@@ -1154,6 +1163,7 @@ case "$CMD" in
|
|||||||
printf 'due_status=%s\n' "$DUE_STATUS"
|
printf 'due_status=%s\n' "$DUE_STATUS"
|
||||||
printf 'due_rc=%s\n' "$DUE_RC"
|
printf 'due_rc=%s\n' "$DUE_RC"
|
||||||
printf 'due_file=%s\n' "$DUE_MD"
|
printf 'due_file=%s\n' "$DUE_MD"
|
||||||
|
printf 'due_rows_file=%s\n' "${DUE_ROWS:-}"
|
||||||
printf 'pending=%s\n' "$PEND_COUNT"
|
printf 'pending=%s\n' "$PEND_COUNT"
|
||||||
printf 'events_total=%s\n' "$EV_TOTAL"
|
printf 'events_total=%s\n' "$EV_TOTAL"
|
||||||
printf 'events_bad=%s\n' "$EV_BAD"
|
printf 'events_bad=%s\n' "$EV_BAD"
|
||||||
@@ -1204,4 +1214,54 @@ case "$CMD" in
|
|||||||
printf 'heartbeat=not-written\n'
|
printf 'heartbeat=not-written\n'
|
||||||
lock_release
|
lock_release
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
|
|
||||||
|
# 拿舊頁重組出要寫回去的整頁。三塊的接法固定,所以由這一支做完,呼叫端只要「讀回舊頁、
|
||||||
|
# 叫這一支、把結果寫回去」三步。
|
||||||
|
#
|
||||||
|
# 為什麼要有這個子命令:接三塊這件事本來留給呼叫端,而呼叫端是一個代理人,於是它每一輪
|
||||||
|
# 臨場挑工具——實測某一輪挑了 awk,兩次都被權限擋掉;那一輪有人在場所以它改用別的方式繞
|
||||||
|
# 過去了,並且自己指出無人值守那一輪遇到同一個擋法會直接卡住,而鎖在第一步就取走了。
|
||||||
|
# 更根本的是暫存目錄實測累積了 124 個檔案,名字是 newpage.md、new-page.md、new_page.md、
|
||||||
|
# page-new.md 這種——同一件事每輪重做一次,每輪換一組檔名。規格說那裡只該有五個檔案。
|
||||||
|
# 接法寫死在這裡之後,那一步不再需要任何通用文字工具,也就沒有東西可以被擋。
|
||||||
|
rebuild)
|
||||||
|
[ -n "$OLD_PAGE" ] || usage
|
||||||
|
[ -f "$OLD_PAGE" ] || die 6 "舊頁檔讀不到:$OLD_PAGE。這一支不自己讀 wiki,舊頁要由呼叫端先取下來。"
|
||||||
|
for _f in "$RD/latest.md" "$RD/summary.md"; do
|
||||||
|
[ -f "$_f" ] || die 6 "$_f 不在。rebuild 要接的是同一輪 collect 產出的那幾塊,先跑 collect。"
|
||||||
|
done
|
||||||
|
_out="$RD/rebuilt.md"
|
||||||
|
# 舊頁那一塊「本頁基本資料」原樣留著,從它的標題到「最新一輪」前一行為止。整頁沒有
|
||||||
|
# 「最新一輪」那個標題時視為舊格式:基本資料留著、堆疊的各輪小節丟掉,摘要表從本輪開始。
|
||||||
|
if grep -q '^## 最新一輪' "$OLD_PAGE" 2>/dev/null; then
|
||||||
|
awk '/^## 最新一輪/{exit} {print}' "$OLD_PAGE" >"$RD/rb-head.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-head.md。"
|
||||||
|
printf 'old_format=no\n'
|
||||||
|
else
|
||||||
|
awk '/^## /{n++} n<=1{print}' "$OLD_PAGE" >"$RD/rb-head.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-head.md。"
|
||||||
|
printf 'old_format=yes converted=1\n'
|
||||||
|
fi
|
||||||
|
# 舊表的資料列照原順序留在本輪那一列底下,整張表截到 24 列。摘要那一塊已經含標題、
|
||||||
|
# 表頭與本輪那一列,所以這裡只補舊列。
|
||||||
|
awk '/^## 近 24 輪摘要/{f=1;next} f && /^\| 2/{print}' "$OLD_PAGE" >"$RD/rb-rows.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-rows.md。"
|
||||||
|
_new_rows=$(awk '/^\| 2/{n++} END{print n+0}' "$RD/summary.md")
|
||||||
|
_keep=$((24 - _new_rows))
|
||||||
|
[ "$_keep" -lt 0 ] && _keep=0
|
||||||
|
# 基本資料那一塊逐字照抄,連尾端空行都不動——技能本文寫的是「逐字保留」,剝掉再補一個
|
||||||
|
# 雖然內容沒少,那句話就變成假的了。只在它沒有以空行收尾時補一個分隔,免得下一塊的
|
||||||
|
# 標題黏在表格最後一列後面。
|
||||||
|
_tail=$(awk 'END{print (NF==0 ? "blank" : "text")}' "$RD/rb-head.md" 2>/dev/null)
|
||||||
|
{
|
||||||
|
cat "$RD/rb-head.md"
|
||||||
|
[ "$_tail" = "blank" ] || printf '\n'
|
||||||
|
cat "$RD/latest.md"
|
||||||
|
printf '\n'
|
||||||
|
cat "$RD/summary.md"
|
||||||
|
[ "$_keep" -gt 0 ] && head -n "$_keep" "$RD/rb-rows.md"
|
||||||
|
} >"$_out" 2>/dev/null || die 5 "暫存檔寫不進去:$_out。"
|
||||||
|
printf 'rebuilt_file=%s\n' "$_out"
|
||||||
|
printf 'blocks=%s summary_rows=%s old_rows_kept=%s\n' \
|
||||||
|
"$(grep -c '^## ' "$_out")" \
|
||||||
|
"$(awk '/^## 近 24 輪摘要/{f=1;next} f && /^\| 2/{n++} END{print n+0}' "$_out")" \
|
||||||
|
"$([ "$_keep" -gt 0 ] && awk 'END{print NR+0}' "$RD/rb-rows.md" | awk -v k="$_keep" '{print ($1<k?$1:k)}' || echo 0)"
|
||||||
|
exit 0 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Executable
+377
@@ -0,0 +1,377 @@
|
|||||||
|
#!/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
|
||||||
|
# --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——沒跑過判定,跟「都沒到期」不是同一件事。"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清單太舊就拒跑。判到期與執行之間隔著呼叫端,那一段有可能斷掉——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,成敗沒有地方回寫。待辦簿只有一個寫入者,缺了它這一輪不該跑。'
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 跟排程條目安裝當下記下的那一份比對。少了哪一支要點名,不是只印個數字。
|
||||||
|
#
|
||||||
|
# 為什麼要比:偵測那一支純看 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
|
||||||
|
|
||||||
|
# --- 判斷動作是哪一種 ---
|
||||||
|
|
||||||
|
# 技能名的形狀是 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 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" "${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 的結束碼。"
|
||||||
|
[ "$RC_CMD" -ne 0 ] && warn "有 $N_FAIL 筆的指令回非零,已經記成失敗、失敗次數加一。逐筆印在上面的 failed= 與 target_fail= 那幾行。"
|
||||||
|
|
||||||
|
[ "$RC_WRITE" -eq 0 ] || exit 4
|
||||||
|
[ "$RC_CMD" -eq 0 ] || exit 1
|
||||||
|
exit 0
|
||||||
+94
-2
@@ -336,6 +336,14 @@ patrol_command() {
|
|||||||
# 不寫死是刻意的:頁型會增加,目錄頁後來也分了自己的存取庫變數,寫死清單就要跟著改一次,
|
# 不寫死是刻意的:頁型會增加,目錄頁後來也分了自己的存取庫變數,寫死清單就要跟著改一次,
|
||||||
# 而漏掉的那一個在排程那一輪是空值,wiki 寫不成、心跳不寫,外面只看得到心跳過期。
|
# 而漏掉的那一個在排程那一輪是空值,wiki 寫不成、心跳不寫,外面只看得到心跳過期。
|
||||||
snapshot_names() {
|
snapshot_names() {
|
||||||
|
# PATH 也要快照。cron 給的 PATH 只有 /usr/bin 與 /bin,而這台機器的 CLI 執行檔多半裝在
|
||||||
|
# 家目錄底下,所以那一輪偵測 CLI 會一支都找不到——而且是**回結束碼 0、印出空清單**,
|
||||||
|
# 不是失敗。看起來像「這台機器沒裝 CLI」,實際上是 PATH 上找不到。
|
||||||
|
# 2026-09-04 實測踩到:無人值守那一輪 clis=0,帶 {cli} 代入點的內建項全部代不出目標,
|
||||||
|
# 而同一支腳本在有人的 shell 裡回五支。
|
||||||
|
# 巡檢那一輪要跑的指令本身走的是字面絕對路徑、不靠 PATH;靠 PATH 的是那幾支腳本自己
|
||||||
|
# 呼叫的外部程式,所以修在條目這一層,不是逐支腳本各自去猜安裝路徑——猜就要維護一份
|
||||||
|
# 路徑清單,而清單會過期。
|
||||||
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL
|
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL
|
||||||
env 2>/dev/null \
|
env 2>/dev/null \
|
||||||
| sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \
|
| sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \
|
||||||
@@ -346,6 +354,56 @@ snapshot_names() {
|
|||||||
# 整條 cron 指令切斷。
|
# 整條 cron 指令切斷。
|
||||||
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||||
|
|
||||||
|
# 條目要帶的 PATH。不是整條照抄——這台機器實測 2160 字元,去重又去掉不存在的那幾段之後
|
||||||
|
# 還有 1332 字元,而 crontab 一行有長度上限,照抄會直接回「command too long」而裝不起來。
|
||||||
|
# 實測踩過:加上整條 PATH 之後 install 回 4,錯誤訊息是 crontab 自己吐的。
|
||||||
|
#
|
||||||
|
# 只帶放得下 CLI 執行檔的那幾個目錄,這台機器實測是兩個、合起來 70 字元。這樣夠用的理由是
|
||||||
|
# 這一條要解決的問題很窄:cron 只給 /usr/bin 與 /bin,而偵測 CLI 那一支靠 PATH 找執行檔,
|
||||||
|
# 找不到就回結束碼 0 加一份空清單——看起來像「這台機器沒裝 CLI」。巡檢要跑的指令本身走的是
|
||||||
|
# 字面絕對路徑,不靠這一條。
|
||||||
|
#
|
||||||
|
# 一個代價要講白:有些 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),那種路徑
|
||||||
|
# 下一次開機就不在了。這裡不特別過濾——過濾要認得每一種安裝方式,那份名單會過期得比路徑
|
||||||
|
# 還快。重跑一次 install 就會重新拍一份,而條目本來就是安裝當下的快照。
|
||||||
|
cli_path() {
|
||||||
|
_dirs=''
|
||||||
|
for _c in claude codex copilot agy kiro-cli; do
|
||||||
|
_abs=$(command -v "$_c" 2>/dev/null) || continue
|
||||||
|
case "$_abs" in /*) ;; *) continue ;; esac
|
||||||
|
_d=$(dirname -- "$_abs")
|
||||||
|
case ":$_dirs:" in
|
||||||
|
*":$_d:"*) ;;
|
||||||
|
*) _dirs="${_dirs:+$_dirs:}$_d" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# 收尾接上 cron 本來就會給的那兩段,免得只剩自訂目錄而找不到系統工具。
|
||||||
|
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='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。
|
# 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。
|
||||||
# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁
|
# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁
|
||||||
@@ -357,7 +415,7 @@ shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
|||||||
# 中止的唯一證據」。不設這個變數,兩半就都落在 CLI 自己給的代號上。
|
# 中止的唯一證據」。不設這個變數,兩半就都落在 CLI 自己給的代號上。
|
||||||
# 2026-09-04 實測:一台機器累積了 91 筆假中止,其中 90 筆是同一支被每輪叫用的技能。
|
# 2026-09-04 實測:一台機器累積了 91 筆假中止,其中 90 筆是同一支被每輪叫用的技能。
|
||||||
env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值)
|
env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值)
|
||||||
_p='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=''
|
_names=''
|
||||||
for _n in $(snapshot_names); do
|
for _n in $(snapshot_names); do
|
||||||
eval "_v=\${$_n:-}"
|
eval "_v=\${$_n:-}"
|
||||||
@@ -459,7 +517,7 @@ trap 'rm -rf "$TMPD"' EXIT
|
|||||||
# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡
|
# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡
|
||||||
# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。
|
# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。
|
||||||
PATROL_RESOLVED=''
|
PATROL_RESOLVED=''
|
||||||
ENV_PREFIX='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=''
|
SNAPSHOT_NAMES=''
|
||||||
case "$CMD:$JOBS" in
|
case "$CMD:$JOBS" in
|
||||||
install:*patrol*)
|
install:*patrol*)
|
||||||
@@ -498,8 +556,12 @@ print_allow_rules() {
|
|||||||
# 代跑(那是已放行指令的子行程,不會再問一次),但收尾那一筆 skill-end 是技能自己用 Bash 叫的,
|
# 代跑(那是已放行指令的子行程,不會再問一次),但收尾那一筆 skill-end 是技能自己用 Bash 叫的,
|
||||||
# 那一次就要這一條規則。少了它,那一輪會停在最後一步的權限詢問,而排程那一輪沒有人可以按
|
# 那一次就要這一條規則。少了它,那一輪會停在最後一步的權限詢問,而排程那一輪沒有人可以按
|
||||||
# 同意:那一輪的收尾事件寫不出去,start 永遠配不到 end,下一輪就把一輪其實做完的巡檢報成中止。
|
# 同意:那一輪的收尾事件寫不出去,start 永遠配不到 end,下一輪就把一輪其實做完的巡檢報成中止。
|
||||||
|
# run-due.sh 是巡檢那一輪真的執行到期內建項的那一支,技能自己用 Bash 叫它。少了這一條,
|
||||||
|
# 那一輪會停在那一步的權限詢問,而鎖已經握在手上:下一輪跟著讓開,一直到鎖逾時為止。
|
||||||
|
# 它自己跑的那幾支盤點指令是它的子行程,不會再問一次,所以只要放行它自己這一條。
|
||||||
for _s in "$ROOT/jsc-assist/tools/schedule.sh" \
|
for _s in "$ROOT/jsc-assist/tools/schedule.sh" \
|
||||||
"$ROOT/jsc-assist/tools/patrol.sh" \
|
"$ROOT/jsc-assist/tools/patrol.sh" \
|
||||||
|
"$ROOT/jsc-assist/tools/run-due.sh" \
|
||||||
"$ROOT/jsc-hooks/hooks/heartbeat.sh" \
|
"$ROOT/jsc-hooks/hooks/heartbeat.sh" \
|
||||||
"$ROOT/jsc-hooks/tools/report-status.sh" \
|
"$ROOT/jsc-hooks/tools/report-status.sh" \
|
||||||
"$ROOT/jsc-gitea/tools/gitea.sh" \
|
"$ROOT/jsc-gitea/tools/gitea.sh" \
|
||||||
@@ -508,12 +570,42 @@ print_allow_rules() {
|
|||||||
printf 'allow_rule=Bash(%s:*)\n' "$_s"
|
printf 'allow_rule=Bash(%s:*)\n' "$_s"
|
||||||
printf 'allow_rule=Bash(sh %s:*)\n' "$_s"
|
printf 'allow_rule=Bash(sh %s:*)\n' "$_s"
|
||||||
printf 'allow_rule=Bash(bash %s:*)\n' "$_s"
|
printf 'allow_rule=Bash(bash %s:*)\n' "$_s"
|
||||||
|
_printed="${_printed:-} ${_s#"$ROOT/"}"
|
||||||
done
|
done
|
||||||
# 檔案規則寫的是解出來的絕對路徑,不是 $JSC_HOME/assistant:設定檔不展開變數,寫變數名
|
# 檔案規則寫的是解出來的絕對路徑,不是 $JSC_HOME/assistant:設定檔不展開變數,寫變數名
|
||||||
# 等於這條規則永遠比對不到。
|
# 等於這條規則永遠比對不到。
|
||||||
printf 'allow_rule=Read(%s/**)\n' "$STATE_DIR"
|
printf 'allow_rule=Read(%s/**)\n' "$STATE_DIR"
|
||||||
printf 'allow_rule=Edit(%s/**)\n' "$STATE_DIR"
|
printf 'allow_rule=Edit(%s/**)\n' "$STATE_DIR"
|
||||||
printf 'allow_rule=Skill(jsc-gitea:wiki)\n'
|
printf 'allow_rule=Skill(jsc-gitea:wiki)\n'
|
||||||
|
|
||||||
|
# 上面那份清單是寫死的,而技能本文哪天多叫一支腳本,這裡不會自己知道。漏一支的代價不是
|
||||||
|
# 報錯,是那一輪停在權限詢問、鎖握在手上,接下來每一輪都讓開到鎖逾時為止——查起來只看得到
|
||||||
|
# 「心跳過期」。所以這裡回頭掃一次技能本文,把它叫的每一支腳本跟上面印過的比對。
|
||||||
|
# 只警告不中止:本文的寫法可能變,比對規則跟不上時不該連排程都裝不起來。
|
||||||
|
# 目錄名以 jsc-assist 為準,找不到才退回不帶前綴的 assist:安裝版面是前者,開發用的並排
|
||||||
|
# 工作目錄版面是後者。只認一種的話,在另一種版面底下這道檢核會被 [ -f ] 靜靜跳過——
|
||||||
|
# 一道「找不到就當通過」的檢核,跟沒有那道檢核是同一件事。
|
||||||
|
_skill=''
|
||||||
|
for _cand in "$ROOT/jsc-assist/skills/assistant/SKILL.md" \
|
||||||
|
"$ROOT/assist/skills/assistant/SKILL.md"; do
|
||||||
|
[ -f "$_cand" ] && { _skill="$_cand"; break; }
|
||||||
|
done
|
||||||
|
if [ -z "$_skill" ]; then
|
||||||
|
note "找不到技能本文,這一次沒有比對「本文叫得到的腳本有沒有全部放行」。找過 $ROOT/jsc-assist 與 $ROOT/assist 兩處。規則清單照樣印在上面,但少一支就是那一輪停在權限詢問,請自己核對一次。"
|
||||||
|
else
|
||||||
|
_missing=''
|
||||||
|
# 只掃 patrol 那一節。規則防的是無人值守那一輪,而 start 與 status 兩個操作都有人在現場,
|
||||||
|
# 它們叫的腳本跳一次權限詢問有人按得掉。整份掃的話那幾支會每次都被報成缺規則,而一份
|
||||||
|
# 每次都有雜訊的警告,看的人第二次就會跳過它——那時候真的漏一支就沒有人看得到了。
|
||||||
|
for _w in $(awk '/^## patrol$/{f=1;next} f && /^## /{exit} f' "$_skill" \
|
||||||
|
| sed -n 's|.*{CURRENT}/\(jsc-[a-z-]*/[a-z-]*/[a-z-]*\.sh\).*|\1|p' | sort -u); do
|
||||||
|
case " $_printed " in
|
||||||
|
*" $_w "*) ;;
|
||||||
|
*) _missing="${_missing:+$_missing、}$_w" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -z "$_missing" ] || note "技能本文叫得到、但上面的規則沒有涵蓋的腳本:$_missing。那一輪叫到它就會停在權限詢問,而鎖已經握在手上,接下來每一輪都會讓開到鎖逾時。請把這幾支補進這一支腳本的規則清單,再重跑一次 install。"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 巡檢那一輪只用 $JSC_HOME/current 那一組連結叫工具,連結不在就是那一輪一定失敗。這裡只查
|
# 巡檢那一輪只用 $JSC_HOME/current 那一組連結叫工具,連結不在就是那一輪一定失敗。這裡只查
|
||||||
|
|||||||
Reference in New Issue
Block a user