prizmkit 1.1.104 → 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/README.md +6 -7
- package/bundled/dev-pipeline/assets/prizm-dev-team-integration.md +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 +497 -154
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +98 -9
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +37 -28
- package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +4 -3
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +62 -38
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +57 -49
- package/bundled/dev-pipeline/templates/sections/critical-paths-agent.md +0 -1
- package/bundled/dev-pipeline/templates/sections/critical-paths-full.md +0 -1
- package/bundled/dev-pipeline/templates/sections/directory-convention-agent.md +2 -2
- package/bundled/dev-pipeline/templates/sections/directory-convention-full.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +1 -0
- package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +1 -0
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +46 -0
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +308 -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/dev-pipeline/templates/agent-prompts/dev-implement.md +0 -71
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import sys
|
|
6
7
|
import time
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -15,6 +16,7 @@ from .gitops import (
|
|
|
15
16
|
branch_ensure_return,
|
|
16
17
|
branch_merge_plan,
|
|
17
18
|
current_branch,
|
|
19
|
+
default_branch,
|
|
18
20
|
ensure_linked_worktree,
|
|
19
21
|
guarded_worktree_remove,
|
|
20
22
|
materialize_worktree_support_assets,
|
|
@@ -24,7 +26,12 @@ from .gitops import (
|
|
|
24
26
|
worktree_runtime_context,
|
|
25
27
|
worktree_save_wip,
|
|
26
28
|
)
|
|
27
|
-
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
|
+
)
|
|
28
35
|
from .runner_classification import classify_session
|
|
29
36
|
from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
|
|
30
37
|
from .runner_prompts import PromptGenerationResult, generate_prompt
|
|
@@ -92,7 +99,10 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
|
|
|
92
99
|
processed = 0
|
|
93
100
|
details: list[str] = []
|
|
94
101
|
if invocation.item_id:
|
|
95
|
-
|
|
102
|
+
try:
|
|
103
|
+
status = _process_item(family, invocation, invocation.item_id, paths, initial_metadata={})
|
|
104
|
+
except KeyboardInterrupt:
|
|
105
|
+
return PipelineRunResult(False, 1, "interrupted", "interrupted", tuple(details))
|
|
96
106
|
return PipelineRunResult(status == "completed", 1, "single_item_done", status, tuple(details))
|
|
97
107
|
|
|
98
108
|
while True:
|
|
@@ -109,10 +119,16 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
|
|
|
109
119
|
item_id = str(next_result.data.get(f"{family.kind}_id") or next_result.data.get("feature_id") or next_result.data.get("bug_id") or next_result.data.get("refactor_id") or "")
|
|
110
120
|
if not item_id:
|
|
111
121
|
return PipelineRunResult(False, processed, "missing_item_id", details=(marker[:500],))
|
|
112
|
-
|
|
122
|
+
try:
|
|
123
|
+
final_status = _process_item(family, invocation, item_id, paths, initial_metadata=next_result.data)
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
processed += 1
|
|
126
|
+
details.append(f"{item_id}:interrupted")
|
|
127
|
+
return PipelineRunResult(False, processed, "interrupted", "interrupted", tuple(details))
|
|
113
128
|
processed += 1
|
|
114
129
|
details.append(f"{item_id}:{final_status}")
|
|
115
130
|
if final_status == "completed":
|
|
131
|
+
_emit_info(f"Pausing 5s before next {family.kind}...")
|
|
116
132
|
continue
|
|
117
133
|
if final_status == "pending":
|
|
118
134
|
continue
|
|
@@ -122,6 +138,167 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
|
|
|
122
138
|
return PipelineRunResult(False, processed, "item_failed", final_status, tuple(details))
|
|
123
139
|
|
|
124
140
|
|
|
141
|
+
_SEPARATOR = "─" * 52
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _emit_separator() -> None:
|
|
145
|
+
print(_SEPARATOR, file=sys.stderr, flush=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _emit_info(message: str, *, level: str = "INFO") -> None:
|
|
149
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
150
|
+
print(f"{f'[{level}]'.ljust(10)}{timestamp} {message}", file=sys.stderr, flush=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _emit_success(message: str) -> None:
|
|
154
|
+
_emit_info(message, level="SUCCESS")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _emit_warning(message: str) -> None:
|
|
158
|
+
_emit_info(message, level="WARN")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _family_label(family: RunnerFamily) -> str:
|
|
162
|
+
return {"feature": "Feature", "bugfix": "Bug", "refactor": "Refactor"}.get(family.kind, family.kind.title())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _max_retries(invocation: RunnerInvocation) -> int:
|
|
166
|
+
if invocation.max_retries is not None:
|
|
167
|
+
return max(0, invocation.max_retries)
|
|
168
|
+
return RunnerEnvironment.from_env().max_retries
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _emit_item_header(family: RunnerFamily, invocation: RunnerInvocation, item_id: str, metadata: dict[str, object]) -> None:
|
|
172
|
+
title = str(metadata.get("title") or metadata.get("description") or "").strip()
|
|
173
|
+
title_text = f" — {title}" if title else ""
|
|
174
|
+
retry_count = int(metadata.get("retry_count") or 0)
|
|
175
|
+
infra_count = int(metadata.get("infra_error_count") or 0)
|
|
176
|
+
_emit_separator()
|
|
177
|
+
_emit_info(f"{_family_label(family)}: {item_id}{title_text}")
|
|
178
|
+
_emit_info(f"Code retry: {retry_count} / {_max_retries(invocation)}")
|
|
179
|
+
_emit_info(f"Infrastructure retry: {infra_count} / {invocation.max_infra_retries}")
|
|
180
|
+
_emit_separator()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_recovery_side_effect_branch(branch: str, family: RunnerFamily, item_id: str) -> bool:
|
|
184
|
+
return bool(branch) and branch.startswith(f"{family.branch_prefix}/{item_id}-") and "-recovery-" in branch
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) -> bool:
|
|
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}-")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _resolve_base_branch(project_root: Path, metadata: dict[str, object], active_branch: str, family: RunnerFamily, item_id: str) -> str:
|
|
194
|
+
recorded = str(metadata.get("base_branch") or "").strip()
|
|
195
|
+
if recorded:
|
|
196
|
+
return recorded
|
|
197
|
+
detected_default = default_branch(project_root)
|
|
198
|
+
if _is_current_task_branch(active_branch, family, item_id) or _is_recovery_side_effect_branch(active_branch, family, item_id):
|
|
199
|
+
return detected_default
|
|
200
|
+
return active_branch or detected_default
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _resolve_working_branch(family: RunnerFamily, item_id: str, metadata: dict[str, object], env: RunnerEnvironment, active_branch: str) -> str:
|
|
204
|
+
recorded = str(metadata.get("active_dev_branch") or "").strip()
|
|
205
|
+
if recorded and not _is_recovery_side_effect_branch(recorded, family, item_id):
|
|
206
|
+
return recorded
|
|
207
|
+
if env.dev_branch and not _is_recovery_side_effect_branch(env.dev_branch, family, item_id):
|
|
208
|
+
return env.dev_branch
|
|
209
|
+
if _is_current_task_branch(active_branch, family, item_id):
|
|
210
|
+
return active_branch
|
|
211
|
+
return _default_branch_name(family, item_id)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _emit_branch_setup_result(branch_context: BranchContext, result) -> None:
|
|
215
|
+
results = getattr(result, "results", ()) or ()
|
|
216
|
+
created = len(results) > 1 and results[1].return_code == 0
|
|
217
|
+
reused = len(results) > 2 and results[2].return_code == 0 and not created
|
|
218
|
+
if created:
|
|
219
|
+
_emit_info(f"Created and checked out branch: {branch_context.working_branch} (from {branch_context.base_branch})")
|
|
220
|
+
elif reused:
|
|
221
|
+
_emit_info(f"Reusing branch: {branch_context.working_branch} (from {branch_context.base_branch})")
|
|
222
|
+
else:
|
|
223
|
+
_emit_info(f"Dev branch: {branch_context.working_branch}")
|
|
224
|
+
_emit_info(f"Dev branch: {branch_context.working_branch}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _emit_git_plan_output(result) -> None:
|
|
228
|
+
for command_result in getattr(result, "results", ()) or ():
|
|
229
|
+
for text in (getattr(command_result, "stdout", ""), getattr(command_result, "stderr", "")):
|
|
230
|
+
stripped = str(text or "").strip()
|
|
231
|
+
if stripped:
|
|
232
|
+
print(stripped, file=sys.stderr, flush=True)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _bytes_text(size: int) -> str:
|
|
236
|
+
if size >= 1024 * 1024:
|
|
237
|
+
return f"{round(size / (1024 * 1024))}MB"
|
|
238
|
+
if size >= 1024:
|
|
239
|
+
return f"{round(size / 1024)}KB"
|
|
240
|
+
return f"{size}B"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _session_log_summary(session_log: Path) -> tuple[int, str]:
|
|
244
|
+
try:
|
|
245
|
+
size = session_log.stat().st_size
|
|
246
|
+
with session_log.open("r", encoding="utf-8", errors="ignore") as handle:
|
|
247
|
+
line_count = sum(1 for _ in handle)
|
|
248
|
+
return line_count, _bytes_text(size)
|
|
249
|
+
except OSError:
|
|
250
|
+
return 0, "0B"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _progress_data(path: Path) -> dict[str, object]:
|
|
254
|
+
if not path.is_file():
|
|
255
|
+
return {}
|
|
256
|
+
try:
|
|
257
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
258
|
+
except (OSError, json.JSONDecodeError):
|
|
259
|
+
return {}
|
|
260
|
+
return data if isinstance(data, dict) else {}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _emit_prompt_summary(prompt: PromptGenerationResult, session_id: str) -> None:
|
|
264
|
+
if prompt.pipeline_mode:
|
|
265
|
+
_emit_info(f"Pipeline mode: {_pipeline_mode_label(prompt.pipeline_mode)}")
|
|
266
|
+
if prompt.agent_count:
|
|
267
|
+
critic = "enabled" if prompt.critic_enabled else "disabled"
|
|
268
|
+
_emit_info(f"Agents: {prompt.agent_count} (critic: {critic})")
|
|
269
|
+
_emit_info(f"Spawning AI CLI session: {session_id}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _pipeline_mode_label(mode: str) -> str:
|
|
273
|
+
labels = {
|
|
274
|
+
"lite": "lite (Tier 1 — Single Agent)",
|
|
275
|
+
"standard": "standard (Tier 2 — Orchestrator + Critic/Reviewer)",
|
|
276
|
+
"full": "full (Tier 3 — Orchestrator + Critic/Reviewer)",
|
|
277
|
+
}
|
|
278
|
+
return labels.get(mode, mode)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _emit_session_summary(session_result, session_paths: SessionPaths, classification) -> None:
|
|
282
|
+
lines, size = _session_log_summary(session_paths.session_log)
|
|
283
|
+
_emit_info(f"Session log: {lines} lines, {size}")
|
|
284
|
+
_emit_info(f"exit_code={getattr(session_result, 'exit_code', None)}")
|
|
285
|
+
_emit_info(f"Session result: {classification.session_status}")
|
|
286
|
+
progress = _progress_data(session_paths.progress_json)
|
|
287
|
+
subagent_calls = int(progress.get("subagent_spawn_count") or 0)
|
|
288
|
+
if subagent_calls:
|
|
289
|
+
_emit_info(f"Subagent calls detected in session: {subagent_calls}")
|
|
290
|
+
if classification.session_status == "success":
|
|
291
|
+
_emit_info("CHECKPOINT: All workflow steps completed")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _emit_update_summary(family: RunnerFamily, item_id: str, update) -> None:
|
|
295
|
+
if not isinstance(getattr(update, "data", None), dict):
|
|
296
|
+
return
|
|
297
|
+
new_status = str(update.data.get("new_status") or "")
|
|
298
|
+
if family.kind == "feature" and new_status == "completed":
|
|
299
|
+
_emit_info(f"Propagated completion notes for {item_id} to feature-list.json")
|
|
300
|
+
|
|
301
|
+
|
|
125
302
|
def _process_item(
|
|
126
303
|
family: RunnerFamily,
|
|
127
304
|
invocation: RunnerInvocation,
|
|
@@ -134,185 +311,306 @@ def _process_item(
|
|
|
134
311
|
config = load_runtime_config(paths)
|
|
135
312
|
if not config.ai_client.command:
|
|
136
313
|
raise RuntimeError("No supported AI CLI command was found")
|
|
137
|
-
start = start_item(family, invocation, item_id, paths.project_root)
|
|
138
|
-
if not start.ok:
|
|
139
|
-
raise RuntimeError(start.text)
|
|
140
|
-
commit_pre_branch_bookkeeping(paths.project_root, family, item_id)
|
|
141
314
|
|
|
142
|
-
|
|
143
|
-
|
|
315
|
+
active_branch = current_branch(paths.project_root)
|
|
316
|
+
base_branch = _resolve_base_branch(paths.project_root, initial_metadata, active_branch, family, item_id)
|
|
317
|
+
branch_name = _resolve_working_branch(family, item_id, initial_metadata, env, active_branch)
|
|
144
318
|
branch_context = BranchContext(base_branch, branch_name, paths.project_root)
|
|
145
319
|
worktree_runtime = None
|
|
146
320
|
use_worktree = _use_worktree_for_family(env, family)
|
|
147
321
|
execution_root = paths.project_root
|
|
148
|
-
if use_worktree:
|
|
149
|
-
worktree_runtime = worktree_runtime_context(
|
|
150
|
-
branch_context,
|
|
151
|
-
WorktreePolicy(
|
|
152
|
-
use_worktree=True,
|
|
153
|
-
cleanup_on_success=True,
|
|
154
|
-
preserve_on_failure=True,
|
|
155
|
-
worktree_root=paths.state_dir / "worktrees",
|
|
156
|
-
),
|
|
157
|
-
)
|
|
158
|
-
setup = ensure_linked_worktree(worktree_runtime)
|
|
159
|
-
if not setup.ok:
|
|
160
|
-
status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
|
|
161
|
-
return _status_from_update(status)
|
|
162
|
-
worktree_runtime = setup.runtime or worktree_runtime
|
|
163
|
-
support_assets = materialize_worktree_support_assets(worktree_runtime)
|
|
164
|
-
if not support_assets.ok:
|
|
165
|
-
status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
|
|
166
|
-
return _status_from_update(status)
|
|
167
|
-
execution_root = worktree_runtime.worktree_path
|
|
168
|
-
else:
|
|
169
|
-
branch_result = run_git_plan(paths.project_root, branch_create_plan(branch_context))
|
|
170
|
-
if not branch_result.ok:
|
|
171
|
-
status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
|
|
172
|
-
return _status_from_update(status)
|
|
173
|
-
|
|
174
|
-
previous_fingerprint = initial_metadata.get("last_progress_fingerprint")
|
|
175
|
-
previous_no_progress_count = int(initial_metadata.get("no_progress_count") or 0)
|
|
176
|
-
continuation_pending = bool(initial_metadata.get("continuation_pending"))
|
|
177
|
-
previous_session_id = str(initial_metadata.get("previous_session_id") or initial_metadata.get("last_context_overflow_session_id") or "")
|
|
178
|
-
continuation_count = int(initial_metadata.get("continuation_count") or 0)
|
|
179
|
-
context_overflow_count = int(initial_metadata.get("context_overflow_count") or 0)
|
|
180
322
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
"active_dev_branch": branch_name,
|
|
192
|
-
"base_branch": base_branch,
|
|
193
|
-
"continuation_summary_path": _continuation_summary_path(paths.project_root, family, item_id, None),
|
|
194
|
-
}
|
|
195
|
-
prompt = generate_prompt(
|
|
196
|
-
family,
|
|
197
|
-
invocation,
|
|
198
|
-
item_id=item_id,
|
|
199
|
-
session_id=session_id,
|
|
200
|
-
run_id="run-python",
|
|
201
|
-
retry_count=0,
|
|
202
|
-
session_paths=session_paths,
|
|
203
|
-
project_root=paths.project_root,
|
|
204
|
-
execution_root=execution_root,
|
|
205
|
-
continuation=continuation,
|
|
206
|
-
)
|
|
207
|
-
if invocation.dry_run:
|
|
208
|
-
return "dry_run"
|
|
209
|
-
stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
|
|
210
|
-
base_head = run_git_command(execution_root, ("rev-parse", "HEAD")).stdout.strip()
|
|
211
|
-
session_result = AISessionLauncher(
|
|
212
|
-
AISessionConfig(
|
|
213
|
-
cli_command=config.ai_client.command,
|
|
214
|
-
platform=config.ai_client.platform or "codebuddy",
|
|
215
|
-
model=prompt.model or config.model,
|
|
216
|
-
prompt_path=prompt.output_path,
|
|
217
|
-
cwd=execution_root,
|
|
218
|
-
log_path=session_paths.session_log,
|
|
219
|
-
progress_path=session_paths.progress_json,
|
|
220
|
-
heartbeat_path=session_paths.heartbeat_json,
|
|
221
|
-
effort=config.effort,
|
|
222
|
-
verbose=env.verbose,
|
|
223
|
-
live_output=env.live_output,
|
|
224
|
-
use_stream_json=stream_json,
|
|
225
|
-
stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
|
|
323
|
+
try:
|
|
324
|
+
if use_worktree:
|
|
325
|
+
worktree_runtime = worktree_runtime_context(
|
|
326
|
+
branch_context,
|
|
327
|
+
WorktreePolicy(
|
|
328
|
+
use_worktree=True,
|
|
329
|
+
cleanup_on_success=True,
|
|
330
|
+
preserve_on_failure=True,
|
|
331
|
+
worktree_root=paths.state_dir / "worktrees",
|
|
332
|
+
),
|
|
226
333
|
)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
334
|
+
setup = ensure_linked_worktree(worktree_runtime)
|
|
335
|
+
if not setup.ok:
|
|
336
|
+
return "infra_error"
|
|
337
|
+
worktree_runtime = setup.runtime or worktree_runtime
|
|
338
|
+
_emit_info(f"Dev branch: {branch_name}")
|
|
339
|
+
_emit_info(f"Worktree: {worktree_runtime.worktree_path}")
|
|
340
|
+
support_assets = materialize_worktree_support_assets(worktree_runtime)
|
|
341
|
+
if not support_assets.ok:
|
|
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
|
+
)
|
|
352
|
+
execution_root = worktree_runtime.worktree_path
|
|
353
|
+
else:
|
|
354
|
+
branch_result = run_git_plan(paths.project_root, branch_create_plan(branch_context))
|
|
355
|
+
_emit_branch_setup_result(branch_context, branch_result)
|
|
356
|
+
if not branch_result.ok:
|
|
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)
|
|
367
|
+
|
|
368
|
+
previous_fingerprint = initial_metadata.get("last_progress_fingerprint")
|
|
369
|
+
previous_no_progress_count = int(initial_metadata.get("no_progress_count") or 0)
|
|
370
|
+
continuation_pending = bool(initial_metadata.get("continuation_pending"))
|
|
371
|
+
previous_session_id = str(initial_metadata.get("previous_session_id") or initial_metadata.get("last_context_overflow_session_id") or "")
|
|
372
|
+
continuation_count = int(initial_metadata.get("continuation_count") or 0)
|
|
373
|
+
context_overflow_count = int(initial_metadata.get("context_overflow_count") or 0)
|
|
374
|
+
|
|
375
|
+
while True:
|
|
376
|
+
session_id = _session_id(item_id)
|
|
377
|
+
session_paths = SessionPaths.for_item(family.state_dir, item_id, session_id)
|
|
378
|
+
continuation = None
|
|
379
|
+
if continuation_pending:
|
|
380
|
+
continuation = {
|
|
381
|
+
"previous_session_id": previous_session_id,
|
|
382
|
+
"continuation_count": continuation_count,
|
|
383
|
+
"context_overflow_count": context_overflow_count,
|
|
384
|
+
"task_type": family.task_type,
|
|
385
|
+
"active_dev_branch": branch_name,
|
|
386
|
+
"base_branch": base_branch,
|
|
387
|
+
"continuation_summary_path": _continuation_summary_path(execution_root, family, item_id, None),
|
|
388
|
+
}
|
|
389
|
+
prompt = generate_prompt(
|
|
239
390
|
family,
|
|
240
391
|
invocation,
|
|
392
|
+
item_id=item_id,
|
|
393
|
+
session_id=session_id,
|
|
394
|
+
run_id="run-python",
|
|
395
|
+
retry_count=0,
|
|
396
|
+
session_paths=session_paths,
|
|
397
|
+
project_root=paths.project_root,
|
|
398
|
+
execution_root=execution_root,
|
|
399
|
+
continuation=continuation,
|
|
400
|
+
)
|
|
401
|
+
if invocation.dry_run:
|
|
402
|
+
return "dry_run"
|
|
403
|
+
_emit_prompt_summary(prompt, session_id)
|
|
404
|
+
stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
|
|
405
|
+
base_head = run_git_command(execution_root, ("rev-parse", "HEAD")).stdout.strip()
|
|
406
|
+
session_result = AISessionLauncher(
|
|
407
|
+
AISessionConfig(
|
|
408
|
+
cli_command=config.ai_client.command,
|
|
409
|
+
platform=config.ai_client.platform or "codebuddy",
|
|
410
|
+
model=prompt.model or config.model,
|
|
411
|
+
prompt_path=prompt.output_path,
|
|
412
|
+
cwd=execution_root,
|
|
413
|
+
log_path=session_paths.session_log,
|
|
414
|
+
progress_path=session_paths.progress_json,
|
|
415
|
+
heartbeat_path=session_paths.heartbeat_json,
|
|
416
|
+
effort=config.effort,
|
|
417
|
+
verbose=env.verbose,
|
|
418
|
+
live_output=env.live_output,
|
|
419
|
+
use_stream_json=stream_json,
|
|
420
|
+
stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
|
|
421
|
+
live_progress_interval_seconds=env.heartbeat_interval_seconds,
|
|
422
|
+
live_banner=False,
|
|
423
|
+
suppress_stream_output=True,
|
|
424
|
+
)
|
|
425
|
+
).run()
|
|
426
|
+
classification = classify_session(
|
|
427
|
+
session_result,
|
|
428
|
+
project_root=execution_root,
|
|
429
|
+
base_head=base_head,
|
|
430
|
+
prompt=prompt,
|
|
431
|
+
previous_fingerprint=previous_fingerprint,
|
|
432
|
+
previous_no_progress_count=previous_no_progress_count,
|
|
433
|
+
)
|
|
434
|
+
_emit_session_summary(session_result, session_paths, classification)
|
|
435
|
+
if classification.session_status == "context_overflow":
|
|
436
|
+
summary_path = _continuation_summary_path(execution_root, family, item_id, prompt)
|
|
437
|
+
update = update_item(
|
|
438
|
+
status_family,
|
|
439
|
+
status_invocation,
|
|
440
|
+
item_id,
|
|
441
|
+
"context_overflow",
|
|
442
|
+
session_id,
|
|
443
|
+
status_root,
|
|
444
|
+
active_dev_branch=branch_name,
|
|
445
|
+
base_branch=base_branch,
|
|
446
|
+
last_fatal_error_code=classification.fatal_error_code or "context_overflow",
|
|
447
|
+
continuation_summary_path=summary_path,
|
|
448
|
+
no_progress_count=classification.no_progress_count,
|
|
449
|
+
progress_fingerprint=classification.progress_fingerprint,
|
|
450
|
+
)
|
|
451
|
+
if not update.ok:
|
|
452
|
+
raise RuntimeError(update.text)
|
|
453
|
+
_emit_update_summary(family, item_id, update)
|
|
454
|
+
previous_fingerprint = classification.progress_fingerprint
|
|
455
|
+
previous_no_progress_count = classification.no_progress_count
|
|
456
|
+
continuation_pending = True
|
|
457
|
+
previous_session_id = session_id
|
|
458
|
+
continuation_count += 1
|
|
459
|
+
context_overflow_count += 1
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
final_session_status = classification.session_status
|
|
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,
|
|
241
480
|
item_id,
|
|
242
|
-
|
|
481
|
+
final_session_status,
|
|
243
482
|
session_id,
|
|
244
|
-
|
|
483
|
+
status_root,
|
|
245
484
|
active_dev_branch=branch_name,
|
|
246
485
|
base_branch=base_branch,
|
|
247
|
-
last_fatal_error_code=classification.fatal_error_code
|
|
248
|
-
continuation_summary_path=summary_path,
|
|
486
|
+
last_fatal_error_code=classification.fatal_error_code,
|
|
249
487
|
no_progress_count=classification.no_progress_count,
|
|
250
488
|
progress_fingerprint=classification.progress_fingerprint,
|
|
251
489
|
)
|
|
252
490
|
if not update.ok:
|
|
253
491
|
raise RuntimeError(update.text)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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:
|
|
498
|
+
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
499
|
+
else:
|
|
500
|
+
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
501
|
+
return new_status
|
|
261
502
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if
|
|
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}...")
|
|
265
507
|
merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
|
|
508
|
+
_emit_git_plan_output(merge)
|
|
266
509
|
if merge.ok:
|
|
267
510
|
guarded_worktree_remove(worktree_runtime, delete_branch=True)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
511
|
+
_emit_success(f"Merged {branch_name} into {base_branch}")
|
|
512
|
+
return new_status
|
|
513
|
+
final_session_status = "merge_conflict"
|
|
271
514
|
else:
|
|
272
|
-
|
|
273
|
-
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
274
|
-
else:
|
|
275
|
-
if final_session_status == "success":
|
|
515
|
+
_emit_info(f"Merging {branch_name} into {base_branch}...")
|
|
276
516
|
merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
536
|
+
status_family,
|
|
537
|
+
status_invocation,
|
|
538
|
+
item_id,
|
|
539
|
+
final_session_status,
|
|
540
|
+
session_id,
|
|
541
|
+
status_root,
|
|
542
|
+
active_dev_branch=branch_name,
|
|
543
|
+
base_branch=base_branch,
|
|
544
|
+
last_fatal_error_code=classification.fatal_error_code,
|
|
545
|
+
no_progress_count=classification.no_progress_count,
|
|
546
|
+
progress_fingerprint=classification.progress_fingerprint,
|
|
547
|
+
)
|
|
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)
|
|
280
556
|
else:
|
|
281
557
|
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
558
|
+
return new_status
|
|
559
|
+
except KeyboardInterrupt:
|
|
560
|
+
_emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
|
|
561
|
+
if use_worktree and worktree_runtime is not None:
|
|
562
|
+
worktree_save_wip(worktree_runtime)
|
|
563
|
+
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
564
|
+
else:
|
|
565
|
+
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
566
|
+
raise
|
|
282
567
|
|
|
283
|
-
_write_session_status(
|
|
284
|
-
session_paths,
|
|
285
|
-
status=final_session_status,
|
|
286
|
-
session_id=session_id,
|
|
287
|
-
task_id=item_id,
|
|
288
|
-
task_type=family.task_type,
|
|
289
|
-
base_branch=base_branch,
|
|
290
|
-
active_dev_branch=branch_name,
|
|
291
|
-
worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
|
|
292
|
-
reason=classification.reason,
|
|
293
|
-
fatal_error_code=classification.fatal_error_code,
|
|
294
|
-
)
|
|
295
568
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
session_id,
|
|
302
|
-
paths.project_root,
|
|
303
|
-
active_dev_branch=branch_name,
|
|
304
|
-
base_branch=base_branch,
|
|
305
|
-
last_fatal_error_code=classification.fatal_error_code,
|
|
306
|
-
no_progress_count=classification.no_progress_count,
|
|
307
|
-
progress_fingerprint=classification.progress_fingerprint,
|
|
308
|
-
)
|
|
309
|
-
if not update.ok:
|
|
310
|
-
raise RuntimeError(update.text)
|
|
311
|
-
new_status = _status_from_update(update)
|
|
312
|
-
commit_final_bookkeeping(paths.project_root, family, item_id, new_status)
|
|
313
|
-
return new_status
|
|
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)
|
|
314
574
|
|
|
315
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
|
|
316
614
|
|
|
317
615
|
def _test_cli(kind: str, paths) -> CommandResult:
|
|
318
616
|
"""Report the AI CLI resolution used by Python runner commands."""
|
|
@@ -340,6 +638,52 @@ def _use_worktree_for_family(env: RunnerEnvironment, family: RunnerFamily) -> bo
|
|
|
340
638
|
return env.use_worktree and family.kind in {"feature", "bugfix"}
|
|
341
639
|
|
|
342
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
|
+
|
|
343
687
|
def _write_session_status(
|
|
344
688
|
session_paths: SessionPaths,
|
|
345
689
|
*,
|
|
@@ -380,8 +724,7 @@ def _status_from_update(update_result) -> str:
|
|
|
380
724
|
|
|
381
725
|
|
|
382
726
|
def _default_branch_name(family: RunnerFamily, item_id: str) -> str:
|
|
383
|
-
|
|
384
|
-
return f"{family.branch_prefix}/{item_id}-{stamp}"
|
|
727
|
+
return f"{family.branch_prefix}/{item_id}-{time.strftime('%Y%m%d%H%M%S')}"
|
|
385
728
|
|
|
386
729
|
|
|
387
730
|
def _session_id(item_id: str) -> str:
|