alissa-tools-github-devloop 0.8.28__tar.gz → 0.8.30__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.28/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.30}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/alissa.py +37 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/config.py +113 -3
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/loop.py +218 -10
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/loop_events.py +116 -3
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/prompts.py +207 -6
- alissa_tools_github_devloop-0.8.30/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.8.28/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.28 → alissa_tools_github_devloop-0.8.30}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -80,6 +80,26 @@ def pane_path_argv(name: str) -> "list[str]":
|
|
|
80
80
|
]
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
# What `#{pane_current_command}` reads on a worker pane whose `claude` has
|
|
84
|
+
# EXITED (issue #140): the shell `alissa tmux new` launched it from, back in
|
|
85
|
+
# the foreground. A live worker reads `node` (Claude Code's runtime); a
|
|
86
|
+
# name here means the process the session was spawned for is gone, whatever
|
|
87
|
+
# the session listing says about liveness.
|
|
88
|
+
SHELL_COMMANDS = frozenset((
|
|
89
|
+
"bash", "sh", "zsh", "fish", "dash", "ksh", "ash", "tcsh", "csh",
|
|
90
|
+
))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pane_command_argv(name: str) -> "list[str]":
|
|
94
|
+
"""`tmux -S <socket> display-message -p -t =<real>: #{pane_current_command}`:
|
|
95
|
+
the foreground process of the session's active pane, by name."""
|
|
96
|
+
check_session_name(name)
|
|
97
|
+
return [
|
|
98
|
+
"tmux", "-S", tmux_socket(), "display-message", "-p",
|
|
99
|
+
"-t", tmux_target(name), "#{pane_current_command}",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
83
103
|
def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
|
|
84
104
|
"""`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
|
|
85
105
|
from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
|
|
@@ -270,6 +290,23 @@ class Alissa:
|
|
|
270
290
|
log.debug("could not read the pane path of %s (%s)", name, exc)
|
|
271
291
|
return ""
|
|
272
292
|
|
|
293
|
+
def pane_command(self, name: str) -> str:
|
|
294
|
+
"""The session's pane current command (`#{pane_current_command}`),
|
|
295
|
+
or "" when tmux cannot answer. The first-turn death check's one
|
|
296
|
+
liveness probe (issue #140): a worker whose pane reads a shell
|
|
297
|
+
(`SHELL_COMMANDS`) has lost its `claude`, however alive the tmux
|
|
298
|
+
session itself is. A READ: runs under dry-run too."""
|
|
299
|
+
try:
|
|
300
|
+
argv = pane_command_argv(name)
|
|
301
|
+
except ValueError as exc:
|
|
302
|
+
log.warning("pane_command refused: %s", exc)
|
|
303
|
+
return ""
|
|
304
|
+
try:
|
|
305
|
+
return run(argv, timeout=15).strip()
|
|
306
|
+
except CommandError as exc:
|
|
307
|
+
log.debug("could not read the pane command of %s (%s)", name, exc)
|
|
308
|
+
return ""
|
|
309
|
+
|
|
273
310
|
def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
|
|
274
311
|
"""Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
|
|
275
312
|
-- on ONE managed session's pane. True when tmux accepted them.
|
|
@@ -26,6 +26,10 @@ through to the config rather than fail the daemon's boot on a mode of `''`.
|
|
|
26
26
|
`fleet_vitals_enabled` through `ALISSA_DEV_FLEET_VITALS_ENABLED` likewise.
|
|
27
27
|
`revert_label` rides `ALISSA_REVERT_LABEL` the same way (issue #131): a
|
|
28
28
|
non-blank value wins over the CLI and the file, a blank one falls through.
|
|
29
|
+
|
|
30
|
+
`regenerate` (issue #138) is env-only: `ALISSA_DEV_REGENERATE` maps
|
|
31
|
+
repo-scoped globs to the command that regenerates them, and the daemon only
|
|
32
|
+
ever RENDERS that map into a worker's directive -- it never runs the command.
|
|
29
33
|
"""
|
|
30
34
|
|
|
31
35
|
from __future__ import annotations
|
|
@@ -116,6 +120,94 @@ ENV_FLEET_VITALS = "ALISSA_DEV_FLEET_VITALS_ENABLED"
|
|
|
116
120
|
# it (no `_DEV_` infix), because that is the name the operator runbook gives.
|
|
117
121
|
ENV_REVERT_LABEL = "ALISSA_REVERT_LABEL"
|
|
118
122
|
|
|
123
|
+
# The regenerable-paths map (issue #138): `owner/repo:<glob>=<command>`
|
|
124
|
+
# entries, `|`-separated (the daemon-family list separator -- a command may
|
|
125
|
+
# itself carry `,`, `;` or spaces, so `|` is the ONLY separator here), default
|
|
126
|
+
# empty. Env-only, read straight from the environment like ENV_REVERT_LABEL:
|
|
127
|
+
# not a config key, not a build ARG, not written by the config generator. Each
|
|
128
|
+
# entry is validated at load and a malformed one is refused by NAME -- the
|
|
129
|
+
# variable's and the entry's -- because the failure mode of a silently
|
|
130
|
+
# dropped entry is a worker hand-merging a generated file a poll later. The
|
|
131
|
+
# command is rendered into the worker's directive and runs INSIDE the
|
|
132
|
+
# worker's worktree as part of the session; the daemon never executes it.
|
|
133
|
+
ENV_REGENERATE = "ALISSA_DEV_REGENERATE"
|
|
134
|
+
|
|
135
|
+
# `owner/repo` for a regenerate entry: exactly one `/`, no whitespace, and no
|
|
136
|
+
# `:` (the separator that ends it). Shape only, like the actor-id check.
|
|
137
|
+
_REGENERATE_REPO_RE = re.compile(r"^[^\s/:]+/[^\s/:]+\Z")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class RegenerateRule:
|
|
142
|
+
"""One `ALISSA_DEV_REGENERATE` entry: files under `glob` in `repo` are a
|
|
143
|
+
pure function of the code, regenerated by `command` (run in the worker's
|
|
144
|
+
worktree, never by the daemon). `repo` keeps the operator's casing;
|
|
145
|
+
matching casefolds, as `Config.watches` does."""
|
|
146
|
+
|
|
147
|
+
repo: str
|
|
148
|
+
glob: str
|
|
149
|
+
command: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_regenerate(raw: str) -> "tuple[RegenerateRule, ...]":
|
|
153
|
+
"""Parse the ALISSA_DEV_REGENERATE value into rules, refusing malformed
|
|
154
|
+
entries by name.
|
|
155
|
+
|
|
156
|
+
The grammar is `owner/repo:<glob>=<command>` per entry, split on `|`
|
|
157
|
+
ONLY: the repo ends at the first `:`, the glob at the first `=`, and the
|
|
158
|
+
command is everything after it (so a command may carry `=` and `:`, and a
|
|
159
|
+
glob may carry `:` but not `=`). Whitespace around an entry and its three
|
|
160
|
+
parts is stripped; empty entries (a trailing `|`) are dropped, so a blank
|
|
161
|
+
or unset variable is the empty map -- today's behaviour exactly. An
|
|
162
|
+
entry with a missing part, a repo that is not `owner/repo`, or the same
|
|
163
|
+
repo+glob given twice with different commands is a ValueError naming
|
|
164
|
+
ENV_REGENERATE and the entry: a dropped entry would surface a poll later
|
|
165
|
+
as a hand-merged generated file, which is the very thing the map exists
|
|
166
|
+
to prevent. Exact duplicates collapse.
|
|
167
|
+
"""
|
|
168
|
+
rules: "list[RegenerateRule]" = []
|
|
169
|
+
seen: "dict[tuple[str, str], RegenerateRule]" = {}
|
|
170
|
+
for part in (raw or "").split("|"):
|
|
171
|
+
entry = part.strip()
|
|
172
|
+
if not entry:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
def refuse(problem: str) -> ValueError:
|
|
176
|
+
return ValueError(
|
|
177
|
+
f"{ENV_REGENERATE} entry {entry!r} is malformed: {problem} "
|
|
178
|
+
f"(expected owner/repo:<glob>=<command>, entries separated "
|
|
179
|
+
f"by '|')"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
repo, colon, rest = entry.partition(":")
|
|
183
|
+
if not colon:
|
|
184
|
+
raise refuse("missing ':' between the repo and the glob")
|
|
185
|
+
repo = repo.strip()
|
|
186
|
+
if not _REGENERATE_REPO_RE.match(repo):
|
|
187
|
+
raise refuse(f"repo {repo!r} is not of the form owner/repo")
|
|
188
|
+
glob, equals, command = rest.partition("=")
|
|
189
|
+
if not equals:
|
|
190
|
+
raise refuse("missing '=' between the glob and the command")
|
|
191
|
+
glob = glob.strip()
|
|
192
|
+
command = command.strip()
|
|
193
|
+
if not glob:
|
|
194
|
+
raise refuse("the glob is empty")
|
|
195
|
+
if not command:
|
|
196
|
+
raise refuse("the command is empty")
|
|
197
|
+
rule = RegenerateRule(repo=repo, glob=glob, command=command)
|
|
198
|
+
key = (repo.casefold(), glob)
|
|
199
|
+
prior = seen.get(key)
|
|
200
|
+
if prior is not None:
|
|
201
|
+
if prior.command != command:
|
|
202
|
+
raise refuse(
|
|
203
|
+
f"{repo}:{glob} is already mapped to {prior.command!r}"
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
seen[key] = rule
|
|
207
|
+
rules.append(rule)
|
|
208
|
+
return tuple(rules)
|
|
209
|
+
|
|
210
|
+
|
|
119
211
|
# The prompt responder's master switch (issue #127): `on` answers, `observe`
|
|
120
212
|
# classifies and narrates but sends nothing, `off` never captures a pane.
|
|
121
213
|
PROMPT_ON = "on"
|
|
@@ -744,6 +836,12 @@ class Config:
|
|
|
744
836
|
# to act (at the incident's growth rate, ~2 days). 0 never warns.
|
|
745
837
|
disk_warn_percent: int = 80
|
|
746
838
|
|
|
839
|
+
# The regenerable-paths map (issue #138), from ALISSA_DEV_REGENERATE only
|
|
840
|
+
# (no config key, no CLI flag). Empty by default, and empty means every
|
|
841
|
+
# worker directive renders byte-identical to a build without the map.
|
|
842
|
+
# Read through `regenerate_for(owner, repo)`, never executed here.
|
|
843
|
+
regenerate: tuple[RegenerateRule, ...] = ()
|
|
844
|
+
|
|
747
845
|
dry_run: bool = False
|
|
748
846
|
|
|
749
847
|
# Derived at build time: `repos` casefolded for matching. GitHub names are
|
|
@@ -875,6 +973,13 @@ class Config:
|
|
|
875
973
|
and gets an answer that includes the enrolled repos."""
|
|
876
974
|
return full_name.casefold() in self._repo_match
|
|
877
975
|
|
|
976
|
+
def regenerate_for(self, owner: str, repo: str) -> "tuple[RegenerateRule, ...]":
|
|
977
|
+
"""The regenerate rules scoped to `owner/repo`, in the order the
|
|
978
|
+
operator wrote them; empty for a repo with no entry. Casefolded like
|
|
979
|
+
`watches`: GitHub names are case-insensitive."""
|
|
980
|
+
wanted = f"{owner}/{repo}".casefold()
|
|
981
|
+
return tuple(r for r in self.regenerate if r.repo.casefold() == wanted)
|
|
982
|
+
|
|
878
983
|
def trusts_feed_owner(self, actor_id: "str | None") -> bool:
|
|
879
984
|
"""May a Body of Work owned by this actor act as a repo feed?
|
|
880
985
|
|
|
@@ -900,9 +1005,10 @@ class Config:
|
|
|
900
1005
|
"""Merge the layers and validate. `overrides` entries that are None mean
|
|
901
1006
|
"not specified on the CLI" and fall through to the file / defaults.
|
|
902
1007
|
|
|
903
|
-
`environ` is only read for
|
|
904
|
-
|
|
905
|
-
|
|
1008
|
+
`environ` is only read for the env rails (`ALISSA_DEV_REPOS_SOURCE`
|
|
1009
|
+
and its siblings, see the module docstring, plus the env-only
|
|
1010
|
+
`ALISSA_DEV_REGENERATE` map); it defaults to the real environment and
|
|
1011
|
+
is a parameter so a test never has to monkeypatch it."""
|
|
906
1012
|
raw: dict[str, Any] = dict(file_data or {})
|
|
907
1013
|
|
|
908
1014
|
if "workspace_root" in raw:
|
|
@@ -994,6 +1100,9 @@ class Config:
|
|
|
994
1100
|
revert_label = env_revert_label
|
|
995
1101
|
if not revert_label.strip():
|
|
996
1102
|
raise ValueError("revert_label must not be blank")
|
|
1103
|
+
# The regenerable-paths map (issue #138): env-only, validated here so
|
|
1104
|
+
# a malformed entry fails the boot by name instead of a directive.
|
|
1105
|
+
regenerate = parse_regenerate(env_map.get(ENV_REGENERATE, ""))
|
|
997
1106
|
|
|
998
1107
|
bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
|
|
999
1108
|
|
|
@@ -1284,6 +1393,7 @@ class Config:
|
|
|
1284
1393
|
raw.get("cache_paths", cls.cache_paths), "cache_paths"
|
|
1285
1394
|
),
|
|
1286
1395
|
disk_warn_percent=warn_percent,
|
|
1396
|
+
regenerate=regenerate,
|
|
1287
1397
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
1288
1398
|
)
|
|
1289
1399
|
|
|
@@ -458,13 +458,13 @@ from collections import Counter
|
|
|
458
458
|
from dataclasses import dataclass, replace
|
|
459
459
|
from enum import Enum
|
|
460
460
|
from pathlib import Path
|
|
461
|
-
from typing import Callable
|
|
461
|
+
from typing import Callable, Sequence
|
|
462
462
|
|
|
463
463
|
from . import janitor
|
|
464
464
|
from . import fleet_vitals as fleet_vitals_mod
|
|
465
465
|
from . import loop_events as loop_events_mod
|
|
466
466
|
from . import prompts as prompts_mod
|
|
467
|
-
from .alissa import MANAGED_PREFIX, Alissa
|
|
467
|
+
from .alissa import MANAGED_PREFIX, SHELL_COMMANDS, Alissa
|
|
468
468
|
from .alissa_client import AlissaClient
|
|
469
469
|
from .bows import BowRepoSource
|
|
470
470
|
from .hubs import (
|
|
@@ -486,6 +486,7 @@ from .config import (
|
|
|
486
486
|
PROMPT_OFF,
|
|
487
487
|
REPOS_BOWS,
|
|
488
488
|
Config,
|
|
489
|
+
RegenerateRule,
|
|
489
490
|
)
|
|
490
491
|
from .ghclient import (
|
|
491
492
|
FIRST_PARENT_MAX_PAGES,
|
|
@@ -771,6 +772,49 @@ _SHELL_RULE = (
|
|
|
771
772
|
"change the command; do not retry it. "
|
|
772
773
|
)
|
|
773
774
|
|
|
775
|
+
# The regenerate-on-conflict rule (issue #138), rendered into every worker
|
|
776
|
+
# directive that can end up merging main into its branch -- develop, resume,
|
|
777
|
+
# fix, fix-resume and maintain -- and ONLY for a repo with entries in
|
|
778
|
+
# ALISSA_DEV_REGENERATE: `render_regenerate_rule` returns "" otherwise, so a
|
|
779
|
+
# repo without entries (and a deployment without the variable) renders every
|
|
780
|
+
# directive byte-identical to before the rule existed. A generated file is a
|
|
781
|
+
# pure function of the code; a hand-merge of it usually works and is still
|
|
782
|
+
# the wrong operation (the repo's generated-drift CI job fails any hand-merge
|
|
783
|
+
# that does not match, at the cost of a review round), so the rule is: take
|
|
784
|
+
# either side, finish the merge, rerun the generator, commit. The command
|
|
785
|
+
# runs in the worker's worktree as part of the session -- the daemon never
|
|
786
|
+
# executes it, it only spells it here.
|
|
787
|
+
_REGENERATE_RULE_ONE = (
|
|
788
|
+
"If merging main conflicts inside `{glob}`, do not hand-merge it: "
|
|
789
|
+
"`git checkout --theirs -- <paths>` (or `--ours` — either side), finish "
|
|
790
|
+
"the merge, run `{command}`, and commit the regenerated files in the "
|
|
791
|
+
"same push; the repo's CI verifies them. "
|
|
792
|
+
)
|
|
793
|
+
_REGENERATE_RULE_MANY = (
|
|
794
|
+
"If merging main conflicts inside {paths}, do not hand-merge it: "
|
|
795
|
+
"`git checkout --theirs -- <paths>` (or `--ours` — either side), finish "
|
|
796
|
+
"the merge, run that path's regenerate command, and commit the "
|
|
797
|
+
"regenerated files in the same push; the repo's CI verifies them. "
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def render_regenerate_rule(rules: "Sequence[RegenerateRule]") -> str:
|
|
802
|
+
"""The rule for one repo's ALISSA_DEV_REGENERATE entries, or "" when it
|
|
803
|
+
has none. One entry renders the issue's own sentence with the glob and the
|
|
804
|
+
command substituted; several render one sentence naming each glob with
|
|
805
|
+
its command. Always ends in a space so it splices between two sentences
|
|
806
|
+
of a directive, and its placeholder renders to nothing when empty."""
|
|
807
|
+
if not rules:
|
|
808
|
+
return ""
|
|
809
|
+
if len(rules) == 1:
|
|
810
|
+
return _REGENERATE_RULE_ONE.format(
|
|
811
|
+
glob=rules[0].glob, command=rules[0].command
|
|
812
|
+
)
|
|
813
|
+
named = [f"`{r.glob}` (regenerate with `{r.command}`)" for r in rules]
|
|
814
|
+
paths = ", ".join(named[:-1]) + " or " + named[-1]
|
|
815
|
+
return _REGENERATE_RULE_MANY.format(paths=paths)
|
|
816
|
+
|
|
817
|
+
|
|
774
818
|
DEV_DIRECTIVE = (
|
|
775
819
|
"You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
|
|
776
820
|
"{issue_url} (attempt {attempt} of {cap}) — the issue is your "
|
|
@@ -786,7 +830,8 @@ DEV_DIRECTIVE = (
|
|
|
786
830
|
+ _BRANCH_RULE
|
|
787
831
|
+ _SHELL_RULE
|
|
788
832
|
+ "Implement to the issue's acceptance criteria and VERIFY locally (run the "
|
|
789
|
-
"repo's test/style/type scripts) before publishing.
|
|
833
|
+
"repo's test/style/type scripts) before publishing. {regenerate_rule}"
|
|
834
|
+
"Open a DRAFT PR whose "
|
|
790
835
|
"body contains `Closes #{number}` and BOTH task refs — the origin task "
|
|
791
836
|
"AND your implementation task: the PR body is the canonical cross-actor "
|
|
792
837
|
"record, and each actor's tasks live in its own partition — and attach "
|
|
@@ -845,7 +890,8 @@ RESUME_DIRECTIVE = (
|
|
|
845
890
|
"are unchanged and still authoritative — and VERIFY locally (run the "
|
|
846
891
|
"repo's test/style/type scripts) before publishing. NEVER force-push: "
|
|
847
892
|
"history stays append-only, or the PR's review threads lose their "
|
|
848
|
-
"anchors.
|
|
893
|
+
"anchors. {regenerate_rule}"
|
|
894
|
+
"Keep the PR body carrying `Closes #{number}` and BOTH task refs "
|
|
849
895
|
"(the origin task AND your implementation task) — restoring them if the "
|
|
850
896
|
"dead session did not. "
|
|
851
897
|
+ _SHELL_RULE
|
|
@@ -906,7 +952,7 @@ FIX_DIRECTIVE = (
|
|
|
906
952
|
+ "FIRST verify the triage of every prior review finding per CR8: exactly "
|
|
907
953
|
"one reasoned `[triage:*]` reply on each finding's thread — post any that "
|
|
908
954
|
"are missing. Then fix the pursued findings on the same branch and VERIFY "
|
|
909
|
-
"locally (run the repo's test/style/type scripts). "
|
|
955
|
+
"locally (run the repo's test/style/type scripts). {regenerate_rule}"
|
|
910
956
|
+ _NEWER_ROUNDS
|
|
911
957
|
+ _FIX_DELIVERY_NOTE
|
|
912
958
|
+ " "
|
|
@@ -964,7 +1010,7 @@ FIX_RESUME_DIRECTIVE = (
|
|
|
964
1010
|
"thread it already answered. Then fix the pursued findings, completing "
|
|
965
1011
|
"the ORIGINAL acceptance criteria the issue set, and VERIFY locally (run "
|
|
966
1012
|
"the repo's test/style/type scripts). NEVER force-push: history stays "
|
|
967
|
-
"append-only, or the review threads lose their anchors. "
|
|
1013
|
+
"append-only, or the review threads lose their anchors. {regenerate_rule}"
|
|
968
1014
|
+ _NEWER_ROUNDS
|
|
969
1015
|
+ _FIX_DELIVERY_NOTE
|
|
970
1016
|
+ " "
|
|
@@ -1015,6 +1061,7 @@ MAINTAIN_DIRECTIVE = (
|
|
|
1015
1061
|
"while history is append-only — resolving any conflicts so BOTH sides' "
|
|
1016
1062
|
"intent is preserved, then run the repo's four scripts (unit tests, "
|
|
1017
1063
|
"coverage, style, types) and get ALL of them green before publishing. "
|
|
1064
|
+
"{regenerate_rule}"
|
|
1018
1065
|
+ _REVIEW_SEAT
|
|
1019
1066
|
+ "The terminal sequence's ORDER is load-bearing: push the merge commit, "
|
|
1020
1067
|
"THEN re-request review from {reviewers} — the push stales any approve, "
|
|
@@ -1221,6 +1268,10 @@ PROMPT_PAGE_COMMENT = (
|
|
|
1221
1268
|
"not answering it, and it is HOLDING new worker spawns until the "
|
|
1222
1269
|
"condition clears (live sessions keep running). Operator options: "
|
|
1223
1270
|
"`login_expired` → re-run the claude login on the worker container; "
|
|
1271
|
+
"`auth_rejected` → Claude refused the worker's credential (401): replace "
|
|
1272
|
+
"the container's token (`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`) "
|
|
1273
|
+
"or re-run the claude login — the `auth.rejected` loop event names the "
|
|
1274
|
+
"variable and the token's last four characters; "
|
|
1224
1275
|
"`usage_limit` → wait for the reset or raise the plan; `out_of_credits` "
|
|
1225
1276
|
"→ top up. The hold lifts on the first poll that no longer sees the "
|
|
1226
1277
|
"notice and in any case after {hold_minutes} min on the same pane: the "
|
|
@@ -1528,6 +1579,11 @@ ESCALATION_PROMPT_PAGE = "prompt-page"
|
|
|
1528
1579
|
# a slow container.
|
|
1529
1580
|
PROMPT_SETTLE_SECONDS = 3.0
|
|
1530
1581
|
|
|
1582
|
+
# The first-turn death check's "never looked" marker (issue #140): the
|
|
1583
|
+
# cache maps a session to a finding, to None ("looked: died, not auth"),
|
|
1584
|
+
# or -- absent -- to this, so the two negatives cannot be confused.
|
|
1585
|
+
_UNCHECKED = object()
|
|
1586
|
+
|
|
1531
1587
|
|
|
1532
1588
|
def prompt_event_kind(kind: str, verb: str, session: str, seq: int) -> str:
|
|
1533
1589
|
"""The ledger kind of ONE responder act: `prompt:<kind>:<verb>@<session>#<seq>`.
|
|
@@ -2435,6 +2491,11 @@ class Sweep:
|
|
|
2435
2491
|
waiting: "tuple[dict, ...]" = ()
|
|
2436
2492
|
hold: "str | None" = None
|
|
2437
2493
|
answered: int = 0
|
|
2494
|
+
# The refusals the responder observed this pass (issue #140): the
|
|
2495
|
+
# `auth.rejected` records the loop-events emitter turns into events,
|
|
2496
|
+
# one per session (`{session, repo, edge, number, attempt, round,
|
|
2497
|
+
# source, matched}`).
|
|
2498
|
+
auth_rejected: "tuple[dict, ...]" = ()
|
|
2438
2499
|
|
|
2439
2500
|
@property
|
|
2440
2501
|
def total(self) -> int:
|
|
@@ -2446,12 +2507,14 @@ class Sweep:
|
|
|
2446
2507
|
class PromptSweep:
|
|
2447
2508
|
"""What ONE prompt-responder pass found and did (issue #127): the
|
|
2448
2509
|
sessions parked on a prompt (`{session, kind, since, action}`), the
|
|
2449
|
-
spawn hold an account-level notice raised,
|
|
2450
|
-
sent. Folded into the
|
|
2510
|
+
spawn hold an account-level notice raised, the keystroke answers
|
|
2511
|
+
sent, and the `auth.rejected` records (issue #140). Folded into the
|
|
2512
|
+
pass's `Sweep`."""
|
|
2451
2513
|
|
|
2452
2514
|
waiting: "tuple[dict, ...]" = ()
|
|
2453
2515
|
hold: "str | None" = None
|
|
2454
2516
|
answered: int = 0
|
|
2517
|
+
auth_rejected: "tuple[dict, ...]" = ()
|
|
2455
2518
|
|
|
2456
2519
|
|
|
2457
2520
|
@dataclass(frozen=True, slots=True)
|
|
@@ -3200,6 +3263,16 @@ class DevWatcher:
|
|
|
3200
3263
|
# rebuilt only when the allowlist itself changes rather than per
|
|
3201
3264
|
# session per sweep.
|
|
3202
3265
|
self._slug_repos = _slug_index(config.repos)
|
|
3266
|
+
# The first-turn death check's memory (issue #140): every develop
|
|
3267
|
+
# session whose pane was found back in a shell inside its first
|
|
3268
|
+
# stale window, mapped to what its last lines said -- an
|
|
3269
|
+
# `auth_rejected` finding, or None for the ordinary "died, not
|
|
3270
|
+
# auth". The pane is captured ONCE per session; later passes
|
|
3271
|
+
# re-assert a cached finding (so the hold and its expiry run the
|
|
3272
|
+
# login_expired ladder) and never re-read a None. In-memory on
|
|
3273
|
+
# purpose: a restart re-checks once, and the loop event's key is
|
|
3274
|
+
# the session name, so a re-observation is a silent duplicate.
|
|
3275
|
+
self._exit_findings: "dict[str, prompts_mod.PromptFinding | None]" = {}
|
|
3203
3276
|
# The janitor's two pieces of per-process state: when its expensive
|
|
3204
3277
|
# half last ran (monotonic; None = never, so the first pass of a
|
|
3205
3278
|
# process always sweeps) and the last per-hub breakdown it walked,
|
|
@@ -3680,6 +3753,7 @@ class DevWatcher:
|
|
|
3680
3753
|
"none resolved — follow the alissa-pr-review skill's reviewer "
|
|
3681
3754
|
"resolution"
|
|
3682
3755
|
)
|
|
3756
|
+
regenerate_rule = self._regenerate_rule(issue.owner, issue.repo)
|
|
3683
3757
|
if resume_pr is not None:
|
|
3684
3758
|
directive = RESUME_DIRECTIVE.format(
|
|
3685
3759
|
issue_url=issue.url,
|
|
@@ -3693,6 +3767,7 @@ class DevWatcher:
|
|
|
3693
3767
|
head_ref=resume_pr.head_ref,
|
|
3694
3768
|
worktree_dir=worktree_dir(resume_pr.head_ref),
|
|
3695
3769
|
dead_session=dead_session,
|
|
3770
|
+
regenerate_rule=regenerate_rule,
|
|
3696
3771
|
)
|
|
3697
3772
|
else:
|
|
3698
3773
|
directive = DEV_DIRECTIVE.format(
|
|
@@ -3703,6 +3778,7 @@ class DevWatcher:
|
|
|
3703
3778
|
task_instruction=task_instruction,
|
|
3704
3779
|
reviewers=reviewers,
|
|
3705
3780
|
session=name,
|
|
3781
|
+
regenerate_rule=regenerate_rule,
|
|
3706
3782
|
)
|
|
3707
3783
|
|
|
3708
3784
|
# assign_self FIRST: the assignment is the server-side in-flight
|
|
@@ -6802,6 +6878,7 @@ class DevWatcher:
|
|
|
6802
6878
|
reviewer=review.author,
|
|
6803
6879
|
session=name,
|
|
6804
6880
|
dead_session=dead_session,
|
|
6881
|
+
regenerate_rule=self._regenerate_rule(pr.owner, pr.repo),
|
|
6805
6882
|
)
|
|
6806
6883
|
|
|
6807
6884
|
# BEFORE the enqueue (issue #53), for the reason _spawn spells out:
|
|
@@ -7065,6 +7142,7 @@ class DevWatcher:
|
|
|
7065
7142
|
worktree_dir=worktree_dir(pr.head_ref),
|
|
7066
7143
|
reviewers=reviewers,
|
|
7067
7144
|
session=name,
|
|
7145
|
+
regenerate_rule=self._regenerate_rule(pr.owner, pr.repo),
|
|
7068
7146
|
)
|
|
7069
7147
|
|
|
7070
7148
|
# BEFORE the enqueue (issue #53), for the reason _spawn spells out:
|
|
@@ -7685,6 +7763,13 @@ class DevWatcher:
|
|
|
7685
7763
|
if self._append_activity(owner, repo, number, line, display_slug):
|
|
7686
7764
|
self.state.record_activity(slug_key, number, kind)
|
|
7687
7765
|
|
|
7766
|
+
def _regenerate_rule(self, owner: str, repo: str) -> str:
|
|
7767
|
+
"""The regenerate-on-conflict clause for this repo's directives
|
|
7768
|
+
(issue #138): "" -- and so a byte-identical directive -- unless
|
|
7769
|
+
ALISSA_DEV_REGENERATE names `owner/repo`. Shared by all five worker
|
|
7770
|
+
directives. Rendered only: the command is the worker's to run."""
|
|
7771
|
+
return render_regenerate_rule(self.config.regenerate_for(owner, repo))
|
|
7772
|
+
|
|
7688
7773
|
def _ensure_hub(self, owner: str, repo: str) -> tuple[Path, str | None]:
|
|
7689
7774
|
"""Resolve a spawn's cwd (the hub ROOT), hub-ifying the repo first if
|
|
7690
7775
|
configured. Shared by all three edges (issue, fix, and maintenance
|
|
@@ -8530,6 +8615,7 @@ class DevWatcher:
|
|
|
8530
8615
|
waiting: "list[dict]" = []
|
|
8531
8616
|
hold: "str | None" = None
|
|
8532
8617
|
answered = 0
|
|
8618
|
+
rejected: "list[dict]" = []
|
|
8533
8619
|
for entry in sessions:
|
|
8534
8620
|
name = str(entry.get("name") or "")
|
|
8535
8621
|
worker = parse_worker_session(name)
|
|
@@ -8537,6 +8623,20 @@ class DevWatcher:
|
|
|
8537
8623
|
continue
|
|
8538
8624
|
live.add(name)
|
|
8539
8625
|
tmux_name = str(entry.get("session") or "") or MANAGED_PREFIX + name
|
|
8626
|
+
# The first-turn death check (issue #140) runs BEFORE the two
|
|
8627
|
+
# triggers below: a pane whose `claude` exited is a shell at
|
|
8628
|
+
# rest, which neither a marker nor the quiet gate reads as a
|
|
8629
|
+
# prompt, and which the banner classifier would call idle.
|
|
8630
|
+
exited = self._first_turn_death(worker, name)
|
|
8631
|
+
if exited is not None:
|
|
8632
|
+
outcome = self._answer_prompt(worker, tmux_name, None, exited, mode)
|
|
8633
|
+
waiting.append(outcome["waiting"])
|
|
8634
|
+
answered += outcome["answered"]
|
|
8635
|
+
if outcome["hold"] and hold is None:
|
|
8636
|
+
hold = outcome["hold"]
|
|
8637
|
+
if outcome["auth"]:
|
|
8638
|
+
rejected.append(outcome["auth"])
|
|
8639
|
+
continue
|
|
8540
8640
|
marker = self._waiting_marker(tmux_name)
|
|
8541
8641
|
quiet = self._entry_quiet_for(entry)
|
|
8542
8642
|
if marker is None and (
|
|
@@ -8565,8 +8665,88 @@ class DevWatcher:
|
|
|
8565
8665
|
answered += outcome["answered"]
|
|
8566
8666
|
if outcome["hold"] and hold is None:
|
|
8567
8667
|
hold = outcome["hold"]
|
|
8668
|
+
if outcome["auth"]:
|
|
8669
|
+
rejected.append(outcome["auth"])
|
|
8568
8670
|
self.state.prune_prompt_sightings(live)
|
|
8569
|
-
|
|
8671
|
+
for gone in [n for n in self._exit_findings if n not in live]:
|
|
8672
|
+
del self._exit_findings[gone]
|
|
8673
|
+
return PromptSweep(
|
|
8674
|
+
waiting=tuple(waiting), hold=hold, answered=answered,
|
|
8675
|
+
auth_rejected=tuple(rejected),
|
|
8676
|
+
)
|
|
8677
|
+
|
|
8678
|
+
def _first_turn_death(
|
|
8679
|
+
self, worker: WorkerSession, name: str
|
|
8680
|
+
) -> "prompts_mod.PromptFinding | None":
|
|
8681
|
+
"""The first-turn death check (issue #140): an `auth_rejected`
|
|
8682
|
+
finding with `source: "exit"` for a develop session whose `claude`
|
|
8683
|
+
died on a 401 before it could do anything, else None.
|
|
8684
|
+
|
|
8685
|
+
The candidate is narrow by design, and every leg is cheap or rare:
|
|
8686
|
+
a DEVELOP session (the only edge whose lane has no PR until the
|
|
8687
|
+
worker opens one) that this ledger spawned inside the last stale
|
|
8688
|
+
window, whose pane's current command is back in `SHELL_COMMANDS`
|
|
8689
|
+
(one `display-message` per young develop session per pass -- a
|
|
8690
|
+
handful, for twenty minutes), with no open PR linked to its issue
|
|
8691
|
+
(one timeline read, only for a dead shell). Its last lines are
|
|
8692
|
+
captured ONCE and read by `prompts.classify_exit`; a match is
|
|
8693
|
+
remembered and re-asserted on later passes while the pane is still
|
|
8694
|
+
a shell, so the page-and-hold path and its one-hour expiry run
|
|
8695
|
+
exactly as they do for a login_expired banner. No match is the
|
|
8696
|
+
ordinary "died, not auth": remembered too (never re-read), and
|
|
8697
|
+
nothing new is emitted -- the stale-retry edge's respawn is the
|
|
8698
|
+
remedy it always was."""
|
|
8699
|
+
cached = self._exit_findings.get(name, _UNCHECKED)
|
|
8700
|
+
if cached is None:
|
|
8701
|
+
return None
|
|
8702
|
+
if cached is not _UNCHECKED:
|
|
8703
|
+
command = self.alissa.pane_command(name)
|
|
8704
|
+
if command in SHELL_COMMANDS or not command:
|
|
8705
|
+
# Still the dead shell -- or tmux could not answer ("" is
|
|
8706
|
+
# a failed read, never a command), in which case the last
|
|
8707
|
+
# thing known stands: forgetting it here would re-arm a
|
|
8708
|
+
# second capture inside the stale window and drop the
|
|
8709
|
+
# hold and its expiry past it, for a shell still dead.
|
|
8710
|
+
return cached # type: ignore[return-value]
|
|
8711
|
+
# A named, non-shell command: someone re-ran claude by hand in
|
|
8712
|
+
# the session -- a live pane again, and the ordinary triggers
|
|
8713
|
+
# own it from here.
|
|
8714
|
+
del self._exit_findings[name]
|
|
8715
|
+
return None
|
|
8716
|
+
if not worker.is_issue:
|
|
8717
|
+
return None
|
|
8718
|
+
row = self.state.spawn_for_session(name)
|
|
8719
|
+
if row is None:
|
|
8720
|
+
return None
|
|
8721
|
+
if time.time() - int(row["spawned_at"]) >= self.config.stale_minutes * 60:
|
|
8722
|
+
return None
|
|
8723
|
+
if self.alissa.pane_command(name) not in SHELL_COMMANDS:
|
|
8724
|
+
return None
|
|
8725
|
+
repo = self._slug_repos.get(worker.slug)
|
|
8726
|
+
if repo is None:
|
|
8727
|
+
return None
|
|
8728
|
+
try:
|
|
8729
|
+
linked = self.github.linked_open_prs(repo[0], repo[1], worker.number)
|
|
8730
|
+
except Exception as exc:
|
|
8731
|
+
log.debug(
|
|
8732
|
+
"first-turn death check: could not read the PRs linked to %s#%d "
|
|
8733
|
+
"(%s) — deferring the check to the next pass",
|
|
8734
|
+
row["repo_slug"], worker.number, exc,
|
|
8735
|
+
)
|
|
8736
|
+
return None
|
|
8737
|
+
if linked:
|
|
8738
|
+
return None
|
|
8739
|
+
pane = self.alissa.capture_pane(name, prompts_mod.PANE_LINES)
|
|
8740
|
+
finding = prompts_mod.classify_exit(pane)
|
|
8741
|
+
self._exit_findings[name] = finding
|
|
8742
|
+
if finding is None:
|
|
8743
|
+
log.info(
|
|
8744
|
+
"first-turn death check: %s exited to a shell %d s after its "
|
|
8745
|
+
"spawn with no linked PR and no auth wording in its last lines "
|
|
8746
|
+
"— died, not auth; the retry edge owns it",
|
|
8747
|
+
name, int(time.time() - int(row["spawned_at"])),
|
|
8748
|
+
)
|
|
8749
|
+
return finding
|
|
8570
8750
|
|
|
8571
8751
|
def _answer_prompt(
|
|
8572
8752
|
self,
|
|
@@ -8711,10 +8891,16 @@ class DevWatcher:
|
|
|
8711
8891
|
tail = " · killed; the ordinary retry edge respawns"
|
|
8712
8892
|
elif action.sends_keys and not sent and not self.config.dry_run:
|
|
8713
8893
|
tail = " · keys not sent (tmux refused)"
|
|
8894
|
+
# The auth kinds name the wording and where it was read (the
|
|
8895
|
+
# `auth.rejected` event's own tokens, never the pane's text).
|
|
8896
|
+
auth_tail = (
|
|
8897
|
+
f" · matched {finding.matched} · source {finding.source}"
|
|
8898
|
+
if finding.kind in prompts_mod.AUTH_KINDS and finding.matched else ""
|
|
8899
|
+
)
|
|
8714
8900
|
line = self._activity_line(
|
|
8715
8901
|
name, f"{worker.edge} edge",
|
|
8716
8902
|
f"{prefix}: {finding.kind} → {action.verb} ({action.reason})"
|
|
8717
|
-
f"{f' · target {target_rel}' if target_rel else ''}"
|
|
8903
|
+
f"{f' · target {target_rel}' if target_rel else ''}{auth_tail}"
|
|
8718
8904
|
f" · {since} s after it appeared{tail}",
|
|
8719
8905
|
)
|
|
8720
8906
|
self._append_activity(owner, repo_name, worker.number, line, f"{slug}#{worker.number}")
|
|
@@ -8728,6 +8914,25 @@ class DevWatcher:
|
|
|
8728
8914
|
"prompt responder: %s (%s) is not on the allowlist — %s → %s "
|
|
8729
8915
|
"recorded in the log only", name, worker.slug, finding.kind, action.verb,
|
|
8730
8916
|
)
|
|
8917
|
+
# The `auth.rejected` record (issue #140): the two AUTH_KINDS are
|
|
8918
|
+
# the same signal, and the record carries the vocabulary tokens and
|
|
8919
|
+
# the lane's coordinates -- never the signature. Live mode only:
|
|
8920
|
+
# `observe` pages nobody and holds nothing, and Studio acts on this
|
|
8921
|
+
# event (it flips the stored credential), so an observing daemon
|
|
8922
|
+
# must not make that claim either.
|
|
8923
|
+
auth: "dict | None" = None
|
|
8924
|
+
if finding.kind in prompts_mod.AUTH_KINDS and live_mode:
|
|
8925
|
+
auth = {
|
|
8926
|
+
"session": name,
|
|
8927
|
+
"edge": "issue" if worker.is_issue else "pr",
|
|
8928
|
+
"number": worker.number,
|
|
8929
|
+
"attempt": worker.attempt,
|
|
8930
|
+
"round": worker.round_,
|
|
8931
|
+
"source": finding.source,
|
|
8932
|
+
"matched": finding.matched or prompts_mod.MATCHED_API_ERROR_401,
|
|
8933
|
+
}
|
|
8934
|
+
if repo:
|
|
8935
|
+
auth["repo"] = slug
|
|
8731
8936
|
return {
|
|
8732
8937
|
"waiting": {
|
|
8733
8938
|
"session": name, "kind": finding.kind,
|
|
@@ -8736,6 +8941,7 @@ class DevWatcher:
|
|
|
8736
8941
|
},
|
|
8737
8942
|
"answered": 1 if sent else 0,
|
|
8738
8943
|
"hold": hold,
|
|
8944
|
+
"auth": auth,
|
|
8739
8945
|
}
|
|
8740
8946
|
|
|
8741
8947
|
def _page_prompt(
|
|
@@ -8926,6 +9132,7 @@ class DevWatcher:
|
|
|
8926
9132
|
prompt = self._respond_to_prompts(sessions, sweep.names)
|
|
8927
9133
|
sweep = replace(
|
|
8928
9134
|
sweep, waiting=prompt.waiting, hold=prompt.hold, answered=prompt.answered,
|
|
9135
|
+
auth_rejected=prompt.auth_rejected,
|
|
8929
9136
|
)
|
|
8930
9137
|
if sweep.hold:
|
|
8931
9138
|
log.warning(
|
|
@@ -9991,6 +10198,7 @@ class DevWatcher:
|
|
|
9991
10198
|
self.loop_events.emit_pass(
|
|
9992
10199
|
deferrals=deferrals,
|
|
9993
10200
|
reaps=self._reap_records(sweep.names),
|
|
10201
|
+
auth_rejections=list(sweep.auth_rejected),
|
|
9994
10202
|
now_ms=int(time.time() * 1000),
|
|
9995
10203
|
)
|
|
9996
10204
|
|
|
@@ -69,6 +69,22 @@ Event vocabulary (the `kind` field; Studio validates the SHAPE
|
|
|
69
69
|
`escalation.stacked_base`, `escalation.foreign_session`, …): Studio's kind
|
|
70
70
|
shape allows no hyphens, so the raw ledger kind rides in ``data.ledgerKind``
|
|
71
71
|
and `reason` instead.
|
|
72
|
+
* ``auth.rejected`` — a worker REFUSED by Claude (issue #140; Studio design
|
|
73
|
+
`managed-dark-factory-claude-auth.md` §2.6, lane L5): the prompt responder
|
|
74
|
+
saw a documented 401 wording (or the login-expired banner) on a live
|
|
75
|
+
pane, or the first-turn death check found one in the last lines of a pane
|
|
76
|
+
whose `claude` had already exited. Pass-observed and keyed on the SESSION
|
|
77
|
+
name (`auth_rejected_key`), so one session emits at most one whatever
|
|
78
|
+
the pass count. ``data: { status: 401, source: "pane" | "exit", matched,
|
|
79
|
+
envName, tokenSuffix }`` — `envName` is which of `ANTHROPIC_API_KEY` /
|
|
80
|
+
`CLAUDE_CODE_OAUTH_TOKEN` the container carries, read BY NAME ONLY (`null`
|
|
81
|
+
on a persisted `claude /login`); `tokenSuffix` is the last four characters
|
|
82
|
+
of that variable's value, computed in-process and emitted as the suffix
|
|
83
|
+
only (`null` beside a `null` envName). The suffix is the design's R1 key:
|
|
84
|
+
Studio's reducer flips a credential only when the suffix matches the
|
|
85
|
+
stored row, so a container still running an OLD token after a same-mode
|
|
86
|
+
replace cannot flip the new, good credential. `reason` is a fixed
|
|
87
|
+
sentence, never the pane's text.
|
|
72
88
|
|
|
73
89
|
No ``poll`` vitals events: live vitals stay a console pull (`/api/state`).
|
|
74
90
|
"""
|
|
@@ -76,6 +92,7 @@ No ``poll`` vitals events: live vitals stay a console pull (`/api/state`).
|
|
|
76
92
|
from __future__ import annotations
|
|
77
93
|
|
|
78
94
|
import logging
|
|
95
|
+
import os
|
|
79
96
|
import re
|
|
80
97
|
from typing import Iterable, Mapping, Sequence
|
|
81
98
|
|
|
@@ -253,6 +270,14 @@ def reap_key(session: str) -> str:
|
|
|
253
270
|
return f"{SEAT}:reap:{session}"
|
|
254
271
|
|
|
255
272
|
|
|
273
|
+
def auth_rejected_key(session: str) -> str:
|
|
274
|
+
"""One `auth.rejected` per SESSION (issue #140): worker names are
|
|
275
|
+
deterministic per (repo, number, attempt) and a session is refused
|
|
276
|
+
once -- the pane detector seeing the same banner on every pass, or the
|
|
277
|
+
exit check re-asserting a dead shell, is the same refusal."""
|
|
278
|
+
return f"{SEAT}:auth.rejected:{session}"
|
|
279
|
+
|
|
280
|
+
|
|
256
281
|
def escalation_key(event_kind: str, repo_slug: str, number: int,
|
|
257
282
|
raw_kind: str) -> str:
|
|
258
283
|
"""Escalation-derived events key on the ledger PRIMARY KEY verbatim
|
|
@@ -494,6 +519,83 @@ def reap_events(reaps: "Sequence[Mapping]", at_ms: int) -> "list[dict]":
|
|
|
494
519
|
return events
|
|
495
520
|
|
|
496
521
|
|
|
522
|
+
# -- auth.rejected (issue #140) ---------------------------------------------
|
|
523
|
+
|
|
524
|
+
# The credential variables a container may carry, in Claude Code's own
|
|
525
|
+
# precedence order (design §1.4: `ANTHROPIC_API_KEY` outranks
|
|
526
|
+
# `CLAUDE_CODE_OAUTH_TOKEN`; both outrank the persisted login). Read by
|
|
527
|
+
# NAME only -- the value is touched for its last four characters and for
|
|
528
|
+
# nothing else, and never leaves this function whole.
|
|
529
|
+
AUTH_ENV_NAMES = ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN")
|
|
530
|
+
TOKEN_SUFFIX_CHARS = 4
|
|
531
|
+
AUTH_STATUS = 401
|
|
532
|
+
|
|
533
|
+
# The fixed `reason` sentences -- never the pane's text (the contract's
|
|
534
|
+
# `reason` is ≤ 2000 characters, and a pane is another agent's screen).
|
|
535
|
+
AUTH_REASONS = {
|
|
536
|
+
"pane": "claude answered 401 — the worker's pane shows an auth banner",
|
|
537
|
+
"exit": "claude answered 401 on the first turn and exited",
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def credential_identity(
|
|
542
|
+
environ: "Mapping[str, str] | None" = None,
|
|
543
|
+
) -> "tuple[str | None, str | None]":
|
|
544
|
+
"""`(envName, tokenSuffix)` for the container this daemon runs in: the
|
|
545
|
+
first of AUTH_ENV_NAMES that is set and non-blank, and the last
|
|
546
|
+
TOKEN_SUFFIX_CHARS characters of its value; `(None, None)` when neither
|
|
547
|
+
is set -- a container running on a persisted `claude /login`. The
|
|
548
|
+
workers are spawned on this same container by `alissa tmux`, so the
|
|
549
|
+
daemon's environment IS theirs."""
|
|
550
|
+
env = os.environ if environ is None else environ
|
|
551
|
+
for name in AUTH_ENV_NAMES:
|
|
552
|
+
value = str(env.get(name) or "").strip()
|
|
553
|
+
if value:
|
|
554
|
+
return name, value[-TOKEN_SUFFIX_CHARS:]
|
|
555
|
+
return None, None
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def auth_rejected_events(
|
|
559
|
+
rejections: "Sequence[Mapping]",
|
|
560
|
+
at_ms: int,
|
|
561
|
+
environ: "Mapping[str, str] | None" = None,
|
|
562
|
+
) -> "list[dict]":
|
|
563
|
+
"""Pass-observed refusals as events. Each entry carries `session`,
|
|
564
|
+
`source` (`pane` / `exit`), `matched` (one of the design's tokens),
|
|
565
|
+
and -- when the session resolved to a repo -- `repo`, `edge`
|
|
566
|
+
(`issue`/`pr`), `number`, `attempt`, `round`. Keyed on the session
|
|
567
|
+
name alone (see `auth_rejected_key`). `data` is the design's §2.6
|
|
568
|
+
shape, `envName` and `tokenSuffix` from `credential_identity`."""
|
|
569
|
+
env_name, suffix = credential_identity(environ)
|
|
570
|
+
events = []
|
|
571
|
+
for item in rejections:
|
|
572
|
+
edge = str(item.get("edge") or "")
|
|
573
|
+
number = item.get("number")
|
|
574
|
+
source = str(item.get("source") or "pane")
|
|
575
|
+
events.append(_event(
|
|
576
|
+
"auth.rejected",
|
|
577
|
+
at_ms,
|
|
578
|
+
auth_rejected_key(str(item["session"])),
|
|
579
|
+
repo=str(item.get("repo") or "") or None,
|
|
580
|
+
issue_number=int(number) if number is not None and edge == "issue"
|
|
581
|
+
else None,
|
|
582
|
+
pr_number=int(number) if number is not None and edge == "pr"
|
|
583
|
+
else None,
|
|
584
|
+
attempt=item.get("attempt"),
|
|
585
|
+
round_=item.get("round"),
|
|
586
|
+
session=str(item["session"]),
|
|
587
|
+
reason=AUTH_REASONS.get(source, AUTH_REASONS["pane"]),
|
|
588
|
+
data={
|
|
589
|
+
"status": AUTH_STATUS,
|
|
590
|
+
"source": source,
|
|
591
|
+
"matched": str(item.get("matched") or "api_error_401"),
|
|
592
|
+
"envName": env_name,
|
|
593
|
+
"tokenSuffix": suffix,
|
|
594
|
+
},
|
|
595
|
+
))
|
|
596
|
+
return events
|
|
597
|
+
|
|
598
|
+
|
|
497
599
|
# -- the emitter -------------------------------------------------------------
|
|
498
600
|
|
|
499
601
|
|
|
@@ -513,9 +615,16 @@ class LoopEventEmitter:
|
|
|
513
615
|
with its flag intact.
|
|
514
616
|
"""
|
|
515
617
|
|
|
516
|
-
def __init__(
|
|
618
|
+
def __init__(
|
|
619
|
+
self, state, client: "AlissaClient",
|
|
620
|
+
environ: "Mapping[str, str] | None" = None,
|
|
621
|
+
):
|
|
517
622
|
self.state = state
|
|
518
623
|
self.client = client
|
|
624
|
+
# Where `auth.rejected` reads the credential's name and suffix
|
|
625
|
+
# (issue #140): the process environment by default, injectable so a
|
|
626
|
+
# test never depends on -- or leaks -- the seat's own token.
|
|
627
|
+
self.environ = environ
|
|
519
628
|
self._emitted: "set[str]" = set()
|
|
520
629
|
self._observed: "dict[str, dict]" = {}
|
|
521
630
|
self._resume_keys: "set[str]" = set()
|
|
@@ -530,6 +639,7 @@ class LoopEventEmitter:
|
|
|
530
639
|
*,
|
|
531
640
|
deferrals: "Sequence[Mapping]" = (),
|
|
532
641
|
reaps: "Sequence[Mapping]" = (),
|
|
642
|
+
auth_rejections: "Sequence[Mapping]" = (),
|
|
533
643
|
now_ms: int,
|
|
534
644
|
) -> int:
|
|
535
645
|
"""Derive this pass's events and POST what the server has not yet
|
|
@@ -538,10 +648,13 @@ class LoopEventEmitter:
|
|
|
538
648
|
Returns how many events were accepted this pass. NEVER raises: any
|
|
539
649
|
failure — a wedged ledger read, a transport error, an ingest 400 —
|
|
540
650
|
logs one warning naming the count (and status, when there is one)
|
|
541
|
-
and the pass completes.
|
|
651
|
+
and the pass completes. `auth_rejections` (issue #140) are
|
|
652
|
+
pass-observed like the reaps: carried until a POST lands, first
|
|
653
|
+
observation per session wins."""
|
|
542
654
|
try:
|
|
543
655
|
for event in (defer_events(deferrals, now_ms)
|
|
544
|
-
+ reap_events(reaps, now_ms)
|
|
656
|
+
+ reap_events(reaps, now_ms)
|
|
657
|
+
+ auth_rejected_events(auth_rejections, now_ms, self.environ)):
|
|
545
658
|
self._observed.setdefault(str(event["dedupeKey"]), event)
|
|
546
659
|
events = (
|
|
547
660
|
spawn_events(
|
|
@@ -50,6 +50,12 @@ KIND_PERMISSION = "permission"
|
|
|
50
50
|
KIND_TRUST = "trust"
|
|
51
51
|
KIND_RESUME_PICKER = "resume_picker"
|
|
52
52
|
KIND_LOGIN_EXPIRED = "login_expired"
|
|
53
|
+
# A 401 from the API on a worker's turn (issue #140, Studio design
|
|
54
|
+
# managed-dark-factory-claude-auth §2.6): the credential the container
|
|
55
|
+
# carries -- an env token or the persisted login -- was REFUSED. Same
|
|
56
|
+
# family as login_expired (an account-level notice: page, hold spawns, no
|
|
57
|
+
# keys) and the same loop-event signal (`auth.rejected`, see `matched`).
|
|
58
|
+
KIND_AUTH_REJECTED = "auth_rejected"
|
|
53
59
|
KIND_USAGE_LIMIT = "usage_limit"
|
|
54
60
|
KIND_OUT_OF_CREDITS = "out_of_credits"
|
|
55
61
|
KIND_UNKNOWN_DIALOG = "unknown_dialog"
|
|
@@ -60,6 +66,7 @@ KINDS = (
|
|
|
60
66
|
KIND_TRUST,
|
|
61
67
|
KIND_RESUME_PICKER,
|
|
62
68
|
KIND_LOGIN_EXPIRED,
|
|
69
|
+
KIND_AUTH_REJECTED,
|
|
63
70
|
KIND_USAGE_LIMIT,
|
|
64
71
|
KIND_OUT_OF_CREDITS,
|
|
65
72
|
KIND_UNKNOWN_DIALOG,
|
|
@@ -68,7 +75,38 @@ KINDS = (
|
|
|
68
75
|
# The account-level kinds: nothing a keystroke can fix. The policy pages the
|
|
69
76
|
# operator and holds NEW spawns while the condition stands, so attempts are
|
|
70
77
|
# not burned against a dead account.
|
|
71
|
-
ACCOUNT_KINDS = frozenset((
|
|
78
|
+
ACCOUNT_KINDS = frozenset((
|
|
79
|
+
KIND_LOGIN_EXPIRED, KIND_AUTH_REJECTED, KIND_USAGE_LIMIT, KIND_OUT_OF_CREDITS,
|
|
80
|
+
))
|
|
81
|
+
|
|
82
|
+
# The two kinds that ARE the `auth.rejected` loop event (issue #140): a
|
|
83
|
+
# refused credential, whichever wording Claude Code chose for it. The
|
|
84
|
+
# existing login_expired banner is the same signal under its old name.
|
|
85
|
+
AUTH_KINDS = frozenset((KIND_LOGIN_EXPIRED, KIND_AUTH_REJECTED))
|
|
86
|
+
|
|
87
|
+
# -- the auth.rejected vocabulary (design §2.6) ---------------------------
|
|
88
|
+
|
|
89
|
+
# `data.matched` -- WHICH documented wording the pane showed. A closed list,
|
|
90
|
+
# settled in the design before any daemon emits it; Studio's reducer reads
|
|
91
|
+
# the suffix, not this, so the token is for the operator's eyes.
|
|
92
|
+
MATCHED_API_ERROR_401 = "api_error_401"
|
|
93
|
+
MATCHED_INVALID_API_KEY = "invalid_api_key"
|
|
94
|
+
MATCHED_OAUTH_EXPIRED = "oauth_expired"
|
|
95
|
+
MATCHED_OAUTH_REVOKED = "oauth_revoked"
|
|
96
|
+
MATCHED_LOGIN_EXPIRED = "login_expired"
|
|
97
|
+
AUTH_MATCHED = (
|
|
98
|
+
MATCHED_API_ERROR_401,
|
|
99
|
+
MATCHED_INVALID_API_KEY,
|
|
100
|
+
MATCHED_OAUTH_EXPIRED,
|
|
101
|
+
MATCHED_OAUTH_REVOKED,
|
|
102
|
+
MATCHED_LOGIN_EXPIRED,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# `data.source` -- where the detector read the wording: a still-open pane
|
|
106
|
+
# (the banner classifier) or the last lines of a pane whose `claude`
|
|
107
|
+
# already exited back to the shell (the first-turn death check).
|
|
108
|
+
SOURCE_PANE = "pane"
|
|
109
|
+
SOURCE_EXIT = "exit"
|
|
72
110
|
|
|
73
111
|
# -- keys ----------------------------------------------------------------
|
|
74
112
|
|
|
@@ -122,6 +160,16 @@ BANNER_LINES = 3
|
|
|
122
160
|
# excerpt the issue allows: three lines, secrets-scrubbed, never more).
|
|
123
161
|
EXCERPT_LINES = 3
|
|
124
162
|
|
|
163
|
+
# How many non-blank lines from the bottom the first-turn death check reads
|
|
164
|
+
# (issue #140). A `claude` that 401s prints the error and exits; the shell
|
|
165
|
+
# then draws its prompt (and, on some shells, a blank line or two) under it,
|
|
166
|
+
# so the wording sits a few lines above the bottom -- never as the LAST
|
|
167
|
+
# thing on screen, which is why the banner rule cannot see it. Twelve lines
|
|
168
|
+
# hold the error, the JSON body Claude Code sometimes wraps it in, and the
|
|
169
|
+
# shell chrome, and nothing older: the scrollback above is the worker's own
|
|
170
|
+
# turn, where a quoted phrase would be a false positive.
|
|
171
|
+
EXIT_SCAN_LINES = 12
|
|
172
|
+
|
|
125
173
|
# The default per-kind page cadence for the account-level kinds: one
|
|
126
174
|
# operator page per kind per six hours through the escalation ledger.
|
|
127
175
|
PAGE_WINDOW_SECONDS = 6 * 3600
|
|
@@ -161,6 +209,34 @@ _LOGIN_EXPIRED_RE = re.compile(
|
|
|
161
209
|
r"(?:Your )?(?:session|login|token) (?:has )?expired\b)",
|
|
162
210
|
re.IGNORECASE,
|
|
163
211
|
)
|
|
212
|
+
# The 401 wordings Anthropic documents (issue #140; design §1.4: the Claude
|
|
213
|
+
# Code *Errors* page and the Platform *Authentication* page): `API Error:
|
|
214
|
+
# 401 …`, `Invalid authentication credentials`, `Invalid API key`, `OAuth
|
|
215
|
+
# token has expired`, `OAuth token revoked`. Anchored like the banners
|
|
216
|
+
# above (an optional error glyph is the only thing allowed before the
|
|
217
|
+
# wording), and read on the de-decorated line: Claude Code draws the API
|
|
218
|
+
# error on a `⎿` row of its own under the turn that failed, so
|
|
219
|
+
# `_banner_line` admits THAT row for this one family -- a `●` turn or tool
|
|
220
|
+
# call above it in the same paragraph still makes it a worker's output.
|
|
221
|
+
_AUTH_REJECTED_RE = re.compile(
|
|
222
|
+
r"^(?:[✗✘⚠]\s*)?(?:API Error:\s*401\b|Invalid authentication credentials\b|"
|
|
223
|
+
r"Invalid API key\b|OAuth token (?:has )?expired\b|"
|
|
224
|
+
r"OAuth token (?:has been |was )?revoked\b)",
|
|
225
|
+
re.IGNORECASE,
|
|
226
|
+
)
|
|
227
|
+
# The specific wordings, tested on an auth line AFTER `_AUTH_REJECTED_RE`
|
|
228
|
+
# admitted it, most specific first; a line none of them names is the bare
|
|
229
|
+
# 401 (`api_error_401`), which is also what `API Error: 401 Invalid
|
|
230
|
+
# authentication credentials` reads as -- the credentials sentence is the
|
|
231
|
+
# 401's own message.
|
|
232
|
+
_AUTH_MATCHED_RES = (
|
|
233
|
+
(MATCHED_INVALID_API_KEY, re.compile(r"Invalid API key\b", re.IGNORECASE)),
|
|
234
|
+
(MATCHED_OAUTH_EXPIRED, re.compile(r"OAuth token (?:has )?expired\b", re.IGNORECASE)),
|
|
235
|
+
(MATCHED_OAUTH_REVOKED, re.compile(r"OAuth token (?:has been |was )?revoked\b", re.IGNORECASE)),
|
|
236
|
+
)
|
|
237
|
+
# A `⎿` tool-OUTPUT row specifically (the `●` turn row is the other half of
|
|
238
|
+
# `_OUTPUT_ROW_RE`): the row Claude Code draws its own API error on.
|
|
239
|
+
_TOOL_OUTPUT_ROW_RE = re.compile(r"^\s*⎿")
|
|
164
240
|
_USAGE_LIMIT_RE = re.compile(
|
|
165
241
|
r"^(?:(?:You've|You have) hit your (?:(?:weekly|daily|5-hour|session|usage) )?limit\b|"
|
|
166
242
|
r"(?:Weekly|Daily|Session|Usage) limit reached\b|Usage limit\b|"
|
|
@@ -178,6 +254,11 @@ _OUT_OF_CREDITS_RE = re.compile(
|
|
|
178
254
|
# (`⎿ ...`): a worker's output, which Claude Code never uses to draw a
|
|
179
255
|
# notice of its own. Tested on the ANSI-stripped line, decoration intact.
|
|
180
256
|
_OUTPUT_ROW_RE = re.compile(r"^\s*[●⎿]")
|
|
257
|
+
# The row Claude Code draws for a SUBMITTED prompt (`> implement issue
|
|
258
|
+
# #7`) or the bare input prompt at column 0 (`❯`): the boundary between
|
|
259
|
+
# one worker turn and the next, which is what releases the lines below
|
|
260
|
+
# it from the `●` above (issue #140, PR #141 review round 2).
|
|
261
|
+
_USER_PROMPT_ROW_RE = re.compile(r"^[>❯](?:\s|$)")
|
|
181
262
|
# The dialog's path line, as the trust gate draws it: the path and nothing
|
|
182
263
|
# else on the line.
|
|
183
264
|
_PATH_LINE_RE = re.compile(r"^/[^\s'\"]+$")
|
|
@@ -273,14 +354,31 @@ def _is_banner_chrome(text: str) -> bool:
|
|
|
273
354
|
return text in ("❯", ">") or _is_chrome(text)
|
|
274
355
|
|
|
275
356
|
|
|
357
|
+
def auth_matched(text: str) -> "str | None":
|
|
358
|
+
"""Which documented 401 wording this (plain) line is, as the
|
|
359
|
+
`data.matched` token, or None when it is not an auth-rejected line at
|
|
360
|
+
all. The login-expired banner is NOT read here (its own regex and kind
|
|
361
|
+
already exist); `classify` stamps that finding `login_expired`."""
|
|
362
|
+
if not _AUTH_REJECTED_RE.search(text):
|
|
363
|
+
return None
|
|
364
|
+
for token, pattern in _AUTH_MATCHED_RES:
|
|
365
|
+
if pattern.search(text):
|
|
366
|
+
return token
|
|
367
|
+
return MATCHED_API_ERROR_401
|
|
368
|
+
|
|
369
|
+
|
|
276
370
|
def _banner_line(lines: "list[str]") -> "tuple[int, str] | None":
|
|
277
371
|
"""The one line an account-level banner could be, as (index into
|
|
278
372
|
`lines`, plain text), or None. Bottom-up: chrome and the bare input
|
|
279
373
|
prompt are skipped, then the contiguous paragraph above them is read
|
|
280
374
|
up to the blank line or chrome that bounds it. A `●`/`⎿` tool row
|
|
281
375
|
anywhere in that paragraph makes it a worker's output (the row and its
|
|
282
|
-
indented continuation lines), never a banner
|
|
283
|
-
|
|
376
|
+
indented continuation lines), never a banner -- with ONE exception: a
|
|
377
|
+
`⎿` row that IS a documented 401 wording is how Claude Code draws its
|
|
378
|
+
own API error (issue #140), so that row is admitted as a candidate,
|
|
379
|
+
and a `●` turn above it in the same paragraph still voids the whole
|
|
380
|
+
paragraph. Otherwise the paragraph's last BANNER_LINES lines are the
|
|
381
|
+
candidates."""
|
|
284
382
|
index = len(lines) - 1
|
|
285
383
|
while index >= 0 and _is_banner_chrome(_plain(lines[index])):
|
|
286
384
|
index -= 1
|
|
@@ -290,12 +388,16 @@ def _banner_line(lines: "list[str]") -> "tuple[int, str] | None":
|
|
|
290
388
|
text = _plain(raw)
|
|
291
389
|
if _is_banner_chrome(text):
|
|
292
390
|
break
|
|
293
|
-
if _OUTPUT_ROW_RE.match(raw)
|
|
391
|
+
if _OUTPUT_ROW_RE.match(raw) and not (
|
|
392
|
+
_TOOL_OUTPUT_ROW_RE.match(raw) and _AUTH_REJECTED_RE.search(text)
|
|
393
|
+
):
|
|
294
394
|
return None
|
|
295
395
|
paragraph.append((index, text))
|
|
296
396
|
index -= 1
|
|
297
397
|
for index, text in paragraph[:BANNER_LINES]:
|
|
298
|
-
for pattern in (
|
|
398
|
+
for pattern in (
|
|
399
|
+
_LOGIN_EXPIRED_RE, _AUTH_REJECTED_RE, _USAGE_LIMIT_RE, _OUT_OF_CREDITS_RE,
|
|
400
|
+
):
|
|
299
401
|
if pattern.search(text):
|
|
300
402
|
return index, text
|
|
301
403
|
return None
|
|
@@ -323,7 +425,12 @@ class PromptFinding:
|
|
|
323
425
|
it, scrubbed, for the DEBUG log and nothing else. `pane_hash` fingerprints
|
|
324
426
|
the dialog so the loop can tell "the same pane, one poll later" from "a
|
|
325
427
|
new prompt": the unknown-dialog ladder and the answered/unanswered
|
|
326
|
-
re-capture both hang off it.
|
|
428
|
+
re-capture both hang off it.
|
|
429
|
+
|
|
430
|
+
`matched` and `source` are the `auth.rejected` event's vocabulary
|
|
431
|
+
(issue #140), set on the two AUTH_KINDS only: which documented wording
|
|
432
|
+
was seen (`AUTH_MATCHED`) and whether it was read off a live pane or
|
|
433
|
+
the last lines of an exited one (`SOURCE_PANE` / `SOURCE_EXIT`)."""
|
|
327
434
|
|
|
328
435
|
kind: str
|
|
329
436
|
signature: str
|
|
@@ -331,6 +438,8 @@ class PromptFinding:
|
|
|
331
438
|
options: "tuple[PromptOption, ...]"
|
|
332
439
|
excerpt: "tuple[str, ...]"
|
|
333
440
|
pane_hash: str
|
|
441
|
+
matched: "str | None" = None
|
|
442
|
+
source: str = SOURCE_PANE
|
|
334
443
|
|
|
335
444
|
@property
|
|
336
445
|
def selected(self) -> "PromptOption | None":
|
|
@@ -425,6 +534,96 @@ def _excerpt(lines: "list[str]", index: int) -> "tuple[str, ...]":
|
|
|
425
534
|
return tuple(scrub(strip_ansi(ln).rstrip()) for ln in lines[lo:hi])
|
|
426
535
|
|
|
427
536
|
|
|
537
|
+
def _matched_for(kind: str, text: str) -> "str | None":
|
|
538
|
+
"""The `matched` token of a banner finding: the login-expired banner is
|
|
539
|
+
reported as the same signal (`login_expired`), an auth line as the
|
|
540
|
+
wording it carries, every other kind as None."""
|
|
541
|
+
if kind == KIND_LOGIN_EXPIRED:
|
|
542
|
+
return MATCHED_LOGIN_EXPIRED
|
|
543
|
+
if kind == KIND_AUTH_REJECTED:
|
|
544
|
+
return auth_matched(text)
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def classify_exit(pane: str) -> "PromptFinding | None":
|
|
549
|
+
"""The first-turn death check's half of the classifier (issue #140):
|
|
550
|
+
what a pane whose `claude` has already EXITED back to the shell says
|
|
551
|
+
about why, or None for the ordinary "died, not auth".
|
|
552
|
+
|
|
553
|
+
Only ever called on a pane the loop has established is a shell (the
|
|
554
|
+
pane's current command is one of `alissa.SHELL_COMMANDS`, inside the
|
|
555
|
+
first stale window of the spawn, no linked PR), so the banner rule --
|
|
556
|
+
the wording must be the LAST thing on screen -- does not apply: the
|
|
557
|
+
shell prompt is the last thing on screen, and the error sits a few
|
|
558
|
+
lines above it. The last EXIT_SCAN_LINES non-blank lines are read
|
|
559
|
+
bottom-up for a documented 401 wording or the login-expired banner; a
|
|
560
|
+
`●` turn row is skipped, and so is EVERY line that belongs to a turn
|
|
561
|
+
(`_under_a_turn`): every line below a `●` row until the next submitted
|
|
562
|
+
prompt row (`>`), paragraph breaks included -- Claude Code renders one
|
|
563
|
+
assistant message as blank-separated paragraphs and code blocks, and
|
|
564
|
+
only the first line carries the `●`. So the worker's own prose
|
|
565
|
+
wrapping onto the wording, a quoted error in a paragraph or code block
|
|
566
|
+
of its own, a README quoted under a tool call, a `grep` for it, are
|
|
567
|
+
all the worker's -- never Claude Code's error, which is drawn under
|
|
568
|
+
the `>` prompt row (`⎿ API Error: 401 …`) or, on a start-up refusal,
|
|
569
|
+
before any turn exists. The finding is an `auth_rejected` with
|
|
570
|
+
`source: "exit"` and the wording under `matched` -- the same kind the
|
|
571
|
+
pane detector raises, so the policy (page once, hold spawns) is the
|
|
572
|
+
same row."""
|
|
573
|
+
if not pane or not pane.strip():
|
|
574
|
+
return None
|
|
575
|
+
lines = pane.splitlines()
|
|
576
|
+
indices = [i for i, ln in enumerate(lines) if _plain(ln)][-EXIT_SCAN_LINES:]
|
|
577
|
+
for index in reversed(indices):
|
|
578
|
+
raw = strip_ansi(lines[index])
|
|
579
|
+
if _OUTPUT_ROW_RE.match(raw) and not _TOOL_OUTPUT_ROW_RE.match(raw):
|
|
580
|
+
continue
|
|
581
|
+
text = _plain(raw)
|
|
582
|
+
matched = (
|
|
583
|
+
MATCHED_LOGIN_EXPIRED if _LOGIN_EXPIRED_RE.search(text)
|
|
584
|
+
else auth_matched(text)
|
|
585
|
+
)
|
|
586
|
+
if matched is None:
|
|
587
|
+
continue
|
|
588
|
+
if _under_a_turn(lines, index):
|
|
589
|
+
# Any line under a `●` turn or tool call, up to the next `>`
|
|
590
|
+
# prompt row, is that turn's: a `⎿` row is its output (a README
|
|
591
|
+
# quoting the wording, a `grep` for it), a plain row is its own
|
|
592
|
+
# prose -- wrapped onto the wording, or quoting it in a later
|
|
593
|
+
# paragraph or code block. Claude Code's error is never drawn
|
|
594
|
+
# under a `●` without a `>` between them.
|
|
595
|
+
continue
|
|
596
|
+
return PromptFinding(
|
|
597
|
+
kind=KIND_AUTH_REJECTED,
|
|
598
|
+
signature=scrub(text),
|
|
599
|
+
target=None,
|
|
600
|
+
options=(),
|
|
601
|
+
excerpt=_excerpt(lines, index),
|
|
602
|
+
pane_hash=pane_hash(pane),
|
|
603
|
+
matched=matched,
|
|
604
|
+
source=SOURCE_EXIT,
|
|
605
|
+
)
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _under_a_turn(lines: "list[str]", index: int) -> bool:
|
|
610
|
+
"""Whether `lines[index]` belongs to a worker's turn: a `●` row sits
|
|
611
|
+
above it, anywhere in the capture, with no submitted-prompt row
|
|
612
|
+
(`_USER_PROMPT_ROW_RE`, the `>` Claude Code draws for the prompt that
|
|
613
|
+
opens the NEXT turn) between them. Blank lines and chrome do NOT end
|
|
614
|
+
a turn (PR #141 review round 2): one assistant message is several
|
|
615
|
+
blank-separated paragraphs and code blocks, `●` on its first line
|
|
616
|
+
only, so the paragraph is no boundary -- only the next prompt row is.
|
|
617
|
+
A `⎿` row above is the turn's output and bounds nothing either."""
|
|
618
|
+
for above in range(index - 1, -1, -1):
|
|
619
|
+
raw = strip_ansi(lines[above])
|
|
620
|
+
if _USER_PROMPT_ROW_RE.match(raw):
|
|
621
|
+
return False
|
|
622
|
+
if _OUTPUT_ROW_RE.match(raw) and not _TOOL_OUTPUT_ROW_RE.match(raw):
|
|
623
|
+
return True
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
|
|
428
627
|
def classify(pane: str) -> "PromptFinding | None":
|
|
429
628
|
"""What this pane is parked on, or None for a pane that is WORKING, idle
|
|
430
629
|
at the input prompt, empty, or unreadable.
|
|
@@ -455,6 +654,7 @@ def classify(pane: str) -> "PromptFinding | None":
|
|
|
455
654
|
index, text = banner
|
|
456
655
|
for kind, pattern in (
|
|
457
656
|
(KIND_LOGIN_EXPIRED, _LOGIN_EXPIRED_RE),
|
|
657
|
+
(KIND_AUTH_REJECTED, _AUTH_REJECTED_RE),
|
|
458
658
|
(KIND_USAGE_LIMIT, _USAGE_LIMIT_RE),
|
|
459
659
|
(KIND_OUT_OF_CREDITS, _OUT_OF_CREDITS_RE),
|
|
460
660
|
):
|
|
@@ -466,6 +666,7 @@ def classify(pane: str) -> "PromptFinding | None":
|
|
|
466
666
|
options=(),
|
|
467
667
|
excerpt=_excerpt(lines, index),
|
|
468
668
|
pane_hash=digest,
|
|
669
|
+
matched=_matched_for(kind, text),
|
|
469
670
|
)
|
|
470
671
|
|
|
471
672
|
parked = _tail_is_dialog(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.30
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.28
|
|
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
|