alissa-tools-github-devloop 0.8.40__py3-none-any.whl → 0.8.42__py3-none-any.whl

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.
@@ -284,6 +284,8 @@ CONFIG_KEYS = (
284
284
  "attempt_cap",
285
285
  "stale_minutes",
286
286
  "max_sessions",
287
+ "spawn_headroom_threads",
288
+ "spawn_headroom_memory_pct",
287
289
  "reap_grace_minutes",
288
290
  "foreign_session_policy",
289
291
  "foreign_session_allow_prefixes",
@@ -535,6 +537,19 @@ class Config:
535
537
  # listing, so enabling it costs no extra GitHub or CLI calls.
536
538
  max_sessions: int = 0
537
539
 
540
+ # Headroom-aware admission (issue #162): a session count does not bound
541
+ # what the sessions RUN -- an idle claude holds ~16 threads, a test suite
542
+ # or a Chromium screenshot 100-200+ -- and the container's cgroup v2
543
+ # `pids.max` counts THREADS. When `pids.max - pids.current` falls under
544
+ # `spawn_headroom_threads`, or the free share of `memory.max` under
545
+ # `spawn_headroom_memory_pct`, a pass spawns nothing new: every spawn
546
+ # (resumes and fix rounds included) defers on the capacity valve and is
547
+ # re-decided next poll. 0 turns either guard off; an unlimited (`max`) or
548
+ # unreadable cgroup file turns its guard off too. The 2026-10-13 outage
549
+ # (a `gh` fork failing EAGAIN at ~1000 threads) set the defaults.
550
+ spawn_headroom_threads: int = 300
551
+ spawn_headroom_memory_pct: int = 15
552
+
538
553
  # The reaper's grace: how long a worker session must have been QUIET (no
539
554
  # tmux activity) before a terminal anchor licenses killing it. The point
540
555
  # is the window right after an anchor goes terminal -- a PR merges while
@@ -1154,6 +1169,25 @@ class Config:
1154
1169
  f"max_sessions must be >= 0 (0 = unlimited), got {max_sessions}"
1155
1170
  )
1156
1171
 
1172
+ headroom_threads = int(
1173
+ raw.get("spawn_headroom_threads", cls.spawn_headroom_threads)
1174
+ )
1175
+ if headroom_threads < 0:
1176
+ raise ValueError(
1177
+ f"spawn_headroom_threads must be >= 0 (0 = off), got "
1178
+ f"{headroom_threads}"
1179
+ )
1180
+ headroom_memory = int(
1181
+ raw.get("spawn_headroom_memory_pct", cls.spawn_headroom_memory_pct)
1182
+ )
1183
+ # Capped at 90: a reserve of the whole limit would hold every spawn
1184
+ # for ever on any container that has started anything at all.
1185
+ if not 0 <= headroom_memory <= 90:
1186
+ raise ValueError(
1187
+ f"spawn_headroom_memory_pct must be between 0 and 90 (0 = "
1188
+ f"off), got {headroom_memory}"
1189
+ )
1190
+
1157
1191
  # The reaper's grace, validated like the two below it -- same 0
1158
1192
  # sentinel, and a negative value is the only invalid one (it would
1159
1193
  # make every listed session instantly reapable, which is the one
@@ -1340,6 +1374,8 @@ class Config:
1340
1374
  attempt_cap=cap,
1341
1375
  stale_minutes=stale,
1342
1376
  max_sessions=max_sessions,
1377
+ spawn_headroom_threads=headroom_threads,
1378
+ spawn_headroom_memory_pct=headroom_memory,
1343
1379
  reap_grace_minutes=reap_grace,
1344
1380
  foreign_session_policy=foreign_policy,
1345
1381
  foreign_session_allow_prefixes=normalize_session_prefixes(
@@ -451,6 +451,7 @@ import calendar
451
451
  import dataclasses
452
452
  import json
453
453
  import logging
454
+ import os
454
455
  import re
455
456
  import sqlite3
456
457
  import time
@@ -464,6 +465,7 @@ from . import janitor
464
465
  from . import fleet_vitals as fleet_vitals_mod
465
466
  from . import loop_events as loop_events_mod
466
467
  from . import parking as parking_mod
468
+ from . import proc as proc_mod
467
469
  from . import prompts as prompts_mod
468
470
  from .alissa import MANAGED_PREFIX, SHELL_COMMANDS, Alissa
469
471
  from .alissa_client import AlissaClient
@@ -915,6 +917,117 @@ RESUME_DIRECTIVE = (
915
917
  "(best-effort — the daemon's reaper sweep collects stragglers)."
916
918
  )
917
919
 
920
+ # The discovery round's directive (issue #164, design
921
+ # alissa-build-discovery.md §3.2, lane DS-7). Sent INSTEAD of DEV_DIRECTIVE
922
+ # when the issue carries the trailer `Alissa-Kind: discovery` (rendered by
923
+ # Studio's feed_plan since DS-1): a no-code round in the PLANS repository
924
+ # that reads a code repository at the SHA pinned by `Alissa-Reads:` and writes
925
+ # one knowledge-base directory. What differs from the develop contract, and
926
+ # why each difference is load-bearing:
927
+ #
928
+ # - The read is DETACHED and read-only (a scratch `--detach` worktree off a
929
+ # hub of the read repo, else `gh api …?ref=<sha>`): no branch exists that
930
+ # could be pushed, and the push guard (docker/claude/hooks/guard-shell.py,
931
+ # armed by `write_discovery_marker`) refuses any push that is not to the
932
+ # plans repository anyway.
933
+ # - The read is bounded by the SHA, not by a file count (D1: no rationing);
934
+ # only the OUTPUT is bounded, with the findings schema's own limits.
935
+ # - The local `--kb` lint gates the push: findings the linter refuses never
936
+ # leave the machine, so the run of record never sees them.
937
+ # - The PR is opened READY, carries the discovery marker on a line of its
938
+ # own, and requests NO reviewer: revloop skips a marked PR confined to
939
+ # knowledge-base/ (§3.3), orcloop merges it on the lint of record (§3.5),
940
+ # and a draft would sit unmergeable until the orphan sweep flipped it.
941
+ # - No delivery note: the findings ARE the narrative, and their reader is a
942
+ # linter and a plan, not a human validator deciding on a merge.
943
+ #
944
+ # The {trailer_note} slot renders to nothing when the trailers parse; see
945
+ # render_discovery_directive for what it says when they do not.
946
+ DISCOVERY_DIRECTIVE = (
947
+ "You are a DISCOVERY worker, not an implementer and not a reviewer: this "
948
+ "round writes NO code. Answer the questions of GitHub issue {issue_url} "
949
+ "(attempt {attempt} of {cap}) by READING code, and record the answers as "
950
+ "findings — the issue is your self-contained spec, its numbered "
951
+ "questions are what you answer, and its acceptance criteria are "
952
+ "authoritative. {task_instruction} {trailer_note}"
953
+ "Golden path: your cwd is the worktree hub ROOT of the PLANS repository "
954
+ "{plans_repo} — create the task worktree there with `git worktree add "
955
+ "TASK-<n>-<DESC>`, where <n> is YOUR IMPLEMENTATION task's number (the "
956
+ "downstream task YOU created, NOT the origin task's), from there (one "
957
+ "task = one branch = one worktree; never clone, never work directly on "
958
+ "main). "
959
+ + _BRANCH_RULE
960
+ + _SHELL_RULE
961
+ + "READ the code at the pinned SHA and nowhere else, read-only: "
962
+ "`{read_repo}` at `{read_sha}` (the issue's `Alissa-Reads:` trailer). "
963
+ "If this workspace holds a hub of {read_repo} (a sibling of your hub "
964
+ "root with a `.source/`), read through a DETACHED scratch worktree: `git "
965
+ "-C <hub> worktree add --detach <scratch> {read_sha}` (fetch that SHA "
966
+ "into the hub first if it is missing) — no branch, never pushed — and "
967
+ "remove it with `git -C <hub> worktree remove --force <scratch>` when "
968
+ "you are done. Otherwise read through `gh api "
969
+ "repos/{read_repo}/contents/<path>?ref={read_sha}` and `gh search "
970
+ "code`. Never clone {read_repo}, and never commit or push to it: this "
971
+ "session's push guard refuses any `git push` to a repository other than "
972
+ "{plans_repo}. Never open a deny-listed path (`.env*`, `*.pem`, `*.key`, "
973
+ "`*.p12`, `id_rsa*`, `*secret*`, `*credential*`, `.npmrc`, `.git/**`, "
974
+ "`**/node_modules/**`). The read is bounded by the SHA, not by a file "
975
+ "count — read whatever the questions need; what is bounded is the "
976
+ "OUTPUT. "
977
+ "WRITE exactly one directory, `{kb_dir}/`, holding `findings.md` and "
978
+ "nothing else; no other file anywhere in the repository changes. "
979
+ "`findings.md` follows the findings schema v1 (`schema/findings.v1.json` "
980
+ "in the plans repository, and the knowledge-base section of its "
981
+ "README): YAML frontmatter with `schema: alissa-findings/v1`, `task` = "
982
+ "the origin task, `reads` = `{read_repo}` at `{read_sha}`, every "
983
+ "question either answered with cites (repo, path, line range and the "
984
+ "file's git blob SHA at the pinned SHA) or marked `unanswerable` with a "
985
+ "reason; then a `## Summary` and one section per question. Stay inside "
986
+ "the schema's limits: at most 32 KB, 1–12 questions, 12 risks, 8 "
987
+ "suggested tasks and 24 cites per entry, 400 lines per cite, 1,200 "
988
+ "characters of Summary and 6,000 per question's section. "
989
+ "LINT it locally before anything leaves the machine: `node "
990
+ "lint/plan-lint.ts --kb {kb_dir}` from your worktree (`npm ci` first if "
991
+ "`node_modules` is missing), with this seat's own `ALISSA_API_TOKEN` in "
992
+ "the environment for the K6 task check and the K7 cite check. Findings "
993
+ "the local run refuses are NEVER pushed — fix them and rerun until it "
994
+ "passes; `plan-lint could not run` is a refusal, not a pass. "
995
+ "Then commit, push the branch to {plans_repo}, and open a READY (not "
996
+ "draft) pull request whose body contains `Closes #{number}`, BOTH task "
997
+ "refs — the origin task AND your implementation task — and the marker "
998
+ "`<!-- alissa-discovery v1 -->` on a line of its own. That is the "
999
+ "body's single closing claim: never put a closing keyword (`close`, "
1000
+ "`fix`, `resolve` and their forms) before any other issue reference. "
1001
+ + _DEFAULT_BASE
1002
+ + "Attach the PR URL to your implementation task as evidence, and attach "
1003
+ "a `Findings` evidence item to the origin task — the `## Summary` "
1004
+ "section; one line per question (`q1 · answered · high`); the risk "
1005
+ "count; the PR URL and the blob URL of `findings.md` at the head commit "
1006
+ "— or, if the origin task refuses that write, to your implementation "
1007
+ "task, saying so in the PR body. Then move your implementation task to "
1008
+ "pending_validation. Do NOT request a reviewer — no `gh pr edit … "
1009
+ "--add-reviewer …`, no review skill: a discovery PR is checked by the "
1010
+ "plan-lint run of record and merged on it. NEVER merge, never push to "
1011
+ "main, and do NOT create further ali-* sessions — this loop is depth-1. "
1012
+ "Once your task is pending_validation and the PR is open, your last "
1013
+ "act: run `alissa tmux kill {session}` to end this session and free the "
1014
+ "machine (best-effort — the daemon's reaper sweep collects stragglers)."
1015
+ )
1016
+
1017
+ # Part of DISCOVERY_DIRECTIVE's {trailer_note} when the issue's `Alissa-Reads:`
1018
+ # trailer is absent or not `<owner>/<repo>@<40-hex sha>`. There is then no
1019
+ # pinned SHA to read, and a worker that picked one (the default branch's
1020
+ # head, say) would write findings K6 refuses at the run of record -- so the
1021
+ # round stops instead, and the defect goes where the plan author sees it.
1022
+ DISCOVERY_READS_MISSING_NOTE = (
1023
+ "This issue's `Alissa-Reads:` trailer is missing or malformed, so there "
1024
+ "is NO pinned SHA: do not pick one yourself. Comment on the issue naming "
1025
+ "the defect (it must read `Alissa-Reads: <owner>/<repo>@<40-hex sha>`), "
1026
+ "leave your implementation task in_progress with that note, open no "
1027
+ "pull request, and run `alissa tmux kill {session}` to end this "
1028
+ "session. "
1029
+ )
1030
+
918
1031
  # The handover clause (issue #121), shared by FIX_DIRECTIVE and
919
1032
  # FIX_RESUME_DIRECTIVE. When a later review round lands on the head a fix
920
1033
  # session is still working (the 2026-09-15 studio#1243 shape: a phantom round
@@ -1708,6 +1821,139 @@ def marker_file_name(tmux_session: str) -> str:
1708
1821
  return safe + ".json"
1709
1822
 
1710
1823
 
1824
+ # -- discovery rounds (issue #164) ---------------------------------------
1825
+ #
1826
+ # The trailers Studio's feed_plan renders on a discovery issue (design
1827
+ # alissa-build-discovery.md §2.2). Line-anchored and MULTILINE like
1828
+ # `Alissa-Task:`: a trailer sits on a line of its own, so a body QUOTING one
1829
+ # mid-sentence (a design issue explaining discovery) is not a discovery issue.
1830
+ _DISCOVERY_KIND_RE = re.compile(r"^Alissa-Kind:[ \t]*discovery[ \t]*\r?$", re.MULTILINE)
1831
+ _ALISSA_READS_RE = re.compile(
1832
+ r"^Alissa-Reads:[ \t]*(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)"
1833
+ r"@(?P<sha>[0-9a-f]{40})[ \t]*\r?$",
1834
+ re.MULTILINE,
1835
+ )
1836
+ _KB_SCOPE_RE = re.compile(
1837
+ r"^Autonomous-Scope:[ \t]*(?P<dir>knowledge-base/\d{4}/\d{2}/\d{2}/TASK-\d+)"
1838
+ r"/\*\*[ \t]*\r?$",
1839
+ re.MULTILINE,
1840
+ )
1841
+
1842
+ # The knowledge-base directory a discovery directive names when the issue's
1843
+ # `Autonomous-Scope:` does not spell one (feed_plan always derives it), and
1844
+ # the sentence telling the worker how to fill it in.
1845
+ DISCOVERY_KB_DIR_FALLBACK = "knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>"
1846
+ DISCOVERY_KB_DIR_NOTE = (
1847
+ "This issue's `Autonomous-Scope:` does not name the knowledge-base "
1848
+ "directory: in `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/` below, <n> is "
1849
+ "the ORIGIN task's number and the date its creation day (UTC). "
1850
+ )
1851
+
1852
+ # Where the daemon leaves the marker that arms the push guard for one
1853
+ # discovery session, and the env var both sides read it from. The daemon and
1854
+ # every worker run in one container with one environment, so the hook
1855
+ # (docker/claude/hooks/guard-shell.py, which spells the same default) and
1856
+ # this module agree on the directory without a config key.
1857
+ DISCOVERY_DIR_ENV = "ALISSA_DISCOVERY_DIR"
1858
+ DEFAULT_DISCOVERY_DIR = "/workspace/.discovery"
1859
+
1860
+
1861
+ @dataclass(frozen=True)
1862
+ class Discovery:
1863
+ """What a discovery issue's trailers say. `read_repo`/`read_sha` are None
1864
+ when `Alissa-Reads:` is absent or malformed, `kb_dir` when
1865
+ `Autonomous-Scope:` is not one knowledge-base directory."""
1866
+
1867
+ read_repo: "str | None"
1868
+ read_sha: "str | None"
1869
+ kb_dir: "str | None"
1870
+
1871
+
1872
+ def discovery_of(body: "str | None") -> "Discovery | None":
1873
+ """The discovery trailers of an issue body, or None when it does not
1874
+ carry `Alissa-Kind: discovery` -- the one signal that selects
1875
+ DISCOVERY_DIRECTIVE. The first `Alissa-Reads:` wins."""
1876
+ text = body or ""
1877
+ if not _DISCOVERY_KIND_RE.search(text):
1878
+ return None
1879
+ reads = _ALISSA_READS_RE.search(text)
1880
+ scope = _KB_SCOPE_RE.search(text)
1881
+ return Discovery(
1882
+ read_repo=reads.group("repo") if reads else None,
1883
+ read_sha=reads.group("sha") if reads else None,
1884
+ kb_dir=scope.group("dir") if scope else None,
1885
+ )
1886
+
1887
+
1888
+ def render_discovery_directive(
1889
+ *,
1890
+ issue_url: str,
1891
+ number: int,
1892
+ attempt: int,
1893
+ cap: int,
1894
+ task_instruction: str,
1895
+ plans_repo: str,
1896
+ session: str,
1897
+ discovery: Discovery,
1898
+ ) -> str:
1899
+ """DISCOVERY_DIRECTIVE for one issue. A missing `Alissa-Reads:` renders
1900
+ the read target as placeholders plus DISCOVERY_READS_MISSING_NOTE (stop,
1901
+ do not guess a SHA); a missing knowledge-base scope renders
1902
+ DISCOVERY_KB_DIR_FALLBACK plus DISCOVERY_KB_DIR_NOTE."""
1903
+ notes = ""
1904
+ if discovery.read_repo is None or discovery.read_sha is None:
1905
+ notes += DISCOVERY_READS_MISSING_NOTE.format(session=session)
1906
+ if discovery.kb_dir is None:
1907
+ notes += DISCOVERY_KB_DIR_NOTE
1908
+ return DISCOVERY_DIRECTIVE.format(
1909
+ issue_url=issue_url,
1910
+ number=number,
1911
+ attempt=attempt,
1912
+ cap=cap,
1913
+ task_instruction=task_instruction,
1914
+ trailer_note=notes,
1915
+ plans_repo=plans_repo,
1916
+ read_repo=discovery.read_repo or "<owner>/<repo>",
1917
+ read_sha=discovery.read_sha or "<sha>",
1918
+ kb_dir=discovery.kb_dir or DISCOVERY_KB_DIR_FALLBACK,
1919
+ session=session,
1920
+ )
1921
+
1922
+
1923
+ def discovery_dir() -> Path:
1924
+ return Path(os.environ.get(DISCOVERY_DIR_ENV, "").strip() or DEFAULT_DISCOVERY_DIR)
1925
+
1926
+
1927
+ def write_discovery_marker(session: str, plans_repo: str, number: int, discovery: Discovery) -> Path:
1928
+ """Arm the push guard for the managed session `session` (its tmux name is
1929
+ `ali-<session>`, the name the hook reads from `$ALISSA_TMUX_SESSION`):
1930
+ `<discovery_dir>/<tmux name>.json`, spelled like the waiting marker
1931
+ (`marker_file_name`), naming the one repository the session may push to.
1932
+ Written atomically (temp file + rename) so the hook never reads a torn
1933
+ file. Raises OSError; the caller stands the spawn down rather than start a
1934
+ discovery session the guard does not know about. Never removed: a
1935
+ session name is unique per issue and attempt, so a stale marker can only
1936
+ ever guard a later session on the same discovery issue."""
1937
+ tmux_name = f"{MANAGED_PREFIX}{session}"
1938
+ folder = discovery_dir()
1939
+ folder.mkdir(parents=True, exist_ok=True)
1940
+ path = folder / marker_file_name(tmux_name)
1941
+ payload = {
1942
+ "session": tmux_name,
1943
+ "plansRepo": plans_repo,
1944
+ "issue": number,
1945
+ "reads": (
1946
+ f"{discovery.read_repo}@{discovery.read_sha}"
1947
+ if discovery.read_repo is not None
1948
+ else None
1949
+ ),
1950
+ }
1951
+ tmp = path.with_name(path.name + ".tmp")
1952
+ tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
1953
+ os.replace(tmp, path)
1954
+ return path
1955
+
1956
+
1711
1957
  # The first-run-dialog wedge's once-per-episode marker (issue #123; see
1712
1958
  # first_run_dialog_kind). Like ESCALATION_WEDGED an ACT, not a page: the
1713
1959
  # daemon killed a session whose pane showed one of Claude Code's first-run
@@ -2689,6 +2935,112 @@ class DiskReport:
2689
2935
  offenders: "tuple[tuple[str, int], ...]" = ()
2690
2936
 
2691
2937
 
2938
+ # The container's cgroup v2 mount (issue #162). A module global, read at each
2939
+ # call, so a test can point every watcher at a fake cgroup directory.
2940
+ CGROUP_ROOT = Path("/sys/fs/cgroup")
2941
+
2942
+
2943
+ def _cgroup_value(path: Path) -> "int | str | None":
2944
+ """One cgroup v2 counter file: its integer, the string "max" (no limit),
2945
+ or None when it is absent, unreadable, or malformed."""
2946
+ try:
2947
+ raw = path.read_text().split()[0]
2948
+ except (OSError, ValueError, IndexError):
2949
+ return None
2950
+ if raw == "max":
2951
+ return "max"
2952
+ try:
2953
+ return int(raw)
2954
+ except ValueError:
2955
+ return None
2956
+
2957
+
2958
+ def _cgroup_stat(path: Path, field: str) -> int:
2959
+ """One `key value` line of a cgroup `memory.stat`; 0 when unreadable --
2960
+ it only ever ADDS headroom (reclaimable page cache), so a missing stat
2961
+ reads the conservative way."""
2962
+ try:
2963
+ for line in path.read_text().splitlines():
2964
+ key, _, value = line.partition(" ")
2965
+ if key == field:
2966
+ return int(value.strip())
2967
+ except (OSError, ValueError):
2968
+ return 0
2969
+ return 0
2970
+
2971
+
2972
+ @dataclass(frozen=True)
2973
+ class Headroom:
2974
+ """One pass's cgroup headroom reading (issue #162).
2975
+
2976
+ `low` names every guard whose reserve is breached, in the poll-summary
2977
+ wording (`threads 812/1000, reserve 300`), and is empty when the pass may
2978
+ spawn. `unreadable` names every ENABLED guard whose files could not be
2979
+ read: that guard is off this pass (failing open, like the max_sessions
2980
+ budget on a listing failure -- a missing file must not freeze spawning)
2981
+ and the watcher warns about it once."""
2982
+
2983
+ low: "tuple[str, ...]" = ()
2984
+ unreadable: "tuple[str, ...]" = ()
2985
+
2986
+ @property
2987
+ def reason(self) -> "str | None":
2988
+ """`headroom (threads 812/1000, reserve 300)`, or None when no
2989
+ reserve is breached."""
2990
+ if not self.low:
2991
+ return None
2992
+ return f"headroom ({'; '.join(self.low)})"
2993
+
2994
+
2995
+ def read_headroom(
2996
+ threads_reserve: int,
2997
+ memory_pct: int,
2998
+ cgroup_root: "Path | None" = None,
2999
+ ) -> Headroom:
3000
+ """Read cgroup v2 `pids.current`/`pids.max` and `memory.current`/
3001
+ `memory.max` and judge them against the two reserves. A reserve of 0 is
3002
+ that guard off (its files are not even read); a `max` limit is unlimited,
3003
+ so that guard is off too. `pids.max` counts THREADS, not processes --
3004
+ which is why a session count alone never bounded it. Free memory is the
3005
+ limit minus what is charged, with the inactive page cache the kernel
3006
+ would drop under pressure (`memory.stat inactive_file`) counted as free:
3007
+ a container that just walked a few big checkouts is not out of memory."""
3008
+ root = CGROUP_ROOT if cgroup_root is None else cgroup_root
3009
+ low: "list[str]" = []
3010
+ unreadable: "list[str]" = []
3011
+ if threads_reserve > 0:
3012
+ current = _cgroup_value(root / "pids.current")
3013
+ limit = _cgroup_value(root / "pids.max")
3014
+ if not isinstance(current, int) or limit is None:
3015
+ unreadable.append("threads")
3016
+ elif isinstance(limit, int) and limit - current < threads_reserve:
3017
+ low.append(f"threads {current}/{limit}, reserve {threads_reserve}")
3018
+ if memory_pct > 0:
3019
+ current = _cgroup_value(root / "memory.current")
3020
+ limit = _cgroup_value(root / "memory.max")
3021
+ if not isinstance(current, int) or limit is None:
3022
+ unreadable.append("memory")
3023
+ elif isinstance(limit, int) and limit > 0:
3024
+ charged = max(0, current - _cgroup_stat(root / "memory.stat", "inactive_file"))
3025
+ free = max(0, limit - charged)
3026
+ if free * 100 < memory_pct * limit:
3027
+ low.append(
3028
+ f"memory {free * 100 // limit}% free of "
3029
+ f"{limit / (1 << 30):.1f} GiB, reserve {memory_pct}%"
3030
+ )
3031
+ return Headroom(low=tuple(low), unreadable=tuple(unreadable))
3032
+
3033
+
3034
+ def cgroup_threads(cgroup_root: "Path | None" = None) -> "str | None":
3035
+ """`pids 998/1000` for a log line, or None when unreadable."""
3036
+ root = CGROUP_ROOT if cgroup_root is None else cgroup_root
3037
+ current = _cgroup_value(root / "pids.current")
3038
+ limit = _cgroup_value(root / "pids.max")
3039
+ if not isinstance(current, int) or limit is None:
3040
+ return None
3041
+ return f"pids {current}/{limit}"
3042
+
3043
+
2692
3044
  class SessionBudget:
2693
3045
  """The per-pass concurrency budget behind `max_sessions`.
2694
3046
 
@@ -2727,10 +3079,18 @@ class SessionBudget:
2727
3079
  hold: "str | None" = None,
2728
3080
  probe: bool = False,
2729
3081
  limited: int = 0,
3082
+ headroom: "str | None" = None,
2730
3083
  ):
2731
3084
  self.limit = limit
2732
3085
  self.active = active
2733
3086
  self.reserved = 0
3087
+ # The cgroup headroom guard (issue #162): while the container's
3088
+ # thread or memory reserve is breached, every reserve() is refused --
3089
+ # resumes, fix rounds and the usage-limit probe included, since each
3090
+ # is a new session with the same footprint. It defers on the
3091
+ # capacity valve (claims nothing, re-decided next poll); `headroom`
3092
+ # is the reason, `headroom (threads 812/1000, reserve 300)`.
3093
+ self.headroom = headroom
2734
3094
  # The prompt responder's spawn hold (issue #127): while an account-
2735
3095
  # level notice (an expired login, a hit limit, no credits) is on a
2736
3096
  # worker's screen, NEW sessions would only burn attempts against a
@@ -2763,7 +3123,10 @@ class SessionBudget:
2763
3123
  def reserve(self) -> bool:
2764
3124
  """Claim one slot if the cap allows. Unlimited always grants -- unless
2765
3125
  the responder's hold stands, which refuses every spawn but the one
2766
- probe a usage-limit hold admits (issue #144)."""
3126
+ probe a usage-limit hold admits (issue #144). A breached cgroup
3127
+ headroom reserve refuses everything, the probe too (issue #162)."""
3128
+ if self.headroom:
3129
+ return False
2767
3130
  if self.hold:
2768
3131
  if not self.probe or self._probe_taken:
2769
3132
  return False
@@ -2797,13 +3160,20 @@ class SessionBudget:
2797
3160
  def defer_token(self) -> "str | None":
2798
3161
  """The loop-events defer token a DEFERRED decision off this budget
2799
3162
  carries: the hold's own token while it stands, else None (the
2800
- emitter reads None as the capacity valve)."""
3163
+ emitter reads None as the capacity valve). A headroom deferral is
3164
+ the capacity valve too (issue #162), even under a hold: the guard
3165
+ refused it first."""
3166
+ if self.headroom:
3167
+ return None
2801
3168
  return loop_events_mod.DEFER_PROMPT_HOLD if self.hold else None
2802
3169
 
2803
3170
  def deferral(self) -> str:
2804
3171
  """The DEFERRED decision's reason -- `at capacity k/k`, the operator's
2805
3172
  signal that the cap (not per-item state) is what held the spawn --
2806
- or the responder's hold, which names the notice standing in the way."""
3173
+ or the responder's hold, which names the notice standing in the way,
3174
+ or the cgroup headroom reserve the container is under (issue #162)."""
3175
+ if self.headroom:
3176
+ return f"{self.headroom}; retrying next poll"
2807
3177
  if self.hold:
2808
3178
  return f"spawns held by the prompt responder — {self.hold}"
2809
3179
  return f"at capacity {self.used}/{self.limit}; retrying next poll"
@@ -3408,6 +3778,10 @@ class DevWatcher:
3408
3778
  # The reaper's warn-once latch for a CLI without `tmux kill` (kills
3409
3779
  # are best-effort; the condition is per-process, not per-session).
3410
3780
  self._kill_warned = False
3781
+ # The headroom guard's warn-once latch (issue #162): the guards whose
3782
+ # cgroup files could not be read. Per-process like the one above --
3783
+ # an unreadable cgroup does not become readable between polls.
3784
+ self._headroom_warned: "set[str]" = set()
3411
3785
  # The BOW-derived allowlist (`repos_source: "bows"`), or None in
3412
3786
  # static mode -- in which nothing below this line ever runs and the
3413
3787
  # allowlist is `config.repos`, unchanged.
@@ -4114,6 +4488,11 @@ class DevWatcher:
4114
4488
  "resolution"
4115
4489
  )
4116
4490
  regenerate_rule = self._regenerate_rule(issue.owner, issue.repo)
4491
+ # A discovery issue (issue #164) gets DISCOVERY_DIRECTIVE on a fresh
4492
+ # spawn; a resume keeps RESUME_DIRECTIVE (it finishes a PR that
4493
+ # already exists). Both are discovery SESSIONS, so both get the
4494
+ # push-guard marker below.
4495
+ discovery = discovery_of(issue.body)
4117
4496
  if resume_pr is not None:
4118
4497
  directive = RESUME_DIRECTIVE.format(
4119
4498
  issue_url=issue.url,
@@ -4129,6 +4508,17 @@ class DevWatcher:
4129
4508
  dead_session=dead_session,
4130
4509
  regenerate_rule=regenerate_rule,
4131
4510
  )
4511
+ elif discovery is not None:
4512
+ directive = render_discovery_directive(
4513
+ issue_url=issue.url,
4514
+ number=issue.number,
4515
+ attempt=attempt,
4516
+ cap=self.config.attempt_cap,
4517
+ task_instruction=task_instruction,
4518
+ plans_repo=issue.full_name,
4519
+ session=name,
4520
+ discovery=discovery,
4521
+ )
4132
4522
  else:
4133
4523
  directive = DEV_DIRECTIVE.format(
4134
4524
  issue_url=issue.url,
@@ -4179,6 +4569,32 @@ class DevWatcher:
4179
4569
  attempt - 1,
4180
4570
  )
4181
4571
 
4572
+ # The push guard's marker (issue #164), before the assignment and the
4573
+ # enqueue: a discovery session must never exist without it, because
4574
+ # the marker is the only thing that tells the shell guard to refuse
4575
+ # its pushes to the read repository. A marker that cannot be written
4576
+ # stands the spawn down exactly like an unreadable issue -- nothing
4577
+ # claimed, the slot handed back, retried next poll. The issue
4578
+ # edge's own repository is the plans repository: Autonomous-Dev of a
4579
+ # discovery issue is the fleet's plans repo (design §2.2).
4580
+ if discovery is not None and not self.config.dry_run:
4581
+ try:
4582
+ write_discovery_marker(name, issue.full_name, issue.number, discovery)
4583
+ except OSError as exc:
4584
+ if budget is not None:
4585
+ budget.release()
4586
+ log.warning(
4587
+ "%s: could not write the discovery push-guard marker for "
4588
+ "%s (%s) — standing the spawn down this pass",
4589
+ issue.issue_slug, name, exc,
4590
+ )
4591
+ return Decision(
4592
+ Action.SKIPPED,
4593
+ f"discovery push-guard marker could not be written ({exc}) "
4594
+ f"— retried next poll",
4595
+ attempt - 1,
4596
+ )
4597
+
4182
4598
  # assign_self FIRST: the assignment is the server-side in-flight
4183
4599
  # marker, so it must land before anything is enqueued -- a crash
4184
4600
  # between the two leaves an assigned-but-sessionless issue (safe:
@@ -10234,8 +10650,14 @@ class DevWatcher:
10234
10650
  roster fails CLOSED in the same breath -- an unlisted roster answers
10235
10651
  INDETERMINATE, so a hiccup can never manufacture a resume.
10236
10652
  `max_sessions=0` also yields an unlimited budget -- the default, so
10237
- nothing changes until an operator opts in."""
10653
+ nothing changes until an operator opts in.
10654
+
10655
+ The cgroup headroom guard (issue #162) is read here too, from files
10656
+ rather than the listing, so it stands through a listing failure: a
10657
+ container out of threads is exactly the one whose `tmux ls` fork
10658
+ fails, and failing the budget open THEN would spawn into the wall."""
10238
10659
  limit = self.config.max_sessions or None
10660
+ headroom = self._read_headroom()
10239
10661
  try:
10240
10662
  sessions = self.alissa.list_sessions()
10241
10663
  except CommandError as exc:
@@ -10250,7 +10672,7 @@ class DevWatcher:
10250
10672
  limit_hold, _probe = self._limit_gate(None)
10251
10673
  return (
10252
10674
  Sweep(reaped=0, names=set(), hold=limit_hold),
10253
- SessionBudget(None, 0, hold=limit_hold),
10675
+ SessionBudget(None, 0, hold=limit_hold, headroom=headroom),
10254
10676
  SessionLiveness(None),
10255
10677
  )
10256
10678
  sweep = self._reap_sessions(sessions)
@@ -10295,10 +10717,60 @@ class DevWatcher:
10295
10717
  )
10296
10718
  return (
10297
10719
  sweep,
10298
- SessionBudget(limit, active, hold=sweep.hold, probe=probe, limited=limited),
10720
+ SessionBudget(
10721
+ limit, active, hold=sweep.hold, probe=probe, limited=limited,
10722
+ headroom=headroom,
10723
+ ),
10299
10724
  SessionLiveness(sessions, gone),
10300
10725
  )
10301
10726
 
10727
+ def _read_headroom(self) -> "str | None":
10728
+ """The pass's headroom deferral reason, or None when the pass may
10729
+ spawn (issue #162). Logs the breach at WARNING (the operator's signal
10730
+ that the container, not the session cap, is what holds the fleet),
10731
+ and an unreadable guard ONCE per process -- that guard is off."""
10732
+ reading = read_headroom(
10733
+ self.config.spawn_headroom_threads,
10734
+ self.config.spawn_headroom_memory_pct,
10735
+ )
10736
+ for guard in reading.unreadable:
10737
+ if guard in self._headroom_warned:
10738
+ continue
10739
+ self._headroom_warned.add(guard)
10740
+ log.warning(
10741
+ "headroom: the cgroup v2 %s files under %s cannot be read — "
10742
+ "the %s headroom guard is OFF (spawns are not held on it)",
10743
+ guard, CGROUP_ROOT,
10744
+ "spawn_headroom_threads" if guard == "threads"
10745
+ else "spawn_headroom_memory_pct",
10746
+ )
10747
+ if reading.reason:
10748
+ log.warning(
10749
+ "%s: HOLDING new worker spawns this pass (resumes and fix "
10750
+ "rounds included); live sessions keep running and the next "
10751
+ "poll re-reads the cgroup", reading.reason,
10752
+ )
10753
+ return reading.reason
10754
+
10755
+ def _warn_start_failures(self) -> None:
10756
+ """ONE warning per poll for every command that could not even start
10757
+ (issue #162): the fork or exec failed -- EAGAIN at the cgroup's
10758
+ pids.max on 2026-10-13 -- and `proc.run` raised it as the
10759
+ CommandError its caller already handles. Names the errno(s) and the
10760
+ thread budget when the cgroup reads."""
10761
+ failures = proc_mod.take_start_failures()
10762
+ if not failures:
10763
+ return
10764
+ names = ", ".join(sorted(Counter(failures)))
10765
+ threads = cgroup_threads()
10766
+ log.warning(
10767
+ "%d command(s) could not start this poll (%s)%s — the container "
10768
+ "is likely at its process/thread or memory limit; the poll's "
10769
+ "failure paths handled each one",
10770
+ len(failures), names,
10771
+ f"; cgroup {threads}" if threads else "",
10772
+ )
10773
+
10302
10774
  def _check_session_cap(self, active: int, sweep: Sweep) -> None:
10303
10775
  """Page-worthy log when the machine is STILL over `max_sessions` after
10304
10776
  the sweep.
@@ -11259,7 +11731,7 @@ class DevWatcher:
11259
11731
  "poll summary: %d candidate(s) — %d spawned, %d in-flight, "
11260
11732
  "%d skipped, %d escalated, %d capped, %d deferred; "
11261
11733
  "%d worker session(s) reaped, %d foreign; %d parked on a prompt, "
11262
- "%d answered%s; vitals: %s",
11734
+ "%d answered%s%s; vitals: %s",
11263
11735
  len(results),
11264
11736
  counts[Action.SPAWNED],
11265
11737
  counts[Action.IN_FLIGHT],
@@ -11272,6 +11744,7 @@ class DevWatcher:
11272
11744
  len(sweep.waiting),
11273
11745
  sweep.answered,
11274
11746
  " (spawns HELD)" if sweep.hold else "",
11747
+ f"; deferred: {budget.headroom}" if budget.headroom else "",
11275
11748
  vitals,
11276
11749
  )
11277
11750
  return results
@@ -11701,6 +12174,11 @@ class DevWatcher:
11701
12174
  "not a GitHub or auth failure; retrying in %ds",
11702
12175
  exc, backoff,
11703
12176
  )
12177
+ finally:
12178
+ # After whichever branch ran, before the sleep: a fork
12179
+ # failure is usually caught deep in the pass, so a
12180
+ # "successful" poll is where it most often surfaces.
12181
+ self._warn_start_failures()
11704
12182
  time.sleep(backoff)
11705
12183
  except KeyboardInterrupt:
11706
12184
  log.info("stopping")
@@ -3,6 +3,7 @@ their own auth handling; we never touch tokens ourselves."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import errno
6
7
  import json
7
8
  import logging
8
9
  import os
@@ -20,6 +21,29 @@ class CommandError(RuntimeError):
20
21
  super().__init__(f"{argv[0]} exited {returncode}: {stderr.strip()[:400]}")
21
22
 
22
23
 
24
+ # The errno names of every command that could not even START (fork/exec
25
+ # failed) since the last take_start_failures(). Issue #162: at the container's
26
+ # cgroup pids.max a fork raises BlockingIOError (EAGAIN), which run() now
27
+ # turns into a CommandError so each caller's existing failure path handles it;
28
+ # the daemon loop drains this list once per poll to log ONE warning naming
29
+ # the errno and the thread budget, instead of one per failed command.
30
+ _start_failures: "list[str]" = []
31
+
32
+
33
+ def _errno_name(exc: OSError) -> str:
34
+ if exc.errno is None:
35
+ return type(exc).__name__
36
+ return errno.errorcode.get(exc.errno, str(exc.errno))
37
+
38
+
39
+ def take_start_failures() -> "list[str]":
40
+ """The errno names of the commands that failed to start since the last
41
+ call, oldest first; clears the list."""
42
+ taken = list(_start_failures)
43
+ _start_failures.clear()
44
+ return taken
45
+
46
+
23
47
  def run(
24
48
  argv: Sequence[str],
25
49
  *,
@@ -39,6 +63,17 @@ def run(
39
63
  )
40
64
  except subprocess.TimeoutExpired as exc:
41
65
  raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
66
+ except OSError as exc:
67
+ # The command never ran: the fork or the exec failed (EAGAIN at the
68
+ # cgroup's pids.max, ENOMEM, a missing binary). Raised as the
69
+ # CommandError every caller already handles -- the 2026-10-13 outage
70
+ # was a BlockingIOError escaping run_forever, which only catches
71
+ # CommandError, and taking the whole daemon down with it.
72
+ name = _errno_name(exc)
73
+ _start_failures.append(name)
74
+ raise CommandError(
75
+ argv, -1, f"could not start {argv[0]}: {name}: {exc.strerror or exc}"
76
+ ) from exc
42
77
 
43
78
  if check and proc.returncode != 0:
44
79
  raise CommandError(argv, proc.returncode, proc.stderr)
@@ -1 +1 @@
1
- 0.8.40
1
+ 0.8.42
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.40
3
+ Version: 0.8.42
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -148,6 +148,52 @@ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
148
148
  closed between search and spawn", nothing is claimed, and the reserved slot
149
149
  is handed back. The respawn-with-resume on an open draft PR is unchanged.
150
150
 
151
+ ## The discovery round (issue #164)
152
+
153
+ A discovery issue is a no-code lane in the fleet's **plans repository**
154
+ (design `alissa-build-discovery.md` §3.2, lane DS-7a). Studio's `feed_plan`
155
+ renders it with the trailers `Alissa-Kind: discovery`, `Alissa-Reads:
156
+ <owner>/<repo>@<40-hex sha>` (the code repository it reads, at a pinned SHA)
157
+ and `Autonomous-Scope: knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/**`. The
158
+ issue edge spawns the ordinary develop session — same name, same caps, same
159
+ liveness rules — with `DISCOVERY_DIRECTIVE` (in `loop.py`, byte-pinned in
160
+ `test_discovery.py`) instead of `DEV_DIRECTIVE`. Every other issue gets
161
+ today's directive byte for byte; a resume of a dead discovery session keeps
162
+ `RESUME_DIRECTIVE`.
163
+
164
+ The directive has the worker:
165
+
166
+ 1. create its `TASK-<n>-<DESC>` branch in the plans repository, the branch
167
+ grammar unchanged;
168
+ 2. read the code at the pinned SHA, read-only — a `git -C <hub> worktree add
169
+ --detach <scratch> <sha>` when the workspace has a hub of the read
170
+ repository (no branch, removed at the end), otherwise `gh api
171
+ repos/<repo>/contents/<path>?ref=<sha>` and `gh search code`; never a
172
+ clone, never a commit or push there. The read is bounded by the SHA, not a
173
+ file count; the output by the findings schema's limits;
174
+ 3. write exactly one directory, `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/`,
175
+ holding only `findings.md` (findings schema v1, `schema/findings.v1.json`
176
+ in the plans repository);
177
+ 4. run `node lint/plan-lint.ts --kb <dir>` locally with its own
178
+ `ALISSA_API_TOKEN` (K6/K7) and never push findings it refuses;
179
+ 5. open a ready pull request with `Closes #<n>`, both task refs and the
180
+ marker `<!-- alissa-discovery v1 -->` on a line of its own, attach a
181
+ `Findings` evidence item, move its task to `pending_validation`, request
182
+ **no** reviewer (revloop skips such PRs; orcloop merges on the lint of
183
+ record) and end its session.
184
+
185
+ **The push guard.** Before it enqueues a discovery session (fresh or
186
+ resumed), the daemon writes `${ALISSA_DISCOVERY_DIR:-/workspace/.discovery}/
187
+ ali-<session>.json` naming the plans repository; a marker that cannot be
188
+ written stands the spawn down for the pass. The container's shell guard
189
+ (`docker/claude/hooks/guard-shell.py`) finds the marker through
190
+ `$ALISSA_TMUX_SESSION` and refuses, with a PreToolUse deny and a reason, any
191
+ `git push` from that session whose destination is not the plans repository —
192
+ resolved from the remote name or URL in the directory the push runs in
193
+ (`-C`, `cd`), and refused outright when it cannot be resolved. Sessions with
194
+ no marker are untouched. `ALISSA_SHELL_GUARD=off` disables this guard with
195
+ the rest.
196
+
151
197
  ## License
152
198
 
153
199
  Licensed under the
@@ -3,18 +3,18 @@ alissa/tools/github/devloop/__main__.py,sha256=5p_SjwpOvdyTA8pPr54gS-RP99itkydVc
3
3
  alissa/tools/github/devloop/alissa.py,sha256=TJU4eSZc2MvP35HewCwBKvhPAel32vKjtt3ApSPDLrM,18064
4
4
  alissa/tools/github/devloop/alissa_client.py,sha256=wmpXYWf9ThcLeYnz6gGeDz7gj5BpYjduuVASMc-QOOY,14873
5
5
  alissa/tools/github/devloop/bows.py,sha256=pzDNMRk_S78FX7vMNYMlXHxW6VPHPo1Xd2EzFMgVCUM,17325
6
- alissa/tools/github/devloop/config.py,sha256=VKeyTsXfoRSDltuU1h9UqjrHGE6YvgCQDvimDt3BH58,73330
6
+ alissa/tools/github/devloop/config.py,sha256=j6W0Wrz7smFdFdSs3g5S6xadcxfFS5YEhQDL64yAuJk,75109
7
7
  alissa/tools/github/devloop/fleet_vitals.py,sha256=Hl2xshweh7A9R0N7fyYRg2WzHN4aNefVlt0TiXCv8zk,24979
8
8
  alissa/tools/github/devloop/ghclient.py,sha256=tYUHwmoy9CBBt-jSOz9nuAl8lpMvm1LH4A-wHx25bss,71842
9
9
  alissa/tools/github/devloop/hubs.py,sha256=yjnsnSHLG7GNNP8X2hILeAMV6P5n4JGc_IaAxpSP_YQ,18974
10
10
  alissa/tools/github/devloop/janitor.py,sha256=R8REoHBeY2cfW7fYJVhKr-F5z9Mjlc-vmv-z4KtvBZc,27360
11
- alissa/tools/github/devloop/loop.py,sha256=A28TYgoz4l0MxOIJ5ypc1fo4A8KPKzjMWsPDv5jZIgs,590661
11
+ alissa/tools/github/devloop/loop.py,sha256=uMfuomSdIPC8KkWUGF0OrW3RR9OSC1txKVnXGZ1SeIc,613973
12
12
  alissa/tools/github/devloop/loop_events.py,sha256=tmso0OALqY2yAPW2Fo6ae0sYetQIdIeIYooJ2Sc2cc4,37921
13
13
  alissa/tools/github/devloop/parking.py,sha256=2Sys1bh_hG8qbq7cO9RxW1F1kAu3dCWHnvwg_iAFVvY,7987
14
- alissa/tools/github/devloop/proc.py,sha256=_dDr3g76WEf2DRyLUz1Dzl4KvLVptJant_UZOrRixNk,1649
14
+ alissa/tools/github/devloop/proc.py,sha256=nzfsxJSHFWRQcrXmfv588KazFysv8dVQrhMPH1q5eRk,3134
15
15
  alissa/tools/github/devloop/prompts.py,sha256=VzN6ec5eF2wwg7QSCRg52WLcxo-tGbGRgQbc4HSahUc,55169
16
16
  alissa/tools/github/devloop/state.py,sha256=uVwhbkogUTjWXXFq-COKzUeYrpuGbHzi05CjfEzAtPA,88668
17
- alissa/tools/github/devloop/version,sha256=Iu0zRmy7L74fnLuqsTvu96vsUQo8I7iy8DwL-jhPqXs,7
17
+ alissa/tools/github/devloop/version,sha256=AuempWUg4vcEdSLs24Raav8UvlQqlRrI2s1oyudhK4I,7
18
18
  alissa/tools/github/devloop/version.py,sha256=znuuOaitXGrBpbskNy78_rlVpFdtmKmk_3k1ACxZrl0,1418
19
19
  alissa/tools/github/devloop/webui/__init__.py,sha256=vdXYoyGYzD7BrJ5lV6XdH8Bpw_pej8-RK0hjfxop7-8,1513
20
20
  alissa/tools/github/devloop/webui/__main__.py,sha256=wORyW5AkgsniK0TZcrOrvm_P7Q4Mr-fH-4S6qj9XcgA,6137
@@ -23,10 +23,10 @@ alissa/tools/github/devloop/webui/page.py,sha256=4OD3e01ByvVM4Wo04AZyFuyOYTFEMT2
23
23
  alissa/tools/github/devloop/webui/server.py,sha256=YuUmcng5qdWqSurcuyxH3FKKkBBfrpDtvtZLidJOGiU,26226
24
24
  alissa/tools/github/devloop/webui/sources.py,sha256=Ud8357-Ag4qC39V3mlepqnArui9p1fTJ058bAdN29Jc,36730
25
25
  alissa/tools/github/devloop/webui/sysinfo.py,sha256=ptlogH9alFv0vZ-pQfyUaA3w-K7LQS3tQHGv0d8Tdlk,9649
26
- alissa_tools_github_devloop-0.8.40.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
27
- alissa_tools_github_devloop-0.8.40.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
28
- alissa_tools_github_devloop-0.8.40.dist-info/METADATA,sha256=uSiJc6s7Y4u9uz8MTj1oETKuZVJfCtE_NmMSYsvkKyE,8093
29
- alissa_tools_github_devloop-0.8.40.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
- alissa_tools_github_devloop-0.8.40.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
31
- alissa_tools_github_devloop-0.8.40.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
32
- alissa_tools_github_devloop-0.8.40.dist-info/RECORD,,
26
+ alissa_tools_github_devloop-0.8.42.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
27
+ alissa_tools_github_devloop-0.8.42.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
28
+ alissa_tools_github_devloop-0.8.42.dist-info/METADATA,sha256=ZITb9-7j5ZOmFkpcc4wmIz6DRT7_1HygSpTD7sHLp50,10759
29
+ alissa_tools_github_devloop-0.8.42.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
+ alissa_tools_github_devloop-0.8.42.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
31
+ alissa_tools_github_devloop-0.8.42.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
32
+ alissa_tools_github_devloop-0.8.42.dist-info/RECORD,,