alissa-tools-github-devloop 0.8.23__tar.gz → 0.8.25__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_devloop-0.8.23/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.25}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.8.25/src/main/alissa/tools/github/devloop/hubs.py +427 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/loop.py +272 -1
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/loop_events.py +3 -1
- alissa_tools_github_devloop-0.8.25/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_devloop-0.8.23/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/config.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.23 → alissa_tools_github_devloop-0.8.25}/src/main/alissa_tools_github_devloop.egg-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,
|
|
@@ -716,6 +724,24 @@ _BRANCH_RULE = (
|
|
|
716
724
|
"maintenance and no resume until an operator renames it. "
|
|
717
725
|
)
|
|
718
726
|
|
|
727
|
+
# The shell rule (issue #125). Workers run unattended under
|
|
728
|
+
# `--dangerously-skip-permissions`, and Claude Code's critical-path removal
|
|
729
|
+
# check STILL prompts on an rm whose target it cannot resolve statically (a
|
|
730
|
+
# glob, a variable, a path outside the cwd) -- the dialog that wedged #107 for
|
|
731
|
+
# seven hours. The container's PreToolUse guard refuses those shapes with a
|
|
732
|
+
# reason; this rule tells the worker up front so it never writes them, and
|
|
733
|
+
# never retries one the guard refused. In EVERY directive -- issue, resume,
|
|
734
|
+
# fix, fix-resume, maintain -- because every one of them runs the same shell.
|
|
735
|
+
_SHELL_RULE = (
|
|
736
|
+
"You are unattended: nothing can answer an interactive prompt. Never run "
|
|
737
|
+
"rm/rmdir on a glob, a shell variable or a path outside your worktree — "
|
|
738
|
+
"use literal paths, `find <dir> -mindepth 1 -delete` or `git clean -fdx "
|
|
739
|
+
"-- <path>`; never start an interactive command (editors, pagers, `git "
|
|
740
|
+
"rebase -i`, `npm init`, anything that asks y/n — pass the "
|
|
741
|
+
"non-interactive flag). If a command is refused by the shell guard, "
|
|
742
|
+
"change the command; do not retry it. "
|
|
743
|
+
)
|
|
744
|
+
|
|
719
745
|
DEV_DIRECTIVE = (
|
|
720
746
|
"You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
|
|
721
747
|
"{issue_url} (attempt {attempt} of {cap}) — the issue is your "
|
|
@@ -729,6 +755,7 @@ DEV_DIRECTIVE = (
|
|
|
729
755
|
"task = one branch = one worktree; never clone, never work directly on "
|
|
730
756
|
"main). "
|
|
731
757
|
+ _BRANCH_RULE
|
|
758
|
+
+ _SHELL_RULE
|
|
732
759
|
+ "Implement to the issue's acceptance criteria and VERIFY locally (run the "
|
|
733
760
|
"repo's test/style/type scripts) before publishing. Open a DRAFT PR whose "
|
|
734
761
|
"body contains `Closes #{number}` and BOTH task refs — the origin task "
|
|
@@ -792,6 +819,7 @@ RESUME_DIRECTIVE = (
|
|
|
792
819
|
"anchors. Keep the PR body carrying `Closes #{number}` and BOTH task refs "
|
|
793
820
|
"(the origin task AND your implementation task) — restoring them if the "
|
|
794
821
|
"dead session did not. "
|
|
822
|
+
+ _SHELL_RULE
|
|
795
823
|
+ _RESUME_DELIVERY_NOTE
|
|
796
824
|
+ " "
|
|
797
825
|
"If the PR already carries review findings, the ORDER is load-bearing: "
|
|
@@ -845,6 +873,7 @@ FIX_DIRECTIVE = (
|
|
|
845
873
|
"EXISTING name (never a new branch, never a rename, never a clone, never "
|
|
846
874
|
"main). "
|
|
847
875
|
+ _BRANCH_RULE
|
|
876
|
+
+ _SHELL_RULE
|
|
848
877
|
+ "FIRST verify the triage of every prior review finding per CR8: exactly "
|
|
849
878
|
"one reasoned `[triage:*]` reply on each finding's thread — post any that "
|
|
850
879
|
"are missing. Then fix the pursued findings on the same branch and VERIFY "
|
|
@@ -899,6 +928,7 @@ FIX_RESUME_DIRECTIVE = (
|
|
|
899
928
|
"that branch under its EXISTING name: never a new branch, never a "
|
|
900
929
|
"rename, never a clone, never main, and NEVER a second PR. "
|
|
901
930
|
+ _BRANCH_RULE
|
|
931
|
+
+ _SHELL_RULE
|
|
902
932
|
+ "The dead session may have posted SOME of its CR8 triage replies already: "
|
|
903
933
|
"read every finding's thread and post exactly the ones that are missing — "
|
|
904
934
|
"one reasoned `[triage:*]` reply per finding, NEVER a duplicate on a "
|
|
@@ -946,6 +976,7 @@ MAINTAIN_DIRECTIVE = (
|
|
|
946
976
|
"EXISTING name (never a new branch, never a rename, never a clone, never "
|
|
947
977
|
"main). "
|
|
948
978
|
+ _BRANCH_RULE
|
|
979
|
+
+ _SHELL_RULE
|
|
949
980
|
+ "FIRST the idempotent no-op check: if the branch is already conflict-free "
|
|
950
981
|
"AND current vs origin/main, remove the `{label}` label from the PR "
|
|
951
982
|
"(`gh pr edit {number} --remove-label \"{label}\"`) and exit — push "
|
|
@@ -1258,6 +1289,16 @@ ESCALATION_LIVE_STALLED = "live-stalled"
|
|
|
1258
1289
|
# poll is the remedy, not a bug.
|
|
1259
1290
|
ESCALATION_WEDGED = "wedged"
|
|
1260
1291
|
|
|
1292
|
+
# The first-run-dialog wedge's once-per-episode marker (issue #123; see
|
|
1293
|
+
# first_run_dialog_kind). Like ESCALATION_WEDGED an ACT, not a page: the
|
|
1294
|
+
# daemon killed a session whose pane showed one of Claude Code's first-run
|
|
1295
|
+
# gates, seeded the trust the gate was asking for, and re-enqueued the
|
|
1296
|
+
# attempt itself. The row dedupes the WARNING (one per episode -- a kill
|
|
1297
|
+
# that fails and is retried next poll logs its retry at INFO) and the
|
|
1298
|
+
# activity-comment line; the loop-events emitter turns it into the generic
|
|
1299
|
+
# `escalation.first-run-dialog`, anchored on the issue.
|
|
1300
|
+
ESCALATION_FIRST_RUN_DIALOG = "first-run-dialog"
|
|
1301
|
+
|
|
1261
1302
|
# The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
|
|
1262
1303
|
# rows from ever colliding with the issue edge's `cap` when an issue and a
|
|
1263
1304
|
# PR share a number; the epoch suffix is maintain_cap_kind's job below.
|
|
@@ -1614,6 +1655,17 @@ def wedge_kind(attempt: int) -> str:
|
|
|
1614
1655
|
return f"{ESCALATION_WEDGED}:a{attempt}"
|
|
1615
1656
|
|
|
1616
1657
|
|
|
1658
|
+
def first_run_dialog_kind(attempt: int) -> str:
|
|
1659
|
+
"""The ledger kind for ONE first-run-dialog wedge episode (issue #123).
|
|
1660
|
+
|
|
1661
|
+
wedge_kind's episode reasoning: every attempt can meet the dialog (a
|
|
1662
|
+
hub trusted after attempt k spawned is trusted for attempt k+1, but a
|
|
1663
|
+
seeding that failed to land leaves the next attempt on the same prompt),
|
|
1664
|
+
and attempt k's row must not silence attempt k+1's WARNING.
|
|
1665
|
+
"""
|
|
1666
|
+
return f"{ESCALATION_FIRST_RUN_DIALOG}:a{attempt}"
|
|
1667
|
+
|
|
1668
|
+
|
|
1617
1669
|
def deferral_activity_kind(session: str) -> str:
|
|
1618
1670
|
"""The activity-log kind that dedupes ONE capacity-deferral episode's line.
|
|
1619
1671
|
|
|
@@ -2408,6 +2460,12 @@ MANAGED_SESSION_PREFIX = "ali-"
|
|
|
2408
2460
|
# pane said "Nothing left -- the review task is done.").
|
|
2409
2461
|
FOREIGN_PANE_TAIL_LINES = 20
|
|
2410
2462
|
|
|
2463
|
+
# How many pane lines the first-run-dialog check reads (issue #123). The
|
|
2464
|
+
# trust dialog is a ~12-line box and the bypass-permissions gate ~20; a
|
|
2465
|
+
# session sitting on either has printed nothing since, so the last 40 lines
|
|
2466
|
+
# hold the whole prompt with room for the banner above it.
|
|
2467
|
+
FIRST_RUN_PANE_TAIL_LINES = 40
|
|
2468
|
+
|
|
2411
2469
|
# NOTE on where that capture goes: NOT onto the operator-facing line. The tail
|
|
2412
2470
|
# is untrusted third-party terminal content and the console serves the log over
|
|
2413
2471
|
# HTTP, so only its SHAPE (a line count) reaches WARNING and the transcript
|
|
@@ -2860,6 +2918,7 @@ class DevWatcher:
|
|
|
2860
2918
|
# terminal one.
|
|
2861
2919
|
resume_pr: "PullRequest | None" = None
|
|
2862
2920
|
dead_session: "str | None" = None
|
|
2921
|
+
wedged_dialog = False
|
|
2863
2922
|
if age is not None:
|
|
2864
2923
|
# `exhaustive` only when a resume could actually fire: the probe's
|
|
2865
2924
|
# default early break answers the boolean the deferral needs, but
|
|
@@ -2983,6 +3042,25 @@ class DevWatcher:
|
|
|
2983
3042
|
# successful listing without this name) -- so the retry below
|
|
2984
3043
|
# can name the session it proved gone instead of presuming.
|
|
2985
3044
|
dead_session = live_session
|
|
3045
|
+
if liveness is Liveness.ALIVE:
|
|
3046
|
+
# Issue #123: ALIVE is a process, not a worker. A session
|
|
3047
|
+
# sitting on Claude Code's first-run "trust this folder?"
|
|
3048
|
+
# (or bypass-permissions) gate is listed, idle, and will
|
|
3049
|
+
# never publish anything -- the directive typed into the
|
|
3050
|
+
# pane was swallowed by the prompt. Only on ALIVE (a
|
|
3051
|
+
# successful listing naming the row), and only when the
|
|
3052
|
+
# pane SHOWS the gate: every other alive-but-idle case
|
|
3053
|
+
# keeps the floored defer below, exactly as before.
|
|
3054
|
+
if self._wedged_on_first_run_dialog(
|
|
3055
|
+
issue, attempts, live_session
|
|
3056
|
+
):
|
|
3057
|
+
wedged_dialog = True
|
|
3058
|
+
# The kill landed and the hub is seeded: fall through
|
|
3059
|
+
# to the cap check and the re-enqueue below as the
|
|
3060
|
+
# CONFIRMED-dead path would -- one WARNING for the
|
|
3061
|
+
# episode was logged by the classifier, so the
|
|
3062
|
+
# respawn's own line is suppressed.
|
|
3063
|
+
liveness = Liveness.DEAD
|
|
2986
3064
|
if liveness is not Liveness.DEAD:
|
|
2987
3065
|
# Floored exactly like the linked-PR deferral, and needing
|
|
2988
3066
|
# it for the same reason: this sits ABOVE the cap check,
|
|
@@ -3036,7 +3114,7 @@ class DevWatcher:
|
|
|
3036
3114
|
dead_session,
|
|
3037
3115
|
attempts + 1,
|
|
3038
3116
|
)
|
|
3039
|
-
elif age is not None:
|
|
3117
|
+
elif age is not None and not wedged_dialog:
|
|
3040
3118
|
log.warning(
|
|
3041
3119
|
"%s attempt %d has been in flight %.0f min with no completion "
|
|
3042
3120
|
"and no linked open PR, and its session %s is absent from the "
|
|
@@ -3581,6 +3659,132 @@ class DevWatcher:
|
|
|
3581
3659
|
return None
|
|
3582
3660
|
return min(youngest, max(0.0, now - committed))
|
|
3583
3661
|
|
|
3662
|
+
def _wedged_on_first_run_dialog(
|
|
3663
|
+
self, issue: Issue, attempts: int, session: str
|
|
3664
|
+
) -> bool:
|
|
3665
|
+
"""Issue #123: is this stale, ALIVE, PR-less session sitting on one of
|
|
3666
|
+
Claude Code's first-run gates -- and if so, clear the lane.
|
|
3667
|
+
|
|
3668
|
+
Reads the pane (`alissa tmux tail`, the reaper's own evidence seam)
|
|
3669
|
+
and asks `hubs.pane_shows_first_run_dialog` whether the session is
|
|
3670
|
+
PARKED on a gate -- the gate's accept option among the last lines
|
|
3671
|
+
with nothing but the gate's own chrome below it, its question
|
|
3672
|
+
above -- not merely whether the gate's words appear: this repo's
|
|
3673
|
+
own README, CHANGELOG, `hubs.py` and issue #123 quote them, so a
|
|
3674
|
+
session that cats or diffs any of them has them on screen while at
|
|
3675
|
+
work, and a kill here lands on the lane's own worktree. A pane not
|
|
3676
|
+
parked on a gate, or one the CLI could not capture, answers False
|
|
3677
|
+
and the caller keeps the floored defer it always took (runbook
|
|
3678
|
+
caveat C7's other alive-but-idle causes -- expired OAuth, usage
|
|
3679
|
+
limits, any other permission prompt -- are deliberately NOT
|
|
3680
|
+
classified here).
|
|
3681
|
+
|
|
3682
|
+
A match is `wedged:first-run-dialog`: ONE WARNING per episode (keyed
|
|
3683
|
+
first_run_dialog_kind(attempts) in the escalations ledger; a kill
|
|
3684
|
+
that fails and is retried next poll logs at INFO), then the row's
|
|
3685
|
+
own session is killed (`alissa tmux kill <name>`, never a sweep),
|
|
3686
|
+
the hub it started in is trusted (root, main/, worktrees -- the very
|
|
3687
|
+
entries the gate was asking for, so the retry does not meet it
|
|
3688
|
+
again), one activity-comment line records the act, and True tells
|
|
3689
|
+
the caller to re-enqueue as attempt k+1 NOW, counting toward
|
|
3690
|
+
attempt_cap like any stale retry -- and on the TERMINAL attempt the
|
|
3691
|
+
caller's cap check refuses that re-enqueue and pages the operator
|
|
3692
|
+
instead, which is what the WARNING, the activity line and the
|
|
3693
|
+
dry-run line say when `attempts >= attempt_cap`, rather than
|
|
3694
|
+
promising a retry the lane will not get. Dry-run classifies and logs but
|
|
3695
|
+
kills nothing and answers False (the defer holds). A kill that
|
|
3696
|
+
fails answers False too: the defer holds and the next poll, reading
|
|
3697
|
+
the same pane, tries again -- the remedy for a session that will not
|
|
3698
|
+
die, not a loop to guard against.
|
|
3699
|
+
|
|
3700
|
+
The pane is untrusted third-party terminal content: only its
|
|
3701
|
+
CLASSIFICATION reaches the log (see `_foreign_pane_evidence` for why
|
|
3702
|
+
the capture itself stays at DEBUG)."""
|
|
3703
|
+
pane = self.alissa.tail_session(session, FIRST_RUN_PANE_TAIL_LINES)
|
|
3704
|
+
if not pane_shows_first_run_dialog(pane):
|
|
3705
|
+
return False
|
|
3706
|
+
log.debug(
|
|
3707
|
+
"%s: last %d pane lines of %s:\n%s",
|
|
3708
|
+
WEDGE_FIRST_RUN_DIALOG, FIRST_RUN_PANE_TAIL_LINES, session, pane,
|
|
3709
|
+
)
|
|
3710
|
+
hub = self.config.hub_for(issue.owner, issue.repo)
|
|
3711
|
+
kind = first_run_dialog_kind(attempts)
|
|
3712
|
+
# What the caller does with a True: the cap check sits right after
|
|
3713
|
+
# this probe, so the terminal attempt caps out instead of retrying.
|
|
3714
|
+
capping = attempts >= self.config.attempt_cap
|
|
3715
|
+
if capping:
|
|
3716
|
+
outcome = (
|
|
3717
|
+
f"NOT re-enqueuing: attempt {attempts} was the last "
|
|
3718
|
+
f"(attempt_cap {self.config.attempt_cap}), so the lane caps "
|
|
3719
|
+
f"out and pages the operator"
|
|
3720
|
+
)
|
|
3721
|
+
activity_outcome = (
|
|
3722
|
+
f"attempt_cap {self.config.attempt_cap} reached — the lane "
|
|
3723
|
+
f"caps out and pages the operator instead of re-enqueuing"
|
|
3724
|
+
)
|
|
3725
|
+
else:
|
|
3726
|
+
outcome = (
|
|
3727
|
+
f"re-enqueuing as attempt {attempts + 1} (counting toward "
|
|
3728
|
+
f"attempt_cap {self.config.attempt_cap})"
|
|
3729
|
+
)
|
|
3730
|
+
activity_outcome = f"re-enqueued as attempt {attempts + 1}"
|
|
3731
|
+
if self.state.escalated(issue.full_name, issue.number, kind):
|
|
3732
|
+
log.info(
|
|
3733
|
+
"%s: session %s (%s attempt %d) is still on the claude "
|
|
3734
|
+
"first-run dialog — retrying the kill",
|
|
3735
|
+
WEDGE_FIRST_RUN_DIALOG, session, issue.issue_slug, attempts,
|
|
3736
|
+
)
|
|
3737
|
+
else:
|
|
3738
|
+
log.warning(
|
|
3739
|
+
"%s: session %s (%s attempt %d) reads alive but its pane shows "
|
|
3740
|
+
"claude's first-run dialog (%s) — the directive was swallowed "
|
|
3741
|
+
"by the prompt and nothing will ever be published. Killing "
|
|
3742
|
+
"it, pre-trusting %s (root, main/, TASK-* worktrees) for "
|
|
3743
|
+
"claude, and %s. Operator lever: `alissa tmux tail <session>` "
|
|
3744
|
+
"to see the prompt, `tmux send-keys Down Enter` to accept it "
|
|
3745
|
+
"by hand, or kill + seed as the daemon does",
|
|
3746
|
+
WEDGE_FIRST_RUN_DIALOG, session, issue.issue_slug, attempts,
|
|
3747
|
+
" / ".join(repr(m) for m in FIRST_RUN_DIALOG_MARKERS), hub,
|
|
3748
|
+
outcome,
|
|
3749
|
+
)
|
|
3750
|
+
self.state.record_escalation(issue.full_name, issue.number, kind)
|
|
3751
|
+
if self.config.dry_run:
|
|
3752
|
+
log.info(
|
|
3753
|
+
"[dry-run] would kill %s, pre-trust %s and %s",
|
|
3754
|
+
session, hub,
|
|
3755
|
+
f"cap out (attempt_cap {self.config.attempt_cap})" if capping
|
|
3756
|
+
else f"re-enqueue attempt {attempts + 1}",
|
|
3757
|
+
)
|
|
3758
|
+
return False
|
|
3759
|
+
try:
|
|
3760
|
+
self.alissa.kill_session(session)
|
|
3761
|
+
except CommandError as exc:
|
|
3762
|
+
log.warning(
|
|
3763
|
+
"%s: could not kill %s (%s) — kills are best-effort; the lane "
|
|
3764
|
+
"keeps its defer and the kill is retried next poll",
|
|
3765
|
+
WEDGE_FIRST_RUN_DIALOG, session, exc,
|
|
3766
|
+
)
|
|
3767
|
+
return False
|
|
3768
|
+
self._trust_hub(hub, WEDGE_FIRST_RUN_DIALOG)
|
|
3769
|
+
if not self.state.activity_logged(issue.full_name, issue.number, kind):
|
|
3770
|
+
landed = self._append_activity(
|
|
3771
|
+
issue.owner,
|
|
3772
|
+
issue.repo,
|
|
3773
|
+
issue.number,
|
|
3774
|
+
self._activity_line(
|
|
3775
|
+
session,
|
|
3776
|
+
"issue edge",
|
|
3777
|
+
f"{ACTIVITY_WEDGE_KILLED} — {WEDGE_FIRST_RUN_DIALOG}: the "
|
|
3778
|
+
f"session read alive but its pane sat on claude's first-run "
|
|
3779
|
+
f"dialog (the hub was not pre-trusted); killed, hub {hub} "
|
|
3780
|
+
f"pre-trusted, {activity_outcome}",
|
|
3781
|
+
),
|
|
3782
|
+
issue.issue_slug,
|
|
3783
|
+
)
|
|
3784
|
+
if landed:
|
|
3785
|
+
self.state.record_activity(issue.full_name, issue.number, kind)
|
|
3786
|
+
return True
|
|
3787
|
+
|
|
3584
3788
|
def _kill_wedged(
|
|
3585
3789
|
self,
|
|
3586
3790
|
issue: Issue,
|
|
@@ -6560,6 +6764,12 @@ class DevWatcher:
|
|
|
6560
6764
|
full_name = f"{owner}/{repo}"
|
|
6561
6765
|
hub = self.config.hub_for(owner, repo)
|
|
6562
6766
|
if hub.is_dir():
|
|
6767
|
+
# An existing hub may have grown TASK-* worktrees since boot (a
|
|
6768
|
+
# session creates its own), and a hub that appeared between
|
|
6769
|
+
# boots was never seeded by the entrypoint at all: trust is
|
|
6770
|
+
# re-asserted before EVERY spawn. Idempotent and cheap -- a
|
|
6771
|
+
# state file already carrying every path is not rewritten.
|
|
6772
|
+
self._trust_hub(hub, "before spawn")
|
|
6563
6773
|
return hub, None
|
|
6564
6774
|
|
|
6565
6775
|
if self.config.on_missing_hub != HUB_ADD:
|
|
@@ -6600,8 +6810,39 @@ class DevWatcher:
|
|
|
6600
6810
|
f"success but {hub} still does not exist — check hub_template "
|
|
6601
6811
|
f"against the manifest's `dir:` override"
|
|
6602
6812
|
)
|
|
6813
|
+
# Issue #123: the entrypoint pre-trusts only the hubs it can name at
|
|
6814
|
+
# boot, and under `repos_source: bows` a hub created here was never
|
|
6815
|
+
# among them -- the first session on it hung on Claude Code's "trust
|
|
6816
|
+
# this folder?" dialog for 108 minutes (ali-fhr/docs.alissa.app#10).
|
|
6817
|
+
# Seed the hub root (the spawn cwd), main/ and any worktree BEFORE
|
|
6818
|
+
# the session is enqueued.
|
|
6819
|
+
self._trust_hub(hub, "hub-ified")
|
|
6603
6820
|
return hub, None
|
|
6604
6821
|
|
|
6822
|
+
def _trust_hub(self, hub: Path, why: str) -> "list[Path]":
|
|
6823
|
+
"""Pre-accept Claude Code's trust dialog for every directory a
|
|
6824
|
+
session on this hub may start in (see `hubs.hub_trust_paths`), in
|
|
6825
|
+
both claude state files. Returns the state files that changed.
|
|
6826
|
+
Dry-run seeds nothing. Best-effort by construction (`seed_trust`
|
|
6827
|
+
never raises): a session can still be spawned into an untrusted hub
|
|
6828
|
+
-- it just may wedge on the dialog, which the stale-session branch
|
|
6829
|
+
now detects -- so a failed seed must never cost the spawn."""
|
|
6830
|
+
paths = hub_trust_paths(hub)
|
|
6831
|
+
if self.config.dry_run:
|
|
6832
|
+
log.info(
|
|
6833
|
+
"[dry-run] would pre-trust %d claude dir(s) for hub %s (%s)",
|
|
6834
|
+
len(paths), hub, why,
|
|
6835
|
+
)
|
|
6836
|
+
return []
|
|
6837
|
+
changed = seed_trust(paths)
|
|
6838
|
+
if changed:
|
|
6839
|
+
log.info(
|
|
6840
|
+
"trusted hub %s for claude (%s): %d dir(s) — %s — written to %s",
|
|
6841
|
+
hub, why, len(paths), ", ".join(str(p) for p in paths),
|
|
6842
|
+
", ".join(str(t) for t in changed),
|
|
6843
|
+
)
|
|
6844
|
+
return changed
|
|
6845
|
+
|
|
6605
6846
|
# -- the derived allowlist (repos_source: "bows") ----------------------
|
|
6606
6847
|
|
|
6607
6848
|
def _apply_repos(self, repos: "tuple[str, ...]") -> None:
|
|
@@ -6692,6 +6933,36 @@ class DevWatcher:
|
|
|
6692
6933
|
)
|
|
6693
6934
|
return
|
|
6694
6935
|
self._apply_repos(source.repos())
|
|
6936
|
+
self._record_derived(source.derived)
|
|
6937
|
+
|
|
6938
|
+
def _record_derived(self, derived: "tuple[str, ...]") -> None:
|
|
6939
|
+
"""Issue #123: make the derived allowlist visible to the entrypoint's
|
|
6940
|
+
boot-time trust seeding (`{root}/.alissa-derived-repos`) and trust
|
|
6941
|
+
the derived hubs NOW -- hub-ified or not, exactly as the entrypoint
|
|
6942
|
+
trusts a static allowlist's hubs before they exist. Both are
|
|
6943
|
+
best-effort and idempotent; dry-run writes nothing."""
|
|
6944
|
+
if not derived:
|
|
6945
|
+
return
|
|
6946
|
+
if self.config.dry_run:
|
|
6947
|
+
log.info(
|
|
6948
|
+
"[dry-run] would record %d derived repo(s) and pre-trust "
|
|
6949
|
+
"their hubs", len(derived),
|
|
6950
|
+
)
|
|
6951
|
+
return
|
|
6952
|
+
write_derived_repos(self.config.workspace_root, derived)
|
|
6953
|
+
paths: "list[Path]" = []
|
|
6954
|
+
for full_name in derived:
|
|
6955
|
+
owner, _, repo = full_name.partition("/")
|
|
6956
|
+
if not owner or not repo:
|
|
6957
|
+
continue
|
|
6958
|
+
paths.extend(hub_trust_paths(self.config.hub_for(owner, repo)))
|
|
6959
|
+
changed = seed_trust(paths)
|
|
6960
|
+
if changed:
|
|
6961
|
+
log.info(
|
|
6962
|
+
"repos_source=bows: pre-trusted the hubs of %d derived "
|
|
6963
|
+
"repo(s) for claude in %s",
|
|
6964
|
+
len(derived), ", ".join(str(t) for t in changed),
|
|
6965
|
+
)
|
|
6695
6966
|
|
|
6696
6967
|
# -- preflight ---------------------------------------------------------
|
|
6697
6968
|
|
|
@@ -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:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.25
|
|
@@ -12,6 +12,7 @@ src/main/alissa/tools/github/devloop/bows.py
|
|
|
12
12
|
src/main/alissa/tools/github/devloop/config.py
|
|
13
13
|
src/main/alissa/tools/github/devloop/fleet_vitals.py
|
|
14
14
|
src/main/alissa/tools/github/devloop/ghclient.py
|
|
15
|
+
src/main/alissa/tools/github/devloop/hubs.py
|
|
15
16
|
src/main/alissa/tools/github/devloop/janitor.py
|
|
16
17
|
src/main/alissa/tools/github/devloop/loop.py
|
|
17
18
|
src/main/alissa/tools/github/devloop/loop_events.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.23
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|