alissa-tools-github-devloop 0.8.18__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.18/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.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/janitor.py +73 -24
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/loop.py +505 -152
- alissa_tools_github_devloop-0.8.20/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.18 → 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.18/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/config.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.18 → 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.18 → 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.18 → 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.18 → 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:
|
|
@@ -27,11 +27,15 @@ and the merge, a finished session's PR sits open for the whole review loop.
|
|
|
27
27
|
DEAD alone would therefore fire on the modal happy path. So the resume takes
|
|
28
28
|
a FOURTH signal from the PR the session left behind: the handoff publishes
|
|
29
29
|
itself (`gh pr ready` flips the draft off, then the re-request puts a
|
|
30
|
-
reviewer on it), which makes a PR that is STILL A DRAFT
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
reviewer on it), which makes a PR that is STILL A DRAFT the one state that
|
|
31
|
+
says "this session never handed off" -- whatever reviewer is on it, because
|
|
32
|
+
a review request on a draft publishes nothing (the reviewer daemon only
|
|
33
|
+
reviews non-draft PRs and the fix edge refuses drafts, so nothing downstream
|
|
34
|
+
can ever move a draft; studio.alissa.app#1204 sat 80 minutes behind exactly
|
|
35
|
+
that shape). That test also keeps the edges disjoint -- the moment a PR
|
|
36
|
+
leaves draft it belongs to the review lifecycle and the fix edge owns it
|
|
37
|
+
exclusively, so no `develop-*` resume can land on a branch a `fix-*` session
|
|
38
|
+
is already working.
|
|
35
39
|
|
|
36
40
|
The signals so far all describe the PR's STATE, and a sibling task's young
|
|
37
41
|
draft satisfies every one of them; a cross-reference is raised by ANY mention
|
|
@@ -91,8 +95,8 @@ above takes over as attempt k+1, counting toward attempt_cap like any
|
|
|
91
95
|
resume. Nothing about the kill is new machinery on the resume side: it
|
|
92
96
|
manufactures the one signal the matrix already acts on. The invariants are
|
|
93
97
|
the matrix's own -- never on INDETERMINATE (absence of evidence keeps the
|
|
94
|
-
defer), never on a PR that left draft
|
|
95
|
-
|
|
98
|
+
defer), never on a PR that left draft (the wedge sits strictly behind
|
|
99
|
+
`_resumable_pr`), never with `resume_enabled` false (a
|
|
96
100
|
kill whose resume cannot fire would only orphan the lane), and never a
|
|
97
101
|
roster sweep.
|
|
98
102
|
|
|
@@ -173,7 +177,9 @@ self-heal needs an unanswered review, and the issue-edge resume keys off an
|
|
|
173
177
|
OPEN labeled issue, which a draft replayed after its issue merged does not
|
|
174
178
|
have (fahera-mx/studio.alissa.app#279, 2026-07-28: an operator found it by
|
|
175
179
|
accident). So the PR search is widened to drafts and every draft is asked the
|
|
176
|
-
handoff question instead of being skipped: self-authored
|
|
180
|
+
handoff question instead of being skipped: self-authored (a reviewer already
|
|
181
|
+
requested on it is NOT a reason to skip -- a request on a draft arms nothing,
|
|
182
|
+
so the sweep readies it and requests only the reviewers still missing),
|
|
177
183
|
a daemon-shaped head ref naming an implementation task (a `TASK-<n>-<DESC>`
|
|
178
184
|
branch or a task ref in the body -- the provenance test, `closes_issue`'s
|
|
179
185
|
role here), no live worker session for the PR (the SAME liveness rule --
|
|
@@ -217,7 +223,9 @@ attempt number in, and a fresh budget reusing attempt 1 could collide with
|
|
|
217
223
|
a prior epoch's zombie session. With no readable labeled event the budget
|
|
218
224
|
conservatively stays lifetime-wide (the pre-epoch behavior). The re-fetch
|
|
219
225
|
guards mirror the fix edge's: closed/merged between search and fetch, a
|
|
220
|
-
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).
|
|
221
229
|
|
|
222
230
|
All three edges share ONE backpressure valve, `max_sessions` (0 = unlimited,
|
|
223
231
|
the default -- today's behavior preserved). The daemon spawns a session per
|
|
@@ -690,6 +698,23 @@ _REVIEW_SEAT = (
|
|
|
690
698
|
"foreign session the daemon reaps, and its verdict reaches nobody. "
|
|
691
699
|
)
|
|
692
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
|
+
|
|
693
718
|
DEV_DIRECTIVE = (
|
|
694
719
|
"You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
|
|
695
720
|
"{issue_url} (attempt {attempt} of {cap}) — the issue is your "
|
|
@@ -702,7 +727,8 @@ DEV_DIRECTIVE = (
|
|
|
702
727
|
"origin collides with every sibling of that origin), from there (one "
|
|
703
728
|
"task = one branch = one worktree; never clone, never work directly on "
|
|
704
729
|
"main). "
|
|
705
|
-
|
|
730
|
+
+ _BRANCH_RULE
|
|
731
|
+
+ "Implement to the issue's acceptance criteria and VERIFY locally (run the "
|
|
706
732
|
"repo's test/style/type scripts) before publishing. Open a DRAFT PR whose "
|
|
707
733
|
"body contains `Closes #{number}` and BOTH task refs — the origin task "
|
|
708
734
|
"AND your implementation task: the PR body is the canonical cross-actor "
|
|
@@ -741,8 +767,9 @@ RESUME_DIRECTIVE = (
|
|
|
741
767
|
"previous developer session on GitHub issue {issue_url} died AFTER "
|
|
742
768
|
"opening its pull request but BEFORE handing it off for review — the "
|
|
743
769
|
"daemon confirmed its session `{dead_session}` is gone from the machine, "
|
|
744
|
-
"and its DRAFT pull request {pr_url} is still open
|
|
745
|
-
"
|
|
770
|
+
"and its DRAFT pull request {pr_url} is still open (a reviewer may "
|
|
771
|
+
"already be requested on it — that is not a handoff; the ready flip is). "
|
|
772
|
+
"This is attempt {attempt} of {cap}, and your job is to FINISH THAT "
|
|
746
773
|
"PULL REQUEST, not to start the work over. "
|
|
747
774
|
"{task_instruction} "
|
|
748
775
|
"FIRST hydrate the context you did not live through: read the issue (its "
|
|
@@ -751,10 +778,12 @@ RESUME_DIRECTIVE = (
|
|
|
751
778
|
"session got. Golden path: your cwd is the repo's worktree hub ROOT and "
|
|
752
779
|
"the PR's head branch `{head_ref}` ALREADY EXISTS — REUSE its worktree if "
|
|
753
780
|
"one is still on the volume (the dead session's work is in it), otherwise "
|
|
754
|
-
"re-attach it with `git worktree add {
|
|
755
|
-
"
|
|
756
|
-
"
|
|
757
|
-
"
|
|
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. "
|
|
758
787
|
"Then complete the ORIGINAL acceptance criteria — the issue's criteria "
|
|
759
788
|
"are unchanged and still authoritative — and VERIFY locally (run the "
|
|
760
789
|
"repo's test/style/type scripts) before publishing. NEVER force-push: "
|
|
@@ -791,9 +820,13 @@ FIX_DIRECTIVE = (
|
|
|
791
820
|
"of your job: the PR body is the canonical cross-actor record. "
|
|
792
821
|
"Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
|
|
793
822
|
"branch `{head_ref}` already exists — re-attach its worktree with `git "
|
|
794
|
-
"worktree add {
|
|
795
|
-
"
|
|
796
|
-
"
|
|
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 "
|
|
797
830
|
"one reasoned `[triage:*]` reply on each finding's thread — post any that "
|
|
798
831
|
"are missing. Then fix the pursued findings on the same branch and VERIFY "
|
|
799
832
|
"locally (run the repo's test/style/type scripts). "
|
|
@@ -840,10 +873,13 @@ FIX_RESUME_DIRECTIVE = (
|
|
|
840
873
|
"far the dead session got. Golden path: your cwd is the repo's worktree "
|
|
841
874
|
"hub ROOT and the PR's head branch `{head_ref}` ALREADY EXISTS — REUSE "
|
|
842
875
|
"its worktree if one is still on the volume (the dead session's work is "
|
|
843
|
-
"in it), otherwise re-attach it with `git worktree add {
|
|
844
|
-
"{head_ref}
|
|
845
|
-
"
|
|
846
|
-
"
|
|
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: "
|
|
847
883
|
"read every finding's thread and post exactly the ones that are missing — "
|
|
848
884
|
"one reasoned `[triage:*]` reply per finding, NEVER a duplicate on a "
|
|
849
885
|
"thread it already answered. Then fix the pursued findings, completing "
|
|
@@ -883,9 +919,13 @@ MAINTAIN_DIRECTIVE = (
|
|
|
883
919
|
"freshen it. "
|
|
884
920
|
"Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
|
|
885
921
|
"branch `{head_ref}` already exists — re-attach its worktree with `git "
|
|
886
|
-
"worktree add {
|
|
887
|
-
"
|
|
888
|
-
"
|
|
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 "
|
|
889
929
|
"AND current vs origin/main, remove the `{label}` label from the PR "
|
|
890
930
|
"(`gh pr edit {number} --remove-label \"{label}\"`) and exit — push "
|
|
891
931
|
"nothing, re-request nobody; this no-op path is what makes retries safe. "
|
|
@@ -1025,6 +1065,21 @@ STACKED_BASE_COMMENT = (
|
|
|
1025
1065
|
"belongs to release gating, not to the PR graph."
|
|
1026
1066
|
)
|
|
1027
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
|
+
|
|
1028
1083
|
ASSIGNMENT_REJECTED_COMMENT = (
|
|
1029
1084
|
"**Dev loop cannot start** — the daemon tried to self-assign this issue "
|
|
1030
1085
|
"as its in-flight marker, but GitHub silently dropped the assignment: "
|
|
@@ -1244,6 +1299,16 @@ ESCALATION_ORPHAN_REJECTED = "orphan-rejected"
|
|
|
1244
1299
|
# number, like every other PR-side kind here.
|
|
1245
1300
|
ESCALATION_STACKED_BASE = "stacked-base"
|
|
1246
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
|
+
|
|
1247
1312
|
# The `archive` holdout policy could not archive a workspace (issue #99): the
|
|
1248
1313
|
# commit, the push to the holdout ref, or the ls-remote verification failed,
|
|
1249
1314
|
# or the remote sha did not equal the local HEAD. Keyed per WORKSPACE slug
|
|
@@ -1334,6 +1399,11 @@ def rerequest_rejected_kind(round_: int, head_sha: str) -> str:
|
|
|
1334
1399
|
return f"{ESCALATION_REREQUEST_REJECTED}:r{round_}@{head_sha}"
|
|
1335
1400
|
|
|
1336
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
|
+
|
|
1337
1407
|
def orphan_kind(head_sha: str) -> str:
|
|
1338
1408
|
"""The ledger kind that marks ONE draft's mechanical handoff as DONE.
|
|
1339
1409
|
|
|
@@ -1514,16 +1584,60 @@ STALLED_DEFER_MULTIPLE = 2
|
|
|
1514
1584
|
# long enough to need scrolling is one nobody reads.
|
|
1515
1585
|
DISK_OFFENDERS = 3
|
|
1516
1586
|
|
|
1517
|
-
# The daemon's own branch shape
|
|
1518
|
-
#
|
|
1519
|
-
# {head_ref}`), and a PR's head branch is
|
|
1520
|
-
# with push access can move it) -- so every
|
|
1521
|
-
#
|
|
1522
|
-
# `
|
|
1523
|
-
#
|
|
1524
|
-
#
|
|
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.
|
|
1525
1596
|
DAEMON_HEAD_REF = re.compile(r"[A-Za-z0-9._-]+")
|
|
1526
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
|
+
|
|
1527
1641
|
# GitHub's closing keywords, the PROVENANCE signal behind a resume. Every
|
|
1528
1642
|
# other admission test the resume applies asks what STATE a PR is in (open,
|
|
1529
1643
|
# draft, unreviewed, self-authored, daemon-shaped head) -- and a sibling
|
|
@@ -1614,6 +1728,14 @@ def closed_issue_numbers(body: str, owner: str, repo: str) -> "tuple[int, ...]":
|
|
|
1614
1728
|
# mid-name is not the convention.
|
|
1615
1729
|
DAEMON_TASK_BRANCH = re.compile(r"TASK-(\d+)(?:-|$)")
|
|
1616
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
|
+
|
|
1617
1739
|
# The follow-up PR's provenance trailer, mandated by _APPROVE_FOLLOWUP and read
|
|
1618
1740
|
# by the orphan sweep's liveness matrix.
|
|
1619
1741
|
#
|
|
@@ -1673,7 +1795,9 @@ def pr_task_ref(head_ref: str, body: str) -> "int | None":
|
|
|
1673
1795
|
dead session cannot have failed to establish (it exists before the PR
|
|
1674
1796
|
does), while a body ref can be missing precisely because the session died
|
|
1675
1797
|
before writing it. Falling back to the body still catches the PR whose
|
|
1676
|
-
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).
|
|
1677
1801
|
|
|
1678
1802
|
Deliberately NOT `parse_task_ref`: that reads the `Alissa-Task:` TRAILER an
|
|
1679
1803
|
ISSUE carries, and a PR body's task refs are prose ("origin TASK-1, mine
|
|
@@ -1681,7 +1805,7 @@ def pr_task_ref(head_ref: str, body: str) -> "int | None":
|
|
|
1681
1805
|
logging and the activity line only -- nothing keys off WHICH task it is,
|
|
1682
1806
|
only that the PR names one.
|
|
1683
1807
|
"""
|
|
1684
|
-
match =
|
|
1808
|
+
match = WORKER_TASK_BRANCH.match(head_ref or "")
|
|
1685
1809
|
if match:
|
|
1686
1810
|
return int(match.group(1))
|
|
1687
1811
|
match = TASK_REF_RE.search(body or "")
|
|
@@ -2629,11 +2753,16 @@ class DevWatcher:
|
|
|
2629
2753
|
# the session behind the open PR is not running -- but NOT that it was
|
|
2630
2754
|
# abandoned, because a healthy handoff ends in `alissa tmux kill`. So
|
|
2631
2755
|
# DEAD only opens the question, and `_resumable_pr` answers it: a
|
|
2632
|
-
# still-draft
|
|
2756
|
+
# still-draft PR that CLOSES this issue (a reviewer on the draft
|
|
2757
|
+
# changes nothing -- a draft is never handed off) is one of this
|
|
2633
2758
|
# issue's own sessions that was never handed off, and it
|
|
2634
2759
|
# is the one case the deferral was always wrong about -- resumed as
|
|
2635
2760
|
# attempt k+1 with RESUME_DIRECTIVE instead of parking the lane behind
|
|
2636
|
-
# an operator ping.
|
|
2761
|
+
# an operator ping. (When the orphan sweep is on it runs EARLIER in
|
|
2762
|
+
# the same pass and readies such a draft mechanically, so the fetch
|
|
2763
|
+
# here sees a non-draft PR and defers -- the sweep's handoff wins
|
|
2764
|
+
# over a resume without any coordination between the edges.)
|
|
2765
|
+
# Everything else -- ALIVE, INDETERMINATE, and DEAD
|
|
2637
2766
|
# behind a PR already in the review lifecycle -- keeps the floored
|
|
2638
2767
|
# defer: past STALLED_DEFER_MULTIPLE stale windows it posts a
|
|
2639
2768
|
# "stalled" operator comment (this deferral sits above the cap check,
|
|
@@ -2708,9 +2837,9 @@ class DevWatcher:
|
|
|
2708
2837
|
# an operator reading the log must not have to guess.
|
|
2709
2838
|
why = (
|
|
2710
2839
|
"no linked PR is resumable (a handed-off PR — out of "
|
|
2711
|
-
"draft
|
|
2712
|
-
"
|
|
2713
|
-
"
|
|
2840
|
+
"draft — belongs to the review lifecycle, not to a "
|
|
2841
|
+
"resume; a PR that only MENTIONS this issue is "
|
|
2842
|
+
"another task's work)"
|
|
2714
2843
|
if liveness is Liveness.DEAD
|
|
2715
2844
|
and self.config.resume_enabled
|
|
2716
2845
|
else "work may be alive (the review loop can outlast "
|
|
@@ -2953,6 +3082,7 @@ class DevWatcher:
|
|
|
2953
3082
|
session=name,
|
|
2954
3083
|
pr_url=resume_pr.url,
|
|
2955
3084
|
head_ref=resume_pr.head_ref,
|
|
3085
|
+
worktree_dir=worktree_dir(resume_pr.head_ref),
|
|
2956
3086
|
dead_session=dead_session,
|
|
2957
3087
|
)
|
|
2958
3088
|
else:
|
|
@@ -3080,12 +3210,23 @@ class DevWatcher:
|
|
|
3080
3210
|
|
|
3081
3211
|
What separates the two is the PR the session left behind, because the
|
|
3082
3212
|
handoff PUBLISHES itself: `gh pr ready` flips the draft off, then the
|
|
3083
|
-
re-request puts a reviewer on it. So a PR that is STILL A DRAFT
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3213
|
+
re-request puts a reviewer on it. So a PR that is STILL A DRAFT is one
|
|
3214
|
+
its session never handed off -- the only state a resume is owed --
|
|
3215
|
+
and a reviewer requested on that draft does not change the reading: a
|
|
3216
|
+
request on a draft arms nothing (the reviewer daemon reviews only
|
|
3217
|
+
non-draft PRs, the fix edge refuses drafts), so no automation
|
|
3218
|
+
downstream can ever move it. The two half-states a session can die in
|
|
3219
|
+
are therefore read differently: (a) READY + NO reviewer -- the ready
|
|
3220
|
+
flip landed, the request did not -- reads handed-off here and keeps
|
|
3221
|
+
the legacy floored defer, conservative on purpose since that is also
|
|
3222
|
+
exactly what a live review loop looks like from this edge; where this
|
|
3223
|
+
daemon's own sweep made that flip, the `orphan-ready@<head>` row lets
|
|
3224
|
+
the PR edge's `_finish_orphan_handoff` complete it. (b) DRAFT +
|
|
3225
|
+
reviewer -- the request landed, the flip did not (studio#1204: 80
|
|
3226
|
+
minutes behind a draft with `alissa-app` on it) -- is resumable when
|
|
3227
|
+
the session is DEAD, exactly like a review-less draft, and deferred
|
|
3228
|
+
while ALIVE; with the orphan sweep on, that sweep readies it earlier
|
|
3229
|
+
in the same pass, so the resume yields to the mechanical handoff.
|
|
3089
3230
|
|
|
3090
3231
|
That same test settles the cross-edge race: the moment a PR leaves
|
|
3091
3232
|
draft it belongs to the review lifecycle, and the fix edge -- which
|
|
@@ -3098,11 +3239,12 @@ class DevWatcher:
|
|
|
3098
3239
|
timeline: still OPEN, authored by the token identity (a foreign PR
|
|
3099
3240
|
must never grow a session pushing to its branch -- `linked_open_prs`
|
|
3100
3241
|
filters on open + same-repo only, so a collaborator's or a fork's PR
|
|
3101
|
-
qualifies there), and a head ref of
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
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.
|
|
3106
3248
|
|
|
3107
3249
|
Those four tests all ask what STATE a PR is in; none asks whether it
|
|
3108
3250
|
is OURS. `linked_open_prs` admits any open same-repo PR that
|
|
@@ -3162,16 +3304,24 @@ class DevWatcher:
|
|
|
3162
3304
|
# still a PR whose base can strand it, and the guard's own author
|
|
3163
3305
|
# test is what keeps it off PRs that are not ours.
|
|
3164
3306
|
self._check_stacked_base(pr)
|
|
3165
|
-
if not pr.draft
|
|
3307
|
+
if not pr.draft:
|
|
3166
3308
|
log.info(
|
|
3167
|
-
"%s: %s has been handed off (draft
|
|
3168
|
-
"not a resume candidate; the review lifecycle owns it",
|
|
3309
|
+
"%s: %s has been handed off (out of draft, reviewers=%s) "
|
|
3310
|
+
"— not a resume candidate; the review lifecycle owns it",
|
|
3169
3311
|
issue.issue_slug,
|
|
3170
3312
|
url,
|
|
3171
|
-
pr.draft,
|
|
3172
3313
|
", ".join(pr.requested_reviewers) or "none",
|
|
3173
3314
|
)
|
|
3174
3315
|
continue
|
|
3316
|
+
if pr.requested_reviewers:
|
|
3317
|
+
log.info(
|
|
3318
|
+
"%s: %s is still a DRAFT with %s requested — a review "
|
|
3319
|
+
"request on a draft is not a handoff (the ready flip is), "
|
|
3320
|
+
"so this PR is a resume candidate",
|
|
3321
|
+
issue.issue_slug,
|
|
3322
|
+
url,
|
|
3323
|
+
", ".join(pr.requested_reviewers),
|
|
3324
|
+
)
|
|
3175
3325
|
if pr.author != self.github.login:
|
|
3176
3326
|
log.warning(
|
|
3177
3327
|
"%s: %s is authored by %r, not the developer identity — "
|
|
@@ -3181,7 +3331,7 @@ class DevWatcher:
|
|
|
3181
3331
|
pr.author,
|
|
3182
3332
|
)
|
|
3183
3333
|
continue
|
|
3184
|
-
if not
|
|
3334
|
+
if not WORKER_HEAD_REF.fullmatch(pr.head_ref):
|
|
3185
3335
|
log.warning(
|
|
3186
3336
|
"%s: %s head ref %r does not match the daemon's own "
|
|
3187
3337
|
"branch shape — not resuming onto a foreign-shaped "
|
|
@@ -3258,12 +3408,13 @@ class DevWatcher:
|
|
|
3258
3408
|
|
|
3259
3409
|
Two gates, in order. First `_resumable_pr`, verbatim -- the wedge
|
|
3260
3410
|
path inherits every admission test the resume has (still a draft,
|
|
3261
|
-
|
|
3262
|
-
|
|
3411
|
+
self-authored, daemon-shaped head, a body that CLOSES this issue,
|
|
3412
|
+
exactly one candidate; a reviewer requested on the draft is not a
|
|
3413
|
+
handoff and does not exclude it), because the kill it licenses is
|
|
3263
3414
|
only safe where the resume that follows it is: a PR that has left
|
|
3264
|
-
draft
|
|
3265
|
-
|
|
3266
|
-
|
|
3415
|
+
draft belongs to the review lifecycle, and a live review loop behind
|
|
3416
|
+
an ALIVE session is the healthy shape this daemon was built around.
|
|
3417
|
+
Then the footprint (`_footprint_age`):
|
|
3267
3418
|
None means unreadable, and unreadable keeps the defer -- the same
|
|
3268
3419
|
fail-safe every other presumed-dead decision here makes.
|
|
3269
3420
|
|
|
@@ -4459,24 +4610,26 @@ class DevWatcher:
|
|
|
4459
4610
|
f"identity — not this daemon's to hand off",
|
|
4460
4611
|
)
|
|
4461
4612
|
|
|
4462
|
-
# A draft already carrying a review request is
|
|
4463
|
-
#
|
|
4464
|
-
#
|
|
4465
|
-
#
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4613
|
+
# A draft already carrying a review request is NOT excluded (issue
|
|
4614
|
+
# #113). The sweep used to read a reviewer on a draft as someone's
|
|
4615
|
+
# deliberate work in progress, but a request on a draft arms nothing
|
|
4616
|
+
# -- the reviewer daemon reviews only non-draft PRs and the fix edge
|
|
4617
|
+
# refuses drafts -- and the issue edge read the same shape as "handed
|
|
4618
|
+
# off", so a session that died between its `--add-reviewer` and its
|
|
4619
|
+
# `gh pr ready` (studio.alissa.app#1204: 80 minutes of silence after
|
|
4620
|
+
# a 10-hour attempt) left a draft NO edge would ever move. The
|
|
4621
|
+
# liveness gate below is what protects a deliberately-parked draft:
|
|
4622
|
+
# a live session behind it is never swept, and for a DEAD one the
|
|
4623
|
+
# sweep does the FIRST half only -- the ready flip -- and requests
|
|
4624
|
+
# just the configured reviewers that are not already on the PR.
|
|
4473
4625
|
|
|
4474
4626
|
# head_ref reaches nothing but log lines here (the sweep spawns no
|
|
4475
4627
|
# session, so it never becomes a worktree path or a shell token), but
|
|
4476
4628
|
# the shape is still the provenance signal every other PR-side act
|
|
4477
|
-
# gates on: this daemon
|
|
4478
|
-
# branches,
|
|
4479
|
-
|
|
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):
|
|
4480
4633
|
return Decision(
|
|
4481
4634
|
Action.SKIPPED,
|
|
4482
4635
|
f"draft head ref {pr.head_ref!r} does not match the daemon's "
|
|
@@ -4804,36 +4957,64 @@ class DevWatcher:
|
|
|
4804
4957
|
not retried on this head (escalate-don't-retry, `_escalate_rejection`
|
|
4805
4958
|
verbatim).
|
|
4806
4959
|
|
|
4960
|
+
A draft that ALREADY carries a review request (issue #113) takes the
|
|
4961
|
+
same walk minus the request: the session died between its
|
|
4962
|
+
`--add-reviewer` and its `gh pr ready`, so the flip is the only act
|
|
4963
|
+
still owed. `request_reviews` is skipped entirely when every
|
|
4964
|
+
configured reviewer is already on the PR -- re-requesting an
|
|
4965
|
+
already-requested reviewer is a no-op on GitHub, but it must not be
|
|
4966
|
+
recorded as a fresh request -- and requests only the missing ones
|
|
4967
|
+
otherwise. Both ledger rows still land, so the once-per-(PR, head)
|
|
4968
|
+
guarantee and `_owes_orphan_handoff` keep holding on this shape.
|
|
4969
|
+
|
|
4807
4970
|
The activity line comes LAST and stays best-effort: it is telemetry
|
|
4808
4971
|
about acts that have already happened, and it is written as a NON-SPAWN
|
|
4809
4972
|
context because no session exists for it to claim a lane on behalf of.
|
|
4810
4973
|
"""
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4974
|
+
already, missing = self._orphan_reviewers_split(pr)
|
|
4975
|
+
if already:
|
|
4976
|
+
log.warning(
|
|
4977
|
+
"ORPHAN DRAFT %s (%s): head %s has been public %.0f min, %s "
|
|
4978
|
+
"already requested on it, and no worker session for this PR "
|
|
4979
|
+
"is listed — the session that opened it died after its "
|
|
4980
|
+
"review request but before flipping it ready (a request on a "
|
|
4981
|
+
"draft arms nothing); marking it ready mechanically%s (once "
|
|
4982
|
+
"per PR/head)",
|
|
4983
|
+
pr.pr_slug,
|
|
4984
|
+
task_ref,
|
|
4985
|
+
pr.head_sha[:8],
|
|
4986
|
+
waited / 60,
|
|
4987
|
+
", ".join(already),
|
|
4988
|
+
f" and requesting {', '.join(missing)}" if missing else "",
|
|
4989
|
+
)
|
|
4990
|
+
else:
|
|
4991
|
+
log.warning(
|
|
4992
|
+
"ORPHAN DRAFT %s (%s): head %s has been public %.0f min, no "
|
|
4993
|
+
"reviewer is requested, and no worker session for this PR is "
|
|
4994
|
+
"listed — the session that opened it died before handing it "
|
|
4995
|
+
"off; marking it ready and requesting %s mechanically (once "
|
|
4996
|
+
"per PR/head)",
|
|
4997
|
+
pr.pr_slug,
|
|
4998
|
+
task_ref,
|
|
4999
|
+
pr.head_sha[:8],
|
|
5000
|
+
waited / 60,
|
|
5001
|
+
", ".join(missing),
|
|
5002
|
+
)
|
|
4824
5003
|
|
|
4825
5004
|
if self.config.dry_run:
|
|
5005
|
+
request = (
|
|
5006
|
+
f"request {', '.join(missing)}"
|
|
5007
|
+
if missing
|
|
5008
|
+
else f"request nobody ({', '.join(already)} already requested)"
|
|
5009
|
+
)
|
|
4826
5010
|
log.info(
|
|
4827
|
-
"[dry-run] would mark %s ready and
|
|
4828
|
-
pr.pr_slug,
|
|
4829
|
-
", ".join(reviewers),
|
|
5011
|
+
"[dry-run] would mark %s ready and %s", pr.pr_slug, request
|
|
4830
5012
|
)
|
|
4831
5013
|
return Decision(
|
|
4832
5014
|
Action.IN_FLIGHT,
|
|
4833
5015
|
f"[dry-run] would mechanically hand off draft head "
|
|
4834
5016
|
f"{pr.head_sha[:8]} (public {int(waited / 60)} min, its "
|
|
4835
|
-
f"session gone): mark ready + request
|
|
4836
|
-
f"{', '.join(reviewers)}",
|
|
5017
|
+
f"session gone): mark ready + {request}",
|
|
4837
5018
|
task_ref=task_ref,
|
|
4838
5019
|
)
|
|
4839
5020
|
|
|
@@ -4863,6 +5044,28 @@ class DevWatcher:
|
|
|
4863
5044
|
|
|
4864
5045
|
return self._request_orphan_reviewers(pr, task_ref, waited)
|
|
4865
5046
|
|
|
5047
|
+
def _orphan_reviewers_split(
|
|
5048
|
+
self, pr: PullRequest
|
|
5049
|
+
) -> "tuple[tuple[str, ...], tuple[str, ...]]":
|
|
5050
|
+
"""The configured reviewers, split by what the PR already carries:
|
|
5051
|
+
(already requested, still missing), each in config order.
|
|
5052
|
+
|
|
5053
|
+
Casefolded like `_reviewer_reviews`: the configured set is
|
|
5054
|
+
operator-typed and GitHub logins are case-insensitive, so `Alissa-App`
|
|
5055
|
+
in the config and `alissa-app` on the PR are one reviewer, not a
|
|
5056
|
+
missing one. Stated once because the sweep and the completion path
|
|
5057
|
+
both need exactly this answer -- the request must never name a
|
|
5058
|
+
reviewer who is already on the PR (a no-op on GitHub that would still
|
|
5059
|
+
be recorded, and logged, as a fresh request)."""
|
|
5060
|
+
on_pr = {r.casefold() for r in pr.requested_reviewers}
|
|
5061
|
+
already = tuple(
|
|
5062
|
+
r for r in self.config.reviewers if r.casefold() in on_pr
|
|
5063
|
+
)
|
|
5064
|
+
missing = tuple(
|
|
5065
|
+
r for r in self.config.reviewers if r.casefold() not in on_pr
|
|
5066
|
+
)
|
|
5067
|
+
return already, missing
|
|
5068
|
+
|
|
4866
5069
|
def _owes_orphan_handoff(self, pr: PullRequest) -> bool:
|
|
4867
5070
|
"""Did THIS daemon ready this exact head and still owe it a reviewer?
|
|
4868
5071
|
|
|
@@ -4904,6 +5107,14 @@ class DevWatcher:
|
|
|
4904
5107
|
entitled to finish its own handoff). Not re-run is the grace window --
|
|
4905
5108
|
it was satisfied when the flip fired, and the head has not moved since
|
|
4906
5109
|
(a moved head would be a different key with no ready row at all).
|
|
5110
|
+
|
|
5111
|
+
Reached only for a PR with NO pending request (evaluate_pr's own
|
|
5112
|
+
guard sits above this). So a draft that already carried a reviewer
|
|
5113
|
+
can never arrive here: it left draft carrying that reviewer, and the
|
|
5114
|
+
pending-request guard reads it as in review. That is why
|
|
5115
|
+
`_request_orphan_reviewers` never leaves that shape half-recorded --
|
|
5116
|
+
a failed request for the MISSING reviewers there is given up on and
|
|
5117
|
+
the handoff completed on the spot (round-1 review fix, PR #114).
|
|
4907
5118
|
"""
|
|
4908
5119
|
if not self._owes_orphan_handoff(pr) or not self.config.reviewers:
|
|
4909
5120
|
return None
|
|
@@ -4952,43 +5163,73 @@ class DevWatcher:
|
|
|
4952
5163
|
this is the completion path (where that number is a poll or more old
|
|
4953
5164
|
and would be reported as fact).
|
|
4954
5165
|
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
5166
|
+
Only the configured reviewers NOT already on the PR are requested
|
|
5167
|
+
(`_orphan_reviewers_split`, issue #113). When every one of them is
|
|
5168
|
+
already requested -- the draft the session put a reviewer on before
|
|
5169
|
+
dying, or a completion-path PR someone requested by hand between
|
|
5170
|
+
polls -- no call is made at all: the DONE marker lands, the activity
|
|
5171
|
+
line says who was already there, and the reviewer daemon already sees
|
|
5172
|
+
the PR.
|
|
5173
|
+
|
|
5174
|
+
Failure handling on the review-less shape mirrors
|
|
5175
|
+
`_rerequest_review`'s exactly: a ReviewRequestRejected is permanent
|
|
5176
|
+
and operator-fixable (escalate, don't retry, and never record the
|
|
5177
|
+
DONE marker); a CommandError is transient (record nothing, retry next
|
|
5178
|
+
poll). The READY row stays in both cases -- the flip really did
|
|
5179
|
+
happen, and re-flipping an already-ready PR is not what the retry
|
|
5180
|
+
needs to do.
|
|
5181
|
+
|
|
5182
|
+
On the PARTIAL shape -- some configured reviewers already on the PR,
|
|
5183
|
+
the call carrying only the missing rest -- neither doctrine applies
|
|
5184
|
+
(round-1 review fix, PR #114). `request_reviews` raises only when
|
|
5185
|
+
nothing in THAT call landed, which here does not mean nobody is on
|
|
5186
|
+
the PR: the flip landed and `already` is requested, so the PR is in
|
|
5187
|
+
review and the reviewer daemon sees it. The refusal page would be
|
|
5188
|
+
false ("never marked ready or sent to a reviewer"), and a retry is
|
|
5189
|
+
structurally impossible: next poll the pending-request guard in
|
|
5190
|
+
evaluate_pr reads the PR as in review before `_finish_orphan_handoff`
|
|
5191
|
+
is reached, so a ready row left down would stick on that head
|
|
5192
|
+
forever. So a failed request for the missing subset, permanent or
|
|
5193
|
+
transient, is logged as given up on -- naming who IS on the PR --
|
|
5194
|
+
and the handoff is completed: DONE row, activity line."""
|
|
5195
|
+
already, missing = self._orphan_reviewers_split(pr)
|
|
5196
|
+
landed: tuple[str, ...] = ()
|
|
5197
|
+
if missing:
|
|
5198
|
+
try:
|
|
5199
|
+
landed = self.github.request_reviews(
|
|
5200
|
+
pr.owner, pr.repo, pr.number, missing
|
|
5201
|
+
)
|
|
5202
|
+
except ReviewRequestRejected as exc:
|
|
5203
|
+
if not already:
|
|
5204
|
+
return self._escalate_orphan_rejected(pr, task_ref, exc)
|
|
5205
|
+
self._log_orphan_reviewers_given_up(pr, missing, already, exc)
|
|
5206
|
+
except CommandError as exc:
|
|
5207
|
+
if not already:
|
|
5208
|
+
log.error(
|
|
5209
|
+
"could not request review from %s on %s: %s — NOT "
|
|
5210
|
+
"recording the handoff; the sweep retries next poll",
|
|
5211
|
+
", ".join(missing),
|
|
5212
|
+
pr.pr_slug,
|
|
5213
|
+
exc,
|
|
5214
|
+
)
|
|
5215
|
+
return Decision(
|
|
5216
|
+
Action.SKIPPED,
|
|
5217
|
+
f"the PR was marked ready but the review request "
|
|
5218
|
+
f"did not land ({exc}) — transient; not recorded, "
|
|
5219
|
+
f"retrying next poll",
|
|
5220
|
+
task_ref=task_ref,
|
|
5221
|
+
)
|
|
5222
|
+
self._log_orphan_reviewers_given_up(pr, missing, already, exc)
|
|
4982
5223
|
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
5224
|
+
dropped = tuple(r for r in missing if r not in landed)
|
|
5225
|
+
if dropped and landed:
|
|
5226
|
+
log.warning(
|
|
5227
|
+
"%s: GitHub silently dropped reviewer(s) %s from the "
|
|
5228
|
+
"mechanical handoff; %s landed, so the PR IS in review",
|
|
5229
|
+
pr.pr_slug,
|
|
5230
|
+
", ".join(dropped),
|
|
5231
|
+
", ".join(landed + already),
|
|
5232
|
+
)
|
|
4992
5233
|
|
|
4993
5234
|
self.state.record_escalation(
|
|
4994
5235
|
pr.full_name, pr.number, orphan_kind(pr.head_sha)
|
|
@@ -4998,6 +5239,23 @@ class DevWatcher:
|
|
|
4998
5239
|
if waited is not None
|
|
4999
5240
|
else ""
|
|
5000
5241
|
)
|
|
5242
|
+
if landed and already:
|
|
5243
|
+
acted = (
|
|
5244
|
+
f"marked ready and requested review from {', '.join(landed)} "
|
|
5245
|
+
f"({', '.join(already)} already requested)"
|
|
5246
|
+
)
|
|
5247
|
+
died = "before handing it off"
|
|
5248
|
+
elif landed:
|
|
5249
|
+
acted = f"marked ready and requested review from {', '.join(landed)}"
|
|
5250
|
+
died = "before handing it off"
|
|
5251
|
+
else:
|
|
5252
|
+
noun = "reviewer" if len(already) == 1 else "reviewers"
|
|
5253
|
+
verb = "was" if len(already) == 1 else "were"
|
|
5254
|
+
acted = (
|
|
5255
|
+
f"marked ready ({noun} {', '.join(already)} {verb} already "
|
|
5256
|
+
f"requested)"
|
|
5257
|
+
)
|
|
5258
|
+
died = "before flipping it"
|
|
5001
5259
|
self._append_activity(
|
|
5002
5260
|
pr.owner,
|
|
5003
5261
|
pr.repo,
|
|
@@ -5005,22 +5263,51 @@ class DevWatcher:
|
|
|
5005
5263
|
self._activity_line(
|
|
5006
5264
|
orphan_activity_lane(pr),
|
|
5007
5265
|
"orphan sweep",
|
|
5008
|
-
f"{ACTIVITY_HANDED_OFF} —
|
|
5009
|
-
f"
|
|
5010
|
-
f"
|
|
5011
|
-
f"protocol (no session spawned, once per PR/head)",
|
|
5266
|
+
f"{ACTIVITY_HANDED_OFF} — {acted}; the session that opened "
|
|
5267
|
+
f"this draft died {died}, so the daemon completed its "
|
|
5268
|
+
f"protocol{age} (no session spawned, once per PR/head)",
|
|
5012
5269
|
),
|
|
5013
5270
|
pr.pr_slug,
|
|
5014
5271
|
)
|
|
5015
5272
|
|
|
5273
|
+
if landed:
|
|
5274
|
+
requested = f"and review requested from {', '.join(landed)}"
|
|
5275
|
+
else:
|
|
5276
|
+
requested = (
|
|
5277
|
+
f"(review already requested from {', '.join(already)}, no "
|
|
5278
|
+
f"new request made)"
|
|
5279
|
+
)
|
|
5016
5280
|
return Decision(
|
|
5017
5281
|
Action.IN_FLIGHT,
|
|
5018
|
-
f"orphaned draft handed off — marked ready
|
|
5019
|
-
f"
|
|
5020
|
-
f"in the reviewer's court",
|
|
5282
|
+
f"orphaned draft handed off — marked ready {requested} for head "
|
|
5283
|
+
f"{pr.head_sha[:8]}; the ball is in the reviewer's court",
|
|
5021
5284
|
task_ref=task_ref,
|
|
5022
5285
|
)
|
|
5023
5286
|
|
|
5287
|
+
@staticmethod
|
|
5288
|
+
def _log_orphan_reviewers_given_up(
|
|
5289
|
+
pr: PullRequest,
|
|
5290
|
+
missing: "tuple[str, ...]",
|
|
5291
|
+
already: "tuple[str, ...]",
|
|
5292
|
+
exc: Exception,
|
|
5293
|
+
) -> None:
|
|
5294
|
+
"""The partial shape's one failure path (see
|
|
5295
|
+
`_request_orphan_reviewers`): the request for the configured
|
|
5296
|
+
reviewers NOT yet on the PR failed, `already` IS on it, and no retry
|
|
5297
|
+
can ever reach this PR again -- so the missing reviewers are given up
|
|
5298
|
+
on, out loud, and the handoff completes with who is there."""
|
|
5299
|
+
log.warning(
|
|
5300
|
+
"%s: could not request review from %s on the mechanical handoff "
|
|
5301
|
+
"(%s) — giving up on them: %s already requested and the PR is out "
|
|
5302
|
+
"of draft, so it IS in review; the daemon will not retry (the "
|
|
5303
|
+
"pending request reads as in review from here on), request them "
|
|
5304
|
+
"by hand if wanted",
|
|
5305
|
+
pr.pr_slug,
|
|
5306
|
+
", ".join(missing),
|
|
5307
|
+
exc,
|
|
5308
|
+
", ".join(already),
|
|
5309
|
+
)
|
|
5310
|
+
|
|
5024
5311
|
def _escalate_orphan_rejected(
|
|
5025
5312
|
self, pr: PullRequest, task_ref: "str | None", exc: Exception
|
|
5026
5313
|
) -> Decision:
|
|
@@ -5256,28 +5543,92 @@ class DevWatcher:
|
|
|
5256
5543
|
|
|
5257
5544
|
def _foreign_head_skip(self, pr: PullRequest, attempt: int) -> "Decision | None":
|
|
5258
5545
|
"""The head-ref shape guard for the PR-side edges that evaluate ONE
|
|
5259
|
-
PR (fix and maintain): this daemon
|
|
5260
|
-
`TASK-<n>-<DESC>` branches
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
the
|
|
5265
|
-
|
|
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):
|
|
5266
5563
|
return None
|
|
5267
|
-
|
|
5268
|
-
"%s head ref %r does not match the daemon's own branch shape "
|
|
5269
|
-
"— not spawning a worker session on a foreign-shaped branch",
|
|
5270
|
-
pr.pr_slug,
|
|
5271
|
-
pr.head_ref,
|
|
5272
|
-
)
|
|
5564
|
+
self._page_foreign_head(pr)
|
|
5273
5565
|
return Decision(
|
|
5274
5566
|
Action.SKIPPED,
|
|
5275
5567
|
f"head ref {pr.head_ref!r} does not match the daemon's own "
|
|
5276
|
-
f"branch shape (
|
|
5277
|
-
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",
|
|
5278
5570
|
attempt,
|
|
5279
5571
|
)
|
|
5280
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
|
+
|
|
5281
5632
|
def _spawn_fix(
|
|
5282
5633
|
self,
|
|
5283
5634
|
pr: PullRequest,
|
|
@@ -5354,6 +5705,7 @@ class DevWatcher:
|
|
|
5354
5705
|
attempt=attempt,
|
|
5355
5706
|
cap=self.config.attempt_cap,
|
|
5356
5707
|
head_ref=pr.head_ref,
|
|
5708
|
+
worktree_dir=worktree_dir(pr.head_ref),
|
|
5357
5709
|
# Re-request the reviewer whose request_changes this round
|
|
5358
5710
|
# answers -- precise, and always resolvable (the review exists).
|
|
5359
5711
|
reviewer=review.author,
|
|
@@ -5613,6 +5965,7 @@ class DevWatcher:
|
|
|
5613
5965
|
attempt=attempt,
|
|
5614
5966
|
cap=self.config.attempt_cap,
|
|
5615
5967
|
head_ref=pr.head_ref,
|
|
5968
|
+
worktree_dir=worktree_dir(pr.head_ref),
|
|
5616
5969
|
reviewers=reviewers,
|
|
5617
5970
|
session=name,
|
|
5618
5971
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.20
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.18
|
|
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
|