alissa-tools-github-devloop 0.8.12__tar.gz → 0.8.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {alissa_tools_github_devloop-0.8.12/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.14}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/__main__.py +28 -0
  3. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/alissa_client.py +45 -9
  4. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/config.py +91 -0
  5. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/janitor.py +79 -0
  6. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/loop.py +480 -35
  7. alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/loop_events.py +532 -0
  8. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/state.py +116 -0
  9. alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/version +1 -0
  10. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/page.py +23 -2
  11. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/sources.py +36 -3
  12. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  13. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  14. alissa_tools_github_devloop-0.8.12/src/main/alissa/tools/github/devloop/version +0 -1
  15. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/LICENSE +0 -0
  16. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/MANIFEST.in +0 -0
  17. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/NOTICE +0 -0
  18. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/README.md +0 -0
  19. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/requirements.txt +0 -0
  20. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/setup.cfg +0 -0
  21. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/setup.py +0 -0
  22. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  23. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  24. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  25. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  26. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  27. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/version.py +0 -0
  28. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  31. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  32. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  33. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  34. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  35. {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.12
3
+ Version: 0.8.14
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -282,6 +282,26 @@ def build_parser() -> argparse.ArgumentParser:
282
282
  "(two overlapping instances can then both spawn on one item)",
283
283
  )
284
284
 
285
+ loop_events = over.add_mutually_exclusive_group()
286
+ loop_events.add_argument(
287
+ "--loop-events",
288
+ dest="loop_events_enabled",
289
+ action="store_true",
290
+ default=None,
291
+ help="push one idempotent batch of loop events (spawns, defers, "
292
+ "cap-outs, stalls, hand-offs, reaps, escalations) per poll pass to "
293
+ "Studio's POST /v1/loop-events on the alissa endpoint — best-effort, "
294
+ "never fatal. ALISSA_DEV_LOOP_EVENTS_ENABLED (non-blank) overrides "
295
+ "even this flag",
296
+ )
297
+ loop_events.add_argument(
298
+ "--no-loop-events",
299
+ dest="loop_events_enabled",
300
+ action="store_false",
301
+ help="never push loop telemetry, even if the config enables it "
302
+ "(the default: the push is opt-in)",
303
+ )
304
+
285
305
  janitor = over.add_mutually_exclusive_group()
286
306
  janitor.add_argument(
287
307
  "--janitor",
@@ -386,6 +406,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
386
406
  "orphan_sweep_enabled": args.orphan_sweep_enabled,
387
407
  "orphan_grace_minutes": args.orphan_grace_minutes,
388
408
  "activity_claims_enabled": args.activity_claims_enabled,
409
+ "loop_events_enabled": args.loop_events_enabled,
389
410
  "janitor_enabled": args.janitor_enabled,
390
411
  "janitor_grace_minutes": args.janitor_grace_minutes,
391
412
  "janitor_interval_minutes": args.janitor_interval_minutes,
@@ -500,6 +521,13 @@ def log_effective_config(config: Config, login: str) -> None:
500
521
  config.cache_cap_mb,
501
522
  config.disk_warn_percent,
502
523
  )
524
+ log.info(
525
+ "loop_events=%s%s",
526
+ config.loop_events_enabled,
527
+ f" (one batch per pass → POST {config.alissa_endpoint}"
528
+ f"/v1/loop-events, best-effort)"
529
+ if config.loop_events_enabled else "",
530
+ )
503
531
  log.debug("hub_template: %s", config.hub_template)
504
532
  log.debug(
505
533
  "cache_paths: %s",
@@ -22,7 +22,12 @@ already authenticates — the same transport, endpoints and taxonomy orcloop's
22
22
  `urllib` only: the distribution ships no third-party runtime dependency, and
23
23
  this must not be the change that adds one.
24
24
 
25
- Endpoints, both verified against the live API:
25
+ Since issue #101 the adapter also carries the daemon's ONE write —
26
+ ``POST /v1/loop-events`` (`post_loop_events`), the loop-telemetry push — over
27
+ the same transport, token and taxonomy, so the daemon still reads and writes
28
+ Alissa through a single REST idiom.
29
+
30
+ Endpoints, the reads both verified against the live API:
26
31
 
27
32
  ``GET /v1/ping``
28
33
  ``{"pong", "timestamp", "userId", "actorId", "displayName"}`` — the
@@ -141,22 +146,32 @@ class AlissaClient:
141
146
  self._token = token if token is not None else os.environ.get(ENV_TOKEN)
142
147
  self._timeout = timeout
143
148
 
144
- def _request(self, path: str) -> object:
145
- """One GET. Every failure leaves as a taxonomy exception — the caller
146
- never sees a raw urllib error or an HTTP status."""
149
+ def _request(
150
+ self, path: str, *, method: str = "GET", body: object = None
151
+ ) -> object:
152
+ """One call. Every failure leaves as a taxonomy exception — the caller
153
+ never sees a raw urllib error or an HTTP status. `body`, when given,
154
+ is JSON-serialised and sent with the matching Content-Type (the shape
155
+ every write on this API takes)."""
147
156
  if not self._token:
148
157
  # No token at all is an auth condition, not a transport one: the
149
158
  # operator must set the env var. Fail the way a 401 would, so the
150
159
  # boot path treats it as permanent rather than retrying forever.
151
160
  raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
152
161
 
162
+ headers = {
163
+ "Accept": "application/json",
164
+ "Authorization": f"Bearer {self._token}",
165
+ }
166
+ data = None
167
+ if body is not None:
168
+ data = json.dumps(body).encode("utf-8")
169
+ headers["Content-Type"] = "application/json"
153
170
  req = urllib.request.Request(
154
171
  f"{self.base}{path}",
155
- method="GET",
156
- headers={
157
- "Accept": "application/json",
158
- "Authorization": f"Bearer {self._token}",
159
- },
172
+ method=method,
173
+ data=data,
174
+ headers=headers,
160
175
  )
161
176
  try:
162
177
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
@@ -226,6 +241,27 @@ class AlissaClient:
226
241
  display_name=str(row.get("displayName") or ""),
227
242
  )
228
243
 
244
+ def post_loop_events(self, events: "list[dict]") -> "dict":
245
+ """Push one batch of loop-telemetry events (issue #101).
246
+
247
+ ``POST /v1/loop-events`` (fahera-mx/studio.alissa.app, `API.md` →
248
+ "Loop telemetry — ingest"): 1–200 events per call, idempotent on the
249
+ caller's `dedupeKey` — a repeated key is a silent duplicate, never an
250
+ error — and any shape error fails the WHOLE batch with a 400 naming
251
+ the offending index. The batch bound and the event shapes are the
252
+ CALLER's contract (`loop_events.py` derives and splits); this method
253
+ is just the transport, returning the server's
254
+ ``{"accepted": n, "duplicates": m}`` verbatim.
255
+
256
+ Failures leave as the taxonomy like every other call here. The one
257
+ caller is best-effort by design — it catches AlissaError, warns once
258
+ and moves on — so nothing extra is softened at this layer.
259
+ """
260
+ payload = self._request(
261
+ "/v1/loop-events", method="POST", body={"events": list(events)}
262
+ )
263
+ return payload if isinstance(payload, dict) else {}
264
+
229
265
  def list_bodies_of_work(self) -> "list[BodyOfWork]":
230
266
  """Every Body of Work this actor owns OR collaborates on — ONE call.
231
267
 
@@ -21,6 +21,8 @@ var > `--repos-source` > the config key. A BLANK env var means "not set" here
21
21
  (unlike ALISSA_DEV_REVIEWERS, where empty is a meaningful answer): an unset
22
22
  platform variable reference renders as the empty string, and that must fall
23
23
  through to the config rather than fail the daemon's boot on a mode of `''`.
24
+ `loop_events_enabled` rides the same shape of rail through
25
+ `ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule.
24
26
  """
25
27
 
26
28
  from __future__ import annotations
@@ -64,6 +66,17 @@ FOREIGN_IGNORE = "ignore" # today's behavior: never even considered
64
66
 
65
67
  _FOREIGN_POLICIES = {FOREIGN_REAP, FOREIGN_WARN, FOREIGN_IGNORE}
66
68
 
69
+ # What the workspace janitor does about a dirty HOLDOUT -- a task workspace
70
+ # whose anchor PR is terminal but which carries uncommitted or unpushed work
71
+ # (issue #99). `warn` is the invariant as it has always been read: never
72
+ # delete unpushed work, say so once per episode. `archive` keeps the invariant
73
+ # and adds a way out: the work is committed, pushed to a NEW
74
+ # `refs/heads/holdout/<branch>` ref, verified there, and only then reclaimed.
75
+ HOLDOUT_WARN = "warn" # track and warn (once per 24 h); never touch the tree
76
+ HOLDOUT_ARCHIVE = "archive" # commit, push to holdout/<branch>, verify, reclaim
77
+
78
+ _HOLDOUT_POLICIES = {HOLDOUT_WARN, HOLDOUT_ARCHIVE}
79
+
67
80
  CONFIG_FILENAME = "devloop.config.json"
68
81
 
69
82
  # Comma-separated reviewer logins; authoritative when set, even if empty.
@@ -77,6 +90,18 @@ ENV_REPOS_SOURCE = "ALISSA_DEV_REPOS_SOURCE"
77
90
  # `alissa_endpoint` so one deployment can point both daemons at one API.
78
91
  DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
79
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
+
80
105
  # What an Alissa actor id looks like: a 32-character opaque handle of
81
106
  # lower-case letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id
82
107
  # the API mints has this shape, and `bow_owners` is checked against it so a
@@ -127,9 +152,11 @@ CONFIG_KEYS = (
127
152
  "orphan_sweep_enabled",
128
153
  "orphan_grace_minutes",
129
154
  "activity_claims_enabled",
155
+ "loop_events_enabled",
130
156
  "janitor_enabled",
131
157
  "janitor_grace_minutes",
132
158
  "janitor_interval_minutes",
159
+ "janitor_holdout_policy",
133
160
  "cache_cap_mb",
134
161
  "cache_paths",
135
162
  "disk_warn_percent",
@@ -487,6 +514,18 @@ class Config:
487
514
  # that would rather not pay the extra comments fetch.
488
515
  activity_claims_enabled: bool = True
489
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
+
490
529
  # The workspace janitor (issue #74): the filesystem sibling of the session
491
530
  # reaper. Every worker session materializes a per-task workspace under its
492
531
  # repo's hub (a `TASK-<n>-<DESC>` worktree plus whatever its lane installs
@@ -518,6 +557,21 @@ class Config:
518
557
  # so the telemetry line never goes quiet. 0 means every poll.
519
558
  janitor_interval_minutes: int = 10
520
559
 
560
+ # What the janitor does about a dirty holdout (issue #99). Default `warn`:
561
+ # the workspace is tracked in the `janitor_holdouts` ledger table, warned
562
+ # about on first sighting and at most once per 24 h after that (DEBUG in
563
+ # between), and never touched -- the "never delete unpushed work"
564
+ # invariant exactly as before, minus the ~3,000 identical WARNING lines a
565
+ # day the fleet measured. `archive` is opt-in: a holdout whose anchor is
566
+ # terminal and whose branch is a task branch has its work committed (when
567
+ # dirty), pushed to a NEW `refs/heads/holdout/<branch>` ref (never the
568
+ # task branch, never force), verified there by `ls-remote`, and only then
569
+ # reclaimed like a clean workspace. Any failure leaves the workspace in
570
+ # place -- at worst with its work committed locally, never removed -- and
571
+ # pages `janitor-archive-failed` once per workspace. The commit stages
572
+ # EVERY untracked, non-ignored file, scratch directories included.
573
+ janitor_holdout_policy: str = HOLDOUT_WARN
574
+
521
575
  # The cache bound, in MiB: package-manager caches over this are trimmed
522
576
  # oldest-entry-first back under it. 0 disables the trim entirely. A cache
523
577
  # is a legitimate speed tradeoff -- the default is generous on purpose
@@ -732,6 +786,28 @@ class Config:
732
786
  + (f" (from {ENV_REPOS_SOURCE})" if env_source else "")
733
787
  )
734
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
+
735
811
  bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
736
812
 
737
813
  refresh_polls = int(
@@ -853,6 +929,19 @@ class Config:
853
929
  f"got {janitor_interval}"
854
930
  )
855
931
 
932
+ # The holdout policy. An enum validated by NAME like
933
+ # foreign_session_policy: a typo would otherwise be "not archive",
934
+ # i.e. silently the default, for an operator who set the key to opt
935
+ # INTO a git push -- the one outcome they cannot have wanted.
936
+ holdout_policy = str(
937
+ raw.get("janitor_holdout_policy", cls.janitor_holdout_policy)
938
+ ).strip()
939
+ if holdout_policy not in _HOLDOUT_POLICIES:
940
+ raise ValueError(
941
+ f"janitor_holdout_policy must be one of "
942
+ f"{sorted(_HOLDOUT_POLICIES)}, got {holdout_policy!r}"
943
+ )
944
+
856
945
  cache_cap = int(raw.get("cache_cap_mb", cls.cache_cap_mb))
857
946
  if cache_cap < 0:
858
947
  raise ValueError(
@@ -921,9 +1010,11 @@ class Config:
921
1010
  activity_claims_enabled=bool(
922
1011
  raw.get("activity_claims_enabled", cls.activity_claims_enabled)
923
1012
  ),
1013
+ loop_events_enabled=loop_events,
924
1014
  janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
925
1015
  janitor_grace_minutes=janitor_grace,
926
1016
  janitor_interval_minutes=janitor_interval,
1017
+ janitor_holdout_policy=holdout_policy,
927
1018
  cache_cap_mb=cache_cap,
928
1019
  cache_paths=_string_list(
929
1020
  raw.get("cache_paths", cls.cache_paths), "cache_paths"
@@ -53,6 +53,18 @@ log = logging.getLogger(__name__)
53
53
  # one of ours.
54
54
  TASK_WORKSPACE_RE = re.compile(r"TASK-(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?")
55
55
 
56
+ # Where the `archive` holdout policy pushes unpushed work (issue #99): a NEW
57
+ # ref under this prefix, named after the task branch. Never the task branch
58
+ # itself -- its PR is closed, and a push there would resurrect a dead lane --
59
+ # and never `--force`, so a ref that already exists at a different sha refuses
60
+ # the push and the workspace stays. An operator recovers the work with
61
+ # `git fetch origin holdout/<branch>`.
62
+ HOLDOUT_REF_PREFIX = "refs/heads/holdout/"
63
+
64
+ # The remote the archive pushes to. The dev identity already pushes task
65
+ # branches to it; the archive needs no new permission.
66
+ HOLDOUT_REMOTE = "origin"
67
+
56
68
  # Package-manager caches, as they land in this image and every other
57
69
  # Debian-ish agent container. Used only when the operator configures no
58
70
  # `cache_paths` of their own; entries that do not exist are skipped silently,
@@ -117,6 +129,21 @@ def parse_task_workspace(path: Path, hub: Path) -> "TaskWorkspace | None":
117
129
  )
118
130
 
119
131
 
132
+ def is_task_branch(name: str) -> bool:
133
+ """Is `name` one of devloop's own task branches (`TASK-<n>-<DESC>`)?
134
+
135
+ The same full-match grammar `parse_task_workspace` admits directories
136
+ with, re-asserted by name at the one place that pushes: the archive path
137
+ writes a ref to the remote, and its name is derived from this one, so a
138
+ branch the daemon did not create must never get a `holdout/` twin."""
139
+ return TASK_WORKSPACE_RE.fullmatch(name) is not None
140
+
141
+
142
+ def holdout_ref(branch: str) -> str:
143
+ """The full ref the archive pushes `branch`'s work to."""
144
+ return f"{HOLDOUT_REF_PREFIX}{branch}"
145
+
146
+
120
147
  def scan_hub(hub: Path) -> "list[TaskWorkspace]":
121
148
  """Every task workspace directly under `hub`, sorted by name.
122
149
 
@@ -375,6 +402,58 @@ def delete_branch(common: Path, branch: str, runner: "Runner" = run) -> None:
375
402
  _git(["branch", "-D", branch], common, runner)
376
403
 
377
404
 
405
+ # -- the archive's git plumbing (issue #99) --------------------------------
406
+ #
407
+ # Three commands, each one step of the `archive` holdout policy, each doing
408
+ # exactly what its name says and nothing else: the ORDER (commit only when
409
+ # dirty, push to the holdout ref, verify by ls-remote, and only then remove)
410
+ # and every refusal live in `loop.DevWatcher._archive_holdout`. All three
411
+ # raise CommandError on failure, which the caller turns into "the workspace
412
+ # stays" -- nothing here decides.
413
+
414
+
415
+ def commit_all(path: Path, message: str, runner: "Runner" = run) -> None:
416
+ """`git add -A && git commit --allow-empty -m <message>` in `path`.
417
+
418
+ `-A` stages every uncommitted change `dirty_entries` reported, untracked
419
+ files included, because that is precisely the work the holdout exists to
420
+ keep; ignored build output stays ignored. That is BROADER than "the
421
+ work": a `.harness/` or `.fu…prep/` scratch directory a session left
422
+ behind is untracked and not ignored, so it goes into the commit and onto
423
+ the remote ref too. The caller logs the entry count before this runs and
424
+ the docs say so -- an operator opting into `archive` opts into that. `--allow-empty` so a status that
425
+ went clean between the check and the commit still yields a commit that
426
+ carries the message -- the archive's provenance line on the ref."""
427
+ _git(["add", "-A"], path, runner)
428
+ _git(["commit", "--allow-empty", "-m", message], path, runner)
429
+
430
+
431
+ def push_ref(path: Path, ref: str, runner: "Runner" = run) -> None:
432
+ """`git push origin HEAD:<ref>` from `path`. Deliberately WITHOUT
433
+ `--force`, ever: a ref that already exists at another sha rejects the
434
+ push, and the caller keeps the workspace. `ref` is a full ref name
435
+ (`refs/heads/holdout/<branch>`), so git cannot resolve it to anything but
436
+ a branch under `holdout/`."""
437
+ _git(["push", HOLDOUT_REMOTE, f"HEAD:{ref}"], path, runner)
438
+
439
+
440
+ def remote_ref_sha(path: Path, ref: str, runner: "Runner" = run) -> "str | None":
441
+ """The sha `ref` points at on the remote, per `git ls-remote origin
442
+ <ref>`, or None when the remote has no such ref.
443
+
444
+ THE verification step: the archive deletes only when this equals the
445
+ local HEAD. A remote that cannot be asked raises (the caller keeps the
446
+ workspace and says why); a remote that answers "no such ref" is the same
447
+ verdict spelled quietly, hence None rather than a raise -- the caller
448
+ compares it to a real sha and refuses."""
449
+ out = _git(["ls-remote", HOLDOUT_REMOTE, ref], path, runner)
450
+ for line in out.splitlines():
451
+ sha, _, name = line.strip().partition("\t")
452
+ if name == ref and sha:
453
+ return sha
454
+ return None
455
+
456
+
378
457
  def remove_tree(path: Path) -> None:
379
458
  """Delete a standalone checkout's directory outright.
380
459