alissa-tools-github-devloop 0.8.13__tar.gz → 0.8.15__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_devloop-0.8.13/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.15}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/__main__.py +28 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/alissa_client.py +45 -9
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/config.py +50 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/loop.py +138 -0
- alissa_tools_github_devloop-0.8.15/src/main/alissa/tools/github/devloop/loop_events.py +532 -0
- alissa_tools_github_devloop-0.8.15/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/page.py +40 -17
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/sources.py +114 -7
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
- 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.15}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.13 → alissa_tools_github_devloop-0.8.15}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -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":
|