alissa-tools-github-revloop 0.31.2__tar.gz → 0.31.3__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.3}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/alissa.py +166 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/config.py +119 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/loop.py +823 -12
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/loop_events.py +49 -0
- alissa_tools_github_revloop-0.31.3/src/main/alissa/tools/github/revloop/prompts.py +779 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/state.py +197 -3
- alissa_tools_github_revloop-0.31.3/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/page.py +66 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/server.py +119 -3
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/sources.py +85 -6
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3/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.3}/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.3}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.3}/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.3}/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.3}/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
|
|
|
@@ -336,6 +339,83 @@ class Task:
|
|
|
336
339
|
# kill on.
|
|
337
340
|
REVIEW_SESSION_PREFIX = "review-"
|
|
338
341
|
|
|
342
|
+
# `alissa tmux new` names the REAL tmux session `ali-<name>`; the roster's
|
|
343
|
+
# `name` is the managed name without it. The raw tmux surface below addresses
|
|
344
|
+
# the real one, and the waiting marker (docker/claude/hooks/note-waiting.py)
|
|
345
|
+
# is named after it too.
|
|
346
|
+
MANAGED_PREFIX = "ali-"
|
|
347
|
+
|
|
348
|
+
# The console's session-name rule (webui.server), stated once so the raw
|
|
349
|
+
# tmux argv builders and the HTTP surface can never drift: a managed name
|
|
350
|
+
# starts with a letter or digit and carries only these characters, which is
|
|
351
|
+
# what keeps a `-flag`-shaped or shell-metacharacter name out of any argv.
|
|
352
|
+
SAFE_SESSION = re.compile(r"\A[A-Za-z0-9._][A-Za-z0-9._-]{0,199}\Z")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def tmux_socket() -> str:
|
|
356
|
+
"""The path of the tmux server socket the reviewer sessions live on:
|
|
357
|
+
`$TMUX_TMPDIR/tmux-<uid>/default` (tmux's own default). Spelled out
|
|
358
|
+
rather than left to tmux because the daemon may run without `TMUX` in
|
|
359
|
+
its environment and must still reach the SAME server `alissa tmux new`
|
|
360
|
+
created for the reviewers -- same container, same user, same socket."""
|
|
361
|
+
base = os.environ.get("TMUX_TMPDIR", "").strip() or "/tmp"
|
|
362
|
+
return os.path.join(base, f"tmux-{os.getuid()}", "default")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def tmux_target(name: str) -> str:
|
|
366
|
+
"""The `-t` target for a managed session: the real tmux name, prefixed
|
|
367
|
+
with `=` so tmux matches it EXACTLY (its default is prefix matching, and
|
|
368
|
+
`review-widgets-pr1-r1-abcdef` is a prefix of `…-r1-abcdef0`) and
|
|
369
|
+
suffixed with `:` so the target is the session's CURRENT WINDOW (and its
|
|
370
|
+
active pane) -- a bare `=name` is a session target, which `capture-pane`
|
|
371
|
+
and `send-keys` refuse with "can't find pane" (tmux 3.3a)."""
|
|
372
|
+
real = name if name.startswith(MANAGED_PREFIX) else MANAGED_PREFIX + name
|
|
373
|
+
return "=" + real + ":"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def check_session_name(name: "str | None") -> str:
|
|
377
|
+
"""`name` when it passes SAFE_SESSION, else ValueError. Every raw tmux
|
|
378
|
+
argv builder calls this first."""
|
|
379
|
+
if not name or not SAFE_SESSION.match(name):
|
|
380
|
+
raise ValueError(f"invalid session name {name!r}")
|
|
381
|
+
return name
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def capture_pane_argv(name: str, lines: int) -> "list[str]":
|
|
385
|
+
"""`tmux -S <socket> capture-pane -p -t =<real> -S -<lines>`: the last
|
|
386
|
+
`lines` lines of the session's active pane, printed to stdout, with
|
|
387
|
+
escape sequences stripped (`-p` without `-e`)."""
|
|
388
|
+
check_session_name(name)
|
|
389
|
+
return [
|
|
390
|
+
"tmux", "-S", tmux_socket(), "capture-pane", "-p",
|
|
391
|
+
"-t", tmux_target(name), "-S", f"-{max(1, int(lines))}",
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def pane_path_argv(name: str) -> "list[str]":
|
|
396
|
+
check_session_name(name)
|
|
397
|
+
return [
|
|
398
|
+
"tmux", "-S", tmux_socket(), "display-message", "-p",
|
|
399
|
+
"-t", tmux_target(name), "#{pane_current_path}",
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
|
|
404
|
+
"""`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
|
|
405
|
+
from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
|
|
406
|
+
is a ValueError before any process is spawned: the responder presses a
|
|
407
|
+
dialog's buttons and can never type into a reviewer."""
|
|
408
|
+
check_session_name(name)
|
|
409
|
+
if not keys:
|
|
410
|
+
raise ValueError("send_keys needs at least one key")
|
|
411
|
+
bad = [k for k in keys if k not in ALLOWED_KEYS]
|
|
412
|
+
if bad:
|
|
413
|
+
raise ValueError(
|
|
414
|
+
f"key(s) not in the allowlist {sorted(ALLOWED_KEYS)}: {bad!r}"
|
|
415
|
+
)
|
|
416
|
+
return ["tmux", "-S", tmux_socket(), "send-keys", "-t", tmux_target(name), *keys]
|
|
417
|
+
|
|
418
|
+
|
|
339
419
|
# The reviewer-session GRAMMAR. The sweep kills sessions and the worker
|
|
340
420
|
# container is shared with other lanes (`develop-*`, `fix-*`, `maintain-*`,
|
|
341
421
|
# ...), so only a name that parses as one of the two shapes the review loop
|
|
@@ -419,11 +499,29 @@ class ManagedSession:
|
|
|
419
499
|
# not report one -- treated as "long quiet", so a missing field can never
|
|
420
500
|
# indefinitely immunize a session against the sweep.
|
|
421
501
|
last_activity: float = 0.0
|
|
502
|
+
# The REAL tmux session name (`ali-<name>`, the roster's `session`
|
|
503
|
+
# field), or None when the CLI did not report one. The prompt responder
|
|
504
|
+
# names the waiting marker by it; absent, `MANAGED_PREFIX + name`.
|
|
505
|
+
session: "str | None" = None
|
|
422
506
|
|
|
423
507
|
@property
|
|
424
508
|
def is_idle(self) -> bool:
|
|
425
509
|
return self.status == "idle"
|
|
426
510
|
|
|
511
|
+
@property
|
|
512
|
+
def tmux_name(self) -> str:
|
|
513
|
+
"""The real tmux session name -- the roster's, else derived."""
|
|
514
|
+
return self.session or MANAGED_PREFIX + self.name
|
|
515
|
+
|
|
516
|
+
@property
|
|
517
|
+
def quiet_for(self) -> "float | None":
|
|
518
|
+
"""Seconds since the last tmux activity, or None when the CLI
|
|
519
|
+
reported no timestamp (the sweep reads that 0 as "long quiet"; the
|
|
520
|
+
prompt responder reads it as "unknown" and waits for a marker)."""
|
|
521
|
+
if not self.last_activity:
|
|
522
|
+
return None
|
|
523
|
+
return max(0.0, time.time() - self.last_activity)
|
|
524
|
+
|
|
427
525
|
@property
|
|
428
526
|
def ref(self) -> "SessionRef | None":
|
|
429
527
|
"""What the name says about the round -- see parse_session_name."""
|
|
@@ -1117,11 +1215,13 @@ class Alissa:
|
|
|
1117
1215
|
name = row.get("name")
|
|
1118
1216
|
if isinstance(name, str) and parse_session_name(name) is not None:
|
|
1119
1217
|
last = row.get("lastActivity")
|
|
1218
|
+
real = row.get("session")
|
|
1120
1219
|
sessions.append(
|
|
1121
1220
|
ManagedSession(
|
|
1122
1221
|
name=name,
|
|
1123
1222
|
status=str(row.get("status") or ""),
|
|
1124
1223
|
last_activity=float(last) if isinstance(last, (int, float)) else 0.0,
|
|
1224
|
+
session=real if isinstance(real, str) and real else None,
|
|
1125
1225
|
)
|
|
1126
1226
|
)
|
|
1127
1227
|
return sessions
|
|
@@ -1161,6 +1261,72 @@ class Alissa:
|
|
|
1161
1261
|
log.debug("could not tail %s: %s", session, exc)
|
|
1162
1262
|
return ""
|
|
1163
1263
|
|
|
1264
|
+
def capture_pane(self, name: str, lines: int = 40) -> str:
|
|
1265
|
+
"""The last `lines` of ONE managed session's pane over raw tmux
|
|
1266
|
+
(`capture-pane -p`), or "" when tmux cannot answer (no such session,
|
|
1267
|
+
the socket gone, a timeout).
|
|
1268
|
+
|
|
1269
|
+
The responder's evidence seam (issue #138). Like `tail_session` it
|
|
1270
|
+
returns UNTRUSTED, possibly sensitive terminal content: the caller
|
|
1271
|
+
classifies it and lets only the classification -- plus a scrubbed
|
|
1272
|
+
signature line -- reach an operator-facing level. A READ, so it runs
|
|
1273
|
+
under dry-run too: a dry pass has to classify what a live one would.
|
|
1274
|
+
Raw tmux rather than the `alissa` CLI because the CLI's `tail` is a
|
|
1275
|
+
convenience without `send-keys` beside it, and the answer has to land
|
|
1276
|
+
on the very pane the capture read."""
|
|
1277
|
+
try:
|
|
1278
|
+
argv = capture_pane_argv(name, lines)
|
|
1279
|
+
except ValueError as exc:
|
|
1280
|
+
log.warning("capture_pane refused: %s", exc)
|
|
1281
|
+
return ""
|
|
1282
|
+
try:
|
|
1283
|
+
out = run(argv, timeout=15)
|
|
1284
|
+
except CommandError as exc:
|
|
1285
|
+
log.debug("could not capture the pane of %s (%s)", name, exc)
|
|
1286
|
+
return ""
|
|
1287
|
+
return out.rstrip("\n")
|
|
1288
|
+
|
|
1289
|
+
def pane_path(self, name: str) -> str:
|
|
1290
|
+
"""The session's pane current path (`#{pane_current_path}`), or ""
|
|
1291
|
+
when tmux cannot answer. One leg of the review-checkout resolution:
|
|
1292
|
+
a reviewer that `cd`ed into its `REVIEW-*` checkout names it here."""
|
|
1293
|
+
try:
|
|
1294
|
+
argv = pane_path_argv(name)
|
|
1295
|
+
except ValueError as exc:
|
|
1296
|
+
log.warning("pane_path refused: %s", exc)
|
|
1297
|
+
return ""
|
|
1298
|
+
try:
|
|
1299
|
+
return run(argv, timeout=15).strip()
|
|
1300
|
+
except CommandError as exc:
|
|
1301
|
+
log.debug("could not read the pane path of %s (%s)", name, exc)
|
|
1302
|
+
return ""
|
|
1303
|
+
|
|
1304
|
+
def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
|
|
1305
|
+
"""Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
|
|
1306
|
+
-- on ONE managed session's pane. True when tmux accepted them.
|
|
1307
|
+
|
|
1308
|
+
The ONLY key-pressing surface in this daemon, and deliberately
|
|
1309
|
+
narrow: an allowlisted enum, a validated session name, an exact-match
|
|
1310
|
+
target, one `send-keys` per call. A key outside the allowlist is
|
|
1311
|
+
refused before any process is spawned and logged at WARNING (it is a
|
|
1312
|
+
programming error, never a pane's doing -- keys come from the
|
|
1313
|
+
policy table, not from the screen). Dry-run logs the intent and
|
|
1314
|
+
sends nothing."""
|
|
1315
|
+
try:
|
|
1316
|
+
argv = send_keys_argv(name, tuple(keys))
|
|
1317
|
+
except ValueError as exc:
|
|
1318
|
+
log.warning("send_keys refused for %s: %s", name, exc)
|
|
1319
|
+
return False
|
|
1320
|
+
if dry_run:
|
|
1321
|
+
log.info("[dry-run] would send keys %s to %s", " ".join(keys), name)
|
|
1322
|
+
return False
|
|
1323
|
+
try:
|
|
1324
|
+
run(argv, timeout=15)
|
|
1325
|
+
except CommandError as exc:
|
|
1326
|
+
log.warning("could not send keys %s to %s (%s)", " ".join(keys), name, exc)
|
|
1327
|
+
return False
|
|
1328
|
+
return True
|
|
1329
|
+
|
|
1164
1330
|
def add_repo_to_workspace(
|
|
1165
1331
|
self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
|
|
1166
1332
|
) -> 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
|
)
|