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.
- {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
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/__main__.py +28 -0
- {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
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/config.py +50 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/loop.py +138 -0
- alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/loop_events.py +532 -0
- alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/version +1 -0
- {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
- {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
- alissa_tools_github_devloop-0.8.13/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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
|
-
|
|
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(
|
|
145
|
-
|
|
146
|
-
|
|
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=
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.14
|
|
@@ -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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.13
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|