alissa-tools-github-devloop 0.8.57__tar.gz → 0.8.59__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.
Files changed (41) hide show
  1. {alissa_tools_github_devloop-0.8.57/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.59}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/loop.py +231 -8
  3. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/loop_events.py +1 -1
  4. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/state.py +17 -0
  5. alissa_tools_github_devloop-0.8.59/src/main/alissa/tools/github/devloop/version +1 -0
  6. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_devloop-0.8.57/src/main/alissa/tools/github/devloop/version +0 -1
  8. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/LICENSE +0 -0
  9. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/MANIFEST.in +0 -0
  10. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/NOTICE +0 -0
  11. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  17. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  19. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  20. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/config.py +0 -0
  21. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  22. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  23. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  24. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/hungtests.py +0 -0
  25. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/integration_check.py +0 -0
  26. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  27. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/parking.py +0 -0
  28. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  29. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/prompts.py +0 -0
  30. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/version.py +0 -0
  31. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  34. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  35. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  36. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  37. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  38. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  39. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  40. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  41. {alissa_tools_github_devloop-0.8.57 → alissa_tools_github_devloop-0.8.59}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.57
3
+ Version: 0.8.59
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -516,6 +516,44 @@ from .state import State
516
516
 
517
517
  log = logging.getLogger(__name__)
518
518
 
519
+ # The criteria-typing rule (issue #194). orcloop ticks only `evidence`
520
+ # criteria when a PR merges, and the soft validator answers "needs a human"
521
+ # for every `human` one -- so a worker that types a FACT ("PR ready for
522
+ # review", "screenshots committed") as `human` parks its task at
523
+ # pending_validation forever (TASK-183426950, PR #2652: 103 fleet tasks piled
524
+ # up and the backlog brake paused every release). The worker picks the type,
525
+ # so the directive must say how: by who can close the criterion. The
526
+ # operator-gate list stays out of the criteria because orcloop already
527
+ # harvests it from the PR body into the origin task's "Needs a look"
528
+ # checklist; copied in, it would turn every unverifiable item into a human
529
+ # criterion again. The legacy type words are deliberately never written here
530
+ # (a test pins their absence from every directive): naming them, even to
531
+ # forbid them, is how they get copied.
532
+ _CRITERIA_RULE = (
533
+ "Type every validation criterion on your implementation task by WHO CAN "
534
+ "CLOSE IT, writing the type as exactly one of `evidence`, `check` or "
535
+ "`human`: `evidence` is the default — it closes from what the PR, the "
536
+ "delivery note, a merged commit, a test run or a committed file shows "
537
+ "(orcloop ticks these on merge); `check`, with a `source`, when "
538
+ "a machine signal names it (`ci:<owner>/<repo>[#<check>]` for \"CI "
539
+ "green\"); `human` ONLY for a person's judgment (taste, a live phone "
540
+ "check, a pilot user, a decision) — and then say so in the criterion's "
541
+ "text. Facts are never `human`: \"PR ready / review requested / approved "
542
+ "/ merged\", \"tests X pass\", \"screenshots at <path> committed\", "
543
+ "\"doc section added\", \"delivery note registered\" are all "
544
+ "`evidence`. Never copy the PR body's \"Not verified — operator's gate\" "
545
+ "list into task criteria: orcloop harvests it into a \"Needs a look\" "
546
+ "checklist on the origin task by itself."
547
+ )
548
+
549
+ # The adopt half: a resumed or fix-round session inherits a task some other
550
+ # session typed, possibly before this rule existed.
551
+ _CRITERIA_ADOPT = (
552
+ " On a task you ADOPT, retype any criterion that breaks this rule (a fact "
553
+ "typed `human` becomes `evidence`), and type any criterion you add the "
554
+ "same way."
555
+ )
556
+
519
557
  # The origin-task instruction is split in two because the downstream rule is
520
558
  # the same either way -- only the starting point differs: a present ref is
521
559
  # authoritative; an absent one (spawn_anyway / warn_and_spawn) makes locating
@@ -525,6 +563,8 @@ _TASK_WITH_ORIGIN = (
525
563
  "implementation task DOWNSTREAM of it (create_downstream_task), and keep "
526
564
  "its status honest: in_progress while you work, pending_validation at "
527
565
  "handoff."
566
+ + " "
567
+ + _CRITERIA_RULE
528
568
  )
529
569
 
530
570
  _TASK_WITHOUT_ORIGIN = (
@@ -532,6 +572,8 @@ _TASK_WITHOUT_ORIGIN = (
532
572
  "behind this issue, or create one, then create your implementation task "
533
573
  "DOWNSTREAM of it before writing code, and keep its status honest: "
534
574
  "in_progress while you work, pending_validation at handoff."
575
+ + " "
576
+ + _CRITERIA_RULE
535
577
  )
536
578
 
537
579
  # The resume counterparts. A resumed attempt inherits a PR the dead session
@@ -547,6 +589,9 @@ _RESUME_TASK_WITH_ORIGIN = (
547
589
  "in_progress while you work, pending_validation at handoff. Only if the "
548
590
  "PR body names none, create your implementation task DOWNSTREAM of the "
549
591
  "origin (create_downstream_task) before writing code."
592
+ + " "
593
+ + _CRITERIA_RULE
594
+ + _CRITERIA_ADOPT
550
595
  )
551
596
 
552
597
  _RESUME_TASK_WITHOUT_ORIGIN = (
@@ -555,6 +600,9 @@ _RESUME_TASK_WITHOUT_ORIGIN = (
555
600
  "in_progress while you work, pending_validation at handoff); otherwise "
556
601
  "locate the origin task behind this issue, or create one, and create "
557
602
  "your implementation task DOWNSTREAM of it before writing code."
603
+ + " "
604
+ + _CRITERIA_RULE
605
+ + _CRITERIA_ADOPT
558
606
  )
559
607
 
560
608
  # The delivery note (issue #63). A worker's highest-value output is not the
@@ -1514,6 +1562,9 @@ FIX_DIRECTIVE = (
1514
1562
  "keep its status honest — in_progress while you work, pending_validation "
1515
1563
  "at handoff. If a ref is missing from the PR body, restoring both is part "
1516
1564
  "of your job: the PR body is the canonical cross-actor record. "
1565
+ + _CRITERIA_RULE
1566
+ + _CRITERIA_ADOPT
1567
+ + " "
1517
1568
  "Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
1518
1569
  "branch `{head_ref}` already exists — re-attach its worktree with `git "
1519
1570
  "worktree add {worktree_dir} {head_ref}` if it is missing (the directory "
@@ -1567,6 +1618,9 @@ FIX_RESUME_DIRECTIVE = (
1567
1618
  "in_progress while you work, pending_validation at handoff. If a ref is "
1568
1619
  "missing from the PR body, restoring both is part of your job: the PR "
1569
1620
  "body is the canonical cross-actor record. "
1621
+ + _CRITERIA_RULE
1622
+ + _CRITERIA_ADOPT
1623
+ + " "
1570
1624
  "FIRST hydrate the context you did not live through: read the PR (body, "
1571
1625
  "commits, and every review thread) and the issue behind it, to see how "
1572
1626
  "far the dead session got. Golden path: your cwd is the repo's worktree "
@@ -1835,6 +1889,16 @@ LIVE_STALLED_COMMENT = (
1835
1889
  "`{classified}`."
1836
1890
  )
1837
1891
 
1892
+ ASSIGNED_OUTSIDE_COMMENT = (
1893
+ "**Dev loop skipping this issue** — it has been assigned to `{login}` for "
1894
+ "at least {minutes} min, but this daemon has no spawn for it on its "
1895
+ "ledger and no spawn line of its own on the activity comment, so the "
1896
+ "assignment was made outside the loop (by hand, or under another "
1897
+ "workspace's ledger). The daemon will not spawn over it. Operator "
1898
+ "options: unassign `{login}` to let the loop take the issue, or leave it "
1899
+ "if someone is working it by hand. (One-time notice.)"
1900
+ )
1901
+
1838
1902
  # The operator page for an account-level prompt kind (issue #127): an
1839
1903
  # expired login, a hit usage limit, an empty credit balance. No keystroke
1840
1904
  # answers these, so the page is the act -- one per kind per
@@ -2199,6 +2263,16 @@ ESCALATION_FIX_HANDOVER = "fix-handover"
2199
2263
  # neither ping may silence the other.
2200
2264
  ESCALATION_LIVE_STALLED = "live-stalled"
2201
2265
 
2266
+ # The assigned-outside skip's once-per-issue operator page (issue #200): the
2267
+ # issue is assigned to this login, the ledger has no row and its activity
2268
+ # comment no spawn line of ours, so nothing the daemon can see explains the
2269
+ # assignment. Once-ever like ESCALATION_ASSIGNMENT -- the remedy (unassign,
2270
+ # or leave it to whoever is working it by hand) does not change per poll.
2271
+ # ASSIGNED_OUTSIDE_SEEN is the episode's durable first-seen clock, kept in the
2272
+ # activity_log ledger so a daemon restart does not restart the wait.
2273
+ ESCALATION_ASSIGNED_OUTSIDE = "assigned-outside"
2274
+ ASSIGNED_OUTSIDE_SEEN = "assigned-outside-seen"
2275
+
2202
2276
  # The wedge kill's once-per-episode marker (see wedge_kind). Not an operator
2203
2277
  # page -- the daemon acted instead of asking -- but, like ESCALATION_REREQUEST,
2204
2278
  # a once-per-key act the `escalations` table is the ledger of: it dedupes the
@@ -4778,14 +4852,21 @@ class DevWatcher:
4778
4852
  return self._claim_deferral(claim, attempts)
4779
4853
 
4780
4854
  if login in issue.assignees and age is None:
4781
- # Self-assigned but the ledger never spawned: assigned outside this
4782
- # daemon (by hand, or under another workspace's ledger). Not a
4783
- # fresh self-spawn, so not ours to double-book.
4784
- return Decision(
4785
- Action.SKIPPED,
4786
- f"assigned to {login} outside this daemon (no spawn on the "
4787
- f"ledger) — unassign the issue to let the loop take it",
4788
- )
4855
+ # Self-assigned but the ledger never spawned. Issue #200: when the
4856
+ # issue's own activity comment carries an EXPIRED spawn line by
4857
+ # this login on this lane (the claim above already let it age
4858
+ # out), the assignment is a dead attempt of ours whose ledger row
4859
+ # was lost (an overlapping deploy wrote it) -- adopt it into the
4860
+ # ledger and let the ordinary stale matrix below decide, exactly
4861
+ # as if the row had been there all along.
4862
+ orphans = self._orphaned_spawns(issue, session_name(issue, 1))
4863
+ if orphans:
4864
+ attempts, age = self._adopt_orphaned_spawns(issue, orphans)
4865
+ else:
4866
+ # No spawn line at all: assigned outside this daemon (by
4867
+ # hand, or under another workspace's ledger). Not ours to
4868
+ # double-book -- but not silent forever either.
4869
+ return self._assigned_outside(issue, login)
4789
4870
 
4790
4871
  # Staleness needs THREE signals, not one: the ledger timer says the
4791
4872
  # newest spawn is old, but elapsed time alone cannot tell a dead
@@ -10123,6 +10204,148 @@ class DevWatcher:
10123
10204
  defer_token=loop_events_mod.DEFER_CLAIM,
10124
10205
  )
10125
10206
 
10207
+ def _orphaned_spawns(
10208
+ self, issue: Issue, session: str
10209
+ ) -> "list[ActivityClaim]":
10210
+ """The EXPIRED spawn lines this login wrote on the issue's activity
10211
+ comment for `session`'s lane, oldest first and one per session name
10212
+ (issue #200). Called only when the issue is self-assigned and the
10213
+ ledger has no row for it, i.e. after `external_claim` has already
10214
+ turned down every line as not fresh: what is left is a dead attempt
10215
+ of ours whose ledger row was lost (written by an overlapping
10216
+ deployment, or before a ledger loss).
10217
+
10218
+ Own authorship comes from `marked_comment`, the lane from
10219
+ `activity_lane`. Same switch and fail-open rule as `external_claim`:
10220
+ with claims disabled, or on any read failure, the answer is "no
10221
+ lines", which keeps today's skip."""
10222
+ if not self.config.activity_claims_enabled:
10223
+ return []
10224
+ try:
10225
+ comment = self.github.marked_comment(
10226
+ issue.owner, issue.repo, issue.number, ACTIVITY_MARKER
10227
+ )
10228
+ except Exception as exc:
10229
+ log.warning(
10230
+ "could not read the activity comment on %s (%s) — no "
10231
+ "orphaned attempt to adopt this poll",
10232
+ issue.issue_slug,
10233
+ exc,
10234
+ )
10235
+ return []
10236
+ if comment is None:
10237
+ return []
10238
+ lane = activity_lane(session)
10239
+ window = self.config.stale_minutes * 60
10240
+ oldest: "dict[str, ActivityClaim]" = {}
10241
+ for claim in activity_claims(comment.body):
10242
+ if activity_lane(claim.session) != lane or claim.age < window:
10243
+ continue
10244
+ known = oldest.get(claim.session)
10245
+ if known is None or claim.at < known.at:
10246
+ oldest[claim.session] = claim
10247
+ return sorted(oldest.values(), key=lambda claim: claim.at)
10248
+
10249
+ def _adopt_orphaned_spawns(
10250
+ self, issue: Issue, orphans: "list[ActivityClaim]"
10251
+ ) -> "tuple[int, float]":
10252
+ """Record each orphaned spawn line as a ledger row stamped with the
10253
+ line's own time, so attempt_cap counts it and the ordinary stale
10254
+ matrix (linked-PR probe, tmux liveness, resume) judges it exactly as
10255
+ a row that was never lost. Returns the (attempts, age) the caller
10256
+ continues with.
10257
+
10258
+ Rows are numbered 1..k in line order (the ledger has none for this
10259
+ issue), keeping the session name the line carries; numbering by the
10260
+ name's own suffix could REPLACE a row when a line is missing. Not
10261
+ written on a dry run (no ledger write is), but the returned values
10262
+ let the dry pass reach the same decision."""
10263
+ for attempt, orphan in enumerate(orphans, start=1):
10264
+ log.warning(
10265
+ "%s: adopted orphaned attempt %d from activity log (`%s`, "
10266
+ "spawned %d min ago by this login, no local ledger row) — "
10267
+ "taking the stale path",
10268
+ issue.issue_slug,
10269
+ attempt,
10270
+ orphan.session,
10271
+ int(orphan.age / 60),
10272
+ )
10273
+ if self.config.dry_run:
10274
+ continue
10275
+ self.state.record_spawn(
10276
+ repo_slug=issue.full_name,
10277
+ issue=issue.number,
10278
+ attempt=attempt,
10279
+ session=orphan.session,
10280
+ )
10281
+ # The row just written is the newest; move its clock back to the
10282
+ # line's, so its age says what the line says.
10283
+ self.state.age_out_spawn(issue.full_name, issue.number, orphan.at)
10284
+ return len(orphans), orphans[-1].age
10285
+
10286
+ def _assigned_outside(self, issue: Issue, login: str) -> Decision:
10287
+ """The skip for an issue assigned to this login with nothing on the
10288
+ ledger or the activity comment to explain it -- assigned by hand.
10289
+ Not silent forever (issue #200): once the skip has persisted for
10290
+ STALLED_DEFER_MULTIPLE stale windows (clock: the episode's durable
10291
+ first-seen row) it posts ONE operator comment, recorded only after
10292
+ it lands so a failed post retries next poll."""
10293
+ full_name, number = issue.full_name, issue.number
10294
+ reason = (
10295
+ f"assigned to {login} outside this daemon (no spawn on the "
10296
+ f"ledger) — unassign the issue to let the loop take it"
10297
+ )
10298
+ if self.state.escalated(full_name, number, ESCALATION_ASSIGNED_OUTSIDE):
10299
+ return Decision(Action.SKIPPED, reason)
10300
+ if self.config.dry_run:
10301
+ return Decision(Action.SKIPPED, reason)
10302
+ first_seen = self.state.activity_logged_at(
10303
+ full_name, number, ASSIGNED_OUTSIDE_SEEN
10304
+ )
10305
+ if first_seen is None:
10306
+ self.state.record_activity(full_name, number, ASSIGNED_OUTSIDE_SEEN)
10307
+ first_seen = int(time.time())
10308
+ stalled_after = STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
10309
+ waited = time.time() - first_seen
10310
+ if waited < stalled_after:
10311
+ # The page's clock is the seat's: no parking across it, or it
10312
+ # would fire on the first pass after the wake (issue #153).
10313
+ return Decision(
10314
+ Action.SKIPPED,
10315
+ reason,
10316
+ owed=True,
10317
+ timer=(
10318
+ parking_mod.TIMER_STALE_WINDOW, first_seen + stalled_after
10319
+ ),
10320
+ )
10321
+ body = ASSIGNED_OUTSIDE_COMMENT.format(
10322
+ login=login, minutes=int(waited / 60)
10323
+ )
10324
+ log.warning(
10325
+ "%s has been assigned to %s outside this daemon for %.0f min — "
10326
+ "escalating to operator (once)",
10327
+ issue.issue_slug,
10328
+ login,
10329
+ waited / 60,
10330
+ )
10331
+ try:
10332
+ self.github.comment(issue.owner, issue.repo, number, body)
10333
+ except CommandError as exc:
10334
+ log.error(
10335
+ "could not post the assigned-outside comment on %s: %s — "
10336
+ "the page retries next poll",
10337
+ issue.issue_slug,
10338
+ exc,
10339
+ )
10340
+ return Decision(Action.SKIPPED, reason, owed=True)
10341
+ self.state.record_escalation(
10342
+ full_name, number, ESCALATION_ASSIGNED_OUTSIDE
10343
+ )
10344
+ return Decision(
10345
+ Action.ESCALATED,
10346
+ f"{reason}; operator paged after {int(waited / 60)} min",
10347
+ )
10348
+
10126
10349
  def _log_deferral(
10127
10350
  self,
10128
10351
  owner: str,
@@ -189,7 +189,7 @@ _LIMIT_LIFTED_RE = re.compile(r"^usage-limit-lifted:(?P<session>[^@]+)@(?P<since
189
189
  # points the operator at an unrelated PR or at nothing.
190
190
  _ISSUE_ANCHORED = (
191
191
  "cap", "assignment", "stalled", "live-stalled", "wedged", "first-run-dialog",
192
- "claude-exited",
192
+ "claude-exited", "assigned-outside",
193
193
  )
194
194
 
195
195
 
@@ -1245,6 +1245,23 @@ class State:
1245
1245
  ).fetchone()
1246
1246
  return row is not None
1247
1247
 
1248
+ def activity_logged_at(
1249
+ self, repo_slug: str, number: int, kind: str
1250
+ ) -> "int | None":
1251
+ """When this activity KIND was first recorded (unix seconds), or None.
1252
+ The durable first-seen clock of an episode that must outlive a daemon
1253
+ restart (issue #200: the assigned-outside skip escalates once it has
1254
+ persisted long enough). A row held in memory by a failed write answers
1255
+ with the timestamp it would have carried."""
1256
+ pending = self._pending_gates.get(("activity_log", repo_slug, number, kind))
1257
+ if pending is not None:
1258
+ return int(pending)
1259
+ row = self._db.execute(
1260
+ "SELECT logged_at FROM activity_log WHERE repo_slug=? AND number=? AND kind=?",
1261
+ (repo_slug, number, kind),
1262
+ ).fetchone()
1263
+ return None if row is None else int(row["logged_at"])
1264
+
1248
1265
  def record_activity(self, repo_slug: str, number: int, kind: str) -> None:
1249
1266
  """Idempotent per kind: OR IGNORE keeps the first line's timestamp.
1250
1267
  Recorded by the caller only AFTER the append lands, so a transient
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.57
3
+ Version: 0.8.59
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera