alissa-tools-github-devloop 0.8.19__tar.gz → 0.8.20__tar.gz
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.
- {alissa_tools_github_devloop-0.8.19/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.20}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/janitor.py +73 -24
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/loop.py +236 -50
- alissa_tools_github_devloop-0.8.20/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.8.19/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/config.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -42,16 +42,58 @@ from .proc import CommandError, run
|
|
|
42
42
|
|
|
43
43
|
log = logging.getLogger(__name__)
|
|
44
44
|
|
|
45
|
-
# A task workspace's directory name
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# `
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
45
|
+
# A task workspace's directory name. The directives mandate `git worktree add
|
|
46
|
+
# TASK-<n>-<DESC>` with <n> the worker's own implementation task, and
|
|
47
|
+
# `pr_task_ref` in loop.py reads the same shape off a PR's head ref -- and,
|
|
48
|
+
# since issue #115, the PR-side directives tolerate a branch behind one
|
|
49
|
+
# `task/` prefix and then say `git worktree add task--TASK-<n>-<DESC>
|
|
50
|
+
# task/TASK-<n>-<DESC>`: the directory is the branch with `/` rewritten to
|
|
51
|
+
# `--` (WORKTREE_DIR_SEPARATOR) so the worktree stays a flat direct child of
|
|
52
|
+
# the hub. So a workspace directory is the flat shape, or the flat shape
|
|
53
|
+
# behind `task--`, or `task--<n>-<desc>` (the `TASK-` literal dropped, the
|
|
54
|
+
# third shape the incident's workers produced). Full-matched, never
|
|
55
|
+
# prefix-matched, for `parse_worker_session`'s reason: this module's consumer
|
|
56
|
+
# deletes directories, and `TASK-` matches anything an operator felt like
|
|
57
|
+
# naming that way. The description charset is the branch charset the fix
|
|
58
|
+
# edge already refuses to leave (`[A-Za-z0-9._-]+`), so a name that would
|
|
59
|
+
# need shell quoting is not one of ours.
|
|
60
|
+
TASK_WORKSPACE_RE = re.compile(
|
|
61
|
+
r"(?:task--(?:TASK-)?|TASK-)(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# The same grammar as a BRANCH name -- the directory grammar with the
|
|
65
|
+
# rewritten separator put back. `is_task_branch` asks this one; the two are
|
|
66
|
+
# kept side by side so the pair a `task--…` directory promises (`task/…`
|
|
67
|
+
# checked out in it) is one mapping stated once (`branch_of_dir`).
|
|
68
|
+
TASK_BRANCH_RE = re.compile(
|
|
69
|
+
r"(?:task/(?:TASK-)?|TASK-)(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# What a branch's `/` becomes in its worktree directory's name, and the
|
|
73
|
+
# prefix that marks a directory as carrying one. Only a directory that BEGINS
|
|
74
|
+
# with `task--` is ever read back as a prefixed branch, so a flat branch that
|
|
75
|
+
# happens to contain `--` mid-name maps to itself in both directions.
|
|
76
|
+
WORKTREE_DIR_SEPARATOR = "--"
|
|
77
|
+
_PREFIXED_DIR = "task" + WORKTREE_DIR_SEPARATOR
|
|
78
|
+
_PREFIXED_BRANCH = "task/"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dir_of_branch(branch: str) -> str:
|
|
82
|
+
"""The worktree directory name for `branch`: `/` rewritten to `--`, so a
|
|
83
|
+
`task/TASK-<n>-<DESC>` branch lives in `task--TASK-<n>-<DESC>` and a flat
|
|
84
|
+
branch in a directory of its own name. Pure string mapping -- the shape
|
|
85
|
+
gates are the caller's (`loop.worktree_dir` applies them)."""
|
|
86
|
+
return branch.replace("/", WORKTREE_DIR_SEPARATOR)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def branch_of_dir(name: str) -> str:
|
|
90
|
+
"""The branch a workspace directory `name` promises: the inverse of
|
|
91
|
+
`dir_of_branch` for the one prefix the daemon tolerates. `task--X` ->
|
|
92
|
+
`task/X`; anything else is its own branch name."""
|
|
93
|
+
if name.startswith(_PREFIXED_DIR):
|
|
94
|
+
return _PREFIXED_BRANCH + name[len(_PREFIXED_DIR):]
|
|
95
|
+
return name
|
|
96
|
+
|
|
55
97
|
|
|
56
98
|
# Where the `archive` holdout policy pushes unpushed work (issue #99): a NEW
|
|
57
99
|
# ref under this prefix, named after the task branch. Never the task branch
|
|
@@ -85,12 +127,14 @@ DEFAULT_CACHE_PATHS: "tuple[str, ...]" = (
|
|
|
85
127
|
class TaskWorkspace:
|
|
86
128
|
"""One per-task workspace directory, parsed back into what it is.
|
|
87
129
|
|
|
88
|
-
`branch` is the
|
|
89
|
-
|
|
90
|
-
directory and the branch together),
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
130
|
+
`branch` is the branch the directory name PROMISES (`branch_of_dir`):
|
|
131
|
+
the directory name itself for the flat shape (`git worktree add
|
|
132
|
+
TASK-<n>-<DESC>` names the worktree directory and the branch together),
|
|
133
|
+
and `task/…` for a `task--…` directory (the PR-side directives' flat
|
|
134
|
+
worktree dir for a tolerated `task/`-prefixed branch, issue #115). The
|
|
135
|
+
removal code reads honestly either way: the directory is removed by path,
|
|
136
|
+
the branch is deleted by name, and the reclaim's first gate checks that
|
|
137
|
+
the checked-out branch IS the promised one.
|
|
94
138
|
"""
|
|
95
139
|
|
|
96
140
|
path: Path
|
|
@@ -125,18 +169,23 @@ def parse_task_workspace(path: Path, hub: Path) -> "TaskWorkspace | None":
|
|
|
125
169
|
if match is None:
|
|
126
170
|
return None
|
|
127
171
|
return TaskWorkspace(
|
|
128
|
-
path=path,
|
|
172
|
+
path=path,
|
|
173
|
+
branch=branch_of_dir(path.name),
|
|
174
|
+
task=int(match["task"]),
|
|
175
|
+
hub=hub,
|
|
129
176
|
)
|
|
130
177
|
|
|
131
178
|
|
|
132
179
|
def is_task_branch(name: str) -> bool:
|
|
133
|
-
"""Is `name` one of devloop's own task branches (`TASK-<n>-<DESC
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
180
|
+
"""Is `name` one of devloop's own task branches (`TASK-<n>-<DESC>`, or the
|
|
181
|
+
tolerated `task/TASK-<n>-<DESC>` / `task/<n>-<desc>`)?
|
|
182
|
+
|
|
183
|
+
The branch twin of the full-match grammar `parse_task_workspace` admits
|
|
184
|
+
directories with, re-asserted by name at the one place that pushes: the
|
|
185
|
+
archive path writes a ref to the remote, and its name is derived from
|
|
186
|
+
this one, so a branch the daemon did not create must never get a
|
|
187
|
+
`holdout/` twin."""
|
|
188
|
+
return TASK_BRANCH_RE.fullmatch(name) is not None
|
|
140
189
|
|
|
141
190
|
|
|
142
191
|
def holdout_ref(branch: str) -> str:
|
|
@@ -223,7 +223,9 @@ attempt number in, and a fresh budget reusing attempt 1 could collide with
|
|
|
223
223
|
a prior epoch's zombie session. With no readable labeled event the budget
|
|
224
224
|
conservatively stays lifetime-wide (the pre-epoch behavior). The re-fetch
|
|
225
225
|
guards mirror the fix edge's: closed/merged between search and fetch, a
|
|
226
|
-
foreign author, and a foreign-shaped head ref all skip
|
|
226
|
+
foreign author, and a foreign-shaped head ref all skip (a `task/`-prefixed
|
|
227
|
+
worker branch is NOT foreign since issue #115 -- WORKER_HEAD_REF -- and the
|
|
228
|
+
skip is paged on the PR once per head rather than logged every poll).
|
|
227
229
|
|
|
228
230
|
All three edges share ONE backpressure valve, `max_sessions` (0 = unlimited,
|
|
229
231
|
the default -- today's behavior preserved). The daemon spawns a session per
|
|
@@ -696,6 +698,23 @@ _REVIEW_SEAT = (
|
|
|
696
698
|
"foreign session the daemon reaps, and its verdict reaches nobody. "
|
|
697
699
|
)
|
|
698
700
|
|
|
701
|
+
# The branch-NAME rule, stated as a hard MUST with its negative and its
|
|
702
|
+
# consequence (issue #115). The 2026-09-09/10 studio incident: three workers
|
|
703
|
+
# in one window named their branches `task/…` despite the golden-path line
|
|
704
|
+
# above -- the common convention won over a directive that only stated the
|
|
705
|
+
# positive. The daemon now TOLERATES exactly that one deviation on the way
|
|
706
|
+
# back in (WORKER_HEAD_REF), but the rule is still the flat shape, because
|
|
707
|
+
# every other prefix is still foreign and the tolerance is a repair, not a
|
|
708
|
+
# licence. Shared by the issue edge (where the branch is created) and, as a
|
|
709
|
+
# restatement, by the fix and maintain directives (where it is reused).
|
|
710
|
+
_BRANCH_RULE = (
|
|
711
|
+
"The branch name is a hard rule, not a convention: name the branch "
|
|
712
|
+
"exactly `TASK-<n>-<DESC>` — NO `task/`, `feat/` or any other prefix, no "
|
|
713
|
+
"lowercase `task-`; the daemon's fix and maintain edges only spawn on "
|
|
714
|
+
"that shape, and a PR on any other shape gets no fix round, no "
|
|
715
|
+
"maintenance and no resume until an operator renames it. "
|
|
716
|
+
)
|
|
717
|
+
|
|
699
718
|
DEV_DIRECTIVE = (
|
|
700
719
|
"You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
|
|
701
720
|
"{issue_url} (attempt {attempt} of {cap}) — the issue is your "
|
|
@@ -708,7 +727,8 @@ DEV_DIRECTIVE = (
|
|
|
708
727
|
"origin collides with every sibling of that origin), from there (one "
|
|
709
728
|
"task = one branch = one worktree; never clone, never work directly on "
|
|
710
729
|
"main). "
|
|
711
|
-
|
|
730
|
+
+ _BRANCH_RULE
|
|
731
|
+
+ "Implement to the issue's acceptance criteria and VERIFY locally (run the "
|
|
712
732
|
"repo's test/style/type scripts) before publishing. Open a DRAFT PR whose "
|
|
713
733
|
"body contains `Closes #{number}` and BOTH task refs — the origin task "
|
|
714
734
|
"AND your implementation task: the PR body is the canonical cross-actor "
|
|
@@ -758,10 +778,12 @@ RESUME_DIRECTIVE = (
|
|
|
758
778
|
"session got. Golden path: your cwd is the repo's worktree hub ROOT and "
|
|
759
779
|
"the PR's head branch `{head_ref}` ALREADY EXISTS — REUSE its worktree if "
|
|
760
780
|
"one is still on the volume (the dead session's work is in it), otherwise "
|
|
761
|
-
"re-attach it with `git worktree add {
|
|
762
|
-
"
|
|
763
|
-
"
|
|
764
|
-
"
|
|
781
|
+
"re-attach it with `git worktree add {worktree_dir} {head_ref}` (the "
|
|
782
|
+
"directory and the branch differ only when the branch carries a prefix: "
|
|
783
|
+
"`/` becomes `--` in the directory name). Work ONLY on that branch, "
|
|
784
|
+
"under its EXISTING name: never a new branch, never a rename, never a "
|
|
785
|
+
"clone, never main, and NEVER a second PR — the open one IS the "
|
|
786
|
+
"deliverable, and pushing more commits to it is how you deliver. "
|
|
765
787
|
"Then complete the ORIGINAL acceptance criteria — the issue's criteria "
|
|
766
788
|
"are unchanged and still authoritative — and VERIFY locally (run the "
|
|
767
789
|
"repo's test/style/type scripts) before publishing. NEVER force-push: "
|
|
@@ -798,9 +820,13 @@ FIX_DIRECTIVE = (
|
|
|
798
820
|
"of your job: the PR body is the canonical cross-actor record. "
|
|
799
821
|
"Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
|
|
800
822
|
"branch `{head_ref}` already exists — re-attach its worktree with `git "
|
|
801
|
-
"worktree add {
|
|
802
|
-
"
|
|
803
|
-
"
|
|
823
|
+
"worktree add {worktree_dir} {head_ref}` if it is missing (the directory "
|
|
824
|
+
"and the branch differ only when the branch carries a prefix: `/` becomes "
|
|
825
|
+
"`--` in the directory name), and work ONLY on that branch under its "
|
|
826
|
+
"EXISTING name (never a new branch, never a rename, never a clone, never "
|
|
827
|
+
"main). "
|
|
828
|
+
+ _BRANCH_RULE
|
|
829
|
+
+ "FIRST verify the triage of every prior review finding per CR8: exactly "
|
|
804
830
|
"one reasoned `[triage:*]` reply on each finding's thread — post any that "
|
|
805
831
|
"are missing. Then fix the pursued findings on the same branch and VERIFY "
|
|
806
832
|
"locally (run the repo's test/style/type scripts). "
|
|
@@ -847,10 +873,13 @@ FIX_RESUME_DIRECTIVE = (
|
|
|
847
873
|
"far the dead session got. Golden path: your cwd is the repo's worktree "
|
|
848
874
|
"hub ROOT and the PR's head branch `{head_ref}` ALREADY EXISTS — REUSE "
|
|
849
875
|
"its worktree if one is still on the volume (the dead session's work is "
|
|
850
|
-
"in it), otherwise re-attach it with `git worktree add {
|
|
851
|
-
"{head_ref}
|
|
852
|
-
"
|
|
853
|
-
"
|
|
876
|
+
"in it), otherwise re-attach it with `git worktree add {worktree_dir} "
|
|
877
|
+
"{head_ref}` (the directory and the branch differ only when the branch "
|
|
878
|
+
"carries a prefix: `/` becomes `--` in the directory name). Work ONLY on "
|
|
879
|
+
"that branch under its EXISTING name: never a new branch, never a "
|
|
880
|
+
"rename, never a clone, never main, and NEVER a second PR. "
|
|
881
|
+
+ _BRANCH_RULE
|
|
882
|
+
+ "The dead session may have posted SOME of its CR8 triage replies already: "
|
|
854
883
|
"read every finding's thread and post exactly the ones that are missing — "
|
|
855
884
|
"one reasoned `[triage:*]` reply per finding, NEVER a duplicate on a "
|
|
856
885
|
"thread it already answered. Then fix the pursued findings, completing "
|
|
@@ -890,9 +919,13 @@ MAINTAIN_DIRECTIVE = (
|
|
|
890
919
|
"freshen it. "
|
|
891
920
|
"Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
|
|
892
921
|
"branch `{head_ref}` already exists — re-attach its worktree with `git "
|
|
893
|
-
"worktree add {
|
|
894
|
-
"
|
|
895
|
-
"
|
|
922
|
+
"worktree add {worktree_dir} {head_ref}` if it is missing (the directory "
|
|
923
|
+
"and the branch differ only when the branch carries a prefix: `/` becomes "
|
|
924
|
+
"`--` in the directory name), and work ONLY on that branch under its "
|
|
925
|
+
"EXISTING name (never a new branch, never a rename, never a clone, never "
|
|
926
|
+
"main). "
|
|
927
|
+
+ _BRANCH_RULE
|
|
928
|
+
+ "FIRST the idempotent no-op check: if the branch is already conflict-free "
|
|
896
929
|
"AND current vs origin/main, remove the `{label}` label from the PR "
|
|
897
930
|
"(`gh pr edit {number} --remove-label \"{label}\"`) and exit — push "
|
|
898
931
|
"nothing, re-request nobody; this no-op path is what makes retries safe. "
|
|
@@ -1032,6 +1065,21 @@ STACKED_BASE_COMMENT = (
|
|
|
1032
1065
|
"belongs to release gating, not to the PR graph."
|
|
1033
1066
|
)
|
|
1034
1067
|
|
|
1068
|
+
FOREIGN_HEAD_COMMENT = (
|
|
1069
|
+
"**Dev loop: foreign-shaped head ref `{head_ref}`** — the daemon will not "
|
|
1070
|
+
"run fix or maintain rounds on it. Worker branches must be named exactly "
|
|
1071
|
+
"`TASK-<n>-<DESC>` (a single `task/` prefix is tolerated: "
|
|
1072
|
+
"`task/TASK-<n>-<DESC>` or `task/<n>-<desc>`); any other prefix, a "
|
|
1073
|
+
"second `/`, whitespace or shell metacharacters keep the daemon off the "
|
|
1074
|
+
"branch, because the name is interpolated into a worker session's "
|
|
1075
|
+
"prompt as a worktree path and a shell token. Until the head moves, "
|
|
1076
|
+
"review findings on this PR reach no session and a maintenance label is "
|
|
1077
|
+
"ignored. Operator options: rename the branch (`git branch -m` + push, "
|
|
1078
|
+
"then re-open or retarget the PR), or re-open the PR from a "
|
|
1079
|
+
"`TASK-<n>-<DESC>` branch. Paged once per head; a new head on the same "
|
|
1080
|
+
"shape pages again."
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1035
1083
|
ASSIGNMENT_REJECTED_COMMENT = (
|
|
1036
1084
|
"**Dev loop cannot start** — the daemon tried to self-assign this issue "
|
|
1037
1085
|
"as its in-flight marker, but GitHub silently dropped the assignment: "
|
|
@@ -1251,6 +1299,16 @@ ESCALATION_ORPHAN_REJECTED = "orphan-rejected"
|
|
|
1251
1299
|
# number, like every other PR-side kind here.
|
|
1252
1300
|
ESCALATION_STACKED_BASE = "stacked-base"
|
|
1253
1301
|
|
|
1302
|
+
# The fix/maintain edges' foreign-shaped-head skip, paged ON the PR once per
|
|
1303
|
+
# (PR, head) (`foreign_head_kind`, issue #115). Before it, the skip was one
|
|
1304
|
+
# WARNING per poll in a log nobody tails and NOTHING on the PR: three
|
|
1305
|
+
# reviewed studio PRs sat for 1-12 h behind it. Keyed per HEAD, not per PR,
|
|
1306
|
+
# because the remedy is to move the head (rename the branch, re-open from a
|
|
1307
|
+
# `TASK-<n>-<DESC>` one) and a PR whose head moved and is still foreign has
|
|
1308
|
+
# earned a second page. The `foreign-head` prefix keeps these rows from
|
|
1309
|
+
# colliding with any issue-edge kind when an issue and a PR share a number.
|
|
1310
|
+
ESCALATION_FOREIGN_HEAD = "foreign-head"
|
|
1311
|
+
|
|
1254
1312
|
# The `archive` holdout policy could not archive a workspace (issue #99): the
|
|
1255
1313
|
# commit, the push to the holdout ref, or the ls-remote verification failed,
|
|
1256
1314
|
# or the remote sha did not equal the local HEAD. Keyed per WORKSPACE slug
|
|
@@ -1341,6 +1399,11 @@ def rerequest_rejected_kind(round_: int, head_sha: str) -> str:
|
|
|
1341
1399
|
return f"{ESCALATION_REREQUEST_REJECTED}:r{round_}@{head_sha}"
|
|
1342
1400
|
|
|
1343
1401
|
|
|
1402
|
+
def foreign_head_kind(head_sha: str) -> str:
|
|
1403
|
+
"""The ledger kind for the foreign-shaped-head page on ONE (PR, head)."""
|
|
1404
|
+
return f"{ESCALATION_FOREIGN_HEAD}:{head_sha}"
|
|
1405
|
+
|
|
1406
|
+
|
|
1344
1407
|
def orphan_kind(head_sha: str) -> str:
|
|
1345
1408
|
"""The ledger kind that marks ONE draft's mechanical handoff as DONE.
|
|
1346
1409
|
|
|
@@ -1521,16 +1584,60 @@ STALLED_DEFER_MULTIPLE = 2
|
|
|
1521
1584
|
# long enough to need scrolling is one nobody reads.
|
|
1522
1585
|
DISK_OFFENDERS = 3
|
|
1523
1586
|
|
|
1524
|
-
# The daemon's own branch shape
|
|
1525
|
-
#
|
|
1526
|
-
# {head_ref}`), and a PR's head branch is
|
|
1527
|
-
# with push access can move it) -- so every
|
|
1528
|
-
#
|
|
1529
|
-
# `
|
|
1530
|
-
#
|
|
1531
|
-
#
|
|
1587
|
+
# The daemon's own branch shape -- the one its directives NAME (`TASK-<n>-
|
|
1588
|
+
# <DESC>`, flat). head_ref reaches every worker directive as a shell token
|
|
1589
|
+
# (`git worktree add {worktree_dir} {head_ref}`), and a PR's head branch is
|
|
1590
|
+
# not strictly self-authored (anyone with push access can move it) -- so every
|
|
1591
|
+
# PR-side spawn gates on a shape test: the fix and maintain edges via
|
|
1592
|
+
# `_foreign_head_skip`, the issue edge's resume via `_resumable_pr`. This is
|
|
1593
|
+
# also the shape the WORKTREE DIRECTORY must have (`worktree_dir`), since a
|
|
1594
|
+
# slash there would nest the worktree under the hub. Whitespace and shell
|
|
1595
|
+
# metacharacters are all outside it: skip conservatively rather than sanitize.
|
|
1532
1596
|
DAEMON_HEAD_REF = re.compile(r"[A-Za-z0-9._-]+")
|
|
1533
1597
|
|
|
1598
|
+
# The shape a WORKER'S branch is tolerated in: the daemon's own flat shape, or
|
|
1599
|
+
# that shape behind exactly one `task/` prefix (issue #115). The 2026-09-09/10
|
|
1600
|
+
# studio incident: three of five worker sessions in one window named their
|
|
1601
|
+
# branches `task/TASK-<n>-<desc>` / `task/<n>-<desc>` despite the directive,
|
|
1602
|
+
# and DAEMON_HEAD_REF -- flat by design, because head_ref used to be the
|
|
1603
|
+
# worktree PATH as well as the branch -- stranded all three: no fix round, no
|
|
1604
|
+
# maintain, no resume, a WARNING per poll for 12 h. The work was complete and
|
|
1605
|
+
# reviewed; only the gate stood between it and a fix session. So the PR-side
|
|
1606
|
+
# gates admit this ONE common deviation, with the worktree path derived by
|
|
1607
|
+
# `worktree_dir` so the directory stays flat. Everything else stays foreign:
|
|
1608
|
+
# any other prefix (`feat/x`), a second slash (`a/b/c`), whitespace, shell
|
|
1609
|
+
# metacharacters. `DAEMON_HEAD_REF` stays what the daemon itself names, and
|
|
1610
|
+
# DEV_DIRECTIVE still forbids the prefix -- tolerating it on the way back in
|
|
1611
|
+
# is not the same as licensing it on the way out.
|
|
1612
|
+
WORKER_HEAD_REF = re.compile(r"(?:task/)?[A-Za-z0-9._-]+")
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def worktree_dir(head_ref: str) -> str:
|
|
1616
|
+
"""The worktree DIRECTORY a PR-side directive tells its session to create
|
|
1617
|
+
for `head_ref`: the branch name with `/` rewritten to `--`, so the
|
|
1618
|
+
worktree stays a flat direct child of the hub whatever the branch is
|
|
1619
|
+
called (`task/TASK-42384172-sales-chat-surface` ->
|
|
1620
|
+
`task--TASK-42384172-sales-chat-surface`; a flat name maps to itself).
|
|
1621
|
+
|
|
1622
|
+
Both values are interpolated into an agent prompt as shell tokens, so
|
|
1623
|
+
BOTH pass the shape tests before anything is rendered: the branch must be
|
|
1624
|
+
WORKER_HEAD_REF and the directory DAEMON_HEAD_REF (flat, no metacharacters).
|
|
1625
|
+
A `head_ref` that fails either raises rather than rendering -- the gates
|
|
1626
|
+
upstream (`_foreign_head_skip`, `_resumable_pr`) are what keep such a ref
|
|
1627
|
+
from reaching here, and this is the assertion that they did. The mapping
|
|
1628
|
+
itself is the janitor's (`janitor.dir_of_branch`), so the directory the
|
|
1629
|
+
directive names and the one the janitor reclaims cannot drift apart.
|
|
1630
|
+
"""
|
|
1631
|
+
if not WORKER_HEAD_REF.fullmatch(head_ref or ""):
|
|
1632
|
+
raise ValueError(f"head ref {head_ref!r} is not a worker branch shape")
|
|
1633
|
+
directory = janitor.dir_of_branch(head_ref)
|
|
1634
|
+
if not DAEMON_HEAD_REF.fullmatch(directory):
|
|
1635
|
+
raise ValueError(
|
|
1636
|
+
f"worktree dir {directory!r} for head ref {head_ref!r} is not flat"
|
|
1637
|
+
)
|
|
1638
|
+
return directory
|
|
1639
|
+
|
|
1640
|
+
|
|
1534
1641
|
# GitHub's closing keywords, the PROVENANCE signal behind a resume. Every
|
|
1535
1642
|
# other admission test the resume applies asks what STATE a PR is in (open,
|
|
1536
1643
|
# draft, unreviewed, self-authored, daemon-shaped head) -- and a sibling
|
|
@@ -1621,6 +1728,14 @@ def closed_issue_numbers(body: str, owner: str, repo: str) -> "tuple[int, ...]":
|
|
|
1621
1728
|
# mid-name is not the convention.
|
|
1622
1729
|
DAEMON_TASK_BRANCH = re.compile(r"TASK-(\d+)(?:-|$)")
|
|
1623
1730
|
|
|
1731
|
+
# The same convention as a WORKER may have written it (issue #115, the
|
|
1732
|
+
# tolerated deviation WORKER_HEAD_REF admits): the flat form, or the flat form
|
|
1733
|
+
# behind one `task/`, or `task/<n>-<desc>` with the `TASK-` literal dropped --
|
|
1734
|
+
# the three shapes the studio incident's workers actually produced. The task
|
|
1735
|
+
# number is the segment after the prefix either way (`task/609837646-…` counts
|
|
1736
|
+
# as TASK-609837646). Still anchored at the start, still no other prefix.
|
|
1737
|
+
WORKER_TASK_BRANCH = re.compile(r"(?:task/(?:TASK-)?|TASK-)(\d+)(?:-|$)")
|
|
1738
|
+
|
|
1624
1739
|
# The follow-up PR's provenance trailer, mandated by _APPROVE_FOLLOWUP and read
|
|
1625
1740
|
# by the orphan sweep's liveness matrix.
|
|
1626
1741
|
#
|
|
@@ -1680,7 +1795,9 @@ def pr_task_ref(head_ref: str, body: str) -> "int | None":
|
|
|
1680
1795
|
dead session cannot have failed to establish (it exists before the PR
|
|
1681
1796
|
does), while a body ref can be missing precisely because the session died
|
|
1682
1797
|
before writing it. Falling back to the body still catches the PR whose
|
|
1683
|
-
branch was named some other way but whose record is intact.
|
|
1798
|
+
branch was named some other way but whose record is intact. The branch is
|
|
1799
|
+
read with WORKER_TASK_BRANCH, so a `task/`-prefixed worker branch names
|
|
1800
|
+
its task exactly as a flat one does (issue #115).
|
|
1684
1801
|
|
|
1685
1802
|
Deliberately NOT `parse_task_ref`: that reads the `Alissa-Task:` TRAILER an
|
|
1686
1803
|
ISSUE carries, and a PR body's task refs are prose ("origin TASK-1, mine
|
|
@@ -1688,7 +1805,7 @@ def pr_task_ref(head_ref: str, body: str) -> "int | None":
|
|
|
1688
1805
|
logging and the activity line only -- nothing keys off WHICH task it is,
|
|
1689
1806
|
only that the PR names one.
|
|
1690
1807
|
"""
|
|
1691
|
-
match =
|
|
1808
|
+
match = WORKER_TASK_BRANCH.match(head_ref or "")
|
|
1692
1809
|
if match:
|
|
1693
1810
|
return int(match.group(1))
|
|
1694
1811
|
match = TASK_REF_RE.search(body or "")
|
|
@@ -2965,6 +3082,7 @@ class DevWatcher:
|
|
|
2965
3082
|
session=name,
|
|
2966
3083
|
pr_url=resume_pr.url,
|
|
2967
3084
|
head_ref=resume_pr.head_ref,
|
|
3085
|
+
worktree_dir=worktree_dir(resume_pr.head_ref),
|
|
2968
3086
|
dead_session=dead_session,
|
|
2969
3087
|
)
|
|
2970
3088
|
else:
|
|
@@ -3121,11 +3239,12 @@ class DevWatcher:
|
|
|
3121
3239
|
timeline: still OPEN, authored by the token identity (a foreign PR
|
|
3122
3240
|
must never grow a session pushing to its branch -- `linked_open_prs`
|
|
3123
3241
|
filters on open + same-repo only, so a collaborator's or a fork's PR
|
|
3124
|
-
qualifies there), and a head ref of
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3242
|
+
qualifies there), and a head ref of a worker branch shape
|
|
3243
|
+
(WORKER_HEAD_REF: the daemon's own flat shape, or that shape behind
|
|
3244
|
+
one tolerated `task/`). Resolving head_ref here is also what lets
|
|
3245
|
+
RESUME_DIRECTIVE NAME the branch instead of telling the agent to
|
|
3246
|
+
derive it from the PR -- an unsanitized token read out of GitHub is
|
|
3247
|
+
exactly what the shape test exists to keep out of an agent prompt.
|
|
3129
3248
|
|
|
3130
3249
|
Those four tests all ask what STATE a PR is in; none asks whether it
|
|
3131
3250
|
is OURS. `linked_open_prs` admits any open same-repo PR that
|
|
@@ -3212,7 +3331,7 @@ class DevWatcher:
|
|
|
3212
3331
|
pr.author,
|
|
3213
3332
|
)
|
|
3214
3333
|
continue
|
|
3215
|
-
if not
|
|
3334
|
+
if not WORKER_HEAD_REF.fullmatch(pr.head_ref):
|
|
3216
3335
|
log.warning(
|
|
3217
3336
|
"%s: %s head ref %r does not match the daemon's own "
|
|
3218
3337
|
"branch shape — not resuming onto a foreign-shaped "
|
|
@@ -4507,9 +4626,10 @@ class DevWatcher:
|
|
|
4507
4626
|
# head_ref reaches nothing but log lines here (the sweep spawns no
|
|
4508
4627
|
# session, so it never becomes a worktree path or a shell token), but
|
|
4509
4628
|
# the shape is still the provenance signal every other PR-side act
|
|
4510
|
-
# gates on: this daemon
|
|
4511
|
-
# branches,
|
|
4512
|
-
|
|
4629
|
+
# gates on: this daemon's workers create flat `TASK-<n>-<DESC>`
|
|
4630
|
+
# branches (or, tolerated, the same behind one `task/` -- issue
|
|
4631
|
+
# #115), so a ref outside that shape is not its work.
|
|
4632
|
+
if not WORKER_HEAD_REF.fullmatch(pr.head_ref):
|
|
4513
4633
|
return Decision(
|
|
4514
4634
|
Action.SKIPPED,
|
|
4515
4635
|
f"draft head ref {pr.head_ref!r} does not match the daemon's "
|
|
@@ -5423,28 +5543,92 @@ class DevWatcher:
|
|
|
5423
5543
|
|
|
5424
5544
|
def _foreign_head_skip(self, pr: PullRequest, attempt: int) -> "Decision | None":
|
|
5425
5545
|
"""The head-ref shape guard for the PR-side edges that evaluate ONE
|
|
5426
|
-
PR (fix and maintain): this daemon
|
|
5427
|
-
`TASK-<n>-<DESC>` branches
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
the
|
|
5432
|
-
|
|
5546
|
+
PR (fix and maintain): this daemon's workers create flat
|
|
5547
|
+
`TASK-<n>-<DESC>` branches -- or, tolerated since issue #115, the
|
|
5548
|
+
same behind exactly one `task/` -- so a ref outside WORKER_HEAD_REF
|
|
5549
|
+
is not its work. Returns the SKIPPED Decision, or None when the ref
|
|
5550
|
+
is safe to interpolate (as `worktree_dir(head_ref)` + `head_ref`).
|
|
5551
|
+
(The issue edge's resume applies the same shape test via
|
|
5552
|
+
`_resumable_pr`, which filters candidates rather than skipping the
|
|
5553
|
+
item.)
|
|
5554
|
+
|
|
5555
|
+
A skip is PAGED on the PR, once per (PR, head) -- `_page_foreign_head`
|
|
5556
|
+
-- rather than logged at WARNING every poll: the skip is a standing
|
|
5557
|
+
condition nothing else in the system will ever surface (no session is
|
|
5558
|
+
spawned, so no activity line; the reviewer daemon has done its part),
|
|
5559
|
+
and the 2026-09-09/10 incident measured the cost of a log-only signal
|
|
5560
|
+
at 1-12 h per PR. The decision itself is unchanged by the page.
|
|
5561
|
+
"""
|
|
5562
|
+
if WORKER_HEAD_REF.fullmatch(pr.head_ref):
|
|
5433
5563
|
return None
|
|
5434
|
-
|
|
5435
|
-
"%s head ref %r does not match the daemon's own branch shape "
|
|
5436
|
-
"— not spawning a worker session on a foreign-shaped branch",
|
|
5437
|
-
pr.pr_slug,
|
|
5438
|
-
pr.head_ref,
|
|
5439
|
-
)
|
|
5564
|
+
self._page_foreign_head(pr)
|
|
5440
5565
|
return Decision(
|
|
5441
5566
|
Action.SKIPPED,
|
|
5442
5567
|
f"head ref {pr.head_ref!r} does not match the daemon's own "
|
|
5443
|
-
f"branch shape (
|
|
5444
|
-
f"not this daemon's work",
|
|
5568
|
+
f"branch shape (TASK-<n>-<DESC>, or task/ + that) — "
|
|
5569
|
+
f"foreign-shaped branch, not this daemon's work",
|
|
5445
5570
|
attempt,
|
|
5446
5571
|
)
|
|
5447
5572
|
|
|
5573
|
+
def _page_foreign_head(self, pr: PullRequest) -> bool:
|
|
5574
|
+
"""Page the operator ONCE per (PR, head) that the head-ref guard is
|
|
5575
|
+
skipping this PR, on the PR. Returns whether the page was raised.
|
|
5576
|
+
|
|
5577
|
+
`_check_stacked_base`'s doctrine: advisory by construction (the guard
|
|
5578
|
+
still skips; nothing renames or closes anything), and the row lands
|
|
5579
|
+
only AFTER the comment posts, so a transient POST failure retries next
|
|
5580
|
+
poll rather than being recorded as delivered. Dry-run logs and records
|
|
5581
|
+
nothing. Every poll after the first sees the row and logs at DEBUG --
|
|
5582
|
+
the per-poll WARNING this replaces was the silence the incident hid
|
|
5583
|
+
behind. A head with no readable sha cannot be keyed and keeps the
|
|
5584
|
+
per-poll WARNING instead of paging (only reachable from a malformed
|
|
5585
|
+
payload).
|
|
5586
|
+
"""
|
|
5587
|
+
if not pr.head_sha:
|
|
5588
|
+
log.warning(
|
|
5589
|
+
"%s head ref %r does not match the daemon's own branch shape "
|
|
5590
|
+
"— not spawning a worker session on a foreign-shaped branch "
|
|
5591
|
+
"(no head sha to key a page on)",
|
|
5592
|
+
pr.pr_slug,
|
|
5593
|
+
pr.head_ref,
|
|
5594
|
+
)
|
|
5595
|
+
return False
|
|
5596
|
+
kind = foreign_head_kind(pr.head_sha)
|
|
5597
|
+
if self.state.escalated(pr.full_name, pr.number, kind):
|
|
5598
|
+
log.debug(
|
|
5599
|
+
"%s head ref %r is foreign-shaped — skipped (already paged "
|
|
5600
|
+
"on head %s)",
|
|
5601
|
+
pr.pr_slug,
|
|
5602
|
+
pr.head_ref,
|
|
5603
|
+
pr.head_sha[:8],
|
|
5604
|
+
)
|
|
5605
|
+
return False
|
|
5606
|
+
|
|
5607
|
+
log.error(
|
|
5608
|
+
"FOREIGN HEAD %s: head ref %r does not match the daemon's own "
|
|
5609
|
+
"branch shape — no fix or maintain round will spawn on it; "
|
|
5610
|
+
"paging the operator on the PR once for head %s",
|
|
5611
|
+
pr.pr_slug,
|
|
5612
|
+
pr.head_ref,
|
|
5613
|
+
pr.head_sha[:8],
|
|
5614
|
+
)
|
|
5615
|
+
body = FOREIGN_HEAD_COMMENT.format(head_ref=pr.head_ref)
|
|
5616
|
+
if self.config.dry_run:
|
|
5617
|
+
log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
|
|
5618
|
+
return False
|
|
5619
|
+
try:
|
|
5620
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
5621
|
+
except CommandError as exc:
|
|
5622
|
+
log.error(
|
|
5623
|
+
"could not post the foreign-head comment on %s: %s — not "
|
|
5624
|
+
"recording the page; it retries next poll",
|
|
5625
|
+
pr.pr_slug,
|
|
5626
|
+
exc,
|
|
5627
|
+
)
|
|
5628
|
+
return False
|
|
5629
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
5630
|
+
return True
|
|
5631
|
+
|
|
5448
5632
|
def _spawn_fix(
|
|
5449
5633
|
self,
|
|
5450
5634
|
pr: PullRequest,
|
|
@@ -5521,6 +5705,7 @@ class DevWatcher:
|
|
|
5521
5705
|
attempt=attempt,
|
|
5522
5706
|
cap=self.config.attempt_cap,
|
|
5523
5707
|
head_ref=pr.head_ref,
|
|
5708
|
+
worktree_dir=worktree_dir(pr.head_ref),
|
|
5524
5709
|
# Re-request the reviewer whose request_changes this round
|
|
5525
5710
|
# answers -- precise, and always resolvable (the review exists).
|
|
5526
5711
|
reviewer=review.author,
|
|
@@ -5780,6 +5965,7 @@ class DevWatcher:
|
|
|
5780
5965
|
attempt=attempt,
|
|
5781
5966
|
cap=self.config.attempt_cap,
|
|
5782
5967
|
head_ref=pr.head_ref,
|
|
5968
|
+
worktree_dir=worktree_dir(pr.head_ref),
|
|
5783
5969
|
reviewers=reviewers,
|
|
5784
5970
|
session=name,
|
|
5785
5971
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.20
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.19
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|