prizmkit 1.1.106 → 1.1.107
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/prizmkit_runtime/runner_classification.py +56 -5
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -8
- package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +9 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +278 -62
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +102 -17
- package/bundled/dev-pipeline/scripts/continuation.py +0 -4
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +7 -39
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +1 -31
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +1 -31
- package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +5 -6
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +28 -28
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +36 -52
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +34 -54
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +1 -1
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -3
- package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +2 -3
- package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +4 -9
- package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +4 -10
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -3
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +3 -3
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +49 -2
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +17 -0
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +50 -1
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +352 -1
- package/bundled/dev-pipeline/tests/test_unified_cli.py +207 -0
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/prizmkit-code-review/SKILL.md +12 -13
- package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +10 -10
- package/package.json +1 -1
- package/bundled/dev-pipeline/templates/agent-prompts/dev-fix.md +0 -7
- package/bundled/dev-pipeline/templates/agent-prompts/dev-resume.md +0 -5
- package/bundled/skills/prizmkit-code-review/references/dev-agent-prompt.md +0 -30
package/bundled/VERSION.json
CHANGED
|
@@ -29,6 +29,10 @@ INFRA_MARKERS = (
|
|
|
29
29
|
"authentication",
|
|
30
30
|
"unauthorized",
|
|
31
31
|
"api error",
|
|
32
|
+
"quota",
|
|
33
|
+
"usage limit",
|
|
34
|
+
"credit balance",
|
|
35
|
+
"billing",
|
|
32
36
|
)
|
|
33
37
|
|
|
34
38
|
|
|
@@ -52,6 +56,13 @@ def classify_session(
|
|
|
52
56
|
progress_fingerprint=fingerprint,
|
|
53
57
|
)
|
|
54
58
|
|
|
59
|
+
if _has_late_runtime_error_after_completion(result, prompt, has_commit_since_base):
|
|
60
|
+
return SessionClassification(
|
|
61
|
+
"success",
|
|
62
|
+
reason="completed_checkpoint_with_late_runtime_error",
|
|
63
|
+
progress_fingerprint=fingerprint,
|
|
64
|
+
)
|
|
65
|
+
|
|
55
66
|
if _is_context_overflow(result):
|
|
56
67
|
if has_commit_since_base:
|
|
57
68
|
return SessionClassification("success", reason="commit_since_base", progress_fingerprint=fingerprint)
|
|
@@ -121,6 +132,16 @@ def _is_terminal_success(result: AISessionResult) -> bool:
|
|
|
121
132
|
)
|
|
122
133
|
|
|
123
134
|
|
|
135
|
+
def _has_late_runtime_error_after_completion(
|
|
136
|
+
result: AISessionResult,
|
|
137
|
+
prompt: PromptGenerationResult | None,
|
|
138
|
+
has_commit_since_base: bool,
|
|
139
|
+
) -> bool:
|
|
140
|
+
if not has_commit_since_base or not _has_completed_workflow_artifact(prompt):
|
|
141
|
+
return False
|
|
142
|
+
return _is_context_overflow(result) or _is_infra_error(result)
|
|
143
|
+
|
|
144
|
+
|
|
124
145
|
def _has_completed_workflow_artifact(prompt: PromptGenerationResult | None) -> bool:
|
|
125
146
|
if prompt is None:
|
|
126
147
|
return False
|
|
@@ -131,11 +152,32 @@ def _has_completed_workflow_artifact(prompt: PromptGenerationResult | None) -> b
|
|
|
131
152
|
data = {}
|
|
132
153
|
if isinstance(data, dict) and _checkpoint_cursor(data) == "complete":
|
|
133
154
|
return True
|
|
134
|
-
if prompt.artifact_path and (prompt.artifact_path
|
|
155
|
+
if prompt.artifact_path and has_completed_artifact_path(prompt.artifact_path):
|
|
135
156
|
return True
|
|
136
157
|
return False
|
|
137
158
|
|
|
138
159
|
|
|
160
|
+
def has_branch_completion_evidence(project_root: Path, base_branch: str, working_branch: str, artifact_path: Path | None) -> bool:
|
|
161
|
+
"""Return true when a recorded task branch already has completed work to merge."""
|
|
162
|
+
if not base_branch or not working_branch or not has_completed_artifact_path(artifact_path):
|
|
163
|
+
return False
|
|
164
|
+
return _has_commit_range(project_root, base_branch, working_branch)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def has_completed_artifact_path(artifact_path: Path | None) -> bool:
|
|
168
|
+
if artifact_path is None:
|
|
169
|
+
return False
|
|
170
|
+
checkpoint = artifact_path / "workflow-checkpoint.json"
|
|
171
|
+
if checkpoint.is_file():
|
|
172
|
+
try:
|
|
173
|
+
data = json.loads(checkpoint.read_text(encoding="utf-8"))
|
|
174
|
+
except (OSError, json.JSONDecodeError):
|
|
175
|
+
data = {}
|
|
176
|
+
if isinstance(data, dict) and _checkpoint_cursor(data) == "complete":
|
|
177
|
+
return True
|
|
178
|
+
return (artifact_path / "completion-summary.json").is_file()
|
|
179
|
+
|
|
180
|
+
|
|
139
181
|
def _next_no_progress_count(previous: object | None, current: object, count: int) -> int:
|
|
140
182
|
if previous and previous == current:
|
|
141
183
|
return max(0, count) + 1
|
|
@@ -161,11 +203,20 @@ def _is_infra_error(result: AISessionResult) -> bool:
|
|
|
161
203
|
return bool(result.fatal_error_code and result.fatal_error_code != CONTEXT_OVERFLOW) or any(marker in text for marker in INFRA_MARKERS)
|
|
162
204
|
|
|
163
205
|
|
|
164
|
-
def
|
|
165
|
-
if not
|
|
206
|
+
def _has_commit_range(project_root: Path, start_ref: str, end_ref: str = "HEAD") -> bool:
|
|
207
|
+
if not start_ref or not end_ref:
|
|
166
208
|
return False
|
|
167
|
-
|
|
168
|
-
|
|
209
|
+
result = run_git_command(project_root, ("rev-list", "--count", f"{start_ref}..{end_ref}"))
|
|
210
|
+
if result.return_code != 0:
|
|
211
|
+
return False
|
|
212
|
+
try:
|
|
213
|
+
return int(result.stdout.strip() or "0") > 0
|
|
214
|
+
except ValueError:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _has_commit_since(project_root: Path, base_head: str) -> bool:
|
|
219
|
+
return _has_commit_range(project_root, base_head, "HEAD")
|
|
169
220
|
|
|
170
221
|
|
|
171
222
|
def _has_workspace_changes(project_root: Path) -> bool:
|
|
@@ -45,7 +45,6 @@ class RunnerEnvironment:
|
|
|
45
45
|
heartbeat_interval_seconds: int = 30
|
|
46
46
|
heartbeat_stale_threshold_seconds: int = 600
|
|
47
47
|
stale_kill_threshold_seconds: int = 600
|
|
48
|
-
max_log_size: int | None = None
|
|
49
48
|
max_retries: int = 3
|
|
50
49
|
strict_behavior_check: bool = True
|
|
51
50
|
|
|
@@ -67,7 +66,6 @@ class RunnerEnvironment:
|
|
|
67
66
|
_int_value(values, "HEARTBEAT_TIMEOUT_SECONDS", 600),
|
|
68
67
|
),
|
|
69
68
|
stale_kill_threshold_seconds=_int_value(values, "STALE_KILL_THRESHOLD", 600),
|
|
70
|
-
max_log_size=_optional_int(values.get("MAX_LOG_SIZE")),
|
|
71
69
|
max_retries=max(0, _int_value(values, "MAX_RETRIES", 3)),
|
|
72
70
|
strict_behavior_check=not _falsey(values.get("STRICT_BEHAVIOR_CHECK")),
|
|
73
71
|
)
|
|
@@ -349,9 +347,3 @@ def _safe_int(value: str | None, default: int) -> int:
|
|
|
349
347
|
|
|
350
348
|
def _int_value(values: Mapping[str, str], name: str, default: int) -> int:
|
|
351
349
|
return _safe_int(values.get(name), default)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def _optional_int(value: str | None) -> int | None:
|
|
355
|
-
if value is None or str(value).strip() == "":
|
|
356
|
-
return None
|
|
357
|
-
return _safe_int(value, 0)
|
|
@@ -12,7 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from .config import load_runtime_config
|
|
13
13
|
from .paths import RuntimePaths
|
|
14
14
|
from .runner_models import RunnerEnvironment, SessionPaths
|
|
15
|
-
from .sessions import AISessionConfig, AISessionLauncher
|
|
15
|
+
from .sessions import AISessionConfig, AISessionLauncher, detect_stream_json_support
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@dataclass(frozen=True)
|
|
@@ -122,6 +122,9 @@ def run_recovery(paths: RuntimePaths, legacy_args: tuple[str, ...] = ()) -> tupl
|
|
|
122
122
|
if not config.ai_client.command:
|
|
123
123
|
return 1, "", "No supported AI CLI command was found for recovery\n"
|
|
124
124
|
env = RunnerEnvironment.from_env()
|
|
125
|
+
stream_json = False
|
|
126
|
+
if env.live_output:
|
|
127
|
+
stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
|
|
125
128
|
launcher = AISessionLauncher(
|
|
126
129
|
AISessionConfig(
|
|
127
130
|
cli_command=config.ai_client.command,
|
|
@@ -134,7 +137,12 @@ def run_recovery(paths: RuntimePaths, legacy_args: tuple[str, ...] = ()) -> tupl
|
|
|
134
137
|
heartbeat_path=session_paths.heartbeat_json,
|
|
135
138
|
effort=config.effort,
|
|
136
139
|
verbose=env.verbose,
|
|
140
|
+
live_output=env.live_output,
|
|
141
|
+
use_stream_json=stream_json,
|
|
137
142
|
stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
|
|
143
|
+
live_progress_interval_seconds=env.heartbeat_interval_seconds,
|
|
144
|
+
live_banner=False,
|
|
145
|
+
suppress_stream_output=True,
|
|
138
146
|
)
|
|
139
147
|
)
|
|
140
148
|
result = launcher.run()
|
|
@@ -32,7 +32,7 @@ from .runner_bookkeeping import (
|
|
|
32
32
|
commit_task_branch_changes,
|
|
33
33
|
commit_wip_changes,
|
|
34
34
|
)
|
|
35
|
-
from .runner_classification import classify_session
|
|
35
|
+
from .runner_classification import classify_session, has_branch_completion_evidence, has_completed_artifact_path
|
|
36
36
|
from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
|
|
37
37
|
from .runner_prompts import PromptGenerationResult, generate_prompt
|
|
38
38
|
from .runner_recovery import run_recovery, run_recovery_detect
|
|
@@ -111,6 +111,14 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
|
|
|
111
111
|
return PipelineRunResult(False, processed, "get_next_failed", details=(next_result.text,))
|
|
112
112
|
marker = next_result.stdout.strip()
|
|
113
113
|
if marker == "PIPELINE_COMPLETE":
|
|
114
|
+
if _has_non_terminal_details(details):
|
|
115
|
+
return PipelineRunResult(
|
|
116
|
+
False,
|
|
117
|
+
processed,
|
|
118
|
+
"pipeline_complete_with_non_terminal_history",
|
|
119
|
+
_last_detail_status(details),
|
|
120
|
+
tuple(details),
|
|
121
|
+
)
|
|
114
122
|
return PipelineRunResult(True, processed, "pipeline_complete", details=tuple(details))
|
|
115
123
|
if marker == "PIPELINE_BLOCKED":
|
|
116
124
|
return PipelineRunResult(False, processed, "pipeline_blocked", details=tuple(details))
|
|
@@ -138,6 +146,37 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
|
|
|
138
146
|
return PipelineRunResult(False, processed, "item_failed", final_status, tuple(details))
|
|
139
147
|
|
|
140
148
|
|
|
149
|
+
_NON_TERMINAL_DETAIL_STATUSES = {
|
|
150
|
+
"pending",
|
|
151
|
+
"retryable",
|
|
152
|
+
"in_progress",
|
|
153
|
+
"infra_error",
|
|
154
|
+
"context_overflow",
|
|
155
|
+
"stalled_context_continuation",
|
|
156
|
+
"crashed",
|
|
157
|
+
"timed_out",
|
|
158
|
+
"failed",
|
|
159
|
+
"needs_info",
|
|
160
|
+
"merge_conflict",
|
|
161
|
+
"interrupted",
|
|
162
|
+
"unknown",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _detail_status(detail: str) -> str:
|
|
167
|
+
return detail.rsplit(":", 1)[-1] if ":" in detail else detail
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _last_detail_status(details: list[str]) -> str:
|
|
171
|
+
if not details:
|
|
172
|
+
return ""
|
|
173
|
+
return _detail_status(details[-1])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _has_non_terminal_details(details: list[str]) -> bool:
|
|
177
|
+
return any(_detail_status(detail) in _NON_TERMINAL_DETAIL_STATUSES for detail in details)
|
|
178
|
+
|
|
179
|
+
|
|
141
180
|
_SEPARATOR = "─" * 52
|
|
142
181
|
|
|
143
182
|
|
|
@@ -190,6 +229,34 @@ def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) ->
|
|
|
190
229
|
return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
|
|
191
230
|
|
|
192
231
|
|
|
232
|
+
def _with_recovery_metadata(family: RunnerFamily, item_id: str, metadata: dict[str, object]) -> dict[str, object]:
|
|
233
|
+
enriched = dict(metadata)
|
|
234
|
+
if enriched.get("active_dev_branch") and enriched.get("base_branch"):
|
|
235
|
+
return enriched
|
|
236
|
+
recovered = _latest_session_status(family.state_dir, item_id)
|
|
237
|
+
for key in ("active_dev_branch", "base_branch"):
|
|
238
|
+
if not enriched.get(key) and recovered.get(key):
|
|
239
|
+
enriched[key] = recovered[key]
|
|
240
|
+
return enriched
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _latest_session_status(state_dir: Path, item_id: str) -> dict[str, object]:
|
|
244
|
+
sessions_dir = state_dir / item_id / "sessions"
|
|
245
|
+
try:
|
|
246
|
+
sessions = sorted(child for child in sessions_dir.iterdir() if child.is_dir())
|
|
247
|
+
except OSError:
|
|
248
|
+
return {}
|
|
249
|
+
for session in reversed(sessions):
|
|
250
|
+
status_path = session / "session-status.json"
|
|
251
|
+
try:
|
|
252
|
+
data = json.loads(status_path.read_text(encoding="utf-8"))
|
|
253
|
+
except (OSError, json.JSONDecodeError):
|
|
254
|
+
continue
|
|
255
|
+
if isinstance(data, dict):
|
|
256
|
+
return data
|
|
257
|
+
return {}
|
|
258
|
+
|
|
259
|
+
|
|
193
260
|
def _resolve_base_branch(project_root: Path, metadata: dict[str, object], active_branch: str, family: RunnerFamily, item_id: str) -> str:
|
|
194
261
|
recorded = str(metadata.get("base_branch") or "").strip()
|
|
195
262
|
if recorded:
|
|
@@ -313,8 +380,9 @@ def _process_item(
|
|
|
313
380
|
raise RuntimeError("No supported AI CLI command was found")
|
|
314
381
|
|
|
315
382
|
active_branch = current_branch(paths.project_root)
|
|
316
|
-
|
|
317
|
-
|
|
383
|
+
metadata = _with_recovery_metadata(family, item_id, initial_metadata)
|
|
384
|
+
base_branch = _resolve_base_branch(paths.project_root, metadata, active_branch, family, item_id)
|
|
385
|
+
branch_name = _resolve_working_branch(family, item_id, metadata, env, active_branch)
|
|
318
386
|
branch_context = BranchContext(base_branch, branch_name, paths.project_root)
|
|
319
387
|
worktree_runtime = None
|
|
320
388
|
use_worktree = _use_worktree_for_family(env, family)
|
|
@@ -356,21 +424,51 @@ def _process_item(
|
|
|
356
424
|
if not branch_result.ok:
|
|
357
425
|
return "infra_error"
|
|
358
426
|
|
|
359
|
-
_emit_item_header(family, invocation, item_id,
|
|
427
|
+
_emit_item_header(family, invocation, item_id, metadata)
|
|
360
428
|
status_root = execution_root if use_worktree else paths.project_root
|
|
361
429
|
status_family = _family_for_execution_root(family, paths.project_root, status_root)
|
|
362
430
|
status_invocation = _invocation_for_execution_family(invocation, status_family)
|
|
431
|
+
recovered_session_id = _latest_session_id(family.state_dir, item_id)
|
|
432
|
+
recovery_artifact = _artifact_path_for_item(execution_root, family, item_id, metadata)
|
|
433
|
+
if recovered_session_id and has_completed_artifact_path(recovery_artifact) and has_branch_completion_evidence(execution_root, base_branch, branch_name, recovery_artifact):
|
|
434
|
+
session_paths = SessionPaths.for_item(family.state_dir, item_id, recovered_session_id)
|
|
435
|
+
_write_session_status(
|
|
436
|
+
session_paths,
|
|
437
|
+
status="success",
|
|
438
|
+
session_id=recovered_session_id,
|
|
439
|
+
task_id=item_id,
|
|
440
|
+
task_type=family.task_type,
|
|
441
|
+
base_branch=base_branch,
|
|
442
|
+
active_dev_branch=branch_name,
|
|
443
|
+
worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
|
|
444
|
+
reason="completed_branch_recovered_from_late_runtime_error",
|
|
445
|
+
)
|
|
446
|
+
return _finalize_recovered_success(
|
|
447
|
+
family,
|
|
448
|
+
item_id,
|
|
449
|
+
paths,
|
|
450
|
+
status_family,
|
|
451
|
+
status_invocation,
|
|
452
|
+
status_root,
|
|
453
|
+
execution_root,
|
|
454
|
+
branch_context,
|
|
455
|
+
branch_name,
|
|
456
|
+
base_branch,
|
|
457
|
+
recovered_session_id,
|
|
458
|
+
session_paths,
|
|
459
|
+
worktree_runtime,
|
|
460
|
+
)
|
|
363
461
|
start = start_item(status_family, status_invocation, item_id, status_root)
|
|
364
462
|
if not start.ok:
|
|
365
463
|
raise RuntimeError(start.text)
|
|
366
464
|
commit_pre_branch_bookkeeping(status_root, status_family, item_id)
|
|
367
465
|
|
|
368
|
-
previous_fingerprint =
|
|
369
|
-
previous_no_progress_count = int(
|
|
370
|
-
continuation_pending = bool(
|
|
371
|
-
previous_session_id = str(
|
|
372
|
-
continuation_count = int(
|
|
373
|
-
context_overflow_count = int(
|
|
466
|
+
previous_fingerprint = metadata.get("last_progress_fingerprint")
|
|
467
|
+
previous_no_progress_count = int(metadata.get("no_progress_count") or 0)
|
|
468
|
+
continuation_pending = bool(metadata.get("continuation_pending"))
|
|
469
|
+
previous_session_id = str(metadata.get("previous_session_id") or metadata.get("last_context_overflow_session_id") or "")
|
|
470
|
+
continuation_count = int(metadata.get("continuation_count") or 0)
|
|
471
|
+
context_overflow_count = int(metadata.get("context_overflow_count") or 0)
|
|
374
472
|
|
|
375
473
|
while True:
|
|
376
474
|
session_id = _session_id(item_id)
|
|
@@ -500,62 +598,23 @@ def _process_item(
|
|
|
500
598
|
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
501
599
|
return new_status
|
|
502
600
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
_emit_git_plan_output(merge)
|
|
509
|
-
if merge.ok:
|
|
510
|
-
guarded_worktree_remove(worktree_runtime, delete_branch=True)
|
|
511
|
-
_emit_success(f"Merged {branch_name} into {base_branch}")
|
|
512
|
-
return new_status
|
|
513
|
-
final_session_status = "merge_conflict"
|
|
514
|
-
else:
|
|
515
|
-
_emit_info(f"Merging {branch_name} into {base_branch}...")
|
|
516
|
-
merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
|
|
517
|
-
_emit_git_plan_output(merge)
|
|
518
|
-
if merge.ok:
|
|
519
|
-
_emit_success(f"Merged {branch_name} into {base_branch}")
|
|
520
|
-
return new_status
|
|
521
|
-
final_session_status = "merge_conflict"
|
|
522
|
-
|
|
523
|
-
_write_session_status(
|
|
524
|
-
session_paths,
|
|
525
|
-
status=final_session_status,
|
|
526
|
-
session_id=session_id,
|
|
527
|
-
task_id=item_id,
|
|
528
|
-
task_type=family.task_type,
|
|
529
|
-
base_branch=base_branch,
|
|
530
|
-
active_dev_branch=branch_name,
|
|
531
|
-
worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
|
|
532
|
-
reason="merge_conflict",
|
|
533
|
-
fatal_error_code=classification.fatal_error_code,
|
|
534
|
-
)
|
|
535
|
-
update = update_item(
|
|
601
|
+
return _complete_success_merge(
|
|
602
|
+
family,
|
|
603
|
+
item_id,
|
|
604
|
+
new_status,
|
|
605
|
+
paths,
|
|
536
606
|
status_family,
|
|
537
607
|
status_invocation,
|
|
538
|
-
item_id,
|
|
539
|
-
final_session_status,
|
|
540
|
-
session_id,
|
|
541
608
|
status_root,
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
609
|
+
execution_root,
|
|
610
|
+
branch_context,
|
|
611
|
+
branch_name,
|
|
612
|
+
base_branch,
|
|
613
|
+
session_id,
|
|
614
|
+
session_paths,
|
|
615
|
+
worktree_runtime,
|
|
616
|
+
classification_fatal_error_code=classification.fatal_error_code,
|
|
547
617
|
)
|
|
548
|
-
if not update.ok:
|
|
549
|
-
raise RuntimeError(update.text)
|
|
550
|
-
_emit_update_summary(family, item_id, update)
|
|
551
|
-
new_status = _status_from_update(update)
|
|
552
|
-
commit_final_bookkeeping(status_root, status_family, item_id, new_status)
|
|
553
|
-
_save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
|
|
554
|
-
if use_worktree and worktree_runtime is not None:
|
|
555
|
-
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
556
|
-
else:
|
|
557
|
-
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
558
|
-
return new_status
|
|
559
618
|
except KeyboardInterrupt:
|
|
560
619
|
_emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
|
|
561
620
|
if use_worktree and worktree_runtime is not None:
|
|
@@ -566,6 +625,126 @@ def _process_item(
|
|
|
566
625
|
raise
|
|
567
626
|
|
|
568
627
|
|
|
628
|
+
def _finalize_recovered_success(
|
|
629
|
+
family: RunnerFamily,
|
|
630
|
+
item_id: str,
|
|
631
|
+
paths,
|
|
632
|
+
status_family: RunnerFamily,
|
|
633
|
+
status_invocation: RunnerInvocation,
|
|
634
|
+
status_root: Path,
|
|
635
|
+
execution_root: Path,
|
|
636
|
+
branch_context: BranchContext,
|
|
637
|
+
branch_name: str,
|
|
638
|
+
base_branch: str,
|
|
639
|
+
session_id: str,
|
|
640
|
+
session_paths: SessionPaths,
|
|
641
|
+
worktree_runtime,
|
|
642
|
+
) -> str:
|
|
643
|
+
update = update_item(
|
|
644
|
+
status_family,
|
|
645
|
+
status_invocation,
|
|
646
|
+
item_id,
|
|
647
|
+
"success",
|
|
648
|
+
session_id,
|
|
649
|
+
status_root,
|
|
650
|
+
active_dev_branch=branch_name,
|
|
651
|
+
base_branch=base_branch,
|
|
652
|
+
)
|
|
653
|
+
if not update.ok:
|
|
654
|
+
raise RuntimeError(update.text)
|
|
655
|
+
_emit_update_summary(family, item_id, update)
|
|
656
|
+
return _complete_success_merge(
|
|
657
|
+
family,
|
|
658
|
+
item_id,
|
|
659
|
+
_status_from_update(update),
|
|
660
|
+
paths,
|
|
661
|
+
status_family,
|
|
662
|
+
status_invocation,
|
|
663
|
+
status_root,
|
|
664
|
+
execution_root,
|
|
665
|
+
branch_context,
|
|
666
|
+
branch_name,
|
|
667
|
+
base_branch,
|
|
668
|
+
session_id,
|
|
669
|
+
session_paths,
|
|
670
|
+
worktree_runtime,
|
|
671
|
+
classification_fatal_error_code="",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _complete_success_merge(
|
|
676
|
+
family: RunnerFamily,
|
|
677
|
+
item_id: str,
|
|
678
|
+
new_status: str,
|
|
679
|
+
paths,
|
|
680
|
+
status_family: RunnerFamily,
|
|
681
|
+
status_invocation: RunnerInvocation,
|
|
682
|
+
status_root: Path,
|
|
683
|
+
execution_root: Path,
|
|
684
|
+
branch_context: BranchContext,
|
|
685
|
+
branch_name: str,
|
|
686
|
+
base_branch: str,
|
|
687
|
+
session_id: str,
|
|
688
|
+
session_paths: SessionPaths,
|
|
689
|
+
worktree_runtime,
|
|
690
|
+
*,
|
|
691
|
+
classification_fatal_error_code: str = "",
|
|
692
|
+
) -> str:
|
|
693
|
+
env = RunnerEnvironment.from_env()
|
|
694
|
+
commit_final_bookkeeping(status_root, status_family, item_id, new_status)
|
|
695
|
+
commit_task_branch_changes(execution_root, family, item_id, new_status)
|
|
696
|
+
if worktree_runtime is not None:
|
|
697
|
+
_emit_info(f"Merging {branch_name} into {base_branch}...")
|
|
698
|
+
merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
|
|
699
|
+
_emit_git_plan_output(merge)
|
|
700
|
+
if merge.ok:
|
|
701
|
+
guarded_worktree_remove(worktree_runtime, delete_branch=True)
|
|
702
|
+
_emit_success(f"Merged {branch_name} into {base_branch}")
|
|
703
|
+
return new_status
|
|
704
|
+
else:
|
|
705
|
+
_emit_info(f"Merging {branch_name} into {base_branch}...")
|
|
706
|
+
merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
|
|
707
|
+
_emit_git_plan_output(merge)
|
|
708
|
+
if merge.ok:
|
|
709
|
+
_emit_success(f"Merged {branch_name} into {base_branch}")
|
|
710
|
+
return new_status
|
|
711
|
+
|
|
712
|
+
_write_session_status(
|
|
713
|
+
session_paths,
|
|
714
|
+
status="merge_conflict",
|
|
715
|
+
session_id=session_id,
|
|
716
|
+
task_id=item_id,
|
|
717
|
+
task_type=family.task_type,
|
|
718
|
+
base_branch=base_branch,
|
|
719
|
+
active_dev_branch=branch_name,
|
|
720
|
+
worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
|
|
721
|
+
reason="merge_conflict",
|
|
722
|
+
fatal_error_code=classification_fatal_error_code,
|
|
723
|
+
)
|
|
724
|
+
update = update_item(
|
|
725
|
+
status_family,
|
|
726
|
+
status_invocation,
|
|
727
|
+
item_id,
|
|
728
|
+
"merge_conflict",
|
|
729
|
+
session_id,
|
|
730
|
+
status_root,
|
|
731
|
+
active_dev_branch=branch_name,
|
|
732
|
+
base_branch=base_branch,
|
|
733
|
+
last_fatal_error_code=classification_fatal_error_code,
|
|
734
|
+
)
|
|
735
|
+
if not update.ok:
|
|
736
|
+
raise RuntimeError(update.text)
|
|
737
|
+
_emit_update_summary(family, item_id, update)
|
|
738
|
+
merged_status = _status_from_update(update)
|
|
739
|
+
commit_final_bookkeeping(status_root, status_family, item_id, merged_status)
|
|
740
|
+
_save_failure_wip(execution_root, family, item_id, merged_status, worktree_runtime)
|
|
741
|
+
if worktree_runtime is not None:
|
|
742
|
+
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
743
|
+
else:
|
|
744
|
+
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
745
|
+
return merged_status
|
|
746
|
+
|
|
747
|
+
|
|
569
748
|
def _save_failure_wip(execution_root: Path, family: RunnerFamily, item_id: str, status: str, worktree_runtime) -> None:
|
|
570
749
|
if worktree_runtime is not None:
|
|
571
750
|
worktree_save_wip(worktree_runtime)
|
|
@@ -684,6 +863,43 @@ def _path_in_execution_root(path: Path, project_root: Path, execution_root: Path
|
|
|
684
863
|
return path
|
|
685
864
|
|
|
686
865
|
|
|
866
|
+
def _latest_session_id(state_dir: Path, item_id: str) -> str:
|
|
867
|
+
sessions_dir = state_dir / item_id / "sessions"
|
|
868
|
+
try:
|
|
869
|
+
sessions = [child.name for child in sessions_dir.iterdir() if child.is_dir()]
|
|
870
|
+
except OSError:
|
|
871
|
+
return ""
|
|
872
|
+
return sorted(sessions)[-1] if sessions else ""
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _artifact_path_for_item(project_root: Path, family: RunnerFamily, item_id: str, metadata: dict[str, object]) -> Path:
|
|
876
|
+
if family.kind == "feature":
|
|
877
|
+
title = str(metadata.get("title") or "")
|
|
878
|
+
if title:
|
|
879
|
+
return project_root / ".prizmkit" / "specs" / _feature_slug(item_id, title)
|
|
880
|
+
specs = project_root / ".prizmkit" / "specs"
|
|
881
|
+
numeric = item_id.replace("F-", "").replace("f-", "").zfill(3)
|
|
882
|
+
try:
|
|
883
|
+
matches = sorted(path for path in specs.iterdir() if path.is_dir() and path.name.startswith(f"{numeric}-"))
|
|
884
|
+
except OSError:
|
|
885
|
+
matches = []
|
|
886
|
+
if matches:
|
|
887
|
+
return matches[0]
|
|
888
|
+
return project_root / ".prizmkit" / family.artifact_root_name / item_id
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _feature_slug(feature_id: str, title: str) -> str:
|
|
892
|
+
import re
|
|
893
|
+
|
|
894
|
+
numeric = feature_id.replace("F-", "").replace("f-", "").zfill(3)
|
|
895
|
+
cleaned = re.sub(r"[^a-z0-9\s-]", "", title.lower())
|
|
896
|
+
cleaned = re.sub(r"[\s]+", "-", cleaned.strip())
|
|
897
|
+
cleaned = re.sub(r"-+", "-", cleaned).strip("-")
|
|
898
|
+
if not cleaned:
|
|
899
|
+
cleaned = "feature"
|
|
900
|
+
return f"{numeric}-{cleaned}"
|
|
901
|
+
|
|
902
|
+
|
|
687
903
|
def _write_session_status(
|
|
688
904
|
session_paths: SessionPaths,
|
|
689
905
|
*,
|