prizmkit 1.1.149 → 1.1.151

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.
@@ -0,0 +1,822 @@
1
+ """Non-destructive failed-chain reset for the unified Python runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import tempfile
11
+ import time
12
+ from contextlib import ExitStack
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .checkpoint_state import (
18
+ load_checkpoint_state,
19
+ rewind_checkpoint_for_implementation,
20
+ )
21
+ from .gitops import (
22
+ BranchContext,
23
+ WorktreePolicy,
24
+ WorktreeRuntimeContext,
25
+ create_branch_from_base,
26
+ ensure_linked_worktree,
27
+ local_branch_exists,
28
+ registered_worktrees,
29
+ worktree_runtime_context,
30
+ )
31
+ from .runner_models import RunnerFamily
32
+ from .task_checkout import (
33
+ CHECKOUT_MODE_BRANCH,
34
+ CHECKOUT_MODE_WORKTREE,
35
+ TaskCheckout,
36
+ TaskCheckoutError,
37
+ load_task_checkout,
38
+ replace_active_task_checkout_locked,
39
+ task_checkout_guard,
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PreservePlanItem:
45
+ """Validated plan item used by preserve-runtime target discovery."""
46
+
47
+ item_id: str
48
+ title: str
49
+ slug: str
50
+ status: str
51
+ dependencies: tuple[str, ...]
52
+ index: int
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class PreservePlanSnapshot:
57
+ """Validated selected plan plus failed-chain targets."""
58
+
59
+ data: dict[str, Any]
60
+ items: tuple[PreservePlanItem, ...]
61
+ failed_roots: tuple[PreservePlanItem, ...]
62
+ auto_skipped_descendants: tuple[PreservePlanItem, ...]
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class RootRecovery:
67
+ """Preflighted recovery operation for one failed root."""
68
+
69
+ item: PreservePlanItem
70
+ checkout: TaskCheckout
71
+ status_path: Path
72
+ status_data: dict[str, Any]
73
+ replacement_checkout: TaskCheckout | None = None
74
+ replacement_worktree: WorktreeRuntimeContext | None = None
75
+ source_artifact: Path | None = None
76
+ target_artifact: Path | None = None
77
+ checkpoint_target: Path | None = None
78
+ checkpoint_data: dict[str, Any] | None = None
79
+
80
+ @property
81
+ def replaces_branch(self) -> bool:
82
+ return self.replacement_checkout is not None
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class PreserveRuntimeResetResult:
87
+ """Observable result of one preserve-runtime reset invocation."""
88
+
89
+ ok: bool
90
+ failed_roots: tuple[str, ...] = ()
91
+ auto_skipped_descendants: tuple[str, ...] = ()
92
+ reused_checkouts: tuple[str, ...] = ()
93
+ replacement_checkouts: tuple[tuple[str, str, str], ...] = ()
94
+ blockers: tuple[str, ...] = ()
95
+ no_targets: bool = False
96
+
97
+ def lines(self, family: RunnerFamily, list_path: Path) -> tuple[str, ...]:
98
+ if self.no_targets:
99
+ return (
100
+ f"No failed {family.kind} tasks require preserve-runtime recovery.",
101
+ "No plan, status, checkout, checkpoint, branch, worktree, session, or artifact state changed.",
102
+ )
103
+ if not self.ok:
104
+ return (
105
+ "Preserve-runtime reset blocked before task-state recovery:",
106
+ *(f"ERROR: {blocker}" for blocker in self.blockers),
107
+ )
108
+
109
+ lines = [
110
+ "Preserve-runtime reset complete.",
111
+ f"Failed roots restored: {', '.join(self.failed_roots)}",
112
+ ]
113
+ if self.auto_skipped_descendants:
114
+ lines.append(
115
+ "Causal auto-skipped descendants restored: "
116
+ + ", ".join(self.auto_skipped_descendants)
117
+ )
118
+ else:
119
+ lines.append("Causal auto-skipped descendants restored: none")
120
+ if self.reused_checkouts:
121
+ lines.append("Active checkouts reused: " + ", ".join(self.reused_checkouts))
122
+ for item_id, old_branch, new_branch in self.replacement_checkouts:
123
+ lines.append(f"Replacement checkout: {item_id}: {old_branch} -> {new_branch}")
124
+ lines.extend(
125
+ (
126
+ "Existing sessions, checkpoints, artifacts, branches, worktrees, and task work were not deleted.",
127
+ "Reset did not start a runner. Run separately:",
128
+ "python3 ./.prizmkit/dev-pipeline/cli.py "
129
+ f"{family.kind} run {shlex.quote(str(list_path))}",
130
+ )
131
+ )
132
+ return tuple(lines)
133
+
134
+
135
+ def run_preserve_runtime_reset(
136
+ family: RunnerFamily,
137
+ list_path: Path,
138
+ project_root: Path,
139
+ ) -> PreserveRuntimeResetResult:
140
+ """Restore every failed root and its causal auto-skipped descendants."""
141
+ try:
142
+ initial_bytes = list_path.read_bytes()
143
+ initial = _load_plan_snapshot(family, list_path, initial_bytes)
144
+ except (OSError, ValueError) as exc:
145
+ return PreserveRuntimeResetResult(False, blockers=(str(exc),))
146
+
147
+ if not initial.failed_roots:
148
+ return PreserveRuntimeResetResult(True, no_targets=True)
149
+
150
+ blockers: list[str] = []
151
+ recoveries: list[RootRecovery] = []
152
+ with ExitStack() as stack:
153
+ for item in initial.failed_roots:
154
+ try:
155
+ stack.enter_context(task_checkout_guard(family.state_dir, item.item_id))
156
+ except (RuntimeError, TaskCheckoutError) as exc:
157
+ blockers.append(f"{item.item_id}: checkout lifecycle is busy: {exc}")
158
+ if blockers:
159
+ return _blocked(initial, blockers)
160
+
161
+ try:
162
+ locked_bytes = list_path.read_bytes()
163
+ except OSError as exc:
164
+ return _blocked(initial, [f"Cannot reload selected plan under task guards: {exc}"])
165
+ if locked_bytes != initial_bytes:
166
+ return _blocked(initial, ["Selected plan changed while preserve-runtime reset acquired task guards"])
167
+ try:
168
+ locked = _load_plan_snapshot(family, list_path, locked_bytes)
169
+ except ValueError as exc:
170
+ return _blocked(initial, [str(exc)])
171
+
172
+ registrations = registered_worktrees(project_root)
173
+ for item in locked.failed_roots:
174
+ recovery, item_blockers = _preflight_root(
175
+ family,
176
+ item,
177
+ project_root,
178
+ registrations,
179
+ )
180
+ blockers.extend(item_blockers)
181
+ if recovery is not None:
182
+ recoveries.append(recovery)
183
+
184
+ blockers.extend(_writability_blockers(list_path, recoveries))
185
+ if blockers:
186
+ return _blocked(locked, blockers)
187
+
188
+ try:
189
+ _apply_recovery(family, locked, recoveries, project_root, list_path)
190
+ except (OSError, RuntimeError, TaskCheckoutError, ValueError) as exc:
191
+ return _blocked(
192
+ locked,
193
+ [
194
+ "Recovery apply failed after successful preflight; existing task work was not cleaned or deleted: "
195
+ f"{exc}"
196
+ ],
197
+ )
198
+
199
+ reused = tuple(recovery.item.item_id for recovery in recoveries if not recovery.replaces_branch)
200
+ replacements = tuple(
201
+ (
202
+ recovery.item.item_id,
203
+ recovery.checkout.active_dev_branch,
204
+ recovery.replacement_checkout.active_dev_branch,
205
+ )
206
+ for recovery in recoveries
207
+ if recovery.replacement_checkout is not None
208
+ )
209
+ return PreserveRuntimeResetResult(
210
+ True,
211
+ failed_roots=tuple(item.item_id for item in initial.failed_roots),
212
+ auto_skipped_descendants=tuple(
213
+ item.item_id for item in initial.auto_skipped_descendants
214
+ ),
215
+ reused_checkouts=reused,
216
+ replacement_checkouts=replacements,
217
+ )
218
+
219
+
220
+ def _blocked(
221
+ snapshot: PreservePlanSnapshot,
222
+ blockers: list[str],
223
+ ) -> PreserveRuntimeResetResult:
224
+ return PreserveRuntimeResetResult(
225
+ False,
226
+ failed_roots=tuple(item.item_id for item in snapshot.failed_roots),
227
+ auto_skipped_descendants=tuple(
228
+ item.item_id for item in snapshot.auto_skipped_descendants
229
+ ),
230
+ blockers=tuple(blockers),
231
+ )
232
+
233
+
234
+ def _load_plan_snapshot(
235
+ family: RunnerFamily,
236
+ list_path: Path,
237
+ raw_bytes: bytes | None = None,
238
+ ) -> PreservePlanSnapshot:
239
+ try:
240
+ data = json.loads((raw_bytes if raw_bytes is not None else list_path.read_bytes()).decode("utf-8"))
241
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
242
+ raise ValueError(f"Invalid {family.kind} plan JSON: {exc}") from exc
243
+ if not isinstance(data, dict):
244
+ raise ValueError(f"Invalid {family.kind} plan: root must be an object")
245
+ raw_items = data.get(family.list_key)
246
+ if not isinstance(raw_items, list):
247
+ raise ValueError(f"Invalid {family.kind} plan: {family.list_key} must be an array")
248
+
249
+ items: list[PreservePlanItem] = []
250
+ seen: set[str] = set()
251
+ errors: list[str] = []
252
+ for index, raw_item in enumerate(raw_items):
253
+ if not isinstance(raw_item, dict):
254
+ errors.append(f"item at index {index} must be an object")
255
+ continue
256
+ item_id = _normalize_item_id(str(raw_item.get("id") or ""), family.id_prefix)
257
+ if not item_id:
258
+ errors.append(f"item at index {index} has no valid id")
259
+ continue
260
+ if item_id in seen:
261
+ errors.append(f"duplicate item id: {item_id}")
262
+ continue
263
+ seen.add(item_id)
264
+ raw_dependencies = raw_item.get("dependencies", [])
265
+ if raw_dependencies is None:
266
+ raw_dependencies = []
267
+ if not isinstance(raw_dependencies, list) or any(
268
+ not isinstance(value, str) for value in raw_dependencies
269
+ ):
270
+ errors.append(f"{item_id}: dependencies must be an array of task IDs")
271
+ continue
272
+ dependencies = tuple(
273
+ _normalize_item_id(value, family.id_prefix) for value in raw_dependencies
274
+ )
275
+ if any(not value for value in dependencies):
276
+ errors.append(f"{item_id}: dependencies contain an invalid task ID")
277
+ continue
278
+ title = str(raw_item.get("title") or "")
279
+ items.append(
280
+ PreservePlanItem(
281
+ item_id=item_id,
282
+ title=title,
283
+ slug=_item_slug(family, item_id, title),
284
+ status=str(raw_item.get("status") or ""),
285
+ dependencies=dependencies,
286
+ index=index,
287
+ )
288
+ )
289
+
290
+ known_ids = {item.item_id for item in items}
291
+ for item in items:
292
+ for dependency in item.dependencies:
293
+ if dependency not in known_ids:
294
+ errors.append(f"{item.item_id}: unknown dependency {dependency}")
295
+ elif dependency == item.item_id:
296
+ errors.append(f"{item.item_id}: cannot depend on itself")
297
+ cycle = _dependency_cycle(items)
298
+ if cycle:
299
+ errors.append("dependency cycle: " + " -> ".join(cycle))
300
+ if errors:
301
+ raise ValueError(f"Invalid {family.kind} plan: " + "; ".join(errors))
302
+
303
+ failed_roots = tuple(item for item in items if item.status == "failed")
304
+ restored_ids = {item.item_id for item in failed_roots}
305
+ descendants: list[PreservePlanItem] = []
306
+ changed = True
307
+ while changed:
308
+ changed = False
309
+ for item in items:
310
+ if item.item_id in restored_ids or item.status != "auto_skipped":
311
+ continue
312
+ if any(dependency in restored_ids for dependency in item.dependencies):
313
+ restored_ids.add(item.item_id)
314
+ descendants.append(item)
315
+ changed = True
316
+
317
+ return PreservePlanSnapshot(
318
+ data=data,
319
+ items=tuple(items),
320
+ failed_roots=failed_roots,
321
+ auto_skipped_descendants=tuple(descendants),
322
+ )
323
+
324
+
325
+ def _dependency_cycle(items: list[PreservePlanItem]) -> tuple[str, ...]:
326
+ dependencies = {item.item_id: item.dependencies for item in items}
327
+ visiting: set[str] = set()
328
+ visited: set[str] = set()
329
+ stack: list[str] = []
330
+
331
+ def visit(item_id: str) -> tuple[str, ...]:
332
+ visiting.add(item_id)
333
+ stack.append(item_id)
334
+ for dependency in dependencies.get(item_id, ()):
335
+ if dependency not in dependencies:
336
+ continue
337
+ if dependency in visiting:
338
+ start = stack.index(dependency)
339
+ return tuple((*stack[start:], dependency))
340
+ if dependency not in visited:
341
+ cycle = visit(dependency)
342
+ if cycle:
343
+ return cycle
344
+ stack.pop()
345
+ visiting.remove(item_id)
346
+ visited.add(item_id)
347
+ return ()
348
+
349
+ for item in items:
350
+ if item.item_id not in visited:
351
+ cycle = visit(item.item_id)
352
+ if cycle:
353
+ return cycle
354
+ return ()
355
+
356
+
357
+ def _preflight_root(
358
+ family: RunnerFamily,
359
+ item: PreservePlanItem,
360
+ project_root: Path,
361
+ registrations,
362
+ ) -> tuple[RootRecovery | None, list[str]]:
363
+ blockers: list[str] = []
364
+ status_path = family.state_dir / item.item_id / "status.json"
365
+ try:
366
+ status_data = json.loads(status_path.read_text(encoding="utf-8"))
367
+ except FileNotFoundError:
368
+ status_data = None
369
+ blockers.append(f"{item.item_id}: runtime status file is missing: {status_path}")
370
+ except (OSError, json.JSONDecodeError) as exc:
371
+ status_data = None
372
+ blockers.append(f"{item.item_id}: runtime status file is invalid: {exc}")
373
+ if status_data is not None and not isinstance(status_data, dict):
374
+ blockers.append(f"{item.item_id}: runtime status root must be an object")
375
+ status_data = None
376
+
377
+ try:
378
+ checkout = load_task_checkout(family.state_dir, family.task_type, item.item_id)
379
+ except TaskCheckoutError as exc:
380
+ checkout = None
381
+ blockers.append(f"{item.item_id}: {exc}")
382
+ if checkout is None:
383
+ blockers.append(f"{item.item_id}: active checkout identity is missing")
384
+ elif not checkout.is_active:
385
+ blockers.append(
386
+ f"{item.item_id}: checkout state is {checkout.state}, expected active"
387
+ )
388
+ if checkout is None or not checkout.is_active or status_data is None:
389
+ return None, blockers
390
+
391
+ if not local_branch_exists(project_root, checkout.base_branch):
392
+ blockers.append(
393
+ f"{item.item_id}: recorded base branch is missing: {checkout.base_branch}"
394
+ )
395
+
396
+ branch_exists = local_branch_exists(project_root, checkout.active_dev_branch)
397
+ active_registration = [
398
+ registration
399
+ for registration in registrations
400
+ if registration.branch == checkout.active_dev_branch
401
+ ]
402
+ execution_root = project_root
403
+ old_runtime: WorktreeRuntimeContext | None = None
404
+ if checkout.checkout_mode == CHECKOUT_MODE_WORKTREE:
405
+ recorded_worktree = Path(checkout.worktree_path).resolve()
406
+ old_runtime = worktree_runtime_context(
407
+ BranchContext(
408
+ checkout.base_branch,
409
+ checkout.active_dev_branch,
410
+ project_root,
411
+ ),
412
+ WorktreePolicy(True, True, True, recorded_worktree.parent),
413
+ )
414
+ if not old_runtime.is_cleanup_path_safe() or old_runtime.worktree_path != recorded_worktree:
415
+ blockers.append(
416
+ f"{item.item_id}: recorded worktree path is unsafe or inconsistent: {recorded_worktree}"
417
+ )
418
+ execution_root = recorded_worktree
419
+ if branch_exists:
420
+ if not recorded_worktree.is_dir():
421
+ blockers.append(
422
+ f"{item.item_id}: recorded worktree directory is missing: {recorded_worktree}"
423
+ )
424
+ if not any(
425
+ registration.path == recorded_worktree
426
+ and registration.branch == checkout.active_dev_branch
427
+ for registration in registrations
428
+ ):
429
+ blockers.append(
430
+ f"{item.item_id}: recorded branch/worktree registration is inconsistent"
431
+ )
432
+ elif any(registration.path == recorded_worktree for registration in registrations):
433
+ blockers.append(
434
+ f"{item.item_id}: missing branch still has a conflicting registered worktree"
435
+ )
436
+ elif checkout.checkout_mode == CHECKOUT_MODE_BRANCH:
437
+ if branch_exists and any(
438
+ registration.path != project_root.resolve()
439
+ for registration in active_registration
440
+ ):
441
+ blockers.append(
442
+ f"{item.item_id}: branch checkout is owned by a different linked worktree"
443
+ )
444
+ else:
445
+ blockers.append(
446
+ f"{item.item_id}: unsupported checkout mode: {checkout.checkout_mode}"
447
+ )
448
+
449
+ replacement_checkout: TaskCheckout | None = None
450
+ replacement_worktree: WorktreeRuntimeContext | None = None
451
+ if not branch_exists and not blockers:
452
+ try:
453
+ replacement_branch, replacement_worktree = _replacement_identity(
454
+ family,
455
+ item,
456
+ checkout,
457
+ project_root,
458
+ registrations,
459
+ old_runtime,
460
+ )
461
+ replacement_checkout = TaskCheckout.active(
462
+ family.task_type,
463
+ item.item_id,
464
+ replacement_branch,
465
+ checkout.base_branch,
466
+ checkout_mode=checkout.checkout_mode,
467
+ worktree_path=(
468
+ str(replacement_worktree.worktree_path)
469
+ if replacement_worktree is not None
470
+ else ""
471
+ ),
472
+ )
473
+ except (TaskCheckoutError, ValueError) as exc:
474
+ blockers.append(str(exc))
475
+
476
+ checkpoint_pairs = _checkpoint_candidates(
477
+ family,
478
+ item,
479
+ project_root,
480
+ execution_root,
481
+ )
482
+ for checkpoint_path, checkpoint_root in checkpoint_pairs:
483
+ state = load_checkpoint_state(
484
+ checkpoint_path,
485
+ project_root=checkpoint_root,
486
+ )
487
+ if not state.valid:
488
+ blockers.append(
489
+ f"{item.item_id}: invalid checkpoint {checkpoint_path}: "
490
+ f"{state.error_code}: {state.error_message}"
491
+ )
492
+ elif not state.semantic.sequence_valid:
493
+ blockers.append(
494
+ f"{item.item_id}: invalid checkpoint sequence {checkpoint_path}: "
495
+ f"{state.semantic.error_message or 'mandatory stage sequence is invalid'}"
496
+ )
497
+
498
+ source_artifact: Path | None = None
499
+ target_artifact = _artifact_path(family, item, execution_root)
500
+ checkpoint_target: Path | None = None
501
+ checkpoint_data: dict[str, Any] | None = None
502
+ if replacement_checkout is not None and not blockers:
503
+ if checkout.checkout_mode == CHECKOUT_MODE_WORKTREE:
504
+ source_artifact = _existing_artifact_source(
505
+ family,
506
+ item,
507
+ execution_root,
508
+ project_root,
509
+ )
510
+ if replacement_worktree is None:
511
+ blockers.append(f"{item.item_id}: replacement worktree was not resolved")
512
+ else:
513
+ target_artifact = _artifact_path(
514
+ family,
515
+ item,
516
+ replacement_worktree.worktree_path,
517
+ )
518
+ if target_artifact.exists():
519
+ blockers.append(
520
+ f"{item.item_id}: replacement artifact destination already exists: {target_artifact}"
521
+ )
522
+ else:
523
+ source_artifact = _existing_artifact_source(
524
+ family,
525
+ item,
526
+ project_root,
527
+ project_root,
528
+ )
529
+ target_artifact = _artifact_path(family, item, project_root)
530
+
531
+ source_checkpoint = (
532
+ source_artifact / "workflow-checkpoint.json"
533
+ if source_artifact is not None
534
+ else None
535
+ )
536
+ if source_checkpoint is not None and source_checkpoint.is_file() and not blockers:
537
+ try:
538
+ source_data = json.loads(source_checkpoint.read_text(encoding="utf-8"))
539
+ checkpoint_target = target_artifact / "workflow-checkpoint.json"
540
+ checkpoint_data = rewind_checkpoint_for_implementation(
541
+ source_data,
542
+ path=checkpoint_target,
543
+ project_root=(
544
+ replacement_worktree.worktree_path
545
+ if replacement_worktree is not None
546
+ else project_root
547
+ ),
548
+ )
549
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
550
+ blockers.append(
551
+ f"{item.item_id}: checkpoint cannot be safely rewound: {exc}"
552
+ )
553
+
554
+ if blockers:
555
+ return None, blockers
556
+ return (
557
+ RootRecovery(
558
+ item=item,
559
+ checkout=checkout,
560
+ status_path=status_path,
561
+ status_data=status_data,
562
+ replacement_checkout=replacement_checkout,
563
+ replacement_worktree=replacement_worktree,
564
+ source_artifact=source_artifact,
565
+ target_artifact=target_artifact,
566
+ checkpoint_target=checkpoint_target,
567
+ checkpoint_data=checkpoint_data,
568
+ ),
569
+ [],
570
+ )
571
+
572
+
573
+ def _replacement_identity(
574
+ family: RunnerFamily,
575
+ item: PreservePlanItem,
576
+ checkout: TaskCheckout,
577
+ project_root: Path,
578
+ registrations,
579
+ old_runtime: WorktreeRuntimeContext | None,
580
+ ) -> tuple[str, WorktreeRuntimeContext | None]:
581
+ stamp = time.strftime("%Y%m%d%H%M%S", time.gmtime())
582
+ worktree_root = old_runtime.worktree_root if old_runtime is not None else None
583
+ registered_branches = {registration.branch for registration in registrations}
584
+ registered_paths = {registration.path for registration in registrations}
585
+ for suffix in range(1000):
586
+ ending = "" if suffix == 0 else f"-{suffix}"
587
+ branch = f"{family.branch_prefix}/{item.item_id}-resume-{stamp}{ending}"
588
+ if local_branch_exists(project_root, branch) or branch in registered_branches:
589
+ continue
590
+ runtime = None
591
+ if checkout.checkout_mode == CHECKOUT_MODE_WORKTREE:
592
+ if worktree_root is None:
593
+ continue
594
+ runtime = worktree_runtime_context(
595
+ BranchContext(checkout.base_branch, branch, project_root),
596
+ WorktreePolicy(True, True, True, worktree_root),
597
+ )
598
+ if (
599
+ not runtime.is_cleanup_path_safe()
600
+ or runtime.worktree_path.exists()
601
+ or runtime.worktree_path.resolve() in registered_paths
602
+ ):
603
+ continue
604
+ return branch, runtime
605
+ raise ValueError(f"{item.item_id}: cannot allocate a collision-safe replacement checkout")
606
+
607
+
608
+ def _checkpoint_candidates(
609
+ family: RunnerFamily,
610
+ item: PreservePlanItem,
611
+ project_root: Path,
612
+ execution_root: Path,
613
+ ) -> tuple[tuple[Path, Path], ...]:
614
+ candidates: list[tuple[Path, Path]] = []
615
+ seen: set[Path] = set()
616
+ for root in (execution_root, project_root):
617
+ checkpoint = _artifact_path(family, item, root) / "workflow-checkpoint.json"
618
+ resolved = checkpoint.resolve()
619
+ if resolved in seen or not checkpoint.is_file():
620
+ continue
621
+ seen.add(resolved)
622
+ candidates.append((checkpoint, root))
623
+ return tuple(candidates)
624
+
625
+
626
+ def _existing_artifact_source(
627
+ family: RunnerFamily,
628
+ item: PreservePlanItem,
629
+ primary_root: Path,
630
+ project_root: Path,
631
+ ) -> Path | None:
632
+ for root in (primary_root, project_root):
633
+ candidate = _artifact_path(family, item, root)
634
+ if candidate.is_dir():
635
+ return candidate
636
+ return None
637
+
638
+
639
+ def _artifact_path(
640
+ family: RunnerFamily,
641
+ item: PreservePlanItem,
642
+ execution_root: Path,
643
+ ) -> Path:
644
+ artifact_name = item.slug if family.kind == "feature" else item.item_id
645
+ return execution_root / ".prizmkit" / family.artifact_root_name / artifact_name
646
+
647
+
648
+ def _writability_blockers(
649
+ list_path: Path,
650
+ recoveries: list[RootRecovery],
651
+ ) -> list[str]:
652
+ blockers: list[str] = []
653
+ for path, label in ((list_path, "selected plan"),):
654
+ if not _path_writable(path):
655
+ blockers.append(f"{label} is not writable: {path}")
656
+ for recovery in recoveries:
657
+ checks = [
658
+ (recovery.status_path, f"{recovery.item.item_id} runtime status"),
659
+ (
660
+ recovery.status_path.parent / "checkout.json",
661
+ f"{recovery.item.item_id} checkout identity",
662
+ ),
663
+ ]
664
+ if recovery.checkpoint_target is not None and recovery.replacement_worktree is None:
665
+ checks.append(
666
+ (
667
+ recovery.checkpoint_target,
668
+ f"{recovery.item.item_id} workflow checkpoint",
669
+ )
670
+ )
671
+ if recovery.replacement_worktree is not None:
672
+ checks.append(
673
+ (
674
+ recovery.replacement_worktree.worktree_root,
675
+ f"{recovery.item.item_id} replacement worktree root",
676
+ )
677
+ )
678
+ for path, label in checks:
679
+ if not _path_writable(path):
680
+ blockers.append(f"{label} is not writable: {path}")
681
+ return blockers
682
+
683
+
684
+ def _path_writable(path: Path) -> bool:
685
+ if path.exists():
686
+ return os.access(path, os.W_OK) and os.access(path.parent, os.W_OK)
687
+ parent = path.parent
688
+ while not parent.exists() and parent != parent.parent:
689
+ parent = parent.parent
690
+ return parent.exists() and os.access(parent, os.W_OK)
691
+
692
+
693
+ def _apply_recovery(
694
+ family: RunnerFamily,
695
+ snapshot: PreservePlanSnapshot,
696
+ recoveries: list[RootRecovery],
697
+ project_root: Path,
698
+ list_path: Path,
699
+ ) -> None:
700
+ for recovery in recoveries:
701
+ replacement = recovery.replacement_checkout
702
+ if replacement is None:
703
+ continue
704
+ branch_result = create_branch_from_base(
705
+ project_root,
706
+ replacement.active_dev_branch,
707
+ replacement.base_branch,
708
+ )
709
+ if branch_result.return_code != 0:
710
+ detail = branch_result.stderr.strip() or branch_result.stdout.strip()
711
+ raise RuntimeError(
712
+ f"{recovery.item.item_id}: replacement branch creation failed: {detail or 'unknown Git error'}"
713
+ )
714
+ if recovery.replacement_worktree is not None:
715
+ setup = ensure_linked_worktree(recovery.replacement_worktree)
716
+ if not setup.ok:
717
+ detail = "; ".join(
718
+ result.stderr.strip() or result.stdout.strip()
719
+ for result in setup.results
720
+ if result.return_code != 0
721
+ )
722
+ raise RuntimeError(
723
+ f"{recovery.item.item_id}: replacement worktree creation failed: "
724
+ f"{detail or setup.reason}"
725
+ )
726
+ if recovery.source_artifact is not None and recovery.target_artifact is not None:
727
+ recovery.target_artifact.parent.mkdir(parents=True, exist_ok=True)
728
+ shutil.copytree(
729
+ recovery.source_artifact,
730
+ recovery.target_artifact,
731
+ symlinks=True,
732
+ )
733
+
734
+ for recovery in recoveries:
735
+ if recovery.checkpoint_target is not None and recovery.checkpoint_data is not None:
736
+ _atomic_write_json(recovery.checkpoint_target, recovery.checkpoint_data)
737
+
738
+ for recovery in recoveries:
739
+ if recovery.replacement_checkout is not None:
740
+ replace_active_task_checkout_locked(
741
+ family.state_dir,
742
+ recovery.checkout,
743
+ recovery.replacement_checkout,
744
+ )
745
+
746
+ for recovery in recoveries:
747
+ status_data = copy.deepcopy(recovery.status_data)
748
+ status_data["retry_count"] = 0
749
+ status_data["infra_error_count"] = 0
750
+ status_data["continuation_pending"] = True
751
+ if recovery.replacement_checkout is not None:
752
+ status_data["active_dev_branch"] = (
753
+ recovery.replacement_checkout.active_dev_branch
754
+ )
755
+ status_data["base_branch"] = recovery.replacement_checkout.base_branch
756
+ _atomic_write_json(recovery.status_path, status_data)
757
+
758
+ plan_data = copy.deepcopy(snapshot.data)
759
+ raw_items = plan_data.get(family.list_key)
760
+ if not isinstance(raw_items, list):
761
+ raise ValueError(f"Invalid {family.kind} plan during apply")
762
+ restored_ids = {
763
+ item.item_id
764
+ for item in (*snapshot.failed_roots, *snapshot.auto_skipped_descendants)
765
+ }
766
+ for raw_item in raw_items:
767
+ if not isinstance(raw_item, dict):
768
+ continue
769
+ item_id = _normalize_item_id(str(raw_item.get("id") or ""), family.id_prefix)
770
+ if item_id in restored_ids:
771
+ raw_item["status"] = "pending"
772
+ _atomic_write_json(list_path, plan_data)
773
+
774
+
775
+ def _atomic_write_json(path: Path, data: Any) -> None:
776
+ path.parent.mkdir(parents=True, exist_ok=True)
777
+ descriptor = -1
778
+ temporary = ""
779
+ try:
780
+ descriptor, temporary = tempfile.mkstemp(
781
+ prefix=f".{path.name}.",
782
+ suffix=".tmp",
783
+ dir=path.parent,
784
+ )
785
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
786
+ descriptor = -1
787
+ json.dump(data, handle, indent=2, ensure_ascii=False)
788
+ handle.write("\n")
789
+ handle.flush()
790
+ os.fsync(handle.fileno())
791
+ os.replace(temporary, path)
792
+ except OSError:
793
+ if descriptor >= 0:
794
+ os.close(descriptor)
795
+ if temporary:
796
+ try:
797
+ os.unlink(temporary)
798
+ except OSError:
799
+ pass
800
+ raise
801
+
802
+
803
+ def _normalize_item_id(value: str, prefix: str) -> str:
804
+ upper = value.strip().upper()
805
+ if not upper.startswith(prefix.upper()):
806
+ return ""
807
+ suffix = upper[len(prefix) :]
808
+ if not suffix.isdigit():
809
+ return ""
810
+ return f"{prefix}{int(suffix):03d}"
811
+
812
+
813
+ def _item_slug(family: RunnerFamily, item_id: str, title: str) -> str:
814
+ if family.kind != "feature":
815
+ return item_id
816
+ import re
817
+
818
+ numeric = item_id.removeprefix("F-").zfill(3)
819
+ slug = re.sub(r"[^a-z0-9\s-]", "", title.lower())
820
+ slug = re.sub(r"\s+", "-", slug.strip())
821
+ slug = re.sub(r"-+", "-", slug).strip("-") or "feature"
822
+ return f"{numeric}-{slug}"