alissa-tools-github-devloop 0.8.13__tar.gz → 0.8.14__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.14}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/__main__.py +28 -0
  3. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/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.14}/src/main/alissa/tools/github/devloop/config.py +50 -0
  5. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/loop.py +138 -0
  6. alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/loop_events.py +532 -0
  7. alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  9. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  10. alissa_tools_github_devloop-0.8.13/src/main/alissa/tools/github/devloop/version +0 -1
  11. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/LICENSE +0 -0
  12. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/MANIFEST.in +0 -0
  13. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/NOTICE +0 -0
  14. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/README.md +0 -0
  15. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/requirements.txt +0 -0
  16. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/setup.cfg +0 -0
  17. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/setup.py +0 -0
  18. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  19. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  20. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  21. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  22. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  23. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  24. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/state.py +0 -0
  25. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/version.py +0 -0
  26. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  27. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  28. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  29. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  30. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  31. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  32. {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/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.14}/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.14}/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.14}/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.14
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":
@@ -0,0 +1,532 @@
1
+ """Loop-event telemetry — the Studio push (issue #101).
2
+
3
+ Devloop's history lives in the local `.devloop/state.db` ledgers, on a volume
4
+ that follows the active deployment; the Factory console needs a DURABLE record
5
+ of what the developer seat did — attempts per issue, fix rounds per PR,
6
+ cap-outs, stalls, hand-offs. Studio ingests loop telemetry over
7
+ ``POST /v1/loop-events`` (fahera-mx/studio.alissa.app, `API.md` → "Loop
8
+ telemetry — ingest"), idempotent on the caller's `dedupeKey`. This module is
9
+ the writer: it derives events FROM THE LEDGER ROWS the daemon already keeps,
10
+ so every event's identity is the ledger primary key and its clock is the
11
+ ledger timestamp — the same rows, re-read, always derive the same events.
12
+
13
+ Design rules, all downstream of that idempotency:
14
+
15
+ * **Deterministic keys, no retry queue.** Every event's `dedupeKey` is
16
+ derived from its ledger primary key (or, for the pass-observed events, from
17
+ the observation's own identity), so re-emission is harmless: the server
18
+ answers a repeated key with a silent duplicate. A failed POST therefore
19
+ needs no queue — the same events re-derive next pass and are re-sent.
20
+ * **One batch per poll pass, ≤ MAX_BATCH per call, ≤ MAX_BATCHES_PER_PASS
21
+ calls per pass.** The emitter tracks what it already sent (in memory only —
22
+ a restart re-emits everything once, and the server-side dedupe absorbs it)
23
+ and posts only the new events, split into calls of at most MAX_BATCH; a
24
+ pass POSTs at most MAX_BATCHES_PER_PASS of them and loudly leaves the rest
25
+ to the next pass, so a restart backfill has a bounded wall-clock cost.
26
+ * **Best-effort, never fatal.** A failure logs ONE warning naming the count
27
+ and status and the pass completes. The daemon's job is spawning sessions;
28
+ telemetry must never cost a poll pass.
29
+
30
+ Event vocabulary (the `kind` field; Studio validates the SHAPE
31
+ ``^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$``, not membership):
32
+
33
+ * ``spawn.issue`` / ``spawn.fix`` / ``spawn.maintain`` — one per row of the
34
+ `spawns` / `fix_spawns` / `maintain_spawns` ledgers. A spawn issued with a
35
+ RESUME directive carries ``data.resume: true`` on its spawn event rather
36
+ than a separate ``resume`` kind (the issue offers either; the data flag
37
+ keeps one event per ledger row, so the key derivation stays a pure function
38
+ of the row). The flag is pass-observed and in-memory: after a daemon
39
+ restart the re-emitted spawn event omits it, and the server keeps the
40
+ original (first write wins on a repeated key).
41
+ * ``defer`` — a candidate deferred this pass, keyed per coarse REASON token
42
+ (`capacity`, `activity-claim`, `in-flight`) so a continuing defer is one
43
+ event per reason, not one per pass.
44
+ * ``handoff.ready`` — the orphan sweep readied a draft (`orphan-ready@…`,
45
+ ``data.action: "ready"``), completed a draft's handoff (`orphan@…`,
46
+ ``data.action: "requested"``), or the wedge self-heal re-requested review
47
+ (`rerequest:r<k>@…`, ``data.action: "rerequest"``) — all from the
48
+ `escalations` table, where those acts record their once-per-key rows.
49
+ * ``cap.issue`` / ``cap.fix`` / ``cap.maintain`` — the cap-out escalations
50
+ (`cap`, `fix-cap:r<k>`, `maintain-cap[...]`). The ledger stores no page
51
+ text, so `reason` is derived from the kind, not quoted from the comment.
52
+ * ``stalled`` — the three stall pings (`stalled:a<j>`, `fix-stalled:r<k>a<j>`,
53
+ `live-stalled:a<j>`), with the ledger family under ``data.kind``.
54
+ * ``reap`` — a session the reaper killed this pass (`session`,
55
+ ``data.anchor``). Pass-observed: the reaper writes no ledger row, so the
56
+ event is keyed on the session name — deterministic because worker names
57
+ are deterministic per (repo, number, attempt) and a session dies once.
58
+ * ``escalation.<kind>`` — every other escalation row, under its coarse kind
59
+ token with ``-`` mapped to ``_`` (`escalation.rerequest_rejected`,
60
+ `escalation.stacked_base`, `escalation.foreign_session`, …): Studio's kind
61
+ shape allows no hyphens, so the raw ledger kind rides in ``data.ledgerKind``
62
+ and `reason` instead.
63
+
64
+ No ``poll`` vitals events: live vitals stay a console pull (`/api/state`).
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import logging
70
+ import re
71
+ from typing import Iterable, Mapping, Sequence
72
+
73
+ from .alissa_client import AlissaClient, AlissaError
74
+
75
+ log = logging.getLogger(__name__)
76
+
77
+ SEAT = "devloop"
78
+
79
+ # The ingest contract's batch cap: 1–200 events per call. Larger passes split.
80
+ MAX_BATCH = 200
81
+
82
+ # How many calls one PASS may make (PR #102 review round 1, `[major]`). The
83
+ # push runs in-band in `poll_once` and the loop is single-threaded, so an
84
+ # unbounded sequence of sequential POSTs — the first pass after a restart
85
+ # re-derives the volume's whole history — would delay the reaper sweep and
86
+ # all three spawning edges by O(history / 200) network round-trips. Five
87
+ # calls bounds a pass at a known wall-clock (≤ 5 × the client timeout, worst
88
+ # case) while a restart backfill of thousands of events still drains in a
89
+ # handful of passes: nothing marked emitted is lost, the tail simply
90
+ # re-derives next pass, and the capped pass SAYS how much it left (no silent
91
+ # caps). The fuller answer — a persisted derivation watermark so a pass never
92
+ # scans rows it already shipped — is registered as a follow-up task.
93
+ MAX_BATCHES_PER_PASS = 5
94
+
95
+ # The ingest contract's `reason` cap. Over it is a 400 that fails the WHOLE
96
+ # batch ("never a trim", server-side) — so the trim happens here.
97
+ MAX_REASON = 2000
98
+
99
+ # The kind grammar Studio validates (shape, not membership). Everything this
100
+ # module emits must match it — one hyphen in a kind 400s a whole batch — so
101
+ # derivation normalises through _kind_token and this regex backstops it.
102
+ KIND_SHAPE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
103
+
104
+ # The coarse defer-reason tokens (folded into the defer key so a continuing
105
+ # defer is one event per reason, not one per pass).
106
+ DEFER_CAPACITY = "capacity" # max_sessions backpressure (Action.DEFERRED)
107
+ DEFER_CLAIM = "activity-claim" # another instance's fresh spawn line
108
+ DEFER_IN_FLIGHT = "in-flight" # own work already on the lane
109
+
110
+ # The escalation-ledger kind grammars this module knows how to enrich. An
111
+ # unmatched kind still emits — as a generic `escalation.<token>` — so a kind
112
+ # added to the loop later reaches Studio without touching this table.
113
+ _STALLED_RE = re.compile(r"^stalled:a(\d+)$")
114
+ _LIVE_STALLED_RE = re.compile(r"^live-stalled:a(\d+)$")
115
+ _FIX_STALLED_RE = re.compile(r"^fix-stalled:r(\d+)a(\d+)$")
116
+ _FIX_CAP_RE = re.compile(r"^fix-cap:r(\d+)$")
117
+ _MAINTAIN_CAP_RE = re.compile(r"^maintain-cap(:e\d+)?$")
118
+ _REREQUEST_RE = re.compile(r"^rerequest:r(\d+)@(.+)$")
119
+ _ORPHAN_READY_RE = re.compile(r"^orphan-ready@(.+)$")
120
+ _ORPHAN_DONE_RE = re.compile(r"^orphan@(.+)$")
121
+ _FOREIGN_RE = re.compile(r"^foreign-session:(.+)$")
122
+
123
+ # Escalation kinds anchored on a repo ISSUE; everything else that names a
124
+ # number names a PULL REQUEST (the fix/maintain/orphan/rerequest families all
125
+ # key the PR number into the ledger's `issue` column).
126
+ _ISSUE_ANCHORED = ("cap", "assignment", "stalled", "live-stalled")
127
+
128
+
129
+ def _ms(ts: "int | float | str") -> int:
130
+ """A ledger timestamp (unix seconds) as the contract's epoch ms."""
131
+ return int(ts) * 1000
132
+
133
+
134
+ def _kind_token(raw_kind: str) -> str:
135
+ """The coarse kind token of one escalation row, shaped for the contract.
136
+
137
+ The coarse token is the ledger kind up to its first `:` or `@` (the
138
+ folded-in episode key — round, attempt, head, session — is identity, not
139
+ vocabulary). Hyphens map to underscores because the contract's kind shape
140
+ allows none; anything else non-conforming is dropped, and a token that
141
+ still fails the shape falls back to `unknown` rather than 400ing a batch.
142
+ """
143
+ coarse = re.split(r"[:@]", raw_kind, maxsplit=1)[0]
144
+ token = re.sub(r"[^a-z0-9_]", "", coarse.lower().replace("-", "_"))
145
+ if not token or not token[0].isalpha():
146
+ return "unknown"
147
+ return token
148
+
149
+
150
+ def _event(
151
+ kind: str,
152
+ at_ms: int,
153
+ dedupe_key: str,
154
+ *,
155
+ repo: "str | None" = None,
156
+ issue_number: "int | None" = None,
157
+ pr_number: "int | None" = None,
158
+ round_: "int | None" = None,
159
+ attempt: "int | None" = None,
160
+ session: "str | None" = None,
161
+ reason: "str | None" = None,
162
+ data: "Mapping[str, object] | None" = None,
163
+ ) -> "dict[str, object]":
164
+ """One ingest-shaped event, optional fields omitted rather than nulled.
165
+
166
+ The kind is checked against the contract's shape here — one bad kind
167
+ fails a WHOLE batch server-side, so a derivation bug must die at build
168
+ time, not silently cost every event it was batched with. A raise, not an
169
+ `assert`: `python -O` strips asserts, and a guard that exists to protect
170
+ a whole batch must not depend on nobody ever setting PYTHONOPTIMIZE
171
+ (PR #102 review round 1). The emitter's derivation guard catches it, so
172
+ even then the cost is one warned pass, never a crashed one.
173
+ """
174
+ if not KIND_SHAPE.match(kind):
175
+ raise ValueError(f"kind {kind!r} violates the ingest shape")
176
+ event: "dict[str, object]" = {
177
+ "seat": SEAT,
178
+ "kind": kind,
179
+ "at": at_ms,
180
+ "dedupeKey": dedupe_key,
181
+ }
182
+ if repo:
183
+ event["repo"] = repo
184
+ if issue_number is not None:
185
+ event["issueNumber"] = int(issue_number)
186
+ if pr_number is not None:
187
+ event["prNumber"] = int(pr_number)
188
+ if round_ is not None:
189
+ event["round"] = int(round_)
190
+ if attempt is not None:
191
+ event["attempt"] = int(attempt)
192
+ if session:
193
+ event["session"] = session
194
+ if reason:
195
+ event["reason"] = reason[:MAX_REASON]
196
+ if data:
197
+ event["data"] = dict(data)
198
+ return event
199
+
200
+
201
+ # -- deterministic keys (the documented derivations) -------------------------
202
+
203
+
204
+ def issue_spawn_key(repo: str, issue: int, attempt: int) -> str:
205
+ return f"{SEAT}:spawn.issue:{repo}:{issue}:{attempt}"
206
+
207
+
208
+ def fix_spawn_key(repo: str, pr: int, round_: int, attempt: int) -> str:
209
+ return f"{SEAT}:spawn.fix:{repo}:{pr}:{round_}:{attempt}"
210
+
211
+
212
+ def maintain_spawn_key(repo: str, pr: int, attempt: int) -> str:
213
+ return f"{SEAT}:spawn.maintain:{repo}:{pr}:{attempt}"
214
+
215
+
216
+ def defer_key(repo: str, edge: str, number: int, reason_token: str) -> str:
217
+ return f"{SEAT}:defer:{repo}:{edge}:{number}:{reason_token}"
218
+
219
+
220
+ def reap_key(session: str) -> str:
221
+ return f"{SEAT}:reap:{session}"
222
+
223
+
224
+ def escalation_key(event_kind: str, repo_slug: str, number: int,
225
+ raw_kind: str) -> str:
226
+ """Escalation-derived events key on the ledger PRIMARY KEY verbatim
227
+ (repo_slug, number, kind) under the event kind — one row, one key,
228
+ whatever the kind family. The raw ledger kind stays in the key even when
229
+ the event kind already names its family, because the kind column IS part
230
+ of the row's identity (`fix-cap:r1` and `fix-cap:r2` are two rows)."""
231
+ return f"{SEAT}:{event_kind}:{repo_slug}:{number}:{raw_kind}"
232
+
233
+
234
+ # -- derivation: ledger rows → events ---------------------------------------
235
+
236
+
237
+ def spawn_events(
238
+ spawn_rows: "Iterable[Mapping]",
239
+ fix_rows: "Iterable[Mapping]",
240
+ maintain_rows: "Iterable[Mapping]",
241
+ resume_keys: "frozenset[str] | set[str]" = frozenset(),
242
+ ) -> "list[dict]":
243
+ """The three spawn ledgers as events. `resume_keys` holds the dedupe keys
244
+ of spawns issued with a RESUME directive (pass-observed by the loop);
245
+ their events carry ``data.resume: true``."""
246
+ events = []
247
+ for row in spawn_rows:
248
+ key = issue_spawn_key(row["repo_slug"], row["issue"], row["attempt"])
249
+ events.append(_event(
250
+ "spawn.issue",
251
+ _ms(row["spawned_at"]),
252
+ key,
253
+ repo=row["repo_slug"],
254
+ issue_number=row["issue"],
255
+ attempt=row["attempt"],
256
+ session=row["session"],
257
+ data={"resume": True} if key in resume_keys else None,
258
+ ))
259
+ for row in fix_rows:
260
+ key = fix_spawn_key(
261
+ row["repo_slug"], row["number"], row["round"], row["attempt"]
262
+ )
263
+ events.append(_event(
264
+ "spawn.fix",
265
+ _ms(row["spawned_at"]),
266
+ key,
267
+ repo=row["repo_slug"],
268
+ pr_number=row["number"],
269
+ round_=row["round"],
270
+ attempt=row["attempt"],
271
+ session=row["session"],
272
+ data={"resume": True} if key in resume_keys else None,
273
+ ))
274
+ for row in maintain_rows:
275
+ key = maintain_spawn_key(row["repo_slug"], row["number"], row["attempt"])
276
+ events.append(_event(
277
+ "spawn.maintain",
278
+ _ms(row["spawned_at"]),
279
+ key,
280
+ repo=row["repo_slug"],
281
+ pr_number=row["number"],
282
+ attempt=row["attempt"],
283
+ session=row["session"],
284
+ ))
285
+ return events
286
+
287
+
288
+ def escalation_event(row: "Mapping") -> "dict":
289
+ """One escalation row as its event — cap-out, stall, handoff act, or the
290
+ generic `escalation.<kind>`. Total: every row derives SOMETHING, so a new
291
+ ledger kind reaches Studio without a derivation change."""
292
+ raw = str(row["kind"])
293
+ repo = str(row["repo_slug"])
294
+ number = int(row["issue"])
295
+ at = _ms(row["escalated_at"])
296
+
297
+ def build(kind, **fields):
298
+ return _event(kind, at, escalation_key(kind, repo, number, raw),
299
+ repo=repo, **fields)
300
+
301
+ if raw == "cap":
302
+ return build("cap.issue", issue_number=number,
303
+ reason="developer attempt cap reached")
304
+ match = _FIX_CAP_RE.match(raw)
305
+ if match:
306
+ round_ = int(match.group(1))
307
+ return build("cap.fix", pr_number=number, round_=round_,
308
+ reason=f"fix attempts for round {round_} capped")
309
+ if _MAINTAIN_CAP_RE.match(raw):
310
+ return build("cap.maintain", pr_number=number,
311
+ reason="maintenance attempts capped")
312
+
313
+ match = _STALLED_RE.match(raw)
314
+ if match:
315
+ return build("stalled", issue_number=number,
316
+ attempt=int(match.group(1)), data={"kind": "stalled"})
317
+ match = _LIVE_STALLED_RE.match(raw)
318
+ if match:
319
+ return build("stalled", issue_number=number,
320
+ attempt=int(match.group(1)),
321
+ data={"kind": "live-stalled"})
322
+ match = _FIX_STALLED_RE.match(raw)
323
+ if match:
324
+ return build("stalled", pr_number=number,
325
+ round_=int(match.group(1)), attempt=int(match.group(2)),
326
+ data={"kind": "fix-stalled"})
327
+
328
+ match = _ORPHAN_READY_RE.match(raw)
329
+ if match:
330
+ return build("handoff.ready", pr_number=number,
331
+ data={"action": "ready", "headSha": match.group(1)})
332
+ match = _ORPHAN_DONE_RE.match(raw)
333
+ if match:
334
+ return build("handoff.ready", pr_number=number,
335
+ data={"action": "requested", "headSha": match.group(1)})
336
+ match = _REREQUEST_RE.match(raw)
337
+ if match:
338
+ return build("handoff.ready", pr_number=number,
339
+ round_=int(match.group(1)),
340
+ data={"action": "rerequest", "headSha": match.group(2)})
341
+
342
+ # Everything else: the generic family. The foreign-session page has
343
+ # DEGENERATE ledger coordinates by design (repo "-", number 0 — a tmux
344
+ # session has no GitHub artifact), so those two fields stay off the event
345
+ # (both are optional on the contract) and the session name rides where it
346
+ # belongs. The key still uses the placeholders verbatim: it derives from
347
+ # the row's primary key, whatever that key holds.
348
+ kind = f"escalation.{_kind_token(raw)}"
349
+ match = _FOREIGN_RE.match(raw)
350
+ if match:
351
+ return _event(kind, at, escalation_key(kind, repo, number, raw),
352
+ session=match.group(1), reason=raw,
353
+ data={"ledgerKind": raw})
354
+ fields: "dict[str, object]" = {"reason": raw, "data": {"ledgerKind": raw}}
355
+ if raw.split(":", 1)[0] in _ISSUE_ANCHORED:
356
+ fields["issue_number"] = number
357
+ else:
358
+ # Every other known family keys a PR number into the `issue` column;
359
+ # an unknown future kind is assumed to as well (new edges here have
360
+ # all been PR-side since the issue edge shipped).
361
+ fields["pr_number"] = number
362
+ return build(kind, **fields)
363
+
364
+
365
+ def escalation_events(rows: "Iterable[Mapping]") -> "list[dict]":
366
+ return [escalation_event(row) for row in rows]
367
+
368
+
369
+ def defer_events(
370
+ deferrals: "Sequence[Mapping]", at_ms: int
371
+ ) -> "list[dict]":
372
+ """Pass-observed deferrals as events. Each entry carries `repo`, `edge`
373
+ (`issue`/`fix`/`maintain`), `number`, `token` (the coarse reason), and
374
+ `reason` (the decision's verbatim sentence). Keyed per (repo, edge,
375
+ number, token): a defer that continues across passes — or recurs for the
376
+ same reason — is one event, which is the point of the coarse token."""
377
+ events = []
378
+ for item in deferrals:
379
+ edge = str(item["edge"])
380
+ number = int(item["number"])
381
+ events.append(_event(
382
+ "defer",
383
+ at_ms,
384
+ defer_key(str(item["repo"]), edge, number, str(item["token"])),
385
+ repo=str(item["repo"]),
386
+ issue_number=number if edge == "issue" else None,
387
+ pr_number=number if edge != "issue" else None,
388
+ reason=str(item.get("reason") or "") or None,
389
+ data={"edge": edge, "reasonToken": str(item["token"])},
390
+ ))
391
+ return events
392
+
393
+
394
+ def reap_events(reaps: "Sequence[Mapping]", at_ms: int) -> "list[dict]":
395
+ """Pass-observed reaper kills as events. Each entry carries `session`,
396
+ `anchor` (the kill's stated evidence), and — when the session resolved to
397
+ a ledger row — `repo`, `edge` and `number`. Keyed on the session name
398
+ alone: worker names are deterministic per (repo, number, attempt) and a
399
+ session dies once, so the name IS the kill's identity."""
400
+ events = []
401
+ for item in reaps:
402
+ edge = str(item.get("edge") or "")
403
+ number = item.get("number")
404
+ events.append(_event(
405
+ "reap",
406
+ at_ms,
407
+ reap_key(str(item["session"])),
408
+ repo=str(item.get("repo") or "") or None,
409
+ issue_number=int(number) if number is not None and edge == "issue"
410
+ else None,
411
+ pr_number=int(number) if number is not None and edge != "issue"
412
+ else None,
413
+ session=str(item["session"]),
414
+ data={"anchor": str(item.get("anchor") or "unknown")},
415
+ ))
416
+ return events
417
+
418
+
419
+ # -- the emitter -------------------------------------------------------------
420
+
421
+
422
+ class LoopEventEmitter:
423
+ """One idempotent batch per poll pass, best-effort.
424
+
425
+ Holds three pieces of in-memory state, all safe to lose (a restart
426
+ re-emits, the server dedupes):
427
+
428
+ * `_emitted` — dedupe keys already accepted by the server this process,
429
+ so a pass sends only what is new;
430
+ * `_observed` — pass-observed events (defers, reaps) not yet accepted:
431
+ unlike the ledger-derived events these cannot be re-derived next pass,
432
+ so they are carried until a POST lands;
433
+ * `_resume_keys` — spawn keys the loop flagged as RESUME spawns, kept for
434
+ the life of the process so a failed POST re-derives the spawn event
435
+ with its flag intact.
436
+ """
437
+
438
+ def __init__(self, state, client: "AlissaClient"):
439
+ self.state = state
440
+ self.client = client
441
+ self._emitted: "set[str]" = set()
442
+ self._observed: "dict[str, dict]" = {}
443
+ self._resume_keys: "set[str]" = set()
444
+
445
+ def note_resume(self, dedupe_key: str) -> None:
446
+ """Flag one spawn key as a RESUME spawn (see the module docstring for
447
+ why the flag is a data field on the spawn event, not its own kind)."""
448
+ self._resume_keys.add(dedupe_key)
449
+
450
+ def emit_pass(
451
+ self,
452
+ *,
453
+ deferrals: "Sequence[Mapping]" = (),
454
+ reaps: "Sequence[Mapping]" = (),
455
+ now_ms: int,
456
+ ) -> int:
457
+ """Derive this pass's events and POST what the server has not yet
458
+ accepted, in batches of at most MAX_BATCH and at most
459
+ MAX_BATCHES_PER_PASS calls per pass (the tail re-derives next pass).
460
+ Returns how many events were accepted this pass. NEVER raises: any
461
+ failure — a wedged ledger read, a transport error, an ingest 400 —
462
+ logs one warning naming the count (and status, when there is one)
463
+ and the pass completes."""
464
+ try:
465
+ for event in (defer_events(deferrals, now_ms)
466
+ + reap_events(reaps, now_ms)):
467
+ self._observed.setdefault(str(event["dedupeKey"]), event)
468
+ events = (
469
+ spawn_events(
470
+ self.state.read_spawns(),
471
+ self.state.read_fix_spawns(),
472
+ self.state.read_maintain_spawns(),
473
+ self._resume_keys,
474
+ )
475
+ + escalation_events(self.state.read_escalations())
476
+ + list(self._observed.values())
477
+ )
478
+ except Exception as exc:
479
+ # Derivation reads the ledger, and the ledger can be wedged (the
480
+ # strict readers raise sqlite3.Error). Telemetry must not be the
481
+ # thing that abandons a pass over it.
482
+ log.warning(
483
+ "loop events: derivation failed (%s) — no events pushed this "
484
+ "pass; the ledgers re-derive next pass", exc,
485
+ )
486
+ return 0
487
+
488
+ fresh = [e for e in events if e["dedupeKey"] not in self._emitted]
489
+ if not fresh:
490
+ return 0
491
+
492
+ # The per-pass bound (see MAX_BATCHES_PER_PASS): the push is in-band,
493
+ # so a pass POSTs at most this many batches and leaves the tail —
494
+ # loudly — to the next pass. Ordinary passes never come near it; the
495
+ # case it bounds is the restart backfill.
496
+ cap = MAX_BATCH * MAX_BATCHES_PER_PASS
497
+ held_over = max(0, len(fresh) - cap)
498
+
499
+ sent = 0
500
+ for start in range(0, min(len(fresh), cap), MAX_BATCH):
501
+ batch = fresh[start:start + MAX_BATCH]
502
+ try:
503
+ self.client.post_loop_events(batch)
504
+ except AlissaError as exc:
505
+ log.warning(
506
+ "loop events: POST /v1/loop-events of %d event(s) failed "
507
+ "(status %s: %s) — %d of %d sent this pass; the rest "
508
+ "re-emit next pass (deterministic keys make re-emission "
509
+ "harmless)",
510
+ len(batch), exc.status, exc.detail, sent, len(fresh),
511
+ )
512
+ return sent
513
+ for event in batch:
514
+ key = str(event["dedupeKey"])
515
+ self._emitted.add(key)
516
+ self._observed.pop(key, None)
517
+ sent += len(batch)
518
+ if held_over:
519
+ log.info(
520
+ "loop events: per-pass bound reached — pushed %d of %d new "
521
+ "event(s), the remaining %d re-derive next pass",
522
+ sent, len(fresh), held_over,
523
+ )
524
+ else:
525
+ # INFO, not DEBUG (PR #102 review round 1): an operator who turns
526
+ # the push on and watches unattended logs needs the success path
527
+ # visible without -v, or "enabled and working" reads exactly like
528
+ # "enabled and never firing". Quiet passes stay quiet -- the
529
+ # early return above means this line only prints when something
530
+ # actually shipped.
531
+ log.info("loop events: pushed %d new event(s)", sent)
532
+ return sent
@@ -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.14
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -13,6 +13,7 @@ src/main/alissa/tools/github/devloop/config.py
13
13
  src/main/alissa/tools/github/devloop/ghclient.py
14
14
  src/main/alissa/tools/github/devloop/janitor.py
15
15
  src/main/alissa/tools/github/devloop/loop.py
16
+ src/main/alissa/tools/github/devloop/loop_events.py
16
17
  src/main/alissa/tools/github/devloop/proc.py
17
18
  src/main/alissa/tools/github/devloop/state.py
18
19
  src/main/alissa/tools/github/devloop/version