prizmkit 1.1.105 → 1.1.106
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/gitops.py +0 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +83 -12
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +38 -2
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +194 -50
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +208 -0
- package/bundled/dev-pipeline/tests/test_unified_cli.py +1 -0
- package/bundled/skills/_metadata.json +1 -1
- package/package.json +1 -1
package/bundled/VERSION.json
CHANGED
|
@@ -565,7 +565,6 @@ def branch_ensure_return_plan(context: BranchContext) -> GitCommandPlan:
|
|
|
565
565
|
),
|
|
566
566
|
_git("stash", "push", "-m", "pipeline-ensure-return-stash", allow_failure=True, description="stash checkout blockers"),
|
|
567
567
|
_git("checkout", context.base_branch, allow_failure=True, description="return to original branch"),
|
|
568
|
-
_git("stash", "pop", allow_failure=True, description="restore stashed checkout blockers"),
|
|
569
568
|
),
|
|
570
569
|
never_fails=True,
|
|
571
570
|
notes=("Always returns success to support trap/finally cleanup paths.",),
|
|
@@ -7,10 +7,11 @@ legacy ordering around branch creation and final status updates.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from collections.abc import Sequence
|
|
10
11
|
from dataclasses import dataclass
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
13
|
-
from .gitops import GitCommandResult, git_status_safe, run_git_command
|
|
14
|
+
from .gitops import HIDDEN_TOOL_WORKTREE_EXCLUDES, GitCommandResult, git_status_safe, run_git_command
|
|
14
15
|
from .runner_models import RunnerFamily
|
|
15
16
|
|
|
16
17
|
|
|
@@ -27,25 +28,95 @@ class BookkeepingResult:
|
|
|
27
28
|
return self.result is None or self.result.return_code == 0
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
def _bookkeeping_paths(project_root: Path, family: RunnerFamily, item_id: str) -> tuple[str, ...]:
|
|
32
|
+
return (
|
|
33
|
+
_repo_path(project_root, family.plan_path),
|
|
34
|
+
_repo_path(project_root, family.state_dir / item_id / "status.json"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _repo_path(project_root: Path, path: Path) -> str:
|
|
39
|
+
try:
|
|
40
|
+
return path.resolve().relative_to(project_root.resolve()).as_posix()
|
|
41
|
+
except ValueError:
|
|
42
|
+
return path.as_posix()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _changed_paths_in(project_root: Path, paths: Sequence[str]) -> tuple[str, ...]:
|
|
46
|
+
result = run_git_command(project_root, ("status", "--porcelain", "--", *paths))
|
|
47
|
+
if result.return_code != 0:
|
|
48
|
+
return ()
|
|
49
|
+
changed: list[str] = []
|
|
50
|
+
for line in result.stdout.splitlines():
|
|
51
|
+
path = line[3:].strip()
|
|
52
|
+
if " -> " in path:
|
|
53
|
+
path = path.split(" -> ", 1)[1].strip()
|
|
54
|
+
if path:
|
|
55
|
+
changed.append(path)
|
|
56
|
+
return tuple(changed)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def commit_bookkeeping_changes(
|
|
60
|
+
project_root: Path,
|
|
61
|
+
family: RunnerFamily,
|
|
62
|
+
item_id: str,
|
|
63
|
+
message: str,
|
|
64
|
+
) -> BookkeepingResult:
|
|
65
|
+
"""Stage and commit only runner-owned bookkeeping paths when present."""
|
|
66
|
+
paths = _changed_paths_in(project_root, _bookkeeping_paths(project_root, family, item_id))
|
|
67
|
+
if not paths:
|
|
68
|
+
return BookkeepingResult(attempted=False, committed=False)
|
|
69
|
+
run_git_command(project_root, ("add", "-A", "--", *paths))
|
|
70
|
+
result = run_git_command(project_root, ("commit", "--no-verify", "-m", message))
|
|
71
|
+
return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
|
|
33
72
|
|
|
34
73
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
74
|
+
def commit_task_branch_changes(
|
|
75
|
+
project_root: Path,
|
|
76
|
+
family: RunnerFamily,
|
|
77
|
+
item_id: str,
|
|
78
|
+
status: str,
|
|
79
|
+
) -> BookkeepingResult:
|
|
80
|
+
"""Commit task branch code, artifacts, and status without switching branches."""
|
|
81
|
+
message = f"chore({item_id}): save {family.kind} {status} state"
|
|
82
|
+
if status == "completed":
|
|
83
|
+
message = f"chore({item_id}): complete {family.kind} task"
|
|
84
|
+
if not git_status_safe(project_root).strip():
|
|
38
85
|
return BookkeepingResult(attempted=False, committed=False)
|
|
39
|
-
run_git_command(project_root, ("add", "-A", "--", "."))
|
|
86
|
+
run_git_command(project_root, ("add", "-A", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
|
|
40
87
|
result = run_git_command(project_root, ("commit", "--no-verify", "-m", message))
|
|
41
88
|
return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
|
|
42
89
|
|
|
43
90
|
|
|
91
|
+
def commit_wip_changes(
|
|
92
|
+
project_root: Path,
|
|
93
|
+
family: RunnerFamily,
|
|
94
|
+
item_id: str,
|
|
95
|
+
status: str,
|
|
96
|
+
) -> BookkeepingResult:
|
|
97
|
+
"""Commit all visible task-branch changes as preserved WIP."""
|
|
98
|
+
if not git_status_safe(project_root).strip():
|
|
99
|
+
return BookkeepingResult(attempted=False, committed=False)
|
|
100
|
+
run_git_command(project_root, ("add", "-A", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
|
|
101
|
+
result = run_git_command(
|
|
102
|
+
project_root,
|
|
103
|
+
(
|
|
104
|
+
"commit",
|
|
105
|
+
"--no-verify",
|
|
106
|
+
"-m",
|
|
107
|
+
f"wip({item_id}): preserve {family.kind} {status} state",
|
|
108
|
+
"-m",
|
|
109
|
+
"Pipeline preserved task branch work without merging it to the base branch.",
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
|
|
113
|
+
|
|
114
|
+
|
|
44
115
|
def commit_pre_branch_bookkeeping(project_root: Path, family: RunnerFamily, item_id: str) -> BookkeepingResult:
|
|
45
|
-
"""Commit status
|
|
46
|
-
return
|
|
116
|
+
"""Commit in-progress status on the current task branch."""
|
|
117
|
+
return commit_bookkeeping_changes(project_root, family, item_id, f"chore({item_id}): mark in_progress")
|
|
47
118
|
|
|
48
119
|
|
|
49
120
|
def commit_final_bookkeeping(project_root: Path, family: RunnerFamily, item_id: str, status: str) -> BookkeepingResult:
|
|
50
|
-
"""Commit final
|
|
51
|
-
return
|
|
121
|
+
"""Commit final runner-owned status changes on the current task branch."""
|
|
122
|
+
return commit_bookkeeping_changes(project_root, family, item_id, f"chore({item_id}): update {family.kind} status {status}")
|
|
@@ -43,10 +43,18 @@ def classify_session(
|
|
|
43
43
|
) -> SessionClassification:
|
|
44
44
|
"""Classify raw AI session signals with durable success precedence."""
|
|
45
45
|
fingerprint = progress_fingerprint(project_root, prompt)
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
has_commit_since_base = _has_commit_since(project_root, base_head)
|
|
47
|
+
|
|
48
|
+
if _is_terminal_success(result) and _has_completed_workflow_artifact(prompt):
|
|
49
|
+
return SessionClassification(
|
|
50
|
+
"success",
|
|
51
|
+
reason="terminal_success_with_completed_checkpoint",
|
|
52
|
+
progress_fingerprint=fingerprint,
|
|
53
|
+
)
|
|
48
54
|
|
|
49
55
|
if _is_context_overflow(result):
|
|
56
|
+
if has_commit_since_base:
|
|
57
|
+
return SessionClassification("success", reason="commit_since_base", progress_fingerprint=fingerprint)
|
|
50
58
|
no_progress_count = _next_no_progress_count(previous_fingerprint, fingerprint, previous_no_progress_count)
|
|
51
59
|
if no_progress_count >= 2:
|
|
52
60
|
return SessionClassification(
|
|
@@ -72,6 +80,8 @@ def classify_session(
|
|
|
72
80
|
return SessionClassification("timed_out", reason="timeout", progress_fingerprint=fingerprint)
|
|
73
81
|
if result.exit_code not in (0, None):
|
|
74
82
|
return SessionClassification("crashed", reason=f"exit_code={result.exit_code}", progress_fingerprint=fingerprint)
|
|
83
|
+
if has_commit_since_base:
|
|
84
|
+
return SessionClassification("success", reason="commit_since_base", progress_fingerprint=fingerprint)
|
|
75
85
|
if _has_workspace_changes(project_root):
|
|
76
86
|
_auto_commit_workspace(project_root)
|
|
77
87
|
return SessionClassification("success", reason="auto_commit_uncommitted_changes", progress_fingerprint=fingerprint)
|
|
@@ -100,6 +110,32 @@ def progress_fingerprint(project_root: Path, prompt: PromptGenerationResult | No
|
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
|
|
113
|
+
def _is_terminal_success(result: AISessionResult) -> bool:
|
|
114
|
+
return (
|
|
115
|
+
result.exit_code in (0, None)
|
|
116
|
+
and not result.timed_out
|
|
117
|
+
and result.termination_reason not in {"timeout", "stale_session"}
|
|
118
|
+
and not result.fatal_error_code
|
|
119
|
+
and not result.progress_summary.fatal_error_code
|
|
120
|
+
and result.progress_summary.result_subtype == "success"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _has_completed_workflow_artifact(prompt: PromptGenerationResult | None) -> bool:
|
|
125
|
+
if prompt is None:
|
|
126
|
+
return False
|
|
127
|
+
if prompt.checkpoint_path and prompt.checkpoint_path.is_file():
|
|
128
|
+
try:
|
|
129
|
+
data = json.loads(prompt.checkpoint_path.read_text(encoding="utf-8"))
|
|
130
|
+
except (OSError, json.JSONDecodeError):
|
|
131
|
+
data = {}
|
|
132
|
+
if isinstance(data, dict) and _checkpoint_cursor(data) == "complete":
|
|
133
|
+
return True
|
|
134
|
+
if prompt.artifact_path and (prompt.artifact_path / "completion-summary.json").is_file():
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
103
139
|
def _next_no_progress_count(previous: object | None, current: object, count: int) -> int:
|
|
104
140
|
if previous and previous == current:
|
|
105
141
|
return max(0, count) + 1
|
|
@@ -26,7 +26,12 @@ from .gitops import (
|
|
|
26
26
|
worktree_runtime_context,
|
|
27
27
|
worktree_save_wip,
|
|
28
28
|
)
|
|
29
|
-
from .runner_bookkeeping import
|
|
29
|
+
from .runner_bookkeeping import (
|
|
30
|
+
commit_final_bookkeeping,
|
|
31
|
+
commit_pre_branch_bookkeeping,
|
|
32
|
+
commit_task_branch_changes,
|
|
33
|
+
commit_wip_changes,
|
|
34
|
+
)
|
|
30
35
|
from .runner_classification import classify_session
|
|
31
36
|
from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
|
|
32
37
|
from .runner_prompts import PromptGenerationResult, generate_prompt
|
|
@@ -180,7 +185,9 @@ def _is_recovery_side_effect_branch(branch: str, family: RunnerFamily, item_id:
|
|
|
180
185
|
|
|
181
186
|
|
|
182
187
|
def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) -> bool:
|
|
183
|
-
|
|
188
|
+
if not branch or _is_recovery_side_effect_branch(branch, family, item_id):
|
|
189
|
+
return False
|
|
190
|
+
return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
|
|
184
191
|
|
|
185
192
|
|
|
186
193
|
def _resolve_base_branch(project_root: Path, metadata: dict[str, object], active_branch: str, family: RunnerFamily, item_id: str) -> str:
|
|
@@ -313,13 +320,7 @@ def _process_item(
|
|
|
313
320
|
use_worktree = _use_worktree_for_family(env, family)
|
|
314
321
|
execution_root = paths.project_root
|
|
315
322
|
|
|
316
|
-
_emit_item_header(family, invocation, item_id, initial_metadata)
|
|
317
323
|
try:
|
|
318
|
-
start = start_item(family, invocation, item_id, paths.project_root)
|
|
319
|
-
if not start.ok:
|
|
320
|
-
raise RuntimeError(start.text)
|
|
321
|
-
commit_pre_branch_bookkeeping(paths.project_root, family, item_id)
|
|
322
|
-
|
|
323
324
|
if use_worktree:
|
|
324
325
|
worktree_runtime = worktree_runtime_context(
|
|
325
326
|
branch_context,
|
|
@@ -332,22 +333,37 @@ def _process_item(
|
|
|
332
333
|
)
|
|
333
334
|
setup = ensure_linked_worktree(worktree_runtime)
|
|
334
335
|
if not setup.ok:
|
|
335
|
-
|
|
336
|
-
return _status_from_update(status)
|
|
336
|
+
return "infra_error"
|
|
337
337
|
worktree_runtime = setup.runtime or worktree_runtime
|
|
338
338
|
_emit_info(f"Dev branch: {branch_name}")
|
|
339
339
|
_emit_info(f"Worktree: {worktree_runtime.worktree_path}")
|
|
340
340
|
support_assets = materialize_worktree_support_assets(worktree_runtime)
|
|
341
341
|
if not support_assets.ok:
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
return _record_setup_failure(
|
|
343
|
+
family,
|
|
344
|
+
invocation,
|
|
345
|
+
item_id,
|
|
346
|
+
paths.project_root,
|
|
347
|
+
worktree_runtime.worktree_path,
|
|
348
|
+
base_branch,
|
|
349
|
+
branch_name,
|
|
350
|
+
worktree_runtime=worktree_runtime,
|
|
351
|
+
)
|
|
344
352
|
execution_root = worktree_runtime.worktree_path
|
|
345
353
|
else:
|
|
346
354
|
branch_result = run_git_plan(paths.project_root, branch_create_plan(branch_context))
|
|
347
355
|
_emit_branch_setup_result(branch_context, branch_result)
|
|
348
356
|
if not branch_result.ok:
|
|
349
|
-
|
|
350
|
-
|
|
357
|
+
return "infra_error"
|
|
358
|
+
|
|
359
|
+
_emit_item_header(family, invocation, item_id, initial_metadata)
|
|
360
|
+
status_root = execution_root if use_worktree else paths.project_root
|
|
361
|
+
status_family = _family_for_execution_root(family, paths.project_root, status_root)
|
|
362
|
+
status_invocation = _invocation_for_execution_family(invocation, status_family)
|
|
363
|
+
start = start_item(status_family, status_invocation, item_id, status_root)
|
|
364
|
+
if not start.ok:
|
|
365
|
+
raise RuntimeError(start.text)
|
|
366
|
+
commit_pre_branch_bookkeeping(status_root, status_family, item_id)
|
|
351
367
|
|
|
352
368
|
previous_fingerprint = initial_metadata.get("last_progress_fingerprint")
|
|
353
369
|
previous_no_progress_count = int(initial_metadata.get("no_progress_count") or 0)
|
|
@@ -368,7 +384,7 @@ def _process_item(
|
|
|
368
384
|
"task_type": family.task_type,
|
|
369
385
|
"active_dev_branch": branch_name,
|
|
370
386
|
"base_branch": base_branch,
|
|
371
|
-
"continuation_summary_path": _continuation_summary_path(
|
|
387
|
+
"continuation_summary_path": _continuation_summary_path(execution_root, family, item_id, None),
|
|
372
388
|
}
|
|
373
389
|
prompt = generate_prompt(
|
|
374
390
|
family,
|
|
@@ -417,14 +433,14 @@ def _process_item(
|
|
|
417
433
|
)
|
|
418
434
|
_emit_session_summary(session_result, session_paths, classification)
|
|
419
435
|
if classification.session_status == "context_overflow":
|
|
420
|
-
summary_path = _continuation_summary_path(
|
|
436
|
+
summary_path = _continuation_summary_path(execution_root, family, item_id, prompt)
|
|
421
437
|
update = update_item(
|
|
422
|
-
|
|
423
|
-
|
|
438
|
+
status_family,
|
|
439
|
+
status_invocation,
|
|
424
440
|
item_id,
|
|
425
441
|
"context_overflow",
|
|
426
442
|
session_id,
|
|
427
|
-
|
|
443
|
+
status_root,
|
|
428
444
|
active_dev_branch=branch_name,
|
|
429
445
|
base_branch=base_branch,
|
|
430
446
|
last_fatal_error_code=classification.fatal_error_code or "context_overflow",
|
|
@@ -444,32 +460,65 @@ def _process_item(
|
|
|
444
460
|
continue
|
|
445
461
|
|
|
446
462
|
final_session_status = classification.session_status
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
463
|
+
|
|
464
|
+
_write_session_status(
|
|
465
|
+
session_paths,
|
|
466
|
+
status=final_session_status,
|
|
467
|
+
session_id=session_id,
|
|
468
|
+
task_id=item_id,
|
|
469
|
+
task_type=family.task_type,
|
|
470
|
+
base_branch=base_branch,
|
|
471
|
+
active_dev_branch=branch_name,
|
|
472
|
+
worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
|
|
473
|
+
reason=classification.reason,
|
|
474
|
+
fatal_error_code=classification.fatal_error_code,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
update = update_item(
|
|
478
|
+
status_family,
|
|
479
|
+
status_invocation,
|
|
480
|
+
item_id,
|
|
481
|
+
final_session_status,
|
|
482
|
+
session_id,
|
|
483
|
+
status_root,
|
|
484
|
+
active_dev_branch=branch_name,
|
|
485
|
+
base_branch=base_branch,
|
|
486
|
+
last_fatal_error_code=classification.fatal_error_code,
|
|
487
|
+
no_progress_count=classification.no_progress_count,
|
|
488
|
+
progress_fingerprint=classification.progress_fingerprint,
|
|
489
|
+
)
|
|
490
|
+
if not update.ok:
|
|
491
|
+
raise RuntimeError(update.text)
|
|
492
|
+
_emit_update_summary(family, item_id, update)
|
|
493
|
+
new_status = _status_from_update(update)
|
|
494
|
+
if final_session_status != "success":
|
|
495
|
+
commit_final_bookkeeping(status_root, status_family, item_id, new_status)
|
|
496
|
+
_save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
|
|
497
|
+
if use_worktree and worktree_runtime is not None:
|
|
460
498
|
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
461
|
-
else:
|
|
462
|
-
if final_session_status == "success":
|
|
463
|
-
_emit_info(f"Merging {branch_name} into {base_branch}...")
|
|
464
|
-
merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
|
|
465
|
-
_emit_git_plan_output(merge)
|
|
466
|
-
if merge.ok:
|
|
467
|
-
_emit_success(f"Merged {branch_name} into {base_branch}")
|
|
468
|
-
else:
|
|
469
|
-
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
470
|
-
final_session_status = "merge_conflict"
|
|
471
499
|
else:
|
|
472
500
|
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
501
|
+
return new_status
|
|
502
|
+
|
|
503
|
+
commit_final_bookkeeping(status_root, status_family, item_id, new_status)
|
|
504
|
+
commit_task_branch_changes(execution_root, family, item_id, new_status)
|
|
505
|
+
if use_worktree and worktree_runtime is not None:
|
|
506
|
+
_emit_info(f"Merging {branch_name} into {base_branch}...")
|
|
507
|
+
merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
|
|
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"
|
|
473
522
|
|
|
474
523
|
_write_session_status(
|
|
475
524
|
session_paths,
|
|
@@ -480,17 +529,16 @@ def _process_item(
|
|
|
480
529
|
base_branch=base_branch,
|
|
481
530
|
active_dev_branch=branch_name,
|
|
482
531
|
worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
|
|
483
|
-
reason=
|
|
532
|
+
reason="merge_conflict",
|
|
484
533
|
fatal_error_code=classification.fatal_error_code,
|
|
485
534
|
)
|
|
486
|
-
|
|
487
535
|
update = update_item(
|
|
488
|
-
|
|
489
|
-
|
|
536
|
+
status_family,
|
|
537
|
+
status_invocation,
|
|
490
538
|
item_id,
|
|
491
539
|
final_session_status,
|
|
492
540
|
session_id,
|
|
493
|
-
|
|
541
|
+
status_root,
|
|
494
542
|
active_dev_branch=branch_name,
|
|
495
543
|
base_branch=base_branch,
|
|
496
544
|
last_fatal_error_code=classification.fatal_error_code,
|
|
@@ -501,7 +549,12 @@ def _process_item(
|
|
|
501
549
|
raise RuntimeError(update.text)
|
|
502
550
|
_emit_update_summary(family, item_id, update)
|
|
503
551
|
new_status = _status_from_update(update)
|
|
504
|
-
commit_final_bookkeeping(
|
|
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)
|
|
505
558
|
return new_status
|
|
506
559
|
except KeyboardInterrupt:
|
|
507
560
|
_emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
|
|
@@ -513,6 +566,52 @@ def _process_item(
|
|
|
513
566
|
raise
|
|
514
567
|
|
|
515
568
|
|
|
569
|
+
def _save_failure_wip(execution_root: Path, family: RunnerFamily, item_id: str, status: str, worktree_runtime) -> None:
|
|
570
|
+
if worktree_runtime is not None:
|
|
571
|
+
worktree_save_wip(worktree_runtime)
|
|
572
|
+
else:
|
|
573
|
+
commit_wip_changes(execution_root, family, item_id, status)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _record_setup_failure(
|
|
577
|
+
family: RunnerFamily,
|
|
578
|
+
invocation: RunnerInvocation,
|
|
579
|
+
item_id: str,
|
|
580
|
+
project_root: Path,
|
|
581
|
+
status_root: Path,
|
|
582
|
+
base_branch: str,
|
|
583
|
+
branch_name: str,
|
|
584
|
+
*,
|
|
585
|
+
worktree_runtime=None,
|
|
586
|
+
) -> str:
|
|
587
|
+
"""Record setup-time infra failures on a task branch when one exists."""
|
|
588
|
+
if status_root.resolve() == project_root.resolve() and current_branch(project_root) != branch_name:
|
|
589
|
+
return "infra_error"
|
|
590
|
+
status_family = _family_for_execution_root(family, project_root, status_root)
|
|
591
|
+
status_invocation = _invocation_for_execution_family(invocation, status_family)
|
|
592
|
+
start = start_item(status_family, status_invocation, item_id, status_root)
|
|
593
|
+
if start.ok:
|
|
594
|
+
commit_pre_branch_bookkeeping(status_root, status_family, item_id)
|
|
595
|
+
update = update_item(
|
|
596
|
+
status_family,
|
|
597
|
+
status_invocation,
|
|
598
|
+
item_id,
|
|
599
|
+
"infra_error",
|
|
600
|
+
"",
|
|
601
|
+
status_root,
|
|
602
|
+
active_dev_branch=branch_name,
|
|
603
|
+
base_branch=base_branch,
|
|
604
|
+
)
|
|
605
|
+
new_status = _status_from_update(update) if update.ok else "infra_error"
|
|
606
|
+
if update.ok:
|
|
607
|
+
commit_final_bookkeeping(status_root, status_family, item_id, new_status)
|
|
608
|
+
_save_failure_wip(status_root, family, item_id, new_status, worktree_runtime)
|
|
609
|
+
if worktree_runtime is not None:
|
|
610
|
+
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
611
|
+
elif current_branch(project_root) == branch_name:
|
|
612
|
+
branch_ensure_return(project_root, base_branch, branch_name)
|
|
613
|
+
return new_status
|
|
614
|
+
|
|
516
615
|
def _test_cli(kind: str, paths) -> CommandResult:
|
|
517
616
|
"""Report the AI CLI resolution used by Python runner commands."""
|
|
518
617
|
config = load_runtime_config(paths)
|
|
@@ -539,6 +638,52 @@ def _use_worktree_for_family(env: RunnerEnvironment, family: RunnerFamily) -> bo
|
|
|
539
638
|
return env.use_worktree and family.kind in {"feature", "bugfix"}
|
|
540
639
|
|
|
541
640
|
|
|
641
|
+
def _family_for_execution_root(family: RunnerFamily, project_root: Path, execution_root: Path) -> RunnerFamily:
|
|
642
|
+
"""Return family paths rooted in the task execution checkout."""
|
|
643
|
+
if execution_root.resolve() == project_root.resolve():
|
|
644
|
+
return family
|
|
645
|
+
return RunnerFamily(
|
|
646
|
+
kind=family.kind,
|
|
647
|
+
id_prefix=family.id_prefix,
|
|
648
|
+
list_key=family.list_key,
|
|
649
|
+
list_arg=family.list_arg,
|
|
650
|
+
item_id_arg=family.item_id_arg,
|
|
651
|
+
state_dir=_path_in_execution_root(family.state_dir, project_root, execution_root),
|
|
652
|
+
plan_path=_path_in_execution_root(family.plan_path, project_root, execution_root),
|
|
653
|
+
updater_script=family.updater_script,
|
|
654
|
+
prompt_script=family.prompt_script,
|
|
655
|
+
branch_prefix=family.branch_prefix,
|
|
656
|
+
artifact_root_name=family.artifact_root_name,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _invocation_for_execution_family(invocation: RunnerInvocation, family: RunnerFamily) -> RunnerInvocation:
|
|
661
|
+
return RunnerInvocation(
|
|
662
|
+
action=invocation.action,
|
|
663
|
+
list_path=family.plan_path,
|
|
664
|
+
item_id=invocation.item_id,
|
|
665
|
+
item_filter=invocation.item_filter,
|
|
666
|
+
max_retries=invocation.max_retries,
|
|
667
|
+
max_infra_retries=invocation.max_infra_retries,
|
|
668
|
+
mode=invocation.mode,
|
|
669
|
+
critic=invocation.critic,
|
|
670
|
+
dry_run=invocation.dry_run,
|
|
671
|
+
clean=invocation.clean,
|
|
672
|
+
no_reset=invocation.no_reset,
|
|
673
|
+
resume_phase=invocation.resume_phase,
|
|
674
|
+
help_requested=invocation.help_requested,
|
|
675
|
+
unknown_args=invocation.unknown_args,
|
|
676
|
+
raw_args=invocation.raw_args,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _path_in_execution_root(path: Path, project_root: Path, execution_root: Path) -> Path:
|
|
681
|
+
try:
|
|
682
|
+
return execution_root / path.resolve().relative_to(project_root.resolve())
|
|
683
|
+
except ValueError:
|
|
684
|
+
return path
|
|
685
|
+
|
|
686
|
+
|
|
542
687
|
def _write_session_status(
|
|
543
688
|
session_paths: SessionPaths,
|
|
544
689
|
*,
|
|
@@ -579,8 +724,7 @@ def _status_from_update(update_result) -> str:
|
|
|
579
724
|
|
|
580
725
|
|
|
581
726
|
def _default_branch_name(family: RunnerFamily, item_id: str) -> str:
|
|
582
|
-
|
|
583
|
-
return f"{family.branch_prefix}/{item_id}-{stamp}"
|
|
727
|
+
return f"{family.branch_prefix}/{item_id}-{time.strftime('%Y%m%d%H%M%S')}"
|
|
584
728
|
|
|
585
729
|
|
|
586
730
|
def _session_id(item_id: str) -> str:
|
|
@@ -847,12 +847,24 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
|
|
|
847
847
|
invocation = invocation.__class__(**{**invocation.__dict__, "list_path": paths.feature_plan})
|
|
848
848
|
observed = _install_runner_session_fakes(monkeypatch, runners)
|
|
849
849
|
plan_names = []
|
|
850
|
+
branch_events = []
|
|
851
|
+
lifecycle_order = []
|
|
850
852
|
|
|
851
853
|
def fake_run_git_plan(project_root, plan):
|
|
852
854
|
plan_names.append(plan.name)
|
|
855
|
+
if plan.name == "branch_create":
|
|
856
|
+
lifecycle_order.append("branch_create")
|
|
857
|
+
branch_events.append(("create", plan.commands[1].args[2]))
|
|
858
|
+
if plan.name == "branch_merge":
|
|
859
|
+
branch_events.append(("merge", plan.commands[5].args[2]))
|
|
853
860
|
return SimpleNamespace(ok=True, plan=plan, results=())
|
|
854
861
|
|
|
862
|
+
def fake_start_item(*args, **kwargs):
|
|
863
|
+
lifecycle_order.append("start_item")
|
|
864
|
+
return SimpleNamespace(ok=True, text="", data={"new_status": "in_progress"})
|
|
865
|
+
|
|
855
866
|
monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
|
|
867
|
+
monkeypatch.setattr(runners, "start_item", fake_start_item)
|
|
856
868
|
monkeypatch.setattr(runners, "ensure_linked_worktree", lambda runtime: (_ for _ in ()).throw(AssertionError("worktree setup should not run")), raising=False)
|
|
857
869
|
|
|
858
870
|
status = runners._process_item(family, invocation, "F-001", paths, initial_metadata={})
|
|
@@ -862,6 +874,62 @@ def test_default_no_worktree_preserves_main_worktree_branch_launch(monkeypatch,
|
|
|
862
874
|
assert observed.prompt_calls[0].get("execution_root") == paths.project_root
|
|
863
875
|
assert "branch_create" in plan_names
|
|
864
876
|
assert "branch_merge" in plan_names
|
|
877
|
+
assert lifecycle_order[:2] == ["branch_create", "start_item"]
|
|
878
|
+
assert branch_events == [("create", "dev/F-001-test"), ("merge", "dev/F-001-test")]
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def test_default_branch_names_keep_timestamp_for_each_runner_family(monkeypatch, tmp_path):
|
|
882
|
+
from prizmkit_runtime import runners
|
|
883
|
+
from prizmkit_runtime.runner_models import family_for
|
|
884
|
+
|
|
885
|
+
monkeypatch.setattr(runners.time, "strftime", lambda fmt: "20260707010203")
|
|
886
|
+
paths = _make_paths(tmp_path)
|
|
887
|
+
|
|
888
|
+
assert runners._default_branch_name(family_for("feature", paths), "F-001") == "dev/F-001-20260707010203"
|
|
889
|
+
assert runners._default_branch_name(family_for("bugfix", paths), "B-001") == "bugfix/B-001-20260707010203"
|
|
890
|
+
assert runners._default_branch_name(family_for("refactor", paths), "R-001") == "refactor/R-001-20260707010203"
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
@pytest.mark.parametrize(
|
|
894
|
+
("kind", "item_id", "branch"),
|
|
895
|
+
[
|
|
896
|
+
("feature", "F-001", "dev/F-001-20260707010203"),
|
|
897
|
+
("bugfix", "B-001", "bugfix/B-001-20260707010203"),
|
|
898
|
+
("refactor", "R-001", "refactor/R-001-20260707010203"),
|
|
899
|
+
],
|
|
900
|
+
)
|
|
901
|
+
def test_no_worktree_failure_stays_on_task_branch_without_merge_for_all_families(monkeypatch, tmp_path, kind, item_id, branch):
|
|
902
|
+
from prizmkit_runtime import runners
|
|
903
|
+
from prizmkit_runtime.runner_models import family_for, parse_invocation
|
|
904
|
+
|
|
905
|
+
monkeypatch.setenv("DEV_BRANCH", branch)
|
|
906
|
+
paths = _make_paths(tmp_path / kind)
|
|
907
|
+
family = family_for(kind, paths)
|
|
908
|
+
invocation = parse_invocation(family, "run", ())
|
|
909
|
+
plan_path = {"feature": paths.feature_plan, "bugfix": paths.bugfix_plan, "refactor": paths.refactor_plan}[kind]
|
|
910
|
+
invocation = invocation.__class__(**{**invocation.__dict__, "list_path": plan_path})
|
|
911
|
+
_install_runner_session_fakes(monkeypatch, runners, statuses=("crashed",), update_status="pending")
|
|
912
|
+
events = []
|
|
913
|
+
final_commits = []
|
|
914
|
+
wip_commits = []
|
|
915
|
+
|
|
916
|
+
def fake_run_git_plan(project_root, plan):
|
|
917
|
+
events.append(plan.name)
|
|
918
|
+
return SimpleNamespace(ok=True, plan=plan, results=())
|
|
919
|
+
|
|
920
|
+
monkeypatch.setattr(runners, "run_git_plan", fake_run_git_plan)
|
|
921
|
+
monkeypatch.setattr(runners, "branch_ensure_return", lambda project_root, base, task_branch: events.append(("return", base, task_branch)))
|
|
922
|
+
monkeypatch.setattr(runners, "commit_final_bookkeeping", lambda project_root, family, item_id, status: final_commits.append((project_root, item_id, status)))
|
|
923
|
+
monkeypatch.setattr(runners, "commit_wip_changes", lambda project_root, family, item_id, status: wip_commits.append((project_root, item_id, status)))
|
|
924
|
+
|
|
925
|
+
status = runners._process_item(family, invocation, item_id, paths, initial_metadata={})
|
|
926
|
+
|
|
927
|
+
assert status == "pending"
|
|
928
|
+
assert events[0] == "branch_create"
|
|
929
|
+
assert "branch_merge" not in events
|
|
930
|
+
assert events[-1] == ("return", "main", branch)
|
|
931
|
+
assert final_commits == [(paths.project_root, item_id, "pending")]
|
|
932
|
+
assert wip_commits == [(paths.project_root, item_id, "pending")]
|
|
865
933
|
|
|
866
934
|
|
|
867
935
|
def test_no_worktree_runner_ignores_recovery_side_effect_branch(monkeypatch, tmp_path):
|
|
@@ -956,6 +1024,63 @@ def test_no_worktree_interrupt_saves_wip_and_returns_to_base(monkeypatch, tmp_pa
|
|
|
956
1024
|
assert ensure_calls == [("main", "dev/F-001-interrupt")]
|
|
957
1025
|
|
|
958
1026
|
|
|
1027
|
+
def test_bookkeeping_commits_only_runner_owned_paths(tmp_path):
|
|
1028
|
+
from prizmkit_runtime.runner_bookkeeping import commit_final_bookkeeping
|
|
1029
|
+
from prizmkit_runtime.runner_models import RunnerFamily
|
|
1030
|
+
|
|
1031
|
+
subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
1032
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
|
1033
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
|
|
1034
|
+
plans = tmp_path / ".prizmkit" / "plans"
|
|
1035
|
+
state = tmp_path / ".prizmkit" / "state" / "features"
|
|
1036
|
+
item_state = state / "F-001"
|
|
1037
|
+
plans.mkdir(parents=True)
|
|
1038
|
+
item_state.mkdir(parents=True)
|
|
1039
|
+
plan = plans / "feature-list.json"
|
|
1040
|
+
plan.write_text('{"features": []}', encoding="utf-8")
|
|
1041
|
+
(item_state / "status.json").write_text("{}", encoding="utf-8")
|
|
1042
|
+
source = tmp_path / "src.txt"
|
|
1043
|
+
source.write_text("base", encoding="utf-8")
|
|
1044
|
+
subprocess.run(["git", "add", ".prizmkit/plans/feature-list.json", ".prizmkit/state/features/F-001/status.json", "src.txt"], cwd=tmp_path, check=True)
|
|
1045
|
+
subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
1046
|
+
plan.write_text('{"features": [{"id": "F-001", "status": "completed"}]}', encoding="utf-8")
|
|
1047
|
+
(item_state / "status.json").write_text('{"last_session_id": "s1"}', encoding="utf-8")
|
|
1048
|
+
source.write_text("dirty source change", encoding="utf-8")
|
|
1049
|
+
family = RunnerFamily(
|
|
1050
|
+
kind="feature",
|
|
1051
|
+
id_prefix="F-",
|
|
1052
|
+
list_key="features",
|
|
1053
|
+
list_arg="--feature-list",
|
|
1054
|
+
item_id_arg="--feature-id",
|
|
1055
|
+
state_dir=state,
|
|
1056
|
+
plan_path=plan,
|
|
1057
|
+
updater_script=tmp_path / "update-feature-status.py",
|
|
1058
|
+
prompt_script=tmp_path / "generate-bootstrap-prompt.py",
|
|
1059
|
+
branch_prefix="dev",
|
|
1060
|
+
artifact_root_name="specs",
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
result = commit_final_bookkeeping(tmp_path, family, "F-001", "completed")
|
|
1064
|
+
committed_files = subprocess.run(
|
|
1065
|
+
["git", "show", "--name-only", "--format=", "HEAD"],
|
|
1066
|
+
cwd=tmp_path,
|
|
1067
|
+
check=True,
|
|
1068
|
+
stdout=subprocess.PIPE,
|
|
1069
|
+
text=True,
|
|
1070
|
+
).stdout.splitlines()
|
|
1071
|
+
dirty_files = subprocess.run(
|
|
1072
|
+
["git", "status", "--porcelain", "--", "src.txt"],
|
|
1073
|
+
cwd=tmp_path,
|
|
1074
|
+
check=True,
|
|
1075
|
+
stdout=subprocess.PIPE,
|
|
1076
|
+
text=True,
|
|
1077
|
+
).stdout
|
|
1078
|
+
|
|
1079
|
+
assert result.committed is True
|
|
1080
|
+
assert committed_files == [".prizmkit/plans/feature-list.json", ".prizmkit/state/features/F-001/status.json"]
|
|
1081
|
+
assert dirty_files.startswith(" M src.txt")
|
|
1082
|
+
|
|
1083
|
+
|
|
959
1084
|
def test_context_overflow_reuses_worktree_and_skips_cleanup_until_terminal_outcome(monkeypatch, tmp_path):
|
|
960
1085
|
from prizmkit_runtime import runners
|
|
961
1086
|
from prizmkit_runtime.runner_models import family_for, parse_invocation
|
|
@@ -983,6 +1108,52 @@ def test_context_overflow_reuses_worktree_and_skips_cleanup_until_terminal_outco
|
|
|
983
1108
|
assert observed.cleanup_calls[0][1] is True
|
|
984
1109
|
|
|
985
1110
|
|
|
1111
|
+
def test_classification_accepts_terminal_success_with_completed_checkpoint_without_git_delta(tmp_path):
|
|
1112
|
+
from prizmkit_runtime.runner_classification import classify_session
|
|
1113
|
+
from prizmkit_runtime.runner_models import PromptGenerationResult
|
|
1114
|
+
from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
|
|
1115
|
+
from prizmkit_runtime.status import ProgressSummary
|
|
1116
|
+
|
|
1117
|
+
subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
1118
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
|
1119
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
|
|
1120
|
+
(tmp_path / ".gitignore").write_text(".prizmkit/*\n", encoding="utf-8")
|
|
1121
|
+
(tmp_path / "base.txt").write_text("base", encoding="utf-8")
|
|
1122
|
+
subprocess.run(["git", "add", ".gitignore", "base.txt"], cwd=tmp_path, check=True)
|
|
1123
|
+
subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
1124
|
+
base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
|
|
1125
|
+
artifact_dir = tmp_path / ".prizmkit" / "specs" / "001-done"
|
|
1126
|
+
artifact_dir.mkdir(parents=True)
|
|
1127
|
+
checkpoint = artifact_dir / "workflow-checkpoint.json"
|
|
1128
|
+
checkpoint.write_text(json.dumps({"steps": [{"id": "S01", "skill": "done", "status": "completed"}]}), encoding="utf-8")
|
|
1129
|
+
(artifact_dir / "completion-summary.json").write_text("{}", encoding="utf-8")
|
|
1130
|
+
log = tmp_path / "session.log"
|
|
1131
|
+
log.write_text("terminal success", encoding="utf-8")
|
|
1132
|
+
raw = AISessionResult(
|
|
1133
|
+
command=AISessionCommand(("fake",), None, "claude", "stdin"),
|
|
1134
|
+
exit_code=0,
|
|
1135
|
+
timed_out=False,
|
|
1136
|
+
termination_reason=None,
|
|
1137
|
+
stale_marker=None,
|
|
1138
|
+
fatal_error_code="",
|
|
1139
|
+
progress_summary=ProgressSummary(result_subtype="success", terminal_result_text="Completed; working tree clean"),
|
|
1140
|
+
session_log=log,
|
|
1141
|
+
backup_log=tmp_path / "backup.log",
|
|
1142
|
+
log_recovery=None,
|
|
1143
|
+
started_epoch=0,
|
|
1144
|
+
ended_epoch=1,
|
|
1145
|
+
)
|
|
1146
|
+
prompt = PromptGenerationResult(
|
|
1147
|
+
output_path=tmp_path / "prompt.md",
|
|
1148
|
+
checkpoint_path=checkpoint,
|
|
1149
|
+
artifact_path=artifact_dir,
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
classification = classify_session(raw, project_root=tmp_path, base_head=base_head, prompt=prompt)
|
|
1153
|
+
assert classification.session_status == "success"
|
|
1154
|
+
assert classification.reason == "terminal_success_with_completed_checkpoint"
|
|
1155
|
+
|
|
1156
|
+
|
|
986
1157
|
def test_classification_preserves_success_before_context_overflow(tmp_path):
|
|
987
1158
|
from prizmkit_runtime.runner_classification import classify_session
|
|
988
1159
|
from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
|
|
@@ -1021,6 +1192,43 @@ def test_classification_preserves_success_before_context_overflow(tmp_path):
|
|
|
1021
1192
|
assert classification.reason == "commit_since_base"
|
|
1022
1193
|
|
|
1023
1194
|
|
|
1195
|
+
def test_classification_crash_with_commit_since_base_does_not_become_success(tmp_path):
|
|
1196
|
+
from prizmkit_runtime.runner_classification import classify_session
|
|
1197
|
+
from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
|
|
1198
|
+
from prizmkit_runtime.status import ProgressSummary
|
|
1199
|
+
|
|
1200
|
+
subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
1201
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
|
1202
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
|
|
1203
|
+
(tmp_path / "base.txt").write_text("base", encoding="utf-8")
|
|
1204
|
+
subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
|
|
1205
|
+
subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
1206
|
+
base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, check=True, stdout=subprocess.PIPE, text=True).stdout.strip()
|
|
1207
|
+
(tmp_path / "done.txt").write_text("done", encoding="utf-8")
|
|
1208
|
+
subprocess.run(["git", "add", "done.txt"], cwd=tmp_path, check=True)
|
|
1209
|
+
subprocess.run(["git", "commit", "-m", "done"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
1210
|
+
log = tmp_path / "session.log"
|
|
1211
|
+
log.write_text("hard failure", encoding="utf-8")
|
|
1212
|
+
raw = AISessionResult(
|
|
1213
|
+
command=AISessionCommand(("fake",), None, "codebuddy", "stdin"),
|
|
1214
|
+
exit_code=1,
|
|
1215
|
+
timed_out=False,
|
|
1216
|
+
termination_reason=None,
|
|
1217
|
+
stale_marker=None,
|
|
1218
|
+
fatal_error_code="",
|
|
1219
|
+
progress_summary=ProgressSummary(terminal_result_text="hard failure"),
|
|
1220
|
+
session_log=log,
|
|
1221
|
+
backup_log=tmp_path / "backup.log",
|
|
1222
|
+
log_recovery=None,
|
|
1223
|
+
started_epoch=0,
|
|
1224
|
+
ended_epoch=1,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
classification = classify_session(raw, project_root=tmp_path, base_head=base_head)
|
|
1228
|
+
assert classification.session_status == "crashed"
|
|
1229
|
+
assert classification.reason == "exit_code=1"
|
|
1230
|
+
|
|
1231
|
+
|
|
1024
1232
|
def test_classification_stalls_after_repeated_no_progress_context_overflow(tmp_path):
|
|
1025
1233
|
from prizmkit_runtime.runner_classification import classify_session, progress_fingerprint
|
|
1026
1234
|
from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
|
|
@@ -853,6 +853,7 @@ def test_git_branch_worktree_plans_preserve_shell_invariants(tmp_path):
|
|
|
853
853
|
assert ensure.never_fails is True
|
|
854
854
|
assert any(command.args[:2] == ("rebase", "--abort") for command in ensure.commands)
|
|
855
855
|
assert any(command.args[:2] == ("add", "-A") for command in ensure.commands)
|
|
856
|
+
assert not any(command.args[:2] == ("stash", "pop") for command in ensure.commands)
|
|
856
857
|
assert any(".*/worktrees/**" in " ".join(command.args) for command in save_wip.commands)
|
|
857
858
|
assert worktree.commands[0].args[:2] == ("worktree", "add")
|
|
858
859
|
|