threadkeeper 0.11.0__tar.gz → 0.12.0__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.
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/PKG-INFO +1 -1
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/pyproject.toml +1 -1
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_evolve_applier.py +289 -10
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/config.py +5 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/evolve_applier.py +256 -13
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/PKG-INFO +1 -1
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/LICENSE +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/README.md +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/setup.cfg +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_adapters.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_agent_status.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_auto_update.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_brief_footprint.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_config_settings.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_curator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dashboard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_feed_tools.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_miner.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_observation_resolve.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_recompute.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_validator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_evolve_apply_2.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_evolve_apply_3.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_evolve_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_extract_dedup.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_identity.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_ingest_status.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_lessons.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_menubar_app.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_nudges.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_onnx_embeddings.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_panel.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_probe_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_process_health.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_search_fts_punctuation.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_passive_tier.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skills.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_codex_stdin.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_reap.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_wrap.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_thread_janitor.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_threads.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/_spawn_wrap.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/antigravity.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/agent_status.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/README.md +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/auto_update.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/brief.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/curator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/db.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/dialectic_miner.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/dialectic_validator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/embeddings.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/evolve_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/identity.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/menubar_app.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/migrate_embeddings.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/probe_daemon.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/server.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/shadow_review.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/thread_janitor.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/agent_status.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/dashboard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/dialectic_feed.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/evolve_applier.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/panel.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/spawn.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/threads.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/SOURCES.txt +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/requires.txt +0 -0
- {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadkeeper
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server.
|
|
5
5
|
Author: thread-keeper contributors
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "threadkeeper"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.0"
|
|
8
8
|
description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
authors = [{ name = "thread-keeper contributors" }]
|
|
@@ -57,8 +57,26 @@ def _bootstrap(tmp_path, monkeypatch, interval="0"):
|
|
|
57
57
|
)
|
|
58
58
|
monkeypatch.setattr(
|
|
59
59
|
evolve_applier, "_comment_issue_claim",
|
|
60
|
-
lambda issue, repo_root=None: "",
|
|
60
|
+
lambda issue, repo_root=None: ("https://x/issues/0#issuecomment-1", ""),
|
|
61
61
|
)
|
|
62
|
+
monkeypatch.setattr(
|
|
63
|
+
evolve_applier, "_open_prs_for_issue",
|
|
64
|
+
lambda issue_number, repo_root=None: ([], ""),
|
|
65
|
+
)
|
|
66
|
+
# Note: _resolve_claim_race is NOT monkeypatched here so the new
|
|
67
|
+
# multi-host tests can exercise the real implementation. With the default
|
|
68
|
+
# _fetch_issue_comments returning [], the race resolver sees ≤1 active
|
|
69
|
+
# claim and returns (True, "") — existing tests behave the same.
|
|
70
|
+
monkeypatch.setattr(
|
|
71
|
+
evolve_applier, "_delete_issue_comment",
|
|
72
|
+
lambda comment_url, repo_root=None: "",
|
|
73
|
+
)
|
|
74
|
+
# Skip the real-time race-detection sleep in unit tests so the suite stays
|
|
75
|
+
# snappy. The bootstrap defaults already make the race resolver return True
|
|
76
|
+
# in the "no competing claim" common path.
|
|
77
|
+
import threadkeeper.config as _cfg
|
|
78
|
+
monkeypatch.setattr(_cfg, "ROADMAP_CLAIM_RACE_WINDOW_S", 0.0)
|
|
79
|
+
monkeypatch.setattr(evolve_applier, "ROADMAP_CLAIM_RACE_WINDOW_S", 0.0)
|
|
62
80
|
return {"mcp": _mcp.mcp, "db": db, "ea": evolve_applier, "identity": identity}
|
|
63
81
|
|
|
64
82
|
|
|
@@ -415,7 +433,10 @@ def test_apply_roadmap_issue_comments_before_spawn(
|
|
|
415
433
|
|
|
416
434
|
def _claim(issue, repo_root=None):
|
|
417
435
|
order.append(f"claim#{int(issue['number'])}")
|
|
418
|
-
return
|
|
436
|
+
return (
|
|
437
|
+
f"https://x/issues/{int(issue['number'])}#issuecomment-99",
|
|
438
|
+
"",
|
|
439
|
+
)
|
|
419
440
|
|
|
420
441
|
def _spawn(**kw):
|
|
421
442
|
order.append("spawn")
|
|
@@ -441,7 +462,7 @@ def test_apply_roadmap_issue_queue_reports_no_startable_when_claim_fails(
|
|
|
441
462
|
)
|
|
442
463
|
monkeypatch.setattr(
|
|
443
464
|
pkg["ea"], "_comment_issue_claim",
|
|
444
|
-
lambda issue, repo_root=None: "gh_issue_comment_failed: denied",
|
|
465
|
+
lambda issue, repo_root=None: ("", "gh_issue_comment_failed: denied"),
|
|
445
466
|
)
|
|
446
467
|
|
|
447
468
|
def _boom(**kw):
|
|
@@ -473,8 +494,8 @@ def test_apply_roadmap_issue_queue_tries_next_when_claim_fails(
|
|
|
473
494
|
num = int(issue["number"])
|
|
474
495
|
claimed.append(num)
|
|
475
496
|
if num == 1:
|
|
476
|
-
return "gh_issue_comment_failed: locked"
|
|
477
|
-
return ""
|
|
497
|
+
return "", "gh_issue_comment_failed: locked"
|
|
498
|
+
return f"https://x/issues/{num}#issuecomment-{num}", ""
|
|
478
499
|
|
|
479
500
|
monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
|
|
480
501
|
calls = {}
|
|
@@ -501,7 +522,7 @@ def test_apply_roadmap_issue_exact_issue_does_not_switch_tasks(
|
|
|
501
522
|
)
|
|
502
523
|
monkeypatch.setattr(
|
|
503
524
|
pkg["ea"], "_comment_issue_claim",
|
|
504
|
-
lambda issue, repo_root=None: "gh_issue_comment_failed: locked",
|
|
525
|
+
lambda issue, repo_root=None: ("", "gh_issue_comment_failed: locked"),
|
|
505
526
|
)
|
|
506
527
|
|
|
507
528
|
def _boom(**kw):
|
|
@@ -557,6 +578,263 @@ def test_mark_roadmap_issue_applied_tool_requires_pr_url(
|
|
|
557
578
|
assert row["summary"] == "https://github.com/o/r/pull/6"
|
|
558
579
|
|
|
559
580
|
|
|
581
|
+
# ── multi-host: cross-machine conflict guards ──────────────────────────────
|
|
582
|
+
|
|
583
|
+
def test_apply_roadmap_issue_skips_when_open_pr_already_closes_it(
|
|
584
|
+
tmp_path, monkeypatch,
|
|
585
|
+
):
|
|
586
|
+
"""If another host (or a prior crashed applier) already opened a PR for
|
|
587
|
+
this issue, do NOT spawn or claim — fall through to the next candidate."""
|
|
588
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
589
|
+
monkeypatch.setattr(
|
|
590
|
+
pkg["ea"], "_fetch_open_issues",
|
|
591
|
+
lambda repo_root=None: (
|
|
592
|
+
[_issue(6, "Telemetry dashboard"), _issue(7, "Free issue")],
|
|
593
|
+
"",
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
monkeypatch.setattr(
|
|
597
|
+
pkg["ea"], "_open_prs_for_issue",
|
|
598
|
+
lambda issue_number, repo_root=None: (
|
|
599
|
+
[{"url": "https://github.com/o/r/pull/42",
|
|
600
|
+
"number": 42}] if int(issue_number) == 6 else [],
|
|
601
|
+
"",
|
|
602
|
+
),
|
|
603
|
+
)
|
|
604
|
+
claimed = []
|
|
605
|
+
|
|
606
|
+
def _claim(issue, repo_root=None):
|
|
607
|
+
num = int(issue["number"])
|
|
608
|
+
claimed.append(num)
|
|
609
|
+
return f"https://x/issues/{num}#issuecomment-{num}", ""
|
|
610
|
+
|
|
611
|
+
monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
|
|
612
|
+
calls = {}
|
|
613
|
+
_mock_spawn(monkeypatch, calls)
|
|
614
|
+
|
|
615
|
+
out = pkg["ea"].apply_roadmap_issue()
|
|
616
|
+
|
|
617
|
+
# advanced past #6 (open PR) to #7
|
|
618
|
+
assert out.startswith("spawned roadmap_issue=#7"), out
|
|
619
|
+
# claim was NOT posted for #6 — the open-PR check ran before claim
|
|
620
|
+
assert claimed == [7]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def test_apply_roadmap_issue_exact_mode_returns_open_pr_error(
|
|
624
|
+
tmp_path, monkeypatch,
|
|
625
|
+
):
|
|
626
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
627
|
+
monkeypatch.setattr(
|
|
628
|
+
pkg["ea"], "_fetch_open_issues",
|
|
629
|
+
lambda repo_root=None: ([_issue(6, "Telemetry dashboard")], ""),
|
|
630
|
+
)
|
|
631
|
+
monkeypatch.setattr(
|
|
632
|
+
pkg["ea"], "_open_prs_for_issue",
|
|
633
|
+
lambda issue_number, repo_root=None: (
|
|
634
|
+
[{"url": "https://github.com/o/r/pull/42"}], "",
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def _claim(issue, repo_root=None):
|
|
639
|
+
raise AssertionError("must not claim when an open PR already exists")
|
|
640
|
+
|
|
641
|
+
monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
|
|
642
|
+
|
|
643
|
+
def _boom(**kw):
|
|
644
|
+
raise AssertionError("must not spawn when an open PR already exists")
|
|
645
|
+
|
|
646
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
647
|
+
monkeypatch.setattr(spawn_mod, "spawn", _boom)
|
|
648
|
+
|
|
649
|
+
out = pkg["ea"].apply_roadmap_issue(issue_number=6)
|
|
650
|
+
|
|
651
|
+
assert out.startswith("ERR roadmap_issue_open_pr=#6"), out
|
|
652
|
+
assert "pull/42" in out
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def test_apply_roadmap_issue_retracts_claim_on_lost_race(
|
|
656
|
+
tmp_path, monkeypatch,
|
|
657
|
+
):
|
|
658
|
+
"""TOCTOU: after we post our claim, a competing host's earlier claim is
|
|
659
|
+
visible. We retract our own claim and let the queue advance."""
|
|
660
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
661
|
+
monkeypatch.setattr(
|
|
662
|
+
pkg["ea"], "_fetch_open_issues",
|
|
663
|
+
lambda repo_root=None: (
|
|
664
|
+
[_issue(6, "Telemetry dashboard"), _issue(7, "Other issue")],
|
|
665
|
+
"",
|
|
666
|
+
),
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def _claim(issue, repo_root=None):
|
|
670
|
+
return (
|
|
671
|
+
f"https://x/issues/{int(issue['number'])}#issuecomment-mine",
|
|
672
|
+
"",
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
|
|
676
|
+
|
|
677
|
+
def _race(issue_number, my_comment_url, repo_root=None):
|
|
678
|
+
if int(issue_number) == 6:
|
|
679
|
+
return False, "" # lost
|
|
680
|
+
return True, ""
|
|
681
|
+
|
|
682
|
+
monkeypatch.setattr(pkg["ea"], "_resolve_claim_race", _race)
|
|
683
|
+
calls = {}
|
|
684
|
+
_mock_spawn(monkeypatch, calls)
|
|
685
|
+
|
|
686
|
+
out = pkg["ea"].apply_roadmap_issue()
|
|
687
|
+
|
|
688
|
+
assert out.startswith("spawned roadmap_issue=#7"), out
|
|
689
|
+
assert "ISSUE #7: Other issue" in calls["prompt"]
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def test_apply_roadmap_issue_retracts_claim_on_spawn_failure(
|
|
693
|
+
tmp_path, monkeypatch,
|
|
694
|
+
):
|
|
695
|
+
"""If spawn() raises after we posted our claim, retract the claim so the
|
|
696
|
+
next pass can retry the issue immediately instead of waiting 24h TTL."""
|
|
697
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
698
|
+
monkeypatch.setattr(
|
|
699
|
+
pkg["ea"], "_fetch_open_issues",
|
|
700
|
+
lambda repo_root=None: ([_issue(6, "Telemetry dashboard")], ""),
|
|
701
|
+
)
|
|
702
|
+
monkeypatch.setattr(
|
|
703
|
+
pkg["ea"], "_comment_issue_claim",
|
|
704
|
+
lambda issue, repo_root=None: (
|
|
705
|
+
"https://x/issues/6#issuecomment-mine", "",
|
|
706
|
+
),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
deleted = []
|
|
710
|
+
monkeypatch.setattr(
|
|
711
|
+
pkg["ea"], "_delete_issue_comment",
|
|
712
|
+
lambda comment_url, repo_root=None: (
|
|
713
|
+
deleted.append(comment_url) or ""
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
718
|
+
monkeypatch.setattr(
|
|
719
|
+
spawn_mod, "spawn",
|
|
720
|
+
lambda **kw: (_ for _ in ()).throw(RuntimeError("spawn rejected")),
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
out = pkg["ea"].apply_roadmap_issue(issue_number=6)
|
|
724
|
+
|
|
725
|
+
assert out.startswith("spawn_error issue=#6"), out
|
|
726
|
+
assert "spawn rejected" in out
|
|
727
|
+
assert deleted == ["https://x/issues/6#issuecomment-mine"]
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def test_resolve_claim_race_wins_when_oldest_active_claim_is_ours(
|
|
731
|
+
tmp_path, monkeypatch,
|
|
732
|
+
):
|
|
733
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
734
|
+
monkeypatch.setattr(
|
|
735
|
+
pkg["ea"], "_fetch_issue_comments",
|
|
736
|
+
lambda issue_number, repo_root=None: (
|
|
737
|
+
[
|
|
738
|
+
{
|
|
739
|
+
"body": "<!-- thread-keeper:evolve-applier-claim -->\nmine",
|
|
740
|
+
"url": "https://x/issues/6#issuecomment-100",
|
|
741
|
+
"createdAt": "2026-06-14T12:00:00Z",
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
"body": "<!-- thread-keeper:evolve-applier-claim -->\nthem",
|
|
745
|
+
"url": "https://x/issues/6#issuecomment-200",
|
|
746
|
+
"createdAt": "2026-06-14T12:00:03Z",
|
|
747
|
+
},
|
|
748
|
+
],
|
|
749
|
+
"",
|
|
750
|
+
),
|
|
751
|
+
)
|
|
752
|
+
monkeypatch.setattr(pkg["ea"].time, "time", lambda: 1781438400.0)
|
|
753
|
+
monkeypatch.setattr(pkg["ea"].time, "sleep", lambda _s: None)
|
|
754
|
+
|
|
755
|
+
won, err = pkg["ea"]._resolve_claim_race(
|
|
756
|
+
6, "https://x/issues/6#issuecomment-100",
|
|
757
|
+
)
|
|
758
|
+
assert err == ""
|
|
759
|
+
assert won is True
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def test_resolve_claim_race_loses_and_deletes_own_claim(
|
|
763
|
+
tmp_path, monkeypatch,
|
|
764
|
+
):
|
|
765
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
766
|
+
monkeypatch.setattr(
|
|
767
|
+
pkg["ea"], "_fetch_issue_comments",
|
|
768
|
+
lambda issue_number, repo_root=None: (
|
|
769
|
+
[
|
|
770
|
+
{
|
|
771
|
+
"body": "<!-- thread-keeper:evolve-applier-claim -->\nthem",
|
|
772
|
+
"url": "https://x/issues/6#issuecomment-100",
|
|
773
|
+
"createdAt": "2026-06-14T12:00:00Z",
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
"body": "<!-- thread-keeper:evolve-applier-claim -->\nmine",
|
|
777
|
+
"url": "https://x/issues/6#issuecomment-200",
|
|
778
|
+
"createdAt": "2026-06-14T12:00:03Z",
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
"",
|
|
782
|
+
),
|
|
783
|
+
)
|
|
784
|
+
monkeypatch.setattr(pkg["ea"].time, "time", lambda: 1781438400.0)
|
|
785
|
+
monkeypatch.setattr(pkg["ea"].time, "sleep", lambda _s: None)
|
|
786
|
+
|
|
787
|
+
deleted = []
|
|
788
|
+
monkeypatch.setattr(
|
|
789
|
+
pkg["ea"], "_delete_issue_comment",
|
|
790
|
+
lambda url, repo_root=None: (deleted.append(url) or ""),
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
won, err = pkg["ea"]._resolve_claim_race(
|
|
794
|
+
6, "https://x/issues/6#issuecomment-200",
|
|
795
|
+
)
|
|
796
|
+
assert err == ""
|
|
797
|
+
assert won is False
|
|
798
|
+
assert deleted == ["https://x/issues/6#issuecomment-200"]
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def test_claim_body_includes_host_pid_git_rev(tmp_path, monkeypatch):
|
|
802
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
803
|
+
issue = _issue(42, "Cross-host check")
|
|
804
|
+
body = pkg["ea"]._roadmap_issue_claim_body(issue, now_t=1781438400.0)
|
|
805
|
+
assert pkg["ea"].ROADMAP_ISSUE_CLAIM_MARKER in body
|
|
806
|
+
# The new identity block fields must be present so multi-host triage works.
|
|
807
|
+
assert "- Host:" in body
|
|
808
|
+
assert "- PID:" in body
|
|
809
|
+
assert "- Git rev:" in body
|
|
810
|
+
assert "- Started:" in body
|
|
811
|
+
assert "Claim TTL:" in body
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def test_roadmap_branch_name_carries_host_suffix(tmp_path, monkeypatch):
|
|
815
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
816
|
+
branch = pkg["ea"].roadmap_issue_branch_name(7, "Hot config reload")
|
|
817
|
+
assert branch.startswith("roadmap/issue-7-hot-config-reload-")
|
|
818
|
+
suffix = branch.rsplit("-", 1)[-1]
|
|
819
|
+
# 6 hex chars from the hostname sha1
|
|
820
|
+
assert len(suffix) == 6
|
|
821
|
+
assert all(c in "0123456789abcdef" for c in suffix)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def test_comment_url_to_id_parses_github_url_shape():
|
|
825
|
+
"""The race resolver relies on this to match our own posted claim back to
|
|
826
|
+
the comments list."""
|
|
827
|
+
from threadkeeper.evolve_applier import _comment_url_to_id
|
|
828
|
+
assert _comment_url_to_id(
|
|
829
|
+
"https://github.com/o/r/issues/6#issuecomment-12345"
|
|
830
|
+
) == "12345"
|
|
831
|
+
assert _comment_url_to_id(
|
|
832
|
+
"https://github.com/o/r/issues/6#issuecomment_67890"
|
|
833
|
+
) == "67890"
|
|
834
|
+
assert _comment_url_to_id("https://github.com/o/r/issues/6") == ""
|
|
835
|
+
assert _comment_url_to_id("") == ""
|
|
836
|
+
|
|
837
|
+
|
|
560
838
|
# ── single-flight: refuse while an applier child runs ──────────────────────
|
|
561
839
|
|
|
562
840
|
def test_apply_evolve_single_flight(tmp_path, monkeypatch):
|
|
@@ -777,9 +1055,10 @@ def test_run_apply_pass_skips_unstartable_issue_and_spawns_next(
|
|
|
777
1055
|
)
|
|
778
1056
|
|
|
779
1057
|
def _claim(issue, repo_root=None):
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1058
|
+
num = int(issue["number"])
|
|
1059
|
+
if num == 1:
|
|
1060
|
+
return "", "gh_issue_comment_failed: locked"
|
|
1061
|
+
return f"https://x/issues/{num}#issuecomment-{num}", ""
|
|
783
1062
|
|
|
784
1063
|
monkeypatch.setattr(pkg["ea"], "_comment_issue_claim", _claim)
|
|
785
1064
|
calls = {}
|
|
@@ -803,7 +1082,7 @@ def test_run_apply_pass_falls_back_to_curator_when_no_issue_startable(
|
|
|
803
1082
|
)
|
|
804
1083
|
monkeypatch.setattr(
|
|
805
1084
|
pkg["ea"], "_comment_issue_claim",
|
|
806
|
-
lambda issue, repo_root=None: "gh_issue_comment_failed: locked",
|
|
1085
|
+
lambda issue, repo_root=None: ("", "gh_issue_comment_failed: locked"),
|
|
807
1086
|
)
|
|
808
1087
|
calls = {}
|
|
809
1088
|
_mock_spawn(monkeypatch, calls)
|
|
@@ -202,6 +202,10 @@ class Settings(BaseSettings):
|
|
|
202
202
|
# Periodically picks the top promoted+unapplied evolve suggestion and fires
|
|
203
203
|
# evolve_apply (spawns a child that implements it + opens a PR). 0 = off.
|
|
204
204
|
evolve_apply_interval_s: float = 0.0
|
|
205
|
+
# After posting a roadmap-issue claim comment, wait this long, re-fetch
|
|
206
|
+
# comments, and retract our claim if another host raced us. Cross-host
|
|
207
|
+
# TOCTOU guard. Set to 0 in tests to skip the wait.
|
|
208
|
+
roadmap_claim_race_window_s: float = 3.0
|
|
205
209
|
|
|
206
210
|
# ── Thread janitor daemon ─────────────────────────────────────────────────
|
|
207
211
|
thread_janitor_interval_s: float = 0.0
|
|
@@ -331,6 +335,7 @@ PANEL_EFFORT: str = settings.panel_effort
|
|
|
331
335
|
EVOLVE_REVIEW_INTERVAL_S: float = settings.evolve_review_interval_s
|
|
332
336
|
EVOLVE_REVIEW_MIN: int = settings.evolve_review_min
|
|
333
337
|
EVOLVE_APPLY_INTERVAL_S: float = settings.evolve_apply_interval_s
|
|
338
|
+
ROADMAP_CLAIM_RACE_WINDOW_S: float = settings.roadmap_claim_race_window_s
|
|
334
339
|
THREAD_JANITOR_INTERVAL_S: float = settings.thread_janitor_interval_s
|
|
335
340
|
THREAD_IDLE_CLOSE_DAYS: float = settings.thread_idle_close_days
|
|
336
341
|
DIALECTIC_MINE_INTERVAL_S: float = settings.dialectic_mine_interval_s
|