alissa-tools-github-revloop 0.31.2__tar.gz → 0.31.4__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_revloop-0.31.2/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.4}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/alissa.py +258 -19
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/config.py +119 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/loop.py +909 -30
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/loop_events.py +49 -0
- alissa_tools_github_revloop-0.31.4/src/main/alissa/tools/github/revloop/prompts.py +779 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/state.py +197 -3
- alissa_tools_github_revloop-0.31.4/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/page.py +66 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/server.py +119 -3
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/sources.py +85 -6
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -4,12 +4,15 @@ reviewer session (orchestration P1)."""
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import re
|
|
9
|
+
import time
|
|
8
10
|
from dataclasses import dataclass
|
|
9
11
|
from datetime import datetime
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
|
|
12
14
|
from .proc import CommandError, run, run_json
|
|
15
|
+
from .prompts import ALLOWED_KEYS
|
|
13
16
|
|
|
14
17
|
log = logging.getLogger(__name__)
|
|
15
18
|
|
|
@@ -236,7 +239,7 @@ _VERDICT_RE = re.compile(
|
|
|
236
239
|
# The envelope's merge-readiness judgment (issue #130). The alissa-code-review
|
|
237
240
|
# skill writes it as one list line in the verdict envelope:
|
|
238
241
|
# - **Merge-Readiness:** auto
|
|
239
|
-
# - **Merge-Readiness:** operator —
|
|
242
|
+
# - **Merge-Readiness:** operator — schema-migration: removes users.legacyId
|
|
240
243
|
# Tolerant of the markdown around the label -- an optional bullet, optional
|
|
241
244
|
# `**` bold around the label (with the colon inside or outside it), optional
|
|
242
245
|
# bold around the value -- but NOT of the value's case: `Auto` is not a
|
|
@@ -252,21 +255,83 @@ _READINESS_RE = re.compile(
|
|
|
252
255
|
re.MULTILINE,
|
|
253
256
|
)
|
|
254
257
|
|
|
258
|
+
# The operator reason's CLASS (issue #142). An `operator` judgment names WHY
|
|
259
|
+
# the merge is a human's, and until now it did so in prose the merge edge
|
|
260
|
+
# could not act on: of 14 operator verdicts on ~40 studio PRs, 3 named real
|
|
261
|
+
# merge risk and 11 were "the PR body lists an unverified item" -- validation
|
|
262
|
+
# work the human does at gate 2 anyway, not a reason to withhold the merge.
|
|
263
|
+
# So the reason now LEADS with one token from this closed enum, in the
|
|
264
|
+
# consumer's grammar:
|
|
265
|
+
#
|
|
266
|
+
# Merge-Readiness: auto
|
|
267
|
+
# Merge-Readiness: operator — <class>: <one-line reason>
|
|
268
|
+
#
|
|
269
|
+
# The order below is the SEVERITY order: when more than one row applies, the
|
|
270
|
+
# reviewer names the one nearest the top (the most severe wins), and exactly
|
|
271
|
+
# one. `auto` never carries a class. An operator line whose reason does not
|
|
272
|
+
# lead with a recognised token is still valid grammar -- it parses, value and
|
|
273
|
+
# reason intact -- and reads as UNCLASSED (`klass=None`); the consumer treats
|
|
274
|
+
# unclassed as a hard hold (fail closed), so a reviewer that forgets the
|
|
275
|
+
# class loses throughput, never safety. The token is matched exactly as
|
|
276
|
+
# written here (lowercase, hyphenated): `Schema-Migration:` is not a class
|
|
277
|
+
# this daemon reads, for the same reason `Auto` is not a value it carries.
|
|
278
|
+
READINESS_CLASSES: "tuple[str, ...]" = (
|
|
279
|
+
"schema-migration", # schema shape changes needing a migration / removal / rename on live data
|
|
280
|
+
"data-backfill", # one-off writes over existing rows (backfills, stored-shape version bumps)
|
|
281
|
+
"secrets-env", # new / changed env vars, secrets, credentials the deploy must carry
|
|
282
|
+
"infra-deploy", # Dockerfiles, workflows, Railway config, base-image pins -- what runs
|
|
283
|
+
"billing", # credit charging, pricing, tiers, quotas
|
|
284
|
+
"security", # auth, permission gates, redaction, sandboxing flagged operator-worthy
|
|
285
|
+
"unverified-ux", # the PR body's operator gate is a human look at a screen / copy / mockup
|
|
286
|
+
"unverified-runtime", # the PR body's operator gate is a live smoke / real-model run / replay
|
|
287
|
+
"release-act", # the merge itself is a release (VERSION bump -> publish, tag, npm)
|
|
288
|
+
)
|
|
289
|
+
_READINESS_CLASS_RE = re.compile(
|
|
290
|
+
r"^(" + "|".join(re.escape(klass) for klass in READINESS_CLASSES) + r")[ \t]*:"
|
|
291
|
+
)
|
|
292
|
+
|
|
255
293
|
# The reason lands in a GitHub review body (as a line-anchored trailer) and in
|
|
256
294
|
# a log line, so it is bounded and flattened at the parser: one line, no
|
|
257
295
|
# backticks (a fence would swallow the trailer and everything after it), and
|
|
258
|
-
# no more than this many characters.
|
|
296
|
+
# no more than this many characters. The class prefix is PART of the reason
|
|
297
|
+
# and survives the flattening untouched: the native trailer must stay
|
|
298
|
+
# byte-equal to the envelope's line, class included.
|
|
259
299
|
MAX_READINESS_REASON_CHARS = 200
|
|
260
300
|
|
|
261
301
|
|
|
262
302
|
def clean_readiness_reason(text: object) -> str:
|
|
263
|
-
"""Flatten a Merge-Readiness reason to one bounded, backtick-free line.
|
|
303
|
+
"""Flatten a Merge-Readiness reason to one bounded, backtick-free line.
|
|
304
|
+
|
|
305
|
+
The `<class>:` prefix, when present, is kept intact at the head of the
|
|
306
|
+
line -- classify_readiness_reason reads it off the cleaned reason, and the
|
|
307
|
+
emitter copies the cleaned reason whole.
|
|
308
|
+
"""
|
|
264
309
|
if not isinstance(text, str):
|
|
265
310
|
return ""
|
|
266
311
|
flat = " ".join(text.replace("`", "").split())
|
|
267
312
|
return flat[:MAX_READINESS_REASON_CHARS].rstrip()
|
|
268
313
|
|
|
269
314
|
|
|
315
|
+
def classify_readiness_reason(value: object, reason: object) -> "str | None":
|
|
316
|
+
"""The READINESS_CLASSES token an `operator` reason leads with, or None.
|
|
317
|
+
|
|
318
|
+
None for `auto` (never classed), for an operator reason that leads with
|
|
319
|
+
no recognised token (UNCLASSED -- the consumer's hard hold), and for a
|
|
320
|
+
token in the wrong case or spelling. The reason itself is never altered:
|
|
321
|
+
the class is read off it, not cut out of it.
|
|
322
|
+
"""
|
|
323
|
+
if value != READINESS_OPERATOR or not isinstance(reason, str):
|
|
324
|
+
return None
|
|
325
|
+
match = _READINESS_CLASS_RE.match(reason)
|
|
326
|
+
return match.group(1) if match else None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _classed(value: "str | None", raw_reason: object) -> "tuple[str | None, str, str | None]":
|
|
330
|
+
"""The `(value, reason, klass)` triple both parsers hand back."""
|
|
331
|
+
reason = clean_readiness_reason(raw_reason)
|
|
332
|
+
return (value, reason, classify_readiness_reason(value, reason))
|
|
333
|
+
|
|
334
|
+
|
|
270
335
|
# The trailer's own grammar (issue #134) -- the bare-line sibling of
|
|
271
336
|
# _READINESS_RE. This is the line a native review ENDS with, whoever wrote
|
|
272
337
|
# it: the daemon's emitter (loop.readiness_trailer) when it posts the verdict
|
|
@@ -285,37 +350,42 @@ _TRAILER_RE = re.compile(
|
|
|
285
350
|
)
|
|
286
351
|
|
|
287
352
|
|
|
288
|
-
def parse_trailer(body: object) -> "tuple[str | None, str]":
|
|
289
|
-
"""`(value, reason)` from the first bare `Merge-Readiness:` line in
|
|
290
|
-
review body, or `(None, "")` when no line matches the trailer
|
|
353
|
+
def parse_trailer(body: object) -> "tuple[str | None, str, str | None]":
|
|
354
|
+
"""`(value, reason, klass)` from the first bare `Merge-Readiness:` line in
|
|
355
|
+
a review body, or `(None, "", None)` when no line matches the trailer
|
|
356
|
+
grammar.
|
|
291
357
|
|
|
292
358
|
Strict where parse_readiness is tolerant: the line inside backticks
|
|
293
359
|
mid-sentence (studio #1258) and the envelope's `- **Merge-Readiness:**`
|
|
294
360
|
bullet both read as MISSING here, because the consumer's regex is what
|
|
295
361
|
decides whether the merge edge sees the judgment at all. CRLF bodies (a
|
|
296
362
|
review typed into the web form) are normalised first; the grammar itself
|
|
297
|
-
is unchanged.
|
|
363
|
+
is unchanged. `klass` is the READINESS_CLASSES token an operator reason
|
|
364
|
+
leads with, or None (auto, or an unclassed operator -- issue #142).
|
|
298
365
|
"""
|
|
299
366
|
if not isinstance(body, str):
|
|
300
|
-
return (None, "")
|
|
367
|
+
return (None, "", None)
|
|
301
368
|
match = _TRAILER_RE.search(body.replace("\r\n", "\n"))
|
|
302
369
|
if match is None:
|
|
303
|
-
return (None, "")
|
|
304
|
-
return (match.group(1),
|
|
370
|
+
return (None, "", None)
|
|
371
|
+
return _classed(match.group(1), match.group(2))
|
|
305
372
|
|
|
306
373
|
|
|
307
|
-
def parse_readiness(blob: object) -> "tuple[str | None, str]":
|
|
308
|
-
"""`(value, reason)` from the first Merge-Readiness line in `blob`.
|
|
374
|
+
def parse_readiness(blob: object) -> "tuple[str | None, str, str | None]":
|
|
375
|
+
"""`(value, reason, klass)` from the first Merge-Readiness line in `blob`.
|
|
309
376
|
|
|
310
377
|
`value` is READINESS_AUTO / READINESS_OPERATOR, or None when no line
|
|
311
|
-
parses; `reason` is the cleaned trailing text (empty when there is none)
|
|
378
|
+
parses; `reason` is the cleaned trailing text (empty when there is none),
|
|
379
|
+
class prefix included; `klass` is the READINESS_CLASSES token that reason
|
|
380
|
+
leads with, or None when the value is `auto`, the reason is unclassed, or
|
|
381
|
+
nothing parsed (issue #142).
|
|
312
382
|
"""
|
|
313
383
|
if not isinstance(blob, str):
|
|
314
|
-
return (None, "")
|
|
384
|
+
return (None, "", None)
|
|
315
385
|
match = _READINESS_RE.search(blob)
|
|
316
386
|
if match is None:
|
|
317
|
-
return (None, "")
|
|
318
|
-
return (match.group(1),
|
|
387
|
+
return (None, "", None)
|
|
388
|
+
return _classed(match.group(1), match.group(2))
|
|
319
389
|
|
|
320
390
|
|
|
321
391
|
@dataclass(frozen=True)
|
|
@@ -336,6 +406,83 @@ class Task:
|
|
|
336
406
|
# kill on.
|
|
337
407
|
REVIEW_SESSION_PREFIX = "review-"
|
|
338
408
|
|
|
409
|
+
# `alissa tmux new` names the REAL tmux session `ali-<name>`; the roster's
|
|
410
|
+
# `name` is the managed name without it. The raw tmux surface below addresses
|
|
411
|
+
# the real one, and the waiting marker (docker/claude/hooks/note-waiting.py)
|
|
412
|
+
# is named after it too.
|
|
413
|
+
MANAGED_PREFIX = "ali-"
|
|
414
|
+
|
|
415
|
+
# The console's session-name rule (webui.server), stated once so the raw
|
|
416
|
+
# tmux argv builders and the HTTP surface can never drift: a managed name
|
|
417
|
+
# starts with a letter or digit and carries only these characters, which is
|
|
418
|
+
# what keeps a `-flag`-shaped or shell-metacharacter name out of any argv.
|
|
419
|
+
SAFE_SESSION = re.compile(r"\A[A-Za-z0-9._][A-Za-z0-9._-]{0,199}\Z")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def tmux_socket() -> str:
|
|
423
|
+
"""The path of the tmux server socket the reviewer sessions live on:
|
|
424
|
+
`$TMUX_TMPDIR/tmux-<uid>/default` (tmux's own default). Spelled out
|
|
425
|
+
rather than left to tmux because the daemon may run without `TMUX` in
|
|
426
|
+
its environment and must still reach the SAME server `alissa tmux new`
|
|
427
|
+
created for the reviewers -- same container, same user, same socket."""
|
|
428
|
+
base = os.environ.get("TMUX_TMPDIR", "").strip() or "/tmp"
|
|
429
|
+
return os.path.join(base, f"tmux-{os.getuid()}", "default")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def tmux_target(name: str) -> str:
|
|
433
|
+
"""The `-t` target for a managed session: the real tmux name, prefixed
|
|
434
|
+
with `=` so tmux matches it EXACTLY (its default is prefix matching, and
|
|
435
|
+
`review-widgets-pr1-r1-abcdef` is a prefix of `…-r1-abcdef0`) and
|
|
436
|
+
suffixed with `:` so the target is the session's CURRENT WINDOW (and its
|
|
437
|
+
active pane) -- a bare `=name` is a session target, which `capture-pane`
|
|
438
|
+
and `send-keys` refuse with "can't find pane" (tmux 3.3a)."""
|
|
439
|
+
real = name if name.startswith(MANAGED_PREFIX) else MANAGED_PREFIX + name
|
|
440
|
+
return "=" + real + ":"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def check_session_name(name: "str | None") -> str:
|
|
444
|
+
"""`name` when it passes SAFE_SESSION, else ValueError. Every raw tmux
|
|
445
|
+
argv builder calls this first."""
|
|
446
|
+
if not name or not SAFE_SESSION.match(name):
|
|
447
|
+
raise ValueError(f"invalid session name {name!r}")
|
|
448
|
+
return name
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def capture_pane_argv(name: str, lines: int) -> "list[str]":
|
|
452
|
+
"""`tmux -S <socket> capture-pane -p -t =<real> -S -<lines>`: the last
|
|
453
|
+
`lines` lines of the session's active pane, printed to stdout, with
|
|
454
|
+
escape sequences stripped (`-p` without `-e`)."""
|
|
455
|
+
check_session_name(name)
|
|
456
|
+
return [
|
|
457
|
+
"tmux", "-S", tmux_socket(), "capture-pane", "-p",
|
|
458
|
+
"-t", tmux_target(name), "-S", f"-{max(1, int(lines))}",
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def pane_path_argv(name: str) -> "list[str]":
|
|
463
|
+
check_session_name(name)
|
|
464
|
+
return [
|
|
465
|
+
"tmux", "-S", tmux_socket(), "display-message", "-p",
|
|
466
|
+
"-t", tmux_target(name), "#{pane_current_path}",
|
|
467
|
+
]
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
|
|
471
|
+
"""`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
|
|
472
|
+
from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
|
|
473
|
+
is a ValueError before any process is spawned: the responder presses a
|
|
474
|
+
dialog's buttons and can never type into a reviewer."""
|
|
475
|
+
check_session_name(name)
|
|
476
|
+
if not keys:
|
|
477
|
+
raise ValueError("send_keys needs at least one key")
|
|
478
|
+
bad = [k for k in keys if k not in ALLOWED_KEYS]
|
|
479
|
+
if bad:
|
|
480
|
+
raise ValueError(
|
|
481
|
+
f"key(s) not in the allowlist {sorted(ALLOWED_KEYS)}: {bad!r}"
|
|
482
|
+
)
|
|
483
|
+
return ["tmux", "-S", tmux_socket(), "send-keys", "-t", tmux_target(name), *keys]
|
|
484
|
+
|
|
485
|
+
|
|
339
486
|
# The reviewer-session GRAMMAR. The sweep kills sessions and the worker
|
|
340
487
|
# container is shared with other lanes (`develop-*`, `fix-*`, `maintain-*`,
|
|
341
488
|
# ...), so only a name that parses as one of the two shapes the review loop
|
|
@@ -419,11 +566,29 @@ class ManagedSession:
|
|
|
419
566
|
# not report one -- treated as "long quiet", so a missing field can never
|
|
420
567
|
# indefinitely immunize a session against the sweep.
|
|
421
568
|
last_activity: float = 0.0
|
|
569
|
+
# The REAL tmux session name (`ali-<name>`, the roster's `session`
|
|
570
|
+
# field), or None when the CLI did not report one. The prompt responder
|
|
571
|
+
# names the waiting marker by it; absent, `MANAGED_PREFIX + name`.
|
|
572
|
+
session: "str | None" = None
|
|
422
573
|
|
|
423
574
|
@property
|
|
424
575
|
def is_idle(self) -> bool:
|
|
425
576
|
return self.status == "idle"
|
|
426
577
|
|
|
578
|
+
@property
|
|
579
|
+
def tmux_name(self) -> str:
|
|
580
|
+
"""The real tmux session name -- the roster's, else derived."""
|
|
581
|
+
return self.session or MANAGED_PREFIX + self.name
|
|
582
|
+
|
|
583
|
+
@property
|
|
584
|
+
def quiet_for(self) -> "float | None":
|
|
585
|
+
"""Seconds since the last tmux activity, or None when the CLI
|
|
586
|
+
reported no timestamp (the sweep reads that 0 as "long quiet"; the
|
|
587
|
+
prompt responder reads it as "unknown" and waits for a marker)."""
|
|
588
|
+
if not self.last_activity:
|
|
589
|
+
return None
|
|
590
|
+
return max(0.0, time.time() - self.last_activity)
|
|
591
|
+
|
|
427
592
|
@property
|
|
428
593
|
def ref(self) -> "SessionRef | None":
|
|
429
594
|
"""What the name says about the round -- see parse_session_name."""
|
|
@@ -482,12 +647,17 @@ class VerdictEnvelope:
|
|
|
482
647
|
envelope has no parseable `Merge-Readiness` line -- the case the native
|
|
483
648
|
post fails closed on. `readiness_reason` is already one bounded,
|
|
484
649
|
backtick-free line (see clean_readiness_reason); empty when the envelope
|
|
485
|
-
gave none.
|
|
650
|
+
gave none. `readiness_class` is the READINESS_CLASSES token an operator
|
|
651
|
+
reason leads with (issue #142), or None: for `auto`, for an unclassed
|
|
652
|
+
operator reason, and for a missing line alike -- the reason keeps the
|
|
653
|
+
prefix either way, so the class is never lost between here and the
|
|
654
|
+
native trailer.
|
|
486
655
|
"""
|
|
487
656
|
|
|
488
657
|
verdict: str
|
|
489
658
|
readiness: "str | None" = None
|
|
490
659
|
readiness_reason: str = ""
|
|
660
|
+
readiness_class: "str | None" = None
|
|
491
661
|
|
|
492
662
|
|
|
493
663
|
@dataclass(frozen=True)
|
|
@@ -988,9 +1158,9 @@ class Alissa:
|
|
|
988
1158
|
continue
|
|
989
1159
|
match = _VERDICT_RE.search(blob)
|
|
990
1160
|
if match:
|
|
991
|
-
readiness, reason = parse_readiness(content)
|
|
1161
|
+
readiness, reason, klass = parse_readiness(content)
|
|
992
1162
|
if readiness is None:
|
|
993
|
-
readiness, reason = parse_readiness(title)
|
|
1163
|
+
readiness, reason, klass = parse_readiness(title)
|
|
994
1164
|
found.append(
|
|
995
1165
|
(Alissa._created_key(item.get("createdAt")),
|
|
996
1166
|
index,
|
|
@@ -998,6 +1168,7 @@ class Alissa:
|
|
|
998
1168
|
verdict=match.group(1).lower(),
|
|
999
1169
|
readiness=readiness,
|
|
1000
1170
|
readiness_reason=reason,
|
|
1171
|
+
readiness_class=klass,
|
|
1001
1172
|
))
|
|
1002
1173
|
)
|
|
1003
1174
|
break
|
|
@@ -1117,11 +1288,13 @@ class Alissa:
|
|
|
1117
1288
|
name = row.get("name")
|
|
1118
1289
|
if isinstance(name, str) and parse_session_name(name) is not None:
|
|
1119
1290
|
last = row.get("lastActivity")
|
|
1291
|
+
real = row.get("session")
|
|
1120
1292
|
sessions.append(
|
|
1121
1293
|
ManagedSession(
|
|
1122
1294
|
name=name,
|
|
1123
1295
|
status=str(row.get("status") or ""),
|
|
1124
1296
|
last_activity=float(last) if isinstance(last, (int, float)) else 0.0,
|
|
1297
|
+
session=real if isinstance(real, str) and real else None,
|
|
1125
1298
|
)
|
|
1126
1299
|
)
|
|
1127
1300
|
return sessions
|
|
@@ -1161,6 +1334,72 @@ class Alissa:
|
|
|
1161
1334
|
log.debug("could not tail %s: %s", session, exc)
|
|
1162
1335
|
return ""
|
|
1163
1336
|
|
|
1337
|
+
def capture_pane(self, name: str, lines: int = 40) -> str:
|
|
1338
|
+
"""The last `lines` of ONE managed session's pane over raw tmux
|
|
1339
|
+
(`capture-pane -p`), or "" when tmux cannot answer (no such session,
|
|
1340
|
+
the socket gone, a timeout).
|
|
1341
|
+
|
|
1342
|
+
The responder's evidence seam (issue #138). Like `tail_session` it
|
|
1343
|
+
returns UNTRUSTED, possibly sensitive terminal content: the caller
|
|
1344
|
+
classifies it and lets only the classification -- plus a scrubbed
|
|
1345
|
+
signature line -- reach an operator-facing level. A READ, so it runs
|
|
1346
|
+
under dry-run too: a dry pass has to classify what a live one would.
|
|
1347
|
+
Raw tmux rather than the `alissa` CLI because the CLI's `tail` is a
|
|
1348
|
+
convenience without `send-keys` beside it, and the answer has to land
|
|
1349
|
+
on the very pane the capture read."""
|
|
1350
|
+
try:
|
|
1351
|
+
argv = capture_pane_argv(name, lines)
|
|
1352
|
+
except ValueError as exc:
|
|
1353
|
+
log.warning("capture_pane refused: %s", exc)
|
|
1354
|
+
return ""
|
|
1355
|
+
try:
|
|
1356
|
+
out = run(argv, timeout=15)
|
|
1357
|
+
except CommandError as exc:
|
|
1358
|
+
log.debug("could not capture the pane of %s (%s)", name, exc)
|
|
1359
|
+
return ""
|
|
1360
|
+
return out.rstrip("\n")
|
|
1361
|
+
|
|
1362
|
+
def pane_path(self, name: str) -> str:
|
|
1363
|
+
"""The session's pane current path (`#{pane_current_path}`), or ""
|
|
1364
|
+
when tmux cannot answer. One leg of the review-checkout resolution:
|
|
1365
|
+
a reviewer that `cd`ed into its `REVIEW-*` checkout names it here."""
|
|
1366
|
+
try:
|
|
1367
|
+
argv = pane_path_argv(name)
|
|
1368
|
+
except ValueError as exc:
|
|
1369
|
+
log.warning("pane_path refused: %s", exc)
|
|
1370
|
+
return ""
|
|
1371
|
+
try:
|
|
1372
|
+
return run(argv, timeout=15).strip()
|
|
1373
|
+
except CommandError as exc:
|
|
1374
|
+
log.debug("could not read the pane path of %s (%s)", name, exc)
|
|
1375
|
+
return ""
|
|
1376
|
+
|
|
1377
|
+
def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
|
|
1378
|
+
"""Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
|
|
1379
|
+
-- on ONE managed session's pane. True when tmux accepted them.
|
|
1380
|
+
|
|
1381
|
+
The ONLY key-pressing surface in this daemon, and deliberately
|
|
1382
|
+
narrow: an allowlisted enum, a validated session name, an exact-match
|
|
1383
|
+
target, one `send-keys` per call. A key outside the allowlist is
|
|
1384
|
+
refused before any process is spawned and logged at WARNING (it is a
|
|
1385
|
+
programming error, never a pane's doing -- keys come from the
|
|
1386
|
+
policy table, not from the screen). Dry-run logs the intent and
|
|
1387
|
+
sends nothing."""
|
|
1388
|
+
try:
|
|
1389
|
+
argv = send_keys_argv(name, tuple(keys))
|
|
1390
|
+
except ValueError as exc:
|
|
1391
|
+
log.warning("send_keys refused for %s: %s", name, exc)
|
|
1392
|
+
return False
|
|
1393
|
+
if dry_run:
|
|
1394
|
+
log.info("[dry-run] would send keys %s to %s", " ".join(keys), name)
|
|
1395
|
+
return False
|
|
1396
|
+
try:
|
|
1397
|
+
run(argv, timeout=15)
|
|
1398
|
+
except CommandError as exc:
|
|
1399
|
+
log.warning("could not send keys %s to %s (%s)", " ".join(keys), name, exc)
|
|
1400
|
+
return False
|
|
1401
|
+
return True
|
|
1402
|
+
|
|
1164
1403
|
def add_repo_to_workspace(
|
|
1165
1404
|
self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
|
|
1166
1405
|
) -> None:
|
|
@@ -35,6 +35,7 @@ from __future__ import annotations
|
|
|
35
35
|
|
|
36
36
|
import fnmatch
|
|
37
37
|
import json
|
|
38
|
+
import logging
|
|
38
39
|
import os
|
|
39
40
|
import re
|
|
40
41
|
from dataclasses import dataclass, field
|
|
@@ -42,6 +43,8 @@ from pathlib import Path
|
|
|
42
43
|
from typing import Any, Mapping
|
|
43
44
|
from urllib.parse import urlsplit
|
|
44
45
|
|
|
46
|
+
log = logging.getLogger(__name__)
|
|
47
|
+
|
|
45
48
|
# A POSIX-ish environment variable name -- what `reviewer_token_env` must be.
|
|
46
49
|
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
47
50
|
|
|
@@ -196,6 +199,11 @@ CONFIG_KEYS = (
|
|
|
196
199
|
"task_list_bow_id",
|
|
197
200
|
"loop_events_enabled",
|
|
198
201
|
"fleet_vitals_enabled",
|
|
202
|
+
"prompt_responder",
|
|
203
|
+
"prompt_quiet_seconds",
|
|
204
|
+
"prompt_kill_minutes",
|
|
205
|
+
"prompt_max_answers",
|
|
206
|
+
"waiting_dir",
|
|
199
207
|
"alissa_endpoint",
|
|
200
208
|
"dry_run",
|
|
201
209
|
)
|
|
@@ -454,6 +462,20 @@ def _validate_actor_id(entry: str) -> None:
|
|
|
454
462
|
|
|
455
463
|
MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
|
|
456
464
|
|
|
465
|
+
# The prompt responder's master switch (issue #138; devloop #127's names):
|
|
466
|
+
# `on` answers, `observe` classifies and narrates but sends nothing, `off`
|
|
467
|
+
# never captures a pane.
|
|
468
|
+
PROMPT_ON = "on"
|
|
469
|
+
PROMPT_OBSERVE = "observe"
|
|
470
|
+
PROMPT_OFF = "off"
|
|
471
|
+
_PROMPT_MODES = frozenset((PROMPT_ON, PROMPT_OBSERVE, PROMPT_OFF))
|
|
472
|
+
|
|
473
|
+
# Where the container's hooks leave the "this session is waiting for input"
|
|
474
|
+
# marker (`docker/claude/hooks/note-waiting.py`): `${ALISSA_WAITING_DIR}/<tmux
|
|
475
|
+
# session>.json`. The daemon reads the same directory, so the two halves
|
|
476
|
+
# agree by construction on a default deployment.
|
|
477
|
+
DEFAULT_WAITING_DIR = "/workspace/.waiting"
|
|
478
|
+
|
|
457
479
|
# A reviewer session that has not submitted after this long is presumed dead
|
|
458
480
|
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
|
|
459
481
|
# but only with a second signal agreeing: the timer alone cannot tell a dead
|
|
@@ -853,6 +875,40 @@ class Config:
|
|
|
853
875
|
# about the client (its bearer token) comes from the environment.
|
|
854
876
|
alissa_endpoint: str = DEFAULT_ALISSA_ENDPOINT
|
|
855
877
|
|
|
878
|
+
# The prompt responder (issue #138; devloop #127's knobs under the same
|
|
879
|
+
# names): every poll, for every ALIVE reviewer session this daemon owns,
|
|
880
|
+
# a waiting marker from the container's hook OR a session quiet for
|
|
881
|
+
# `prompt_quiet_seconds` triggers a pane capture, the classifier and the
|
|
882
|
+
# policy table (`prompts.py`), and the answer lands in seconds -- no
|
|
883
|
+
# SSH, no LLM in the loop. `on` (the default) answers; `observe`
|
|
884
|
+
# classifies and narrates but sends no key, kills nothing and pages
|
|
885
|
+
# nobody; `off` never captures a pane. Every side effect honours dry_run
|
|
886
|
+
# (a dry pass classifies and logs, sends nothing).
|
|
887
|
+
prompt_responder: str = PROMPT_ON
|
|
888
|
+
|
|
889
|
+
# How long a reviewer session must have been quiet (`lastActivity` in
|
|
890
|
+
# the roster) before its pane is read WITHOUT a waiting marker. The
|
|
891
|
+
# marker is the fast path (seconds); this is the net under it, for a
|
|
892
|
+
# hook that did not fire. Floored at 30 so a session between two tool
|
|
893
|
+
# calls is not read as parked.
|
|
894
|
+
prompt_quiet_seconds: int = 90
|
|
895
|
+
|
|
896
|
+
# The unknown-dialog ladder's last rung: a dialog no signature knows is
|
|
897
|
+
# waited out one poll, dismissed with Escape on its second sighting, and
|
|
898
|
+
# after this many minutes still on screen the session is killed so the
|
|
899
|
+
# ordinary stale-round re-entry takes over. Floored at 1.
|
|
900
|
+
prompt_kill_minutes: int = 10
|
|
901
|
+
|
|
902
|
+
# The per-session answer cap: after this many keystroke answers the
|
|
903
|
+
# session is killed and its round re-queued -- a reviewer that keeps
|
|
904
|
+
# producing prompts is diverging, and the narration says so. Floored at 1.
|
|
905
|
+
prompt_max_answers: int = 5
|
|
906
|
+
|
|
907
|
+
# Where the waiting markers are (see DEFAULT_WAITING_DIR). The hooks
|
|
908
|
+
# read ALISSA_WAITING_DIR at run time; the container's config renderer
|
|
909
|
+
# writes the same value here so the daemon looks where the hooks wrote.
|
|
910
|
+
waiting_dir: str = DEFAULT_WAITING_DIR
|
|
911
|
+
|
|
856
912
|
dry_run: bool = False
|
|
857
913
|
|
|
858
914
|
def __post_init__(self) -> None:
|
|
@@ -875,6 +931,23 @@ class Config:
|
|
|
875
931
|
def manifest_path(self) -> Path:
|
|
876
932
|
return self.workspace_root / "alissa-workspace.yaml"
|
|
877
933
|
|
|
934
|
+
@property
|
|
935
|
+
def prompt_kill_seconds(self) -> int:
|
|
936
|
+
"""The unknown-dialog kill rung in seconds, floored at one minute
|
|
937
|
+
here as well as in `build()` so a Config assembled without it can
|
|
938
|
+
never kill a session on its first sighting."""
|
|
939
|
+
return max(1, self.prompt_kill_minutes) * 60
|
|
940
|
+
|
|
941
|
+
@property
|
|
942
|
+
def prompt_quiet_floor(self) -> int:
|
|
943
|
+
"""`prompt_quiet_seconds` with the 30 s floor applied again
|
|
944
|
+
(`build()` clamps, and so does the read)."""
|
|
945
|
+
return max(30, self.prompt_quiet_seconds)
|
|
946
|
+
|
|
947
|
+
@property
|
|
948
|
+
def prompt_answer_cap(self) -> int:
|
|
949
|
+
return max(1, self.prompt_max_answers)
|
|
950
|
+
|
|
878
951
|
def hub_for(self, owner: str, repo: str) -> Path:
|
|
879
952
|
return Path(
|
|
880
953
|
self.hub_template.format(
|
|
@@ -1217,6 +1290,47 @@ class Config:
|
|
|
1217
1290
|
f"token — got {_describe(token_env)}.{rotate}"
|
|
1218
1291
|
)
|
|
1219
1292
|
|
|
1293
|
+
# The prompt responder's knobs (issue #138). The mode is an enum
|
|
1294
|
+
# validated by NAME like repos_source (a typo must not read as
|
|
1295
|
+
# "off"); the three numbers have FLOORS rather than 0-sentinels --
|
|
1296
|
+
# none of them has a meaningful "off" value, and each floor is the
|
|
1297
|
+
# smallest value that cannot answer a session that is merely between
|
|
1298
|
+
# two tool calls -- and are clamped up with a warning, because none of
|
|
1299
|
+
# them is worth failing a boot.
|
|
1300
|
+
prompt_mode = str(raw.get("prompt_responder", cls.prompt_responder)).strip()
|
|
1301
|
+
if prompt_mode not in _PROMPT_MODES:
|
|
1302
|
+
raise ValueError(
|
|
1303
|
+
f"prompt_responder must be one of {sorted(_PROMPT_MODES)}, "
|
|
1304
|
+
f"got {prompt_mode!r}"
|
|
1305
|
+
)
|
|
1306
|
+
prompt_quiet = int(raw.get("prompt_quiet_seconds", cls.prompt_quiet_seconds))
|
|
1307
|
+
if prompt_quiet < 30:
|
|
1308
|
+
log.warning(
|
|
1309
|
+
"prompt_quiet_seconds=%d is below the 30 s floor — a shorter "
|
|
1310
|
+
"window would read a session between two tool calls as "
|
|
1311
|
+
"parked; clamping up to 30", prompt_quiet,
|
|
1312
|
+
)
|
|
1313
|
+
prompt_quiet = 30
|
|
1314
|
+
prompt_kill = int(raw.get("prompt_kill_minutes", cls.prompt_kill_minutes))
|
|
1315
|
+
if prompt_kill < 1:
|
|
1316
|
+
log.warning(
|
|
1317
|
+
"prompt_kill_minutes=%d is below the 1 min floor — the "
|
|
1318
|
+
"unknown-dialog ladder needs at least one poll between the "
|
|
1319
|
+
"dismissal and the kill; clamping up to 1", prompt_kill,
|
|
1320
|
+
)
|
|
1321
|
+
prompt_kill = 1
|
|
1322
|
+
prompt_answers = int(raw.get("prompt_max_answers", cls.prompt_max_answers))
|
|
1323
|
+
if prompt_answers < 1:
|
|
1324
|
+
log.warning(
|
|
1325
|
+
"prompt_max_answers=%d is below the floor of 1 — a cap of 0 "
|
|
1326
|
+
"would kill every session on its first prompt; clamping up "
|
|
1327
|
+
"to 1", prompt_answers,
|
|
1328
|
+
)
|
|
1329
|
+
prompt_answers = 1
|
|
1330
|
+
waiting_dir = str(raw.get("waiting_dir", cls.waiting_dir)).strip()
|
|
1331
|
+
if not waiting_dir:
|
|
1332
|
+
raise ValueError("waiting_dir must be a non-empty path")
|
|
1333
|
+
|
|
1220
1334
|
state_path = raw.get("state_path")
|
|
1221
1335
|
return cls(
|
|
1222
1336
|
workspace_root=Path(workspace_root),
|
|
@@ -1248,6 +1362,11 @@ class Config:
|
|
|
1248
1362
|
task_list_bow_id=bow_id,
|
|
1249
1363
|
loop_events_enabled=bool(raw.get("loop_events_enabled", False)),
|
|
1250
1364
|
fleet_vitals_enabled=bool(raw.get("fleet_vitals_enabled", False)),
|
|
1365
|
+
prompt_responder=prompt_mode,
|
|
1366
|
+
prompt_quiet_seconds=prompt_quiet,
|
|
1367
|
+
prompt_kill_minutes=prompt_kill,
|
|
1368
|
+
prompt_max_answers=prompt_answers,
|
|
1369
|
+
waiting_dir=waiting_dir,
|
|
1251
1370
|
alissa_endpoint=endpoint,
|
|
1252
1371
|
dry_run=bool(raw.get("dry_run", False)),
|
|
1253
1372
|
)
|