prizmkit 1.1.145 → 1.1.146
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_bookkeeping.py +25 -13
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +145 -49
- package/bundled/dev-pipeline/scripts/parse-stream-progress.py +566 -4
- package/bundled/dev-pipeline/tests/live_ai_cli_log_smoke.py +206 -0
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +279 -12
- package/bundled/dev-pipeline/tests/test_unified_cli.py +455 -0
- package/bundled/skills/_metadata.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Capability-gated live smoke checks for normalized AI CLI session logging.
|
|
3
|
+
|
|
4
|
+
The deterministic fixture suite remains authoritative. This script optionally
|
|
5
|
+
exercises locally installed, authenticated, non-interactive-capable adapters in
|
|
6
|
+
isolated temporary Git repositories.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable, Iterable
|
|
21
|
+
|
|
22
|
+
PIPELINE_ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
if str(PIPELINE_ROOT) not in sys.path:
|
|
24
|
+
sys.path.insert(0, str(PIPELINE_ROOT))
|
|
25
|
+
|
|
26
|
+
from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher # noqa: E402
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SmokeProfile:
|
|
31
|
+
"""One installed adapter example to exercise."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
command: str
|
|
35
|
+
platform: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SmokeResult:
|
|
40
|
+
"""Machine-readable capability or execution result."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
status: str
|
|
44
|
+
reason: str
|
|
45
|
+
log_path: str = ""
|
|
46
|
+
exit_code: int | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
PROFILE_EXAMPLES = (
|
|
50
|
+
("pi", "pi", "pi"),
|
|
51
|
+
("codebuddy", "cbc", "codebuddy"),
|
|
52
|
+
("codex", "codex", "codex"),
|
|
53
|
+
("claude", "claude", "claude"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
AUTH_MARKERS = (
|
|
57
|
+
"authentication",
|
|
58
|
+
"unauthorized",
|
|
59
|
+
"not logged in",
|
|
60
|
+
"login required",
|
|
61
|
+
"invalid api key",
|
|
62
|
+
"missing api key",
|
|
63
|
+
"credential",
|
|
64
|
+
"billing",
|
|
65
|
+
"quota",
|
|
66
|
+
"model access",
|
|
67
|
+
"model not found",
|
|
68
|
+
"no access to model",
|
|
69
|
+
"network is unreachable",
|
|
70
|
+
"connection refused",
|
|
71
|
+
"could not resolve",
|
|
72
|
+
"service unavailable",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
REQUIRED_LABELS = (
|
|
76
|
+
"[ACTION]",
|
|
77
|
+
"[OBSERVATION]",
|
|
78
|
+
"[FINAL]",
|
|
79
|
+
"[SESSION_END]",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def discover_profiles(
|
|
84
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
85
|
+
examples: Iterable[tuple[str, str, str]] = PROFILE_EXAMPLES,
|
|
86
|
+
) -> tuple[list[SmokeProfile], list[SmokeResult]]:
|
|
87
|
+
"""Return installed profiles and explicit executable-capability skips."""
|
|
88
|
+
installed = []
|
|
89
|
+
skipped = []
|
|
90
|
+
for name, executable, platform in examples:
|
|
91
|
+
resolved = which(executable)
|
|
92
|
+
if resolved:
|
|
93
|
+
installed.append(SmokeProfile(name, resolved, platform))
|
|
94
|
+
else:
|
|
95
|
+
skipped.append(SmokeResult(name, "skipped", f"executable not found: {executable}"))
|
|
96
|
+
return installed, skipped
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def smoke_prompt() -> str:
|
|
100
|
+
"""Return the isolated harmless tool-and-final-answer prompt."""
|
|
101
|
+
return (
|
|
102
|
+
"This is an isolated logging smoke test. Use one local shell tool to run "
|
|
103
|
+
"`printf 'PRIZMKIT_SMOKE_TOOL_OK\\n'`. Do not read, create, edit, or delete "
|
|
104
|
+
"project files. After the tool succeeds, answer exactly "
|
|
105
|
+
"`PRIZMKIT_SMOKE_FINAL_OK`."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _looks_capability_blocked(text: str) -> bool:
|
|
110
|
+
lowered = text.lower()
|
|
111
|
+
return any(marker in lowered for marker in AUTH_MARKERS)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_profile(profile: SmokeProfile, *, timeout_seconds: float = 180) -> SmokeResult:
|
|
115
|
+
"""Run one live adapter in an isolated temporary repository."""
|
|
116
|
+
with tempfile.TemporaryDirectory(prefix=f"prizmkit-{profile.name}-smoke-") as temp_dir:
|
|
117
|
+
root = Path(temp_dir)
|
|
118
|
+
subprocess.run(
|
|
119
|
+
["git", "init", "-b", "main"],
|
|
120
|
+
cwd=root,
|
|
121
|
+
check=True,
|
|
122
|
+
stdout=subprocess.DEVNULL,
|
|
123
|
+
stderr=subprocess.DEVNULL,
|
|
124
|
+
)
|
|
125
|
+
prompt = root / "prompt.md"
|
|
126
|
+
prompt.write_text(smoke_prompt(), encoding="utf-8")
|
|
127
|
+
session_log = root / ".prizmkit" / "state" / "smoke" / "logs" / "session.log"
|
|
128
|
+
progress = session_log.with_name("progress.json")
|
|
129
|
+
result = AISessionLauncher(
|
|
130
|
+
AISessionConfig(
|
|
131
|
+
cli_command=profile.command,
|
|
132
|
+
platform=profile.platform,
|
|
133
|
+
model=None,
|
|
134
|
+
prompt_path=prompt,
|
|
135
|
+
cwd=root,
|
|
136
|
+
log_path=session_log,
|
|
137
|
+
progress_path=progress,
|
|
138
|
+
use_stream_json=True,
|
|
139
|
+
effort="low",
|
|
140
|
+
total_timeout_seconds=timeout_seconds,
|
|
141
|
+
)
|
|
142
|
+
).run()
|
|
143
|
+
text = session_log.read_text(encoding="utf-8", errors="replace") if session_log.is_file() else ""
|
|
144
|
+
missing = [label for label in REQUIRED_LABELS if label not in text]
|
|
145
|
+
if _looks_capability_blocked(text):
|
|
146
|
+
return SmokeResult(
|
|
147
|
+
profile.name,
|
|
148
|
+
"skipped",
|
|
149
|
+
"authentication, model, quota, network, or account capability unavailable",
|
|
150
|
+
str(session_log),
|
|
151
|
+
result.exit_code,
|
|
152
|
+
)
|
|
153
|
+
if result.timed_out:
|
|
154
|
+
return SmokeResult(profile.name, "failed", "live smoke timed out without a capability-block marker", str(session_log), result.exit_code)
|
|
155
|
+
if result.exit_code not in (0, None):
|
|
156
|
+
return SmokeResult(profile.name, "failed", f"AI CLI exited with {result.exit_code}", str(session_log), result.exit_code)
|
|
157
|
+
if missing:
|
|
158
|
+
return SmokeResult(
|
|
159
|
+
profile.name,
|
|
160
|
+
"failed",
|
|
161
|
+
"missing normalized labels: " + ", ".join(missing),
|
|
162
|
+
str(session_log),
|
|
163
|
+
result.exit_code,
|
|
164
|
+
)
|
|
165
|
+
if "PRIZMKIT_SMOKE_TOOL_OK" not in text or "PRIZMKIT_SMOKE_FINAL_OK" not in text:
|
|
166
|
+
return SmokeResult(
|
|
167
|
+
profile.name,
|
|
168
|
+
"failed",
|
|
169
|
+
"tool or final-answer sentinel missing",
|
|
170
|
+
str(session_log),
|
|
171
|
+
result.exit_code,
|
|
172
|
+
)
|
|
173
|
+
return SmokeResult(profile.name, "passed", "normalized live tool session verified", str(session_log), result.exit_code)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(argv: list[str] | None = None) -> int:
|
|
177
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
178
|
+
parser.add_argument("--run", action="store_true", help="authorize live AI CLI calls")
|
|
179
|
+
parser.add_argument("--only", action="append", default=[], help="run only a named installed example")
|
|
180
|
+
parser.add_argument("--timeout", type=float, default=180, help="per-profile timeout in seconds")
|
|
181
|
+
args = parser.parse_args(argv)
|
|
182
|
+
|
|
183
|
+
installed, results = discover_profiles()
|
|
184
|
+
selected = set(args.only)
|
|
185
|
+
if selected:
|
|
186
|
+
installed = [profile for profile in installed if profile.name in selected]
|
|
187
|
+
known = {profile.name for profile in installed} | {result.name for result in results}
|
|
188
|
+
for name in sorted(selected - known):
|
|
189
|
+
results.append(SmokeResult(name, "skipped", "unknown or unavailable profile example"))
|
|
190
|
+
|
|
191
|
+
authorized = args.run or os.environ.get("PRIZMKIT_RUN_LIVE_AI_SMOKE") == "1"
|
|
192
|
+
if not authorized:
|
|
193
|
+
results.extend(
|
|
194
|
+
SmokeResult(profile.name, "skipped", "live calls not authorized; pass --run or set PRIZMKIT_RUN_LIVE_AI_SMOKE=1")
|
|
195
|
+
for profile in installed
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
results.extend(run_profile(profile, timeout_seconds=args.timeout) for profile in installed)
|
|
199
|
+
|
|
200
|
+
payload = {"results": [asdict(result) for result in sorted(results, key=lambda item: item.name)]}
|
|
201
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
202
|
+
return 1 if any(result.status == "failed" for result in results) else 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
raise SystemExit(main())
|
|
@@ -2403,7 +2403,133 @@ def test_preflight_ready_commit_includes_tracked_state_but_excludes_untracked_st
|
|
|
2403
2403
|
assert untracked_state.relative_to(tmp_path).as_posix() not in _head_files(tmp_path)
|
|
2404
2404
|
|
|
2405
2405
|
|
|
2406
|
-
def
|
|
2406
|
+
def _track_manifest(project_root):
|
|
2407
|
+
manifest_path = project_root / ".prizmkit" / "manifest.json"
|
|
2408
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2409
|
+
manifest_path.write_text('{"version":"baseline"}\n', encoding="utf-8")
|
|
2410
|
+
subprocess.run(["git", "add", ".prizmkit/manifest.json"], cwd=project_root, check=True)
|
|
2411
|
+
subprocess.run(["git", "commit", "-m", "track manifest"], cwd=project_root, check=True, stdout=subprocess.DEVNULL)
|
|
2412
|
+
return manifest_path
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
@pytest.mark.parametrize(
|
|
2416
|
+
("scenario", "expected_content"),
|
|
2417
|
+
[
|
|
2418
|
+
("staged_only", '{"version":"staged"}\n'),
|
|
2419
|
+
("unstaged_only", '{"version":"unstaged"}\n'),
|
|
2420
|
+
("staged_and_unstaged", '{"version":"working-tree"}\n'),
|
|
2421
|
+
],
|
|
2422
|
+
)
|
|
2423
|
+
def test_preflight_ready_commit_preserves_tracked_manifest_modification_state(tmp_path, scenario, expected_content):
|
|
2424
|
+
from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
|
|
2425
|
+
|
|
2426
|
+
_init_bookkeeping_repo(tmp_path)
|
|
2427
|
+
manifest_path = _track_manifest(tmp_path)
|
|
2428
|
+
if scenario == "staged_only":
|
|
2429
|
+
manifest_path.write_text(expected_content, encoding="utf-8")
|
|
2430
|
+
subprocess.run(["git", "add", ".prizmkit/manifest.json"], cwd=tmp_path, check=True)
|
|
2431
|
+
elif scenario == "unstaged_only":
|
|
2432
|
+
manifest_path.write_text(expected_content, encoding="utf-8")
|
|
2433
|
+
else:
|
|
2434
|
+
manifest_path.write_text('{"version":"index"}\n', encoding="utf-8")
|
|
2435
|
+
subprocess.run(["git", "add", ".prizmkit/manifest.json"], cwd=tmp_path, check=True)
|
|
2436
|
+
manifest_path.write_text(expected_content, encoding="utf-8")
|
|
2437
|
+
|
|
2438
|
+
before_count = _head_commit_count(tmp_path)
|
|
2439
|
+
result = commit_preflight_ready_changes(tmp_path, "F-003")
|
|
2440
|
+
committed_content = subprocess.run(
|
|
2441
|
+
["git", "show", "HEAD:.prizmkit/manifest.json"],
|
|
2442
|
+
cwd=tmp_path,
|
|
2443
|
+
check=True,
|
|
2444
|
+
stdout=subprocess.PIPE,
|
|
2445
|
+
text=True,
|
|
2446
|
+
).stdout
|
|
2447
|
+
|
|
2448
|
+
assert result.attempted is True
|
|
2449
|
+
assert result.committed is True
|
|
2450
|
+
assert _head_commit_count(tmp_path) == before_count + 1
|
|
2451
|
+
assert _head_message(tmp_path) == "chore: ready for F-003"
|
|
2452
|
+
assert _head_files(tmp_path) == {".prizmkit/manifest.json"}
|
|
2453
|
+
assert committed_content == expected_content
|
|
2454
|
+
assert subprocess.run(
|
|
2455
|
+
["git", "status", "--short", "--untracked-files=no"],
|
|
2456
|
+
cwd=tmp_path,
|
|
2457
|
+
check=True,
|
|
2458
|
+
stdout=subprocess.PIPE,
|
|
2459
|
+
text=True,
|
|
2460
|
+
).stdout == ""
|
|
2461
|
+
|
|
2462
|
+
|
|
2463
|
+
@pytest.mark.parametrize("staged", [False, True])
|
|
2464
|
+
def test_preflight_ready_commit_preserves_tracked_manifest_deletion(tmp_path, staged):
|
|
2465
|
+
from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
|
|
2466
|
+
|
|
2467
|
+
_init_bookkeeping_repo(tmp_path)
|
|
2468
|
+
manifest_path = _track_manifest(tmp_path)
|
|
2469
|
+
manifest_path.unlink()
|
|
2470
|
+
if staged:
|
|
2471
|
+
subprocess.run(["git", "add", "-u", "--", ".prizmkit/manifest.json"], cwd=tmp_path, check=True)
|
|
2472
|
+
(tmp_path / "unstaged.txt").write_text("coexisting tracked change\n", encoding="utf-8")
|
|
2473
|
+
(tmp_path / "coexisting.txt").write_text("coexisting untracked change\n", encoding="utf-8")
|
|
2474
|
+
|
|
2475
|
+
before_count = _head_commit_count(tmp_path)
|
|
2476
|
+
result = commit_preflight_ready_changes(tmp_path, "B-003")
|
|
2477
|
+
manifest_at_head = subprocess.run(
|
|
2478
|
+
["git", "cat-file", "-e", "HEAD:.prizmkit/manifest.json"],
|
|
2479
|
+
cwd=tmp_path,
|
|
2480
|
+
stdout=subprocess.DEVNULL,
|
|
2481
|
+
stderr=subprocess.DEVNULL,
|
|
2482
|
+
)
|
|
2483
|
+
|
|
2484
|
+
assert result.attempted is True
|
|
2485
|
+
assert result.committed is True
|
|
2486
|
+
assert _head_commit_count(tmp_path) == before_count + 1
|
|
2487
|
+
assert _head_message(tmp_path) == "chore: ready for B-003"
|
|
2488
|
+
assert _head_files(tmp_path) == {".prizmkit/manifest.json", "coexisting.txt", "unstaged.txt"}
|
|
2489
|
+
assert manifest_at_head.returncode != 0
|
|
2490
|
+
assert subprocess.run(
|
|
2491
|
+
["git", "status", "--short", "--untracked-files=all"],
|
|
2492
|
+
cwd=tmp_path,
|
|
2493
|
+
check=True,
|
|
2494
|
+
stdout=subprocess.PIPE,
|
|
2495
|
+
text=True,
|
|
2496
|
+
).stdout == ""
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
@pytest.mark.parametrize(
|
|
2500
|
+
("payload", "expected_error"),
|
|
2501
|
+
[
|
|
2502
|
+
("{not-json\n", "not valid JSON"),
|
|
2503
|
+
("[]\n", "not a JSON object"),
|
|
2504
|
+
],
|
|
2505
|
+
)
|
|
2506
|
+
def test_preflight_ready_commit_rejects_unsafe_tracked_manifest_before_any_commit(tmp_path, payload, expected_error):
|
|
2507
|
+
from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
|
|
2508
|
+
|
|
2509
|
+
_init_bookkeeping_repo(tmp_path)
|
|
2510
|
+
manifest_path = _track_manifest(tmp_path)
|
|
2511
|
+
manifest_path.write_text(payload, encoding="utf-8")
|
|
2512
|
+
(tmp_path / "eligible.txt").write_text("must remain uncommitted\n", encoding="utf-8")
|
|
2513
|
+
before_count = _head_commit_count(tmp_path)
|
|
2514
|
+
|
|
2515
|
+
result = commit_preflight_ready_changes(tmp_path, "R-003")
|
|
2516
|
+
|
|
2517
|
+
assert result.attempted is True
|
|
2518
|
+
assert result.committed is False
|
|
2519
|
+
assert result.result is not None
|
|
2520
|
+
assert expected_error in result.result.stderr
|
|
2521
|
+
assert _head_commit_count(tmp_path) == before_count
|
|
2522
|
+
assert "eligible.txt" not in _head_files(tmp_path)
|
|
2523
|
+
assert subprocess.run(
|
|
2524
|
+
["git", "status", "--short", "--untracked-files=all"],
|
|
2525
|
+
cwd=tmp_path,
|
|
2526
|
+
check=True,
|
|
2527
|
+
stdout=subprocess.PIPE,
|
|
2528
|
+
text=True,
|
|
2529
|
+
).stdout.splitlines() == [" M .prizmkit/manifest.json", "?? eligible.txt"]
|
|
2530
|
+
|
|
2531
|
+
|
|
2532
|
+
def test_preflight_ready_commit_preserves_tracked_manifest_with_other_tracked_support(tmp_path):
|
|
2407
2533
|
from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
|
|
2408
2534
|
|
|
2409
2535
|
_init_bookkeeping_repo(tmp_path)
|
|
@@ -2417,8 +2543,9 @@ def test_preflight_ready_commit_excludes_tracked_framework_manifest_but_preserve
|
|
|
2417
2543
|
for path in tracked_support_paths:
|
|
2418
2544
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2419
2545
|
path.write_text("installed\n", encoding="utf-8")
|
|
2546
|
+
tracked_paths = [manifest_path, *tracked_support_paths]
|
|
2420
2547
|
subprocess.run(
|
|
2421
|
-
["git", "add",
|
|
2548
|
+
["git", "add", *(path.relative_to(tmp_path).as_posix() for path in tracked_paths)],
|
|
2422
2549
|
cwd=tmp_path,
|
|
2423
2550
|
check=True,
|
|
2424
2551
|
)
|
|
@@ -2426,6 +2553,8 @@ def test_preflight_ready_commit_excludes_tracked_framework_manifest_but_preserve
|
|
|
2426
2553
|
manifest_path.write_text('{"version":"1.1.128"}\n', encoding="utf-8")
|
|
2427
2554
|
for path in tracked_support_paths:
|
|
2428
2555
|
path.write_text("upgraded\n", encoding="utf-8")
|
|
2556
|
+
visible_path = tmp_path / "visible.txt"
|
|
2557
|
+
visible_path.write_text("eligible untracked change\n", encoding="utf-8")
|
|
2429
2558
|
|
|
2430
2559
|
untracked_support_paths = [
|
|
2431
2560
|
tmp_path / ".claude" / "settings.local.json",
|
|
@@ -2436,24 +2565,66 @@ def test_preflight_ready_commit_excludes_tracked_framework_manifest_but_preserve
|
|
|
2436
2565
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2437
2566
|
path.write_text("transient\n", encoding="utf-8")
|
|
2438
2567
|
|
|
2568
|
+
before_count = _head_commit_count(tmp_path)
|
|
2439
2569
|
result = commit_preflight_ready_changes(tmp_path, "B-001")
|
|
2440
2570
|
|
|
2441
2571
|
assert result.attempted is True
|
|
2442
|
-
assert result.committed is
|
|
2443
|
-
assert
|
|
2444
|
-
assert
|
|
2445
|
-
assert
|
|
2572
|
+
assert result.committed is True
|
|
2573
|
+
assert _head_commit_count(tmp_path) == before_count + 1
|
|
2574
|
+
assert _head_message(tmp_path) == "chore: ready for B-001"
|
|
2575
|
+
assert _head_files(tmp_path) == {
|
|
2576
|
+
*(path.relative_to(tmp_path).as_posix() for path in tracked_paths),
|
|
2577
|
+
visible_path.relative_to(tmp_path).as_posix(),
|
|
2578
|
+
}
|
|
2446
2579
|
assert subprocess.run(
|
|
2447
2580
|
["git", "status", "--short", "--untracked-files=no"],
|
|
2448
2581
|
cwd=tmp_path,
|
|
2449
2582
|
check=True,
|
|
2450
2583
|
stdout=subprocess.PIPE,
|
|
2451
2584
|
text=True,
|
|
2452
|
-
).stdout
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2585
|
+
).stdout == ""
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
def test_preflight_manifest_deletion_is_absent_from_linked_worktree_created_from_updated_head(tmp_path):
|
|
2589
|
+
from prizmkit_runtime.gitops import (
|
|
2590
|
+
BranchContext,
|
|
2591
|
+
WorktreePolicy,
|
|
2592
|
+
ensure_linked_worktree,
|
|
2593
|
+
guarded_worktree_remove,
|
|
2594
|
+
worktree_runtime_context,
|
|
2595
|
+
)
|
|
2596
|
+
from prizmkit_runtime.runner_bookkeeping import commit_preflight_ready_changes
|
|
2597
|
+
|
|
2598
|
+
_init_bookkeeping_repo(tmp_path)
|
|
2599
|
+
manifest_path = _track_manifest(tmp_path)
|
|
2600
|
+
manifest_path.unlink()
|
|
2601
|
+
|
|
2602
|
+
result = commit_preflight_ready_changes(tmp_path, "B-003")
|
|
2603
|
+
runtime = worktree_runtime_context(
|
|
2604
|
+
BranchContext("main", "bugfix/B-003-ready", tmp_path),
|
|
2605
|
+
WorktreePolicy(
|
|
2606
|
+
use_worktree=True,
|
|
2607
|
+
cleanup_on_success=True,
|
|
2608
|
+
preserve_on_failure=True,
|
|
2609
|
+
worktree_root=tmp_path.parent / f"{tmp_path.name}-worktrees",
|
|
2610
|
+
),
|
|
2611
|
+
)
|
|
2612
|
+
setup = ensure_linked_worktree(runtime)
|
|
2613
|
+
|
|
2614
|
+
assert result.committed is True
|
|
2615
|
+
assert setup.ok
|
|
2616
|
+
try:
|
|
2617
|
+
assert not manifest_path.exists()
|
|
2618
|
+
assert not (runtime.worktree_path / ".prizmkit" / "manifest.json").exists()
|
|
2619
|
+
assert subprocess.run(
|
|
2620
|
+
["git", "status", "--short", "--untracked-files=no"],
|
|
2621
|
+
cwd=tmp_path,
|
|
2622
|
+
check=True,
|
|
2623
|
+
stdout=subprocess.PIPE,
|
|
2624
|
+
text=True,
|
|
2625
|
+
).stdout == ""
|
|
2626
|
+
finally:
|
|
2627
|
+
assert guarded_worktree_remove(runtime).ok
|
|
2457
2628
|
|
|
2458
2629
|
|
|
2459
2630
|
def test_preflight_ready_commit_excludes_untracked_support_assets_and_state(tmp_path):
|
|
@@ -2571,6 +2742,8 @@ def test_process_item_creates_one_ready_commit_before_branch_setup_for_all_famil
|
|
|
2571
2742
|
|
|
2572
2743
|
paths = _make_paths(tmp_path / kind)
|
|
2573
2744
|
_init_bookkeeping_repo(paths.project_root)
|
|
2745
|
+
manifest_path = _track_manifest(paths.project_root)
|
|
2746
|
+
manifest_path.write_text('{"version":"ready"}\n', encoding="utf-8")
|
|
2574
2747
|
(paths.project_root / "staged.txt").write_text("staged before run\n", encoding="utf-8")
|
|
2575
2748
|
subprocess.run(["git", "add", "staged.txt"], cwd=paths.project_root, check=True)
|
|
2576
2749
|
(paths.project_root / "unstaged.txt").write_text("unstaged before run\n", encoding="utf-8")
|
|
@@ -2591,6 +2764,13 @@ def test_process_item_creates_one_ready_commit_before_branch_setup_for_all_famil
|
|
|
2591
2764
|
{
|
|
2592
2765
|
"commit_count": _head_commit_count(project_root),
|
|
2593
2766
|
"message": _head_message(project_root),
|
|
2767
|
+
"manifest_content": subprocess.run(
|
|
2768
|
+
["git", "show", "HEAD:.prizmkit/manifest.json"],
|
|
2769
|
+
cwd=project_root,
|
|
2770
|
+
check=True,
|
|
2771
|
+
stdout=subprocess.PIPE,
|
|
2772
|
+
text=True,
|
|
2773
|
+
).stdout,
|
|
2594
2774
|
"visible_status": subprocess.run(
|
|
2595
2775
|
["git", "status", "--porcelain", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES],
|
|
2596
2776
|
cwd=project_root,
|
|
@@ -2611,11 +2791,14 @@ def test_process_item_creates_one_ready_commit_before_branch_setup_for_all_famil
|
|
|
2611
2791
|
{
|
|
2612
2792
|
"commit_count": before_count + 1,
|
|
2613
2793
|
"message": f"chore: ready for {item_id}",
|
|
2794
|
+
"manifest_content": '{"version":"ready"}\n',
|
|
2614
2795
|
"visible_status": "",
|
|
2615
2796
|
}
|
|
2616
2797
|
]
|
|
2617
2798
|
assert _head_commit_count(paths.project_root) == before_count + 1
|
|
2618
|
-
assert {"staged.txt", "unstaged.txt", "untracked.txt"}.issubset(
|
|
2799
|
+
assert {".prizmkit/manifest.json", "staged.txt", "unstaged.txt", "untracked.txt"}.issubset(
|
|
2800
|
+
_head_files(paths.project_root)
|
|
2801
|
+
)
|
|
2619
2802
|
|
|
2620
2803
|
|
|
2621
2804
|
|
|
@@ -2811,6 +2994,90 @@ def test_classification_accepts_late_infra_error_with_completed_checkpoint_and_c
|
|
|
2811
2994
|
assert classification.reason == "semantic_checkpoint_with_late_runtime_error"
|
|
2812
2995
|
|
|
2813
2996
|
|
|
2997
|
+
def test_classification_accepts_final_success_after_internal_stream_retry(tmp_path):
|
|
2998
|
+
import importlib.util
|
|
2999
|
+
|
|
3000
|
+
from prizmkit_runtime.runner_classification import classify_session
|
|
3001
|
+
from prizmkit_runtime.runner_models import PromptGenerationResult
|
|
3002
|
+
from prizmkit_runtime.sessions import AISessionCommand, AISessionResult
|
|
3003
|
+
from prizmkit_runtime.status import ProgressSummary
|
|
3004
|
+
|
|
3005
|
+
subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
3006
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
|
3007
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
|
|
3008
|
+
(tmp_path / "base.txt").write_text("base", encoding="utf-8")
|
|
3009
|
+
subprocess.run(["git", "add", "base.txt"], cwd=tmp_path, check=True)
|
|
3010
|
+
subprocess.run(["git", "commit", "-m", "base"], cwd=tmp_path, check=True, stdout=subprocess.DEVNULL)
|
|
3011
|
+
base_head = subprocess.run(
|
|
3012
|
+
["git", "rev-parse", "HEAD"],
|
|
3013
|
+
cwd=tmp_path,
|
|
3014
|
+
check=True,
|
|
3015
|
+
stdout=subprocess.PIPE,
|
|
3016
|
+
text=True,
|
|
3017
|
+
).stdout.strip()
|
|
3018
|
+
artifact_dir = tmp_path / ".prizmkit" / "bugfix" / "B-004"
|
|
3019
|
+
checkpoint = _write_semantic_completion_checkpoint(tmp_path, artifact_dir, "bugfix-pipeline")
|
|
3020
|
+
|
|
3021
|
+
parser_path = PIPELINE_ROOT / "scripts" / "parse-stream-progress.py"
|
|
3022
|
+
spec = importlib.util.spec_from_file_location("retry_progress_parser", parser_path)
|
|
3023
|
+
module = importlib.util.module_from_spec(spec)
|
|
3024
|
+
assert spec.loader is not None
|
|
3025
|
+
spec.loader.exec_module(module)
|
|
3026
|
+
tracker = module.ProgressTracker()
|
|
3027
|
+
events = [
|
|
3028
|
+
{"type": "session", "id": "pi-session"},
|
|
3029
|
+
{"type": "agent_start"},
|
|
3030
|
+
{"type": "message_end", "message": {"role": "assistant", "content": [], "stopReason": "error", "errorMessage": "Error Code internal_server_error: unexpected EOF"}},
|
|
3031
|
+
{"type": "agent_end", "willRetry": True, "messages": []},
|
|
3032
|
+
{"type": "auto_retry_start", "attempt": 1, "errorMessage": "Error Code internal_server_error: unexpected EOF"},
|
|
3033
|
+
{"type": "agent_start"},
|
|
3034
|
+
{"type": "auto_retry_end", "attempt": 1},
|
|
3035
|
+
{"type": "message_end", "message": {"role": "assistant", "content": [{"type": "text", "text": "WORKFLOW_COMPLETED"}], "stopReason": "stop"}},
|
|
3036
|
+
{"type": "agent_end", "willRetry": False, "messages": [{"role": "assistant", "content": [{"type": "text", "text": "WORKFLOW_COMPLETED"}], "stopReason": "stop"}]},
|
|
3037
|
+
]
|
|
3038
|
+
for event in events:
|
|
3039
|
+
tracker.process_event(event)
|
|
3040
|
+
data = tracker.to_dict()
|
|
3041
|
+
log = tmp_path / "session.log"
|
|
3042
|
+
log.write_text("[ERROR] transient unexpected EOF\n[SESSION_END] status=success\n", encoding="utf-8")
|
|
3043
|
+
raw = AISessionResult(
|
|
3044
|
+
command=AISessionCommand(("fake",), None, "custom", "stdin"),
|
|
3045
|
+
exit_code=0,
|
|
3046
|
+
timed_out=False,
|
|
3047
|
+
termination_reason=None,
|
|
3048
|
+
stale_marker=None,
|
|
3049
|
+
fatal_error_code=str(data.get("fatal_error_code") or ""),
|
|
3050
|
+
progress_summary=ProgressSummary(
|
|
3051
|
+
result_subtype=str(data.get("result_subtype") or ""),
|
|
3052
|
+
stop_reason=str(data.get("stop_reason") or ""),
|
|
3053
|
+
fatal_error_code=str(data.get("fatal_error_code") or ""),
|
|
3054
|
+
terminal_result_text=str(data.get("terminal_result_text") or ""),
|
|
3055
|
+
errors=tuple(str(item) for item in data.get("errors", [])),
|
|
3056
|
+
),
|
|
3057
|
+
session_log=log,
|
|
3058
|
+
backup_log=tmp_path / "backup.log",
|
|
3059
|
+
log_recovery=None,
|
|
3060
|
+
started_epoch=0,
|
|
3061
|
+
ended_epoch=1,
|
|
3062
|
+
)
|
|
3063
|
+
|
|
3064
|
+
classification = classify_session(
|
|
3065
|
+
raw,
|
|
3066
|
+
project_root=tmp_path,
|
|
3067
|
+
base_head=base_head,
|
|
3068
|
+
prompt=PromptGenerationResult(
|
|
3069
|
+
output_path=tmp_path / "prompt.md",
|
|
3070
|
+
checkpoint_path=checkpoint,
|
|
3071
|
+
artifact_path=artifact_dir,
|
|
3072
|
+
),
|
|
3073
|
+
)
|
|
3074
|
+
|
|
3075
|
+
assert data["last_result_is_error"] is False
|
|
3076
|
+
assert any("unexpected EOF" in str(error) for error in data["errors"])
|
|
3077
|
+
assert classification.session_status == "success"
|
|
3078
|
+
assert classification.reason == "terminal_success_with_semantic_checkpoint"
|
|
3079
|
+
|
|
3080
|
+
|
|
2814
3081
|
def test_classification_rejects_late_infra_with_completion_summary_only(tmp_path):
|
|
2815
3082
|
from prizmkit_runtime.runner_classification import classify_session
|
|
2816
3083
|
from prizmkit_runtime.runner_models import PromptGenerationResult
|