Skip to content

Commit de7e3bb

Browse files
authored
Merge pull request #101 from githubnext/codex/fix-crane-stale-completion
fix(crane): ignore stale completion for active migrations
2 parents e564750 + eb650d1 commit de7e3bb

5 files changed

Lines changed: 85 additions & 6 deletions

File tree

.github/workflows/crane.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ The pre-step fetches open issues with the `crane-migration` label via the GitHub
180180
When a migration is issue-based, `/tmp/gh-aw/crane.json` includes:
181181
- **`selected_issue`**: The issue number (e.g., `42`) if the selected migration came from an issue, or `null` if it came from a file.
182182
- **`issue_migrations`**: A mapping of migration name -> issue number for all issue-based migrations found.
183+
- **`stale_completed_state`**: Issue-based migrations whose issue still has `crane-migration` even though repo-memory says `Completed: true`.
183184

184185
### Reading Migrations
185186

@@ -193,6 +194,7 @@ The pre-step has already determined which migration to run. Read `/tmp/gh-aw/cra
193194
- **`selected_strategy`**: The `strategy` value from the migration's frontmatter -- one of `"in-place"`, `"greenfield"`, or `"auto"`. If `"auto"`, the agent must pick on the first iteration and write the chosen strategy back into the state file's Machine State table.
194195
- **`state_file_size_bytes`** / **`state_file_max_bytes`**: For rolling-compaction decisions (see [Update Rules](#update-rules)).
195196
- **`issue_migrations`**: A mapping of migration name -> issue number for all discovered issue-based migrations.
197+
- **`stale_completed_state`**: A list of active issue-based migrations where repo-memory still says `Completed: true`; treat this as stale memory, not as permission to finish.
196198
- **`deferred`**: Other migrations that were due but will be handled in future runs.
197199
- **`unconfigured`**: Migrations that still have the sentinel or placeholder content.
198200
- **`skipped`**: Migrations not due yet based on their per-migration schedule, or completed/paused.
@@ -207,6 +209,7 @@ If `selected` is not null:
207209
3. Read the current state of all source and target paths.
208210
4. Read the state file `{selected}.md` from the repo-memory folder. This contains the Machine State table, the Migration Plan, lessons, blockers, and iteration history.
209211
5. If `selected_issue` is not null, also read the issue comments for any human steering input.
212+
6. If `selected` appears in `stale_completed_state`, ignore any pre-existing `Completed: true`, `Completed Reason`, or target-satisfying `best_metric` as a completion signal. First run the current verification contract and only re-complete after a fresh accepted iteration satisfies the current halting condition.
210213

211214
## Multiple Migrations
212215

@@ -237,7 +240,7 @@ schedule: every 1h
237240

238241
### Target Metric (Halting Condition)
239242

240-
Migrations should usually specify `target-metric: 1.0` in the frontmatter -- the typical "completed when fully migrated and verified" setting. When the health score reaches the target, the migration completes: the `crane-migration` label is removed, `crane-completed` is added (for issue-based migrations), and the state file is marked `Completed: true`.
243+
Migrations should usually specify `target-metric: 1.0` in the frontmatter -- the typical "completed when fully migrated and verified" setting. When the fresh health score from an accepted iteration reaches the target, the migration completes: the `crane-migration` label is removed, `crane-completed` is added (for issue-based migrations), and the state file is marked `Completed: true`.
241244

242245
Migrations without a `target-metric` are **open-ended** and run indefinitely (rare for migrations -- usually a sign you actually want goal-oriented).
243246

@@ -540,12 +543,13 @@ If `status == "failure"`, **fix and retry -- do not revert, do not accept**:
540543
3. Ensure the migration issue exists (see [Migration Issue](#migration-issue) below) -- for file-based migrations with no migration issue yet (`selected_issue` is null in `/tmp/gh-aw/crane.json`), create one and record its number in the state file's `Issue` field.
541544
4. Update the state file `{migration-name}.md` in the repo-memory folder:
542545
- **[*] Machine State** table: reset `consecutive_errors` to 0, set `best_metric` (the new `migration_score`), increment `iteration_count`, set `last_run` to current UTC, append `"accepted"` to `recent_statuses` (keep last 10), set `paused` to false.
546+
- If this migration was listed in `stale_completed_state`, also set `Completed: false` and `Completed Reason: --` before checking the halting condition. Do not carry a stale completion marker forward unless the current accepted iteration completes again.
543547
- **[ladder] Milestones**: update the relevant milestone's status -- typically `done` if the milestone was fully completed, otherwise leave `in-progress` and update its notes. If the milestone is done, the next milestone in the list becomes the new **[target] Current Focus**.
544548
- Prepend an entry to **[chart] Iteration History** using the shared accepted iteration summary: status [+], score, **signed delta**, PR link, commit SHA, run URL, fix-attempt count if `> 0`, and a one-line summary of what milestone was advanced and how.
545549
- Update **[docs] Lessons Learned** if this iteration revealed something new (e.g. a bridging trick, a parity surprise, a perf trap).
546550
- Update **[scope] Future Work** if this iteration opened new threads.
547551
5. **Update the migration issue**: edit the status comment and post a per-iteration comment using the same shared accepted iteration summary.
548-
6. **Check halting condition** (see [Halting Condition](#halting-condition)): if `target-metric` is set, compare the new `best_metric` against it. For `higher` direction: completed when `best_metric >= target-metric`. When the target is met, mark the migration as completed.
552+
6. **Check halting condition** (see [Halting Condition](#halting-condition)): if `target-metric` is set, compare the new `best_metric` from this accepted iteration against it. For `higher` direction: completed when `best_metric >= target-metric`. Never mark completed from stored `best_metric` alone or a pre-existing `Completed: true`; completion requires the current accepted iteration's fresh verification result. When the target is met, mark the migration as completed.
549553

550554
**If the score did not improve**:
551555
1. Discard the code changes (do not commit them to the long-running branch).
@@ -648,6 +652,7 @@ After **every iteration** (accepted, rejected, or error), post a **new comment**
648652
- For issue-based migrations, the source issue body IS the migration definition -- do not modify it (the user owns it).
649653
- For file-based migrations, the migration issue body is informational and may be lightly updated, but the migration file (`migration.md`) remains the source of truth.
650654
- The `crane-migration` label must remain on the issue for the migration to be discovered. When a migration completes, the label is removed and replaced with `crane-completed`.
655+
- If an issue has `crane-migration` but repo-memory says `Completed: true`, the active label wins. Treat the completed state as stale until the current verification contract passes in a fresh accepted iteration.
651656
- Closing the migration issue stops the migration from being discovered. Do NOT close the migration issue when the PR is merged -- the branch continues to accumulate future iterations until the target metric is reached.
652657
- Migration issues are labeled `[crane-migration, automation, crane]`.
653658

@@ -658,7 +663,7 @@ Migrations are usually **goal-oriented** -- you want to finish. Set `target-metr
658663
### How It Works
659664

660665
1. Parse the `target-metric` value from the migration's YAML frontmatter (if present).
661-
2. After each **accepted** iteration, compare the new `best_metric` against the `target-metric`.
666+
2. After each **accepted** iteration, compare the new `best_metric` from that iteration against the `target-metric`.
662667
3. For `higher` direction (default): completed when `best_metric >= target-metric`.
663668
4. For `lower` direction: completed when `best_metric <= target-metric`.
664669
5. When completed:
@@ -669,6 +674,8 @@ Migrations are usually **goal-oriented** -- you want to finish. Set `target-metr
669674
- Post a celebratory per-iteration comment: `[+] **Migration complete!** {source} -> {target} finished after {N} iterations.`
670675
- The migration will not be selected for future runs.
671676

677+
Do not enter this path from repo-memory alone. A stored `Completed: true`, old `Completed Reason`, or historical `best_metric` is only evidence about a previous run; the current run must produce and accept a verification score that satisfies the current migration definition.
678+
672679
### Open-Ended Migrations
673680

674681
Migrations that omit `target-metric` run indefinitely. Useful if you want Crane to keep optimizing a polyglot system long after the initial migration is done (e.g. continuously identifying new hot paths to lift into the native core), but unusual for a one-shot port.

.github/workflows/scripts/crane_scheduler.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,14 @@ def is_unconfigured(content):
214214
return False
215215

216216

217-
def check_skip_conditions(state):
217+
def is_completed_state(state):
218+
"""Return True when repo-memory says the migration is completed."""
219+
return str(state.get("completed", "")).lower() == "true" or state.get("completed") is True
220+
221+
222+
def check_skip_conditions(state, issue_active=False):
218223
"""Return ``(should_skip, reason)`` based on the migration state."""
219-
if str(state.get("completed", "")).lower() == "true" or state.get("completed") is True:
224+
if is_completed_state(state) and not issue_active:
220225
return True, "completed: target metric reached"
221226
if state.get("paused"):
222227
return True, "paused: {}".format(state.get("pause_reason", "unknown"))
@@ -606,6 +611,7 @@ def main():
606611
"due": [],
607612
"skipped": [],
608613
"unconfigured": [],
614+
"stale_completed_state": [],
609615
"no_migrations": True,
610616
"head_branch": None,
611617
"existing_pr": None,
@@ -618,10 +624,12 @@ def main():
618624
due = []
619625
skipped = []
620626
unconfigured = []
627+
stale_completed_state = []
621628
all_migrations = {} # name -> file path
622629

623630
for pf in migration_files:
624631
name = get_migration_name(pf)
632+
issue_active = name in issue_migrations
625633
all_migrations[name] = pf
626634
with open(pf) as f:
627635
content = f.read()
@@ -661,6 +669,14 @@ def main():
661669
else:
662670
print(" {}: no state found (first run)".format(name))
663671

672+
has_stale_completed_state = issue_active and is_completed_state(state)
673+
if has_stale_completed_state:
674+
stale_completed_state.append(name)
675+
print(
676+
f" {name}: issue still has crane-migration label; treating "
677+
"Completed=true as stale until fresh verification passes"
678+
)
679+
664680
last_run = None
665681
lr = state.get("last_run")
666682
if lr:
@@ -669,7 +685,7 @@ def main():
669685
except ValueError:
670686
pass
671687

672-
should_skip, reason = check_skip_conditions(state)
688+
should_skip, reason = check_skip_conditions(state, issue_active=issue_active)
673689
if should_skip:
674690
skipped.append({"name": name, "reason": reason})
675691
continue
@@ -692,6 +708,7 @@ def main():
692708
"target_metric": target_metric,
693709
"metric_direction": metric_direction,
694710
"strategy": strategy,
711+
"stale_completed_state": has_stale_completed_state,
695712
})
696713

697714
selected, selected_file, selected_issue, selected_target_metric, selected_metric_direction, selected_strategy, deferred, error = (
@@ -728,6 +745,7 @@ def main():
728745
"issue_migrations": {
729746
name: info["issue_number"] for name, info in issue_migrations.items()
730747
},
748+
"stale_completed_state": stale_completed_state,
731749
"deferred": deferred,
732750
"skipped": skipped,
733751
"unconfigured": unconfigured,

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Fixed
1515

16+
- Crane no longer treats stale repo-memory `Completed: true` as final when an issue still has `crane-migration`; active issue migrations must re-run fresh verification before completion. (#101)
1617
- Root `.apm` hooks no longer duplicate after renaming the project directory or using git worktrees; Claude, Codex, Cursor, Gemini, and Windsurf hook configs stay idempotent across checkouts. The hook source-id is now derived from `apm.yml`'s `name` field instead of `install_path.name`, and `apm install` silently heals stale same-content entries from prior checkout basenames. Copilot is unaffected (its hooks live in per-file namespaces under `.github/hooks/`, not a shared merged config). (#1392, closes #1329)
1718

1819
## [0.14.1] - 2026-05-20

tests/unit/test_crane_scheduler.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
from pathlib import Path
5+
6+
ROOT = Path(__file__).resolve().parents[2]
7+
SCHEDULER_PATH = ROOT / ".github" / "workflows" / "scripts" / "crane_scheduler.py"
8+
9+
spec = importlib.util.spec_from_file_location("crane_scheduler", SCHEDULER_PATH)
10+
assert spec is not None
11+
crane_scheduler = importlib.util.module_from_spec(spec)
12+
assert spec.loader is not None
13+
spec.loader.exec_module(crane_scheduler)
14+
15+
16+
def test_completed_state_skips_inactive_migration() -> None:
17+
should_skip, reason = crane_scheduler.check_skip_conditions({"completed": True})
18+
19+
assert should_skip is True
20+
assert reason == "completed: target metric reached"
21+
22+
23+
def test_active_issue_overrides_stale_completed_state() -> None:
24+
should_skip, reason = crane_scheduler.check_skip_conditions(
25+
{"completed": True},
26+
issue_active=True,
27+
)
28+
29+
assert should_skip is False
30+
assert reason is None
31+
32+
33+
def test_active_issue_does_not_override_pause() -> None:
34+
should_skip, reason = crane_scheduler.check_skip_conditions(
35+
{"completed": True, "paused": True, "pause_reason": "manual hold"},
36+
issue_active=True,
37+
)
38+
39+
assert should_skip is True
40+
assert reason == "paused: manual hold"
41+
42+
43+
def test_machine_state_completed_string_is_recognized() -> None:
44+
assert crane_scheduler.is_completed_state({"completed": "true"}) is True

tests/unit/test_crane_workflow_prompt.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,12 @@ def test_crane_commit_guidance_provides_structured_summary_fallback() -> None:
2929
assert "Changes:" in text
3030
assert "Run: {run_url}" in text
3131
assert text.index("Changes:") < text.index("Run: {run_url}")
32+
33+
34+
def test_crane_prompt_blocks_stale_completed_state_from_finishing() -> None:
35+
text = _workflow_text()
36+
37+
assert "stale_completed_state" in text
38+
assert "active label wins" in text
39+
assert "Never mark completed from stored `best_metric` alone" in text
40+
assert "the current run must produce and accept a verification score" in text

0 commit comments

Comments
 (0)