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.
Files changed (151) hide show
  1. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/PKG-INFO +1 -1
  2. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/pyproject.toml +1 -1
  3. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_evolve_applier.py +289 -10
  4. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/config.py +5 -0
  5. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/evolve_applier.py +256 -13
  6. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/PKG-INFO +1 -1
  7. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/LICENSE +0 -0
  8. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/README.md +0 -0
  9. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/setup.cfg +0 -0
  10. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_adapters.py +0 -0
  11. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_agent_status.py +0 -0
  12. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_auto_update.py +0 -0
  13. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_brief_footprint.py +0 -0
  14. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_brief_sections.py +0 -0
  15. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_candidate_reviewer.py +0 -0
  16. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_config_settings.py +0 -0
  17. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_core_memory.py +0 -0
  18. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_curator.py +0 -0
  19. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dashboard.py +0 -0
  20. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_delegated_search.py +0 -0
  21. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic.py +0 -0
  22. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_feed_tools.py +0 -0
  23. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_miner.py +0 -0
  24. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_observation_resolve.py +0 -0
  25. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_recompute.py +0 -0
  26. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_tier.py +0 -0
  27. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_dialectic_validator.py +0 -0
  28. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_error_paths.py +0 -0
  29. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_evolve_apply_2.py +0 -0
  30. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_evolve_apply_3.py +0 -0
  31. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_evolve_daemon.py +0 -0
  32. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_extract_daemon.py +0 -0
  33. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_extract_dedup.py +0 -0
  34. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_i18n_multilang.py +0 -0
  35. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_identity.py +0 -0
  36. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_ingest_status.py +0 -0
  37. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_lessons.py +0 -0
  38. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_memory_guard.py +0 -0
  39. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_menubar_app.py +0 -0
  40. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_missed_spawns.py +0 -0
  41. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_nudges.py +0 -0
  42. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_onnx_embeddings.py +0 -0
  43. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_panel.py +0 -0
  44. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_probe_daemon.py +0 -0
  45. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_process_health.py +0 -0
  46. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_search_fts_punctuation.py +0 -0
  47. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_shadow_review.py +0 -0
  48. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_hint.py +0 -0
  49. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_passive_tier.py +0 -0
  50. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_tier.py +0 -0
  51. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_use_parser.py +0 -0
  52. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skill_watcher.py +0 -0
  53. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_skills.py +0 -0
  54. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_budget.py +0 -0
  55. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_codex_stdin.py +0 -0
  56. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_config.py +0 -0
  57. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_hint.py +0 -0
  58. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_reap.py +0 -0
  59. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_slim.py +0 -0
  60. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_spawn_wrap.py +0 -0
  61. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_thread_janitor.py +0 -0
  62. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_threads.py +0 -0
  63. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_tools_smoke.py +0 -0
  64. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_validate_threads.py +0 -0
  65. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/tests/test_vec_search.py +0 -0
  66. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/__init__.py +0 -0
  67. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/_mcp.py +0 -0
  68. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/_setup.py +0 -0
  69. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/_spawn_wrap.py +0 -0
  70. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/__init__.py +0 -0
  71. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
  72. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/antigravity.py +0 -0
  73. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/base.py +0 -0
  74. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/claude_code.py +0 -0
  75. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/claude_desktop.py +0 -0
  76. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/codex.py +0 -0
  77. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/copilot.py +0 -0
  78. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/gemini.py +0 -0
  79. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/adapters/vscode.py +0 -0
  80. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/agent_status.py +0 -0
  81. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
  82. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/README.md +0 -0
  83. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
  84. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
  85. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
  86. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/auto_update.py +0 -0
  87. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/brief.py +0 -0
  88. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/candidate_reviewer.py +0 -0
  89. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/curator.py +0 -0
  90. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/db.py +0 -0
  91. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/dialectic_miner.py +0 -0
  92. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/dialectic_validator.py +0 -0
  93. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/embeddings.py +0 -0
  94. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/evolve_daemon.py +0 -0
  95. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/extract_daemon.py +0 -0
  96. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/helpers.py +0 -0
  97. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/i18n.py +0 -0
  98. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/identity.py +0 -0
  99. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/ingest.py +0 -0
  100. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/lessons.py +0 -0
  101. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/memory_guard.py +0 -0
  102. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/menubar_app.py +0 -0
  103. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/migrate_embeddings.py +0 -0
  104. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/nudges.py +0 -0
  105. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/probe_daemon.py +0 -0
  106. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/process_health.py +0 -0
  107. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/review_prompts.py +0 -0
  108. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/search_proxy.py +0 -0
  109. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/server.py +0 -0
  110. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/shadow_review.py +0 -0
  111. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/skill_watcher.py +0 -0
  112. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/spawn_budget.py +0 -0
  113. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/spawn_config.py +0 -0
  114. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/thread_janitor.py +0 -0
  115. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/__init__.py +0 -0
  116. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/agent_status.py +0 -0
  117. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
  118. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/concepts.py +0 -0
  119. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/consolidate.py +0 -0
  120. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/core_memory.py +0 -0
  121. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/correlation.py +0 -0
  122. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/curator.py +0 -0
  123. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/dashboard.py +0 -0
  124. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/dialectic.py +0 -0
  125. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/dialectic_feed.py +0 -0
  126. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/dialog.py +0 -0
  127. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/distill.py +0 -0
  128. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/evolve_applier.py +0 -0
  129. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/extract.py +0 -0
  130. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/graph.py +0 -0
  131. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/invariants.py +0 -0
  132. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/lessons.py +0 -0
  133. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/memory_guard.py +0 -0
  134. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/missed_spawns.py +0 -0
  135. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/panel.py +0 -0
  136. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/peers.py +0 -0
  137. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/pickup.py +0 -0
  138. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/probes.py +0 -0
  139. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/process_health.py +0 -0
  140. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/session.py +0 -0
  141. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/shadow_review.py +0 -0
  142. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/skills.py +0 -0
  143. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/spawn.py +0 -0
  144. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/style.py +0 -0
  145. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/threads.py +0 -0
  146. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper/tools/validate.py +0 -0
  147. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/SOURCES.txt +0 -0
  148. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
  149. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/entry_points.txt +0 -0
  150. {threadkeeper-0.11.0 → threadkeeper-0.12.0}/threadkeeper.egg-info/requires.txt +0 -0
  151. {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.11.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.11.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
- if int(issue["number"]) == 1:
781
- return "gh_issue_comment_failed: locked"
782
- return ""
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