claude-dev-env 8.83.0 → 8.83.2

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 (37) hide show
  1. package/docs/account-broker.md +1 -1
  2. package/docs/rule-guides/agent-board.md +27 -0
  3. package/docs/rule-guides/features-start-with-an-eval.md +4 -2
  4. package/docs/rule-guides/proof-before-pull-request.md +1 -1
  5. package/hooks/blocking/build_eval_before_eval_write_gate.py +102 -0
  6. package/hooks/blocking/pr_lifecycle_skill_gate.py +42 -16
  7. package/hooks/blocking/pull_request_proof.py +111 -2
  8. package/hooks/blocking/reply_length_gate.py +69 -13
  9. package/hooks/blocking/test_build_eval_before_eval_write_gate.py +162 -0
  10. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +25 -0
  11. package/hooks/blocking/test_pull_request_proof.py +115 -0
  12. package/hooks/blocking/test_reply_length_gate.py +98 -10
  13. package/hooks/context_budget/tests/test_model.py +65 -0
  14. package/hooks/features/conduct-gates.md +4 -0
  15. package/hooks/hooks.json +10 -0
  16. package/hooks/hooks_constants/build_eval_before_eval_write_gate_constants.py +19 -0
  17. package/hooks/hooks_constants/pull_request_proof_constants.py +38 -0
  18. package/hooks/hooks_constants/reply_length_gate_constants.py +14 -0
  19. package/hooks/hooks_constants/test_reply_length_gate_constants.py +8 -0
  20. package/hooks/test_transcript_skill_scan.py +6 -2
  21. package/hooks/transcript_skill_scan.py +18 -7
  22. package/package.json +1 -1
  23. package/rules/agent-board.md +9 -0
  24. package/rules/index.md +1 -1
  25. package/scripts/Sync-RepoMain.ps1 +30 -1
  26. package/scripts/account_broker.py +1 -0
  27. package/scripts/account_broker_support.py +71 -24
  28. package/scripts/dev_env_scripts_constants/account_broker_constants.py +11 -3
  29. package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +15 -0
  30. package/scripts/policy_lint/adapter_configuration.py +5 -0
  31. package/scripts/policy_lint/config/constants.py +1 -0
  32. package/scripts/test_account_broker.py +19 -1
  33. package/scripts/test_account_broker_support.py +161 -3
  34. package/scripts/tests/Sync-RepoMain.Tests.ps1 +25 -0
  35. package/scripts/tests/test_adapter_configuration.py +8 -0
  36. package/scripts/tests/test_rule_load_scopes.py +1 -0
  37. package/scripts/tests/test_sync_repo_main_run_lock.py +168 -0
@@ -7,7 +7,7 @@ import shutil
7
7
  import subprocess
8
8
  import sys
9
9
  import time
10
- from datetime import datetime, timezone
10
+ from datetime import datetime, timedelta, timezone
11
11
  from collections.abc import Callable
12
12
  from pathlib import Path
13
13
  from types import SimpleNamespace
@@ -341,10 +341,11 @@ def test_should_cap_the_meter_read_delay(monkeypatch: pytest.MonkeyPatch, tmp_pa
341
341
  all_failures = [support.WeeklyUtilizationProbeError("down") for _ in range(6)]
342
342
  _claude_meter_reads(monkeypatch, all_failures)
343
343
 
344
- meters, unread_reason = support.read_meters_with_backoff(support.read_claude_meters, Account(Product.CLAUDE, "one", tmp_path))
344
+ meters, unread_reason, is_rate_limited = support.read_meters_with_backoff(support.read_claude_meters, Account(Product.CLAUDE, "one", tmp_path))
345
345
 
346
346
  assert meters is None
347
347
  assert unread_reason == "down"
348
+ assert not is_rate_limited
348
349
  assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
349
350
 
350
351
 
@@ -356,10 +357,11 @@ def test_should_read_a_signed_out_claude_account_once(monkeypatch: pytest.Monkey
356
357
  all_reads.append(account)
357
358
  raise support.ClaudeSignedOutError("no usable bearer token")
358
359
 
359
- meters, unread_reason = support.read_meters_with_backoff(signed_out_reader, Account(Product.CLAUDE, "main", tmp_path))
360
+ meters, unread_reason, is_rate_limited = support.read_meters_with_backoff(signed_out_reader, Account(Product.CLAUDE, "main", tmp_path))
360
361
 
361
362
  assert meters is None
362
363
  assert unread_reason == "no usable bearer token"
364
+ assert not is_rate_limited
363
365
  assert len(all_reads) == 1
364
366
  assert all_delays == []
365
367
 
@@ -385,6 +387,162 @@ def test_should_cache_account_reading_under_injected_state_path(
385
387
  assert support.broker_state_path().is_file()
386
388
 
387
389
 
390
+ def _read_twice(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, all_results: list[object], gap: timedelta) -> object:
391
+ monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
392
+ _recorded_sleeps(monkeypatch)
393
+ account = Account(Product.CLAUDE, "second", tmp_path)
394
+ pending = list(all_results)
395
+
396
+ def reader(_selected: Account) -> Meters:
397
+ next_outcome = pending.pop(0)
398
+ if isinstance(next_outcome, Exception):
399
+ raise next_outcome
400
+ return next_outcome
401
+
402
+ adapter = ProductAdapter(lambda: (account,), reader, "CLAUDE_CONFIG_DIR", ())
403
+ state = support._load_state(support.broker_state_path())
404
+ first_read_at = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
405
+ support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first_read_at)
406
+ return support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first_read_at + gap)[0]
407
+
408
+
409
+ def test_should_keep_the_last_good_meters_when_the_usage_endpoint_rate_limits(
410
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
411
+ ) -> None:
412
+ rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429: Too Many Requests")
413
+ reading = _read_twice(monkeypatch, tmp_path, [Meters(74.0, None, 81.0, None), rate_limited], timedelta(minutes=5))
414
+
415
+ assert reading.meters == Meters(74.0, None, 81.0, None)
416
+ assert reading.unread_reason == str(rate_limited)
417
+ assert reading.measured_at == datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
418
+
419
+
420
+ def test_should_drop_last_good_meters_older_than_the_fallback_window(
421
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
422
+ ) -> None:
423
+ rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429: Too Many Requests")
424
+ reading = _read_twice(monkeypatch, tmp_path, [Meters(74.0, None, 81.0, None), rate_limited], timedelta(hours=2))
425
+
426
+ assert reading.meters is None
427
+ assert reading.unread_reason == "usage probe failed: HTTP Error 429: Too Many Requests"
428
+
429
+
430
+ def test_should_keep_last_good_meters_at_the_fallback_boundary(
431
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
432
+ ) -> None:
433
+ rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429")
434
+ reading = _read_twice(
435
+ monkeypatch, tmp_path,
436
+ [Meters(74.0, None, 81.0, None), rate_limited], timedelta(minutes=30),
437
+ )
438
+
439
+ assert reading.meters == Meters(74.0, None, 81.0, None)
440
+
441
+
442
+ def test_should_drop_last_good_meters_after_a_window_reset(
443
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
444
+ ) -> None:
445
+ first_read = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
446
+ rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429")
447
+ reading = _read_twice(
448
+ monkeypatch, tmp_path,
449
+ [Meters(3.0, first_read + timedelta(minutes=10), 81.0, None), rate_limited],
450
+ timedelta(minutes=25),
451
+ )
452
+
453
+ assert reading.meters is None
454
+ assert reading.unread_reason == str(rate_limited)
455
+
456
+
457
+ def test_should_drop_held_meters_when_a_window_resets(
458
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
459
+ ) -> None:
460
+ monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
461
+ account = Account(Product.CLAUDE, "second", tmp_path)
462
+ start = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
463
+ state = support._load_state(support.broker_state_path())
464
+ state["meters"][support._state_key(account)] = {
465
+ "read_at": start.timestamp(),
466
+ "measured_at": (start - timedelta(minutes=5)).timestamp(),
467
+ "meters": support._meter_payload(Meters(3.0, start + timedelta(minutes=1), 81.0, None)),
468
+ "unread_reason": "usage probe failed: HTTP Error 429",
469
+ "retry_at": (start + timedelta(minutes=5)).timestamp(),
470
+ }
471
+ adapter = ProductAdapter(lambda: (account,), lambda _: pytest.fail("probe during cooldown"), "CLAUDE_CONFIG_DIR", ())
472
+
473
+ reading = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=start + timedelta(minutes=2))[0]
474
+
475
+ assert reading.meters is None
476
+ assert reading.retry_at == start + timedelta(minutes=5)
477
+
478
+
479
+ def test_should_reject_a_last_good_entry_with_an_unrelated_failure() -> None:
480
+ now = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
481
+ entry = {
482
+ "measured_at": now.timestamp(),
483
+ "meters": support._meter_payload(Meters(74.0, None, 81.0, None)),
484
+ "unread_reason": "credentials file missing",
485
+ }
486
+
487
+ assert support._last_good_meters(entry, now) is None
488
+
489
+
490
+ def test_should_not_use_last_good_meters_after_a_file_failure(
491
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
492
+ ) -> None:
493
+ reading = _read_twice(
494
+ monkeypatch, tmp_path,
495
+ [Meters(74.0, None, 81.0, None), *[OSError("credentials file missing") for _ in range(3)]],
496
+ timedelta(minutes=5),
497
+ )
498
+
499
+ assert reading.meters is None
500
+ assert reading.unread_reason == "credentials file missing"
501
+
502
+
503
+ def test_should_keep_first_measurement_time_across_rate_limit_fallbacks(
504
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
505
+ ) -> None:
506
+ monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
507
+ _recorded_sleeps(monkeypatch)
508
+ account = Account(Product.CLAUDE, "second", tmp_path)
509
+ rate_limited = support.WeeklyUtilizationProbeError("usage probe failed: HTTP Error 429")
510
+ pending = [Meters(74.0, None, 81.0, None), rate_limited, rate_limited]
511
+ calls = []
512
+
513
+ def reader(_selected: Account) -> Meters:
514
+ calls.append(1)
515
+ outcome = pending.pop(0)
516
+ if isinstance(outcome, Exception):
517
+ raise outcome
518
+ return outcome
519
+
520
+ adapter = ProductAdapter(lambda: (account,), reader, "CLAUDE_CONFIG_DIR", ())
521
+ state = support._load_state(support.broker_state_path())
522
+ first = datetime(2026, 10, 11, 19, 20, tzinfo=timezone.utc)
523
+ support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first)
524
+ second = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first + timedelta(minutes=5))[0]
525
+ held = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first + timedelta(minutes=6))[0]
526
+ third = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=first + timedelta(minutes=10))[0]
527
+
528
+ assert second.measured_at == first
529
+ assert held.measured_at == first
530
+ assert third.measured_at == first
531
+ assert len(calls) == 3
532
+
533
+
534
+ def test_should_keep_newer_measurement_when_stale_fallback_saves_last(tmp_path: Path) -> None:
535
+ state_path = tmp_path / "broker" / "state.json"
536
+ account = Account(Product.CLAUDE, "second", tmp_path)
537
+ key = support._state_key(account)
538
+ old = {"meters": {key: {"read_at": 40.0, "measured_at": 10.0, "meters": support._meter_payload(Meters(74.0, None, 81.0, None)), "unread_reason": "HTTP Error 429"}}, "spent": {}, "affinity": {}}
539
+ newer = {"meters": {key: {"read_at": 30.0, "measured_at": 30.0, "meters": support._meter_payload(Meters(5.0, None, 81.0, None)), "unread_reason": None}}, "spent": {}, "affinity": {}}
540
+ support._save_state(state_path, newer)
541
+ support._save_state(state_path, old)
542
+
543
+ assert support._load_state(state_path)["meters"][key]["meters"]["session_percent_left"] == 5.0
544
+
545
+
388
546
  def test_should_extract_session_id_and_restore_runner() -> None:
389
547
  original = support.subprocess_runner
390
548
 
@@ -105,4 +105,29 @@ Describe 'Sync-RepoMain' {
105
105
  [System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'added.txt')) | Should -Be "local only`n"
106
106
  [System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'tracked.txt')) | Should -Be "new`n"
107
107
  }
108
+
109
+ It 'skips without fetching while another sync of the same repo holds the run lock' {
110
+ $fixture = New-MirrorBehindRemote -Name 'lock-held'
111
+ $lockPath = [System.IO.Path]::TrimEndingDirectorySeparator([System.IO.Path]::GetFullPath($fixture.MirrorPath))
112
+ $pathBytes = [System.Text.Encoding]::UTF8.GetBytes($lockPath.ToLowerInvariant())
113
+ $lockName = 'Global\SyncRepoMain-' + [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($pathBytes))
114
+ $readyPath = Join-Path $TestDrive 'lock-ready'
115
+ $holderCommand = "`$lock = [System.Threading.Mutex]::new(`$true, '$lockName'); Set-Content -LiteralPath '$readyPath' -Value ready; Start-Sleep -Seconds 30"
116
+ $holder = Start-Process pwsh -ArgumentList '-NoProfile', '-Command', $holderCommand -PassThru
117
+ try {
118
+ $deadline = (Get-Date).AddSeconds(20)
119
+ while (-not (Test-Path -LiteralPath $readyPath) -and (Get-Date) -lt $deadline) {
120
+ Start-Sleep -Milliseconds 200
121
+ }
122
+
123
+ $exitCode = Invoke-SyncScript -Fixture $fixture
124
+ }
125
+ finally {
126
+ Stop-Process -Id $holder.Id -Force -ErrorAction SilentlyContinue
127
+ }
128
+
129
+ $exitCode | Should -Be 3
130
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $fixture.OldTip
131
+ (Get-Content -LiteralPath $fixture.LogPath -Raw) | Should -Match 'still running'
132
+ }
108
133
  }
@@ -130,6 +130,14 @@ def test_new_artifact_page_gate_registration_is_clean(tmp_path: Path) -> None:
130
130
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
131
131
 
132
132
 
133
+ def test_new_eval_write_gate_registration_is_clean(tmp_path: Path) -> None:
134
+ current_document = _hook_document(
135
+ ["hooks/blocking/build_eval_before_eval_write_gate.py"],
136
+ '{"hooks": {}}',
137
+ )
138
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
139
+
140
+
133
141
  def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
134
142
  current_document = _hook_document(
135
143
  ["hooks/blocking/new.py"],
@@ -8,6 +8,7 @@ SKILLS_DIRECTORY = PACKAGE_ROOT / ".agents" / "skills"
8
8
 
9
9
  ALWAYS_ON_RULE_NAMES = frozenset(
10
10
  {
11
+ "agent-board.md",
11
12
  "correction-lens.md",
12
13
  "index.md",
13
14
  "question-presentation.md",
@@ -0,0 +1,168 @@
1
+ """Specifications for Sync-RepoMain skipping while another run of the same repo is live."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+
12
+ _SCRIPT_PATH = Path(__file__).resolve().parents[1] / "Sync-RepoMain.ps1"
13
+ _PWSH = shutil.which("pwsh") or "pwsh"
14
+ _SKIPPED_EXIT_CODE = 3
15
+ _SUCCESS_EXIT_CODE = 0
16
+ _LOCK_READY_TIMEOUT_SECONDS = 20
17
+
18
+
19
+ def _git(repository_path: Path, *arguments: str) -> str:
20
+ completed = subprocess.run(
21
+ [
22
+ "git",
23
+ "-C",
24
+ str(repository_path),
25
+ "-c",
26
+ "user.name=tester",
27
+ "-c",
28
+ "user.email=tester@example.com",
29
+ *arguments,
30
+ ],
31
+ check=True,
32
+ capture_output=True,
33
+ text=True,
34
+ )
35
+ return completed.stdout.strip()
36
+
37
+
38
+ def _mirror_one_commit_behind(root_path: Path) -> Path:
39
+ remote_path = root_path / "remote.git"
40
+ author_path = root_path / "author"
41
+ mirror_path = root_path / "mirror"
42
+ remote_path.mkdir()
43
+ author_path.mkdir()
44
+ _git(remote_path, "init", "-q", "--bare", "-b", "main")
45
+ _git(author_path, "init", "-q", "-b", "main")
46
+ (author_path / "tracked.txt").write_text("old\n", encoding="utf-8")
47
+ _git(author_path, "add", "-A")
48
+ _git(author_path, "commit", "-q", "-m", "first")
49
+ _git(author_path, "remote", "add", "origin", str(remote_path))
50
+ _git(author_path, "push", "-q", "origin", "main")
51
+ subprocess.run(
52
+ ["git", "clone", "-q", str(remote_path), str(mirror_path)], check=True
53
+ )
54
+ (author_path / "tracked.txt").write_text("new\n", encoding="utf-8")
55
+ _git(author_path, "commit", "-q", "-am", "second")
56
+ _git(author_path, "push", "-q", "origin", "main")
57
+ return mirror_path
58
+
59
+
60
+ def _run_lock_name(mirror_path: Path) -> str:
61
+ normalized_path = os.path.normpath(str(mirror_path)).lower()
62
+ path_digest = hashlib.sha256(normalized_path.encode("utf-8")).hexdigest().upper()
63
+ return f"Global\\SyncRepoMain-{path_digest}"
64
+
65
+
66
+ def _run_sync(mirror_path: Path, log_path: Path, repo_path: str | None = None) -> int:
67
+ argument_path = str(mirror_path) if repo_path is None else repo_path
68
+ completed = subprocess.run(
69
+ [
70
+ _PWSH,
71
+ "-NoProfile",
72
+ "-File",
73
+ str(_SCRIPT_PATH),
74
+ "-RepoPath",
75
+ argument_path,
76
+ "-LogPath",
77
+ str(log_path),
78
+ ],
79
+ capture_output=True,
80
+ text=True,
81
+ )
82
+ return completed.returncode
83
+
84
+
85
+ def test_should_skip_without_fetching_while_another_run_holds_the_lock(
86
+ tmp_path: Path,
87
+ ) -> None:
88
+ mirror_path = _mirror_one_commit_behind(tmp_path)
89
+ old_tip = _git(mirror_path, "rev-parse", "HEAD")
90
+ log_path = tmp_path / "sync.log"
91
+ ready_path = tmp_path / "lock-ready"
92
+ holder_command = (
93
+ f"$lock = [System.Threading.Mutex]::new($true, '{_run_lock_name(mirror_path)}'); "
94
+ f"Set-Content -LiteralPath '{ready_path}' -Value ready; Start-Sleep -Seconds 30"
95
+ )
96
+ holder = subprocess.Popen([_PWSH, "-NoProfile", "-Command", holder_command])
97
+ try:
98
+ deadline = time.monotonic() + _LOCK_READY_TIMEOUT_SECONDS
99
+ while not ready_path.exists() and time.monotonic() < deadline:
100
+ time.sleep(0.2)
101
+ held_exit_code = _run_sync(mirror_path, log_path)
102
+ finally:
103
+ holder.kill()
104
+ holder.wait()
105
+
106
+ assert held_exit_code == _SKIPPED_EXIT_CODE
107
+ assert _git(mirror_path, "rev-parse", "HEAD") == old_tip
108
+ assert "still running" in log_path.read_text(encoding="utf-8")
109
+
110
+ assert _run_sync(mirror_path, log_path) == _SUCCESS_EXIT_CODE
111
+ assert _git(mirror_path, "rev-parse", "HEAD") != old_tip
112
+
113
+
114
+ def test_should_skip_a_second_spelling_of_the_same_repo(tmp_path: Path) -> None:
115
+ mirror_path = _mirror_one_commit_behind(tmp_path)
116
+ old_tip = _git(mirror_path, "rev-parse", "HEAD")
117
+ ready_path = tmp_path / "lock-ready"
118
+ holder_command = (
119
+ f"$lock = [System.Threading.Mutex]::new($true, '{_run_lock_name(mirror_path)}'); "
120
+ f"Set-Content -LiteralPath '{ready_path}' -Value ready; Start-Sleep -Seconds 30"
121
+ )
122
+ holder = subprocess.Popen([_PWSH, "-NoProfile", "-Command", holder_command])
123
+ try:
124
+ deadline = time.monotonic() + _LOCK_READY_TIMEOUT_SECONDS
125
+ while not ready_path.exists() and time.monotonic() < deadline:
126
+ time.sleep(0.2)
127
+ assert ready_path.exists()
128
+ spellings = (
129
+ f"{mirror_path}{os.sep}",
130
+ os.path.join(str(mirror_path), "."),
131
+ )
132
+ for spelling in spellings:
133
+ spelling_log = tmp_path / "spelling.log"
134
+ spelling_log.unlink(missing_ok=True)
135
+ exit_code = _run_sync(mirror_path, spelling_log, repo_path=spelling)
136
+ assert exit_code == _SKIPPED_EXIT_CODE
137
+ assert _git(mirror_path, "rev-parse", "HEAD") == old_tip
138
+ assert "still running" in spelling_log.read_text(encoding="utf-8")
139
+ finally:
140
+ holder.kill()
141
+ holder.wait()
142
+
143
+
144
+ def test_should_skip_when_the_repo_path_is_a_symlink(tmp_path: Path) -> None:
145
+ mirror_path = _mirror_one_commit_behind(tmp_path)
146
+ old_tip = _git(mirror_path, "rev-parse", "HEAD")
147
+ link_path = tmp_path / "mirror-link"
148
+ os.symlink(mirror_path, link_path, target_is_directory=True)
149
+ log_path = tmp_path / "sync.log"
150
+ ready_path = tmp_path / "lock-ready"
151
+ holder_command = (
152
+ f"$lock = [System.Threading.Mutex]::new($true, '{_run_lock_name(mirror_path)}'); "
153
+ f"Set-Content -LiteralPath '{ready_path}' -Value ready; Start-Sleep -Seconds 30"
154
+ )
155
+ holder = subprocess.Popen([_PWSH, "-NoProfile", "-Command", holder_command])
156
+ try:
157
+ deadline = time.monotonic() + _LOCK_READY_TIMEOUT_SECONDS
158
+ while not ready_path.exists() and time.monotonic() < deadline:
159
+ time.sleep(0.2)
160
+ assert ready_path.exists()
161
+ exit_code = _run_sync(mirror_path, log_path, repo_path=str(link_path))
162
+ finally:
163
+ holder.kill()
164
+ holder.wait()
165
+
166
+ assert exit_code == _SKIPPED_EXIT_CODE
167
+ assert _git(mirror_path, "rev-parse", "HEAD") == old_tip
168
+ assert "still running" in log_path.read_text(encoding="utf-8")