@caddis/cli 0.4.55 → 0.4.56
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 +378 -37
- 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_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +6 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +58 -6
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +231 -167
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +226 -26
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +2 -1
- 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 +3 -0
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +2 -1
- package/bundles/antigravity-plugin/session_end_agy.py +14 -1
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +13 -6
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- 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 +6 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +2 -2
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +14 -1
- package/bundles/antigravity-plugin-extras/plugin.json +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 +1 -1
- 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)
|
|
@@ -179,7 +249,7 @@ def fetch(root: Path) -> str:
|
|
|
179
249
|
|
|
180
250
|
def show(root: Path, branch: str, path: str) -> str | None:
|
|
181
251
|
proc = subprocess.run(["git", "-C", str(root), "show", f"origin/{branch}:{path}"], capture_output=True,
|
|
182
|
-
stdin=subprocess.DEVNULL)
|
|
252
|
+
stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
|
|
183
253
|
if proc.returncode != 0:
|
|
184
254
|
return None
|
|
185
255
|
# Bytes, not text mode: text mode would turn CRLF into LF and a write would change every line ending.
|
|
@@ -387,6 +457,7 @@ class Blocker:
|
|
|
387
457
|
@dataclass
|
|
388
458
|
class Roadmap:
|
|
389
459
|
runner_host: str = ""
|
|
460
|
+
exit_setup: str = "" # the `**Exit setup:**` command run before an Exit check; empty when there is none
|
|
390
461
|
milestones: list[Milestone] = field(default_factory=list)
|
|
391
462
|
blockers: list[Blocker] = field(default_factory=list)
|
|
392
463
|
errors: list[str] = field(default_factory=list)
|
|
@@ -518,6 +589,12 @@ def parse_roadmap(text: str) -> Roadmap:
|
|
|
518
589
|
rm.runner_host = next((m.group(1) for m in map(HOST_RE.match, view) if m), "")
|
|
519
590
|
if not rm.runner_host:
|
|
520
591
|
rm.errors.append("no `Runner host: <host>` line")
|
|
592
|
+
setups = [(i, m.group(1)) for i, m in enumerate(map(EXIT_SETUP_RE.match, view)) if m]
|
|
593
|
+
if len(setups) > 1:
|
|
594
|
+
rm.errors.append("more than one `**Exit setup:**` line (lines "
|
|
595
|
+
+ ", ".join(str(i + 1) for i, _ in setups) + "); a roadmap has at most one")
|
|
596
|
+
elif setups:
|
|
597
|
+
rm.exit_setup = _cell_text(setups[0][1])
|
|
521
598
|
|
|
522
599
|
table = _section(tables, "Milestones", MILESTONE_COLS, rm.errors)
|
|
523
600
|
seen: set[str] = set()
|
|
@@ -865,7 +942,10 @@ _CALENDAR_NEEDS_DATE = ("a calendar blocker needs --check with an ISO date YYYY-
|
|
|
865
942
|
"date has passed, UTC)")
|
|
866
943
|
|
|
867
944
|
|
|
868
|
-
def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "") -> str:
|
|
945
|
+
def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "", same_prefix: str = "") -> str:
|
|
946
|
+
"""The text with one more Blockers row. No change when the same milestone, type and reason exist, or (with
|
|
947
|
+
`same_prefix`, rollup's stable `exit check failing (...)`) a row whose reason is that prefix or starts with it
|
|
948
|
+
and `: `."""
|
|
869
949
|
rm = parse_roadmap(text)
|
|
870
950
|
if rm.get(mid) is None:
|
|
871
951
|
raise RoadmapError(f"unknown milestone id {mid}")
|
|
@@ -879,7 +959,9 @@ def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "
|
|
|
879
959
|
if when is None:
|
|
880
960
|
raise RoadmapError(_CALENDAR_NEEDS_DATE)
|
|
881
961
|
check_cmd = when.isoformat()
|
|
882
|
-
if any(b.milestone == mid and b.type == btype and b.reason == reason.strip()
|
|
962
|
+
if any(b.milestone == mid and b.type == btype and (b.reason == reason.strip() or (
|
|
963
|
+
same_prefix and (b.reason == same_prefix or b.reason.startswith(same_prefix + ": "))))
|
|
964
|
+
for b in rm.blockers):
|
|
883
965
|
return text
|
|
884
966
|
check_cell = _clean_value(check_cmd, "--check")
|
|
885
967
|
if check_cell and "`" not in check_cell:
|
|
@@ -901,7 +983,18 @@ def remove_blockers(text: str, rows: list[Blocker]) -> str:
|
|
|
901
983
|
|
|
902
984
|
|
|
903
985
|
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.
|
|
986
|
+
"""The rows to remove. More than one row with this milestone, type and reason needs --check to pick one.
|
|
987
|
+
A reason that is only rollup's stable prefix (`exit check failing (exit N)`) removes EVERY row of this milestone
|
|
988
|
+
and type whose reason is that prefix or starts with it and `: ` (each is the same blocker with another tail)."""
|
|
989
|
+
if ROLLUP_PREFIX_RE.fullmatch(reason.strip()):
|
|
990
|
+
prefix = reason.strip()
|
|
991
|
+
rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype
|
|
992
|
+
and (b.reason == prefix or b.reason.startswith(prefix + ": "))]
|
|
993
|
+
if not rows:
|
|
994
|
+
raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{prefix}` (or `{prefix}: ...`)")
|
|
995
|
+
if check_cmd is not None:
|
|
996
|
+
rows = [b for b in rows if b.check == _cell_text(check_cmd)]
|
|
997
|
+
return rows
|
|
905
998
|
rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype and b.reason == reason.strip()]
|
|
906
999
|
if not rows:
|
|
907
1000
|
raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{reason.strip()}`")
|
|
@@ -974,15 +1067,15 @@ def merged_checkout(root: Path, branch: str):
|
|
|
974
1067
|
_git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
|
|
975
1068
|
yield worktree
|
|
976
1069
|
finally:
|
|
977
|
-
|
|
1070
|
+
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
978
1071
|
_git(root, "worktree", "prune", check=False)
|
|
979
|
-
|
|
1072
|
+
caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
|
|
980
1073
|
|
|
981
1074
|
|
|
982
1075
|
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
983
1076
|
if WINDOWS:
|
|
984
1077
|
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True,
|
|
985
|
-
stdin=subprocess.DEVNULL)
|
|
1078
|
+
stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
|
|
986
1079
|
else:
|
|
987
1080
|
try:
|
|
988
1081
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
@@ -1078,11 +1171,11 @@ class _WinJob:
|
|
|
1078
1171
|
|
|
1079
1172
|
def _start(argv, **kwargs) -> tuple[subprocess.Popen, "_WinJob | None"]:
|
|
1080
1173
|
"""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:
|
|
1174
|
+
if not WINDOWS:
|
|
1084
1175
|
kwargs["start_new_session"] = True
|
|
1085
|
-
|
|
1176
|
+
# CREATE_NO_WINDOW: the child (git, gh, a gate) must not flash a window when this runs from a hook.
|
|
1177
|
+
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | _NO_WINDOW) if WINDOWS else 0
|
|
1178
|
+
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, creationflags=flags, **kwargs)
|
|
1086
1179
|
job = None
|
|
1087
1180
|
if WINDOWS:
|
|
1088
1181
|
try:
|
|
@@ -1206,6 +1299,81 @@ def _failure(rc: int | None, timeout: float) -> str:
|
|
|
1206
1299
|
return f"timed out after {timeout:.0f} s" if rc is None else f"exit {rc}"
|
|
1207
1300
|
|
|
1208
1301
|
|
|
1302
|
+
def env_failure(rc: int | None, output: str) -> str | None:
|
|
1303
|
+
"""Why a failed check could not run in the merged checkout (the environment is missing); None for a real failure
|
|
1304
|
+
(also for a timeout: it ran)."""
|
|
1305
|
+
if rc is None or rc == 0:
|
|
1306
|
+
return None
|
|
1307
|
+
if rc in SHELL_CANNOT_RUN_CODES:
|
|
1308
|
+
return f"exit {rc}: the shell could not start the command"
|
|
1309
|
+
lines = [ln for ln in (output or "").splitlines() if ln.strip()][-ENV_SCAN_LINES:]
|
|
1310
|
+
match = ENV_FAILURE_RE.search("\n".join(lines))
|
|
1311
|
+
return f"the output says `{match.group(0)}`" if match else None
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def mask_output(text: str, secrets: list[str]) -> str:
|
|
1315
|
+
"""`text` with secret values (caddis_redact), token-shaped strings, URL credentials and absolute paths masked."""
|
|
1316
|
+
text = caddis_redact.redact(text or "", secrets)
|
|
1317
|
+
for pattern, repl in OUTPUT_MASKS:
|
|
1318
|
+
text = pattern.sub(repl, text)
|
|
1319
|
+
return text
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _plain_line(text: str) -> str:
|
|
1323
|
+
"""Text a roadmap cell holds as is: printable ASCII on one line, no HTML comment marker, and no `|` or backslash,
|
|
1324
|
+
so `_clean_value` escapes nothing and the stored cell is exactly this text."""
|
|
1325
|
+
text = "".join(ch if " " <= ch <= "~" else " " for ch in text)
|
|
1326
|
+
text = text.replace("\\", "/").replace("|", "/").replace("<!--", "<! --").replace("-->", "-- >")
|
|
1327
|
+
return " ".join(text.split())
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def blocker_reason(failure: str, output: str, secrets: list[str]) -> str:
|
|
1331
|
+
"""`exit check failing (<failure>)`, then `: ` and the last REASON_TAIL_LINES non-empty output lines on one line,
|
|
1332
|
+
masked (mask_output), with nothing left to escape, cut from the front so the whole cell is at most
|
|
1333
|
+
REASON_CELL_CHARS: the merged blocker says why. The part before `: ` is the stable identity (ROLLUP_PREFIX_RE)."""
|
|
1334
|
+
base = f"exit check failing ({failure})"
|
|
1335
|
+
lines = [ln.strip() for ln in mask_output(output, secrets).splitlines() if ln.strip()]
|
|
1336
|
+
tail = _plain_line(" / ".join(lines[-REASON_TAIL_LINES:]))
|
|
1337
|
+
room = REASON_CELL_CHARS - len(base) - len(": ")
|
|
1338
|
+
if len(tail) > room:
|
|
1339
|
+
tail = ("..." + tail[-(room - 3):].lstrip())[:room]
|
|
1340
|
+
return f"{base}: {tail}" if tail else base
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _log_stamp() -> str:
|
|
1344
|
+
"""The UTC stamp in an exit-check log name; it sorts in time order (a seam for the tests)."""
|
|
1345
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def keep_check_output(root: Path, slug: str, mid: str, runs: list[tuple[str, str, int | None, str]],
|
|
1349
|
+
secrets: list[str]) -> Path | None:
|
|
1350
|
+
"""Write the last OUTPUT_TAIL_BYTES of what the Exit setup and Exit check printed, masked (mask_output), to
|
|
1351
|
+
`<.local>/exit-check-<id>-<utc>.log` and return its path (None when it cannot be written; the caller says so).
|
|
1352
|
+
Only the newest KEEP_CHECK_LOGS such logs of the roadmap stay. `runs` holds (what, command, exit code or None,
|
|
1353
|
+
output)."""
|
|
1354
|
+
parts = []
|
|
1355
|
+
for what, cmd, rc, output in runs:
|
|
1356
|
+
body = output if not output or output.endswith("\n") else output + "\n"
|
|
1357
|
+
result = "timed out" if rc is None else f"exit {rc}"
|
|
1358
|
+
parts.append(f"== {what}: {cmd}\n{body}== {what} result: {result}\n")
|
|
1359
|
+
data = mask_output("".join(parts), secrets).encode("utf-8")[-OUTPUT_TAIL_BYTES:]
|
|
1360
|
+
path = local_dir(root, slug) / f"exit-check-{mid}-{_log_stamp()}.log"
|
|
1361
|
+
try:
|
|
1362
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1363
|
+
path.write_bytes(data.decode("utf-8", errors="ignore").encode("utf-8"))
|
|
1364
|
+
except OSError:
|
|
1365
|
+
return None
|
|
1366
|
+
logs = sorted(path.parent.glob("exit-check-*.log"), key=lambda f: (f.stem.rsplit("-", 1)[-1], f.name))
|
|
1367
|
+
for old in logs[:-KEEP_CHECK_LOGS]:
|
|
1368
|
+
with contextlib.suppress(OSError):
|
|
1369
|
+
old.unlink()
|
|
1370
|
+
return path
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _log_note(log: Path | None) -> str:
|
|
1374
|
+
return f"Output: {log}" if log is not None else "Output not kept (the .local folder cannot be written)."
|
|
1375
|
+
|
|
1376
|
+
|
|
1209
1377
|
# -- the write path ---------------------------------------------------------------------------------------------
|
|
1210
1378
|
|
|
1211
1379
|
|
|
@@ -1373,7 +1541,7 @@ BATCH_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+|\*)$") # `<plan>#<ph
|
|
|
1373
1541
|
def runner_state_dir(root: Path) -> Path | None:
|
|
1374
1542
|
"""`<git common dir>/caddis-runner`, or None when `root` is not inside a git repo."""
|
|
1375
1543
|
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)
|
|
1544
|
+
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
|
|
1377
1545
|
if proc.returncode != 0 or not proc.stdout.strip():
|
|
1378
1546
|
return None
|
|
1379
1547
|
common = Path(proc.stdout.strip())
|
|
@@ -1667,10 +1835,10 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
|
|
|
1667
1835
|
else:
|
|
1668
1836
|
rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
|
|
1669
1837
|
finally:
|
|
1670
|
-
|
|
1838
|
+
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
1671
1839
|
_git(root, "worktree", "prune", check=False)
|
|
1672
1840
|
_git(root, "branch", "-D", branch, check=False)
|
|
1673
|
-
|
|
1841
|
+
caddis_worktree_safety.remove_tree(tmp)
|
|
1674
1842
|
_audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
|
|
1675
1843
|
"pr": pr, "merge_exit": merge_exit if merge_exit is not None
|
|
1676
1844
|
else (rc if pr is not None else None)})
|
|
@@ -1843,28 +2011,60 @@ def rollup(root: Path, slug: str, mid: str, dry_run: bool = False, out=print) ->
|
|
|
1843
2011
|
return edit_cell(fresh.text, mid, "Status", value)
|
|
1844
2012
|
return edit
|
|
1845
2013
|
|
|
2014
|
+
setup = snap.roadmap.exit_setup
|
|
1846
2015
|
if dry_run:
|
|
1847
2016
|
if ms.exit_check:
|
|
2017
|
+
if setup:
|
|
2018
|
+
out(f"would run setup: {setup}")
|
|
1848
2019
|
out(f"would run: {ms.exit_check}")
|
|
1849
2020
|
return write_change(root, slug, mid, "Status done", to_status("done"), True, out)
|
|
1850
2021
|
rc: int | None = 0
|
|
2022
|
+
output = ""
|
|
2023
|
+
log: Path | None = None
|
|
2024
|
+
secrets: list[str] = []
|
|
1851
2025
|
if ms.exit_check:
|
|
2026
|
+
secrets = caddis_redact.load_secrets(Path.home())
|
|
2027
|
+
runs: list[tuple[str, str, int | None, str]] = []
|
|
2028
|
+
setup_rc: int | None = 0
|
|
1852
2029
|
with merged_checkout(root, snap.branch) as cwd:
|
|
1853
|
-
|
|
2030
|
+
if setup: # installs fresh into this worktree; never a link to a shared venv or node_modules
|
|
2031
|
+
setup_rc, setup_output = run_check(cwd, setup, EXIT_SETUP_TIMEOUT)
|
|
2032
|
+
runs.append(("Exit setup", setup, setup_rc, setup_output))
|
|
2033
|
+
if setup_rc == 0:
|
|
2034
|
+
rc, output = run_check(cwd, ms.exit_check, EXIT_CHECK_TIMEOUT)
|
|
2035
|
+
runs.append(("Exit check", ms.exit_check, rc, output))
|
|
2036
|
+
if setup_rc != 0: # the check never ran: an environment problem, never a blocker
|
|
2037
|
+
log = keep_check_output(root, slug, mid, runs, secrets)
|
|
2038
|
+
out(f"{mid}: exit check not run: the Exit setup failed "
|
|
2039
|
+
f"({_failure(setup_rc, EXIT_SETUP_TIMEOUT)}); nothing written. {_log_note(log)}")
|
|
2040
|
+
return caddis_exit.NOT_RUN
|
|
2041
|
+
if rc != 0:
|
|
2042
|
+
log = keep_check_output(root, slug, mid, runs, secrets)
|
|
2043
|
+
why = env_failure(rc, output)
|
|
2044
|
+
if why is not None:
|
|
2045
|
+
hint = ("check the `**Exit setup:**` line (it ran and passed, but did not install what the check "
|
|
2046
|
+
"needs, or the check does not call the venv it made)" if setup else
|
|
2047
|
+
"give the roadmap an `**Exit setup:**` line that installs it fresh")
|
|
2048
|
+
out(f"{mid}: exit check not run: the merged checkout lacks the project environment ({why}); "
|
|
2049
|
+
f"nothing written. Fix: {hint}, or fix the check. {_log_note(log)}")
|
|
2050
|
+
return caddis_exit.NOT_RUN
|
|
1854
2051
|
if rc == 0:
|
|
1855
2052
|
out(f"{mid}: exit check passed" if ms.exit_check else f"{mid}: every phase merged, no exit check")
|
|
1856
2053
|
return write_change(root, slug, mid, "Status done", to_status("done"), False, out)
|
|
1857
2054
|
failure = _failure(rc, EXIT_CHECK_TIMEOUT)
|
|
1858
2055
|
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"
|
|
2056
|
+
out(f"{mid}: exit check failing ({failure}) and a data-condition blocker is open: code-done/data-pending. "
|
|
2057
|
+
f"{_log_note(log)}")
|
|
1860
2058
|
write_rc = write_change(root, slug, mid, "Status code-done/data-pending",
|
|
1861
2059
|
to_status("code-done/data-pending"), False, out)
|
|
1862
2060
|
return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
|
|
1863
|
-
#
|
|
1864
|
-
reason =
|
|
1865
|
-
out(f"{mid}: {reason}")
|
|
2061
|
+
# Only a short tail of the output reaches the roadmap: redacted, one line, plain ASCII. The rest stays local.
|
|
2062
|
+
reason = blocker_reason(failure, output, secrets)
|
|
2063
|
+
out(f"{mid}: {reason}. {_log_note(log)}")
|
|
2064
|
+
stable = f"exit check failing ({failure})" # a rollup blocker's identity: the tail varies from run to run
|
|
1866
2065
|
write_rc = write_change(root, slug, mid, "block external",
|
|
1867
|
-
lambda fresh: add_blocker(fresh.text, mid, "external", reason), False,
|
|
2066
|
+
lambda fresh: add_blocker(fresh.text, mid, "external", reason, same_prefix=stable), False,
|
|
2067
|
+
out)
|
|
1868
2068
|
return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
|
|
1869
2069
|
|
|
1870
2070
|
|
|
@@ -1944,7 +2144,7 @@ class _Parser(argparse.ArgumentParser):
|
|
|
1944
2144
|
|
|
1945
2145
|
def _repo_root(raw: str) -> Path:
|
|
1946
2146
|
proc = subprocess.run(["git", "-C", raw, "rev-parse", "--show-toplevel"], capture_output=True, text=True,
|
|
1947
|
-
stdin=subprocess.DEVNULL)
|
|
2147
|
+
stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
|
|
1948
2148
|
if proc.returncode != 0:
|
|
1949
2149
|
raise RoadmapError(f"{raw} is not inside a git repo", caddis_exit.NOT_RUN)
|
|
1950
2150
|
return Path(proc.stdout.strip())
|
|
@@ -137,7 +137,8 @@ def main_checkout(root: Path) -> Path:
|
|
|
137
137
|
session-state folder; the waiting hook writes its marks there too."""
|
|
138
138
|
try:
|
|
139
139
|
out = subprocess.run(["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
140
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30
|
|
140
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
|
|
141
|
+
creationflags=0x08000000 if os.name == "nt" else 0)
|
|
141
142
|
common = Path(out.stdout.strip()) if out.returncode == 0 and out.stdout.strip() else None
|
|
142
143
|
except (OSError, subprocess.SubprocessError):
|
|
143
144
|
common = None
|