alissa-tools-github-devloop 0.8.13__tar.gz → 0.8.15__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 (35) hide show
  1. {alissa_tools_github_devloop-0.8.13/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.15}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/__main__.py +28 -0
  3. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/alissa_client.py +45 -9
  4. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/config.py +50 -0
  5. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/loop.py +138 -0
  6. alissa_tools_github_devloop-0.8.15/src/main/alissa/tools/github/devloop/loop_events.py +532 -0
  7. alissa_tools_github_devloop-0.8.15/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/page.py +40 -17
  9. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/sources.py +114 -7
  10. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  11. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  12. alissa_tools_github_devloop-0.8.13/src/main/alissa/tools/github/devloop/version +0 -1
  13. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/LICENSE +0 -0
  14. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/MANIFEST.in +0 -0
  15. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/NOTICE +0 -0
  16. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/README.md +0 -0
  17. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/requirements.txt +0 -0
  18. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/setup.cfg +0 -0
  19. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/setup.py +0 -0
  20. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  21. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  22. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  23. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  24. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  25. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  26. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/state.py +0 -0
  27. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/version.py +0 -0
  28. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  31. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  32. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  33. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  34. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  35. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/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.13
3
+ Version: 0.8.15
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -282,6 +282,26 @@ def build_parser() -> argparse.ArgumentParser:
282
282
  "(two overlapping instances can then both spawn on one item)",
283
283
  )
284
284
 
285
+ loop_events = over.add_mutually_exclusive_group()
286
+ loop_events.add_argument(
287
+ "--loop-events",
288
+ dest="loop_events_enabled",
289
+ action="store_true",
290
+ default=None,
291
+ help="push one idempotent batch of loop events (spawns, defers, "
292
+ "cap-outs, stalls, hand-offs, reaps, escalations) per poll pass to "
293
+ "Studio's POST /v1/loop-events on the alissa endpoint — best-effort, "
294
+ "never fatal. ALISSA_DEV_LOOP_EVENTS_ENABLED (non-blank) overrides "
295
+ "even this flag",
296
+ )
297
+ loop_events.add_argument(
298
+ "--no-loop-events",
299
+ dest="loop_events_enabled",
300
+ action="store_false",
301
+ help="never push loop telemetry, even if the config enables it "
302
+ "(the default: the push is opt-in)",
303
+ )
304
+
285
305
  janitor = over.add_mutually_exclusive_group()
286
306
  janitor.add_argument(
287
307
  "--janitor",
@@ -386,6 +406,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
386
406
  "orphan_sweep_enabled": args.orphan_sweep_enabled,
387
407
  "orphan_grace_minutes": args.orphan_grace_minutes,
388
408
  "activity_claims_enabled": args.activity_claims_enabled,
409
+ "loop_events_enabled": args.loop_events_enabled,
389
410
  "janitor_enabled": args.janitor_enabled,
390
411
  "janitor_grace_minutes": args.janitor_grace_minutes,
391
412
  "janitor_interval_minutes": args.janitor_interval_minutes,
@@ -500,6 +521,13 @@ def log_effective_config(config: Config, login: str) -> None:
500
521
  config.cache_cap_mb,
501
522
  config.disk_warn_percent,
502
523
  )
524
+ log.info(
525
+ "loop_events=%s%s",
526
+ config.loop_events_enabled,
527
+ f" (one batch per pass → POST {config.alissa_endpoint}"
528
+ f"/v1/loop-events, best-effort)"
529
+ if config.loop_events_enabled else "",
530
+ )
503
531
  log.debug("hub_template: %s", config.hub_template)
504
532
  log.debug(
505
533
  "cache_paths: %s",
@@ -22,7 +22,12 @@ already authenticates — the same transport, endpoints and taxonomy orcloop's
22
22
  `urllib` only: the distribution ships no third-party runtime dependency, and
23
23
  this must not be the change that adds one.
24
24
 
25
- Endpoints, both verified against the live API:
25
+ Since issue #101 the adapter also carries the daemon's ONE write —
26
+ ``POST /v1/loop-events`` (`post_loop_events`), the loop-telemetry push — over
27
+ the same transport, token and taxonomy, so the daemon still reads and writes
28
+ Alissa through a single REST idiom.
29
+
30
+ Endpoints, the reads both verified against the live API:
26
31
 
27
32
  ``GET /v1/ping``
28
33
  ``{"pong", "timestamp", "userId", "actorId", "displayName"}`` — the
@@ -141,22 +146,32 @@ class AlissaClient:
141
146
  self._token = token if token is not None else os.environ.get(ENV_TOKEN)
142
147
  self._timeout = timeout
143
148
 
144
- def _request(self, path: str) -> object:
145
- """One GET. Every failure leaves as a taxonomy exception — the caller
146
- never sees a raw urllib error or an HTTP status."""
149
+ def _request(
150
+ self, path: str, *, method: str = "GET", body: object = None
151
+ ) -> object:
152
+ """One call. Every failure leaves as a taxonomy exception — the caller
153
+ never sees a raw urllib error or an HTTP status. `body`, when given,
154
+ is JSON-serialised and sent with the matching Content-Type (the shape
155
+ every write on this API takes)."""
147
156
  if not self._token:
148
157
  # No token at all is an auth condition, not a transport one: the
149
158
  # operator must set the env var. Fail the way a 401 would, so the
150
159
  # boot path treats it as permanent rather than retrying forever.
151
160
  raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
152
161
 
162
+ headers = {
163
+ "Accept": "application/json",
164
+ "Authorization": f"Bearer {self._token}",
165
+ }
166
+ data = None
167
+ if body is not None:
168
+ data = json.dumps(body).encode("utf-8")
169
+ headers["Content-Type"] = "application/json"
153
170
  req = urllib.request.Request(
154
171
  f"{self.base}{path}",
155
- method="GET",
156
- headers={
157
- "Accept": "application/json",
158
- "Authorization": f"Bearer {self._token}",
159
- },
172
+ method=method,
173
+ data=data,
174
+ headers=headers,
160
175
  )
161
176
  try:
162
177
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
@@ -226,6 +241,27 @@ class AlissaClient:
226
241
  display_name=str(row.get("displayName") or ""),
227
242
  )
228
243
 
244
+ def post_loop_events(self, events: "list[dict]") -> "dict":
245
+ """Push one batch of loop-telemetry events (issue #101).
246
+
247
+ ``POST /v1/loop-events`` (fahera-mx/studio.alissa.app, `API.md` →
248
+ "Loop telemetry — ingest"): 1–200 events per call, idempotent on the
249
+ caller's `dedupeKey` — a repeated key is a silent duplicate, never an
250
+ error — and any shape error fails the WHOLE batch with a 400 naming
251
+ the offending index. The batch bound and the event shapes are the
252
+ CALLER's contract (`loop_events.py` derives and splits); this method
253
+ is just the transport, returning the server's
254
+ ``{"accepted": n, "duplicates": m}`` verbatim.
255
+
256
+ Failures leave as the taxonomy like every other call here. The one
257
+ caller is best-effort by design — it catches AlissaError, warns once
258
+ and moves on — so nothing extra is softened at this layer.
259
+ """
260
+ payload = self._request(
261
+ "/v1/loop-events", method="POST", body={"events": list(events)}
262
+ )
263
+ return payload if isinstance(payload, dict) else {}
264
+
229
265
  def list_bodies_of_work(self) -> "list[BodyOfWork]":
230
266
  """Every Body of Work this actor owns OR collaborates on — ONE call.
231
267
 
@@ -21,6 +21,8 @@ var > `--repos-source` > the config key. A BLANK env var means "not set" here
21
21
  (unlike ALISSA_DEV_REVIEWERS, where empty is a meaningful answer): an unset
22
22
  platform variable reference renders as the empty string, and that must fall
23
23
  through to the config rather than fail the daemon's boot on a mode of `''`.
24
+ `loop_events_enabled` rides the same shape of rail through
25
+ `ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule.
24
26
  """
25
27
 
26
28
  from __future__ import annotations
@@ -88,6 +90,18 @@ ENV_REPOS_SOURCE = "ALISSA_DEV_REPOS_SOURCE"
88
90
  # `alissa_endpoint` so one deployment can point both daemons at one API.
89
91
  DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
90
92
 
93
+ # The loop-telemetry rail (issue #101): `loop_events_enabled` ridden the way
94
+ # ALISSA_DEV_REPOS_SOURCE rides repos_source — a NON-BLANK value here wins
95
+ # over the CLI and the file alike, and a blank one falls through (an unset
96
+ # platform variable reference renders as "", which must not decide anything).
97
+ # Accepted spellings are 1/true/0/false, case-insensitive; anything else is a
98
+ # config error at boot — a typo silently read as false would leave an operator
99
+ # staring at a variable that does nothing.
100
+ ENV_LOOP_EVENTS = "ALISSA_DEV_LOOP_EVENTS_ENABLED"
101
+
102
+ _ENV_BOOL_TRUE = frozenset(("1", "true"))
103
+ _ENV_BOOL_FALSE = frozenset(("0", "false"))
104
+
91
105
  # What an Alissa actor id looks like: a 32-character opaque handle of
92
106
  # lower-case letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id
93
107
  # the API mints has this shape, and `bow_owners` is checked against it so a
@@ -138,6 +152,7 @@ CONFIG_KEYS = (
138
152
  "orphan_sweep_enabled",
139
153
  "orphan_grace_minutes",
140
154
  "activity_claims_enabled",
155
+ "loop_events_enabled",
141
156
  "janitor_enabled",
142
157
  "janitor_grace_minutes",
143
158
  "janitor_interval_minutes",
@@ -499,6 +514,18 @@ class Config:
499
514
  # that would rather not pay the extra comments fetch.
500
515
  activity_claims_enabled: bool = True
501
516
 
517
+ # The loop-telemetry push (issue #101): at the end of each poll pass,
518
+ # POST one idempotent batch of the ledger-derived loop events — spawns,
519
+ # defers, cap-outs, stalls, hand-offs, reaps, escalations — to Studio's
520
+ # /v1/loop-events on `alissa_endpoint`, over the same REST client and
521
+ # token `bows` mode reads with. Best-effort and never fatal: a failed
522
+ # push warns once and the pass completes, and deterministic dedupe keys
523
+ # make re-emission harmless. Default OFF — the push writes to an external
524
+ # service on every pass, so it is the operator's to opt into; disabled,
525
+ # no request is ever made. ALISSA_DEV_LOOP_EVENTS_ENABLED (non-blank)
526
+ # wins over the CLI and the file, repos_source's rail exactly.
527
+ loop_events_enabled: bool = False
528
+
502
529
  # The workspace janitor (issue #74): the filesystem sibling of the session
503
530
  # reaper. Every worker session materializes a per-task workspace under its
504
531
  # repo's hub (a `TASK-<n>-<DESC>` worktree plus whatever its lane installs
@@ -759,6 +786,28 @@ class Config:
759
786
  + (f" (from {ENV_REPOS_SOURCE})" if env_source else "")
760
787
  )
761
788
 
789
+ # The loop-telemetry rail, repos_source's shape exactly: a NON-BLANK
790
+ # ALISSA_DEV_LOOP_EVENTS_ENABLED wins over the CLI and the file, and
791
+ # blank falls through. Validated by NAME like the enums — a typo read
792
+ # as false would silently disable a push the operator asked for.
793
+ loop_events = bool(
794
+ raw.get("loop_events_enabled", cls.loop_events_enabled)
795
+ )
796
+ env_loop_events = (os.environ if environ is None else environ).get(
797
+ ENV_LOOP_EVENTS, ""
798
+ ).strip()
799
+ if env_loop_events:
800
+ lowered = env_loop_events.casefold()
801
+ if lowered in _ENV_BOOL_TRUE:
802
+ loop_events = True
803
+ elif lowered in _ENV_BOOL_FALSE:
804
+ loop_events = False
805
+ else:
806
+ raise ValueError(
807
+ f"{ENV_LOOP_EVENTS} must be one of 1/true/0/false "
808
+ f"(case-insensitive), got {env_loop_events!r}"
809
+ )
810
+
762
811
  bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
763
812
 
764
813
  refresh_polls = int(
@@ -961,6 +1010,7 @@ class Config:
961
1010
  activity_claims_enabled=bool(
962
1011
  raw.get("activity_claims_enabled", cls.activity_claims_enabled)
963
1012
  ),
1013
+ loop_events_enabled=loop_events,
964
1014
  janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
965
1015
  janitor_grace_minutes=janitor_grace,
966
1016
  janitor_interval_minutes=janitor_interval,
@@ -405,7 +405,9 @@ from pathlib import Path
405
405
  from typing import Callable
406
406
 
407
407
  from . import janitor
408
+ from . import loop_events as loop_events_mod
408
409
  from .alissa import Alissa
410
+ from .alissa_client import AlissaClient
409
411
  from .bows import BowRepoSource
410
412
  from .config import (
411
413
  FOREIGN_IGNORE,
@@ -1916,6 +1918,18 @@ class Decision:
1916
1918
  session: str | None = None
1917
1919
  task_ref: str | None = None
1918
1920
  round: int | None = None
1921
+ # True on a SPAWNED decision whose directive was a RESUME (issue or fix
1922
+ # edge). Observational like the three above -- the loop-events emitter
1923
+ # reads it to stamp `data.resume` on the spawn's telemetry event; nothing
1924
+ # in the decision logic does.
1925
+ resume: bool = False
1926
+ # The coarse defer-reason token (loop_events.DEFER_*) on a decision that
1927
+ # is a genuine DEFERRAL -- the external-claim hold and the stale-but-
1928
+ # possibly-alive holds that ride Action.IN_FLIGHT. None on ordinary
1929
+ # progress (a fresh attempt in flight is the loop working, not a defer)
1930
+ # and on the capacity valve, whose Action.DEFERRED already says it.
1931
+ # Observational like `resume`: only the loop-events emitter reads it.
1932
+ defer_token: str | None = None
1919
1933
 
1920
1934
 
1921
1935
  def _oldest_first(found: list) -> list:
@@ -2410,6 +2424,7 @@ class DevWatcher:
2410
2424
  state: State | None = None,
2411
2425
  envelope_verdict: "Callable[[str, str, int], str | None] | None" = None,
2412
2426
  repo_source: "BowRepoSource | None" = None,
2427
+ loop_events: "loop_events_mod.LoopEventEmitter | None" = None,
2413
2428
  ):
2414
2429
  self.config = config
2415
2430
  self.github = github or GitHub(config.developer_login)
@@ -2428,6 +2443,18 @@ class DevWatcher:
2428
2443
  self.repo_source = repo_source or (
2429
2444
  BowRepoSource(config) if config.repos_source == REPOS_BOWS else None
2430
2445
  )
2446
+ # The loop-telemetry push (issue #101), or None when
2447
+ # loop_events_enabled is off -- in which case no client is built and
2448
+ # no request is ever made. Rides the same REST endpoint and token the
2449
+ # bows feed reads (`alissa_endpoint`), and is injectable so a test
2450
+ # never opens a socket.
2451
+ self.loop_events = loop_events or (
2452
+ loop_events_mod.LoopEventEmitter(
2453
+ self.state, AlissaClient(base=config.alissa_endpoint)
2454
+ )
2455
+ if config.loop_events_enabled
2456
+ else None
2457
+ )
2431
2458
  # The reaper's name-slug -> (owner, repo) index (see `_anchor_repo`),
2432
2459
  # rebuilt only when the allowlist itself changes rather than per
2433
2460
  # session per sweep.
@@ -2615,6 +2642,7 @@ class DevWatcher:
2615
2642
  f"not respawning",
2616
2643
  attempts,
2617
2644
  session=session_name(issue, attempts),
2645
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
2618
2646
  )
2619
2647
  else:
2620
2648
  # No linked open PR: the IMPLEMENT phase, before the session
@@ -2681,6 +2709,7 @@ class DevWatcher:
2681
2709
  f"still be running",
2682
2710
  attempts,
2683
2711
  session=live_session,
2712
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
2684
2713
  )
2685
2714
 
2686
2715
  # Never spawn attempt cap+1. Escalate ONCE; `escalated()` dedupes, and
@@ -2804,6 +2833,7 @@ class DevWatcher:
2804
2833
  f"PR {resume_pr.url}",
2805
2834
  attempt - 1,
2806
2835
  session=dead_session,
2836
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
2807
2837
  )
2808
2838
 
2809
2839
  hub, problem = self._ensure_hub(issue.owner, issue.repo)
@@ -2948,6 +2978,7 @@ class DevWatcher:
2948
2978
  attempt,
2949
2979
  session=name,
2950
2980
  task_ref=task_ref,
2981
+ resume=resume_pr is not None,
2951
2982
  )
2952
2983
 
2953
2984
  def _resumable_pr(
@@ -3552,6 +3583,7 @@ class DevWatcher:
3552
3583
  attempts,
3553
3584
  session=fix_session_name(pr, round_, attempts),
3554
3585
  round=round_,
3586
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
3555
3587
  )
3556
3588
 
3557
3589
  # The per-round budget ran dry: stop and page the operator ON the
@@ -5024,6 +5056,7 @@ class DevWatcher:
5024
5056
  attempt - 1,
5025
5057
  session=dead_session,
5026
5058
  round=round_,
5059
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
5027
5060
  )
5028
5061
 
5029
5062
  hub, problem = self._ensure_hub(pr.owner, pr.repo)
@@ -5110,6 +5143,7 @@ class DevWatcher:
5110
5143
  attempt,
5111
5144
  session=name,
5112
5145
  round=round_,
5146
+ resume=dead_session is not None,
5113
5147
  )
5114
5148
 
5115
5149
  # -- the maintenance edge (operator-labeled PRs) ------------------------
@@ -5533,6 +5567,7 @@ class DevWatcher:
5533
5567
  attempts,
5534
5568
  session=claim.session,
5535
5569
  round=round_,
5570
+ defer_token=loop_events_mod.DEFER_CLAIM,
5536
5571
  )
5537
5572
 
5538
5573
  def _log_deferral(
@@ -7292,8 +7327,111 @@ class DevWatcher:
7292
7327
  counts,
7293
7328
  duration_ms=int((time.monotonic() - started) * 1000),
7294
7329
  )
7330
+ self._emit_loop_events(
7331
+ issue_results, pr_results, maintain_results, sweep
7332
+ )
7295
7333
  return results
7296
7334
 
7335
+ def _emit_loop_events(
7336
+ self,
7337
+ issue_results: "list[tuple[str, Decision]]",
7338
+ pr_results: "list[tuple[str, Decision]]",
7339
+ maintain_results: "list[tuple[str, Decision]]",
7340
+ sweep: Sweep,
7341
+ ) -> None:
7342
+ """The pass's loop-telemetry push (issue #101): hand the emitter the
7343
+ pass observations the ledgers do not carry -- which spawns were
7344
+ RESUMEs, which candidates deferred and why, which sessions the reaper
7345
+ killed -- then let it derive the ledger events and POST one batch.
7346
+ A no-op when the feature is off, and under dry_run: the push writes
7347
+ to an external service, and a dry pass takes no side effects (the
7348
+ ledgers it would derive from are not written under dry_run either,
7349
+ so a dry pass's batch would misreport the pass as empty anyway).
7350
+ Best-effort end to end -- emit_pass never raises."""
7351
+ if self.loop_events is None or self.config.dry_run:
7352
+ return
7353
+ deferrals: "list[dict]" = []
7354
+ for edge, results in (
7355
+ ("issue", issue_results),
7356
+ ("fix", pr_results),
7357
+ ("maintain", maintain_results),
7358
+ ):
7359
+ for slug, decision in results:
7360
+ repo, _, tail = slug.partition("#")
7361
+ number = int(tail[2:] if tail.startswith("pr") else tail)
7362
+ token = self._defer_token(decision)
7363
+ if token is not None:
7364
+ deferrals.append({
7365
+ "repo": repo,
7366
+ "edge": edge,
7367
+ "number": number,
7368
+ "token": token,
7369
+ "reason": decision.reason,
7370
+ })
7371
+ if decision.action is Action.SPAWNED and decision.resume:
7372
+ if edge == "issue":
7373
+ key = loop_events_mod.issue_spawn_key(
7374
+ repo, number, decision.attempt or 0
7375
+ )
7376
+ else:
7377
+ key = loop_events_mod.fix_spawn_key(
7378
+ repo, number, decision.round or 0,
7379
+ decision.attempt or 0,
7380
+ )
7381
+ self.loop_events.note_resume(key)
7382
+ self.loop_events.emit_pass(
7383
+ deferrals=deferrals,
7384
+ reaps=self._reap_records(sweep.names),
7385
+ now_ms=int(time.time() * 1000),
7386
+ )
7387
+
7388
+ @staticmethod
7389
+ def _defer_token(decision: Decision) -> "str | None":
7390
+ """The coarse defer-reason token of one decision, or None when the
7391
+ decision is not a deferral. DEFERRED is the capacity valve by
7392
+ construction; the deferral flavours of IN_FLIGHT (the external-claim
7393
+ hold, the stale-but-possibly-alive holds) carry their token on the
7394
+ decision itself (`Decision.defer_token`, stamped where each deferral
7395
+ is decided) -- a FRESH in-flight attempt carries none, because normal
7396
+ progress is not a defer. Everything else -- spawns, skips, caps,
7397
+ escalations -- emits nothing here."""
7398
+ if decision.action is Action.DEFERRED:
7399
+ return loop_events_mod.DEFER_CAPACITY
7400
+ return decision.defer_token
7401
+
7402
+ def _reap_records(self, names: "set[str]") -> "list[dict]":
7403
+ """The sweep's kills as the emitter's reap records: session name, the
7404
+ anchor evidence the kill line carried, and -- when the name resolves
7405
+ on this ledger -- the repo and number. A name outside devloop's own
7406
+ grammar is a foreign kill: no anchor exists, so the record says so
7407
+ and carries neither repo nor number (both optional on the ingest
7408
+ contract). Sorted for a deterministic batch order."""
7409
+ records: "list[dict]" = []
7410
+ for name in sorted(names):
7411
+ worker = parse_worker_session(name)
7412
+ if worker is None:
7413
+ records.append({"session": name, "anchor": "foreign session"})
7414
+ continue
7415
+ record: "dict" = {
7416
+ "session": name,
7417
+ "edge": "issue" if worker.is_issue else "pr",
7418
+ "number": worker.number,
7419
+ "anchor": worker.anchor,
7420
+ }
7421
+ if worker.is_issue:
7422
+ row = self.state.spawn_for_session(name)
7423
+ elif worker.edge == EDGE_FIX:
7424
+ row = self.state.fix_spawn_for_session(name)
7425
+ else:
7426
+ row = self.state.maintain_spawn_for_session(name)
7427
+ if row is not None:
7428
+ # The ledger's slug, not the name's: the name slug is lossy
7429
+ # (`a/b-c` and `a-b/c` both squash to `a-b-c`), so only a
7430
+ # ledger row can say which repo the kill belonged to.
7431
+ record["repo"] = row["repo_slug"]
7432
+ records.append(record)
7433
+ return records
7434
+
7297
7435
  def _stage_record(
7298
7436
  self, edge: str, slug: str, decision: Decision
7299
7437
  ) -> "dict":