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.
Files changed (35) hide show
  1. {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
  2. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/janitor.py +73 -24
  3. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/loop.py +505 -152
  4. alissa_tools_github_devloop-0.8.20/src/main/alissa/tools/github/devloop/version +1 -0
  5. {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
  6. alissa_tools_github_devloop-0.8.18/src/main/alissa/tools/github/devloop/version +0 -1
  7. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/LICENSE +0 -0
  8. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/MANIFEST.in +0 -0
  9. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/NOTICE +0 -0
  10. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/README.md +0 -0
  11. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/requirements.txt +0 -0
  12. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/setup.cfg +0 -0
  13. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/setup.py +0 -0
  14. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  15. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  17. {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
  18. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  19. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/config.py +0 -0
  20. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  21. {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
  22. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  23. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/state.py +0 -0
  24. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/version.py +0 -0
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.18
3
+ Version: 0.8.20
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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, which is ALSO its branch name: the
46
- # directives mandate `git worktree add TASK-<n>-<DESC>` with <n> the worker's
47
- # own implementation task, and `pr_task_ref` in loop.py already reads the same
48
- # shape off a PR's head ref. Full-matched, never prefix-matched, for
49
- # `parse_worker_session`'s reason: this module's consumer deletes directories,
50
- # and `TASK-` matches anything an operator felt like naming that way. The
51
- # description charset is the branch charset the fix edge already refuses to
52
- # leave (`[A-Za-z0-9._-]+`), so a name that would need shell quoting is not
53
- # one of ours.
54
- TASK_WORKSPACE_RE = re.compile(r"TASK-(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?")
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 directory name verbatim -- the two are the same string by
89
- construction (`git worktree add TASK-<n>-<DESC>` names the worktree
90
- directory and the branch together), and keeping both names for one value
91
- is what makes the removal code read honestly: the directory is removed by
92
- path, the branch is deleted by name, and a future layout where they differ
93
- has one place to fix.
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, branch=path.name, task=int(match["task"]), hub=hub
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
- The same full-match grammar `parse_task_workspace` admits directories
136
- with, re-asserted by name at the one place that pushes: the archive path
137
- writes a ref to the remote, and its name is derived from this one, so a
138
- branch the daemon did not create must never get a `holdout/` twin."""
139
- return TASK_WORKSPACE_RE.fullmatch(name) is not None
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 with NO reviewer
31
- requested the one state that says "this session never handed off". That test
32
- also keeps the edges disjoint -- the moment a PR leaves draft it belongs to
33
- the review lifecycle and the fix edge owns it exclusively, so no `develop-*`
34
- resume can land on a branch a `fix-*` session is already working.
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 or has a reviewer engaged (the wedge
95
- sits strictly behind `_resumable_pr`), never with `resume_enabled` false (a
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, no reviewer on it,
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
- "Implement to the issue's acceptance criteria and VERIFY locally (run the "
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 with no reviewer on "
745
- "it. This is attempt {attempt} of {cap}, and your job is to FINISH THAT "
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 {head_ref} {head_ref}`. Work ONLY on "
755
- "that branch: never a new branch, never a clone, never main, and NEVER a "
756
- "second PR — the open one IS the deliverable, and pushing more commits to "
757
- "it is how you deliver. "
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 {head_ref} {head_ref}` if it is missing, and work ONLY on "
795
- "that branch (never a new branch, never a clone, never main). "
796
- "FIRST verify the triage of every prior review finding per CR8: exactly "
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 {head_ref} "
844
- "{head_ref}`. Work ONLY on that branch: never a new branch, never a "
845
- "clone, never main, and NEVER a second PR. "
846
- "The dead session may have posted SOME of its CR8 triage replies already: "
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 {head_ref} {head_ref}` if it is missing, and work ONLY on "
887
- "that branch (never a new branch, never a clone, never main). "
888
- "FIRST the idempotent no-op check: if the branch is already conflict-free "
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. head_ref reaches every worker directive as
1518
- # BOTH a worktree path and a shell token (`git worktree add {head_ref}
1519
- # {head_ref}`), and a PR's head branch is not strictly self-authored (anyone
1520
- # with push access can move it) -- so every PR-side spawn gates on it: the fix
1521
- # and maintain edges via `_foreign_head_skip`, the issue edge's resume via
1522
- # `_resumable_pr`. Slashes that would nest the worktree path, whitespace and
1523
- # shell metacharacters are all outside it, and a ref outside this shape is not
1524
- # this daemon's work anyway: skip conservatively rather than sanitize.
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 = DAEMON_TASK_BRANCH.match(head_ref or "")
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, no-reviewer PR that CLOSES this issue is one of this
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. Everything else -- ALIVE, INDETERMINATE, and DEAD
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, or with a reviewer engaged — belongs to the "
2712
- "review lifecycle, not to a resume; and a PR that "
2713
- "only MENTIONS this issue is another task's work)"
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 with
3084
- NO reviewer requested is one its session never handed off -- the only
3085
- state a resume is owed. (A session that died in the narrow window
3086
- between the ready flip and the re-request reads handed-off and keeps
3087
- the legacy floored defer: conservative on purpose, since that is also
3088
- exactly what a live review loop looks like from here.)
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 this daemon's own flat shape.
3102
- Resolving head_ref here is also what lets RESUME_DIRECTIVE NAME the
3103
- branch instead of telling the agent to derive it from the PR -- an
3104
- unsanitized token read out of GitHub is exactly what DAEMON_HEAD_REF
3105
- exists to keep out of an agent prompt.
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 or pr.requested_reviewers:
3307
+ if not pr.draft:
3166
3308
  log.info(
3167
- "%s: %s has been handed off (draft=%s, reviewers=%s) — "
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 DAEMON_HEAD_REF.fullmatch(pr.head_ref):
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
- no reviewer, self-authored, daemon-shaped head, a body that CLOSES
3262
- this issue, exactly one candidate), because the kill it licenses is
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 or has a reviewer engaged belongs to the review lifecycle,
3265
- and a live review loop behind an ALIVE session is the healthy shape
3266
- this daemon was built around. Then the footprint (`_footprint_age`):
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 not unhanded-off: a
4463
- # session (or a human) put a reviewer on it deliberately and simply has
4464
- # not flipped it ready. Nothing here is owed, and readying it would
4465
- # take that decision away from whoever made it.
4466
- if pr.requested_reviewers:
4467
- return Decision(
4468
- Action.SKIPPED,
4469
- f"draft already carries a review request "
4470
- f"({', '.join(pr.requested_reviewers)}) — its handoff is "
4471
- f"someone's deliberate work in progress, not an orphan",
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 only ever creates flat `TASK-<n>-<DESC>`
4478
- # branches, so a ref outside that shape is not its work.
4479
- if not DAEMON_HEAD_REF.fullmatch(pr.head_ref):
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
- reviewers = self.config.reviewers
4812
- log.warning(
4813
- "ORPHAN DRAFT %s (%s): head %s has been public %.0f min, no "
4814
- "reviewer is requested, and no worker session for this PR is "
4815
- "listed — the session that opened it died before handing it off; "
4816
- "marking it ready and requesting %s mechanically (once per "
4817
- "PR/head)",
4818
- pr.pr_slug,
4819
- task_ref,
4820
- pr.head_sha[:8],
4821
- waited / 60,
4822
- ", ".join(reviewers),
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 request review from %s",
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
- Failure handling mirrors `_rerequest_review`'s exactly: a
4956
- ReviewRequestRejected is permanent and operator-fixable (escalate,
4957
- don't retry, and never record the DONE marker); a CommandError is
4958
- transient (record nothing, retry next poll). The READY row stays in
4959
- both cases -- the flip really did happen, and re-flipping an
4960
- already-ready PR is not what the retry needs to do."""
4961
- reviewers = self.config.reviewers
4962
- try:
4963
- landed = self.github.request_reviews(
4964
- pr.owner, pr.repo, pr.number, reviewers
4965
- )
4966
- except ReviewRequestRejected as exc:
4967
- return self._escalate_orphan_rejected(pr, task_ref, exc)
4968
- except CommandError as exc:
4969
- log.error(
4970
- "could not request review from %s on %s: %s — NOT recording "
4971
- "the handoff; the sweep retries next poll",
4972
- ", ".join(reviewers),
4973
- pr.pr_slug,
4974
- exc,
4975
- )
4976
- return Decision(
4977
- Action.SKIPPED,
4978
- f"the PR was marked ready but the review request did not land "
4979
- f"({exc}) — transient; not recorded, retrying next poll",
4980
- task_ref=task_ref,
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
- dropped = tuple(r for r in reviewers if r not in landed)
4984
- if dropped:
4985
- log.warning(
4986
- "%s: GitHub silently dropped reviewer(s) %s from the "
4987
- "mechanical handoff; %s landed, so the PR IS in review",
4988
- pr.pr_slug,
4989
- ", ".join(dropped),
4990
- ", ".join(landed),
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} — marked ready and requested review "
5009
- f"from {', '.join(landed)}; the session that opened this draft "
5010
- f"died before handing it off{age}, so the daemon completed its "
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 and review requested "
5019
- f"from {', '.join(landed)} for head {pr.head_sha[:8]}; the ball is "
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 only ever creates flat
5260
- `TASK-<n>-<DESC>` branches, so a ref outside DAEMON_HEAD_REF is not
5261
- its work. Returns the SKIPPED Decision, or None when the ref is safe
5262
- to interpolate. (The issue edge's resume applies the same shape test
5263
- via `_resumable_pr`, which filters candidates rather than skipping
5264
- the item.)"""
5265
- if DAEMON_HEAD_REF.fullmatch(pr.head_ref):
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
- log.warning(
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 ([A-Za-z0-9._-]+) — foreign-shaped branch, "
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
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.18
3
+ Version: 0.8.20
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera