cctally 1.114.0 → 1.115.0
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.
- package/CHANGELOG.md +25 -0
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +4394 -279
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +178 -2
- package/bin/_cctally_dashboard.py +2 -2
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_sources.py +18 -9
- package/bin/_cctally_db.py +316 -5
- package/bin/_cctally_doctor.py +436 -27
- package/bin/_cctally_five_hour.py +82 -31
- package/bin/_cctally_forecast.py +81 -9
- package/bin/_cctally_journal.py +3884 -112
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +26 -4
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +677 -149
- package/bin/_cctally_rederive.py +297 -32
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +23 -2
- package/bin/_cctally_statusline.py +6137 -517
- package/bin/_cctally_store.py +287 -48
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +57 -53
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation_query.py +133 -19
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +11 -3
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +452 -1
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +29 -302
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_pricing.py +7 -2
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_rederive.py +94 -5
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/cctally +23 -1
- package/package.json +14 -1
package/bin/_lib_merge_gate.py
CHANGED
|
@@ -2,23 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
This is a reviewed component policy, not proof of every possible dependency.
|
|
4
4
|
Unknown paths and shared/infrastructure changes require the full estate. The
|
|
5
|
-
full
|
|
5
|
+
scheduled full run and the release run remain the cross-component backstop.
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
|
-
import base64
|
|
11
|
-
from datetime import datetime, timezone
|
|
12
10
|
import hashlib
|
|
13
11
|
import importlib.util
|
|
14
12
|
import json
|
|
15
13
|
import os
|
|
16
14
|
from pathlib import Path, PurePosixPath
|
|
17
|
-
import re
|
|
18
15
|
import signal
|
|
19
16
|
import subprocess
|
|
20
17
|
import sys
|
|
21
|
-
import time
|
|
22
18
|
|
|
23
19
|
GATE_ID = 'cctally-test-remote/merge-focused@1'
|
|
24
20
|
MAX_SECONDS = 300
|
|
@@ -299,17 +295,6 @@ def select(repo, paths, *, irregular=None, instruction_evidence=None):
|
|
|
299
295
|
if any(not (repo / p).is_file() for p in files):
|
|
300
296
|
reasons.append('required merge pytest file absent')
|
|
301
297
|
full = bool(reasons)
|
|
302
|
-
# Whether the wrapper must read background full-verification health before
|
|
303
|
-
# trusting this focus. Only a memory-only selection derived with its
|
|
304
|
-
# irregular-file evidence is exempt: memory publication is certified by the
|
|
305
|
-
# memory focus whatever the background state. Containing memory is not
|
|
306
|
-
# enough, and a full selection has nothing to read health for.
|
|
307
|
-
if full:
|
|
308
|
-
background_health = 'not-applicable'
|
|
309
|
-
elif sorted(components) == ['memory'] and memory:
|
|
310
|
-
background_health = 'exempt'
|
|
311
|
-
else:
|
|
312
|
-
background_health = 'required'
|
|
313
298
|
return {
|
|
314
299
|
'mode': 'full' if full else 'merge-focused',
|
|
315
300
|
'pytest': 'full' if full else 'selected',
|
|
@@ -317,7 +302,7 @@ def select(repo, paths, *, irregular=None, instruction_evidence=None):
|
|
|
317
302
|
'omittedHarnesses': [n for n in names if not full and n not in harnesses],
|
|
318
303
|
'selectedPytestFiles': [] if full else sorted(files),
|
|
319
304
|
'components': sorted(components), 'reasons': reasons,
|
|
320
|
-
'policyDigest': policy_digest(repo),
|
|
305
|
+
'policyDigest': policy_digest(repo),
|
|
321
306
|
'maxWallSeconds': MAX_SECONDS,
|
|
322
307
|
}
|
|
323
308
|
|
|
@@ -336,8 +321,13 @@ def plan(repo, base_rev, head='HEAD', *, working=True):
|
|
|
336
321
|
return result
|
|
337
322
|
|
|
338
323
|
|
|
339
|
-
def filter_legs(plan_record, directory):
|
|
340
|
-
"""Narrow both expected-node legs to selected files; retain serial ownership.
|
|
324
|
+
def filter_legs(plan_record, directory, quarantined=()):
|
|
325
|
+
"""Narrow both expected-node legs to selected files; retain serial ownership.
|
|
326
|
+
|
|
327
|
+
bin/cctally-test-all applies tests/quarantine.json to the legs first, so a
|
|
328
|
+
selected file whose every node is quarantined has none left. Such a file
|
|
329
|
+
(one that ``quarantined``, the list's node ids, names) contributes no
|
|
330
|
+
targets; any other selected file without a recorded node refuses."""
|
|
341
331
|
directory = Path(directory)
|
|
342
332
|
selected = set(plan_record['selectedPytestFiles'])
|
|
343
333
|
found = set()
|
|
@@ -346,284 +336,20 @@ def filter_legs(plan_record, directory):
|
|
|
346
336
|
nodes = [n for n in path.read_text().splitlines() if n.split('::')[0] in selected]
|
|
347
337
|
found.update(n.split('::')[0] for n in nodes)
|
|
348
338
|
path.write_text(''.join(n + '\n' for n in nodes))
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
# Health reads one unfiltered ci.yml page and selects main on the client:
|
|
354
|
-
# GitHub's `branch=` index can lag the unfiltered listing by weeks (#892).
|
|
355
|
-
_HEALTH_LISTED_RUNS = 100 # one page at GitHub's maximum size; never a second
|
|
356
|
-
_HEALTH_MAIN_DEPTH = 20 # the retired branch-filtered read's depth: no older evidence
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def background_health(rows):
|
|
360
|
-
"""Newest completed full verification wins; running work hides nothing."""
|
|
361
|
-
for row in rows:
|
|
362
|
-
if row.get('head_branch') != 'main' or row.get('status') != 'completed':
|
|
363
|
-
continue
|
|
364
|
-
if row.get('event') not in ('schedule', 'workflow_dispatch') and row.get('fullSuite') is not True:
|
|
365
|
-
continue
|
|
366
|
-
passed = row.get('conclusion') == 'success'
|
|
367
|
-
return {'state': 'passed' if passed else 'failed', 'allowFocused': passed,
|
|
368
|
-
'runId': row.get('id'), 'headSha': row.get('head_sha')}
|
|
369
|
-
return {'state': 'unavailable', 'allowFocused': False, 'runId': None, 'headSha': None}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
# D1(b): a scheduled test-macos job whose suite failed only on the runtime
|
|
373
|
-
# budget. ci.yml runs the marker step when the suite's outcome record says so;
|
|
374
|
-
# the reader also requires the suite step to be the job's only failed step.
|
|
375
|
-
_SUITE_STEP = 'Run cctally test suite'
|
|
376
|
-
_BUDGET_MARKER_STEP = 'Runtime budget is the only failure'
|
|
377
|
-
# D1(a): how many first-parent main commits are searched for a receipt.
|
|
378
|
-
_HEALTH_RECEIPT_COMMITS = 20
|
|
379
|
-
# D1(a) receipt discovery's overall time budget (#899 S2): once spent, the CI-derived
|
|
380
|
-
# result stands; it never waits out twenty commits of slow reads.
|
|
381
|
-
_HEALTH_RECEIPT_BUDGET_S = 60
|
|
382
|
-
_RECEIPT_RUN_ID = re.compile(r'^(\d{8}T\d{6}Z)-')
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def _attempt_start(value):
|
|
386
|
-
"""The UTC instant of a GitHub ISO-8601 timestamp; ValueError when absent or malformed."""
|
|
387
|
-
if not isinstance(value, str) or not value:
|
|
388
|
-
raise ValueError('a run has no attempt start time')
|
|
389
|
-
moment = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
390
|
-
if moment.tzinfo is None:
|
|
391
|
-
raise ValueError('a run attempt start time carries no timezone')
|
|
392
|
-
return moment.astimezone(timezone.utc)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
def _receipt_start(run_id):
|
|
396
|
-
"""The UTC start a receipt's run ID embeds, or None."""
|
|
397
|
-
match = _RECEIPT_RUN_ID.match(run_id) if isinstance(run_id, str) else None
|
|
398
|
-
if match is None:
|
|
399
|
-
return None
|
|
400
|
-
try:
|
|
401
|
-
return datetime.strptime(match.group(1), '%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
|
|
402
|
-
except ValueError:
|
|
403
|
-
return None
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def _budget_only(row, job):
|
|
407
|
-
"""D1(b): a scheduled run's failed test-macos whose only failure is the runtime budget."""
|
|
408
|
-
if row.get('event') != 'schedule' or job.get('status') != 'completed' or job.get('conclusion') != 'failure':
|
|
409
|
-
return False
|
|
410
|
-
steps = [step for step in (job.get('steps') or []) if isinstance(step, dict)]
|
|
411
|
-
markers = [step for step in steps if step.get('name') == _BUDGET_MARKER_STEP]
|
|
412
|
-
failed = {step.get('name') for step in steps if step.get('conclusion') == 'failure'}
|
|
413
|
-
return (len(markers) == 1 and markers[0].get('conclusion') == 'success'
|
|
414
|
-
and failed == {_SUITE_STEP})
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
def _run_item(row, jobs_of):
|
|
418
|
-
"""The full-verification item one main run contributes: 'pass', 'fail', 'unknown'
|
|
419
|
-
(decisive, never a pass), or None when the run is skipped."""
|
|
420
|
-
if row.get('status') != 'completed':
|
|
421
|
-
# GitHub reuses the run ID on retry: the earlier failed attempt
|
|
422
|
-
# disappears from this listing while the retry runs. Do not let that
|
|
423
|
-
# mutation reveal an older successful run. A first attempt that is
|
|
424
|
-
# still running hides nothing.
|
|
425
|
-
return 'unknown' if row.get('run_attempt', 1) != 1 else None
|
|
426
|
-
jobs = jobs_of(row)
|
|
427
|
-
full = next((j for j in jobs if j.get('name') == 'test-macos'
|
|
428
|
-
and j.get('status') == 'completed'
|
|
429
|
-
and j.get('conclusion') != 'skipped'), None)
|
|
430
|
-
if full is not None:
|
|
431
|
-
if full.get('conclusion') == 'success' or _budget_only(row, full):
|
|
432
|
-
return 'pass'
|
|
433
|
-
return 'fail'
|
|
434
|
-
if row.get('event') in ('schedule', 'workflow_dispatch'):
|
|
435
|
-
# A scheduled/manual attempt must actually run the full job. Only
|
|
436
|
-
# receipt-discharged ordinary pushes may be skipped while looking for
|
|
437
|
-
# the last full verification.
|
|
438
|
-
return 'unknown'
|
|
439
|
-
# ci.yml can skip its declared full job on a successful private main push
|
|
440
|
-
# only through a successful release/receipt gate. The skipped job plus
|
|
441
|
-
# successful workflow is evidence of discharge; a merely successful
|
|
442
|
-
# classifier job alone is not (it may emit discharged=false). Missing
|
|
443
|
-
# jobs, cancellation, and failures must not silently inherit an older pass.
|
|
444
|
-
discharged = (
|
|
445
|
-
row.get('event') == 'push' and row.get('conclusion') == 'success'
|
|
446
|
-
and any(j.get('name') == 'test-macos'
|
|
447
|
-
and j.get('status') == 'completed'
|
|
448
|
-
and j.get('conclusion') == 'skipped' for j in jobs)
|
|
449
|
-
and any(j.get('name') in ('receipt-gate', 'release-stamp-gate')
|
|
450
|
-
and j.get('status') == 'completed'
|
|
451
|
-
and j.get('conclusion') == 'success' for j in jobs)
|
|
452
|
-
)
|
|
453
|
-
return None if discharged else 'unknown'
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
def _ci_item(rows, jobs_of):
|
|
457
|
-
"""(kind, start, row) of the newest decisive run, or None.
|
|
458
|
-
|
|
459
|
-
The window is sorted by attempt start, newest first, BEFORE the walk, so a
|
|
460
|
-
retry of an older-created run is read in its true place. Equal starts are
|
|
461
|
-
decided together and a non-pass among them wins.
|
|
462
|
-
"""
|
|
463
|
-
dated = [(_attempt_start(row.get('run_started_at')), index, row) for index, row in enumerate(rows)]
|
|
464
|
-
dated.sort(key=lambda item: (-item[0].timestamp(), item[1]))
|
|
465
|
-
index = 0
|
|
466
|
-
while index < len(dated):
|
|
467
|
-
start = dated[index][0]
|
|
468
|
-
group = []
|
|
469
|
-
while index < len(dated) and dated[index][0] == start:
|
|
470
|
-
group.append(dated[index][2])
|
|
471
|
-
index += 1
|
|
472
|
-
items = [(kind, row) for row in group if (kind := _run_item(row, jobs_of)) is not None]
|
|
473
|
-
if items:
|
|
474
|
-
kind, row = next((item for item in items if item[0] != 'pass'), items[0])
|
|
475
|
-
return kind, start, row
|
|
476
|
-
return None
|
|
477
|
-
|
|
339
|
+
absent = selected - found - {node.split('::')[0] for node in quarantined}
|
|
340
|
+
if absent:
|
|
341
|
+
raise ValueError('selected files absent from recorded pytest estate: ' + ', '.join(sorted(absent)))
|
|
478
342
|
|
|
479
|
-
def _receipt_pass(gh, name, repo, newer_than, digest_of=None):
|
|
480
|
-
"""D1(a): (runId, commit sha) of the first valid passing full-suite receipt for
|
|
481
|
-
the exact tree of a recent first-parent main commit, newer than `newer_than`
|
|
482
|
-
(any age when None); None when there is none. Raises on any read error.
|
|
483
|
-
|
|
484
|
-
`digest_of(sha)`, when given, reconstructs a commit's tree-manifest digest in
|
|
485
|
-
its caller's stead (bounded by the caller's budget); by default it is built
|
|
486
|
-
in-process."""
|
|
487
|
-
# The validator of this reader's own tree, the one receipt-gate runs.
|
|
488
|
-
spec = importlib.util.spec_from_file_location(
|
|
489
|
-
'_merge_gate_receipts',
|
|
490
|
-
Path(__file__).resolve().parents[1] / '.github' / 'scripts' / 'classify_receipt.py')
|
|
491
|
-
receipts = importlib.util.module_from_spec(spec)
|
|
492
|
-
spec.loader.exec_module(receipts)
|
|
493
|
-
toolchain = receipts.toolchain_digest(str(repo))
|
|
494
|
-
if toolchain is None:
|
|
495
|
-
return None
|
|
496
|
-
listing = gh('api', f'repos/{name}/commits?sha=main&per_page=100')
|
|
497
|
-
by_sha = {commit['sha']: commit for commit in listing}
|
|
498
|
-
commit = listing[0] if listing else None
|
|
499
|
-
for _ in range(_HEALTH_RECEIPT_COMMITS):
|
|
500
|
-
if commit is None:
|
|
501
|
-
break
|
|
502
|
-
sha, tree = commit['sha'], commit['commit']['tree']['sha']
|
|
503
|
-
digest = None
|
|
504
|
-
refs = gh('api', f'repos/{name}/git/matching-refs/cctally/receipts/{tree}/')
|
|
505
|
-
for ref in sorted(refs, key=lambda r: r['ref'], reverse=True):
|
|
506
|
-
run_id = ref['ref'].rsplit('/', 1)[-1]
|
|
507
|
-
when = _receipt_start(run_id)
|
|
508
|
-
if when is None or (newer_than is not None and when <= newer_than):
|
|
509
|
-
continue
|
|
510
|
-
blob = gh('api', f'repos/{name}/git/blobs/{ref["object"]["sha"]}')
|
|
511
|
-
receipt = json.loads(base64.b64decode(blob['content']))
|
|
512
|
-
if not isinstance(receipt, dict) or receipt.get('runId') != run_id:
|
|
513
|
-
continue # dated by the run it records, and filed under that run
|
|
514
|
-
if digest is None:
|
|
515
|
-
digest = (digest_of(sha) if digest_of is not None
|
|
516
|
-
else receipts.manifest_digest(receipts.reconstruct_manifest(sha, str(repo))))
|
|
517
|
-
if receipts.verify_candidate(receipt, tree_oid=tree, ref_namespace=tree,
|
|
518
|
-
reconstructed_digest=digest,
|
|
519
|
-
toolchain_digest=toolchain) is None:
|
|
520
|
-
return run_id, sha
|
|
521
|
-
parents = commit.get('parents') or []
|
|
522
|
-
commit = by_sha.get(parents[0]['sha']) if parents else None
|
|
523
|
-
return None
|
|
524
343
|
|
|
344
|
+
def quarantine_ids(repo):
|
|
345
|
+
"""The pytest node ids tests/quarantine.json lists; none when the tree carries no list.
|
|
525
346
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
(b) a scheduled run whose suite failed only on the runtime budget, proven by
|
|
534
|
-
its marker step and its final step conclusions, is a pass;
|
|
535
|
-
(a) a valid passing full-suite receipt for the exact tree of a recent
|
|
536
|
-
first-parent main commit, newer than the newest CI item, is a pass.
|
|
537
|
-
|
|
538
|
-
Runs are ordered by attempt start. A missing start, an unreadable listing
|
|
539
|
-
and a malformed row are missing evidence; a failed receipt read leaves the
|
|
540
|
-
CI-derived result unchanged and never manufactures a pass, and so does a
|
|
541
|
-
receipt discovery that outlives its overall budget (_HEALTH_RECEIPT_BUDGET_S).
|
|
542
|
-
That budget bounds the whole discovery, the reconstruction of a receipt's
|
|
543
|
-
tree manifest included: it runs as a subprocess stopped when the budget is
|
|
544
|
-
spent, after which nothing further is discovered (#899 S2).
|
|
545
|
-
"""
|
|
546
|
-
def gh(*args):
|
|
547
|
-
process = subprocess.run(['gh', *args], cwd=repo, capture_output=True,
|
|
548
|
-
text=True, timeout=20)
|
|
549
|
-
if process.returncode:
|
|
550
|
-
raise ValueError('GitHub full-verification evidence is unavailable')
|
|
551
|
-
return json.loads(process.stdout)
|
|
552
|
-
|
|
553
|
-
def jobs_of(row):
|
|
554
|
-
return gh('api', f'repos/{name}/actions/runs/{row["id"]}/jobs?per_page=100')['jobs']
|
|
555
|
-
try:
|
|
556
|
-
name = gh('repo', 'view', '--json', 'nameWithOwner')['nameWithOwner']
|
|
557
|
-
listed = gh('api', f'repos/{name}/actions/workflows/ci.yml/runs'
|
|
558
|
-
f'?per_page={_HEALTH_LISTED_RUNS}')['workflow_runs']
|
|
559
|
-
# Other branches are never main's evidence, so they are dropped before
|
|
560
|
-
# any rule below can decide on them or count toward the depth.
|
|
561
|
-
rows = [row for row in listed if row.get('head_branch') == 'main']
|
|
562
|
-
item = _ci_item(rows[:_HEALTH_MAIN_DEPTH], jobs_of)
|
|
563
|
-
except (OSError, ValueError, KeyError, TypeError, AttributeError,
|
|
564
|
-
subprocess.TimeoutExpired):
|
|
565
|
-
# A malformed listing (a null or non-object row) is missing evidence.
|
|
566
|
-
return background_health([])
|
|
567
|
-
if item is not None and item[0] == 'pass':
|
|
568
|
-
return background_health([dict(item[2], fullSuite=True, conclusion='success')])
|
|
569
|
-
if item is not None and item[0] == 'fail':
|
|
570
|
-
result = background_health([dict(item[2], fullSuite=True, conclusion='failure')])
|
|
571
|
-
else:
|
|
572
|
-
result = background_health([])
|
|
573
|
-
budget_end = time.monotonic() + _HEALTH_RECEIPT_BUDGET_S
|
|
574
|
-
|
|
575
|
-
def budgeted_gh(*args):
|
|
576
|
-
# Every discovery read gets only what is left of the overall budget, and a read
|
|
577
|
-
# that ends past it voids the discovery: the CI-derived result stands.
|
|
578
|
-
left = budget_end - time.monotonic()
|
|
579
|
-
if left <= 0:
|
|
580
|
-
raise ValueError('the receipt discovery budget is spent')
|
|
581
|
-
process = subprocess.run(['gh', *args], cwd=repo, capture_output=True, text=True,
|
|
582
|
-
timeout=min(20.0, left))
|
|
583
|
-
if process.returncode:
|
|
584
|
-
raise ValueError('GitHub full-verification evidence is unavailable')
|
|
585
|
-
if time.monotonic() > budget_end:
|
|
586
|
-
raise ValueError('the receipt discovery budget is spent')
|
|
587
|
-
return json.loads(process.stdout)
|
|
588
|
-
|
|
589
|
-
def budgeted_digest(sha):
|
|
590
|
-
# The tree-manifest reconstruction runs as the classifier's own `--self-test-manifest`
|
|
591
|
-
# in a separate session, stopped (with every process it started) when the budget is spent.
|
|
592
|
-
left = budget_end - time.monotonic()
|
|
593
|
-
if left <= 0:
|
|
594
|
-
raise ValueError('the receipt discovery budget is spent')
|
|
595
|
-
script = Path(__file__).resolve().parents[1] / '.github' / 'scripts' / 'classify_receipt.py'
|
|
596
|
-
process = subprocess.Popen([sys.executable, str(script), '--sha', sha, '--repo', str(repo),
|
|
597
|
-
'--self-test-manifest'], stdout=subprocess.PIPE,
|
|
598
|
-
stderr=subprocess.DEVNULL, text=True, start_new_session=True)
|
|
599
|
-
try:
|
|
600
|
-
out, _ = process.communicate(timeout=left)
|
|
601
|
-
except subprocess.TimeoutExpired:
|
|
602
|
-
try:
|
|
603
|
-
os.killpg(process.pid, signal.SIGKILL)
|
|
604
|
-
except (ProcessLookupError, PermissionError):
|
|
605
|
-
pass
|
|
606
|
-
try:
|
|
607
|
-
process.communicate(timeout=5)
|
|
608
|
-
except subprocess.TimeoutExpired:
|
|
609
|
-
pass
|
|
610
|
-
raise ValueError('the receipt discovery budget is spent') from None
|
|
611
|
-
if process.returncode:
|
|
612
|
-
raise ValueError('the tree manifest could not be reconstructed')
|
|
613
|
-
if time.monotonic() > budget_end:
|
|
614
|
-
raise ValueError('the receipt discovery budget is spent')
|
|
615
|
-
return out.strip()
|
|
616
|
-
try:
|
|
617
|
-
found = _receipt_pass(budgeted_gh, name, repo, item[1] if item is not None else None,
|
|
618
|
-
digest_of=budgeted_digest)
|
|
619
|
-
if found is not None and time.monotonic() > budget_end:
|
|
620
|
-
found = None # validated after the budget: the CI-derived result stands
|
|
621
|
-
except Exception: # any read error, or a spent budget, leaves the CI-derived result unchanged
|
|
622
|
-
found = None
|
|
623
|
-
if found is None:
|
|
624
|
-
return result
|
|
625
|
-
run_id, sha = found
|
|
626
|
-
return {'state': 'passed', 'allowFocused': True, 'runId': run_id, 'headSha': sha}
|
|
347
|
+
Parsed by the shared bin/_lib_quarantine.py; harness cases are not pytest
|
|
348
|
+
nodes and are left to their harness. bin/cctally-test-all validated the
|
|
349
|
+
list at admission, before this runs."""
|
|
350
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
351
|
+
import _lib_quarantine
|
|
352
|
+
return _lib_quarantine.pytest_ids(_lib_quarantine.load(repo))
|
|
627
353
|
|
|
628
354
|
|
|
629
355
|
def supervise(command, seconds):
|
|
@@ -682,7 +408,6 @@ def main(argv=None):
|
|
|
682
408
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
683
409
|
parser.add_argument('--repo', default=str(Path(__file__).resolve().parents[1]))
|
|
684
410
|
parser.add_argument('--base')
|
|
685
|
-
parser.add_argument('--health', action='store_true')
|
|
686
411
|
parser.add_argument('--required-mode', action='store_true')
|
|
687
412
|
parser.add_argument('--shell', action='store_true')
|
|
688
413
|
parser.add_argument('--filter-legs')
|
|
@@ -693,18 +418,20 @@ def main(argv=None):
|
|
|
693
418
|
if len(args.deadline) < 2:
|
|
694
419
|
parser.error('deadline requires seconds and a command')
|
|
695
420
|
return supervise(args.deadline[1:], int(args.deadline[0]))
|
|
696
|
-
if args.health:
|
|
697
|
-
health = read_background_health(args.repo)
|
|
698
|
-
print(json.dumps(health, sort_keys=True))
|
|
699
|
-
return 0 if health['allowFocused'] else 3
|
|
700
421
|
if not args.base:
|
|
701
|
-
parser.error('--base is required
|
|
422
|
+
parser.error('--base is required')
|
|
702
423
|
try:
|
|
703
424
|
result = plan(args.repo, args.base)
|
|
704
425
|
if args.required_mode:
|
|
705
|
-
|
|
426
|
+
# `<mode> <reason>`: the wrapper decides on the mode alone; the
|
|
427
|
+
# reason names the focused components for whoever reads the log.
|
|
428
|
+
if result['mode'] == 'full':
|
|
429
|
+
reason = 'reviewed-selection'
|
|
430
|
+
else:
|
|
431
|
+
reason = '+'.join(result['components']) or 'none'
|
|
432
|
+
print(f"{result['mode']} {reason}")
|
|
706
433
|
elif args.filter_legs:
|
|
707
|
-
filter_legs(result, args.filter_legs)
|
|
434
|
+
filter_legs(result, args.filter_legs, quarantined=quarantine_ids(args.repo))
|
|
708
435
|
elif args.shell:
|
|
709
436
|
print('mode\t' + result['mode'])
|
|
710
437
|
print('harnesses\t' + ' '.join(result['selectedHarnesses']))
|
|
@@ -246,6 +246,12 @@ def _adjacent_pairs(regimes) -> dict:
|
|
|
246
246
|
`now` with `status: "stale"` and appends a fresh regime starting at the
|
|
247
247
|
analysis start, so the two are neither adjacent nor a rate transition. The
|
|
248
248
|
status check is the second, independent guard.
|
|
249
|
+
|
|
250
|
+
#978 spec 4.5: a pair also requires one `fingerprint` and one
|
|
251
|
+
`coefficientEra`. Units-per-point from two eras measure two different
|
|
252
|
+
weightings, so their difference is a coefficient change and never a
|
|
253
|
+
metering-rate change. The comparison uses `.get()`, so a legacy pair in
|
|
254
|
+
which neither regime names an era keeps its treatment.
|
|
249
255
|
"""
|
|
250
256
|
closed: dict = {}
|
|
251
257
|
for regime in regimes:
|
|
@@ -266,6 +272,10 @@ def _adjacent_pairs(regimes) -> dict:
|
|
|
266
272
|
predecessor = closed.get(start)
|
|
267
273
|
if predecessor is None or predecessor is regime:
|
|
268
274
|
continue
|
|
275
|
+
if predecessor.get("fingerprint") != regime.get("fingerprint") \
|
|
276
|
+
or predecessor.get("coefficientEra") \
|
|
277
|
+
!= regime.get("coefficientEra"):
|
|
278
|
+
continue
|
|
269
279
|
pairs[start] = (predecessor, regime)
|
|
270
280
|
return pairs
|
|
271
281
|
|