alissa-tools-github-devloop 0.8.30__py3-none-any.whl → 0.8.31__py3-none-any.whl
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/loop.py +413 -28
- alissa/tools/github/devloop/loop_events.py +75 -2
- alissa/tools/github/devloop/prompts.py +140 -2
- alissa/tools/github/devloop/state.py +101 -8
- alissa/tools/github/devloop/version +1 -1
- alissa_tools_github_devloop-0.8.31.dist-info/METADATA +124 -0
- {alissa_tools_github_devloop-0.8.30.dist-info → alissa_tools_github_devloop-0.8.31.dist-info}/RECORD +12 -12
- alissa_tools_github_devloop-0.8.30.dist-info/METADATA +0 -78
- {alissa_tools_github_devloop-0.8.30.dist-info → alissa_tools_github_devloop-0.8.31.dist-info}/WHEEL +0 -0
- {alissa_tools_github_devloop-0.8.30.dist-info → alissa_tools_github_devloop-0.8.31.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.30.dist-info → alissa_tools_github_devloop-0.8.31.dist-info}/licenses/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.30.dist-info → alissa_tools_github_devloop-0.8.31.dist-info}/licenses/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.30.dist-info → alissa_tools_github_devloop-0.8.31.dist-info}/top_level.txt +0 -0
|
@@ -1272,7 +1272,12 @@ PROMPT_PAGE_COMMENT = (
|
|
|
1272
1272
|
"the container's token (`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`) "
|
|
1273
1273
|
"or re-run the claude login — the `auth.rejected` loop event names the "
|
|
1274
1274
|
"variable and the token's last four characters; "
|
|
1275
|
-
"`usage_limit`
|
|
1275
|
+
"`usage_limit` (\"You've reached your <Model> limit\" included) → wait "
|
|
1276
|
+
"for the reset, buy usage credits, or set a fallback `ALISSA_AGENT_MODEL` "
|
|
1277
|
+
"(a variable change redeploys and kills live lanes) — a session whose "
|
|
1278
|
+
"ONLY turn was the notice is killed at once, its attempt refunded (it "
|
|
1279
|
+
"never counts toward attempt_cap), and new spawns are held for "
|
|
1280
|
+
"{hold_minutes} min after the last one; `out_of_credits` "
|
|
1276
1281
|
"→ top up. The hold lifts on the first poll that no longer sees the "
|
|
1277
1282
|
"notice and in any case after {hold_minutes} min on the same pane: the "
|
|
1278
1283
|
"session is then killed and the retry edge's respawn re-confirms — a "
|
|
@@ -1303,6 +1308,35 @@ FIX_STALLED_COMMENT = (
|
|
|
1303
1308
|
"close the PR to park the work."
|
|
1304
1309
|
)
|
|
1305
1310
|
|
|
1311
|
+
# The ALIVE twin of FIX_STALLED_COMMENT (issue #142): the fix edge no longer
|
|
1312
|
+
# respawns a stale round on the timer alone, so a session that is LISTED but
|
|
1313
|
+
# has posted no triage reply defers -- floored by this once-per-episode ping
|
|
1314
|
+
# (fix_live_stalled_kind) so a wedged one is never deferred in silence.
|
|
1315
|
+
FIX_LIVE_STALLED_COMMENT = (
|
|
1316
|
+
"**Fix loop stalled?** — fix attempt {attempts} on review round {round} "
|
|
1317
|
+
"has been in flight {minutes} min with no triage reply, but its session "
|
|
1318
|
+
"`{session}` is still listed by tmux. The daemon never respawns over a "
|
|
1319
|
+
"session it cannot prove is gone (a second session would land on the "
|
|
1320
|
+
"same branch in the same worktree), so it keeps deferring the retry — is "
|
|
1321
|
+
"that session still making progress? Operator options: check it, and if "
|
|
1322
|
+
"it is wedged `alissa tmux kill {session}` to let the retry proceed, or "
|
|
1323
|
+
"finish the round by hand (fixes, re-request review)."
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
# The activity context of a retry whose previous session was refunded by a
|
|
1327
|
+
# usage limit (issue #142), on every edge -- in place of "presumed dead" or
|
|
1328
|
+
# "confirmed dead": the session did not die, the account stopped it.
|
|
1329
|
+
LIMITED_RETRY_CONTEXT = (
|
|
1330
|
+
"re-enqueued — previous session limited (usage limit on its first turn; "
|
|
1331
|
+
"attempt refunded)"
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
# The activity context the first-turn limit check posts on the lane's
|
|
1335
|
+
# artifact when it kills a limited session (issue #142).
|
|
1336
|
+
LIMITED_CONTEXT = (
|
|
1337
|
+
"limited — attempt refunded; spawns held until the account is back"
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1306
1340
|
# The hidden marker that identifies a fix-round handover comment (issue #121)
|
|
1307
1341
|
# -- a machine-findable anchor for the live session and for anyone auditing
|
|
1308
1342
|
# the PR, distinct from ACTIVITY_MARKER so the two kinds of comment never
|
|
@@ -1948,6 +1982,14 @@ def fix_stalled_kind(round_: int, attempt: int) -> str:
|
|
|
1948
1982
|
return f"{ESCALATION_FIX_STALLED}:r{round_}a{attempt}"
|
|
1949
1983
|
|
|
1950
1984
|
|
|
1985
|
+
def fix_live_stalled_kind(round_: int, attempt: int) -> str:
|
|
1986
|
+
"""The ledger kind for the stalled ping of ONE fix-round liveness
|
|
1987
|
+
deferral (issue #142): the round is stale, no triage reply exists, and
|
|
1988
|
+
the row's session is still LISTED. Keyed per round AND attempt for
|
|
1989
|
+
fix_stalled_kind's episode reasoning."""
|
|
1990
|
+
return f"fix-live-stalled:r{round_}a{attempt}"
|
|
1991
|
+
|
|
1992
|
+
|
|
1951
1993
|
def fix_handover_kind(round_: int) -> str:
|
|
1952
1994
|
"""The ledger kind that dedupes ONE round's handover comment (issue #121).
|
|
1953
1995
|
|
|
@@ -2496,6 +2538,10 @@ class Sweep:
|
|
|
2496
2538
|
# one per session (`{session, repo, edge, number, attempt, round,
|
|
2497
2539
|
# source, matched}`).
|
|
2498
2540
|
auth_rejected: "tuple[dict, ...]" = ()
|
|
2541
|
+
# The usage limits the responder observed this pass (issue #142): the
|
|
2542
|
+
# `usage.limited` records, one per session (`{session, repo, edge,
|
|
2543
|
+
# number, attempt, round, source, matched, model}`).
|
|
2544
|
+
usage_limited: "tuple[dict, ...]" = ()
|
|
2499
2545
|
|
|
2500
2546
|
@property
|
|
2501
2547
|
def total(self) -> int:
|
|
@@ -2508,13 +2554,14 @@ class PromptSweep:
|
|
|
2508
2554
|
"""What ONE prompt-responder pass found and did (issue #127): the
|
|
2509
2555
|
sessions parked on a prompt (`{session, kind, since, action}`), the
|
|
2510
2556
|
spawn hold an account-level notice raised, the keystroke answers
|
|
2511
|
-
sent,
|
|
2512
|
-
pass's `Sweep`."""
|
|
2557
|
+
sent, the `auth.rejected` records (issue #140) and the
|
|
2558
|
+
`usage.limited` records (issue #142). Folded into the pass's `Sweep`."""
|
|
2513
2559
|
|
|
2514
2560
|
waiting: "tuple[dict, ...]" = ()
|
|
2515
2561
|
hold: "str | None" = None
|
|
2516
2562
|
answered: int = 0
|
|
2517
2563
|
auth_rejected: "tuple[dict, ...]" = ()
|
|
2564
|
+
usage_limited: "tuple[dict, ...]" = ()
|
|
2518
2565
|
|
|
2519
2566
|
|
|
2520
2567
|
@dataclass(frozen=True, slots=True)
|
|
@@ -3273,6 +3320,18 @@ class DevWatcher:
|
|
|
3273
3320
|
# purpose: a restart re-checks once, and the loop event's key is
|
|
3274
3321
|
# the session name, so a re-observation is a silent duplicate.
|
|
3275
3322
|
self._exit_findings: "dict[str, prompts_mod.PromptFinding | None]" = {}
|
|
3323
|
+
# The first-turn limit check's memory (issue #142): the sessions
|
|
3324
|
+
# whose pane showed the usage-limit notice as their only turn (the
|
|
3325
|
+
# finding, re-asserted while the session is still listed -- a kill
|
|
3326
|
+
# that failed is retried, never re-narrated), the sessions whose
|
|
3327
|
+
# first turn was provably something else (never read again for this
|
|
3328
|
+
# check), and the sessions already acted on (paged, refunded,
|
|
3329
|
+
# narrated once). In-memory like `_exit_findings`: the refund and the
|
|
3330
|
+
# hold live on the ledger (`limited_at`), so a restart loses nothing
|
|
3331
|
+
# but one re-read of a pane that is killed anyway.
|
|
3332
|
+
self._limit_findings: "dict[str, prompts_mod.PromptFinding]" = {}
|
|
3333
|
+
self._limit_settled: "set[str]" = set()
|
|
3334
|
+
self._limit_acted: "set[str]" = set()
|
|
3276
3335
|
# The janitor's two pieces of per-process state: when its expensive
|
|
3277
3336
|
# half last ran (monotonic; None = never, so the first pass of a
|
|
3278
3337
|
# process always sweeps) and the last per-hub breakdown it walked,
|
|
@@ -3604,13 +3663,17 @@ class DevWatcher:
|
|
|
3604
3663
|
|
|
3605
3664
|
# Never spawn attempt cap+1. Escalate ONCE; `escalated()` dedupes, and
|
|
3606
3665
|
# raising attempt_cap re-enters the loop without clearing the ledger.
|
|
3607
|
-
|
|
3666
|
+
# Billable attempts only (issue #142): an attempt refunded by a
|
|
3667
|
+
# usage limit never counts toward the cap; the numbering below still
|
|
3668
|
+
# counts it, so session names stay monotonic.
|
|
3669
|
+
billed = self.state.attempt_count(issue.full_name, number, billable=True)
|
|
3670
|
+
if billed >= self.config.attempt_cap:
|
|
3608
3671
|
if self.state.escalated(issue.full_name, number, ESCALATION_CAP):
|
|
3609
3672
|
return Decision(Action.CAPPED, "already escalated", attempts)
|
|
3610
|
-
self._escalate(issue,
|
|
3673
|
+
self._escalate(issue, billed)
|
|
3611
3674
|
return Decision(
|
|
3612
3675
|
Action.ESCALATED,
|
|
3613
|
-
f"{
|
|
3676
|
+
f"{billed} attempts, cap {self.config.attempt_cap}",
|
|
3614
3677
|
attempts,
|
|
3615
3678
|
)
|
|
3616
3679
|
|
|
@@ -3830,6 +3893,8 @@ class DevWatcher:
|
|
|
3830
3893
|
f"tmux listing) with its PR never handed off; finishing draft "
|
|
3831
3894
|
f"PR {resume_pr.url}"
|
|
3832
3895
|
)
|
|
3896
|
+
elif reenqueued and self.state.session_limited(dead_session):
|
|
3897
|
+
context = LIMITED_RETRY_CONTEXT
|
|
3833
3898
|
elif reenqueued:
|
|
3834
3899
|
context = (
|
|
3835
3900
|
"re-enqueued — previous session confirmed dead (absent from "
|
|
@@ -4922,17 +4987,72 @@ class DevWatcher:
|
|
|
4922
4987
|
# triage replies), so it resumes as attempt k+1 with
|
|
4923
4988
|
# FIX_RESUME_DIRECTIVE; ALIVE and INDETERMINATE keep the floored
|
|
4924
4989
|
# defer, and `resume_enabled=false` restores it wholesale.
|
|
4990
|
+
#
|
|
4991
|
+
# With NO author activity the timer used to decide alone ("fix
|
|
4992
|
+
# session presumed dead", issue #142): a session that never got as
|
|
4993
|
+
# far as a triage reply -- the 2026-09-28 incident's, parked idle on
|
|
4994
|
+
# a usage-limit notice as its only turn -- was respawned every stale
|
|
4995
|
+
# window, each respawn billed against attempt_cap, until the round
|
|
4996
|
+
# capped out in three hours. Now the roster answers there too, the
|
|
4997
|
+
# issue edge's PR-less guard transposed: ALIVE is in flight (a live
|
|
4998
|
+
# idle pane belongs to the prompt responder and the first-turn limit
|
|
4999
|
+
# check, never to a second session on the same branch), floored by a
|
|
5000
|
+
# once-per-episode ping so a wedged one is not deferred in silence;
|
|
5001
|
+
# INDETERMINATE defers one pass (a failed listing proves nothing);
|
|
5002
|
+
# only DEAD -- absent from a SUCCESSFUL listing -- respawns, and it
|
|
5003
|
+
# says CONFIRMED dead.
|
|
4925
5004
|
resume_dead_session: "str | None" = None
|
|
5005
|
+
confirmed_dead_session: "str | None" = None
|
|
4926
5006
|
if age is not None:
|
|
4927
5007
|
author_activity = any(
|
|
4928
5008
|
r.author == pr.author and r.submitted_at > newest.submitted_at
|
|
4929
5009
|
for r in records
|
|
4930
5010
|
)
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
5011
|
+
row_session = self.state.newest_fix_spawn_session(
|
|
5012
|
+
pr.full_name, number, round_
|
|
5013
|
+
)
|
|
5014
|
+
liveness = self.session_liveness(row_session, roster)
|
|
5015
|
+
if not author_activity:
|
|
5016
|
+
if liveness is Liveness.ALIVE:
|
|
5017
|
+
stalled_after = (
|
|
5018
|
+
STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
|
|
5019
|
+
)
|
|
5020
|
+
if age >= stalled_after and not self.state.escalated(
|
|
5021
|
+
pr.full_name, number,
|
|
5022
|
+
fix_live_stalled_kind(round_, attempts),
|
|
5023
|
+
):
|
|
5024
|
+
self._escalate_fix_live_stalled(
|
|
5025
|
+
pr, round_, attempts, age, row_session or ""
|
|
5026
|
+
)
|
|
5027
|
+
return Decision(
|
|
5028
|
+
Action.IN_FLIGHT,
|
|
5029
|
+
f"fix attempt {attempts} for round {round_} is stale "
|
|
5030
|
+
f"({int(age / 60)} min) with no triage reply yet, but "
|
|
5031
|
+
f"its session {row_session} reads alive — a live "
|
|
5032
|
+
f"session owns the round (an idle pane is the prompt "
|
|
5033
|
+
f"responder's); not respawning",
|
|
5034
|
+
attempts,
|
|
5035
|
+
session=row_session
|
|
5036
|
+
or fix_session_name(pr, round_, attempts),
|
|
5037
|
+
round=round_,
|
|
5038
|
+
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
5039
|
+
)
|
|
5040
|
+
if liveness is not Liveness.DEAD:
|
|
5041
|
+
return Decision(
|
|
5042
|
+
Action.IN_FLIGHT,
|
|
5043
|
+
f"fix attempt {attempts} for round {round_} is stale "
|
|
5044
|
+
f"({int(age / 60)} min) with no triage reply yet, and "
|
|
5045
|
+
f"its session {row_session} reads "
|
|
5046
|
+
f"{liveness.value} — the listing proves nothing; "
|
|
5047
|
+
f"deferring one pass",
|
|
5048
|
+
attempts,
|
|
5049
|
+
session=row_session
|
|
5050
|
+
or fix_session_name(pr, round_, attempts),
|
|
5051
|
+
round=round_,
|
|
5052
|
+
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
5053
|
+
)
|
|
5054
|
+
confirmed_dead_session = row_session
|
|
5055
|
+
else:
|
|
4936
5056
|
if self.config.resume_enabled and liveness is Liveness.DEAD:
|
|
4937
5057
|
# Falls through to the cap check and the spawn below: a
|
|
4938
5058
|
# resume is an ORDINARY attempt of this round.
|
|
@@ -4967,7 +5087,12 @@ class DevWatcher:
|
|
|
4967
5087
|
# first otherwise), so at cap-out the PR is invisible to reviewloop
|
|
4968
5088
|
# and FIX_CAP_COMMENT is the operator's only signal. Deduped by the
|
|
4969
5089
|
# fix-cap:r<k> row: once per round.
|
|
4970
|
-
|
|
5090
|
+
# Billable attempts only (issue #142): an attempt refunded by a
|
|
5091
|
+
# usage limit never counts toward the round's cap.
|
|
5092
|
+
billed = self.state.fix_attempt_count(
|
|
5093
|
+
pr.full_name, number, round_, billable=True
|
|
5094
|
+
)
|
|
5095
|
+
if billed >= self.config.attempt_cap:
|
|
4971
5096
|
kind = fix_cap_kind(round_)
|
|
4972
5097
|
if self.state.escalated(pr.full_name, number, kind):
|
|
4973
5098
|
return Decision(
|
|
@@ -4976,10 +5101,10 @@ class DevWatcher:
|
|
|
4976
5101
|
attempts,
|
|
4977
5102
|
round=round_,
|
|
4978
5103
|
)
|
|
4979
|
-
self._escalate_fix_cap(pr, round_,
|
|
5104
|
+
self._escalate_fix_cap(pr, round_, billed)
|
|
4980
5105
|
return Decision(
|
|
4981
5106
|
Action.ESCALATED,
|
|
4982
|
-
f"{
|
|
5107
|
+
f"{billed} fix attempts on round {round_}, cap "
|
|
4983
5108
|
f"{self.config.attempt_cap} — operator paged on the PR (no "
|
|
4984
5109
|
f"pending re-request, so the reviewer daemon cannot see it)",
|
|
4985
5110
|
attempts,
|
|
@@ -5002,12 +5127,16 @@ class DevWatcher:
|
|
|
5002
5127
|
elif age is not None:
|
|
5003
5128
|
log.warning(
|
|
5004
5129
|
"%s fix attempt %d (round %d) has been in flight %.0f min "
|
|
5005
|
-
"with no re-request
|
|
5006
|
-
"
|
|
5130
|
+
"with no re-request and its session %s is absent from the "
|
|
5131
|
+
"tmux listing (CONFIRMED dead, not presumed%s) — "
|
|
5132
|
+
"re-enqueuing as attempt %d",
|
|
5007
5133
|
pr.pr_slug,
|
|
5008
5134
|
attempts,
|
|
5009
5135
|
round_,
|
|
5010
5136
|
age / 60,
|
|
5137
|
+
confirmed_dead_session,
|
|
5138
|
+
"; usage-limited, attempt refunded"
|
|
5139
|
+
if self.state.session_limited(confirmed_dead_session) else "",
|
|
5011
5140
|
attempts + 1,
|
|
5012
5141
|
)
|
|
5013
5142
|
|
|
@@ -5019,6 +5148,7 @@ class DevWatcher:
|
|
|
5019
5148
|
budget,
|
|
5020
5149
|
reenqueued=age is not None,
|
|
5021
5150
|
dead_session=resume_dead_session,
|
|
5151
|
+
confirmed_dead=confirmed_dead_session,
|
|
5022
5152
|
)
|
|
5023
5153
|
|
|
5024
5154
|
def _reviewer_reviews(
|
|
@@ -6486,6 +6616,41 @@ class DevWatcher:
|
|
|
6486
6616
|
pr.full_name, pr.number, fix_stalled_kind(round_, attempts)
|
|
6487
6617
|
)
|
|
6488
6618
|
|
|
6619
|
+
def _escalate_fix_live_stalled(
|
|
6620
|
+
self, pr: PullRequest, round_: int, attempts: int, age: float,
|
|
6621
|
+
session: str,
|
|
6622
|
+
) -> None:
|
|
6623
|
+
"""_escalate_fix_stalled's ALIVE twin (issue #142): the liveness
|
|
6624
|
+
guard on the no-triage-reply branch defers while the row's session is
|
|
6625
|
+
listed, and this ping is its floor. Same delivery contract: the row
|
|
6626
|
+
is recorded only AFTER the comment posts; the decision stays
|
|
6627
|
+
IN_FLIGHT either way."""
|
|
6628
|
+
body = FIX_LIVE_STALLED_COMMENT.format(
|
|
6629
|
+
attempts=attempts, round=round_, minutes=int(age / 60),
|
|
6630
|
+
session=session,
|
|
6631
|
+
)
|
|
6632
|
+
log.warning(
|
|
6633
|
+
"FIX STALLED %s fix attempt %d (round %d) has been deferred "
|
|
6634
|
+
"%.0f min behind its still-listed session %s with no triage "
|
|
6635
|
+
"reply — escalating to operator (once per episode)",
|
|
6636
|
+
pr.pr_slug, attempts, round_, age / 60, session,
|
|
6637
|
+
)
|
|
6638
|
+
if self.config.dry_run:
|
|
6639
|
+
log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
|
|
6640
|
+
return
|
|
6641
|
+
try:
|
|
6642
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
6643
|
+
except CommandError as exc:
|
|
6644
|
+
log.error(
|
|
6645
|
+
"could not post the fix live-stalled comment on %s: %s — not "
|
|
6646
|
+
"recording the episode; the ping retries next poll",
|
|
6647
|
+
pr.pr_slug, exc,
|
|
6648
|
+
)
|
|
6649
|
+
return
|
|
6650
|
+
self.state.record_escalation(
|
|
6651
|
+
pr.full_name, pr.number, fix_live_stalled_kind(round_, attempts)
|
|
6652
|
+
)
|
|
6653
|
+
|
|
6489
6654
|
def _hand_over_round(
|
|
6490
6655
|
self,
|
|
6491
6656
|
pr: PullRequest,
|
|
@@ -6805,6 +6970,7 @@ class DevWatcher:
|
|
|
6805
6970
|
*,
|
|
6806
6971
|
reenqueued: bool = False,
|
|
6807
6972
|
dead_session: "str | None" = None,
|
|
6973
|
+
confirmed_dead: "str | None" = None,
|
|
6808
6974
|
) -> Decision:
|
|
6809
6975
|
skipped = self._foreign_head_skip(pr, attempt)
|
|
6810
6976
|
if skipped is not None:
|
|
@@ -6899,8 +7065,15 @@ class DevWatcher:
|
|
|
6899
7065
|
"resumed — previous session confirmed dead (absent from the "
|
|
6900
7066
|
"tmux listing); finishing the round on the same branch"
|
|
6901
7067
|
)
|
|
7068
|
+
elif reenqueued and self.state.session_limited(confirmed_dead):
|
|
7069
|
+
context = LIMITED_RETRY_CONTEXT
|
|
6902
7070
|
elif reenqueued:
|
|
6903
|
-
|
|
7071
|
+
# Issue #142: this edge retries only past the liveness guard now,
|
|
7072
|
+
# so the previous session was absent from a successful listing.
|
|
7073
|
+
context = (
|
|
7074
|
+
"re-enqueued — previous session confirmed dead (absent from "
|
|
7075
|
+
"the tmux listing) with no triage reply"
|
|
7076
|
+
)
|
|
6904
7077
|
else:
|
|
6905
7078
|
context = "spawned"
|
|
6906
7079
|
self._append_activity(
|
|
@@ -7051,14 +7224,18 @@ class DevWatcher:
|
|
|
7051
7224
|
# explicit operator ask, and its removal -- by the session on
|
|
7052
7225
|
# completion, or by the operator to park the PR -- is the only
|
|
7053
7226
|
# convergence signal this edge has.
|
|
7054
|
-
|
|
7227
|
+
# Billable attempts only (issue #142), on the same epoch floor.
|
|
7228
|
+
billed = self.state.maintain_attempt_count(
|
|
7229
|
+
pr.full_name, number, since=labeled_ts or 0, billable=True
|
|
7230
|
+
)
|
|
7231
|
+
if billed >= self.config.attempt_cap:
|
|
7055
7232
|
kind = maintain_cap_kind(labeled_ts)
|
|
7056
7233
|
if self.state.escalated(pr.full_name, number, kind):
|
|
7057
7234
|
return Decision(Action.CAPPED, "already escalated", attempts)
|
|
7058
|
-
self._escalate_maintain_cap(pr,
|
|
7235
|
+
self._escalate_maintain_cap(pr, billed, kind)
|
|
7059
7236
|
return Decision(
|
|
7060
7237
|
Action.ESCALATED,
|
|
7061
|
-
f"{
|
|
7238
|
+
f"{billed} maintenance attempts this request, cap "
|
|
7062
7239
|
f"{self.config.attempt_cap} — the label is still present; "
|
|
7063
7240
|
f"operator paged on the PR",
|
|
7064
7241
|
attempts,
|
|
@@ -7085,6 +7262,11 @@ class DevWatcher:
|
|
|
7085
7262
|
self.state.maintain_max_attempt(pr.full_name, number) + 1,
|
|
7086
7263
|
budget,
|
|
7087
7264
|
reenqueued=age is not None,
|
|
7265
|
+
limited=age is not None and self.state.session_limited(
|
|
7266
|
+
maintain_session_name(
|
|
7267
|
+
pr, self.state.maintain_max_attempt(pr.full_name, number)
|
|
7268
|
+
)
|
|
7269
|
+
),
|
|
7088
7270
|
)
|
|
7089
7271
|
|
|
7090
7272
|
def _spawn_maintain(
|
|
@@ -7094,6 +7276,7 @@ class DevWatcher:
|
|
|
7094
7276
|
budget: "SessionBudget | None" = None,
|
|
7095
7277
|
*,
|
|
7096
7278
|
reenqueued: bool = False,
|
|
7279
|
+
limited: bool = False,
|
|
7097
7280
|
) -> Decision:
|
|
7098
7281
|
skipped = self._foreign_head_skip(pr, attempt)
|
|
7099
7282
|
if skipped is not None:
|
|
@@ -7154,8 +7337,8 @@ class DevWatcher:
|
|
|
7154
7337
|
# cost; the line stays best-effort and dry-run gated. The session name
|
|
7155
7338
|
# carries the attempt; the context marks a retry.
|
|
7156
7339
|
context = (
|
|
7157
|
-
|
|
7158
|
-
if reenqueued
|
|
7340
|
+
LIMITED_RETRY_CONTEXT if reenqueued and limited
|
|
7341
|
+
else "re-enqueued — previous session presumed dead" if reenqueued
|
|
7159
7342
|
else "spawned"
|
|
7160
7343
|
)
|
|
7161
7344
|
self._append_activity(
|
|
@@ -8616,6 +8799,7 @@ class DevWatcher:
|
|
|
8616
8799
|
hold: "str | None" = None
|
|
8617
8800
|
answered = 0
|
|
8618
8801
|
rejected: "list[dict]" = []
|
|
8802
|
+
limited: "list[dict]" = []
|
|
8619
8803
|
for entry in sessions:
|
|
8620
8804
|
name = str(entry.get("name") or "")
|
|
8621
8805
|
worker = parse_worker_session(name)
|
|
@@ -8636,6 +8820,8 @@ class DevWatcher:
|
|
|
8636
8820
|
hold = outcome["hold"]
|
|
8637
8821
|
if outcome["auth"]:
|
|
8638
8822
|
rejected.append(outcome["auth"])
|
|
8823
|
+
if outcome["limited"]:
|
|
8824
|
+
limited.append(outcome["limited"])
|
|
8639
8825
|
continue
|
|
8640
8826
|
marker = self._waiting_marker(tmux_name)
|
|
8641
8827
|
quiet = self._entry_quiet_for(entry)
|
|
@@ -8649,6 +8835,19 @@ class DevWatcher:
|
|
|
8649
8835
|
self.state.clear_prompt_sighting(name)
|
|
8650
8836
|
continue
|
|
8651
8837
|
pane = self.alissa.capture_pane(name, prompts_mod.PANE_LINES)
|
|
8838
|
+
# The first-turn limit check (issue #142) reads the SAME capture:
|
|
8839
|
+
# a young worker of any edge whose only turn is the usage-limit
|
|
8840
|
+
# notice is killed and refunded here, before the banner rule --
|
|
8841
|
+
# which, by design, never reads a `●` row -- calls it idle.
|
|
8842
|
+
limit = self._first_turn_limit(worker, name, pane)
|
|
8843
|
+
if limit is not None:
|
|
8844
|
+
outcome = self._answer_first_turn_limit(worker, limit, mode)
|
|
8845
|
+
waiting.append(outcome["waiting"])
|
|
8846
|
+
if outcome["hold"] and hold is None:
|
|
8847
|
+
hold = outcome["hold"]
|
|
8848
|
+
if outcome["limited"]:
|
|
8849
|
+
limited.append(outcome["limited"])
|
|
8850
|
+
continue
|
|
8652
8851
|
finding = prompts_mod.classify(pane)
|
|
8653
8852
|
if finding is None:
|
|
8654
8853
|
log.debug(
|
|
@@ -8667,12 +8866,18 @@ class DevWatcher:
|
|
|
8667
8866
|
hold = outcome["hold"]
|
|
8668
8867
|
if outcome["auth"]:
|
|
8669
8868
|
rejected.append(outcome["auth"])
|
|
8869
|
+
if outcome["limited"]:
|
|
8870
|
+
limited.append(outcome["limited"])
|
|
8670
8871
|
self.state.prune_prompt_sightings(live)
|
|
8671
8872
|
for gone in [n for n in self._exit_findings if n not in live]:
|
|
8672
8873
|
del self._exit_findings[gone]
|
|
8874
|
+
for gone in [n for n in self._limit_findings if n not in live]:
|
|
8875
|
+
del self._limit_findings[gone]
|
|
8876
|
+
self._limit_settled &= live
|
|
8877
|
+
self._limit_acted &= live
|
|
8673
8878
|
return PromptSweep(
|
|
8674
8879
|
waiting=tuple(waiting), hold=hold, answered=answered,
|
|
8675
|
-
auth_rejected=tuple(rejected),
|
|
8880
|
+
auth_rejected=tuple(rejected), usage_limited=tuple(limited),
|
|
8676
8881
|
)
|
|
8677
8882
|
|
|
8678
8883
|
def _first_turn_death(
|
|
@@ -8933,6 +9138,13 @@ class DevWatcher:
|
|
|
8933
9138
|
}
|
|
8934
9139
|
if repo:
|
|
8935
9140
|
auth["repo"] = slug
|
|
9141
|
+
# The `usage.limited` record (issue #142) for a usage-limit BANNER
|
|
9142
|
+
# (`source: "pane"`): live mode only, like the auth record -- the
|
|
9143
|
+
# first-turn check builds its own (`source: "first_turn"`).
|
|
9144
|
+
limited = (
|
|
9145
|
+
self._limited_record(worker, repo, finding)
|
|
9146
|
+
if finding.kind == prompts_mod.KIND_USAGE_LIMIT and live_mode else None
|
|
9147
|
+
)
|
|
8936
9148
|
return {
|
|
8937
9149
|
"waiting": {
|
|
8938
9150
|
"session": name, "kind": finding.kind,
|
|
@@ -8942,8 +9154,174 @@ class DevWatcher:
|
|
|
8942
9154
|
"answered": 1 if sent else 0,
|
|
8943
9155
|
"hold": hold,
|
|
8944
9156
|
"auth": auth,
|
|
9157
|
+
"limited": limited,
|
|
9158
|
+
}
|
|
9159
|
+
|
|
9160
|
+
def _limited_record(
|
|
9161
|
+
self,
|
|
9162
|
+
worker: WorkerSession,
|
|
9163
|
+
repo: "tuple[str, str] | None",
|
|
9164
|
+
finding: "prompts_mod.PromptFinding",
|
|
9165
|
+
) -> "dict":
|
|
9166
|
+
"""One `usage.limited` record (issue #142): the lane's coordinates
|
|
9167
|
+
and the finding's vocabulary tokens -- never its signature."""
|
|
9168
|
+
record = {
|
|
9169
|
+
"session": worker.name,
|
|
9170
|
+
"edge": "issue" if worker.is_issue else "pr",
|
|
9171
|
+
"number": worker.number,
|
|
9172
|
+
"attempt": worker.attempt,
|
|
9173
|
+
"round": worker.round_,
|
|
9174
|
+
"source": finding.source,
|
|
9175
|
+
"matched": finding.matched or prompts_mod.MATCHED_USAGE_LIMIT,
|
|
9176
|
+
"model": finding.model,
|
|
9177
|
+
}
|
|
9178
|
+
if repo:
|
|
9179
|
+
record["repo"] = f"{repo[0]}/{repo[1]}"
|
|
9180
|
+
return record
|
|
9181
|
+
|
|
9182
|
+
def _spawn_row(self, name: str) -> "sqlite3.Row | None":
|
|
9183
|
+
"""The spawn row behind a worker session name on ANY of the four
|
|
9184
|
+
ledgers, or None when this ledger never spawned it."""
|
|
9185
|
+
return (
|
|
9186
|
+
self.state.spawn_for_session(name)
|
|
9187
|
+
or self.state.fix_spawn_for_session(name)
|
|
9188
|
+
or self.state.maintain_spawn_for_session(name)
|
|
9189
|
+
or self.state.revert_spawn_for_session(name)
|
|
9190
|
+
)
|
|
9191
|
+
|
|
9192
|
+
def _first_turn_limit(
|
|
9193
|
+
self, worker: WorkerSession, name: str, pane: str
|
|
9194
|
+
) -> "prompts_mod.PromptFinding | None":
|
|
9195
|
+
"""The first-turn limit check (issue #142): a `usage_limit` finding
|
|
9196
|
+
with `source: "first_turn"` for a worker of ANY edge that this
|
|
9197
|
+
ledger spawned inside the last stale window and whose captured pane
|
|
9198
|
+
(`pane`, the responder's one capture) shows an idle prompt under the
|
|
9199
|
+
usage-limit notice as its ONLY assistant turn -- else None.
|
|
9200
|
+
|
|
9201
|
+
Reads nothing new: the pane is the capture the responder already
|
|
9202
|
+
took (a marker or the quiet gate asked for it), and the gate is the
|
|
9203
|
+
ledger row. The verdict is remembered per session: a finding is
|
|
9204
|
+
re-asserted while the session stays listed (its kill is retried),
|
|
9205
|
+
and a session whose first turn was provably something else
|
|
9206
|
+
(`classify_first_turn`'s `settled`) is never read for this again."""
|
|
9207
|
+
cached = self._limit_findings.get(name)
|
|
9208
|
+
if cached is not None:
|
|
9209
|
+
return cached
|
|
9210
|
+
if name in self._limit_settled:
|
|
9211
|
+
return None
|
|
9212
|
+
row = self._spawn_row(name)
|
|
9213
|
+
if row is None:
|
|
9214
|
+
return None
|
|
9215
|
+
if time.time() - int(row["spawned_at"]) >= self.config.stale_minutes * 60:
|
|
9216
|
+
self._limit_settled.add(name)
|
|
9217
|
+
return None
|
|
9218
|
+
finding, settled = prompts_mod.classify_first_turn(pane)
|
|
9219
|
+
if finding is not None:
|
|
9220
|
+
self._limit_findings[name] = finding
|
|
9221
|
+
elif settled:
|
|
9222
|
+
self._limit_settled.add(name)
|
|
9223
|
+
return finding
|
|
9224
|
+
|
|
9225
|
+
def _answer_first_turn_limit(
|
|
9226
|
+
self, worker: WorkerSession, finding: "prompts_mod.PromptFinding",
|
|
9227
|
+
mode: str,
|
|
9228
|
+
) -> "dict":
|
|
9229
|
+
"""Act on a first-turn limit (issue #142): the account-kind policy
|
|
9230
|
+
with the kill brought forward. The session is idle and useless -- a
|
|
9231
|
+
worker whose only turn is the notice will never do anything -- so,
|
|
9232
|
+
in live mode: page the operator (the account-kind page, once per
|
|
9233
|
+
kind per PAGE_WINDOW_SECONDS), REFUND the attempt (`limited_at` on
|
|
9234
|
+
its ledger row, which also arms the daemon-wide spawn hold for
|
|
9235
|
+
ACCOUNT_HOLD_SECONDS -- see `_limit_hold`), kill it through the
|
|
9236
|
+
responder's kill path, and leave one activity line naming the
|
|
9237
|
+
limit and the refund. `observe` narrates and does nothing else;
|
|
9238
|
+
dry-run withholds every side effect at its own seam. Returns the
|
|
9239
|
+
responder's `{waiting, hold, limited}` for the sweep."""
|
|
9240
|
+
name = worker.name
|
|
9241
|
+
now = time.time()
|
|
9242
|
+
live_mode = mode != PROMPT_OBSERVE
|
|
9243
|
+
repo = self._slug_repos.get(worker.slug)
|
|
9244
|
+
slug = f"{repo[0]}/{repo[1]}" if repo else FOREIGN_ESCALATION_SLUG
|
|
9245
|
+
number = worker.number if repo else FOREIGN_ESCALATION_ISSUE
|
|
9246
|
+
first = name not in self._limit_acted
|
|
9247
|
+
self._limit_acted.add(name)
|
|
9248
|
+
model = f" · model {finding.model}" if finding.model else ""
|
|
9249
|
+
if first:
|
|
9250
|
+
log.warning(
|
|
9251
|
+
"first-turn limit check: %s answered its directive with the "
|
|
9252
|
+
"usage-limit notice only (%s, matched %s%s) — %s",
|
|
9253
|
+
name, finding.signature[:120], finding.matched, model,
|
|
9254
|
+
"killing it, refunding the attempt and holding new spawns"
|
|
9255
|
+
if live_mode else "observe mode, nothing done",
|
|
9256
|
+
)
|
|
9257
|
+
hold: "str | None" = None
|
|
9258
|
+
record: "dict | None" = None
|
|
9259
|
+
killed = False
|
|
9260
|
+
if live_mode:
|
|
9261
|
+
hold = f"usage_limit on {name} ({finding.signature[:80]})"
|
|
9262
|
+
if first:
|
|
9263
|
+
self._page_prompt(finding, worker, repo, slug, number)
|
|
9264
|
+
if not self.config.dry_run:
|
|
9265
|
+
self.state.mark_limited(name, int(now))
|
|
9266
|
+
if self.config.dry_run:
|
|
9267
|
+
log.info("[dry-run] would kill %s (usage limit on its first turn)", name)
|
|
9268
|
+
else:
|
|
9269
|
+
try:
|
|
9270
|
+
self.alissa.kill_session(name)
|
|
9271
|
+
killed = True
|
|
9272
|
+
except CommandError as exc:
|
|
9273
|
+
log.warning(
|
|
9274
|
+
"first-turn limit check: could not kill %s (%s) — "
|
|
9275
|
+
"kills are best-effort; retried next poll", name, exc,
|
|
9276
|
+
)
|
|
9277
|
+
record = self._limited_record(worker, repo, finding)
|
|
9278
|
+
if first and repo:
|
|
9279
|
+
owner, repo_name = repo
|
|
9280
|
+
tail = (
|
|
9281
|
+
"" if not live_mode or killed or self.config.dry_run
|
|
9282
|
+
else " · kill failed, retried next poll"
|
|
9283
|
+
)
|
|
9284
|
+
context = (
|
|
9285
|
+
LIMITED_CONTEXT if live_mode
|
|
9286
|
+
else "prompt-seen: usage limit on its first turn · observe mode, nothing sent"
|
|
9287
|
+
)
|
|
9288
|
+
line = self._activity_line(
|
|
9289
|
+
name, f"{worker.edge} edge",
|
|
9290
|
+
f"{context} (usage_limit · matched {finding.matched}{model} · "
|
|
9291
|
+
f"source {finding.source}){tail}",
|
|
9292
|
+
)
|
|
9293
|
+
self._append_activity(
|
|
9294
|
+
owner, repo_name, worker.number, line, f"{slug}#{worker.number}"
|
|
9295
|
+
)
|
|
9296
|
+
return {
|
|
9297
|
+
"waiting": {
|
|
9298
|
+
"session": name, "kind": finding.kind, "since": int(now),
|
|
9299
|
+
"action": prompts_mod.VERB_KILL if live_mode else prompts_mod.VERB_WAIT,
|
|
9300
|
+
},
|
|
9301
|
+
"hold": hold,
|
|
9302
|
+
"limited": record,
|
|
8945
9303
|
}
|
|
8946
9304
|
|
|
9305
|
+
def _limit_hold(self) -> "str | None":
|
|
9306
|
+
"""The spawn hold a usage limit keeps standing after its session was
|
|
9307
|
+
killed (issue #142), or None: the newest `limited_at` on the ledger,
|
|
9308
|
+
for ACCOUNT_HOLD_SECONDS. When it expires the retry edges respawn as
|
|
9309
|
+
they would have, and a fresh pane still showing the notice stamps a
|
|
9310
|
+
new row -- re-arming the hold (the re-confirmation doctrine of the
|
|
9311
|
+
banner ladder). On the ledger, so a restart keeps it."""
|
|
9312
|
+
row = self.state.newest_limited()
|
|
9313
|
+
if row is None:
|
|
9314
|
+
return None
|
|
9315
|
+
age = max(0.0, time.time() - int(row["limited_at"]))
|
|
9316
|
+
if age >= prompts_mod.ACCOUNT_HOLD_SECONDS:
|
|
9317
|
+
return None
|
|
9318
|
+
return (
|
|
9319
|
+
f"usage_limit on {row['session']} {int(age // 60)} min ago — held "
|
|
9320
|
+
f"until the account is back (the hold lifts "
|
|
9321
|
+
f"{int((prompts_mod.ACCOUNT_HOLD_SECONDS - age) // 60)} min from "
|
|
9322
|
+
f"now and the next spawn re-confirms)"
|
|
9323
|
+
)
|
|
9324
|
+
|
|
8947
9325
|
def _page_prompt(
|
|
8948
9326
|
self,
|
|
8949
9327
|
finding: "prompts_mod.PromptFinding",
|
|
@@ -9119,9 +9497,12 @@ class DevWatcher:
|
|
|
9119
9497
|
"max_sessions is not enforced this pass, and session liveness "
|
|
9120
9498
|
"reads INDETERMINATE (no resume can fire)", exc,
|
|
9121
9499
|
)
|
|
9500
|
+
# The usage-limit hold (issue #142) is on the ledger, not the
|
|
9501
|
+
# listing, so it stands through a listing failure too.
|
|
9502
|
+
limit_hold = self._limit_hold()
|
|
9122
9503
|
return (
|
|
9123
|
-
Sweep(reaped=0, names=set()),
|
|
9124
|
-
SessionBudget(None, 0),
|
|
9504
|
+
Sweep(reaped=0, names=set(), hold=limit_hold),
|
|
9505
|
+
SessionBudget(None, 0, hold=limit_hold),
|
|
9125
9506
|
SessionLiveness(None),
|
|
9126
9507
|
)
|
|
9127
9508
|
sweep = self._reap_sessions(sessions)
|
|
@@ -9131,14 +9512,17 @@ class DevWatcher:
|
|
|
9131
9512
|
# outcome -- the account-level hold -- has to reach every edge.
|
|
9132
9513
|
prompt = self._respond_to_prompts(sessions, sweep.names)
|
|
9133
9514
|
sweep = replace(
|
|
9134
|
-
sweep, waiting=prompt.waiting,
|
|
9135
|
-
|
|
9515
|
+
sweep, waiting=prompt.waiting,
|
|
9516
|
+
hold=prompt.hold or self._limit_hold(), answered=prompt.answered,
|
|
9517
|
+
auth_rejected=prompt.auth_rejected, usage_limited=prompt.usage_limited,
|
|
9136
9518
|
)
|
|
9137
9519
|
if sweep.hold:
|
|
9138
9520
|
log.warning(
|
|
9139
9521
|
"prompt responder: HOLDING new worker spawns this pass — %s. "
|
|
9140
9522
|
"Live sessions keep running; the hold lifts on the first "
|
|
9141
|
-
"pass that no longer sees the notice
|
|
9523
|
+
"pass that no longer sees the notice (a usage limit's hold: "
|
|
9524
|
+
"%d min after the last one)", sweep.hold,
|
|
9525
|
+
prompts_mod.ACCOUNT_HOLD_SECONDS // 60,
|
|
9142
9526
|
)
|
|
9143
9527
|
active = self._count_active_sessions(sessions, sweep.names)
|
|
9144
9528
|
self._check_session_cap(active, sweep)
|
|
@@ -10199,6 +10583,7 @@ class DevWatcher:
|
|
|
10199
10583
|
deferrals=deferrals,
|
|
10200
10584
|
reaps=self._reap_records(sweep.names),
|
|
10201
10585
|
auth_rejections=list(sweep.auth_rejected),
|
|
10586
|
+
usage_limits=list(sweep.usage_limited),
|
|
10202
10587
|
now_ms=int(time.time() * 1000),
|
|
10203
10588
|
)
|
|
10204
10589
|
|