@caddis/cli 0.4.55 → 0.4.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +1056 -83
- package/bundles/antigravity-plugin/hooks.json +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +182 -4
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
- package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
- package/bundles/antigravity-plugin/session_end_agy.py +27 -11
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -23,6 +23,13 @@ Rules the runner trusts unattended:
|
|
|
23
23
|
environment (any value, even empty), stdin not a terminal, or (Windows) stdin not a console (a NUL stdin is not);
|
|
24
24
|
- Exit check and Check commands run in a temporary worktree of `origin/<default>` (the merged code), with no stdin,
|
|
25
25
|
in their own process group (a job object on Windows); the tree is killed on every exit;
|
|
26
|
+
- that worktree has no venv, no node_modules and no build. An optional roadmap line `**Exit setup:** <command>` runs
|
|
27
|
+
first in it (900 s) and must install fresh into it; a failing setup is exit 3 and writes nothing. An exit check
|
|
28
|
+
that fails because the environment is missing (ENV_FAILURE_PATTERNS, exit 9009/127/126) is exit 3 and writes
|
|
29
|
+
nothing; a real failure writes an `external` blocker whose reason ends with the masked tail of the output (one
|
|
30
|
+
line, the whole cell at most 200 characters; deduped and unblocked by its stable `exit check failing (exit N)`
|
|
31
|
+
part). Both keep the last 64 KB of output (masked) in `.caddis/roadmaps/<slug>/.local/exit-check-<id>-<utc>.log`
|
|
32
|
+
(the newest 10 per roadmap);
|
|
26
33
|
- every network call has a timeout (git fetch/ls-remote/push 120 s, gh 60 s, the merge chain 1800 s) and runs with
|
|
27
34
|
GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never; a timeout is exit 3 (a merge-chain timeout is logged as
|
|
28
35
|
"merge_exit": "timeout"); Ctrl+C kills the tree too;
|
|
@@ -39,7 +46,8 @@ commit on a `roadmap/<slug>-<id>-<utc>` branch made from `origin/<default>` in a
|
|
|
39
46
|
prints the diff it would write and the commands it would run, and runs and writes nothing.
|
|
40
47
|
|
|
41
48
|
Exit codes (caddis_exit): 0 clean, 1 blocked (nothing ready, check failing, PR not merged), 3 could not run
|
|
42
|
-
(no origin, no roadmap, lock busy
|
|
49
|
+
(no origin, no roadmap, lock busy, an exit check or Exit setup that cannot run in the merged checkout), 4 error
|
|
50
|
+
(malformed roadmap, unknown id, column or value, refused change).
|
|
43
51
|
|
|
44
52
|
Usage: caddis_roadmap.py status --roadmap SLUG [--root DIR] [--json]
|
|
45
53
|
caddis_roadmap.py next --roadmap SLUG [--root DIR] [--json]
|
|
@@ -63,7 +71,6 @@ import difflib
|
|
|
63
71
|
import json
|
|
64
72
|
import os
|
|
65
73
|
import re
|
|
66
|
-
import shutil
|
|
67
74
|
import signal
|
|
68
75
|
import socket
|
|
69
76
|
import subprocess
|
|
@@ -78,14 +85,22 @@ from pathlib import Path
|
|
|
78
85
|
|
|
79
86
|
try:
|
|
80
87
|
import caddis_exit
|
|
88
|
+
import caddis_redact
|
|
81
89
|
import caddis_rows
|
|
90
|
+
import caddis_worktree_safety
|
|
82
91
|
except ModuleNotFoundError as exc:
|
|
83
|
-
if exc.name not in {"caddis_exit", "caddis_rows"}:
|
|
92
|
+
if exc.name not in {"caddis_exit", "caddis_redact", "caddis_rows", "caddis_worktree_safety"}:
|
|
84
93
|
raise
|
|
85
94
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
86
95
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
87
96
|
import caddis_exit
|
|
97
|
+
import caddis_redact
|
|
88
98
|
import caddis_rows
|
|
99
|
+
import caddis_worktree_safety
|
|
100
|
+
|
|
101
|
+
# No child process opens a console window on Windows (this runs under hooks and status lines that
|
|
102
|
+
# have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
|
|
103
|
+
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
89
104
|
|
|
90
105
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
91
106
|
STATUSES = ("todo", "in-progress", "code-done/data-pending", "done", "not-needed")
|
|
@@ -97,7 +112,55 @@ MILESTONE_COLS = ["ID", "Milestone", "Phases", "Depends on", "Exit check", "Stat
|
|
|
97
112
|
BLOCKER_COLS = ["Milestone", "Type", "Reason", "Check"]
|
|
98
113
|
HEADLESS_VARS = ("CADDIS_HEADLESS", "CLAUDECODE")
|
|
99
114
|
EXIT_CHECK_TIMEOUT = 300
|
|
115
|
+
EXIT_SETUP_TIMEOUT = 900 # a fresh venv + pip install, or npm ci + a build, is slower than a check
|
|
100
116
|
RECHECK_TIMEOUT = 120
|
|
117
|
+
# Exit codes of a shell that could not start the command: 9009 cmd.exe "not recognized", 127 sh "command not
|
|
118
|
+
# found", 126 sh "found but not executable".
|
|
119
|
+
SHELL_CANNOT_RUN_CODES = (9009, 127, 126)
|
|
120
|
+
# Output that says the merged checkout lacks the project's environment (no venv, no node_modules, no build), not that
|
|
121
|
+
# the product fails (serve-sight #301). Matched case-insensitively, only on a check that exits non-zero; a match is
|
|
122
|
+
# NOT_RUN (exit 3): no blocker, no PR. Each pattern has a sample in test_caddis_roadmap.py ENV_SAMPLES.
|
|
123
|
+
ENV_FAILURE_PATTERNS = (
|
|
124
|
+
"not recognized as an internal or external command", # cmd.exe: the tool is not on PATH
|
|
125
|
+
"command not found", # sh / bash: the tool is not on PATH
|
|
126
|
+
"No module named", # Python: no venv, or a package not installed
|
|
127
|
+
"ModuleNotFoundError", # Python: the same, as a traceback names it
|
|
128
|
+
"Cannot find module", # Node (CommonJS): no node_modules
|
|
129
|
+
"ERR_MODULE_NOT_FOUND", # Node (ES modules): no node_modules
|
|
130
|
+
"ENOENT", # Node: a file or binary that a build or an install makes is missing
|
|
131
|
+
"run npm run build first", # a check that needs the front-end bundle
|
|
132
|
+
"is not installed", # a tool or a browser (Playwright) that an install step adds
|
|
133
|
+
"could not find a version", # pip: a package index the setup cannot reach
|
|
134
|
+
)
|
|
135
|
+
ENV_FAILURE_RE = re.compile("|".join(re.escape(p) for p in ENV_FAILURE_PATTERNS), re.IGNORECASE)
|
|
136
|
+
ENV_SCAN_LINES = 40 # the patterns are searched only in this many last non-empty output lines
|
|
137
|
+
REASON_TAIL_LINES = 3 # a real failure's blocker reason ends with this many last non-empty output lines
|
|
138
|
+
REASON_CELL_CHARS = 200 # the whole stored Reason cell of a rollup blocker, after every escape
|
|
139
|
+
ROLLUP_PREFIX_RE = re.compile(r"exit check failing \((?:exit -?\d+|timed out after \d+ s)\)") # its stable part
|
|
140
|
+
KEEP_CHECK_LOGS = 10 # exit-check-*.log files kept per roadmap; older ones are removed
|
|
141
|
+
# Masks for the output tail that goes to origin and for the local log, after caddis_redact (which masks only the
|
|
142
|
+
# values of secret-named variables). The token rules are copied from the high-confidence SECRET_RULES in
|
|
143
|
+
# .github/tools/secret_filter.py (that folder is not on this script's import path in an installed plugin).
|
|
144
|
+
OUTPUT_MASKS: list[tuple[re.Pattern, str]] = [
|
|
145
|
+
# secret_filter private-key, widened to the WHOLE block: BEGIN to END, or to the end of the output with no END.
|
|
146
|
+
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)", re.DOTALL), "***"),
|
|
147
|
+
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "***"), # secret_filter aws-key
|
|
148
|
+
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "***"), # secret_filter github-token
|
|
149
|
+
(re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), "***"), # secret_filter slack-token
|
|
150
|
+
(re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), "***"), # secret_filter jwt
|
|
151
|
+
# secret_filter assignment, widened: a quoted value may hold spaces (a passphrase of several words).
|
|
152
|
+
(re.compile(r"(?i)([A-Za-z0-9_]*(?:password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*\s*[:=]\s*)"
|
|
153
|
+
r"(['\"])[^'\"\n]{8,}\2"), r"\1\2***\2"),
|
|
154
|
+
(re.compile(r"(?im)^(\s*(?:export\s+)?[A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|API_KEY|APIKEY|TOKEN|PRIVATE_KEY)"
|
|
155
|
+
r"[A-Za-z0-9_]*\s*=\s*)[^\s'\"#]{8,}"), r"\1***"), # secret_filter env-assignment
|
|
156
|
+
(re.compile(r"\b([A-Za-z][A-Za-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"), # URL credentials
|
|
157
|
+
# Absolute paths, which can name a user or a host. A path may hold spaces, so each one runs to the end of the
|
|
158
|
+
# line or a quote; a Windows drive or UNC path also stops at `<`, `>` or `|` (never part of a Windows path).
|
|
159
|
+
(re.compile(r"(?<![A-Za-z0-9])[A-Za-z]:[\\/][^\r\n'\"<>|]*"), "<path>"), # a Windows drive path
|
|
160
|
+
(re.compile(r"(?<![\w\\])\\\\[^\s\\'\"<>|]+\\[^\r\n'\"<>|]*"), "<path>"), # a UNC path
|
|
161
|
+
(re.compile(r"(?<![\w:/.~-])/(?:home|Users|tmp|root|var|etc|opt)/[^\r\n'\"]*"), "<path>"), # a POSIX path
|
|
162
|
+
]
|
|
163
|
+
EXIT_SETUP_RE = re.compile(r"^\*\*Exit setup:\*\*[ \t]*(.*?)[ \t]*$")
|
|
101
164
|
LOCK_TIMEOUT = 30
|
|
102
165
|
LOCK_MALFORMED_STALE_S = 60 # an empty or non-JSON lock file older than this is stale
|
|
103
166
|
_WINDOWS = os.name == "nt" # a create can fail with PermissionError while a deleted lock's delete is pending
|
|
@@ -136,10 +199,17 @@ class RoadmapError(Exception):
|
|
|
136
199
|
# -- git -------------------------------------------------------------------------------------------------------
|
|
137
200
|
|
|
138
201
|
|
|
202
|
+
# The login this process's own git, gh and merge-chain calls use (GH_TOKEN from the runner's --gh-account). It is
|
|
203
|
+
# never put in os.environ, so a session or lane started from os.environ cannot inherit it (robustness #4).
|
|
204
|
+
AUTH_ENV: dict[str, str] = {}
|
|
205
|
+
|
|
206
|
+
|
|
139
207
|
def _quiet_env() -> dict:
|
|
140
|
-
"""The environment for git, gh and the merge chain: never wait for a credential prompt.
|
|
208
|
+
"""The environment for git, gh and the merge chain: never wait for a credential prompt. A fresh dict each call,
|
|
209
|
+
with AUTH_ENV on top."""
|
|
141
210
|
env = dict(os.environ)
|
|
142
211
|
env.update(NO_PROMPT_ENV)
|
|
212
|
+
env.update(AUTH_ENV)
|
|
143
213
|
return env
|
|
144
214
|
|
|
145
215
|
|
|
@@ -149,7 +219,7 @@ def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProc
|
|
|
149
219
|
proc = run_bounded(argv, GIT_NET_TIMEOUT, f"git {args[0]}")
|
|
150
220
|
else:
|
|
151
221
|
proc = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
152
|
-
stdin=subprocess.DEVNULL, env=_quiet_env())
|
|
222
|
+
stdin=subprocess.DEVNULL, env=_quiet_env(), creationflags=_NO_WINDOW)
|
|
153
223
|
if check and proc.returncode != 0:
|
|
154
224
|
raise RoadmapError(f"git {' '.join(args)} failed: {(proc.stderr or proc.stdout).strip()[:300]}",
|
|
155
225
|
caddis_exit.NOT_RUN)
|
|
@@ -171,15 +241,19 @@ def default_branch(root: Path) -> str:
|
|
|
171
241
|
caddis_exit.NOT_RUN)
|
|
172
242
|
|
|
173
243
|
|
|
244
|
+
FETCH = True # `render --no-fetch` (the waiting hook) turns it off for one command: read the origin refs it has
|
|
245
|
+
|
|
246
|
+
|
|
174
247
|
def fetch(root: Path) -> str:
|
|
175
248
|
"""Fetch origin and return the default branch name."""
|
|
176
|
-
|
|
249
|
+
if FETCH:
|
|
250
|
+
_git(root, "fetch", "--quiet", "origin")
|
|
177
251
|
return default_branch(root)
|
|
178
252
|
|
|
179
253
|
|
|
180
254
|
def show(root: Path, branch: str, path: str) -> str | None:
|
|
181
255
|
proc = subprocess.run(["git", "-C", str(root), "show", f"origin/{branch}:{path}"], capture_output=True,
|
|
182
|
-
stdin=subprocess.DEVNULL)
|
|
256
|
+
stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
|
|
183
257
|
if proc.returncode != 0:
|
|
184
258
|
return None
|
|
185
259
|
# Bytes, not text mode: text mode would turn CRLF into LF and a write would change every line ending.
|
|
@@ -387,6 +461,7 @@ class Blocker:
|
|
|
387
461
|
@dataclass
|
|
388
462
|
class Roadmap:
|
|
389
463
|
runner_host: str = ""
|
|
464
|
+
exit_setup: str = "" # the `**Exit setup:**` command run before an Exit check; empty when there is none
|
|
390
465
|
milestones: list[Milestone] = field(default_factory=list)
|
|
391
466
|
blockers: list[Blocker] = field(default_factory=list)
|
|
392
467
|
errors: list[str] = field(default_factory=list)
|
|
@@ -518,6 +593,12 @@ def parse_roadmap(text: str) -> Roadmap:
|
|
|
518
593
|
rm.runner_host = next((m.group(1) for m in map(HOST_RE.match, view) if m), "")
|
|
519
594
|
if not rm.runner_host:
|
|
520
595
|
rm.errors.append("no `Runner host: <host>` line")
|
|
596
|
+
setups = [(i, m.group(1)) for i, m in enumerate(map(EXIT_SETUP_RE.match, view)) if m]
|
|
597
|
+
if len(setups) > 1:
|
|
598
|
+
rm.errors.append("more than one `**Exit setup:**` line (lines "
|
|
599
|
+
+ ", ".join(str(i + 1) for i, _ in setups) + "); a roadmap has at most one")
|
|
600
|
+
elif setups:
|
|
601
|
+
rm.exit_setup = _cell_text(setups[0][1])
|
|
521
602
|
|
|
522
603
|
table = _section(tables, "Milestones", MILESTONE_COLS, rm.errors)
|
|
523
604
|
seen: set[str] = set()
|
|
@@ -865,7 +946,10 @@ _CALENDAR_NEEDS_DATE = ("a calendar blocker needs --check with an ISO date YYYY-
|
|
|
865
946
|
"date has passed, UTC)")
|
|
866
947
|
|
|
867
948
|
|
|
868
|
-
def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "") -> str:
|
|
949
|
+
def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "", same_prefix: str = "") -> str:
|
|
950
|
+
"""The text with one more Blockers row. No change when the same milestone, type and reason exist, or (with
|
|
951
|
+
`same_prefix`, rollup's stable `exit check failing (...)`) a row whose reason is that prefix or starts with it
|
|
952
|
+
and `: `."""
|
|
869
953
|
rm = parse_roadmap(text)
|
|
870
954
|
if rm.get(mid) is None:
|
|
871
955
|
raise RoadmapError(f"unknown milestone id {mid}")
|
|
@@ -879,7 +963,9 @@ def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "
|
|
|
879
963
|
if when is None:
|
|
880
964
|
raise RoadmapError(_CALENDAR_NEEDS_DATE)
|
|
881
965
|
check_cmd = when.isoformat()
|
|
882
|
-
if any(b.milestone == mid and b.type == btype and b.reason == reason.strip()
|
|
966
|
+
if any(b.milestone == mid and b.type == btype and (b.reason == reason.strip() or (
|
|
967
|
+
same_prefix and (b.reason == same_prefix or b.reason.startswith(same_prefix + ": "))))
|
|
968
|
+
for b in rm.blockers):
|
|
883
969
|
return text
|
|
884
970
|
check_cell = _clean_value(check_cmd, "--check")
|
|
885
971
|
if check_cell and "`" not in check_cell:
|
|
@@ -901,7 +987,18 @@ def remove_blockers(text: str, rows: list[Blocker]) -> str:
|
|
|
901
987
|
|
|
902
988
|
|
|
903
989
|
def _find_blockers(rm: Roadmap, mid: str, btype: str, reason: str, check_cmd: str | None = None) -> list[Blocker]:
|
|
904
|
-
"""The rows to remove. More than one row with this milestone, type and reason needs --check to pick one.
|
|
990
|
+
"""The rows to remove. More than one row with this milestone, type and reason needs --check to pick one.
|
|
991
|
+
A reason that is only rollup's stable prefix (`exit check failing (exit N)`) removes EVERY row of this milestone
|
|
992
|
+
and type whose reason is that prefix or starts with it and `: ` (each is the same blocker with another tail)."""
|
|
993
|
+
if ROLLUP_PREFIX_RE.fullmatch(reason.strip()):
|
|
994
|
+
prefix = reason.strip()
|
|
995
|
+
rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype
|
|
996
|
+
and (b.reason == prefix or b.reason.startswith(prefix + ": "))]
|
|
997
|
+
if not rows:
|
|
998
|
+
raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{prefix}` (or `{prefix}: ...`)")
|
|
999
|
+
if check_cmd is not None:
|
|
1000
|
+
rows = [b for b in rows if b.check == _cell_text(check_cmd)]
|
|
1001
|
+
return rows
|
|
905
1002
|
rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype and b.reason == reason.strip()]
|
|
906
1003
|
if not rows:
|
|
907
1004
|
raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{reason.strip()}`")
|
|
@@ -974,15 +1071,15 @@ def merged_checkout(root: Path, branch: str):
|
|
|
974
1071
|
_git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
|
|
975
1072
|
yield worktree
|
|
976
1073
|
finally:
|
|
977
|
-
|
|
1074
|
+
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
978
1075
|
_git(root, "worktree", "prune", check=False)
|
|
979
|
-
|
|
1076
|
+
caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
|
|
980
1077
|
|
|
981
1078
|
|
|
982
1079
|
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
983
1080
|
if WINDOWS:
|
|
984
1081
|
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True,
|
|
985
|
-
stdin=subprocess.DEVNULL)
|
|
1082
|
+
stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
|
|
986
1083
|
else:
|
|
987
1084
|
try:
|
|
988
1085
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
@@ -1078,11 +1175,11 @@ class _WinJob:
|
|
|
1078
1175
|
|
|
1079
1176
|
def _start(argv, **kwargs) -> tuple[subprocess.Popen, "_WinJob | None"]:
|
|
1080
1177
|
"""Popen with no stdin, in its own process group (and, on Windows, its own job object)."""
|
|
1081
|
-
if WINDOWS:
|
|
1082
|
-
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
1083
|
-
else:
|
|
1178
|
+
if not WINDOWS:
|
|
1084
1179
|
kwargs["start_new_session"] = True
|
|
1085
|
-
|
|
1180
|
+
# CREATE_NO_WINDOW: the child (git, gh, a gate) must not flash a window when this runs from a hook.
|
|
1181
|
+
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | _NO_WINDOW) if WINDOWS else 0
|
|
1182
|
+
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, creationflags=flags, **kwargs)
|
|
1086
1183
|
job = None
|
|
1087
1184
|
if WINDOWS:
|
|
1088
1185
|
try:
|
|
@@ -1206,6 +1303,81 @@ def _failure(rc: int | None, timeout: float) -> str:
|
|
|
1206
1303
|
return f"timed out after {timeout:.0f} s" if rc is None else f"exit {rc}"
|
|
1207
1304
|
|
|
1208
1305
|
|
|
1306
|
+
def env_failure(rc: int | None, output: str) -> str | None:
|
|
1307
|
+
"""Why a failed check could not run in the merged checkout (the environment is missing); None for a real failure
|
|
1308
|
+
(also for a timeout: it ran)."""
|
|
1309
|
+
if rc is None or rc == 0:
|
|
1310
|
+
return None
|
|
1311
|
+
if rc in SHELL_CANNOT_RUN_CODES:
|
|
1312
|
+
return f"exit {rc}: the shell could not start the command"
|
|
1313
|
+
lines = [ln for ln in (output or "").splitlines() if ln.strip()][-ENV_SCAN_LINES:]
|
|
1314
|
+
match = ENV_FAILURE_RE.search("\n".join(lines))
|
|
1315
|
+
return f"the output says `{match.group(0)}`" if match else None
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def mask_output(text: str, secrets: list[str]) -> str:
|
|
1319
|
+
"""`text` with secret values (caddis_redact), token-shaped strings, URL credentials and absolute paths masked."""
|
|
1320
|
+
text = caddis_redact.redact(text or "", secrets)
|
|
1321
|
+
for pattern, repl in OUTPUT_MASKS:
|
|
1322
|
+
text = pattern.sub(repl, text)
|
|
1323
|
+
return text
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _plain_line(text: str) -> str:
|
|
1327
|
+
"""Text a roadmap cell holds as is: printable ASCII on one line, no HTML comment marker, and no `|` or backslash,
|
|
1328
|
+
so `_clean_value` escapes nothing and the stored cell is exactly this text."""
|
|
1329
|
+
text = "".join(ch if " " <= ch <= "~" else " " for ch in text)
|
|
1330
|
+
text = text.replace("\\", "/").replace("|", "/").replace("<!--", "<! --").replace("-->", "-- >")
|
|
1331
|
+
return " ".join(text.split())
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def blocker_reason(failure: str, output: str, secrets: list[str]) -> str:
|
|
1335
|
+
"""`exit check failing (<failure>)`, then `: ` and the last REASON_TAIL_LINES non-empty output lines on one line,
|
|
1336
|
+
masked (mask_output), with nothing left to escape, cut from the front so the whole cell is at most
|
|
1337
|
+
REASON_CELL_CHARS: the merged blocker says why. The part before `: ` is the stable identity (ROLLUP_PREFIX_RE)."""
|
|
1338
|
+
base = f"exit check failing ({failure})"
|
|
1339
|
+
lines = [ln.strip() for ln in mask_output(output, secrets).splitlines() if ln.strip()]
|
|
1340
|
+
tail = _plain_line(" / ".join(lines[-REASON_TAIL_LINES:]))
|
|
1341
|
+
room = REASON_CELL_CHARS - len(base) - len(": ")
|
|
1342
|
+
if len(tail) > room:
|
|
1343
|
+
tail = ("..." + tail[-(room - 3):].lstrip())[:room]
|
|
1344
|
+
return f"{base}: {tail}" if tail else base
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def _log_stamp() -> str:
|
|
1348
|
+
"""The UTC stamp in an exit-check log name; it sorts in time order (a seam for the tests)."""
|
|
1349
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def keep_check_output(root: Path, slug: str, mid: str, runs: list[tuple[str, str, int | None, str]],
|
|
1353
|
+
secrets: list[str]) -> Path | None:
|
|
1354
|
+
"""Write the last OUTPUT_TAIL_BYTES of what the Exit setup and Exit check printed, masked (mask_output), to
|
|
1355
|
+
`<.local>/exit-check-<id>-<utc>.log` and return its path (None when it cannot be written; the caller says so).
|
|
1356
|
+
Only the newest KEEP_CHECK_LOGS such logs of the roadmap stay. `runs` holds (what, command, exit code or None,
|
|
1357
|
+
output)."""
|
|
1358
|
+
parts = []
|
|
1359
|
+
for what, cmd, rc, output in runs:
|
|
1360
|
+
body = output if not output or output.endswith("\n") else output + "\n"
|
|
1361
|
+
result = "timed out" if rc is None else f"exit {rc}"
|
|
1362
|
+
parts.append(f"== {what}: {cmd}\n{body}== {what} result: {result}\n")
|
|
1363
|
+
data = mask_output("".join(parts), secrets).encode("utf-8")[-OUTPUT_TAIL_BYTES:]
|
|
1364
|
+
path = local_dir(root, slug) / f"exit-check-{mid}-{_log_stamp()}.log"
|
|
1365
|
+
try:
|
|
1366
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1367
|
+
path.write_bytes(data.decode("utf-8", errors="ignore").encode("utf-8"))
|
|
1368
|
+
except OSError:
|
|
1369
|
+
return None
|
|
1370
|
+
logs = sorted(path.parent.glob("exit-check-*.log"), key=lambda f: (f.stem.rsplit("-", 1)[-1], f.name))
|
|
1371
|
+
for old in logs[:-KEEP_CHECK_LOGS]:
|
|
1372
|
+
with contextlib.suppress(OSError):
|
|
1373
|
+
old.unlink()
|
|
1374
|
+
return path
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _log_note(log: Path | None) -> str:
|
|
1378
|
+
return f"Output: {log}" if log is not None else "Output not kept (the .local folder cannot be written)."
|
|
1379
|
+
|
|
1380
|
+
|
|
1209
1381
|
# -- the write path ---------------------------------------------------------------------------------------------
|
|
1210
1382
|
|
|
1211
1383
|
|
|
@@ -1373,7 +1545,7 @@ BATCH_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+|\*)$") # `<plan>#<ph
|
|
|
1373
1545
|
def runner_state_dir(root: Path) -> Path | None:
|
|
1374
1546
|
"""`<git common dir>/caddis-runner`, or None when `root` is not inside a git repo."""
|
|
1375
1547
|
proc = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-common-dir"], capture_output=True, text=True,
|
|
1376
|
-
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL)
|
|
1548
|
+
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
|
|
1377
1549
|
if proc.returncode != 0 or not proc.stdout.strip():
|
|
1378
1550
|
return None
|
|
1379
1551
|
common = Path(proc.stdout.strip())
|
|
@@ -1667,10 +1839,10 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
|
|
|
1667
1839
|
else:
|
|
1668
1840
|
rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
|
|
1669
1841
|
finally:
|
|
1670
|
-
|
|
1842
|
+
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
1671
1843
|
_git(root, "worktree", "prune", check=False)
|
|
1672
1844
|
_git(root, "branch", "-D", branch, check=False)
|
|
1673
|
-
|
|
1845
|
+
caddis_worktree_safety.remove_tree(tmp)
|
|
1674
1846
|
_audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
|
|
1675
1847
|
"pr": pr, "merge_exit": merge_exit if merge_exit is not None
|
|
1676
1848
|
else (rc if pr is not None else None)})
|
|
@@ -1843,28 +2015,60 @@ def rollup(root: Path, slug: str, mid: str, dry_run: bool = False, out=print) ->
|
|
|
1843
2015
|
return edit_cell(fresh.text, mid, "Status", value)
|
|
1844
2016
|
return edit
|
|
1845
2017
|
|
|
2018
|
+
setup = snap.roadmap.exit_setup
|
|
1846
2019
|
if dry_run:
|
|
1847
2020
|
if ms.exit_check:
|
|
2021
|
+
if setup:
|
|
2022
|
+
out(f"would run setup: {setup}")
|
|
1848
2023
|
out(f"would run: {ms.exit_check}")
|
|
1849
2024
|
return write_change(root, slug, mid, "Status done", to_status("done"), True, out)
|
|
1850
2025
|
rc: int | None = 0
|
|
2026
|
+
output = ""
|
|
2027
|
+
log: Path | None = None
|
|
2028
|
+
secrets: list[str] = []
|
|
1851
2029
|
if ms.exit_check:
|
|
2030
|
+
secrets = caddis_redact.load_secrets(Path.home())
|
|
2031
|
+
runs: list[tuple[str, str, int | None, str]] = []
|
|
2032
|
+
setup_rc: int | None = 0
|
|
1852
2033
|
with merged_checkout(root, snap.branch) as cwd:
|
|
1853
|
-
|
|
2034
|
+
if setup: # installs fresh into this worktree; never a link to a shared venv or node_modules
|
|
2035
|
+
setup_rc, setup_output = run_check(cwd, setup, EXIT_SETUP_TIMEOUT)
|
|
2036
|
+
runs.append(("Exit setup", setup, setup_rc, setup_output))
|
|
2037
|
+
if setup_rc == 0:
|
|
2038
|
+
rc, output = run_check(cwd, ms.exit_check, EXIT_CHECK_TIMEOUT)
|
|
2039
|
+
runs.append(("Exit check", ms.exit_check, rc, output))
|
|
2040
|
+
if setup_rc != 0: # the check never ran: an environment problem, never a blocker
|
|
2041
|
+
log = keep_check_output(root, slug, mid, runs, secrets)
|
|
2042
|
+
out(f"{mid}: exit check not run: the Exit setup failed "
|
|
2043
|
+
f"({_failure(setup_rc, EXIT_SETUP_TIMEOUT)}); nothing written. {_log_note(log)}")
|
|
2044
|
+
return caddis_exit.NOT_RUN
|
|
2045
|
+
if rc != 0:
|
|
2046
|
+
log = keep_check_output(root, slug, mid, runs, secrets)
|
|
2047
|
+
why = env_failure(rc, output)
|
|
2048
|
+
if why is not None:
|
|
2049
|
+
hint = ("check the `**Exit setup:**` line (it ran and passed, but did not install what the check "
|
|
2050
|
+
"needs, or the check does not call the venv it made)" if setup else
|
|
2051
|
+
"give the roadmap an `**Exit setup:**` line that installs it fresh")
|
|
2052
|
+
out(f"{mid}: exit check not run: the merged checkout lacks the project environment ({why}); "
|
|
2053
|
+
f"nothing written. Fix: {hint}, or fix the check. {_log_note(log)}")
|
|
2054
|
+
return caddis_exit.NOT_RUN
|
|
1854
2055
|
if rc == 0:
|
|
1855
2056
|
out(f"{mid}: exit check passed" if ms.exit_check else f"{mid}: every phase merged, no exit check")
|
|
1856
2057
|
return write_change(root, slug, mid, "Status done", to_status("done"), False, out)
|
|
1857
2058
|
failure = _failure(rc, EXIT_CHECK_TIMEOUT)
|
|
1858
2059
|
if any(b.type == "data-condition" for b in snap.roadmap.blockers_of(mid)):
|
|
1859
|
-
out(f"{mid}: exit check failing ({failure}) and a data-condition blocker is open: code-done/data-pending"
|
|
2060
|
+
out(f"{mid}: exit check failing ({failure}) and a data-condition blocker is open: code-done/data-pending. "
|
|
2061
|
+
f"{_log_note(log)}")
|
|
1860
2062
|
write_rc = write_change(root, slug, mid, "Status code-done/data-pending",
|
|
1861
2063
|
to_status("code-done/data-pending"), False, out)
|
|
1862
2064
|
return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
|
|
1863
|
-
#
|
|
1864
|
-
reason =
|
|
1865
|
-
out(f"{mid}: {reason}")
|
|
2065
|
+
# Only a short tail of the output reaches the roadmap: redacted, one line, plain ASCII. The rest stays local.
|
|
2066
|
+
reason = blocker_reason(failure, output, secrets)
|
|
2067
|
+
out(f"{mid}: {reason}. {_log_note(log)}")
|
|
2068
|
+
stable = f"exit check failing ({failure})" # a rollup blocker's identity: the tail varies from run to run
|
|
1866
2069
|
write_rc = write_change(root, slug, mid, "block external",
|
|
1867
|
-
lambda fresh: add_blocker(fresh.text, mid, "external", reason), False,
|
|
2070
|
+
lambda fresh: add_blocker(fresh.text, mid, "external", reason, same_prefix=stable), False,
|
|
2071
|
+
out)
|
|
1868
2072
|
return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
|
|
1869
2073
|
|
|
1870
2074
|
|
|
@@ -1944,7 +2148,7 @@ class _Parser(argparse.ArgumentParser):
|
|
|
1944
2148
|
|
|
1945
2149
|
def _repo_root(raw: str) -> Path:
|
|
1946
2150
|
proc = subprocess.run(["git", "-C", raw, "rev-parse", "--show-toplevel"], capture_output=True, text=True,
|
|
1947
|
-
stdin=subprocess.DEVNULL)
|
|
2151
|
+
stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
|
|
1948
2152
|
if proc.returncode != 0:
|
|
1949
2153
|
raise RoadmapError(f"{raw} is not inside a git repo", caddis_exit.NOT_RUN)
|
|
1950
2154
|
return Path(proc.stdout.strip())
|
|
@@ -2018,9 +2222,93 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2018
2222
|
which.add_argument("--all", action="store_true", help="every roadmap on origin (used by hooks and commands)")
|
|
2019
2223
|
p.add_argument("--root", default=".")
|
|
2020
2224
|
p.add_argument("--out", default=None, help="the HTML file (default ~/.caddis/roadmap-pages/<repo>/<slug>.html)")
|
|
2225
|
+
p.add_argument("--no-fetch", action="store_true",
|
|
2226
|
+
help="use the origin refs this checkout has; no git fetch (the waiting hook's background render)")
|
|
2227
|
+
p.add_argument("--delay", type=int, default=0,
|
|
2228
|
+
help="wait this many seconds (at most 60) first: the waiting hook's follow-up render")
|
|
2021
2229
|
return parser
|
|
2022
2230
|
|
|
2023
2231
|
|
|
2232
|
+
RENDER_LOCK_STALE_S = 600 # a render lock older than this is left by a killed render
|
|
2233
|
+
PENDING_NAME = ".roadmap-render.pending" # session_waiting.py writes it beside its debounce stamp
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
def _clear(path: Path | None) -> bool:
|
|
2237
|
+
"""Delete `path`; True when it was there."""
|
|
2238
|
+
if path is None:
|
|
2239
|
+
return False
|
|
2240
|
+
try:
|
|
2241
|
+
path.unlink()
|
|
2242
|
+
return True
|
|
2243
|
+
except FileNotFoundError:
|
|
2244
|
+
return False
|
|
2245
|
+
except OSError:
|
|
2246
|
+
return path.exists()
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
def _take_render_lock(lock: Path) -> bool:
|
|
2250
|
+
"""One render per repo at a time (robustness #8): create `lock` with O_EXCL, never wait. A lock older than
|
|
2251
|
+
RENDER_LOCK_STALE_S is taken over once. False: another render holds it."""
|
|
2252
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
2253
|
+
for attempt in range(2):
|
|
2254
|
+
try:
|
|
2255
|
+
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
2256
|
+
except FileExistsError:
|
|
2257
|
+
pass
|
|
2258
|
+
except PermissionError: # Windows: a deleted lock whose delete is still pending
|
|
2259
|
+
return False
|
|
2260
|
+
else:
|
|
2261
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
2262
|
+
fh.write(str(os.getpid()))
|
|
2263
|
+
return True
|
|
2264
|
+
try:
|
|
2265
|
+
stale = time.time() - lock.stat().st_mtime > RENDER_LOCK_STALE_S
|
|
2266
|
+
except FileNotFoundError:
|
|
2267
|
+
stale = True # released between the create and the stat: try again
|
|
2268
|
+
except OSError: # Windows: a lock being deleted cannot be read either
|
|
2269
|
+
return False
|
|
2270
|
+
if attempt or not stale:
|
|
2271
|
+
return False
|
|
2272
|
+
try:
|
|
2273
|
+
lock.unlink()
|
|
2274
|
+
except FileNotFoundError:
|
|
2275
|
+
pass
|
|
2276
|
+
except OSError:
|
|
2277
|
+
return False
|
|
2278
|
+
return False
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def _render(root: Path, args: argparse.Namespace) -> int:
|
|
2282
|
+
"""`render`: the pages, the prune and the hub. The caller holds the repo's render lock."""
|
|
2283
|
+
import caddis_roadmap_page
|
|
2284
|
+
slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
|
|
2285
|
+
failed = written = pruned = 0
|
|
2286
|
+
for slug in slugs: # --all: one bad roadmap must not stop the others
|
|
2287
|
+
try:
|
|
2288
|
+
path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
|
|
2289
|
+
sys.modules[__name__])
|
|
2290
|
+
if path is None: # a newer caddis owns this page: kept, and neither written nor failed
|
|
2291
|
+
continue
|
|
2292
|
+
written += 1
|
|
2293
|
+
print(f"[ROADMAP] page written: {path.resolve()}")
|
|
2294
|
+
except Exception as exc: # --all: one roadmap must never stop the others
|
|
2295
|
+
if not args.all:
|
|
2296
|
+
raise
|
|
2297
|
+
failed += 1
|
|
2298
|
+
print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
|
|
2299
|
+
try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
|
|
2300
|
+
if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
|
|
2301
|
+
# that failed removes nothing, and a folder still on origin keeps its page even if its render failed
|
|
2302
|
+
pruned = caddis_roadmap_page.prune_folder(
|
|
2303
|
+
caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
|
|
2304
|
+
hub = caddis_roadmap_page.write_hub() if not args.out and (written or pruned) else None
|
|
2305
|
+
if hub is not None: # None: a newer caddis owns the hub (write_hub said so)
|
|
2306
|
+
print(f"[ROADMAP] hub written: {hub.resolve()}")
|
|
2307
|
+
except Exception as exc:
|
|
2308
|
+
print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
|
|
2309
|
+
return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
|
|
2310
|
+
|
|
2311
|
+
|
|
2024
2312
|
def _dispatch(args: argparse.Namespace) -> int:
|
|
2025
2313
|
root = _repo_root(args.root)
|
|
2026
2314
|
if args.cmd == "list":
|
|
@@ -2066,29 +2354,37 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
2066
2354
|
import caddis_roadmap_page # the page is separate; only `render` needs it
|
|
2067
2355
|
if args.all and args.out:
|
|
2068
2356
|
raise RoadmapError("--out works with one --roadmap, not with --all", caddis_exit.ERROR)
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2357
|
+
if args.delay > 0:
|
|
2358
|
+
time.sleep(min(args.delay, 60))
|
|
2359
|
+
folder = caddis_roadmap_page.default_page_path(root, "_").parent.name
|
|
2360
|
+
lock = caddis_roadmap_page.pages_root() / ".locks" / f"{folder}.lock"
|
|
2361
|
+
# The waiting hook's debounce (and a render that finds the lock held) leaves PENDING_NAME: the render that
|
|
2362
|
+
# holds the lock renders once more at its end, so the last mark change always reaches the page.
|
|
2363
|
+
state = caddis_roadmap_page.main_checkout(root) / ".caddis" / "session-state"
|
|
2364
|
+
pending = state / PENDING_NAME if args.all and not args.out else None
|
|
2365
|
+
if not _take_render_lock(lock):
|
|
2366
|
+
if pending is not None and state.is_dir():
|
|
2367
|
+
try:
|
|
2368
|
+
pending.write_text(str(os.getpid()), encoding="utf-8")
|
|
2369
|
+
except OSError:
|
|
2370
|
+
pass
|
|
2371
|
+
print("[ROADMAP] another render of this repo is running; skipped")
|
|
2372
|
+
return caddis_exit.CLEAN
|
|
2373
|
+
global FETCH
|
|
2374
|
+
FETCH = not args.no_fetch
|
|
2375
|
+
try:
|
|
2376
|
+
_clear(pending) # this render reads the marks as they are now
|
|
2377
|
+
code = _render(root, args)
|
|
2378
|
+
if _clear(pending): # a mark changed while it ran: once more, under the same lock
|
|
2379
|
+
code = _render(root, args)
|
|
2380
|
+
return code
|
|
2381
|
+
finally:
|
|
2382
|
+
FETCH = True
|
|
2383
|
+
try: # only our own lock: a render that ran past the stale age may have been taken over
|
|
2384
|
+
if lock.read_text(encoding="utf-8").strip() == str(os.getpid()):
|
|
2385
|
+
lock.unlink()
|
|
2386
|
+
except (OSError, UnicodeDecodeError):
|
|
2387
|
+
pass
|
|
2092
2388
|
return caddis_exit.ERROR
|
|
2093
2389
|
|
|
2094
2390
|
|