alissa-tools-github-devloop 0.6.0__tar.gz → 0.6.2__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 (29) hide show
  1. {alissa_tools_github_devloop-0.6.0/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.6.2}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/__main__.py +22 -1
  3. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/config.py +18 -0
  4. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/ghclient.py +23 -0
  5. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/loop.py +397 -63
  6. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/state.py +25 -0
  7. alissa_tools_github_devloop-0.6.2/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/webui/sources.py +1 -0
  9. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_devloop-0.6.0/src/main/alissa/tools/github/devloop/version +0 -1
  11. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/MANIFEST.in +0 -0
  12. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/README.md +0 -0
  13. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/requirements.txt +0 -0
  14. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/setup.cfg +0 -0
  15. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/setup.py +0 -0
  16. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  17. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  19. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/version.py +0 -0
  20. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  21. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  22. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  23. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  24. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  25. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  26. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  27. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  28. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  29. {alissa_tools_github_devloop-0.6.0 → alissa_tools_github_devloop-0.6.2}/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.6.0
3
+ Version: 0.6.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -165,6 +165,24 @@ def build_parser() -> argparse.ArgumentParser:
165
165
  "never auto-resume — a dead-with-PR lane waits for an operator",
166
166
  )
167
167
 
168
+ claims = over.add_mutually_exclusive_group()
169
+ claims.add_argument(
170
+ "--activity-claims",
171
+ dest="activity_claims_enabled",
172
+ action="store_true",
173
+ default=None,
174
+ help="read the trigger artifact's activity comment for another "
175
+ "instance's fresh spawn line and defer on it, so spawn dedupe "
176
+ "survives a ledger the local instance never wrote (the default)",
177
+ )
178
+ claims.add_argument(
179
+ "--no-activity-claims",
180
+ dest="activity_claims_enabled",
181
+ action="store_false",
182
+ help="ledger-only spawn dedupe: never read external claim evidence "
183
+ "(two overlapping instances can then both spawn on one item)",
184
+ )
185
+
168
186
  dry = over.add_mutually_exclusive_group()
169
187
  dry.add_argument(
170
188
  "--dry-run",
@@ -203,6 +221,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
203
221
  "maintain_label": args.maintain_label,
204
222
  "maintain_enabled": args.maintain_enabled,
205
223
  "resume_enabled": args.resume_enabled,
224
+ "activity_claims_enabled": args.activity_claims_enabled,
206
225
  "dry_run": args.dry_run,
207
226
  }
208
227
 
@@ -234,13 +253,15 @@ def log_effective_config(config: Config, login: str) -> None:
234
253
  log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
235
254
  log.info(
236
255
  "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
237
- "max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s",
256
+ "max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
257
+ "activity_claims=%s",
238
258
  config.poll_interval, config.dry_run, config.attempt_cap,
239
259
  config.stale_minutes,
240
260
  config.max_sessions or "unlimited",
241
261
  config.fix_rounds_enabled,
242
262
  config.maintain_enabled, config.maintain_label,
243
263
  config.resume_enabled,
264
+ config.activity_claims_enabled,
244
265
  )
245
266
  log.debug("hub_template: %s", config.hub_template)
246
267
  log.debug("agent_profile: %s", config.agent_profile)
@@ -63,6 +63,7 @@ CONFIG_KEYS = (
63
63
  "maintain_label",
64
64
  "maintain_enabled",
65
65
  "resume_enabled",
66
+ "activity_claims_enabled",
66
67
  "dry_run",
67
68
  )
68
69
 
@@ -180,6 +181,20 @@ class Config:
180
181
  # operator who wants a dead-with-PR lane to wait for a human.
181
182
  resume_enabled: bool = True
182
183
 
184
+ # Cross-instance spawn dedupe (issue #53): before spawning on any edge,
185
+ # read the trigger artifact's mechanical activity comment and treat a
186
+ # spawn line younger than stale_minutes as an in-flight claim EVEN when
187
+ # the local ledger has no row. The ledger is instance-local and its volume
188
+ # follows the active deployment, so during a deploy overlap or a
189
+ # crashloop two daemons run with one GitHub identity and two ledgers and
190
+ # both spawn; the activity comment is the one claim record either can
191
+ # read. Default ON -- it closes a thrice-observed duplicate-PR incident,
192
+ # it can only ever DEFER a spawn, and a claim expires with the same
193
+ # stale_minutes window a ledger row does. false restores the
194
+ # ledger-only dedupe: the escape hatch for a single-instance deployment
195
+ # that would rather not pay the extra comments fetch.
196
+ activity_claims_enabled: bool = True
197
+
183
198
  dry_run: bool = False
184
199
 
185
200
  # Derived at build time: `repos` casefolded for matching. GitHub names are
@@ -338,6 +353,9 @@ class Config:
338
353
  resume_enabled=bool(
339
354
  raw.get("resume_enabled", cls.resume_enabled)
340
355
  ),
356
+ activity_claims_enabled=bool(
357
+ raw.get("activity_claims_enabled", cls.activity_claims_enabled)
358
+ ),
341
359
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
342
360
  )
343
361
 
@@ -821,6 +821,29 @@ class GitHub:
821
821
  if isinstance(c, dict)
822
822
  ]
823
823
 
824
+ def marked_comment(
825
+ self, owner: str, repo: str, number: int, marker: str
826
+ ) -> "IssueComment | None":
827
+ """THE own-authored comment carrying `marker` on this issue/PR, or None.
828
+
829
+ The find half of the activity comment's find-or-create, lifted out of
830
+ the writer so the READER shares one definition of "the daemon's
831
+ activity comment" with it -- a marker/author filter that drifted
832
+ between the two would let a spawn append to one comment while the
833
+ cross-instance claim check read another.
834
+
835
+ Both halves of the filter are load-bearing. The marker names the
836
+ mechanical log (an operator page on the same artifact must never be
837
+ mistaken for it), and OWN AUTHORSHIP is what keeps a marker anyone can
838
+ paste from being PATCHed by the writer -- or, for the reader, from
839
+ being able to park a lane by spoofing a claim line. First match wins,
840
+ matching the writer's `mine[0]`.
841
+ """
842
+ for comment in self.issue_comments(owner, repo, number):
843
+ if comment.author == self.login and marker in comment.body:
844
+ return comment
845
+ return None
846
+
824
847
  def update_comment(
825
848
  self, owner: str, repo: str, comment_id: int, body: str
826
849
  ) -> None:
@@ -161,6 +161,35 @@ spams the comment; spawn lines self-dedupe on their own spawn ledger row. Being
161
161
  plain issue comments, they create no review records, never move the fix edge's
162
162
  author-activity liveness signal, and never register as operator escalations.
163
163
 
164
+ That comment is also the loop's only CROSS-INSTANCE claim record, and since
165
+ `activity_claims_enabled` (default true) it is READ as well as written. Every
166
+ dedupe signal above this one is local (the ledger) or identity-scoped (the
167
+ assignment), and neither can see the failure that produced three
168
+ closed-as-duplicate PRs: during a deploy overlap or a crashloop window two
169
+ daemon instances are briefly alive sharing ONE GitHub identity but not one
170
+ ledger -- the state volume follows the active deployment -- so each reads an
171
+ empty ledger, each believes it holds the only claim, and both spawn. The
172
+ activity comment escapes both limits: it lives on the trigger artifact, so any
173
+ instance can read any other's spawn line from the one place both already
174
+ write. Before spawning on ANY edge, once the local ledger stops vouching for
175
+ the lane, the daemon reads it: a spawn line younger than stale_minutes defers
176
+ exactly like a live local claim, EVEN with no ledger row at all. A claim is
177
+ matched by LANE (the session name minus its attempt segment, so the fix edge's
178
+ round stays in and two instances numbering attempts differently still agree
179
+ what is claimed) and tested on FRESHNESS ALONE -- never liveness, because a
180
+ peer's sessions are not in this container's tmux and would read DEAD, which
181
+ would defeat the entire signal. Past the window the claim expires and the
182
+ stale/liveness matrix above governs untouched. The new signal is fail-open by
183
+ construction: no comment, an unreadable one, a foreign author, a missing
184
+ marker, a fetch that raises -- every one of them reads as "no claim" and
185
+ restores the ledger-only behavior, because a gate that could latch a lane
186
+ closed on a bad read would be worse than the double-spawn it prevents. This is
187
+ also why the activity line is now written BEFORE the enqueue on all three
188
+ edges rather than after: a claim published after the session exists does not
189
+ cover the window it is for. The cost is a crash between the line and the
190
+ enqueue leaving a claim with no session -- one stale window of deferral, then
191
+ the line ages out and the next poll spawns normally.
192
+
164
193
  Finally, every pass runs the SESSION REAPER: finished `develop-*`/`fix-*`
165
194
  worker sessions otherwise idle in tmux forever (observed live on the reviewer
166
195
  daemon, 2026-07-22 -- and dev workers are worse, because they block through
@@ -184,6 +213,7 @@ spared, dry-run only logs, and kills are best-effort: a CLI without
184
213
 
185
214
  from __future__ import annotations
186
215
 
216
+ import calendar
187
217
  import logging
188
218
  import re
189
219
  import time
@@ -520,6 +550,37 @@ ACTIVITY_HEADER = (
520
550
  # line is deduped per episode -- see deferral_activity_kind.
521
551
  ACTIVITY_DEFERRED = "deferred"
522
552
 
553
+ # The reader's grammar for a line `_activity_line` wrote: bullet, UTC
554
+ # timestamp, backticked session name, edge, context. The edge field is
555
+ # non-greedy and em-dash-free so the CONTEXT keeps every em-dash it contains
556
+ # ("deferred — at capacity", "re-enqueued — previous session presumed dead");
557
+ # a line that does not match this shape is simply not read, which is the
558
+ # fail-open direction (see external_claim).
559
+ ACTIVITY_LINE_RE = re.compile(
560
+ r"^-\s+(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) UTC\s+—\s+"
561
+ r"`(?P<session>[^`]+)`\s+—\s+(?P<edge>[^—]*?)\s+—\s+(?P<context>.*)$"
562
+ )
563
+
564
+ # The trailing attempt segment every session name ends in. Stripping it turns
565
+ # a session name into its LANE -- the (edge, artifact, and on the fix edge
566
+ # round) identity that a claim is made against. Attempt-independent on
567
+ # purpose: two instances with different ledgers disagree about the attempt
568
+ # NUMBER while working the very same lane, and the lane is what must not be
569
+ # double-claimed.
570
+ ATTEMPT_SUFFIX_RE = re.compile(r"-a\d+$")
571
+
572
+ # How far a claim line's timestamp may sit in the FUTURE and still be read.
573
+ # The line is stamped by whichever instance wrote it, off ITS clock, and read
574
+ # against ours, so a small forward skew is ordinary and must not make a real
575
+ # claim unreadable -- inside the tolerance the age simply floors at 0 (the
576
+ # line reads as brand new, the deferring direction). Beyond it the clock is
577
+ # not plausibly a peer's: honouring such a line would park the lane for as
578
+ # long as the bogus timestamp stays in the future, so it is dropped -- the
579
+ # fail-open direction this whole signal is held to. A backward-skewed clock
580
+ # needs no tolerance: its line simply looks older, and past stale_minutes it
581
+ # stops claiming anything, exactly as an old line should.
582
+ ACTIVITY_SKEW_TOLERANCE = 300
583
+
523
584
 
524
585
  # Escalation-ledger kinds. Cap-out, assignment-rejection, and a stalled
525
586
  # linked-PR deferral have different operator remedies (raise attempt_cap vs
@@ -920,6 +981,86 @@ def maintain_session_name(pr: PullRequest, attempt: int) -> str:
920
981
  return f"maintain-{owner}-{repo}-pr{pr.number}-a{attempt}"
921
982
 
922
983
 
984
+ def activity_lane(session: str) -> str:
985
+ """A session name reduced to its LANE by dropping the attempt segment.
986
+
987
+ `develop-acme-widgets-i7-a3` -> `develop-acme-widgets-i7`;
988
+ `fix-acme-widgets-pr16-r2-a1` -> `fix-acme-widgets-pr16-r2` (the ROUND
989
+ stays: the fix ledger dedupes per round, so a claim must too);
990
+ `maintain-acme-widgets-pr16-a1` -> `maintain-acme-widgets-pr16`.
991
+
992
+ The lane is what a spawn claims. Attempt numbers come from a LOCAL ledger,
993
+ and the whole point of reading another instance's spawn line is that its
994
+ ledger is not ours -- it can be on attempt 1 of a lane we would number 3.
995
+ Comparing lanes rather than names is what lets the two agree on what is
996
+ already claimed. A name without an attempt segment is returned unchanged
997
+ and so simply matches nothing (no lane the daemon ever spawns lacks one).
998
+ """
999
+ return ATTEMPT_SUFFIX_RE.sub("", session)
1000
+
1001
+
1002
+ def parse_activity_timestamp(ts: str) -> "int | None":
1003
+ """An activity line's `%Y-%m-%d %H:%M:%S` UTC stamp as unix seconds, or
1004
+ None when it is not a real instant.
1005
+
1006
+ `_activity_line` writes UTC via `time.gmtime`, so the read is `timegm`,
1007
+ never `mktime` -- reading a UTC stamp through the host's local timezone
1008
+ would shift every claim by the offset and, east of Greenwich, make fresh
1009
+ lines look future-dated. The mirror of ghclient.parse_github_timestamp
1010
+ (different format, same contract: unreadable is None, never an error).
1011
+ """
1012
+ try:
1013
+ return calendar.timegm(time.strptime(ts, "%Y-%m-%d %H:%M:%S"))
1014
+ except (ValueError, TypeError):
1015
+ return None
1016
+
1017
+
1018
+ @dataclass(frozen=True, slots=True)
1019
+ class ActivityClaim:
1020
+ """One readable spawn record from the activity comment: a session name, the
1021
+ instant its line was written, and how old that is against OUR clock (never
1022
+ negative -- see ACTIVITY_SKEW_TOLERANCE)."""
1023
+
1024
+ session: str
1025
+ at: int
1026
+ age: float
1027
+
1028
+
1029
+ def activity_claims(body: str, now: "float | None" = None) -> "list[ActivityClaim]":
1030
+ """Every readable SPAWN record in an activity comment's body.
1031
+
1032
+ Spawn records only: a capacity-deferral line explicitly claims nothing (no
1033
+ self-assign, no ledger row, no session -- see _spawn's gate), so reading
1034
+ one as a claim would let a deferring instance park the lane against
1035
+ everybody including itself. The context field is what tells them apart,
1036
+ and `deferred` is the only context that is not a spawn.
1037
+
1038
+ Everything else fails open, one line at a time: a line that does not match
1039
+ the grammar, or whose stamp is not an instant, or whose stamp is
1040
+ implausibly far in the future is dropped and the remaining lines are still
1041
+ read. A comment that is entirely unreadable therefore yields no claims,
1042
+ which is exactly "behave as before" on the new signal.
1043
+ """
1044
+ now = time.time() if now is None else now
1045
+ claims: "list[ActivityClaim]" = []
1046
+ for raw in (body or "").splitlines():
1047
+ match = ACTIVITY_LINE_RE.match(raw.strip())
1048
+ if match is None:
1049
+ continue
1050
+ if match.group("context").strip().lower().startswith(ACTIVITY_DEFERRED):
1051
+ continue
1052
+ at = parse_activity_timestamp(match.group("ts"))
1053
+ if at is None:
1054
+ continue
1055
+ age = now - at
1056
+ if age < -ACTIVITY_SKEW_TOLERANCE:
1057
+ continue
1058
+ claims.append(
1059
+ ActivityClaim(session=match.group("session"), at=at, age=max(age, 0.0))
1060
+ )
1061
+ return claims
1062
+
1063
+
923
1064
  def no_envelope_verdict(owner: str, repo: str, number: int) -> "str | None":
924
1065
  """The default (stub) envelope tie-breaker: no verdict is ever readable.
925
1066
 
@@ -1026,6 +1167,29 @@ class DevWatcher:
1026
1167
  session=session_name(issue, attempts),
1027
1168
  )
1028
1169
 
1170
+ # The one dedupe signal that survives an instance change, consulted
1171
+ # the moment the LOCAL ledger stops vouching for the lane (no row, or
1172
+ # a row already past the window): another instance's fresh spawn line
1173
+ # on this issue's own activity comment. It sits ABOVE the
1174
+ # self-assigned-outside guard on purpose. Both branches describe an
1175
+ # issue marked in flight with nothing local to show for it, but their
1176
+ # operator remedies are OPPOSITE -- that guard's message asks the
1177
+ # operator to UNASSIGN, which against a peer instance's live claim
1178
+ # would clear the only other marker and invite the very double-spawn
1179
+ # this closes. The claim is the more specific diagnosis, so it answers
1180
+ # first. The cost is one comments fetch per poll on an issue nothing
1181
+ # local claims; the fresh-in-flight return above keeps it off the
1182
+ # common case, exactly as the linked-PR probe is kept off it.
1183
+ claim = self.external_claim(
1184
+ issue.owner,
1185
+ issue.repo,
1186
+ issue.number,
1187
+ session_name(issue, attempts + 1),
1188
+ issue.issue_slug,
1189
+ )
1190
+ if claim is not None:
1191
+ return self._claim_deferral(claim, attempts)
1192
+
1029
1193
  if login in issue.assignees and age is None:
1030
1194
  # Self-assigned but the ledger never spawned: assigned outside this
1031
1195
  # daemon (by hand, or under another workspace's ledger). Not a
@@ -1305,27 +1469,29 @@ class DevWatcher:
1305
1469
  except AssignmentRejected as exc:
1306
1470
  return self._escalate_rejection(issue, exc)
1307
1471
 
1308
- self.alissa.enqueue_developer(
1309
- session=name,
1310
- directive=directive,
1311
- cwd=hub,
1312
- agent=self.config.agent_profile,
1313
- dry_run=self.config.dry_run,
1314
- )
1315
-
1316
- if not self.config.dry_run:
1317
- self.state.record_spawn(
1318
- repo_slug=issue.full_name,
1319
- issue=issue.number,
1320
- attempt=attempt,
1321
- session=name,
1322
- )
1323
-
1324
- # AFTER the spawn side effects on purpose: the activity comment is
1325
- # telemetry and must never gate the spawn it reports on. A resume
1326
- # names the confirmation that unblocked it (an operator reading the
1327
- # issue must be able to tell a proven-dead resume from a timer-only
1328
- # retry); a plain retry says presumed; attempt 1 is a plain spawn.
1472
+ # BEFORE the enqueue, not after (issue #53). The line stopped being
1473
+ # only telemetry the moment `external_claim` started reading it: it is
1474
+ # now this spawn's CROSS-INSTANCE claim, the one marker a peer daemon
1475
+ # with its own ledger can see, and a claim published after the session
1476
+ # exists leaves the window it is meant to close wide open -- the whole
1477
+ # gap is the seconds between deciding to spawn and the record of it
1478
+ # becoming visible. Publish first, then enqueue.
1479
+ #
1480
+ # The crash story is why this is safe to reorder. A crash between the
1481
+ # line and the enqueue leaves a claim with no session behind it: this
1482
+ # lane then defers for ONE stale window, after which the line ages out
1483
+ # of the freshness test and the next poll spawns normally. That is
1484
+ # bounded, self-healing, and strictly cheaper than the failure the
1485
+ # other order permits (two live sessions, two PRs, two review loops,
1486
+ # one closed as a duplicate). Nothing else moved: the assignment still
1487
+ # lands first (it is the issue edge's server-side marker), the line is
1488
+ # still best-effort -- `_append_activity` swallows its failures and a
1489
+ # failed line never gates the spawn -- and still fully dry-run gated.
1490
+ #
1491
+ # A resume names the confirmation that unblocked it (an operator
1492
+ # reading the issue must be able to tell a proven-dead resume from a
1493
+ # timer-only retry); a plain retry says presumed; attempt 1 is a plain
1494
+ # spawn.
1329
1495
  if resume_pr is not None:
1330
1496
  context = (
1331
1497
  f"resumed — previous session confirmed dead (absent from the "
@@ -1344,6 +1510,22 @@ class DevWatcher:
1344
1510
  issue.issue_slug,
1345
1511
  )
1346
1512
 
1513
+ self.alissa.enqueue_developer(
1514
+ session=name,
1515
+ directive=directive,
1516
+ cwd=hub,
1517
+ agent=self.config.agent_profile,
1518
+ dry_run=self.config.dry_run,
1519
+ )
1520
+
1521
+ if not self.config.dry_run:
1522
+ self.state.record_spawn(
1523
+ repo_slug=issue.full_name,
1524
+ issue=issue.number,
1525
+ attempt=attempt,
1526
+ session=name,
1527
+ )
1528
+
1347
1529
  resumed = (
1348
1530
  ""
1349
1531
  if resume_pr is None
@@ -1743,6 +1925,26 @@ class DevWatcher:
1743
1925
  round=round_,
1744
1926
  )
1745
1927
 
1928
+ # The cross-instance claim check, the issue edge's placement
1929
+ # transposed: past the fresh-in-flight return, the local ledger no
1930
+ # longer vouches for this round, and a peer instance's fresh spawn
1931
+ # line on THIS PR's activity comment is the only thing that can. The
1932
+ # lane is round-scoped (activity_lane keeps the `-r<k>` segment), so a
1933
+ # claim on round k never suppresses round k+1 -- the fix ledger
1934
+ # dedupes per round and the external evidence must match it. This edge
1935
+ # needs the signal most: it has no assignment analogue at all (PR
1936
+ # authorship is the marker, and both instances share the identity that
1937
+ # authored it), so before this the ledger was the ONLY dedupe it had.
1938
+ claim = self.external_claim(
1939
+ pr.owner,
1940
+ pr.repo,
1941
+ pr.number,
1942
+ fix_session_name(pr, round_, attempts + 1),
1943
+ pr.pr_slug,
1944
+ )
1945
+ if claim is not None:
1946
+ return self._claim_deferral(claim, attempts, round_=round_)
1947
+
1746
1948
  # Fix-retry staleness needs TWO signals, the issue edge's doctrine
1747
1949
  # verbatim (see the linked-PR probe in evaluate()): the ledger timer
1748
1950
  # says the newest fix spawn is old, but elapsed time alone cannot
@@ -2093,6 +2295,36 @@ class DevWatcher:
2093
2295
  dead_session=dead_session,
2094
2296
  )
2095
2297
 
2298
+ # BEFORE the enqueue (issue #53), for the reason _spawn spells out:
2299
+ # the line is this spawn's cross-instance claim, and a claim published
2300
+ # after the session exists does not close the window it is for. On
2301
+ # this edge the argument is sharper still -- there is no
2302
+ # self-assignment to fall back on (the PR's authorship IS the marker,
2303
+ # and both instances share the identity that authored it), so this
2304
+ # line is the ONLY thing a peer daemon can read. Same crash story: a
2305
+ # crash between the line and the enqueue costs one stale window of
2306
+ # deferral on this round, then the line ages out and the retry
2307
+ # proceeds. The line stays best-effort and dry-run gated.
2308
+ #
2309
+ # The session name carries the round and attempt; the context marks a
2310
+ # retry, and names the confirmation when a resume is what unblocked it.
2311
+ if dead_session is not None:
2312
+ context = (
2313
+ "resumed — previous session confirmed dead (absent from the "
2314
+ "tmux listing); finishing the round on the same branch"
2315
+ )
2316
+ elif reenqueued:
2317
+ context = "re-enqueued — previous session presumed dead"
2318
+ else:
2319
+ context = "spawned"
2320
+ self._append_activity(
2321
+ pr.owner,
2322
+ pr.repo,
2323
+ pr.number,
2324
+ self._activity_line(name, "fix edge", context),
2325
+ pr.pr_slug,
2326
+ )
2327
+
2096
2328
  # No self-assignment here: the PR's authorship IS the server-side
2097
2329
  # marker (the token already owns it), and the in-flight signal is the
2098
2330
  # fix ledger row plus, once the session re-requests, the pending
@@ -2114,26 +2346,6 @@ class DevWatcher:
2114
2346
  session=name,
2115
2347
  )
2116
2348
 
2117
- # AFTER the spawn side effects: telemetry, never a gate. The session
2118
- # name carries the round and attempt; the context marks a retry, and
2119
- # names the confirmation when a resume is what unblocked it.
2120
- if dead_session is not None:
2121
- context = (
2122
- "resumed — previous session confirmed dead (absent from the "
2123
- "tmux listing); finishing the round on the same branch"
2124
- )
2125
- elif reenqueued:
2126
- context = "re-enqueued — previous session presumed dead"
2127
- else:
2128
- context = "spawned"
2129
- self._append_activity(
2130
- pr.owner,
2131
- pr.repo,
2132
- pr.number,
2133
- self._activity_line(name, "fix edge", context),
2134
- pr.pr_slug,
2135
- )
2136
-
2137
2349
  resumed = (
2138
2350
  f" — RESUMING the round after {dead_session} was confirmed dead"
2139
2351
  if dead_session is not None
@@ -2205,6 +2417,23 @@ class DevWatcher:
2205
2417
  session=maintain_session_name(pr, attempts),
2206
2418
  )
2207
2419
 
2420
+ # The cross-instance claim check, before the epoch re-basing: whether
2421
+ # this request's budget is fresh or re-based, a peer instance that
2422
+ # already spawned against the SAME standing label must not be spawned
2423
+ # over. Like the fix edge, this edge has no assignment analogue (the
2424
+ # label is the operator's ask, not a per-instance claim, and it stays
2425
+ # put until the maintenance session removes it), so the activity
2426
+ # comment is its only cross-instance evidence.
2427
+ claim = self.external_claim(
2428
+ pr.owner,
2429
+ pr.repo,
2430
+ pr.number,
2431
+ maintain_session_name(pr, attempts + 1),
2432
+ pr.pr_slug,
2433
+ )
2434
+ if claim is not None:
2435
+ return self._claim_deferral(claim, attempts)
2436
+
2208
2437
  # Stale path with history: re-base the budget on the current
2209
2438
  # request. An unreadable boundary (no visible labeled event, or an
2210
2439
  # unparseable timestamp) conservatively leaves the lifetime budget
@@ -2321,6 +2550,27 @@ class DevWatcher:
2321
2550
  session=name,
2322
2551
  )
2323
2552
 
2553
+ # BEFORE the enqueue (issue #53), for the reason _spawn spells out:
2554
+ # the line is this spawn's cross-instance claim, and this edge, like
2555
+ # the fix edge, has no per-instance server-side marker to fall back on
2556
+ # -- the label is the OPERATOR's standing ask and stays put until the
2557
+ # maintenance session removes it, so it says nothing about which
2558
+ # instance already took it. Same crash story, same one-stale-window
2559
+ # cost; the line stays best-effort and dry-run gated. The session name
2560
+ # carries the attempt; the context marks a retry.
2561
+ context = (
2562
+ "re-enqueued — previous session presumed dead"
2563
+ if reenqueued
2564
+ else "spawned"
2565
+ )
2566
+ self._append_activity(
2567
+ pr.owner,
2568
+ pr.repo,
2569
+ pr.number,
2570
+ self._activity_line(name, "maintain edge", context),
2571
+ pr.pr_slug,
2572
+ )
2573
+
2324
2574
  # No self-assignment (authorship is the marker, like the fix edge)
2325
2575
  # and NO label removal: remove-on-completion is the design decision.
2326
2576
  # The label stays as the visible in-flight state; the ledger row
@@ -2341,21 +2591,6 @@ class DevWatcher:
2341
2591
  session=name,
2342
2592
  )
2343
2593
 
2344
- # AFTER the spawn side effects: telemetry, never a gate. The session
2345
- # name carries the attempt; the context marks a retry.
2346
- context = (
2347
- "re-enqueued — previous session presumed dead"
2348
- if reenqueued
2349
- else "spawned"
2350
- )
2351
- self._append_activity(
2352
- pr.owner,
2353
- pr.repo,
2354
- pr.number,
2355
- self._activity_line(name, "maintain edge", context),
2356
- pr.pr_slug,
2357
- )
2358
-
2359
2594
  return Decision(
2360
2595
  Action.SPAWNED,
2361
2596
  f"maintenance session {name} (label {self.config.maintain_label!r} "
@@ -2431,14 +2666,10 @@ class DevWatcher:
2431
2666
  log.info("[dry-run] would append activity line on %s: %s", slug, line)
2432
2667
  return False
2433
2668
  try:
2434
- mine = [
2435
- c
2436
- for c in self.github.issue_comments(owner, repo, number)
2437
- if c.author == self.github.login and ACTIVITY_MARKER in c.body
2438
- ]
2439
- if mine:
2669
+ mine = self.github.marked_comment(owner, repo, number, ACTIVITY_MARKER)
2670
+ if mine is not None:
2440
2671
  self.github.update_comment(
2441
- owner, repo, mine[0].id, mine[0].body + "\n" + line
2672
+ owner, repo, mine.id, mine.body + "\n" + line
2442
2673
  )
2443
2674
  else:
2444
2675
  self.github.comment(
@@ -2449,6 +2680,109 @@ class DevWatcher:
2449
2680
  return False
2450
2681
  return True
2451
2682
 
2683
+ def external_claim(
2684
+ self, owner: str, repo: str, number: int, session: str, slug: str
2685
+ ) -> "ActivityClaim | None":
2686
+ """Has ANOTHER instance already claimed this lane? The newest fresh
2687
+ spawn line on the trigger artifact's activity comment, or None.
2688
+
2689
+ The local ledger is an instance-local file on a volume that follows
2690
+ the active deployment, so during a deploy overlap or a crashloop two
2691
+ daemons are briefly alive sharing one GitHub identity and NOT one
2692
+ ledger: each reads an empty ledger, each believes it holds the only
2693
+ claim, and both spawn (observed three times -- orc #7, #13 and #16 all
2694
+ produced closed-as-duplicate PRs). Nothing local can see that, and
2695
+ GitHub assignment cannot dedupe it either, because the two instances
2696
+ ARE the same login. The activity comment can: it is written
2697
+ mechanically at every spawn, on the trigger artifact itself, so any
2698
+ instance can read any other instance's claim from the one place both
2699
+ of them already write. External truth for claims is what makes spawn
2700
+ dedupe survive a volume handoff, an overlapping deploy, or ledger loss
2701
+ outright.
2702
+
2703
+ FRESH is `stale_minutes` measured from the line's own timestamp -- the
2704
+ same window the ledger's rows are aged by, so a claim expires exactly
2705
+ as a local spawn row does and a dead foreign session cannot park the
2706
+ lane forever. Past it this returns None and the existing stale /
2707
+ liveness matrix (resume path included) governs untouched.
2708
+
2709
+ Liveness is deliberately NOT consulted for the session on the line.
2710
+ The tmux roster is this container's; a peer instance's sessions are
2711
+ not in it and would read DEAD -- taking that as permission to respawn
2712
+ would defeat the entire signal. Freshness is the only test a claim
2713
+ gets.
2714
+
2715
+ Fail-open, always, on this signal alone: no marker comment, an
2716
+ unreadable one, a comment fetch that raises (the broad except mirrors
2717
+ `_append_activity` -- `_api` raises RateLimited, not CommandError) all
2718
+ return None, which is precisely today's behavior. This gate can only
2719
+ ever DEFER a spawn, so a false positive parks a lane for one stale
2720
+ window while a false negative is the double-spawn it exists to
2721
+ prevent -- but a signal that could latch a lane closed on a bad read
2722
+ would be worse than the bug, so it never fails closed.
2723
+
2724
+ NOT dry-run gated: reading is free of side effects, and a dry pass has
2725
+ to reach the same decisions the real one would.
2726
+ """
2727
+ if not self.config.activity_claims_enabled:
2728
+ return None
2729
+ lane = activity_lane(session)
2730
+ try:
2731
+ comment = self.github.marked_comment(
2732
+ owner, repo, number, ACTIVITY_MARKER
2733
+ )
2734
+ except Exception as exc:
2735
+ log.warning(
2736
+ "could not read the activity comment on %s (%s) — no external "
2737
+ "claim evidence this poll",
2738
+ slug,
2739
+ exc,
2740
+ )
2741
+ return None
2742
+ if comment is None:
2743
+ return None
2744
+
2745
+ window = self.config.stale_minutes * 60
2746
+ newest: "ActivityClaim | None" = None
2747
+ for claim in activity_claims(comment.body):
2748
+ if activity_lane(claim.session) != lane:
2749
+ continue
2750
+ if claim.age >= window:
2751
+ continue
2752
+ if self.state.knows_session(claim.session):
2753
+ # This daemon's OWN past spawn, read back off the shared
2754
+ # record. Its ledger row is the authoritative clock for it and
2755
+ # the matrix above has already weighed that row; treating the
2756
+ # line as foreign evidence would let the daemon defer behind
2757
+ # itself -- and, because the line never ages differently from
2758
+ # the row it was written beside, it could only ever say what
2759
+ # the ledger just said. EXTERNAL means "a spawn this ledger has
2760
+ # no memory of", which is exactly the ledger-loss, volume-
2761
+ # handoff and overlapping-deploy cases this exists for.
2762
+ continue
2763
+ if newest is None or claim.at > newest.at:
2764
+ newest = claim
2765
+ return newest
2766
+
2767
+ def _claim_deferral(
2768
+ self, claim: "ActivityClaim", attempts: int, round_: "int | None" = None
2769
+ ) -> Decision:
2770
+ """The IN_FLIGHT decision an external claim produces. IN_FLIGHT, not
2771
+ SKIPPED or DEFERRED: the work IS in flight, just under another
2772
+ instance's ledger, and like every other in-flight return this one
2773
+ claims nothing locally -- no assignment, no ledger row -- so the item
2774
+ stays a clean candidate the moment the claim ages out."""
2775
+ return Decision(
2776
+ Action.IN_FLIGHT,
2777
+ f"another daemon instance recorded a spawn on this lane "
2778
+ f"{int(claim.age / 60)} min ago (`{claim.session}`, on the "
2779
+ f"mechanical activity comment) and the local ledger has no fresh "
2780
+ f"row — external claim evidence; not double-spawning",
2781
+ attempts,
2782
+ session=claim.session,
2783
+ round=round_,
2784
+ )
2785
+
2452
2786
  def _log_deferral(
2453
2787
  self,
2454
2788
  owner: str,
@@ -236,6 +236,31 @@ class State:
236
236
  ).fetchone()
237
237
  return None if row is None else row["session"]
238
238
 
239
+ def knows_session(self, session: str) -> bool:
240
+ """Has THIS ledger ever recorded a spawn under this session name?
241
+
242
+ The question the cross-instance claim check asks of every spawn line it
243
+ reads on an activity comment (loop.DevWatcher.external_claim). The
244
+ comment is a shared record: a spawn line is only EXTERNAL evidence when
245
+ this instance has no memory of writing it -- otherwise it is this
246
+ daemon's own past spawn, whose ledger row already carries the
247
+ authoritative clock, and reading it back as a foreign claim would let
248
+ the daemon defer behind itself.
249
+
250
+ Deliberately NOT edge- or artifact-scoped: session names are globally
251
+ unique by construction (each edge's builder folds in its own prefix,
252
+ the owner/repo, the number, and the attempt), so one lookup across the
253
+ three spawn ledgers answers it for every edge without a caller having
254
+ to say which one it is asking about.
255
+ """
256
+ row = self._db.execute(
257
+ "SELECT 1 AS hit FROM spawns WHERE session=? "
258
+ "UNION ALL SELECT 1 FROM fix_spawns WHERE session=? "
259
+ "UNION ALL SELECT 1 FROM maintain_spawns WHERE session=? LIMIT 1",
260
+ (session, session, session),
261
+ ).fetchone()
262
+ return row is not None
263
+
239
264
  def record_fix_spawn(
240
265
  self, *, repo_slug: str, number: int, round_: int, attempt: int, session: str
241
266
  ) -> None:
@@ -324,6 +324,7 @@ class Sources:
324
324
  "fix_rounds_enabled": c.fix_rounds_enabled,
325
325
  "maintain_enabled": c.maintain_enabled,
326
326
  "resume_enabled": c.resume_enabled,
327
+ "activity_claims_enabled": c.activity_claims_enabled,
327
328
  "reviewers": list(c.reviewers),
328
329
  "agent_profile": c.agent_profile,
329
330
  "state_db": str(c.state_db),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera