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.
@@ -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` → wait for the reset or raise the plan; `out_of_credits` "
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, and the `auth.rejected` records (issue #140). Folded into the
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
- if attempts >= self.config.attempt_cap:
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, attempts)
3673
+ self._escalate(issue, billed)
3611
3674
  return Decision(
3612
3675
  Action.ESCALATED,
3613
- f"{attempts} attempts, cap {self.config.attempt_cap}",
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
- if author_activity:
4932
- row_session = self.state.newest_fix_spawn_session(
4933
- pr.full_name, number, round_
4934
- )
4935
- liveness = self.session_liveness(row_session, roster)
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
- if attempts >= self.config.attempt_cap:
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_, attempts)
5104
+ self._escalate_fix_cap(pr, round_, billed)
4980
5105
  return Decision(
4981
5106
  Action.ESCALATED,
4982
- f"{attempts} fix attempts on round {round_}, cap "
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 — re-enqueuing as attempt %d (fix "
5006
- "session presumed dead)",
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
- context = "re-enqueued — previous session presumed dead"
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
- if attempts >= self.config.attempt_cap:
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, attempts, kind)
7235
+ self._escalate_maintain_cap(pr, billed, kind)
7059
7236
  return Decision(
7060
7237
  Action.ESCALATED,
7061
- f"{attempts} maintenance attempts this request, cap "
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
- "re-enqueued — previous session presumed dead"
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, hold=prompt.hold, answered=prompt.answered,
9135
- auth_rejected=prompt.auth_rejected,
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", sweep.hold,
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