alissa-tools-github-devloop 0.8.22__py3-none-any.whl → 0.8.24__py3-none-any.whl
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/hubs.py +427 -0
- alissa/tools/github/devloop/loop.py +547 -3
- alissa/tools/github/devloop/loop_events.py +3 -1
- alissa/tools/github/devloop/state.py +71 -6
- alissa/tools/github/devloop/version +1 -1
- {alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/METADATA +1 -1
- {alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/RECORD +12 -11
- {alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/WHEEL +0 -0
- {alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/licenses/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/licenses/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Hub trust: keeping Claude Code's per-directory "trust this folder?" gate
|
|
2
|
+
pre-accepted for every directory a dev session may start in (issue #123).
|
|
3
|
+
|
|
4
|
+
Claude Code asks, once per directory, whether the folder it was started in is
|
|
5
|
+
trusted -- and `--dangerously-skip-permissions` does NOT suppress that
|
|
6
|
+
dialog. A headless session that meets it hangs: the directive `alissa tmux
|
|
7
|
+
queue` types into the pane is swallowed by the prompt, the session sits in
|
|
8
|
+
the roster reading ALIVE, and the daemon's liveness discipline (correctly)
|
|
9
|
+
declines to respawn over a listed session. Observed 2026-09-16 on
|
|
10
|
+
`dark-devloop-shared`: 108 minutes on `ali-fhr/docs.alissa.app#10`, until an
|
|
11
|
+
operator accepted the dialog by hand.
|
|
12
|
+
|
|
13
|
+
The container entrypoint seeds the gate at boot for the hubs it can NAME at
|
|
14
|
+
boot: `{root}/{basename}` for every entry in `ALISSA_DEV_REPOS`, plus every
|
|
15
|
+
hub already on disk. Under `repos_source: bows` `ALISSA_DEV_REPOS` is empty
|
|
16
|
+
-- the allowlist is DERIVED from the feed Bodies of Work at runtime -- so a
|
|
17
|
+
repo hub-ified after boot was never trusted. This module closes that gap
|
|
18
|
+
from the daemon's side, in three places the loop calls:
|
|
19
|
+
|
|
20
|
+
* after every successful feed refresh: the derived list is written to
|
|
21
|
+
`{root}/.alissa-derived-repos` (`DERIVED_REPOS_FILENAME`) so the NEXT boot's
|
|
22
|
+
entrypoint seeding can read it, and the derived hubs are trusted right
|
|
23
|
+
away, hub-ified or not (trusting a path that does not exist yet is exactly
|
|
24
|
+
what the entrypoint already does for the static allowlist);
|
|
25
|
+
* at hub-ify time and before every spawn (`DevWatcher._ensure_hub`): the hub
|
|
26
|
+
root, `{hub}/main`, and every `{hub}/TASK-*` worktree on disk;
|
|
27
|
+
* after a first-run-dialog wedge kill: the hub the wedged session started in,
|
|
28
|
+
so the re-enqueued attempt does not meet the same prompt.
|
|
29
|
+
|
|
30
|
+
The merge is the entrypoint's, transposed: load-then-update of
|
|
31
|
+
`~/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude.json` (when set --
|
|
32
|
+
`CLAUDE_CONFIG_DIR` reliably relocates only the credential file, so both
|
|
33
|
+
targets are written and whichever claude reads carries the flag), only ever
|
|
34
|
+
ADDING `projects[<path>].hasTrustDialogAccepted = true`. Idempotent: a target
|
|
35
|
+
that already carries every entry is not rewritten at all, which is also what
|
|
36
|
+
keeps the daemon's writes off a file Claude Code itself rewrites -- the only
|
|
37
|
+
time this module touches the file is when a NEW directory has to be
|
|
38
|
+
trusted. That write is guarded on three sides, because Claude Code itself
|
|
39
|
+
rewrites the same file while sessions run (`_trust_hub` runs before EVERY
|
|
40
|
+
spawn, and the `TASK-*` glob makes most of those writes real ones): the
|
|
41
|
+
temp-file + rename keeps a concurrent READER from ever seeing a torn file;
|
|
42
|
+
a compare-and-swap immediately before the rename (the file must still hold
|
|
43
|
+
the bytes the merge was computed from) keeps the OTHER WRITER's update
|
|
44
|
+
from being discarded wholesale -- the payload is the whole file, so a lost
|
|
45
|
+
update could drop another session's `projects` entries or the persisted
|
|
46
|
+
login; and a re-read after the rename catches a writer that landed right
|
|
47
|
+
after ours and dropped OUR entries. Either miss restarts the merge on top
|
|
48
|
+
of the newer file, a bounded number of times, and giving up is one WARNING.
|
|
49
|
+
Never raises: trust is a convenience for the session about to spawn, and a
|
|
50
|
+
failed write is one WARNING, never a lost poll.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import json
|
|
56
|
+
import logging
|
|
57
|
+
import os
|
|
58
|
+
import re
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import Iterable, Sequence
|
|
61
|
+
|
|
62
|
+
log = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
# The file the daemon writes the derived (bows-mode) allowlist to, under the
|
|
65
|
+
# workspace root, one `owner/repo` per line -- and the file the entrypoint's
|
|
66
|
+
# first-run seeding reads at the NEXT boot (`ALISSA_DERIVED_REPOS_FILE`
|
|
67
|
+
# overrides the location on both sides).
|
|
68
|
+
DERIVED_REPOS_FILENAME = ".alissa-derived-repos"
|
|
69
|
+
ENV_DERIVED_REPOS_FILE = "ALISSA_DERIVED_REPOS_FILE"
|
|
70
|
+
|
|
71
|
+
# The state file Claude Code keeps its per-directory trust in, relative to
|
|
72
|
+
# $HOME and to $CLAUDE_CONFIG_DIR.
|
|
73
|
+
CLAUDE_STATE_FILENAME = ".claude.json"
|
|
74
|
+
ENV_CLAUDE_CONFIG_DIR = "CLAUDE_CONFIG_DIR"
|
|
75
|
+
TRUST_KEY = "hasTrustDialogAccepted"
|
|
76
|
+
|
|
77
|
+
# What a pane PARKED on one of Claude Code's first-run gates shows -- and
|
|
78
|
+
# what corroborates that it is parked there rather than merely mentioning
|
|
79
|
+
# it (PR #124 review round 1). The gates' words are quoted by this repo's
|
|
80
|
+
# README, CHANGELOG, this module and issue #123, so a session that cats any
|
|
81
|
+
# of them, greps the tree or shows its own diff has them on screen while at
|
|
82
|
+
# work -- and a match kills the session. The classifier therefore asks for
|
|
83
|
+
# the gate's SHAPE, not its vocabulary (see `pane_shows_first_run_dialog`):
|
|
84
|
+
#
|
|
85
|
+
# * the gate's ACCEPT option (`FIRST_RUN_DIALOG_OPTIONS`) is one of the last
|
|
86
|
+
# `FIRST_RUN_DIALOG_TAIL_LINES` non-blank lines of the capture, once the
|
|
87
|
+
# selection cursor, option numbering and box drawing are stripped;
|
|
88
|
+
# * nothing follows it but the gate's own chrome (`FIRST_RUN_DIALOG_CHROME`):
|
|
89
|
+
# a session that printed ANYTHING after the text -- a spinner, a tool
|
|
90
|
+
# call, the `>` input prompt an idle session sits at, a shell prompt --
|
|
91
|
+
# is not on the gate;
|
|
92
|
+
# * the gate's QUESTION (`FIRST_RUN_DIALOG_MARKERS`) appears at or above
|
|
93
|
+
# the option line.
|
|
94
|
+
#
|
|
95
|
+
# The trust dialog reads "Quick safety check: Is this a project you created
|
|
96
|
+
# or one you trust? … ❯ No, exit / Yes, I trust this folder"; the
|
|
97
|
+
# bypass-permissions gate reads "WARNING: Claude Code running in Bypass
|
|
98
|
+
# Permissions mode … No, exit / Yes, I accept" (either option order, with
|
|
99
|
+
# or without numbering, an optional "Enter to confirm · Esc to exit" footer
|
|
100
|
+
# and box drawing). Both are gates the entrypoint's seeding is meant to
|
|
101
|
+
# pre-answer, so a pane parked on either is a session the seeding missed,
|
|
102
|
+
# not a session at work.
|
|
103
|
+
FIRST_RUN_DIALOG_MARKERS = (
|
|
104
|
+
"trust this folder",
|
|
105
|
+
"bypass permissions mode",
|
|
106
|
+
)
|
|
107
|
+
FIRST_RUN_DIALOG_OPTIONS = (
|
|
108
|
+
"yes, i trust this folder",
|
|
109
|
+
"yes, i accept",
|
|
110
|
+
)
|
|
111
|
+
FIRST_RUN_DIALOG_CHROME = (
|
|
112
|
+
"no, exit",
|
|
113
|
+
"enter to confirm",
|
|
114
|
+
)
|
|
115
|
+
FIRST_RUN_DIALOG_TAIL_LINES = 6
|
|
116
|
+
|
|
117
|
+
# What a gate draws AROUND its text: box drawing, the selection cursor and
|
|
118
|
+
# whitespace (stripped from both ends of a line), then "1." / "2)" option
|
|
119
|
+
# numbering. Nothing else is stripped -- a diff's `+`, a markdown bullet,
|
|
120
|
+
# a quote's backtick and the `>` of Claude Code's input prompt all stay,
|
|
121
|
+
# which is what keeps a line that merely quotes a gate from reading as one.
|
|
122
|
+
_DIALOG_DECORATION = "❯ \t│┃║╭╮╰╯┌┐└┘├┤┬┴┼─━═"
|
|
123
|
+
_DIALOG_NUMBERING = re.compile(r"^\d+[.)]\s*")
|
|
124
|
+
|
|
125
|
+
# How many times `seed_trust` restarts a merge that another writer moved
|
|
126
|
+
# the file under (see the module docstring) before it gives up with a
|
|
127
|
+
# WARNING. Claude Code's own rewrites are bursts around a session's start
|
|
128
|
+
# and end, not a stream, so a second pass is expected to land.
|
|
129
|
+
SEED_TRUST_ATTEMPTS = 3
|
|
130
|
+
|
|
131
|
+
# The wedge classification the loop logs and records for a stale session
|
|
132
|
+
# whose pane shows one of those gates.
|
|
133
|
+
WEDGE_FIRST_RUN_DIALOG = "wedged:first-run-dialog"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def claude_state_targets(
|
|
137
|
+
home: "Path | str | None" = None, config_dir: "Path | str | None" = None
|
|
138
|
+
) -> "list[Path]":
|
|
139
|
+
"""The `.claude.json` files to merge trust into: `$HOME/.claude.json`,
|
|
140
|
+
plus `$CLAUDE_CONFIG_DIR/.claude.json` when that variable is set and
|
|
141
|
+
non-blank. Both arguments default to the environment (`~` expansion
|
|
142
|
+
honours `HOME`), which is what lets a test point the merge at scratch
|
|
143
|
+
directories without touching the daemon's own files."""
|
|
144
|
+
home_dir = Path(home) if home is not None else Path(os.path.expanduser("~"))
|
|
145
|
+
targets = [home_dir / CLAUDE_STATE_FILENAME]
|
|
146
|
+
raw = (
|
|
147
|
+
str(config_dir)
|
|
148
|
+
if config_dir is not None
|
|
149
|
+
else os.environ.get(ENV_CLAUDE_CONFIG_DIR, "")
|
|
150
|
+
).strip()
|
|
151
|
+
if raw:
|
|
152
|
+
extra = Path(raw) / CLAUDE_STATE_FILENAME
|
|
153
|
+
if extra != targets[0]:
|
|
154
|
+
targets.append(extra)
|
|
155
|
+
return targets
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def hub_trust_paths(hub: "Path | str") -> "list[Path]":
|
|
159
|
+
"""Every directory a session on this hub may START in: the hub root
|
|
160
|
+
(where develop/fix/maintain sessions are spawned), `{hub}/main`, and each
|
|
161
|
+
`{hub}/TASK-*` worktree already on disk (a retried or resumed attempt may
|
|
162
|
+
be started inside a surviving worktree). The root and `main` are listed
|
|
163
|
+
whether or not they exist yet -- the point of seeding is to be there
|
|
164
|
+
BEFORE the directory is used."""
|
|
165
|
+
root = Path(hub)
|
|
166
|
+
paths = [root, root / "main"]
|
|
167
|
+
try:
|
|
168
|
+
worktrees = sorted(
|
|
169
|
+
p for p in root.glob("TASK-*") if p.is_dir()
|
|
170
|
+
)
|
|
171
|
+
except OSError:
|
|
172
|
+
worktrees = []
|
|
173
|
+
paths.extend(worktrees)
|
|
174
|
+
return paths
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _load_state(path: Path) -> "tuple[str | None, dict]":
|
|
178
|
+
"""The state file's raw text (None when absent) and its parsed object
|
|
179
|
+
(`{}` when absent or not a JSON object). Only ABSENCE is folded into
|
|
180
|
+
"start fresh": an unparseable file raises `ValueError` up to
|
|
181
|
+
`seed_trust`, which skips that target rather than replacing it (a
|
|
182
|
+
corrupt state file is never silently overwritten). The raw text is what
|
|
183
|
+
`_write_atomic` compares against before its rename."""
|
|
184
|
+
try:
|
|
185
|
+
with open(path, encoding="utf-8") as fh:
|
|
186
|
+
raw = fh.read()
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
return None, {}
|
|
189
|
+
data = json.loads(raw)
|
|
190
|
+
return raw, (data if isinstance(data, dict) else {})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _raw_text(path: Path) -> "str | None":
|
|
194
|
+
try:
|
|
195
|
+
return path.read_text(encoding="utf-8")
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _write_atomic(path: Path, data: dict, expected: "str | None") -> bool:
|
|
201
|
+
"""Write `data` over `path` by temp file + rename -- unless the file no
|
|
202
|
+
longer holds `expected` (the raw text the caller merged from), in which
|
|
203
|
+
case nothing is replaced and False is returned so the caller merges
|
|
204
|
+
again on top of whoever wrote in between. The rename keeps READERS
|
|
205
|
+
safe (never a torn file); the check before it keeps the OTHER WRITER's
|
|
206
|
+
update safe, down to the gap between the check and the rename."""
|
|
207
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
209
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
210
|
+
json.dump(data, fh, indent=2)
|
|
211
|
+
fh.write("\n")
|
|
212
|
+
if _raw_text(path) != expected:
|
|
213
|
+
tmp.unlink(missing_ok=True)
|
|
214
|
+
return False
|
|
215
|
+
os.replace(tmp, path)
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _add_trust(state: dict, wanted: "list[str]") -> "list[str]":
|
|
220
|
+
"""Merge `projects[<path>].hasTrustDialogAccepted = true` for every
|
|
221
|
+
wanted path into `state` (in place, repairing a missing or non-object
|
|
222
|
+
`projects`). Returns the paths that were NOT already trusted."""
|
|
223
|
+
projects = state.get("projects")
|
|
224
|
+
if not isinstance(projects, dict):
|
|
225
|
+
projects = {}
|
|
226
|
+
state["projects"] = projects
|
|
227
|
+
added = []
|
|
228
|
+
for p in wanted:
|
|
229
|
+
entry = projects.get(p)
|
|
230
|
+
if not isinstance(entry, dict):
|
|
231
|
+
entry = {}
|
|
232
|
+
projects[p] = entry
|
|
233
|
+
if entry.get(TRUST_KEY) is not True:
|
|
234
|
+
entry[TRUST_KEY] = True
|
|
235
|
+
added.append(p)
|
|
236
|
+
return added
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _untrusted(state: dict, wanted: "list[str]") -> "list[str]":
|
|
240
|
+
projects = state.get("projects")
|
|
241
|
+
if not isinstance(projects, dict):
|
|
242
|
+
return list(wanted)
|
|
243
|
+
return [
|
|
244
|
+
p for p in wanted
|
|
245
|
+
if not isinstance(projects.get(p), dict)
|
|
246
|
+
or projects[p].get(TRUST_KEY) is not True
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _merge_target(target: Path, wanted: "list[str]") -> bool:
|
|
251
|
+
"""Merge `wanted` into ONE claude state file. True when the file was
|
|
252
|
+
written. Load, merge, compare-and-swap, verify -- and start over, up to
|
|
253
|
+
SEED_TRUST_ATTEMPTS times, whenever another writer moved the file
|
|
254
|
+
between the load and the rename (the swap refuses) or right after the
|
|
255
|
+
rename (the verify finds an entry missing). Never raises."""
|
|
256
|
+
wrote = False
|
|
257
|
+
for _ in range(SEED_TRUST_ATTEMPTS):
|
|
258
|
+
try:
|
|
259
|
+
raw, state = _load_state(target)
|
|
260
|
+
except (OSError, ValueError) as exc:
|
|
261
|
+
if wrote:
|
|
262
|
+
# Our write landed; whoever replaced it since left something
|
|
263
|
+
# this daemon will not touch. Nothing more to do here.
|
|
264
|
+
log.debug("trust: %s unreadable after the merge (%s)", target, exc)
|
|
265
|
+
return True
|
|
266
|
+
log.warning(
|
|
267
|
+
"trust: could not read %s (%s) — leaving it untouched; the "
|
|
268
|
+
"directories are NOT pre-trusted there: %s",
|
|
269
|
+
target, exc, ", ".join(wanted),
|
|
270
|
+
)
|
|
271
|
+
return False
|
|
272
|
+
added = _add_trust(state, wanted)
|
|
273
|
+
if not added:
|
|
274
|
+
# Every entry is present: it always was, or the pass before
|
|
275
|
+
# this one landed and survived.
|
|
276
|
+
return wrote
|
|
277
|
+
try:
|
|
278
|
+
swapped = _write_atomic(target, state, raw)
|
|
279
|
+
except OSError as exc:
|
|
280
|
+
log.warning(
|
|
281
|
+
"trust: could not write %s (%s) — the directories are NOT "
|
|
282
|
+
"pre-trusted there: %s", target, exc, ", ".join(added),
|
|
283
|
+
)
|
|
284
|
+
return wrote
|
|
285
|
+
if not swapped:
|
|
286
|
+
log.info(
|
|
287
|
+
"trust: %s changed underneath the merge — merging %s again "
|
|
288
|
+
"on top of the newer file", target, ", ".join(added),
|
|
289
|
+
)
|
|
290
|
+
continue
|
|
291
|
+
wrote = True
|
|
292
|
+
try:
|
|
293
|
+
_, after = _load_state(target)
|
|
294
|
+
except (OSError, ValueError) as exc:
|
|
295
|
+
log.debug("trust: %s unreadable right after the merge (%s)", target, exc)
|
|
296
|
+
return True
|
|
297
|
+
missing = _untrusted(after, wanted)
|
|
298
|
+
if not missing:
|
|
299
|
+
log.debug("trust: %s now pre-trusts %s", target, ", ".join(added))
|
|
300
|
+
return True
|
|
301
|
+
log.info(
|
|
302
|
+
"trust: %s was rewritten right after the merge and lost %s — "
|
|
303
|
+
"merging again", target, ", ".join(missing),
|
|
304
|
+
)
|
|
305
|
+
log.warning(
|
|
306
|
+
"trust: gave up merging into %s after %d attempts — another writer "
|
|
307
|
+
"kept rewriting it, so the directories may NOT be pre-trusted there: %s",
|
|
308
|
+
target, SEED_TRUST_ATTEMPTS, ", ".join(wanted),
|
|
309
|
+
)
|
|
310
|
+
return wrote
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def seed_trust(
|
|
314
|
+
paths: "Iterable[Path | str]",
|
|
315
|
+
*,
|
|
316
|
+
home: "Path | str | None" = None,
|
|
317
|
+
config_dir: "Path | str | None" = None,
|
|
318
|
+
) -> "list[Path]":
|
|
319
|
+
"""Merge `projects[<path>].hasTrustDialogAccepted = true` for every path
|
|
320
|
+
into every claude state target. Returns the targets that were WRITTEN
|
|
321
|
+
(a target already carrying every entry is left untouched -- byte for
|
|
322
|
+
byte, no rewrite). Never raises and never removes anything: an
|
|
323
|
+
unparseable target is skipped with a WARNING rather than replaced, a
|
|
324
|
+
failed write is a WARNING too, and a target another writer keeps
|
|
325
|
+
moving under the merge is retried a bounded number of times (see
|
|
326
|
+
`_merge_target` and the module docstring) before that, too, is one
|
|
327
|
+
WARNING.
|
|
328
|
+
|
|
329
|
+
Paths are recorded as absolute strings, which is how Claude Code keys
|
|
330
|
+
them; a relative path is resolved against the cwd, which is never what a
|
|
331
|
+
caller means, so callers pass absolute hubs."""
|
|
332
|
+
wanted = [os.fspath(Path(p)) for p in paths]
|
|
333
|
+
changed: "list[Path]" = []
|
|
334
|
+
if not wanted:
|
|
335
|
+
return changed
|
|
336
|
+
for target in claude_state_targets(home, config_dir):
|
|
337
|
+
if _merge_target(target, wanted):
|
|
338
|
+
changed.append(target)
|
|
339
|
+
return changed
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def derived_repos_path(root: "Path | str") -> Path:
|
|
343
|
+
"""Where the derived allowlist is written under this workspace root, or
|
|
344
|
+
wherever `ALISSA_DERIVED_REPOS_FILE` points (the same override the
|
|
345
|
+
entrypoint honours, so an operator relocating one relocates both)."""
|
|
346
|
+
override = os.environ.get(ENV_DERIVED_REPOS_FILE, "").strip()
|
|
347
|
+
if override:
|
|
348
|
+
return Path(override)
|
|
349
|
+
return Path(root) / DERIVED_REPOS_FILENAME
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def write_derived_repos(root: "Path | str", repos: "Sequence[str]") -> "Path | None":
|
|
353
|
+
"""Record the derived allowlist, one `owner/repo` per line, for the next
|
|
354
|
+
boot's entrypoint seeding. Rewritten only when the content differs.
|
|
355
|
+
Returns the path written, or None when nothing changed or the write
|
|
356
|
+
failed (logged; never raised -- the daemon has already trusted the hubs
|
|
357
|
+
itself, so this file only matters to a future boot)."""
|
|
358
|
+
path = derived_repos_path(root)
|
|
359
|
+
body = "".join(f"{r}\n" for r in repos)
|
|
360
|
+
try:
|
|
361
|
+
if path.is_file() and path.read_text(encoding="utf-8") == body:
|
|
362
|
+
return None
|
|
363
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
365
|
+
tmp.write_text(body, encoding="utf-8")
|
|
366
|
+
os.replace(tmp, path)
|
|
367
|
+
except OSError as exc:
|
|
368
|
+
log.warning(
|
|
369
|
+
"could not record the derived allowlist at %s (%s) — the next "
|
|
370
|
+
"boot's entrypoint seeding will not pre-trust these hubs until a "
|
|
371
|
+
"refresh succeeds: %s", path, exc, ", ".join(repos),
|
|
372
|
+
)
|
|
373
|
+
return None
|
|
374
|
+
return path
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def read_derived_repos(root: "Path | str") -> "tuple[str, ...]":
|
|
378
|
+
"""The recorded derived allowlist (`owner/repo` per line; blank lines and
|
|
379
|
+
`#` comments ignored), or `()` when absent or unreadable."""
|
|
380
|
+
path = derived_repos_path(root)
|
|
381
|
+
try:
|
|
382
|
+
text = path.read_text(encoding="utf-8")
|
|
383
|
+
except OSError:
|
|
384
|
+
return ()
|
|
385
|
+
out = []
|
|
386
|
+
for line in text.splitlines():
|
|
387
|
+
line = line.strip()
|
|
388
|
+
if line and not line.startswith("#") and "/" in line:
|
|
389
|
+
out.append(line)
|
|
390
|
+
return tuple(out)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _dialog_line(line: str) -> str:
|
|
394
|
+
"""A pane line reduced to the text a gate would render there (see
|
|
395
|
+
_DIALOG_DECORATION), casefolded."""
|
|
396
|
+
text = line.strip(_DIALOG_DECORATION)
|
|
397
|
+
text = _DIALOG_NUMBERING.sub("", text).strip(_DIALOG_DECORATION)
|
|
398
|
+
return text.casefold()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _is_dialog_chrome(text: str) -> bool:
|
|
402
|
+
return any(text.startswith(chrome) for chrome in FIRST_RUN_DIALOG_CHROME)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def pane_shows_first_run_dialog(pane: str) -> bool:
|
|
406
|
+
"""Whether a session's terminal tail is PARKED on one of Claude Code's
|
|
407
|
+
first-run gates: the gate's accept option among the last
|
|
408
|
+
FIRST_RUN_DIALOG_TAIL_LINES non-blank lines, nothing but the gate's own
|
|
409
|
+
chrome below it, and the gate's question at or above it (the notes on
|
|
410
|
+
FIRST_RUN_DIALOG_MARKERS say why each leg is there). An empty capture
|
|
411
|
+
(the CLI could not tail) is never a dialog -- absence of evidence keeps
|
|
412
|
+
the defer -- and neither is a pane that merely MENTIONS a gate: a
|
|
413
|
+
session at work or idle at its prompt has printed something below the
|
|
414
|
+
words, and that something is not the gate's chrome."""
|
|
415
|
+
if not pane:
|
|
416
|
+
return False
|
|
417
|
+
lines = [t for t in (_dialog_line(ln) for ln in pane.splitlines()) if t]
|
|
418
|
+
tail = lines[-FIRST_RUN_DIALOG_TAIL_LINES:]
|
|
419
|
+
for offset in range(len(tail) - 1, -1, -1):
|
|
420
|
+
if any(tail[offset].startswith(o) for o in FIRST_RUN_DIALOG_OPTIONS):
|
|
421
|
+
break
|
|
422
|
+
else:
|
|
423
|
+
return False
|
|
424
|
+
if not all(_is_dialog_chrome(t) for t in tail[offset + 1:]):
|
|
425
|
+
return False
|
|
426
|
+
above = lines[:len(lines) - len(tail) + offset + 1]
|
|
427
|
+
return any(m in t for t in above for m in FIRST_RUN_DIALOG_MARKERS)
|
|
@@ -442,6 +442,14 @@ from . import loop_events as loop_events_mod
|
|
|
442
442
|
from .alissa import Alissa
|
|
443
443
|
from .alissa_client import AlissaClient
|
|
444
444
|
from .bows import BowRepoSource
|
|
445
|
+
from .hubs import (
|
|
446
|
+
FIRST_RUN_DIALOG_MARKERS,
|
|
447
|
+
WEDGE_FIRST_RUN_DIALOG,
|
|
448
|
+
hub_trust_paths,
|
|
449
|
+
pane_shows_first_run_dialog,
|
|
450
|
+
seed_trust,
|
|
451
|
+
write_derived_repos,
|
|
452
|
+
)
|
|
445
453
|
from .config import (
|
|
446
454
|
FOREIGN_IGNORE,
|
|
447
455
|
FOREIGN_REAP,
|
|
@@ -810,6 +818,24 @@ RESUME_DIRECTIVE = (
|
|
|
810
818
|
"(best-effort — the daemon's reaper sweep collects stragglers)."
|
|
811
819
|
)
|
|
812
820
|
|
|
821
|
+
# The handover clause (issue #121), shared by FIX_DIRECTIVE and
|
|
822
|
+
# FIX_RESUME_DIRECTIVE. When a later review round lands on the head a fix
|
|
823
|
+
# session is still working (the 2026-09-15 studio#1243 shape: a phantom round
|
|
824
|
+
# 2 on round 1's head, minutes after round 1), the daemon no longer spawns a
|
|
825
|
+
# second session onto the same branch -- it posts FIX_HANDOVER_COMMENT on the
|
|
826
|
+
# PR and moves the live session's ledger row to the new round. The triage
|
|
827
|
+
# loop reads review THREADS, not the PR's issue comments, so the contract has
|
|
828
|
+
# to say it: look for a newer round before the terminal re-request.
|
|
829
|
+
_NEWER_ROUNDS = (
|
|
830
|
+
"Before you re-request review, check the PR for a NEWER review round: "
|
|
831
|
+
"if another `request_changes` round (or a daemon comment beginning "
|
|
832
|
+
"`Round N+1 arrived while the round-N fix session … is still working "
|
|
833
|
+
"on this head`) landed while you worked, the daemon has handed that "
|
|
834
|
+
"round to THIS session instead of spawning a second one on your branch "
|
|
835
|
+
"— triage and address its findings here as well, and re-request review "
|
|
836
|
+
"ONCE, after the newest round is answered. "
|
|
837
|
+
)
|
|
838
|
+
|
|
813
839
|
FIX_DIRECTIVE = (
|
|
814
840
|
"You are an IMPLEMENTER in a fix round, not a reviewer. PR {pr_url} "
|
|
815
841
|
"received a `request_changes` review (round {round}; this is fix attempt "
|
|
@@ -831,6 +857,7 @@ FIX_DIRECTIVE = (
|
|
|
831
857
|
"one reasoned `[triage:*]` reply on each finding's thread — post any that "
|
|
832
858
|
"are missing. Then fix the pursued findings on the same branch and VERIFY "
|
|
833
859
|
"locally (run the repo's test/style/type scripts). "
|
|
860
|
+
+ _NEWER_ROUNDS
|
|
834
861
|
+ _FIX_DELIVERY_NOTE
|
|
835
862
|
+ " "
|
|
836
863
|
+ _APPROVE_FOLLOWUP
|
|
@@ -887,6 +914,7 @@ FIX_RESUME_DIRECTIVE = (
|
|
|
887
914
|
"the ORIGINAL acceptance criteria the issue set, and VERIFY locally (run "
|
|
888
915
|
"the repo's test/style/type scripts). NEVER force-push: history stays "
|
|
889
916
|
"append-only, or the review threads lose their anchors. "
|
|
917
|
+
+ _NEWER_ROUNDS
|
|
890
918
|
+ _FIX_DELIVERY_NOTE
|
|
891
919
|
+ " "
|
|
892
920
|
+ _APPROVE_FOLLOWUP
|
|
@@ -1000,6 +1028,29 @@ FIX_STALLED_COMMENT = (
|
|
|
1000
1028
|
"close the PR to park the work."
|
|
1001
1029
|
)
|
|
1002
1030
|
|
|
1031
|
+
# The hidden marker that identifies a fix-round handover comment (issue #121)
|
|
1032
|
+
# -- a machine-findable anchor for the live session and for anyone auditing
|
|
1033
|
+
# the PR, distinct from ACTIVITY_MARKER so the two kinds of comment never
|
|
1034
|
+
# collide on one PR. Dedupe is NOT by marker but by the fix-handover:r<k>
|
|
1035
|
+
# ledger row (see fix_handover_kind): once per (PR, round).
|
|
1036
|
+
FIX_HANDOVER_MARKER = "<!-- alissa-devloop:fix-handover -->"
|
|
1037
|
+
|
|
1038
|
+
# The steering comment the fix edge posts INSTEAD of spawning when a later
|
|
1039
|
+
# review round lands on the head a fix session is still working. The first
|
|
1040
|
+
# sentence is the issue's mandated text verbatim; the trailer says what the
|
|
1041
|
+
# daemon did and what happens if the session dies anyway.
|
|
1042
|
+
FIX_HANDOVER_COMMENT = (
|
|
1043
|
+
FIX_HANDOVER_MARKER + "\n"
|
|
1044
|
+
"Round {new_round} arrived while the round-{round} fix session "
|
|
1045
|
+
"`{session}` is still working on this head — handing it over: address "
|
|
1046
|
+
"the round-{new_round} findings in the same session and re-request "
|
|
1047
|
+
"review once.\n\n"
|
|
1048
|
+
"_devloop: no second fix session was spawned for round {new_round} (head "
|
|
1049
|
+
"`{head}` is the one `{session}` is working). If that session dies "
|
|
1050
|
+
"without re-requesting review, the stale path re-enqueues round "
|
|
1051
|
+
"{new_round} as its next attempt._"
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1003
1054
|
MAINTAIN_CAP_COMMENT = (
|
|
1004
1055
|
"**Maintain loop cap-out** — {attempts} maintenance attempt(s) were "
|
|
1005
1056
|
"spawned on this PR without any of them completing (attempt cap: {cap}; "
|
|
@@ -1190,6 +1241,11 @@ ESCALATION_ASSIGNMENT = "assignment"
|
|
|
1190
1241
|
ESCALATION_STALLED = "stalled"
|
|
1191
1242
|
ESCALATION_FIX_CAP = "fix-cap"
|
|
1192
1243
|
ESCALATION_FIX_STALLED = "fix-stalled"
|
|
1244
|
+
# The round handover's once-per-(PR, round) row (issue #121, see
|
|
1245
|
+
# fix_handover_kind). A steering comment, not an operator page -- it lives in
|
|
1246
|
+
# the escalations table because that is the daemon's dedupe gate for
|
|
1247
|
+
# comments that must land exactly once and survive a degraded ledger.
|
|
1248
|
+
ESCALATION_FIX_HANDOVER = "fix-handover"
|
|
1193
1249
|
|
|
1194
1250
|
# The pre-PR liveness deferral's own kind (see live_stalled_kind). Distinct
|
|
1195
1251
|
# from ESCALATION_STALLED because the two stories are different and so are
|
|
@@ -1210,6 +1266,16 @@ ESCALATION_LIVE_STALLED = "live-stalled"
|
|
|
1210
1266
|
# poll is the remedy, not a bug.
|
|
1211
1267
|
ESCALATION_WEDGED = "wedged"
|
|
1212
1268
|
|
|
1269
|
+
# The first-run-dialog wedge's once-per-episode marker (issue #123; see
|
|
1270
|
+
# first_run_dialog_kind). Like ESCALATION_WEDGED an ACT, not a page: the
|
|
1271
|
+
# daemon killed a session whose pane showed one of Claude Code's first-run
|
|
1272
|
+
# gates, seeded the trust the gate was asking for, and re-enqueued the
|
|
1273
|
+
# attempt itself. The row dedupes the WARNING (one per episode -- a kill
|
|
1274
|
+
# that fails and is retried next poll logs its retry at INFO) and the
|
|
1275
|
+
# activity-comment line; the loop-events emitter turns it into the generic
|
|
1276
|
+
# `escalation.first-run-dialog`, anchored on the issue.
|
|
1277
|
+
ESCALATION_FIRST_RUN_DIALOG = "first-run-dialog"
|
|
1278
|
+
|
|
1213
1279
|
# The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
|
|
1214
1280
|
# rows from ever colliding with the issue edge's `cap` when an issue and a
|
|
1215
1281
|
# PR share a number; the epoch suffix is maintain_cap_kind's job below.
|
|
@@ -1503,6 +1569,21 @@ def fix_stalled_kind(round_: int, attempt: int) -> str:
|
|
|
1503
1569
|
return f"{ESCALATION_FIX_STALLED}:r{round_}a{attempt}"
|
|
1504
1570
|
|
|
1505
1571
|
|
|
1572
|
+
def fix_handover_kind(round_: int) -> str:
|
|
1573
|
+
"""The ledger kind that dedupes ONE round's handover comment (issue #121).
|
|
1574
|
+
|
|
1575
|
+
Keyed per round (`fix-handover:r<k>`): round k+1 handed to a live session
|
|
1576
|
+
must not silence a round k+2 handed to the same session later, and the
|
|
1577
|
+
`fix-` prefix keeps the row from colliding with issue-edge kinds when an
|
|
1578
|
+
issue and a PR share a number. Delivery contract as fix_stalled_kind's:
|
|
1579
|
+
the row lands only AFTER the comment posts, so a transient failure
|
|
1580
|
+
retries next poll and the steering lands exactly once per round. The
|
|
1581
|
+
ledger round bump rides BEHIND the comment for the same reason -- a
|
|
1582
|
+
bumped row would close this edge's fresh-in-flight gate before the
|
|
1583
|
+
comment ever landed."""
|
|
1584
|
+
return f"{ESCALATION_FIX_HANDOVER}:r{round_}"
|
|
1585
|
+
|
|
1586
|
+
|
|
1506
1587
|
def stalled_kind(attempt: int) -> str:
|
|
1507
1588
|
"""The ledger kind for the stalled ping of ONE deferral episode.
|
|
1508
1589
|
|
|
@@ -1551,6 +1632,17 @@ def wedge_kind(attempt: int) -> str:
|
|
|
1551
1632
|
return f"{ESCALATION_WEDGED}:a{attempt}"
|
|
1552
1633
|
|
|
1553
1634
|
|
|
1635
|
+
def first_run_dialog_kind(attempt: int) -> str:
|
|
1636
|
+
"""The ledger kind for ONE first-run-dialog wedge episode (issue #123).
|
|
1637
|
+
|
|
1638
|
+
wedge_kind's episode reasoning: every attempt can meet the dialog (a
|
|
1639
|
+
hub trusted after attempt k spawned is trusted for attempt k+1, but a
|
|
1640
|
+
seeding that failed to land leaves the next attempt on the same prompt),
|
|
1641
|
+
and attempt k's row must not silence attempt k+1's WARNING.
|
|
1642
|
+
"""
|
|
1643
|
+
return f"{ESCALATION_FIRST_RUN_DIALOG}:a{attempt}"
|
|
1644
|
+
|
|
1645
|
+
|
|
1554
1646
|
def deferral_activity_kind(session: str) -> str:
|
|
1555
1647
|
"""The activity-log kind that dedupes ONE capacity-deferral episode's line.
|
|
1556
1648
|
|
|
@@ -2345,6 +2437,12 @@ MANAGED_SESSION_PREFIX = "ali-"
|
|
|
2345
2437
|
# pane said "Nothing left -- the review task is done.").
|
|
2346
2438
|
FOREIGN_PANE_TAIL_LINES = 20
|
|
2347
2439
|
|
|
2440
|
+
# How many pane lines the first-run-dialog check reads (issue #123). The
|
|
2441
|
+
# trust dialog is a ~12-line box and the bypass-permissions gate ~20; a
|
|
2442
|
+
# session sitting on either has printed nothing since, so the last 40 lines
|
|
2443
|
+
# hold the whole prompt with room for the banner above it.
|
|
2444
|
+
FIRST_RUN_PANE_TAIL_LINES = 40
|
|
2445
|
+
|
|
2348
2446
|
# NOTE on where that capture goes: NOT onto the operator-facing line. The tail
|
|
2349
2447
|
# is untrusted third-party terminal content and the console serves the log over
|
|
2350
2448
|
# HTTP, so only its SHAPE (a line count) reaches WARNING and the transcript
|
|
@@ -2797,6 +2895,7 @@ class DevWatcher:
|
|
|
2797
2895
|
# terminal one.
|
|
2798
2896
|
resume_pr: "PullRequest | None" = None
|
|
2799
2897
|
dead_session: "str | None" = None
|
|
2898
|
+
wedged_dialog = False
|
|
2800
2899
|
if age is not None:
|
|
2801
2900
|
# `exhaustive` only when a resume could actually fire: the probe's
|
|
2802
2901
|
# default early break answers the boolean the deferral needs, but
|
|
@@ -2920,6 +3019,25 @@ class DevWatcher:
|
|
|
2920
3019
|
# successful listing without this name) -- so the retry below
|
|
2921
3020
|
# can name the session it proved gone instead of presuming.
|
|
2922
3021
|
dead_session = live_session
|
|
3022
|
+
if liveness is Liveness.ALIVE:
|
|
3023
|
+
# Issue #123: ALIVE is a process, not a worker. A session
|
|
3024
|
+
# sitting on Claude Code's first-run "trust this folder?"
|
|
3025
|
+
# (or bypass-permissions) gate is listed, idle, and will
|
|
3026
|
+
# never publish anything -- the directive typed into the
|
|
3027
|
+
# pane was swallowed by the prompt. Only on ALIVE (a
|
|
3028
|
+
# successful listing naming the row), and only when the
|
|
3029
|
+
# pane SHOWS the gate: every other alive-but-idle case
|
|
3030
|
+
# keeps the floored defer below, exactly as before.
|
|
3031
|
+
if self._wedged_on_first_run_dialog(
|
|
3032
|
+
issue, attempts, live_session
|
|
3033
|
+
):
|
|
3034
|
+
wedged_dialog = True
|
|
3035
|
+
# The kill landed and the hub is seeded: fall through
|
|
3036
|
+
# to the cap check and the re-enqueue below as the
|
|
3037
|
+
# CONFIRMED-dead path would -- one WARNING for the
|
|
3038
|
+
# episode was logged by the classifier, so the
|
|
3039
|
+
# respawn's own line is suppressed.
|
|
3040
|
+
liveness = Liveness.DEAD
|
|
2923
3041
|
if liveness is not Liveness.DEAD:
|
|
2924
3042
|
# Floored exactly like the linked-PR deferral, and needing
|
|
2925
3043
|
# it for the same reason: this sits ABOVE the cap check,
|
|
@@ -2973,7 +3091,7 @@ class DevWatcher:
|
|
|
2973
3091
|
dead_session,
|
|
2974
3092
|
attempts + 1,
|
|
2975
3093
|
)
|
|
2976
|
-
elif age is not None:
|
|
3094
|
+
elif age is not None and not wedged_dialog:
|
|
2977
3095
|
log.warning(
|
|
2978
3096
|
"%s attempt %d has been in flight %.0f min with no completion "
|
|
2979
3097
|
"and no linked open PR, and its session %s is absent from the "
|
|
@@ -3518,6 +3636,132 @@ class DevWatcher:
|
|
|
3518
3636
|
return None
|
|
3519
3637
|
return min(youngest, max(0.0, now - committed))
|
|
3520
3638
|
|
|
3639
|
+
def _wedged_on_first_run_dialog(
|
|
3640
|
+
self, issue: Issue, attempts: int, session: str
|
|
3641
|
+
) -> bool:
|
|
3642
|
+
"""Issue #123: is this stale, ALIVE, PR-less session sitting on one of
|
|
3643
|
+
Claude Code's first-run gates -- and if so, clear the lane.
|
|
3644
|
+
|
|
3645
|
+
Reads the pane (`alissa tmux tail`, the reaper's own evidence seam)
|
|
3646
|
+
and asks `hubs.pane_shows_first_run_dialog` whether the session is
|
|
3647
|
+
PARKED on a gate -- the gate's accept option among the last lines
|
|
3648
|
+
with nothing but the gate's own chrome below it, its question
|
|
3649
|
+
above -- not merely whether the gate's words appear: this repo's
|
|
3650
|
+
own README, CHANGELOG, `hubs.py` and issue #123 quote them, so a
|
|
3651
|
+
session that cats or diffs any of them has them on screen while at
|
|
3652
|
+
work, and a kill here lands on the lane's own worktree. A pane not
|
|
3653
|
+
parked on a gate, or one the CLI could not capture, answers False
|
|
3654
|
+
and the caller keeps the floored defer it always took (runbook
|
|
3655
|
+
caveat C7's other alive-but-idle causes -- expired OAuth, usage
|
|
3656
|
+
limits, any other permission prompt -- are deliberately NOT
|
|
3657
|
+
classified here).
|
|
3658
|
+
|
|
3659
|
+
A match is `wedged:first-run-dialog`: ONE WARNING per episode (keyed
|
|
3660
|
+
first_run_dialog_kind(attempts) in the escalations ledger; a kill
|
|
3661
|
+
that fails and is retried next poll logs at INFO), then the row's
|
|
3662
|
+
own session is killed (`alissa tmux kill <name>`, never a sweep),
|
|
3663
|
+
the hub it started in is trusted (root, main/, worktrees -- the very
|
|
3664
|
+
entries the gate was asking for, so the retry does not meet it
|
|
3665
|
+
again), one activity-comment line records the act, and True tells
|
|
3666
|
+
the caller to re-enqueue as attempt k+1 NOW, counting toward
|
|
3667
|
+
attempt_cap like any stale retry -- and on the TERMINAL attempt the
|
|
3668
|
+
caller's cap check refuses that re-enqueue and pages the operator
|
|
3669
|
+
instead, which is what the WARNING, the activity line and the
|
|
3670
|
+
dry-run line say when `attempts >= attempt_cap`, rather than
|
|
3671
|
+
promising a retry the lane will not get. Dry-run classifies and logs but
|
|
3672
|
+
kills nothing and answers False (the defer holds). A kill that
|
|
3673
|
+
fails answers False too: the defer holds and the next poll, reading
|
|
3674
|
+
the same pane, tries again -- the remedy for a session that will not
|
|
3675
|
+
die, not a loop to guard against.
|
|
3676
|
+
|
|
3677
|
+
The pane is untrusted third-party terminal content: only its
|
|
3678
|
+
CLASSIFICATION reaches the log (see `_foreign_pane_evidence` for why
|
|
3679
|
+
the capture itself stays at DEBUG)."""
|
|
3680
|
+
pane = self.alissa.tail_session(session, FIRST_RUN_PANE_TAIL_LINES)
|
|
3681
|
+
if not pane_shows_first_run_dialog(pane):
|
|
3682
|
+
return False
|
|
3683
|
+
log.debug(
|
|
3684
|
+
"%s: last %d pane lines of %s:\n%s",
|
|
3685
|
+
WEDGE_FIRST_RUN_DIALOG, FIRST_RUN_PANE_TAIL_LINES, session, pane,
|
|
3686
|
+
)
|
|
3687
|
+
hub = self.config.hub_for(issue.owner, issue.repo)
|
|
3688
|
+
kind = first_run_dialog_kind(attempts)
|
|
3689
|
+
# What the caller does with a True: the cap check sits right after
|
|
3690
|
+
# this probe, so the terminal attempt caps out instead of retrying.
|
|
3691
|
+
capping = attempts >= self.config.attempt_cap
|
|
3692
|
+
if capping:
|
|
3693
|
+
outcome = (
|
|
3694
|
+
f"NOT re-enqueuing: attempt {attempts} was the last "
|
|
3695
|
+
f"(attempt_cap {self.config.attempt_cap}), so the lane caps "
|
|
3696
|
+
f"out and pages the operator"
|
|
3697
|
+
)
|
|
3698
|
+
activity_outcome = (
|
|
3699
|
+
f"attempt_cap {self.config.attempt_cap} reached — the lane "
|
|
3700
|
+
f"caps out and pages the operator instead of re-enqueuing"
|
|
3701
|
+
)
|
|
3702
|
+
else:
|
|
3703
|
+
outcome = (
|
|
3704
|
+
f"re-enqueuing as attempt {attempts + 1} (counting toward "
|
|
3705
|
+
f"attempt_cap {self.config.attempt_cap})"
|
|
3706
|
+
)
|
|
3707
|
+
activity_outcome = f"re-enqueued as attempt {attempts + 1}"
|
|
3708
|
+
if self.state.escalated(issue.full_name, issue.number, kind):
|
|
3709
|
+
log.info(
|
|
3710
|
+
"%s: session %s (%s attempt %d) is still on the claude "
|
|
3711
|
+
"first-run dialog — retrying the kill",
|
|
3712
|
+
WEDGE_FIRST_RUN_DIALOG, session, issue.issue_slug, attempts,
|
|
3713
|
+
)
|
|
3714
|
+
else:
|
|
3715
|
+
log.warning(
|
|
3716
|
+
"%s: session %s (%s attempt %d) reads alive but its pane shows "
|
|
3717
|
+
"claude's first-run dialog (%s) — the directive was swallowed "
|
|
3718
|
+
"by the prompt and nothing will ever be published. Killing "
|
|
3719
|
+
"it, pre-trusting %s (root, main/, TASK-* worktrees) for "
|
|
3720
|
+
"claude, and %s. Operator lever: `alissa tmux tail <session>` "
|
|
3721
|
+
"to see the prompt, `tmux send-keys Down Enter` to accept it "
|
|
3722
|
+
"by hand, or kill + seed as the daemon does",
|
|
3723
|
+
WEDGE_FIRST_RUN_DIALOG, session, issue.issue_slug, attempts,
|
|
3724
|
+
" / ".join(repr(m) for m in FIRST_RUN_DIALOG_MARKERS), hub,
|
|
3725
|
+
outcome,
|
|
3726
|
+
)
|
|
3727
|
+
self.state.record_escalation(issue.full_name, issue.number, kind)
|
|
3728
|
+
if self.config.dry_run:
|
|
3729
|
+
log.info(
|
|
3730
|
+
"[dry-run] would kill %s, pre-trust %s and %s",
|
|
3731
|
+
session, hub,
|
|
3732
|
+
f"cap out (attempt_cap {self.config.attempt_cap})" if capping
|
|
3733
|
+
else f"re-enqueue attempt {attempts + 1}",
|
|
3734
|
+
)
|
|
3735
|
+
return False
|
|
3736
|
+
try:
|
|
3737
|
+
self.alissa.kill_session(session)
|
|
3738
|
+
except CommandError as exc:
|
|
3739
|
+
log.warning(
|
|
3740
|
+
"%s: could not kill %s (%s) — kills are best-effort; the lane "
|
|
3741
|
+
"keeps its defer and the kill is retried next poll",
|
|
3742
|
+
WEDGE_FIRST_RUN_DIALOG, session, exc,
|
|
3743
|
+
)
|
|
3744
|
+
return False
|
|
3745
|
+
self._trust_hub(hub, WEDGE_FIRST_RUN_DIALOG)
|
|
3746
|
+
if not self.state.activity_logged(issue.full_name, issue.number, kind):
|
|
3747
|
+
landed = self._append_activity(
|
|
3748
|
+
issue.owner,
|
|
3749
|
+
issue.repo,
|
|
3750
|
+
issue.number,
|
|
3751
|
+
self._activity_line(
|
|
3752
|
+
session,
|
|
3753
|
+
"issue edge",
|
|
3754
|
+
f"{ACTIVITY_WEDGE_KILLED} — {WEDGE_FIRST_RUN_DIALOG}: the "
|
|
3755
|
+
f"session read alive but its pane sat on claude's first-run "
|
|
3756
|
+
f"dialog (the hub was not pre-trusted); killed, hub {hub} "
|
|
3757
|
+
f"pre-trusted, {activity_outcome}",
|
|
3758
|
+
),
|
|
3759
|
+
issue.issue_slug,
|
|
3760
|
+
)
|
|
3761
|
+
if landed:
|
|
3762
|
+
self.state.record_activity(issue.full_name, issue.number, kind)
|
|
3763
|
+
return True
|
|
3764
|
+
|
|
3521
3765
|
def _kill_wedged(
|
|
3522
3766
|
self,
|
|
3523
3767
|
issue: Issue,
|
|
@@ -3947,15 +4191,35 @@ class DevWatcher:
|
|
|
3947
4191
|
age = self.state.fix_spawn_age(pr.full_name, number, round_)
|
|
3948
4192
|
|
|
3949
4193
|
if age is not None and age < self.config.stale_minutes * 60:
|
|
4194
|
+
# The row's own session name, not one re-derived from the
|
|
4195
|
+
# attempt: after a handover (below) the row answering round k+1
|
|
4196
|
+
# still carries its `-r<k>-` name, and the console reads this.
|
|
3950
4197
|
return Decision(
|
|
3951
4198
|
Action.IN_FLIGHT,
|
|
3952
4199
|
f"fix attempt {attempts} for round {round_} enqueued "
|
|
3953
4200
|
f"{int(age)}s ago",
|
|
3954
4201
|
attempts,
|
|
3955
|
-
session=
|
|
4202
|
+
session=self.state.newest_fix_spawn_session(
|
|
4203
|
+
pr.full_name, number, round_
|
|
4204
|
+
) or fix_session_name(pr, round_, attempts),
|
|
3956
4205
|
round=round_,
|
|
3957
4206
|
)
|
|
3958
4207
|
|
|
4208
|
+
# A round nothing was spawned for yet -- but the PREVIOUS round's fix
|
|
4209
|
+
# session may still be alive on this very head (issue #121, the
|
|
4210
|
+
# studio#1243 shape: the reviewer daemon posted a phantom round 2 on
|
|
4211
|
+
# round 1's head four minutes after round 1, and this edge spawned a
|
|
4212
|
+
# second session onto the branch the first was mid-work on). A live
|
|
4213
|
+
# session on an unchanged head gets the new round handed to it with
|
|
4214
|
+
# one steering comment; the spawn below is for a head that moved or a
|
|
4215
|
+
# session that is gone. Above the external-claim check because it
|
|
4216
|
+
# spends nothing (ledger + the pass's roster) and a live local session
|
|
4217
|
+
# is the strongest evidence this edge can hold.
|
|
4218
|
+
if age is None:
|
|
4219
|
+
handed = self._hand_over_round(pr, round_, reviews, roster)
|
|
4220
|
+
if handed is not None:
|
|
4221
|
+
return handed
|
|
4222
|
+
|
|
3959
4223
|
# The cross-instance claim check, the issue edge's placement
|
|
3960
4224
|
# transposed: past the fresh-in-flight return, the local ledger no
|
|
3961
4225
|
# longer vouches for this round, and a peer instance's fresh spawn
|
|
@@ -4038,7 +4302,8 @@ class DevWatcher:
|
|
|
4038
4302
|
f"session may be there, already triaging (its CR8 "
|
|
4039
4303
|
f"replies are the footprint); not respawning",
|
|
4040
4304
|
attempts,
|
|
4041
|
-
session=
|
|
4305
|
+
session=row_session
|
|
4306
|
+
or fix_session_name(pr, round_, attempts),
|
|
4042
4307
|
round=round_,
|
|
4043
4308
|
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
4044
4309
|
)
|
|
@@ -5554,6 +5819,215 @@ class DevWatcher:
|
|
|
5554
5819
|
pr.full_name, pr.number, fix_stalled_kind(round_, attempts)
|
|
5555
5820
|
)
|
|
5556
5821
|
|
|
5822
|
+
def _hand_over_round(
|
|
5823
|
+
self,
|
|
5824
|
+
pr: PullRequest,
|
|
5825
|
+
round_: int,
|
|
5826
|
+
reviews: list[Review],
|
|
5827
|
+
roster: "SessionLiveness | None",
|
|
5828
|
+
) -> "Decision | None":
|
|
5829
|
+
"""Round `round_` just landed and nothing was spawned for it. Is the
|
|
5830
|
+
previous round's fix session still ALIVE on this head? Then the round
|
|
5831
|
+
is ITS to answer (issue #121): post one steering comment, move the
|
|
5832
|
+
session's ledger row to this round, and spawn nothing. None means
|
|
5833
|
+
"no handover -- spawn as always".
|
|
5834
|
+
|
|
5835
|
+
The gates, cheapest first, and every one of them is a reason to keep
|
|
5836
|
+
the legacy spawn:
|
|
5837
|
+
|
|
5838
|
+
* no roster, or a listing that failed -- INDETERMINATE, and the issue
|
|
5839
|
+
keeps today's behaviour for it (a spawn); the fail-safe direction
|
|
5840
|
+
is inverted here on purpose: the wedge and resume gates defer
|
|
5841
|
+
because their remedy is a MUTATION on a possibly-live branch, while
|
|
5842
|
+
this gate's remedy is a comment, so ambiguity must not turn a real
|
|
5843
|
+
new round into silence;
|
|
5844
|
+
* the ledger holds no earlier round for this PR, or its newest round
|
|
5845
|
+
is not below this one (a review deleted outright, or a ledger
|
|
5846
|
+
written by a newer daemon -- NOT a dismissal: SUBMITTED_STATES
|
|
5847
|
+
keeps DISMISSED records, so dismissing never shrinks the count);
|
|
5848
|
+
* the newest session of that earlier round is not ALIVE -- DEAD is
|
|
5849
|
+
the ordinary "session finished, next round" flow, and it spawns;
|
|
5850
|
+
* the head is not that session's (see _head_is_sessions) -- the
|
|
5851
|
+
round is on work someone else pushed, and it spawns.
|
|
5852
|
+
|
|
5853
|
+
The comment lands BEFORE the ledger moves, and the move is skipped
|
|
5854
|
+
when the comment fails: a moved row closes the fresh-in-flight gate
|
|
5855
|
+
above, so a comment that failed once would never be retried. With the
|
|
5856
|
+
fix-handover:r<k> row down, the retry re-enters here with the comment
|
|
5857
|
+
already deduped and just moves the row -- the same retry covers a
|
|
5858
|
+
move that found no row to move (logged as an error: the ledger is
|
|
5859
|
+
then not what the daemon just said it was). Dry-run comments nothing
|
|
5860
|
+
and moves nothing. Both decisions ride Action.IN_FLIGHT with
|
|
5861
|
+
DEFER_IN_FLIGHT: a handover is a hold behind own work already on the
|
|
5862
|
+
lane, and the console must be able to tell it from a spawn.
|
|
5863
|
+
|
|
5864
|
+
What the live session does with it: FIX_DIRECTIVE's _NEWER_ROUNDS
|
|
5865
|
+
clause tells it to look for a newer round before its terminal
|
|
5866
|
+
re-request. If it dies without one, the moved row is what the stale
|
|
5867
|
+
path ages: `stale_minutes` after the handover, with the session gone,
|
|
5868
|
+
the edge re-enqueues attempt k+1 of THIS round (never of the old
|
|
5869
|
+
one), because the row now says this is the round in flight."""
|
|
5870
|
+
if roster is None or not roster.listed:
|
|
5871
|
+
return None
|
|
5872
|
+
previous = self.state.max_fix_round(pr.full_name, pr.number)
|
|
5873
|
+
if not previous or previous >= round_:
|
|
5874
|
+
return None
|
|
5875
|
+
row = self.state.newest_fix_spawn(pr.full_name, pr.number, previous)
|
|
5876
|
+
if row is None:
|
|
5877
|
+
return None
|
|
5878
|
+
session = str(row["session"])
|
|
5879
|
+
if roster.of(session) is not Liveness.ALIVE:
|
|
5880
|
+
return None
|
|
5881
|
+
if not self._head_is_sessions(pr, row, reviews):
|
|
5882
|
+
log.info(
|
|
5883
|
+
"%s round %d landed while the round-%d fix session %s is "
|
|
5884
|
+
"still listed, but head %s is not that session's — spawning "
|
|
5885
|
+
"as usual",
|
|
5886
|
+
pr.pr_slug,
|
|
5887
|
+
round_,
|
|
5888
|
+
previous,
|
|
5889
|
+
session,
|
|
5890
|
+
pr.head_sha[:8],
|
|
5891
|
+
)
|
|
5892
|
+
return None
|
|
5893
|
+
|
|
5894
|
+
kind = fix_handover_kind(round_)
|
|
5895
|
+
body = FIX_HANDOVER_COMMENT.format(
|
|
5896
|
+
new_round=round_,
|
|
5897
|
+
round=previous,
|
|
5898
|
+
session=session,
|
|
5899
|
+
head=pr.head_sha[:8],
|
|
5900
|
+
)
|
|
5901
|
+
if self.config.dry_run:
|
|
5902
|
+
log.info(
|
|
5903
|
+
"[dry-run] would hand round %d of %s to the live fix session "
|
|
5904
|
+
"%s and comment:\n%s",
|
|
5905
|
+
round_,
|
|
5906
|
+
pr.pr_slug,
|
|
5907
|
+
session,
|
|
5908
|
+
body,
|
|
5909
|
+
)
|
|
5910
|
+
elif not self.state.escalated(pr.full_name, pr.number, kind):
|
|
5911
|
+
log.warning(
|
|
5912
|
+
"%s round %d arrived while the round-%d fix session %s is "
|
|
5913
|
+
"ALIVE on the same head %s — handing the round to it (one "
|
|
5914
|
+
"steering comment), NOT spawning a second session",
|
|
5915
|
+
pr.pr_slug,
|
|
5916
|
+
round_,
|
|
5917
|
+
previous,
|
|
5918
|
+
session,
|
|
5919
|
+
pr.head_sha[:8],
|
|
5920
|
+
)
|
|
5921
|
+
try:
|
|
5922
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
5923
|
+
except CommandError as exc:
|
|
5924
|
+
log.error(
|
|
5925
|
+
"could not post the round-%d handover comment on %s: %s "
|
|
5926
|
+
"— not moving the ledger row; the handover retries next "
|
|
5927
|
+
"poll",
|
|
5928
|
+
round_,
|
|
5929
|
+
pr.pr_slug,
|
|
5930
|
+
exc,
|
|
5931
|
+
)
|
|
5932
|
+
return Decision(
|
|
5933
|
+
Action.IN_FLIGHT,
|
|
5934
|
+
f"round {round_} is the live round-{previous} fix "
|
|
5935
|
+
f"session {session}'s to answer, but the steering comment "
|
|
5936
|
+
f"could not be posted — retrying next poll",
|
|
5937
|
+
int(row["attempt"]),
|
|
5938
|
+
session=session,
|
|
5939
|
+
round=round_,
|
|
5940
|
+
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
5941
|
+
)
|
|
5942
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
5943
|
+
|
|
5944
|
+
if not self.config.dry_run:
|
|
5945
|
+
moved = self.state.hand_over_fix_spawn(
|
|
5946
|
+
repo_slug=pr.full_name,
|
|
5947
|
+
number=pr.number,
|
|
5948
|
+
session=session,
|
|
5949
|
+
round_=round_,
|
|
5950
|
+
head_sha=pr.head_sha,
|
|
5951
|
+
)
|
|
5952
|
+
if not moved:
|
|
5953
|
+
log.error(
|
|
5954
|
+
"%s round %d handover: no fix_spawns row carries session "
|
|
5955
|
+
"%s any more — the ledger was not moved (the row is "
|
|
5956
|
+
"still on round %d); retrying the move next poll",
|
|
5957
|
+
pr.pr_slug,
|
|
5958
|
+
round_,
|
|
5959
|
+
session,
|
|
5960
|
+
previous,
|
|
5961
|
+
)
|
|
5962
|
+
return Decision(
|
|
5963
|
+
Action.IN_FLIGHT,
|
|
5964
|
+
f"round {round_} is the live round-{previous} fix "
|
|
5965
|
+
f"session {session}'s to answer (steering comment "
|
|
5966
|
+
f"posted), but its ledger row could not be moved — "
|
|
5967
|
+
f"retrying next poll",
|
|
5968
|
+
int(row["attempt"]),
|
|
5969
|
+
session=session,
|
|
5970
|
+
round=round_,
|
|
5971
|
+
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
5972
|
+
)
|
|
5973
|
+
# The moved row is attempt 1 of the new round (see
|
|
5974
|
+
# State.hand_over_fix_spawn); dry-run moved nothing and reports the
|
|
5975
|
+
# row as it stands.
|
|
5976
|
+
return Decision(
|
|
5977
|
+
Action.IN_FLIGHT,
|
|
5978
|
+
f"round {round_} handed to the live round-{previous} fix session "
|
|
5979
|
+
f"{session} on the same head {pr.head_sha[:8]} (steering comment "
|
|
5980
|
+
f"posted once; no second session)",
|
|
5981
|
+
int(row["attempt"]) if self.config.dry_run else 1,
|
|
5982
|
+
session=session,
|
|
5983
|
+
round=round_,
|
|
5984
|
+
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
5985
|
+
)
|
|
5986
|
+
|
|
5987
|
+
def _head_is_sessions(
|
|
5988
|
+
self, pr: PullRequest, row: "sqlite3.Row", reviews: list[Review]
|
|
5989
|
+
) -> bool:
|
|
5990
|
+
"""Is the PR's current head the one the row's session is working --
|
|
5991
|
+
the head it was spawned against, or one it has pushed since?
|
|
5992
|
+
|
|
5993
|
+
Three tiers, cheapest first, and the first two cost nothing:
|
|
5994
|
+
|
|
5995
|
+
1. the ledger's spawn head (`head_sha`, recorded since issue #121;
|
|
5996
|
+
'' on older rows, which skip this tier);
|
|
5997
|
+
2. the PREVIOUS verdict's head: the round before this one was
|
|
5998
|
+
reviewed on `commit_id`, and a head still equal to it has not
|
|
5999
|
+
moved between the rounds -- the issue's "simply" test, and the
|
|
6000
|
+
#1243 shape exactly (round 2 on round 1's head);
|
|
6001
|
+
3. a head that POSTDATES the session's spawn: one branch, one live
|
|
6002
|
+
session, so a commit made after the session started is that
|
|
6003
|
+
session's own push (the directives commit and push within
|
|
6004
|
+
seconds). One GET, on this rare path only, and any failure to
|
|
6005
|
+
read the instant answers False -- the legacy spawn.
|
|
6006
|
+
|
|
6007
|
+
False means "the head moved" for the handover's purposes, which keeps
|
|
6008
|
+
today's behaviour (issue #121 §4)."""
|
|
6009
|
+
spawn_head = str(row["head_sha"] or "")
|
|
6010
|
+
if spawn_head and pr.head_sha == spawn_head:
|
|
6011
|
+
return True
|
|
6012
|
+
if len(reviews) >= 2:
|
|
6013
|
+
before = reviews[-2]
|
|
6014
|
+
if before.commit_id and before.commit_id == pr.head_sha:
|
|
6015
|
+
return True
|
|
6016
|
+
try:
|
|
6017
|
+
committed = self.github.commit_time(pr.owner, pr.repo, pr.head_sha)
|
|
6018
|
+
except CommandError as exc:
|
|
6019
|
+
log.warning(
|
|
6020
|
+
"could not read the commit instant of %s head %s (%s) — "
|
|
6021
|
+
"treating the head as moved",
|
|
6022
|
+
pr.pr_slug,
|
|
6023
|
+
pr.head_sha[:8],
|
|
6024
|
+
exc,
|
|
6025
|
+
)
|
|
6026
|
+
return False
|
|
6027
|
+
if committed is None:
|
|
6028
|
+
return False
|
|
6029
|
+
return committed >= int(row["spawned_at"])
|
|
6030
|
+
|
|
5557
6031
|
def _verdict(self, pr: PullRequest, newest: Review) -> "str | None":
|
|
5558
6032
|
"""The newest review's verdict, mirroring reviewloop's precedence:
|
|
5559
6033
|
the GitHub review state is authoritative where it can speak, and
|
|
@@ -5787,6 +6261,9 @@ class DevWatcher:
|
|
|
5787
6261
|
round_=round_,
|
|
5788
6262
|
attempt=attempt,
|
|
5789
6263
|
session=name,
|
|
6264
|
+
# The head this session takes the round against -- the
|
|
6265
|
+
# handover check's first tier (issue #121).
|
|
6266
|
+
head_sha=pr.head_sha,
|
|
5790
6267
|
)
|
|
5791
6268
|
|
|
5792
6269
|
resumed = (
|
|
@@ -6264,6 +6741,12 @@ class DevWatcher:
|
|
|
6264
6741
|
full_name = f"{owner}/{repo}"
|
|
6265
6742
|
hub = self.config.hub_for(owner, repo)
|
|
6266
6743
|
if hub.is_dir():
|
|
6744
|
+
# An existing hub may have grown TASK-* worktrees since boot (a
|
|
6745
|
+
# session creates its own), and a hub that appeared between
|
|
6746
|
+
# boots was never seeded by the entrypoint at all: trust is
|
|
6747
|
+
# re-asserted before EVERY spawn. Idempotent and cheap -- a
|
|
6748
|
+
# state file already carrying every path is not rewritten.
|
|
6749
|
+
self._trust_hub(hub, "before spawn")
|
|
6267
6750
|
return hub, None
|
|
6268
6751
|
|
|
6269
6752
|
if self.config.on_missing_hub != HUB_ADD:
|
|
@@ -6304,8 +6787,39 @@ class DevWatcher:
|
|
|
6304
6787
|
f"success but {hub} still does not exist — check hub_template "
|
|
6305
6788
|
f"against the manifest's `dir:` override"
|
|
6306
6789
|
)
|
|
6790
|
+
# Issue #123: the entrypoint pre-trusts only the hubs it can name at
|
|
6791
|
+
# boot, and under `repos_source: bows` a hub created here was never
|
|
6792
|
+
# among them -- the first session on it hung on Claude Code's "trust
|
|
6793
|
+
# this folder?" dialog for 108 minutes (ali-fhr/docs.alissa.app#10).
|
|
6794
|
+
# Seed the hub root (the spawn cwd), main/ and any worktree BEFORE
|
|
6795
|
+
# the session is enqueued.
|
|
6796
|
+
self._trust_hub(hub, "hub-ified")
|
|
6307
6797
|
return hub, None
|
|
6308
6798
|
|
|
6799
|
+
def _trust_hub(self, hub: Path, why: str) -> "list[Path]":
|
|
6800
|
+
"""Pre-accept Claude Code's trust dialog for every directory a
|
|
6801
|
+
session on this hub may start in (see `hubs.hub_trust_paths`), in
|
|
6802
|
+
both claude state files. Returns the state files that changed.
|
|
6803
|
+
Dry-run seeds nothing. Best-effort by construction (`seed_trust`
|
|
6804
|
+
never raises): a session can still be spawned into an untrusted hub
|
|
6805
|
+
-- it just may wedge on the dialog, which the stale-session branch
|
|
6806
|
+
now detects -- so a failed seed must never cost the spawn."""
|
|
6807
|
+
paths = hub_trust_paths(hub)
|
|
6808
|
+
if self.config.dry_run:
|
|
6809
|
+
log.info(
|
|
6810
|
+
"[dry-run] would pre-trust %d claude dir(s) for hub %s (%s)",
|
|
6811
|
+
len(paths), hub, why,
|
|
6812
|
+
)
|
|
6813
|
+
return []
|
|
6814
|
+
changed = seed_trust(paths)
|
|
6815
|
+
if changed:
|
|
6816
|
+
log.info(
|
|
6817
|
+
"trusted hub %s for claude (%s): %d dir(s) — %s — written to %s",
|
|
6818
|
+
hub, why, len(paths), ", ".join(str(p) for p in paths),
|
|
6819
|
+
", ".join(str(t) for t in changed),
|
|
6820
|
+
)
|
|
6821
|
+
return changed
|
|
6822
|
+
|
|
6309
6823
|
# -- the derived allowlist (repos_source: "bows") ----------------------
|
|
6310
6824
|
|
|
6311
6825
|
def _apply_repos(self, repos: "tuple[str, ...]") -> None:
|
|
@@ -6396,6 +6910,36 @@ class DevWatcher:
|
|
|
6396
6910
|
)
|
|
6397
6911
|
return
|
|
6398
6912
|
self._apply_repos(source.repos())
|
|
6913
|
+
self._record_derived(source.derived)
|
|
6914
|
+
|
|
6915
|
+
def _record_derived(self, derived: "tuple[str, ...]") -> None:
|
|
6916
|
+
"""Issue #123: make the derived allowlist visible to the entrypoint's
|
|
6917
|
+
boot-time trust seeding (`{root}/.alissa-derived-repos`) and trust
|
|
6918
|
+
the derived hubs NOW -- hub-ified or not, exactly as the entrypoint
|
|
6919
|
+
trusts a static allowlist's hubs before they exist. Both are
|
|
6920
|
+
best-effort and idempotent; dry-run writes nothing."""
|
|
6921
|
+
if not derived:
|
|
6922
|
+
return
|
|
6923
|
+
if self.config.dry_run:
|
|
6924
|
+
log.info(
|
|
6925
|
+
"[dry-run] would record %d derived repo(s) and pre-trust "
|
|
6926
|
+
"their hubs", len(derived),
|
|
6927
|
+
)
|
|
6928
|
+
return
|
|
6929
|
+
write_derived_repos(self.config.workspace_root, derived)
|
|
6930
|
+
paths: "list[Path]" = []
|
|
6931
|
+
for full_name in derived:
|
|
6932
|
+
owner, _, repo = full_name.partition("/")
|
|
6933
|
+
if not owner or not repo:
|
|
6934
|
+
continue
|
|
6935
|
+
paths.extend(hub_trust_paths(self.config.hub_for(owner, repo)))
|
|
6936
|
+
changed = seed_trust(paths)
|
|
6937
|
+
if changed:
|
|
6938
|
+
log.info(
|
|
6939
|
+
"repos_source=bows: pre-trusted the hubs of %d derived "
|
|
6940
|
+
"repo(s) for claude in %s",
|
|
6941
|
+
len(derived), ", ".join(str(t) for t in changed),
|
|
6942
|
+
)
|
|
6399
6943
|
|
|
6400
6944
|
# -- preflight ---------------------------------------------------------
|
|
6401
6945
|
|
|
@@ -127,7 +127,9 @@ _FOREIGN_RE = re.compile(r"^foreign-session:(.+)$")
|
|
|
127
127
|
# lane is working, not the draft PR behind it) -- PR and issue numbers share
|
|
128
128
|
# GitHub's namespace, so a kind missing here does not merely mislabel, it
|
|
129
129
|
# points the operator at an unrelated PR or at nothing.
|
|
130
|
-
_ISSUE_ANCHORED = (
|
|
130
|
+
_ISSUE_ANCHORED = (
|
|
131
|
+
"cap", "assignment", "stalled", "live-stalled", "wedged", "first-run-dialog",
|
|
132
|
+
)
|
|
131
133
|
|
|
132
134
|
|
|
133
135
|
def _ms(ts: "int | float | str") -> int:
|
|
@@ -8,7 +8,11 @@ can be retried, and to remember which escalations were already raised.
|
|
|
8
8
|
The review-response edge keeps its own additive table, `fix_spawns`, keyed
|
|
9
9
|
(repo_slug, number, round, attempt): `number` is the PULL REQUEST number (the
|
|
10
10
|
edge has no issue), `round` is the substantive-review count the fix session
|
|
11
|
-
|
|
11
|
+
is CURRENTLY ANSWERING (the round it was spawned for, until a handover -- see
|
|
12
|
+
`hand_over_fix_spawn` -- moves the row to a later round without spawning),
|
|
13
|
+
`attempt` retries a presumed-dead fix session within one round, and
|
|
14
|
+
`head_sha` is the PR head the row's session took that round against (the
|
|
15
|
+
spawn head, refreshed on handover; '' on rows written before the column).
|
|
12
16
|
A separate table rather than new columns on `spawns` because the two edges
|
|
13
17
|
age and dedupe on different keys (issue vs PR+round), and an additive table
|
|
14
18
|
leaves every existing ledger untouched. Fix-round cap-outs reuse the
|
|
@@ -135,6 +139,7 @@ CREATE TABLE IF NOT EXISTS fix_spawns (
|
|
|
135
139
|
attempt INTEGER NOT NULL,
|
|
136
140
|
session TEXT NOT NULL,
|
|
137
141
|
spawned_at INTEGER NOT NULL,
|
|
142
|
+
head_sha TEXT NOT NULL DEFAULT '',
|
|
138
143
|
PRIMARY KEY (repo_slug, number, round, attempt)
|
|
139
144
|
);
|
|
140
145
|
|
|
@@ -193,6 +198,10 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
|
|
|
193
198
|
_COLUMN_MIGRATIONS = (
|
|
194
199
|
("poll_snapshots", "deferred", "INTEGER NOT NULL DEFAULT 0"),
|
|
195
200
|
("poll_snapshots", "foreign_reaped", "INTEGER NOT NULL DEFAULT 0"),
|
|
201
|
+
# The head a fix session took its round against (issue #121): rows
|
|
202
|
+
# written before the column read '' and the handover check falls back to
|
|
203
|
+
# the previous verdict's head for them.
|
|
204
|
+
("fix_spawns", "head_sha", "TEXT NOT NULL DEFAULT ''"),
|
|
196
205
|
)
|
|
197
206
|
|
|
198
207
|
|
|
@@ -558,18 +567,74 @@ class State:
|
|
|
558
567
|
return row is not None
|
|
559
568
|
|
|
560
569
|
def record_fix_spawn(
|
|
561
|
-
self, *, repo_slug: str, number: int, round_: int, attempt: int, session: str
|
|
570
|
+
self, *, repo_slug: str, number: int, round_: int, attempt: int, session: str,
|
|
571
|
+
head_sha: str = "",
|
|
562
572
|
) -> None:
|
|
563
573
|
"""Record a fix-session spawn for one review round. `repo_slug` is
|
|
564
|
-
`owner/repo`; `number` is the PR number
|
|
574
|
+
`owner/repo`; `number` is the PR number; `head_sha` is the PR head the
|
|
575
|
+
session was spawned against (the handover check's first tier -- see
|
|
576
|
+
loop.DevWatcher._hand_over_round)."""
|
|
565
577
|
self._db.execute(
|
|
566
578
|
"INSERT OR REPLACE INTO fix_spawns "
|
|
567
|
-
"(repo_slug, number, round, attempt, session, spawned_at) "
|
|
568
|
-
"VALUES (
|
|
569
|
-
(repo_slug, number, round_, attempt, session, int(time.time())
|
|
579
|
+
"(repo_slug, number, round, attempt, session, spawned_at, head_sha) "
|
|
580
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
581
|
+
(repo_slug, number, round_, attempt, session, int(time.time()),
|
|
582
|
+
head_sha or ""),
|
|
570
583
|
)
|
|
571
584
|
self._db.commit()
|
|
572
585
|
|
|
586
|
+
def newest_fix_spawn(
|
|
587
|
+
self, repo_slug: str, number: int, round_: int
|
|
588
|
+
) -> "sqlite3.Row | None":
|
|
589
|
+
"""The newest fix-spawn row of THIS PR round (session, attempt,
|
|
590
|
+
spawned_at, head_sha), or None when the round was never spawned --
|
|
591
|
+
newest_fix_spawn_session with the rest of the row, for the handover
|
|
592
|
+
check that needs the head and the spawn instant too."""
|
|
593
|
+
return self._db.execute(
|
|
594
|
+
"SELECT session, attempt, spawned_at, head_sha FROM fix_spawns "
|
|
595
|
+
"WHERE repo_slug=? AND number=? AND round=? "
|
|
596
|
+
"ORDER BY spawned_at DESC, attempt DESC LIMIT 1",
|
|
597
|
+
(repo_slug, number, round_),
|
|
598
|
+
).fetchone()
|
|
599
|
+
|
|
600
|
+
def hand_over_fix_spawn(
|
|
601
|
+
self, *, repo_slug: str, number: int, session: str, round_: int,
|
|
602
|
+
head_sha: str,
|
|
603
|
+
) -> bool:
|
|
604
|
+
"""Move a live fix session's row to the round it is now answering
|
|
605
|
+
(issue #121): a later review round landed on the head that session is
|
|
606
|
+
still working, so the daemon handed the round to it instead of
|
|
607
|
+
spawning a second session. The row's `round` becomes `round_`, its
|
|
608
|
+
`head_sha` the head handed over, and `spawned_at` NOW -- the staleness
|
|
609
|
+
clock restarts at the handover, because the session took the new
|
|
610
|
+
round at that instant and the stale path must not fire on it one poll
|
|
611
|
+
later just because the session was already old.
|
|
612
|
+
|
|
613
|
+
ONE row moves, not a copy: the console's inbox then shows one session
|
|
614
|
+
answering one round, `max_fix_round` reads the handed-over round (so
|
|
615
|
+
the reaper never counts the session as superseded by the round it is
|
|
616
|
+
answering), and the round's attempt budget starts at this row -- so
|
|
617
|
+
the row is RENUMBERED to attempt 1 (PR #122 review round 1). The
|
|
618
|
+
attempt ledger's contract is that a round's attempts are contiguous
|
|
619
|
+
from 1 (`fix_attempt_count` is a COUNT, and `attempts + 1` must be a
|
|
620
|
+
free slot for the next spawn); a resumed session's row is attempt 2,
|
|
621
|
+
and moving it as attempt 2 would leave the new round with one row
|
|
622
|
+
that COUNTs as one attempt -- the stale respawn would then
|
|
623
|
+
`INSERT OR REPLACE` over it as attempt 2 every stale window, and the
|
|
624
|
+
attempt cap would never fire. The target key (repo_slug, number,
|
|
625
|
+
round_, 1) is free by construction: the caller only hands over a
|
|
626
|
+
round nothing was spawned for. Strict like record_fix_spawn: a row
|
|
627
|
+
the daemon believes it moved but did not would spawn over the live
|
|
628
|
+
session next poll. Returns False when no row carries `session` on
|
|
629
|
+
this PR."""
|
|
630
|
+
cursor = self._db.execute(
|
|
631
|
+
"UPDATE fix_spawns SET round=?, attempt=1, head_sha=?, spawned_at=? "
|
|
632
|
+
"WHERE repo_slug=? AND number=? AND session=?",
|
|
633
|
+
(round_, head_sha or "", int(time.time()), repo_slug, number, session),
|
|
634
|
+
)
|
|
635
|
+
self._db.commit()
|
|
636
|
+
return cursor.rowcount > 0
|
|
637
|
+
|
|
573
638
|
def fix_attempt_count(self, repo_slug: str, number: int, round_: int) -> int:
|
|
574
639
|
"""Fix attempts already spawned for THIS round of this PR. Keyed per
|
|
575
640
|
round on purpose: a new review round starts a fresh attempt budget."""
|
|
@@ -1 +1 @@
|
|
|
1
|
-
0.8.
|
|
1
|
+
0.8.24
|
{alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/RECORD
RENAMED
|
@@ -6,12 +6,13 @@ alissa/tools/github/devloop/bows.py,sha256=pzDNMRk_S78FX7vMNYMlXHxW6VPHPo1Xd2EzF
|
|
|
6
6
|
alissa/tools/github/devloop/config.py,sha256=2ifQx1LvqXZhtt44adQAFmKIMOK8iVAK53hi56U00gw,60399
|
|
7
7
|
alissa/tools/github/devloop/fleet_vitals.py,sha256=m3rIKojFt5oCW2K-0qFkehmgR3uJQrvUddAnfW-Ob1k,15620
|
|
8
8
|
alissa/tools/github/devloop/ghclient.py,sha256=UlDRF8V-oECdaUfIUdnbyl054403Q4019mTJD24FRXc,57554
|
|
9
|
+
alissa/tools/github/devloop/hubs.py,sha256=yjnsnSHLG7GNNP8X2hILeAMV6P5n4JGc_IaAxpSP_YQ,18974
|
|
9
10
|
alissa/tools/github/devloop/janitor.py,sha256=R8REoHBeY2cfW7fYJVhKr-F5z9Mjlc-vmv-z4KtvBZc,27360
|
|
10
|
-
alissa/tools/github/devloop/loop.py,sha256=
|
|
11
|
-
alissa/tools/github/devloop/loop_events.py,sha256=
|
|
11
|
+
alissa/tools/github/devloop/loop.py,sha256=cxKbMKyff3STTaiHq7xJ4tpc4HOjAqw5_yI9TQo4omo,452375
|
|
12
|
+
alissa/tools/github/devloop/loop_events.py,sha256=WNVa8gR8Uuv4pQcVxjwg9TtJbLmWs4t54XkD6XvKvgg,23834
|
|
12
13
|
alissa/tools/github/devloop/proc.py,sha256=_dDr3g76WEf2DRyLUz1Dzl4KvLVptJant_UZOrRixNk,1649
|
|
13
|
-
alissa/tools/github/devloop/state.py,sha256=
|
|
14
|
-
alissa/tools/github/devloop/version,sha256=
|
|
14
|
+
alissa/tools/github/devloop/state.py,sha256=kuQRtjtJXvSnsI1_wCng4855MPMgVwkbU8szVxa4iws,63925
|
|
15
|
+
alissa/tools/github/devloop/version,sha256=Bdd85i5avnISwPpcp0fxY0jQEsOQFggOmcEPj39eIL8,7
|
|
15
16
|
alissa/tools/github/devloop/version.py,sha256=znuuOaitXGrBpbskNy78_rlVpFdtmKmk_3k1ACxZrl0,1418
|
|
16
17
|
alissa/tools/github/devloop/webui/__init__.py,sha256=vdXYoyGYzD7BrJ5lV6XdH8Bpw_pej8-RK0hjfxop7-8,1513
|
|
17
18
|
alissa/tools/github/devloop/webui/__main__.py,sha256=FCkklWZLPUJtRPZTrPRkCdMInWPXLaLyCWm9hy3jhg8,5097
|
|
@@ -20,10 +21,10 @@ alissa/tools/github/devloop/webui/page.py,sha256=48siJM-aVSjaZIHJrPDNPeAlCtIPJOH
|
|
|
20
21
|
alissa/tools/github/devloop/webui/server.py,sha256=LP7KKUL1Kd85F119rh8ce9s1GyYeZ-ETh91Y-ZCxzCA,13333
|
|
21
22
|
alissa/tools/github/devloop/webui/sources.py,sha256=fiity1aMpz9KdnQ-MzWYCdqBRmhJfuIYCN_FePw9akE,31825
|
|
22
23
|
alissa/tools/github/devloop/webui/sysinfo.py,sha256=ptlogH9alFv0vZ-pQfyUaA3w-K7LQS3tQHGv0d8Tdlk,9649
|
|
23
|
-
alissa_tools_github_devloop-0.8.
|
|
24
|
-
alissa_tools_github_devloop-0.8.
|
|
25
|
-
alissa_tools_github_devloop-0.8.
|
|
26
|
-
alissa_tools_github_devloop-0.8.
|
|
27
|
-
alissa_tools_github_devloop-0.8.
|
|
28
|
-
alissa_tools_github_devloop-0.8.
|
|
29
|
-
alissa_tools_github_devloop-0.8.
|
|
24
|
+
alissa_tools_github_devloop-0.8.24.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
25
|
+
alissa_tools_github_devloop-0.8.24.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
|
|
26
|
+
alissa_tools_github_devloop-0.8.24.dist-info/METADATA,sha256=Fycs9dGIUvkr8lOmd49FGm6NKeKhvz3EOIMvKNhFlB0,2654
|
|
27
|
+
alissa_tools_github_devloop-0.8.24.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
28
|
+
alissa_tools_github_devloop-0.8.24.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
|
|
29
|
+
alissa_tools_github_devloop-0.8.24.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
|
|
30
|
+
alissa_tools_github_devloop-0.8.24.dist-info/RECORD,,
|
{alissa_tools_github_devloop-0.8.22.dist-info → alissa_tools_github_devloop-0.8.24.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|