harneloop 0.0.2__py3-none-any.whl

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.
harneloop/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Harneloop core lifecycle engine."""
2
+
3
+ __version__ = "0.0.1"
harneloop/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
harneloop/adapters.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .errors import HarneloopError
6
+ from .state import read_state
7
+ from .versioning import ensure_unit
8
+ from .yamlio import read_yaml
9
+
10
+
11
+ SUPPORTED_ADAPTERS = {"generic", "codex", "cursor"}
12
+
13
+
14
+ def collect_agent_markdown(unit_root: Path) -> str:
15
+ sections: list[str] = []
16
+ unit_agent = unit_root / "UNIT_AGENT.md"
17
+ if unit_agent.exists():
18
+ sections.append(unit_agent.read_text(encoding="utf-8").strip())
19
+
20
+ agent_facing = unit_root / "agent-facing"
21
+ if agent_facing.exists():
22
+ for path in sorted(agent_facing.rglob("*.md")):
23
+ sections.append(path.read_text(encoding="utf-8").strip())
24
+
25
+ return "\n\n---\n\n".join(section for section in sections if section)
26
+
27
+
28
+ def export_body(unit_root: Path, adapter: str) -> str:
29
+ unit_meta = read_yaml(unit_root / "unit.yaml")
30
+ state = read_state(unit_root)
31
+ instructions = collect_agent_markdown(unit_root)
32
+ current_version = unit_meta.get("current_version") or "none"
33
+
34
+ return "\n".join(
35
+ [
36
+ f"# {unit_meta.get('name', unit_meta.get('id', 'Harneloop Unit'))}",
37
+ "",
38
+ f"Export adapter: `{adapter}`",
39
+ f"Unit id: `{unit_meta.get('id', 'unknown')}`",
40
+ f"Current version: `{current_version}`",
41
+ f"Lifecycle state: `{state.get('state', 'unknown')}`",
42
+ "",
43
+ "## Harness Instructions",
44
+ "",
45
+ instructions or "No agent-facing instructions have been promoted yet.",
46
+ "",
47
+ "## Operating Rules",
48
+ "",
49
+ "- Treat these instructions as the promoted harness for this task family.",
50
+ "- Do not edit the source harness unit directly from this export.",
51
+ "- Improvements should be proposed as Harneloop candidates and promoted through evidence gates.",
52
+ "- Runtime traces, raw artifacts, local caches, and secrets are not part of this export.",
53
+ ]
54
+ ) + "\n"
55
+
56
+
57
+ def export_unit(unit_root: Path, adapter: str, output: Path | None = None) -> Path:
58
+ unit_root = unit_root.resolve()
59
+ ensure_unit(unit_root)
60
+ if adapter not in SUPPORTED_ADAPTERS:
61
+ supported = ", ".join(sorted(SUPPORTED_ADAPTERS))
62
+ raise HarneloopError(f"Unsupported adapter `{adapter}`. Expected one of: {supported}")
63
+
64
+ export_root = (output or (unit_root / "exports" / adapter)).resolve()
65
+ export_root.mkdir(parents=True, exist_ok=True)
66
+ body = export_body(unit_root, adapter)
67
+
68
+ if adapter == "codex":
69
+ (export_root / "AGENTS.md").write_text(body, encoding="utf-8", newline="\n")
70
+ elif adapter == "cursor":
71
+ cursor_body = "---\ndescription: Harneloop exported harness unit\nalwaysApply: false\n---\n\n" + body
72
+ (export_root / "harneloop-unit.mdc").write_text(cursor_body, encoding="utf-8", newline="\n")
73
+ else:
74
+ (export_root / "UNIT_AGENT.md").write_text(body, encoding="utf-8", newline="\n")
75
+
76
+ return export_root
harneloop/attempts.py ADDED
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .errors import HarneloopError
7
+ from .locking import file_lock, harness_lock_path
8
+ from .runs import read_run, write_run
9
+ from .state import now_iso, update_state
10
+ from .versioning import ensure_unit
11
+ from .yamlio import read_yaml, write_yaml
12
+
13
+
14
+ def normalize_list(value: list[str] | tuple[str, ...] | None) -> list[str]:
15
+ return [item for item in (value or []) if item]
16
+
17
+
18
+ def attempts_root(unit_root: Path) -> Path:
19
+ return unit_root / "attempts"
20
+
21
+
22
+ def next_attempt_id(unit_root: Path) -> str:
23
+ root = attempts_root(unit_root)
24
+ root.mkdir(parents=True, exist_ok=True)
25
+ existing: list[int] = []
26
+ for path in root.iterdir():
27
+ if path.is_dir() and path.name.startswith("attempt-"):
28
+ try:
29
+ existing.append(int(path.name.removeprefix("attempt-")))
30
+ except ValueError:
31
+ continue
32
+ return f"attempt-{(max(existing, default=0) + 1):04d}"
33
+
34
+
35
+ def attempt_path(unit_root: Path, attempt_id: str) -> Path:
36
+ path = attempts_root(unit_root) / attempt_id
37
+ if not path.exists():
38
+ raise HarneloopError(f"Attempt plan does not exist: {attempt_id}")
39
+ return path
40
+
41
+
42
+ def read_attempt(unit_root: Path, attempt_id: str) -> dict[str, Any]:
43
+ return read_yaml(attempt_path(unit_root, attempt_id) / "attempt.yaml")
44
+
45
+
46
+ def write_attempt(unit_root: Path, attempt_id: str, data: dict[str, Any]) -> dict[str, Any]:
47
+ write_yaml(attempt_path(unit_root, attempt_id) / "attempt.yaml", data)
48
+ write_observations_markdown(unit_root, attempt_id, data)
49
+ return data
50
+
51
+
52
+ def create_attempt_plan(
53
+ unit_root: Path,
54
+ goal: str,
55
+ method: str,
56
+ action: list[str] | tuple[str, ...] | None = None,
57
+ expected_artifact: list[str] | tuple[str, ...] | None = None,
58
+ success_check: list[str] | tuple[str, ...] | None = None,
59
+ note: list[str] | tuple[str, ...] | None = None,
60
+ ) -> dict[str, Any]:
61
+ unit_root = unit_root.resolve()
62
+ ensure_unit(unit_root)
63
+ if not goal.strip():
64
+ raise HarneloopError("Attempt goal cannot be empty")
65
+ if not method.strip():
66
+ raise HarneloopError("Attempt method cannot be empty")
67
+
68
+ with file_lock(harness_lock_path(unit_root, "attempts")):
69
+ attempt_id = next_attempt_id(unit_root)
70
+ root = attempts_root(unit_root) / attempt_id
71
+ root.mkdir(parents=True, exist_ok=False)
72
+ data: dict[str, Any] = {
73
+ "schema_version": "0.1",
74
+ "id": attempt_id,
75
+ "goal": goal,
76
+ "method": method,
77
+ "actions": normalize_list(action),
78
+ "expected_artifacts": normalize_list(expected_artifact),
79
+ "success_checks": normalize_list(success_check),
80
+ "notes": normalize_list(note),
81
+ "observations": [],
82
+ "conclusions": [],
83
+ "created_at": now_iso(),
84
+ }
85
+ write_attempt(unit_root, attempt_id, data)
86
+ update_state(
87
+ unit_root,
88
+ reason="attempt_plan_created",
89
+ next_action=f"Execute `{attempt_id}` with the target agent/tools, then start a run and capture artifacts.",
90
+ )
91
+ return data
92
+
93
+
94
+ def add_attempt_observation(
95
+ unit_root: Path,
96
+ attempt_id: str,
97
+ summary: str,
98
+ outcome: str = "unknown",
99
+ run_id: str | None = None,
100
+ finding: list[str] | tuple[str, ...] | None = None,
101
+ ) -> dict[str, Any]:
102
+ if not summary.strip():
103
+ raise HarneloopError("Observation summary cannot be empty")
104
+ unit_root = unit_root.resolve()
105
+ with file_lock(harness_lock_path(unit_root, f"attempt-{attempt_id}")):
106
+ data = read_attempt(unit_root, attempt_id)
107
+ observations = data.get("observations") or []
108
+ observation = {
109
+ "id": f"observation-{len(observations) + 1:04d}",
110
+ "summary": summary,
111
+ "outcome": outcome,
112
+ "run_id": run_id,
113
+ "findings": normalize_list(finding),
114
+ "created_at": now_iso(),
115
+ }
116
+ data["observations"] = [*observations, observation]
117
+ write_attempt(unit_root, attempt_id, data)
118
+ update_state(
119
+ unit_root,
120
+ reason="attempt_observation_added",
121
+ next_action="Use observations to create evidence, candidate changes, or another attempt plan.",
122
+ )
123
+ return observation
124
+
125
+
126
+ VALID_CONCLUSION_OUTCOMES = {"pass", "partial", "fail", "inconclusive"}
127
+ VALID_CONCLUSION_DECISIONS = {"accept", "create_candidate", "rerun", "request_input", "stop"}
128
+ VALID_CONFIDENCE = {"low", "medium", "high"}
129
+
130
+
131
+ def conclude_attempt(
132
+ unit_root: Path,
133
+ attempt_id: str,
134
+ *,
135
+ run_id: str,
136
+ outcome: str,
137
+ decision: str,
138
+ summary: str,
139
+ confidence: str,
140
+ question: str | None = None,
141
+ ) -> dict[str, Any]:
142
+ if outcome not in VALID_CONCLUSION_OUTCOMES:
143
+ raise HarneloopError(f"Invalid attempt outcome: {outcome}")
144
+ if decision not in VALID_CONCLUSION_DECISIONS:
145
+ raise HarneloopError(f"Invalid attempt decision: {decision}")
146
+ if confidence not in VALID_CONFIDENCE:
147
+ raise HarneloopError(f"Invalid conclusion confidence: {confidence}")
148
+ if not summary.strip():
149
+ raise HarneloopError("Attempt conclusion summary cannot be empty")
150
+ if decision == "request_input" and not (question or "").strip():
151
+ raise HarneloopError("The `request_input` decision requires a concrete question")
152
+ if decision == "accept" and outcome != "pass":
153
+ raise HarneloopError("Only a passing attempt can be accepted without further work")
154
+
155
+ unit_root = unit_root.resolve()
156
+ run = read_run(unit_root, run_id)
157
+ if run.get("status") == "running":
158
+ raise HarneloopError(f"Run must be finished before evaluation: {run_id}")
159
+ if run.get("attempt_id") != attempt_id:
160
+ raise HarneloopError(f"Run `{run_id}` is not linked to attempt `{attempt_id}`")
161
+
162
+ with file_lock(harness_lock_path(unit_root, f"attempt-{attempt_id}")):
163
+ data = read_attempt(unit_root, attempt_id)
164
+ expected = data.get("expected_artifacts") or []
165
+ captured = {artifact.get("kind") for artifact in (run.get("artifacts") or [])}
166
+ missing = [kind for kind in expected if kind not in captured]
167
+ if outcome == "pass" and missing:
168
+ missing_text = ", ".join(missing)
169
+ raise HarneloopError(f"Passing conclusion is missing expected artifacts: {missing_text}")
170
+
171
+ conclusions = data.get("conclusions") or []
172
+ conclusion = {
173
+ "id": f"conclusion-{len(conclusions) + 1:04d}",
174
+ "run_id": run_id,
175
+ "outcome": outcome,
176
+ "decision": decision,
177
+ "summary": summary.strip(),
178
+ "confidence": confidence,
179
+ "captured_artifact_kinds": sorted(kind for kind in captured if kind),
180
+ "missing_expected_artifacts": missing,
181
+ "question": (question or "").strip() or None,
182
+ "created_at": now_iso(),
183
+ }
184
+ data["conclusions"] = [*conclusions, conclusion]
185
+ write_attempt(unit_root, attempt_id, data)
186
+
187
+ run["evaluation_status"] = "completed"
188
+ run["evaluation_outcome"] = outcome
189
+ write_run(unit_root, run_id, run)
190
+
191
+ if decision == "accept":
192
+ update_state(
193
+ unit_root,
194
+ state="satisfied",
195
+ reason="current_harness_accepted",
196
+ next_action="No candidate is needed for the current evidence. Resume only for broader tests or a changed goal.",
197
+ )
198
+ elif decision == "create_candidate":
199
+ update_state(
200
+ unit_root,
201
+ state="active",
202
+ reason="candidate_change_needed",
203
+ next_action=f"Create a candidate from conclusion `{conclusion['id']}` and retest it.",
204
+ )
205
+ elif decision == "rerun":
206
+ update_state(
207
+ unit_root,
208
+ state="active",
209
+ reason="attempt_rerun_needed",
210
+ next_action="Revise the attempt or evidence plan and run it again before changing the harness.",
211
+ )
212
+ elif decision == "request_input":
213
+ update_state(
214
+ unit_root,
215
+ state="waiting",
216
+ reason="user_input_required",
217
+ next_action=question.strip(),
218
+ resume_condition="The user provides or delegates the requested context or evidence.",
219
+ )
220
+ else:
221
+ update_state(
222
+ unit_root,
223
+ state="stopped",
224
+ reason="attempt_conclusion_stopped",
225
+ next_action="Resume only when the documented stopping reason changes.",
226
+ )
227
+ return conclusion
228
+
229
+
230
+ def write_observations_markdown(unit_root: Path, attempt_id: str, data: dict[str, Any]) -> None:
231
+ lines = [
232
+ f"# Observations For {attempt_id}",
233
+ "",
234
+ f"Goal: {data.get('goal')}",
235
+ "",
236
+ ]
237
+ observations = data.get("observations") or []
238
+ if not observations:
239
+ lines.append("No observations recorded yet.")
240
+ for observation in observations:
241
+ lines.extend(
242
+ [
243
+ f"## {observation.get('id')}",
244
+ "",
245
+ f"- Outcome: `{observation.get('outcome')}`",
246
+ f"- Run: `{observation.get('run_id') or 'none'}`",
247
+ f"- Summary: {observation.get('summary')}",
248
+ ]
249
+ )
250
+ findings = observation.get("findings") or []
251
+ if findings:
252
+ lines.extend(["", "Findings:"])
253
+ lines.extend(f"- {item}" for item in findings)
254
+ lines.append("")
255
+ (attempt_path(unit_root, attempt_id) / "OBSERVATIONS.md").write_text(
256
+ "\n".join(lines).rstrip() + "\n",
257
+ encoding="utf-8",
258
+ newline="\n",
259
+ )
harneloop/candidate.py ADDED
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .errors import HarneloopError
6
+ from .locking import file_lock, harness_lock_path
7
+ from .state import now_iso, update_state
8
+ from .yamlio import read_yaml, write_yaml
9
+
10
+
11
+ CANDIDATE_PLANES = {"target_harness", "evaluation", "infrastructure", "mixed"}
12
+ VALIDATION_TIERS = ("structural", "targeted", "representative", "full")
13
+ VALIDATION_TIER_RANK = {tier: index for index, tier in enumerate(VALIDATION_TIERS)}
14
+ OPEN_CANDIDATE_STATUSES = {"draft", "accumulating", "ready", "validating", "needs_rebase"}
15
+ TERMINAL_CANDIDATE_STATUSES = {"promoted", "rejected"}
16
+ ALLOWED_STATUS_TRANSITIONS = {
17
+ "draft": {"accumulating", "ready", "rejected"},
18
+ "accumulating": {"ready", "rejected"},
19
+ "ready": {"accumulating", "validating", "rejected"},
20
+ "validating": {"accumulating", "ready", "rejected"},
21
+ "needs_rebase": {"rejected"},
22
+ }
23
+
24
+
25
+ def validate_plane(plane: str) -> str:
26
+ if plane not in CANDIDATE_PLANES:
27
+ supported = ", ".join(sorted(CANDIDATE_PLANES))
28
+ raise HarneloopError(f"Unknown candidate plane `{plane}`. Expected one of: {supported}")
29
+ return plane
30
+
31
+
32
+ def validate_tier(validation_tier: str) -> str:
33
+ if validation_tier not in VALIDATION_TIER_RANK:
34
+ supported = ", ".join(VALIDATION_TIERS)
35
+ raise HarneloopError(f"Unknown validation tier `{validation_tier}`. Expected one of: {supported}")
36
+ return validation_tier
37
+
38
+
39
+ def candidate_path(unit_root: Path, candidate_id: str) -> Path:
40
+ path = unit_root.resolve() / "candidates" / candidate_id
41
+ if not path.exists():
42
+ raise HarneloopError(f"Candidate does not exist: {candidate_id}")
43
+ return path
44
+
45
+
46
+ def read_candidate(unit_root: Path, candidate_id: str) -> dict[str, object]:
47
+ return read_yaml(candidate_path(unit_root, candidate_id) / "candidate.yaml")
48
+
49
+
50
+ def list_candidates(unit_root: Path, include_closed: bool = True) -> list[dict[str, object]]:
51
+ candidates_root = unit_root.resolve() / "candidates"
52
+ if not candidates_root.exists():
53
+ return []
54
+ records = [read_yaml(path / "candidate.yaml") for path in sorted(candidates_root.glob("cand-*")) if path.is_dir()]
55
+ records = [record for record in records if record]
56
+ if not include_closed:
57
+ records = [record for record in records if record.get("status") in OPEN_CANDIDATE_STATUSES]
58
+ return records
59
+
60
+
61
+ def active_candidate_ids(unit_root: Path) -> list[str]:
62
+ return [str(record["id"]) for record in list_candidates(unit_root, include_closed=False)]
63
+
64
+
65
+ def _sync_candidate_state(unit_root: Path, focused_candidate: str | None, reason: str, next_action: str) -> None:
66
+ active = active_candidate_ids(unit_root)
67
+ focused = focused_candidate if focused_candidate in active else (active[-1] if active else None)
68
+ update_state(
69
+ unit_root,
70
+ state="active",
71
+ active_candidates=active,
72
+ active_candidate=focused,
73
+ reason=reason,
74
+ next_action=next_action,
75
+ )
76
+
77
+
78
+ def next_candidate_id(unit_root: Path) -> str:
79
+ candidates_root = unit_root / "candidates"
80
+ candidates_root.mkdir(parents=True, exist_ok=True)
81
+ existing = []
82
+ for path in candidates_root.iterdir():
83
+ if path.is_dir() and path.name.startswith("cand-"):
84
+ try:
85
+ existing.append(int(path.name.removeprefix("cand-")))
86
+ except ValueError:
87
+ continue
88
+ return f"cand-{(max(existing, default=0) + 1):04d}"
89
+
90
+
91
+ def create_candidate(
92
+ unit_root: Path,
93
+ summary: str,
94
+ kind: str = "mixed",
95
+ *,
96
+ plane: str | None = None,
97
+ validation_tier: str = "targeted",
98
+ ) -> Path:
99
+ unit_root = unit_root.resolve()
100
+ if not (unit_root / "unit.yaml").exists():
101
+ raise HarneloopError(f"Not a Harneloop harness unit: {unit_root}")
102
+ selected_plane = validate_plane(plane or (kind if kind in CANDIDATE_PLANES else "mixed"))
103
+ selected_tier = validate_tier(validation_tier)
104
+
105
+ with file_lock(harness_lock_path(unit_root, "candidates")):
106
+ candidate_id = next_candidate_id(unit_root)
107
+ candidate_root = unit_root / "candidates" / candidate_id
108
+ candidate_root.mkdir(parents=True, exist_ok=False)
109
+ for directory in ["changes", "validation", "evidence"]:
110
+ (candidate_root / directory).mkdir(parents=True, exist_ok=True)
111
+
112
+ unit_meta = read_yaml(unit_root / "unit.yaml")
113
+ write_yaml(
114
+ candidate_root / "candidate.yaml",
115
+ {
116
+ "schema_version": "0.2",
117
+ "id": candidate_id,
118
+ "base_version": unit_meta.get("current_version"),
119
+ "kind": kind,
120
+ "plane": selected_plane,
121
+ "validation_tier": selected_tier,
122
+ "status": "accumulating",
123
+ "summary": summary,
124
+ "created_at": now_iso(),
125
+ "changes": {"mode": "overlay", "path": "changes/"},
126
+ "evidence_required": ["rationale", "validation_notes"],
127
+ },
128
+ )
129
+ (candidate_root / "rationale.md").write_text(
130
+ f"# Rationale\n\n{summary}\n", encoding="utf-8", newline="\n"
131
+ )
132
+ (candidate_root / "notes.md").write_text("# Notes\n", encoding="utf-8", newline="\n")
133
+
134
+ _sync_candidate_state(
135
+ unit_root,
136
+ focused_candidate=candidate_id,
137
+ reason="candidate_created",
138
+ next_action=f"Accumulate related changes in `{candidate_id}` and mark it ready when its validation is sufficient.",
139
+ )
140
+ return candidate_root
141
+
142
+
143
+ def set_candidate_status(unit_root: Path, candidate_id: str, status: str) -> dict[str, object]:
144
+ if status not in set().union(*ALLOWED_STATUS_TRANSITIONS.values()):
145
+ supported = ", ".join(sorted(set().union(*ALLOWED_STATUS_TRANSITIONS.values())))
146
+ raise HarneloopError(f"Unsupported candidate status `{status}`. Expected one of: {supported}")
147
+ unit_root = unit_root.resolve()
148
+ with file_lock(harness_lock_path(unit_root, f"candidate-{candidate_id}")):
149
+ path = candidate_path(unit_root, candidate_id) / "candidate.yaml"
150
+ record = read_yaml(path)
151
+ current = str(record.get("status", "draft"))
152
+ allowed = ALLOWED_STATUS_TRANSITIONS.get(current, set())
153
+ if status not in allowed:
154
+ raise HarneloopError(f"Candidate `{candidate_id}` cannot transition from `{current}` to `{status}`")
155
+ record["status"] = status
156
+ record["status_updated_at"] = now_iso()
157
+ write_yaml(path, record)
158
+
159
+ _sync_candidate_state(
160
+ unit_root,
161
+ focused_candidate=candidate_id if status != "rejected" else None,
162
+ reason=f"candidate_{status}",
163
+ next_action=(
164
+ f"Validate and promote `{candidate_id}` when its required evidence is attached."
165
+ if status in {"ready", "validating"}
166
+ else "Continue the remaining candidate work or create another coherent candidate."
167
+ ),
168
+ )
169
+ return record
170
+
171
+
172
+ def rebase_candidate(unit_root: Path, candidate_id: str) -> dict[str, object]:
173
+ unit_root = unit_root.resolve()
174
+ with file_lock(harness_lock_path(unit_root, f"candidate-{candidate_id}")):
175
+ path = candidate_path(unit_root, candidate_id) / "candidate.yaml"
176
+ record = read_yaml(path)
177
+ status = str(record.get("status", "draft"))
178
+ if status in TERMINAL_CANDIDATE_STATUSES:
179
+ raise HarneloopError(f"Closed candidate cannot be rebased: {candidate_id}")
180
+ current_version = read_yaml(unit_root / "unit.yaml").get("current_version")
181
+ previous_base = record.get("base_version")
182
+ if previous_base == current_version and status != "needs_rebase":
183
+ raise HarneloopError(f"Candidate `{candidate_id}` already uses the current base version")
184
+ history = list(record.get("rebase_history") or [])
185
+ history.append({"from": previous_base, "to": current_version, "rebased_at": now_iso()})
186
+ record["base_version"] = current_version
187
+ record["status"] = "accumulating"
188
+ record["rebase_history"] = history
189
+ record["status_updated_at"] = now_iso()
190
+ write_yaml(path, record)
191
+
192
+ _sync_candidate_state(
193
+ unit_root,
194
+ focused_candidate=candidate_id,
195
+ reason="candidate_rebased",
196
+ next_action=f"Revalidate `{candidate_id}` against base version `{current_version or 'none'}` before promotion.",
197
+ )
198
+ return record
199
+
200
+
201
+ def mark_parallel_candidates_needing_rebase(unit_root: Path, promoted_id: str, new_version: str) -> list[str]:
202
+ stale: list[str] = []
203
+ for record in list_candidates(unit_root, include_closed=False):
204
+ candidate_id = str(record.get("id"))
205
+ if candidate_id == promoted_id or record.get("base_version") == new_version:
206
+ continue
207
+ with file_lock(harness_lock_path(unit_root, f"candidate-{candidate_id}")):
208
+ path = candidate_path(unit_root, candidate_id) / "candidate.yaml"
209
+ current = read_yaml(path)
210
+ if current.get("status") not in OPEN_CANDIDATE_STATUSES:
211
+ continue
212
+ current["status"] = "needs_rebase"
213
+ current["status_updated_at"] = now_iso()
214
+ write_yaml(path, current)
215
+ stale.append(candidate_id)
216
+ return stale