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.
@@ -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=fix_session_name(pr, round_, attempts),
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=fix_session_name(pr, round_, attempts),
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 = ("cap", "assignment", "stalled", "live-stalled", "wedged")
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
- answers, and `attempt` retries a presumed-dead fix session within one round.
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.22
1
+ 0.8.24
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.22
3
+ Version: 0.8.24
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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=hyDZk3s94xw0UqOjZCgE8IFMdsS0uV234osEcZ5v0q8,425050
11
- alissa/tools/github/devloop/loop_events.py,sha256=qyvIkQGfl2k4zULlddQdV_u1pd5RbWmmTmWiCsnnhRc,23807
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=nYADHYgOGNmkCdl7lx3DeeJkcPAwkg4mxA9-i0m7ybw,60150
14
- alissa/tools/github/devloop/version,sha256=hkVrzGKdmB4uNNf9UwlvDbqWkEYFk7O4RYO-BfP9VE4,7
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.22.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
24
- alissa_tools_github_devloop-0.8.22.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
25
- alissa_tools_github_devloop-0.8.22.dist-info/METADATA,sha256=lNAnUJOEcPDwzey16pyLtwuk0XfxR2oenQwJQ-uJg58,2654
26
- alissa_tools_github_devloop-0.8.22.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
27
- alissa_tools_github_devloop-0.8.22.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
28
- alissa_tools_github_devloop-0.8.22.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
29
- alissa_tools_github_devloop-0.8.22.dist-info/RECORD,,
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,,