alissa-tools-github-devloop 0.8.25__tar.gz → 0.8.27__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.25/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.27}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/__main__.py +39 -1
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/alissa.py +147 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/config.py +152 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/fleet_vitals.py +2 -1
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/ghclient.py +251 -5
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/loop.py +1424 -29
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/loop_events.py +75 -2
- alissa_tools_github_devloop-0.8.27/src/main/alissa/tools/github/devloop/prompts.py +761 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/state.py +248 -7
- alissa_tools_github_devloop-0.8.27/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/page.py +60 -1
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/server.py +108 -1
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/sources.py +67 -6
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_devloop-0.8.25/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -194,6 +194,40 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
194
194
|
"sessions, even if the config enables it",
|
|
195
195
|
)
|
|
196
196
|
|
|
197
|
+
over.add_argument(
|
|
198
|
+
"--revert-label",
|
|
199
|
+
metavar="LABEL",
|
|
200
|
+
help="label an operator applies to a MERGED PR to request a revert "
|
|
201
|
+
"session (a `TASK-<n>-REVERT-PR<k>` branch reverting the merge "
|
|
202
|
+
"commit, opened as a ready PR); the session removes the label on "
|
|
203
|
+
"completion. Env ALISSA_REVERT_LABEL (non-blank) wins over this flag",
|
|
204
|
+
)
|
|
205
|
+
revert = over.add_mutually_exclusive_group()
|
|
206
|
+
revert.add_argument(
|
|
207
|
+
"--revert",
|
|
208
|
+
dest="revert_enabled",
|
|
209
|
+
action="store_true",
|
|
210
|
+
default=None,
|
|
211
|
+
help="poll merged PRs carrying the revert label and spawn revert "
|
|
212
|
+
"sessions (the default)",
|
|
213
|
+
)
|
|
214
|
+
revert.add_argument(
|
|
215
|
+
"--no-revert",
|
|
216
|
+
dest="revert_enabled",
|
|
217
|
+
action="store_false",
|
|
218
|
+
help="never search for the revert label or spawn revert sessions, "
|
|
219
|
+
"even if the config enables it",
|
|
220
|
+
)
|
|
221
|
+
over.add_argument(
|
|
222
|
+
"--revert-any-author",
|
|
223
|
+
dest="revert_any_author",
|
|
224
|
+
action="store_true",
|
|
225
|
+
default=None,
|
|
226
|
+
help="let the revert lever act on merged PRs by ANY author, not only "
|
|
227
|
+
"the daemon's own (default off: a foreign-authored PR is refused "
|
|
228
|
+
"with a comment and unlabelled)",
|
|
229
|
+
)
|
|
230
|
+
|
|
197
231
|
resume = over.add_mutually_exclusive_group()
|
|
198
232
|
resume.add_argument(
|
|
199
233
|
"--resume",
|
|
@@ -431,6 +465,9 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
431
465
|
"fix_rounds_enabled": args.fix_rounds_enabled,
|
|
432
466
|
"maintain_label": args.maintain_label,
|
|
433
467
|
"maintain_enabled": args.maintain_enabled,
|
|
468
|
+
"revert_label": args.revert_label,
|
|
469
|
+
"revert_enabled": args.revert_enabled,
|
|
470
|
+
"revert_any_author": args.revert_any_author,
|
|
434
471
|
"resume_enabled": args.resume_enabled,
|
|
435
472
|
"wedge_minutes": args.wedge_minutes,
|
|
436
473
|
"rerequest_enabled": args.rerequest_enabled,
|
|
@@ -532,7 +569,7 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
532
569
|
log.info(
|
|
533
570
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
534
571
|
"max_sessions=%s; reap_grace=%s min; fix_rounds=%s; maintain=%s "
|
|
535
|
-
"(label %r); resume=%s; "
|
|
572
|
+
"(label %r); revert=%s (label %r, any_author=%s); resume=%s; "
|
|
536
573
|
"rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
|
|
537
574
|
"activity_claims=%s; janitor=%s (grace %s min, every %s min, cache "
|
|
538
575
|
"cap %s MiB, warn at %s%%)",
|
|
@@ -542,6 +579,7 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
542
579
|
config.reap_grace_seconds // 60,
|
|
543
580
|
config.fix_rounds_enabled,
|
|
544
581
|
config.maintain_enabled, config.maintain_label,
|
|
582
|
+
config.revert_enabled, config.revert_label, config.revert_any_author,
|
|
545
583
|
config.resume_enabled,
|
|
546
584
|
config.rerequest_enabled,
|
|
547
585
|
config.rerequest_grace_seconds // 60,
|
|
@@ -8,12 +8,93 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
|
|
13
15
|
from .proc import CommandError, run
|
|
16
|
+
from .prompts import ALLOWED_KEYS
|
|
14
17
|
|
|
15
18
|
log = logging.getLogger(__name__)
|
|
16
19
|
|
|
20
|
+
# The prefix `alissa tmux` gives every session it manages: the roster's
|
|
21
|
+
# `name` is the MANAGED name (`develop-…`, what `alissa tmux kill` takes) and
|
|
22
|
+
# the real tmux session is that name under this prefix (`ali-develop-…`).
|
|
23
|
+
# The raw-tmux surface below targets the REAL session, so a managed name is
|
|
24
|
+
# prefixed and a name already carrying the prefix is used as is.
|
|
25
|
+
MANAGED_PREFIX = "ali-"
|
|
26
|
+
|
|
27
|
+
# The console's session-name rule (webui.server._SAFE_SESSION), stated once
|
|
28
|
+
# where the raw tmux argv is built: starts with a letter/digit/dot/underscore,
|
|
29
|
+
# carries only those and `-`, bounded length. Validating here makes the argv
|
|
30
|
+
# impossible to weaponise -- a `-flag`-shaped name or one with a space,
|
|
31
|
+
# `;` or `/` never reaches tmux -- and the console re-checks the same rule.
|
|
32
|
+
SAFE_SESSION = re.compile(r"\A[A-Za-z0-9._][A-Za-z0-9._-]{0,199}\Z")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tmux_socket() -> str:
|
|
36
|
+
"""The path of the tmux server socket the worker sessions live on:
|
|
37
|
+
`$TMUX_TMPDIR/tmux-<uid>/default` (tmux's own default). Spelled out
|
|
38
|
+
rather than left to tmux because the daemon may run without `TMUX` in
|
|
39
|
+
its environment and must still reach the SAME server `alissa tmux new`
|
|
40
|
+
created for the workers -- same container, same user, same socket."""
|
|
41
|
+
base = os.environ.get("TMUX_TMPDIR", "").strip() or "/tmp"
|
|
42
|
+
return os.path.join(base, f"tmux-{os.getuid()}", "default")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def tmux_target(name: str) -> str:
|
|
46
|
+
"""The `-t` target for a managed session: the real tmux name, prefixed
|
|
47
|
+
with `=` so tmux matches it EXACTLY (its default is prefix matching, and
|
|
48
|
+
`develop-acme-widgets-i7-a1` is a prefix of `…-a10`) and suffixed with
|
|
49
|
+
`:` so the target is the session's CURRENT WINDOW (and its active pane)
|
|
50
|
+
-- a bare `=name` is a session target, which `capture-pane` and
|
|
51
|
+
`send-keys` refuse with "can't find pane" (tmux 3.3a)."""
|
|
52
|
+
real = name if name.startswith(MANAGED_PREFIX) else MANAGED_PREFIX + name
|
|
53
|
+
return "=" + real + ":"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_session_name(name: "str | None") -> str:
|
|
57
|
+
"""`name` when it passes SAFE_SESSION, else ValueError. Every raw tmux
|
|
58
|
+
argv builder calls this first."""
|
|
59
|
+
if not name or not SAFE_SESSION.match(name):
|
|
60
|
+
raise ValueError(f"invalid session name {name!r}")
|
|
61
|
+
return name
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def capture_pane_argv(name: str, lines: int) -> "list[str]":
|
|
65
|
+
"""`tmux -S <socket> capture-pane -p -t =<real> -S -<lines>`: the last
|
|
66
|
+
`lines` lines of the session's active pane, printed to stdout, with
|
|
67
|
+
escape sequences stripped (`-p` without `-e`)."""
|
|
68
|
+
check_session_name(name)
|
|
69
|
+
return [
|
|
70
|
+
"tmux", "-S", tmux_socket(), "capture-pane", "-p",
|
|
71
|
+
"-t", tmux_target(name), "-S", f"-{max(1, int(lines))}",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def pane_path_argv(name: str) -> "list[str]":
|
|
76
|
+
check_session_name(name)
|
|
77
|
+
return [
|
|
78
|
+
"tmux", "-S", tmux_socket(), "display-message", "-p",
|
|
79
|
+
"-t", tmux_target(name), "#{pane_current_path}",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
|
|
84
|
+
"""`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
|
|
85
|
+
from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
|
|
86
|
+
is a ValueError before any process is spawned: the responder presses a
|
|
87
|
+
dialog's buttons and can never type into a worker."""
|
|
88
|
+
check_session_name(name)
|
|
89
|
+
if not keys:
|
|
90
|
+
raise ValueError("send_keys needs at least one key")
|
|
91
|
+
bad = [k for k in keys if k not in ALLOWED_KEYS]
|
|
92
|
+
if bad:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"key(s) not in the allowlist {sorted(ALLOWED_KEYS)}: {bad!r}"
|
|
95
|
+
)
|
|
96
|
+
return ["tmux", "-S", tmux_socket(), "send-keys", "-t", tmux_target(name), *keys]
|
|
97
|
+
|
|
17
98
|
|
|
18
99
|
class Alissa:
|
|
19
100
|
def enqueue_developer(
|
|
@@ -149,6 +230,72 @@ class Alissa:
|
|
|
149
230
|
`tmux kill`); the reaper treats kills as best-effort."""
|
|
150
231
|
run(["alissa", "tmux", "kill", name], timeout=60)
|
|
151
232
|
|
|
233
|
+
def capture_pane(self, name: str, lines: int = 40) -> str:
|
|
234
|
+
"""The last `lines` of ONE managed session's pane over raw tmux
|
|
235
|
+
(`capture-pane -p`), or "" when tmux cannot answer (no such session,
|
|
236
|
+
the socket gone, a timeout).
|
|
237
|
+
|
|
238
|
+
The responder's evidence seam (issue #127). Like `tail_session` it
|
|
239
|
+
returns UNTRUSTED, possibly sensitive terminal content: the caller
|
|
240
|
+
classifies it and lets only the classification -- plus a scrubbed
|
|
241
|
+
signature line -- reach an operator-facing level. A READ, so it runs
|
|
242
|
+
under dry-run too: a dry pass has to classify what a live one would.
|
|
243
|
+
Raw tmux rather than the `alissa` CLI because the CLI's `tail` is a
|
|
244
|
+
convenience without `send-keys` beside it, and the answer has to land
|
|
245
|
+
on the very pane the capture read."""
|
|
246
|
+
try:
|
|
247
|
+
argv = capture_pane_argv(name, lines)
|
|
248
|
+
except ValueError as exc:
|
|
249
|
+
log.warning("capture_pane refused: %s", exc)
|
|
250
|
+
return ""
|
|
251
|
+
try:
|
|
252
|
+
out = run(argv, timeout=15)
|
|
253
|
+
except CommandError as exc:
|
|
254
|
+
log.debug("could not capture the pane of %s (%s)", name, exc)
|
|
255
|
+
return ""
|
|
256
|
+
return out.rstrip("\n")
|
|
257
|
+
|
|
258
|
+
def pane_path(self, name: str) -> str:
|
|
259
|
+
"""The session's pane current path (`#{pane_current_path}`), or ""
|
|
260
|
+
when tmux cannot answer. One leg of the lane's worktree resolution:
|
|
261
|
+
a worker that `cd`ed into its `TASK-*` worktree names it here."""
|
|
262
|
+
try:
|
|
263
|
+
argv = pane_path_argv(name)
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
log.warning("pane_path refused: %s", exc)
|
|
266
|
+
return ""
|
|
267
|
+
try:
|
|
268
|
+
return run(argv, timeout=15).strip()
|
|
269
|
+
except CommandError as exc:
|
|
270
|
+
log.debug("could not read the pane path of %s (%s)", name, exc)
|
|
271
|
+
return ""
|
|
272
|
+
|
|
273
|
+
def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
|
|
274
|
+
"""Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
|
|
275
|
+
-- on ONE managed session's pane. True when tmux accepted them.
|
|
276
|
+
|
|
277
|
+
The ONLY key-pressing surface in the fleet, and deliberately narrow:
|
|
278
|
+
an allowlisted enum, a validated session name, an exact-match target,
|
|
279
|
+
one `send-keys` per call. A key outside the allowlist is refused
|
|
280
|
+
before any process is spawned and logged at WARNING (it is a
|
|
281
|
+
programming error, never a pane's doing -- keys come from the
|
|
282
|
+
policy table, not from the screen). Dry-run logs the intent and
|
|
283
|
+
sends nothing."""
|
|
284
|
+
try:
|
|
285
|
+
argv = send_keys_argv(name, tuple(keys))
|
|
286
|
+
except ValueError as exc:
|
|
287
|
+
log.warning("send_keys refused for %s: %s", name, exc)
|
|
288
|
+
return False
|
|
289
|
+
if dry_run:
|
|
290
|
+
log.info("[dry-run] would send keys %s to %s", " ".join(keys), name)
|
|
291
|
+
return False
|
|
292
|
+
try:
|
|
293
|
+
run(argv, timeout=15)
|
|
294
|
+
except CommandError as exc:
|
|
295
|
+
log.warning("could not send keys %s to %s (%s)", " ".join(keys), name, exc)
|
|
296
|
+
return False
|
|
297
|
+
return True
|
|
298
|
+
|
|
152
299
|
def worker_running(self) -> bool:
|
|
153
300
|
"""The queue only drains while `alissa worker` reconciles it."""
|
|
154
301
|
try:
|
|
@@ -24,6 +24,8 @@ through to the config rather than fail the daemon's boot on a mode of `''`.
|
|
|
24
24
|
`loop_events_enabled` rides the same shape of rail through
|
|
25
25
|
`ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule, and
|
|
26
26
|
`fleet_vitals_enabled` through `ALISSA_DEV_FLEET_VITALS_ENABLED` likewise.
|
|
27
|
+
`revert_label` rides `ALISSA_REVERT_LABEL` the same way (issue #131): a
|
|
28
|
+
non-blank value wins over the CLI and the file, a blank one falls through.
|
|
27
29
|
"""
|
|
28
30
|
|
|
29
31
|
from __future__ import annotations
|
|
@@ -108,6 +110,25 @@ ENV_LOOP_EVENTS = "ALISSA_DEV_LOOP_EVENTS_ENABLED"
|
|
|
108
110
|
# non-blank-wins / blank-falls-through rule, same config error on a typo.
|
|
109
111
|
ENV_FLEET_VITALS = "ALISSA_DEV_FLEET_VITALS_ENABLED"
|
|
110
112
|
|
|
113
|
+
# The revert lever's label rail (issue #131): `revert_label` ridden the way
|
|
114
|
+
# ALISSA_DEV_REPOS_SOURCE rides repos_source -- a NON-BLANK value wins over
|
|
115
|
+
# the CLI and the file, a blank one falls through. Spelled as the issue names
|
|
116
|
+
# it (no `_DEV_` infix), because that is the name the operator runbook gives.
|
|
117
|
+
ENV_REVERT_LABEL = "ALISSA_REVERT_LABEL"
|
|
118
|
+
|
|
119
|
+
# The prompt responder's master switch (issue #127): `on` answers, `observe`
|
|
120
|
+
# classifies and narrates but sends nothing, `off` never captures a pane.
|
|
121
|
+
PROMPT_ON = "on"
|
|
122
|
+
PROMPT_OBSERVE = "observe"
|
|
123
|
+
PROMPT_OFF = "off"
|
|
124
|
+
_PROMPT_MODES = frozenset((PROMPT_ON, PROMPT_OBSERVE, PROMPT_OFF))
|
|
125
|
+
|
|
126
|
+
# Where lane W1's hooks leave the "this session is waiting for input" marker
|
|
127
|
+
# (`docker/claude/hooks/note-waiting.py`): `${ALISSA_WAITING_DIR}/<tmux
|
|
128
|
+
# session>.json`. The daemon reads the same directory, so the two halves
|
|
129
|
+
# agree by construction on a default deployment.
|
|
130
|
+
DEFAULT_WAITING_DIR = "/workspace/.waiting"
|
|
131
|
+
|
|
111
132
|
_ENV_BOOL_TRUE = frozenset(("1", "true"))
|
|
112
133
|
_ENV_BOOL_FALSE = frozenset(("0", "false"))
|
|
113
134
|
|
|
@@ -179,8 +200,16 @@ CONFIG_KEYS = (
|
|
|
179
200
|
"fix_rounds_enabled",
|
|
180
201
|
"maintain_label",
|
|
181
202
|
"maintain_enabled",
|
|
203
|
+
"revert_label",
|
|
204
|
+
"revert_enabled",
|
|
205
|
+
"revert_any_author",
|
|
182
206
|
"resume_enabled",
|
|
183
207
|
"wedge_minutes",
|
|
208
|
+
"prompt_responder",
|
|
209
|
+
"prompt_quiet_seconds",
|
|
210
|
+
"prompt_kill_minutes",
|
|
211
|
+
"prompt_max_answers",
|
|
212
|
+
"waiting_dir",
|
|
184
213
|
"rerequest_enabled",
|
|
185
214
|
"rerequest_grace_minutes",
|
|
186
215
|
"orphan_sweep_enabled",
|
|
@@ -469,6 +498,20 @@ class Config:
|
|
|
469
498
|
maintain_label: str = "alissa:maintain"
|
|
470
499
|
maintain_enabled: bool = True
|
|
471
500
|
|
|
501
|
+
# The revert lever (issue #131, the fourth polled edge): an operator
|
|
502
|
+
# labels a MERGED PR `revert_label` to request a session that reverts its
|
|
503
|
+
# merge commit on a fresh `TASK-<n>-REVERT-PR<k>` branch and opens the
|
|
504
|
+
# revert PR ready for review. One spawn per label application (the ledger
|
|
505
|
+
# keys on the labeled-at instant), no stale retry -- the operator's retry
|
|
506
|
+
# is unlabel + re-label. `revert_any_author` widens the lever past the
|
|
507
|
+
# loop's own PRs: off by default in `merge_author_logins`' spirit, because
|
|
508
|
+
# the daemon reverting a human's merge is a decision a human should make
|
|
509
|
+
# explicitly, in config, not by applying a label. Default ON like the
|
|
510
|
+
# maintenance edge, and every side effect still honors dry_run.
|
|
511
|
+
revert_label: str = "alissa:revert"
|
|
512
|
+
revert_enabled: bool = True
|
|
513
|
+
revert_any_author: bool = False
|
|
514
|
+
|
|
472
515
|
# The third staleness signal (session liveness, read from the reaper's own
|
|
473
516
|
# tmux listing) and the auto-resume it unlocks: a stale attempt whose
|
|
474
517
|
# ledger session is provably gone is respawned with RESUME_DIRECTIVE /
|
|
@@ -514,6 +557,39 @@ class Config:
|
|
|
514
557
|
# never this field.
|
|
515
558
|
wedge_minutes: int = 0
|
|
516
559
|
|
|
560
|
+
# The prompt responder (issue #127): every poll, for every ALIVE worker
|
|
561
|
+
# session this daemon owns, a waiting marker from lane W1's hook OR a
|
|
562
|
+
# session quiet for `prompt_quiet_seconds` triggers a pane capture, the
|
|
563
|
+
# classifier and the policy table (`prompts.py`), and the answer lands
|
|
564
|
+
# in seconds -- no SSH, no LLM in the loop. `on` (the default) answers;
|
|
565
|
+
# `observe` classifies and narrates but sends no key, kills nothing and
|
|
566
|
+
# pages nobody; `off` never captures a pane. Every side effect honours
|
|
567
|
+
# dry_run (a dry pass classifies and logs, sends nothing).
|
|
568
|
+
prompt_responder: str = PROMPT_ON
|
|
569
|
+
|
|
570
|
+
# How long a worker session must have been quiet (`lastActivity` in the
|
|
571
|
+
# roster) before its pane is read WITHOUT a waiting marker. The marker
|
|
572
|
+
# is the fast path (seconds); this is the net under it, for a hook that
|
|
573
|
+
# did not fire. Floored at 30 so a session between two tool calls is not
|
|
574
|
+
# read as parked.
|
|
575
|
+
prompt_quiet_seconds: int = 90
|
|
576
|
+
|
|
577
|
+
# The unknown-dialog ladder's last rung: a dialog no signature knows is
|
|
578
|
+
# waited out one poll, dismissed with Escape on its second sighting, and
|
|
579
|
+
# after this many minutes still on screen the session is killed so the
|
|
580
|
+
# ordinary retry/resume edge takes over. Floored at 1.
|
|
581
|
+
prompt_kill_minutes: int = 10
|
|
582
|
+
|
|
583
|
+
# The per-session answer cap: after this many keystroke answers the
|
|
584
|
+
# session is killed-and-retried -- a worker that keeps producing prompts
|
|
585
|
+
# is diverging, and the narration says so. Floored at 1.
|
|
586
|
+
prompt_max_answers: int = 5
|
|
587
|
+
|
|
588
|
+
# Where the waiting markers are (see DEFAULT_WAITING_DIR). The hooks
|
|
589
|
+
# read ALISSA_WAITING_DIR at run time; the container's config generator
|
|
590
|
+
# writes the same value here so the daemon looks where the hooks wrote.
|
|
591
|
+
waiting_dir: str = DEFAULT_WAITING_DIR
|
|
592
|
+
|
|
517
593
|
# The wedge self-heal (issue #61): the fix edge's crash-recovery net under
|
|
518
594
|
# FIX_DIRECTIVE's terminal step. A fix session that posted its triage
|
|
519
595
|
# replies and PUSHED, then died before re-requesting review, leaves the PR
|
|
@@ -735,6 +811,23 @@ class Config:
|
|
|
735
811
|
return 0
|
|
736
812
|
return max(self.wedge_minutes, self.stale_minutes) * 60
|
|
737
813
|
|
|
814
|
+
@property
|
|
815
|
+
def prompt_kill_seconds(self) -> int:
|
|
816
|
+
"""The unknown-dialog kill rung in seconds, floored at one minute
|
|
817
|
+
here as well as in `build()` so a Config assembled without it can
|
|
818
|
+
never kill a session on its first sighting."""
|
|
819
|
+
return max(1, self.prompt_kill_minutes) * 60
|
|
820
|
+
|
|
821
|
+
@property
|
|
822
|
+
def prompt_quiet_floor(self) -> int:
|
|
823
|
+
"""`prompt_quiet_seconds` with the 30 s floor applied again (the
|
|
824
|
+
`wedge_seconds` contract: `build()` clamps, and so does the read)."""
|
|
825
|
+
return max(30, self.prompt_quiet_seconds)
|
|
826
|
+
|
|
827
|
+
@property
|
|
828
|
+
def prompt_answer_cap(self) -> int:
|
|
829
|
+
return max(1, self.prompt_max_answers)
|
|
830
|
+
|
|
738
831
|
@property
|
|
739
832
|
def janitor_grace_seconds(self) -> int:
|
|
740
833
|
"""How long an anchor must have been terminal before its workspace may
|
|
@@ -893,6 +986,14 @@ class Config:
|
|
|
893
986
|
env_map, ENV_FLEET_VITALS,
|
|
894
987
|
bool(raw.get("fleet_vitals_enabled", cls.fleet_vitals_enabled)),
|
|
895
988
|
)
|
|
989
|
+
# The revert label's rail (issue #131): repos_source's shape -- a
|
|
990
|
+
# non-blank ALISSA_REVERT_LABEL wins, blank falls through.
|
|
991
|
+
revert_label = str(raw.get("revert_label", cls.revert_label))
|
|
992
|
+
env_revert_label = env_map.get(ENV_REVERT_LABEL, "").strip()
|
|
993
|
+
if env_revert_label:
|
|
994
|
+
revert_label = env_revert_label
|
|
995
|
+
if not revert_label.strip():
|
|
996
|
+
raise ValueError("revert_label must not be blank")
|
|
896
997
|
|
|
897
998
|
bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
|
|
898
999
|
|
|
@@ -1014,6 +1115,47 @@ class Config:
|
|
|
1014
1115
|
)
|
|
1015
1116
|
wedge = stale
|
|
1016
1117
|
|
|
1118
|
+
# The prompt responder's knobs (issue #127). The mode is an enum
|
|
1119
|
+
# validated by NAME like foreign_session_policy (a typo must not read
|
|
1120
|
+
# as "off"); the three numbers have FLOORS rather than 0-sentinels --
|
|
1121
|
+
# none of them has a meaningful "off" value, and each floor is the
|
|
1122
|
+
# smallest value that cannot answer a session that is merely between
|
|
1123
|
+
# two tool calls -- and are clamped up with a warning, the wedge
|
|
1124
|
+
# window's contract, because none of them is worth failing a boot.
|
|
1125
|
+
prompt_mode = str(raw.get("prompt_responder", cls.prompt_responder)).strip()
|
|
1126
|
+
if prompt_mode not in _PROMPT_MODES:
|
|
1127
|
+
raise ValueError(
|
|
1128
|
+
f"prompt_responder must be one of {sorted(_PROMPT_MODES)}, "
|
|
1129
|
+
f"got {prompt_mode!r}"
|
|
1130
|
+
)
|
|
1131
|
+
prompt_quiet = int(raw.get("prompt_quiet_seconds", cls.prompt_quiet_seconds))
|
|
1132
|
+
if prompt_quiet < 30:
|
|
1133
|
+
log.warning(
|
|
1134
|
+
"prompt_quiet_seconds=%d is below the 30 s floor — a shorter "
|
|
1135
|
+
"window would read a session between two tool calls as "
|
|
1136
|
+
"parked; clamping up to 30", prompt_quiet,
|
|
1137
|
+
)
|
|
1138
|
+
prompt_quiet = 30
|
|
1139
|
+
prompt_kill = int(raw.get("prompt_kill_minutes", cls.prompt_kill_minutes))
|
|
1140
|
+
if prompt_kill < 1:
|
|
1141
|
+
log.warning(
|
|
1142
|
+
"prompt_kill_minutes=%d is below the 1 min floor — the "
|
|
1143
|
+
"unknown-dialog ladder needs at least one poll between the "
|
|
1144
|
+
"dismissal and the kill; clamping up to 1", prompt_kill,
|
|
1145
|
+
)
|
|
1146
|
+
prompt_kill = 1
|
|
1147
|
+
prompt_answers = int(raw.get("prompt_max_answers", cls.prompt_max_answers))
|
|
1148
|
+
if prompt_answers < 1:
|
|
1149
|
+
log.warning(
|
|
1150
|
+
"prompt_max_answers=%d is below the floor of 1 — a cap of 0 "
|
|
1151
|
+
"would kill every session on its first prompt; clamping up "
|
|
1152
|
+
"to 1", prompt_answers,
|
|
1153
|
+
)
|
|
1154
|
+
prompt_answers = 1
|
|
1155
|
+
waiting_dir = str(raw.get("waiting_dir", cls.waiting_dir)).strip()
|
|
1156
|
+
if not waiting_dir:
|
|
1157
|
+
raise ValueError("waiting_dir must be a non-empty path")
|
|
1158
|
+
|
|
1017
1159
|
# The janitor's grace, the reaper's sentinel one rung longer (0 = use
|
|
1018
1160
|
# reap_grace_minutes). Negative is the only invalid value, and it is
|
|
1019
1161
|
# the dangerous one: it would license reclaiming a workspace the
|
|
@@ -1106,10 +1248,20 @@ class Config:
|
|
|
1106
1248
|
maintain_enabled=bool(
|
|
1107
1249
|
raw.get("maintain_enabled", cls.maintain_enabled)
|
|
1108
1250
|
),
|
|
1251
|
+
revert_label=revert_label,
|
|
1252
|
+
revert_enabled=bool(raw.get("revert_enabled", cls.revert_enabled)),
|
|
1253
|
+
revert_any_author=bool(
|
|
1254
|
+
raw.get("revert_any_author", cls.revert_any_author)
|
|
1255
|
+
),
|
|
1109
1256
|
resume_enabled=bool(
|
|
1110
1257
|
raw.get("resume_enabled", cls.resume_enabled)
|
|
1111
1258
|
),
|
|
1112
1259
|
wedge_minutes=wedge,
|
|
1260
|
+
prompt_responder=prompt_mode,
|
|
1261
|
+
prompt_quiet_seconds=prompt_quiet,
|
|
1262
|
+
prompt_kill_minutes=prompt_kill,
|
|
1263
|
+
prompt_max_answers=prompt_answers,
|
|
1264
|
+
waiting_dir=waiting_dir,
|
|
1113
1265
|
rerequest_enabled=bool(
|
|
1114
1266
|
raw.get("rerequest_enabled", cls.rerequest_enabled)
|
|
1115
1267
|
),
|
|
@@ -208,7 +208,7 @@ def build_snapshot(
|
|
|
208
208
|
ledgers = sources.ledgers()
|
|
209
209
|
|
|
210
210
|
repo_by_session: "dict[str, str]" = {}
|
|
211
|
-
for table in ("spawns", "fix_spawns", "maintain_spawns"):
|
|
211
|
+
for table in ("spawns", "fix_spawns", "maintain_spawns", "revert_spawns"):
|
|
212
212
|
for row in ledgers[table]:
|
|
213
213
|
session = row.get("session")
|
|
214
214
|
if session and session not in repo_by_session:
|
|
@@ -243,6 +243,7 @@ def build_snapshot(
|
|
|
243
243
|
int(latest.get("issue_candidates") or 0)
|
|
244
244
|
+ int(latest.get("pr_candidates") or 0)
|
|
245
245
|
+ int(latest.get("maintain_candidates") or 0)
|
|
246
|
+
+ int(latest.get("revert_candidates") or 0)
|
|
246
247
|
)
|
|
247
248
|
|
|
248
249
|
drift = sources.drift()
|