[kimi] Fix triage_score.py to merge queue instead of overwrite (#1463) #1464
1
.loop/queue_exclusions.json
Normal file
1
.loop/queue_exclusions.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -45,6 +45,7 @@ QUEUE_BACKUP_FILE = REPO_ROOT / ".loop" / "queue.json.bak"
|
|||||||
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
|
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
|
||||||
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
|
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
|
||||||
CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||||
|
EXCLUSIONS_FILE = REPO_ROOT / ".loop" / "queue_exclusions.json"
|
||||||
|
|
||||||
# Minimum score to be considered "ready"
|
# Minimum score to be considered "ready"
|
||||||
READY_THRESHOLD = 5
|
READY_THRESHOLD = 5
|
||||||
@@ -85,6 +86,24 @@ def load_quarantine() -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_exclusions() -> list[int]:
|
||||||
|
"""Load excluded issue numbers (sticky removals from deep triage)."""
|
||||||
|
if EXCLUSIONS_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(EXCLUSIONS_FILE.read_text())
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [int(x) for x in data if isinstance(x, int) or (isinstance(x, str) and x.isdigit())]
|
||||||
|
except (json.JSONDecodeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_exclusions(exclusions: list[int]) -> None:
|
||||||
|
"""Save excluded issue numbers to persist deep triage removals."""
|
||||||
|
EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
EXCLUSIONS_FILE.write_text(json.dumps(sorted(set(exclusions)), indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
def save_quarantine(q: dict) -> None:
|
def save_quarantine(q: dict) -> None:
|
||||||
QUARANTINE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
QUARANTINE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
QUARANTINE_FILE.write_text(json.dumps(q, indent=2) + "\n")
|
QUARANTINE_FILE.write_text(json.dumps(q, indent=2) + "\n")
|
||||||
@@ -329,6 +348,12 @@ def run_triage() -> list[dict]:
|
|||||||
# Auto-quarantine repeat failures
|
# Auto-quarantine repeat failures
|
||||||
scored = update_quarantine(scored)
|
scored = update_quarantine(scored)
|
||||||
|
|
||||||
|
# Load exclusions (sticky removals from deep triage)
|
||||||
|
exclusions = load_exclusions()
|
||||||
|
|
||||||
|
# Filter out excluded issues - they never get re-added
|
||||||
|
scored = [s for s in scored if s["issue"] not in exclusions]
|
||||||
|
|
||||||
# Sort: ready first, then by score descending, bugs always on top
|
# Sort: ready first, then by score descending, bugs always on top
|
||||||
def sort_key(item: dict) -> tuple:
|
def sort_key(item: dict) -> tuple:
|
||||||
return (
|
return (
|
||||||
@@ -339,10 +364,29 @@ def run_triage() -> list[dict]:
|
|||||||
|
|
||||||
scored.sort(key=sort_key)
|
scored.sort(key=sort_key)
|
||||||
|
|
||||||
# Write queue (ready items only)
|
# Get ready items from current scoring run
|
||||||
ready = [s for s in scored if s["ready"]]
|
newly_ready = [s for s in scored if s["ready"]]
|
||||||
not_ready = [s for s in scored if not s["ready"]]
|
not_ready = [s for s in scored if not s["ready"]]
|
||||||
|
|
||||||
|
# MERGE logic: preserve existing queue, only add new issues
|
||||||
|
existing_queue = []
|
||||||
|
if QUEUE_FILE.exists():
|
||||||
|
try:
|
||||||
|
existing_queue = json.loads(QUEUE_FILE.read_text())
|
||||||
|
if not isinstance(existing_queue, list):
|
||||||
|
existing_queue = []
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
existing_queue = []
|
||||||
|
|
||||||
|
# Build set of existing issue numbers
|
||||||
|
existing_issues = {item["issue"] for item in existing_queue if isinstance(item, dict) and "issue" in item}
|
||||||
|
|
||||||
|
# Add only new issues that aren't already in the queue and aren't excluded
|
||||||
|
new_items = [s for s in newly_ready if s["issue"] not in existing_issues and s["issue"] not in exclusions]
|
||||||
|
|
||||||
|
# Merge: existing items + new items
|
||||||
|
ready = existing_queue + new_items
|
||||||
|
|
||||||
# Save backup before writing (if current file exists and is valid)
|
# Save backup before writing (if current file exists and is valid)
|
||||||
if QUEUE_FILE.exists():
|
if QUEUE_FILE.exists():
|
||||||
try:
|
try:
|
||||||
@@ -351,7 +395,7 @@ def run_triage() -> list[dict]:
|
|||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
pass # Current file is corrupt, don't overwrite backup
|
pass # Current file is corrupt, don't overwrite backup
|
||||||
|
|
||||||
# Write new queue file
|
# Write merged queue file
|
||||||
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n")
|
QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n")
|
||||||
|
|
||||||
@@ -390,7 +434,7 @@ def run_triage() -> list[dict]:
|
|||||||
f.write(json.dumps(retro_entry) + "\n")
|
f.write(json.dumps(retro_entry) + "\n")
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
print(f"[triage] Ready: {len(ready)} | Not ready: {len(not_ready)}")
|
print(f"[triage] Ready: {len(ready)} | Not ready: {len(not_ready)} | Existing: {len(existing_issues)} | New: {len(new_items)}")
|
||||||
for item in ready[:5]:
|
for item in ready[:5]:
|
||||||
flag = "🐛" if item["type"] == "bug" else "✦"
|
flag = "🐛" if item["type"] == "bug" else "✦"
|
||||||
print(f" {flag} #{item['issue']} score={item['score']} {item['title'][:60]}")
|
print(f" {flag} #{item['issue']} score={item['score']} {item['title'][:60]}")
|
||||||
|
|||||||
@@ -157,3 +157,175 @@ def test_backup_path_configuration():
|
|||||||
assert ts.QUEUE_BACKUP_FILE.parent == ts.QUEUE_FILE.parent
|
assert ts.QUEUE_BACKUP_FILE.parent == ts.QUEUE_FILE.parent
|
||||||
assert ts.QUEUE_BACKUP_FILE.name == "queue.json.bak"
|
assert ts.QUEUE_BACKUP_FILE.name == "queue.json.bak"
|
||||||
assert ts.QUEUE_FILE.name == "queue.json"
|
assert ts.QUEUE_FILE.name == "queue.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclusions_file_path():
|
||||||
|
"""Ensure exclusions file path is properly configured."""
|
||||||
|
assert ts.EXCLUSIONS_FILE.name == "queue_exclusions.json"
|
||||||
|
assert ts.EXCLUSIONS_FILE.parent == ts.REPO_ROOT / ".loop"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_exclusions_empty_file(tmp_path):
|
||||||
|
"""Loading from empty/non-existent exclusions file returns empty list."""
|
||||||
|
assert ts.load_exclusions() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_exclusions_with_data(tmp_path, monkeypatch):
|
||||||
|
"""Loading exclusions returns list of integers."""
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts.EXCLUSIONS_FILE.write_text("[123, 456, 789]")
|
||||||
|
assert ts.load_exclusions() == [123, 456, 789]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_exclusions_with_strings(tmp_path, monkeypatch):
|
||||||
|
"""Loading exclusions handles string numbers gracefully."""
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts.EXCLUSIONS_FILE.write_text('["100", 200, "invalid", 300]')
|
||||||
|
assert ts.load_exclusions() == [100, 200, 300]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_exclusions_corrupt_file(tmp_path, monkeypatch):
|
||||||
|
"""Loading from corrupt exclusions file returns empty list."""
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts.EXCLUSIONS_FILE.write_text("not valid json")
|
||||||
|
assert ts.load_exclusions() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_exclusions(tmp_path, monkeypatch):
|
||||||
|
"""Saving exclusions writes sorted unique integers."""
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
ts.save_exclusions([300, 100, 200, 100]) # includes duplicate
|
||||||
|
assert json.loads(ts.EXCLUSIONS_FILE.read_text()) == [100, 200, 300]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_preserves_existing_queue(tmp_path, monkeypatch):
|
||||||
|
"""Merge logic preserves existing queue items and only adds new ones."""
|
||||||
|
monkeypatch.setattr(ts, "QUEUE_FILE", tmp_path / "queue.json")
|
||||||
|
monkeypatch.setattr(ts, "QUEUE_BACKUP_FILE", tmp_path / "queue.json.bak")
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
monkeypatch.setattr(ts, "RETRO_FILE", tmp_path / "retro" / "triage.jsonl")
|
||||||
|
monkeypatch.setattr(ts, "QUARANTINE_FILE", tmp_path / "quarantine.json")
|
||||||
|
monkeypatch.setattr(ts, "CYCLE_RETRO_FILE", tmp_path / "retro" / "cycles.jsonl")
|
||||||
|
|
||||||
|
# Setup: existing queue with 2 items (simulating deep triage cut)
|
||||||
|
existing = [
|
||||||
|
{"issue": 1, "title": "Existing A", "ready": True, "score": 8},
|
||||||
|
{"issue": 2, "title": "Existing B", "ready": True, "score": 7},
|
||||||
|
]
|
||||||
|
ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts.QUEUE_FILE.write_text(json.dumps(existing))
|
||||||
|
|
||||||
|
# Simulate merge logic (extracted from run_triage)
|
||||||
|
newly_ready = [
|
||||||
|
{"issue": 1, "title": "Existing A", "ready": True, "score": 8}, # duplicate
|
||||||
|
{"issue": 2, "title": "Existing B", "ready": True, "score": 7}, # duplicate
|
||||||
|
{"issue": 3, "title": "New C", "ready": True, "score": 9}, # new
|
||||||
|
]
|
||||||
|
exclusions = []
|
||||||
|
|
||||||
|
existing_queue = json.loads(ts.QUEUE_FILE.read_text())
|
||||||
|
existing_issues = {item["issue"] for item in existing_queue}
|
||||||
|
new_items = [
|
||||||
|
s for s in newly_ready if s["issue"] not in existing_issues and s["issue"] not in exclusions
|
||||||
|
]
|
||||||
|
merged = existing_queue + new_items
|
||||||
|
|
||||||
|
# Should preserve existing (2 items) + add new (1 item) = 3 items
|
||||||
|
assert len(merged) == 3
|
||||||
|
assert merged[0]["issue"] == 1
|
||||||
|
assert merged[1]["issue"] == 2
|
||||||
|
assert merged[2]["issue"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluded_issues_not_added(tmp_path, monkeypatch):
|
||||||
|
"""Excluded issues are never added to the queue."""
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts.EXCLUSIONS_FILE.write_text("[5, 10]")
|
||||||
|
|
||||||
|
exclusions = ts.load_exclusions()
|
||||||
|
newly_ready = [
|
||||||
|
{"issue": 5, "title": "Excluded A", "ready": True},
|
||||||
|
{"issue": 6, "title": "New B", "ready": True},
|
||||||
|
{"issue": 10, "title": "Excluded C", "ready": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filter out excluded
|
||||||
|
filtered = [s for s in newly_ready if s["issue"] not in exclusions]
|
||||||
|
|
||||||
|
assert len(filtered) == 1
|
||||||
|
assert filtered[0]["issue"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluded_issues_removed_from_scored(tmp_path, monkeypatch):
|
||||||
|
"""Excluded issues are filtered out before any queue logic."""
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts.EXCLUSIONS_FILE.write_text("[42]")
|
||||||
|
|
||||||
|
exclusions = ts.load_exclusions()
|
||||||
|
scored = [
|
||||||
|
{"issue": 41, "title": "Keep", "ready": True},
|
||||||
|
{"issue": 42, "title": "Excluded", "ready": True},
|
||||||
|
{"issue": 43, "title": "Keep Too", "ready": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
filtered = [s for s in scored if s["issue"] not in exclusions]
|
||||||
|
|
||||||
|
assert len(filtered) == 2
|
||||||
|
assert 42 not in [s["issue"] for s in filtered]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_queue_merge_adds_all_new_items(tmp_path, monkeypatch):
|
||||||
|
"""When queue is empty, all new ready items are added."""
|
||||||
|
monkeypatch.setattr(ts, "QUEUE_FILE", tmp_path / "queue.json")
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
|
||||||
|
# No existing queue file
|
||||||
|
assert not ts.QUEUE_FILE.exists()
|
||||||
|
|
||||||
|
newly_ready = [
|
||||||
|
{"issue": 1, "title": "A", "ready": True},
|
||||||
|
{"issue": 2, "title": "B", "ready": True},
|
||||||
|
]
|
||||||
|
exclusions = ts.load_exclusions()
|
||||||
|
|
||||||
|
existing_queue = []
|
||||||
|
if ts.QUEUE_FILE.exists():
|
||||||
|
existing_queue = json.loads(ts.QUEUE_FILE.read_text())
|
||||||
|
|
||||||
|
existing_issues = {item["issue"] for item in existing_queue}
|
||||||
|
new_items = [
|
||||||
|
s for s in newly_ready if s["issue"] not in existing_issues and s["issue"] not in exclusions
|
||||||
|
]
|
||||||
|
merged = existing_queue + new_items
|
||||||
|
|
||||||
|
assert len(merged) == 2
|
||||||
|
assert merged[0]["issue"] == 1
|
||||||
|
assert merged[1]["issue"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_preserved_when_no_new_ready_items(tmp_path, monkeypatch):
|
||||||
|
"""Existing queue is preserved even when no new ready items are found."""
|
||||||
|
monkeypatch.setattr(ts, "QUEUE_FILE", tmp_path / "queue.json")
|
||||||
|
monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json")
|
||||||
|
|
||||||
|
existing = [{"issue": 1, "title": "Only Item", "ready": True}]
|
||||||
|
ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts.QUEUE_FILE.write_text(json.dumps(existing))
|
||||||
|
|
||||||
|
newly_ready = [] # No new ready items
|
||||||
|
exclusions = ts.load_exclusions()
|
||||||
|
|
||||||
|
existing_queue = json.loads(ts.QUEUE_FILE.read_text())
|
||||||
|
existing_issues = {item["issue"] for item in existing_queue}
|
||||||
|
new_items = [
|
||||||
|
s for s in newly_ready if s["issue"] not in existing_issues and s["issue"] not in exclusions
|
||||||
|
]
|
||||||
|
merged = existing_queue + new_items
|
||||||
|
|
||||||
|
assert len(merged) == 1
|
||||||
|
assert merged[0]["issue"] == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user