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.
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/README.md +20 -17
- package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +77 -0
- package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +234 -39
- package/bundled/dev-pipeline/prizmkit_runtime/reset.py +57 -30
- package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +822 -0
- package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +28 -11
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +3 -0
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +71 -28
- package/bundled/dev-pipeline/prizmkit_runtime/task_checkout.py +18 -0
- package/bundled/dev-pipeline/scripts/parse-stream-progress.py +20 -2
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +86 -13
- package/bundled/dev-pipeline/tests/test_reset_preserve.py +959 -0
- package/bundled/dev-pipeline/tests/test_unified_cli.py +362 -5
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -9
- package/bundled/skills/feature-pipeline-launcher/SKILL.md +21 -9
- package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -9
- package/bundled/templates/project-memory-template.md +38 -0
- package/package.json +1 -1
|
@@ -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}"
|