forgexa-cli 1.14.3__tar.gz → 1.14.5__tar.gz
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.
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/PKG-INFO +1 -1
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli/daemon.py +311 -58
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/pyproject.toml +1 -1
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/README.md +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/setup.cfg +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.5}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.14.
|
|
2
|
+
__version__ = "1.14.5"
|
|
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
523
523
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
524
524
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
525
525
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
526
|
-
DAEMON_VERSION = "1.14.
|
|
526
|
+
DAEMON_VERSION = "1.14.5"
|
|
527
527
|
|
|
528
528
|
|
|
529
529
|
def _detect_client_type() -> str:
|
|
@@ -1225,6 +1225,38 @@ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
|
|
|
1225
1225
|
return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
|
|
1226
1226
|
|
|
1227
1227
|
|
|
1228
|
+
# Mirrors type_workflow_profiles.py — maps req type to the expected design-phase output filename.
|
|
1229
|
+
# Most types use design.md; spike uses research.md (produced by _DESIGN_SPIKE prompt).
|
|
1230
|
+
_DESIGN_OUTPUT_BY_TYPE: dict[str, str] = {
|
|
1231
|
+
"spike": "research.md",
|
|
1232
|
+
# All other types produce design.md (the default).
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _get_design_output_for_type(req_type: str) -> str:
|
|
1237
|
+
"""Get the expected design-phase output filename for a requirement type.
|
|
1238
|
+
|
|
1239
|
+
Used by _validate_outputs and _required_deliverable_paths to avoid
|
|
1240
|
+
hardcoding 'design.md' for all node_type='design' nodes — spike produces
|
|
1241
|
+
'research.md' instead.
|
|
1242
|
+
"""
|
|
1243
|
+
try:
|
|
1244
|
+
from app.services.type_workflow_profiles import get_profile
|
|
1245
|
+
profile = get_profile(req_type)
|
|
1246
|
+
for phase in profile.execution_phases:
|
|
1247
|
+
if phase.node_type == "design":
|
|
1248
|
+
from app.services.type_prompts import TYPE_PROMPT_TEMPLATES
|
|
1249
|
+
tmpl = TYPE_PROMPT_TEMPLATES.get(phase.prompt_key, "")
|
|
1250
|
+
import re as _re
|
|
1251
|
+
m = _re.search(r"{doc_dir}/([\w.-]+)", tmpl)
|
|
1252
|
+
if m:
|
|
1253
|
+
return m.group(1)
|
|
1254
|
+
break
|
|
1255
|
+
except Exception:
|
|
1256
|
+
pass
|
|
1257
|
+
return _DESIGN_OUTPUT_BY_TYPE.get(req_type, "design.md")
|
|
1258
|
+
|
|
1259
|
+
|
|
1228
1260
|
# ── Agent Discovery ──
|
|
1229
1261
|
|
|
1230
1262
|
|
|
@@ -3547,7 +3579,8 @@ class ProcessManager:
|
|
|
3547
3579
|
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
3548
3580
|
required_files = _get_analysis_outputs_for_type(req_type)
|
|
3549
3581
|
elif task.node_type == "design":
|
|
3550
|
-
|
|
3582
|
+
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
3583
|
+
required_files = [_get_design_output_for_type(req_type)]
|
|
3551
3584
|
elif task.node_type == "delivery":
|
|
3552
3585
|
# Required docs come from node input_data (set by delivery_doc_service)
|
|
3553
3586
|
required_files = (task.input_data or {}).get("required_docs") or ["release-note.md"]
|
|
@@ -5073,28 +5106,47 @@ class ProcessManager:
|
|
|
5073
5106
|
|
|
5074
5107
|
return info
|
|
5075
5108
|
|
|
5076
|
-
async def _collect_git_info_vs_parent(
|
|
5077
|
-
|
|
5109
|
+
async def _collect_git_info_vs_parent(
|
|
5110
|
+
self,
|
|
5111
|
+
cwd: Path,
|
|
5112
|
+
*,
|
|
5113
|
+
default_branch: str = "",
|
|
5114
|
+
before_sha: str = "",
|
|
5115
|
+
) -> dict:
|
|
5116
|
+
"""Collect git diff stats comparing HEAD vs merge-base with the base branch.
|
|
5078
5117
|
|
|
5079
|
-
Uses merge-base to capture ALL changes on the
|
|
5118
|
+
Uses merge-base to capture ALL changes on the current branch, not just
|
|
5080
5119
|
the last commit (agents like claude may create multiple commits).
|
|
5081
|
-
|
|
5120
|
+
Prefers the project's configured default branch, then falls back to the
|
|
5121
|
+
conventional `main` / `master` refs. Falls back to HEAD~1 if merge-base
|
|
5122
|
+
detection fails.
|
|
5082
5123
|
"""
|
|
5083
5124
|
info: dict[str, Any] = {}
|
|
5084
5125
|
try:
|
|
5085
|
-
#
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5126
|
+
# Prefer the exact pre-node HEAD when available so the diff is
|
|
5127
|
+
# scoped to this node's work, not the whole requirement branch.
|
|
5128
|
+
base_ref = str(before_sha or "").strip() or "HEAD~1"
|
|
5129
|
+
if not str(before_sha or "").strip():
|
|
5130
|
+
candidate_refs: list[str] = []
|
|
5131
|
+
for candidate in (default_branch, "main", "master"):
|
|
5132
|
+
candidate = str(candidate or "").strip()
|
|
5133
|
+
if not candidate:
|
|
5134
|
+
continue
|
|
5135
|
+
ref = candidate if candidate.startswith("origin/") else f"origin/{candidate}"
|
|
5136
|
+
if ref not in candidate_refs:
|
|
5137
|
+
candidate_refs.append(ref)
|
|
5138
|
+
|
|
5139
|
+
for candidate in candidate_refs:
|
|
5140
|
+
proc_mb = await asyncio.create_subprocess_exec(
|
|
5141
|
+
"git", "merge-base", candidate, "HEAD",
|
|
5142
|
+
stdout=asyncio.subprocess.PIPE,
|
|
5143
|
+
stderr=asyncio.subprocess.PIPE,
|
|
5144
|
+
cwd=str(cwd),
|
|
5145
|
+
)
|
|
5146
|
+
mb_stdout, _ = await asyncio.wait_for(proc_mb.communicate(), timeout=10)
|
|
5147
|
+
if proc_mb.returncode == 0 and mb_stdout.strip():
|
|
5148
|
+
base_ref = mb_stdout.decode().strip()
|
|
5149
|
+
break
|
|
5098
5150
|
|
|
5099
5151
|
# Changed files since branch point
|
|
5100
5152
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -5140,6 +5192,8 @@ class ProcessManager:
|
|
|
5140
5192
|
)
|
|
5141
5193
|
stdout4, _ = await asyncio.wait_for(proc4.communicate(), timeout=10)
|
|
5142
5194
|
info["commit_sha"] = stdout4.decode().strip()
|
|
5195
|
+
info["before_sha"] = base_ref
|
|
5196
|
+
info["after_sha"] = info["commit_sha"]
|
|
5143
5197
|
|
|
5144
5198
|
except Exception as e:
|
|
5145
5199
|
logger.debug("Git info (vs parent) collection failed: %s", e)
|
|
@@ -5177,10 +5231,36 @@ class ProcessManager:
|
|
|
5177
5231
|
class ProgressReporter:
|
|
5178
5232
|
"""Reports task progress back to the server."""
|
|
5179
5233
|
|
|
5234
|
+
_MIN_HEARTBEAT_INTERVAL_SECONDS = 5.0
|
|
5235
|
+
_MIN_PROGRESS_DELTA = 5
|
|
5236
|
+
|
|
5180
5237
|
def __init__(self, client: httpx.AsyncClient, server_url: str, runtime_id: str):
|
|
5181
5238
|
self.client = client
|
|
5182
5239
|
self.server_url = server_url.rstrip("/")
|
|
5183
5240
|
self.runtime_id = runtime_id
|
|
5241
|
+
self._last_progress_sent: dict[str, dict[str, Any]] = {}
|
|
5242
|
+
|
|
5243
|
+
def _should_skip_progress(
|
|
5244
|
+
self,
|
|
5245
|
+
task_id: str,
|
|
5246
|
+
progress_percent: int,
|
|
5247
|
+
current_step: str,
|
|
5248
|
+
output_lines: list[str],
|
|
5249
|
+
files_changed: list[str],
|
|
5250
|
+
lines_added: int,
|
|
5251
|
+
lines_removed: int,
|
|
5252
|
+
) -> bool:
|
|
5253
|
+
if output_lines or files_changed or lines_added or lines_removed:
|
|
5254
|
+
return False
|
|
5255
|
+
|
|
5256
|
+
last = self._last_progress_sent.get(task_id)
|
|
5257
|
+
if not last:
|
|
5258
|
+
return False
|
|
5259
|
+
if last["current_step"] != current_step:
|
|
5260
|
+
return False
|
|
5261
|
+
if progress_percent - int(last["progress_percent"]) >= self._MIN_PROGRESS_DELTA:
|
|
5262
|
+
return False
|
|
5263
|
+
return (time.monotonic() - float(last["sent_at"])) < self._MIN_HEARTBEAT_INTERVAL_SECONDS
|
|
5184
5264
|
|
|
5185
5265
|
async def report_progress(
|
|
5186
5266
|
self,
|
|
@@ -5192,24 +5272,43 @@ class ProgressReporter:
|
|
|
5192
5272
|
lines_added: int = 0,
|
|
5193
5273
|
lines_removed: int = 0,
|
|
5194
5274
|
):
|
|
5275
|
+
output_lines = output_lines or []
|
|
5276
|
+
files_changed = files_changed or []
|
|
5277
|
+
if self._should_skip_progress(
|
|
5278
|
+
task_id,
|
|
5279
|
+
progress_percent,
|
|
5280
|
+
current_step,
|
|
5281
|
+
output_lines,
|
|
5282
|
+
files_changed,
|
|
5283
|
+
lines_added,
|
|
5284
|
+
lines_removed,
|
|
5285
|
+
):
|
|
5286
|
+
return
|
|
5287
|
+
|
|
5195
5288
|
try:
|
|
5196
5289
|
await self.client.post(
|
|
5197
5290
|
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/{task_id}/progress",
|
|
5198
5291
|
json={
|
|
5199
5292
|
"progress_percent": progress_percent,
|
|
5200
5293
|
"current_step": current_step,
|
|
5201
|
-
"output_lines": output_lines
|
|
5202
|
-
"files_changed": files_changed
|
|
5294
|
+
"output_lines": output_lines,
|
|
5295
|
+
"files_changed": files_changed,
|
|
5203
5296
|
"lines_added": lines_added,
|
|
5204
5297
|
"lines_removed": lines_removed,
|
|
5205
5298
|
},
|
|
5206
5299
|
timeout=10,
|
|
5207
5300
|
)
|
|
5301
|
+
self._last_progress_sent[task_id] = {
|
|
5302
|
+
"progress_percent": progress_percent,
|
|
5303
|
+
"current_step": current_step,
|
|
5304
|
+
"sent_at": time.monotonic(),
|
|
5305
|
+
}
|
|
5208
5306
|
except Exception as e:
|
|
5209
5307
|
logger.warning("Failed to report progress for task %s: %s", task_id, e)
|
|
5210
5308
|
|
|
5211
5309
|
async def report_complete(self, task_id: str, result: TaskResult):
|
|
5212
5310
|
"""Report task completion with retry."""
|
|
5311
|
+
self._last_progress_sent.pop(task_id, None)
|
|
5213
5312
|
payload = {
|
|
5214
5313
|
"status": result.status,
|
|
5215
5314
|
"exit_code": result.exit_code,
|
|
@@ -5403,6 +5502,8 @@ class LogUploader:
|
|
|
5403
5502
|
class TaskPoller:
|
|
5404
5503
|
"""Polls the server for assigned tasks."""
|
|
5405
5504
|
|
|
5505
|
+
_EMPTY_BACKOFF_STEPS = (0, 0, 1, 2, 3, 5)
|
|
5506
|
+
|
|
5406
5507
|
def __init__(
|
|
5407
5508
|
self,
|
|
5408
5509
|
client: httpx.AsyncClient,
|
|
@@ -5416,6 +5517,16 @@ class TaskPoller:
|
|
|
5416
5517
|
self.interval = interval
|
|
5417
5518
|
self._on_success = lambda: None # Callbacks set by ServerConnection
|
|
5418
5519
|
self._on_auth_failure = lambda: None
|
|
5520
|
+
self._empty_cycles = 0
|
|
5521
|
+
|
|
5522
|
+
def note_activity(self, *, had_work: bool) -> None:
|
|
5523
|
+
if had_work:
|
|
5524
|
+
self._empty_cycles = 0
|
|
5525
|
+
return
|
|
5526
|
+
self._empty_cycles = min(self._empty_cycles + 1, len(self._EMPTY_BACKOFF_STEPS) - 1)
|
|
5527
|
+
|
|
5528
|
+
def next_delay_seconds(self) -> int:
|
|
5529
|
+
return self.interval + self._EMPTY_BACKOFF_STEPS[self._empty_cycles]
|
|
5419
5530
|
|
|
5420
5531
|
async def poll(self) -> list[TaskInfo]:
|
|
5421
5532
|
"""Poll for tasks assigned to this daemon."""
|
|
@@ -6146,15 +6257,28 @@ class RuntimeDaemon:
|
|
|
6146
6257
|
# 3. Main loop
|
|
6147
6258
|
try:
|
|
6148
6259
|
while not self._shutdown:
|
|
6149
|
-
await self._poll_and_execute()
|
|
6150
|
-
|
|
6260
|
+
had_work = await self._poll_and_execute()
|
|
6261
|
+
next_delay = self.poll_interval
|
|
6262
|
+
if self.connections:
|
|
6263
|
+
next_delay = min(
|
|
6264
|
+
conn.poller.next_delay_seconds()
|
|
6265
|
+
for conn in self.connections
|
|
6266
|
+
if conn.poller is not None
|
|
6267
|
+
)
|
|
6268
|
+
logger.debug(
|
|
6269
|
+
"Daemon poll cycle complete (had_work=%s, next_delay=%ss)",
|
|
6270
|
+
had_work,
|
|
6271
|
+
next_delay,
|
|
6272
|
+
)
|
|
6273
|
+
await asyncio.sleep(next_delay)
|
|
6151
6274
|
except asyncio.CancelledError:
|
|
6152
6275
|
pass
|
|
6153
6276
|
finally:
|
|
6154
6277
|
await self._shutdown_gracefully()
|
|
6155
6278
|
|
|
6156
|
-
async def _poll_and_execute(self):
|
|
6279
|
+
async def _poll_and_execute(self) -> bool:
|
|
6157
6280
|
"""Poll for tasks from all connected servers and execute them."""
|
|
6281
|
+
had_work = False
|
|
6158
6282
|
# Clean up completed tasks
|
|
6159
6283
|
for task_id in list(self.active_tasks):
|
|
6160
6284
|
if self.active_tasks[task_id].done():
|
|
@@ -6169,7 +6293,7 @@ class RuntimeDaemon:
|
|
|
6169
6293
|
|
|
6170
6294
|
# Check capacity
|
|
6171
6295
|
if total_active >= self.max_concurrent:
|
|
6172
|
-
return
|
|
6296
|
+
return True
|
|
6173
6297
|
|
|
6174
6298
|
# Poll from all connected servers
|
|
6175
6299
|
for conn in self.connections:
|
|
@@ -6177,6 +6301,12 @@ class RuntimeDaemon:
|
|
|
6177
6301
|
break
|
|
6178
6302
|
|
|
6179
6303
|
tasks = await conn.poller.poll()
|
|
6304
|
+
ai_jobs: list[dict] = []
|
|
6305
|
+
if len(self.active_tasks) < self.max_concurrent:
|
|
6306
|
+
ai_jobs = await conn.poller.poll_ai_jobs()
|
|
6307
|
+
|
|
6308
|
+
conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
|
|
6309
|
+
had_work = had_work or bool(tasks or ai_jobs)
|
|
6180
6310
|
|
|
6181
6311
|
for task in tasks:
|
|
6182
6312
|
if task.task_id in self.active_tasks:
|
|
@@ -6194,21 +6324,21 @@ class RuntimeDaemon:
|
|
|
6194
6324
|
)
|
|
6195
6325
|
|
|
6196
6326
|
# Poll for AIJobs (workspace-mode tasks)
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
self.
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6327
|
+
for aj in ai_jobs:
|
|
6328
|
+
job_id = aj.get("job_id", "")
|
|
6329
|
+
ai_task_key = f"aijob_{job_id}"
|
|
6330
|
+
if ai_task_key in self.active_tasks:
|
|
6331
|
+
continue
|
|
6332
|
+
if len(self.active_tasks) >= self.max_concurrent:
|
|
6333
|
+
break
|
|
6334
|
+
logger.info("[%s] Starting AIJob %s (type=%s)",
|
|
6335
|
+
conn.label, job_id, aj.get("task_type"))
|
|
6336
|
+
self._task_connections[ai_task_key] = conn
|
|
6337
|
+
self.active_tasks[ai_task_key] = asyncio.create_task(
|
|
6338
|
+
self._execute_ai_job(aj, conn)
|
|
6339
|
+
)
|
|
6340
|
+
|
|
6341
|
+
return had_work
|
|
6212
6342
|
|
|
6213
6343
|
def _recover_timeout_from_workspace_changes(
|
|
6214
6344
|
self,
|
|
@@ -6255,6 +6385,8 @@ class RuntimeDaemon:
|
|
|
6255
6385
|
async def _execute_task(self, task: TaskInfo, conn: ServerConnection):
|
|
6256
6386
|
"""Execute a single task, reporting to the originating server connection."""
|
|
6257
6387
|
reporter = conn.reporter
|
|
6388
|
+
default_branch = str((task.project or {}).get("default_branch") or "").strip()
|
|
6389
|
+
node_before_sha = ""
|
|
6258
6390
|
# Use workspace-level granularity (requirement_workflow_id or graph_id) so that
|
|
6259
6391
|
# tasks for DIFFERENT requirements can run in parallel on the same daemon, while
|
|
6260
6392
|
# tasks for the SAME requirement (e.g. analysis + retry) are still serialized to
|
|
@@ -6434,6 +6566,19 @@ class RuntimeDaemon:
|
|
|
6434
6566
|
output_dir_norm, task.task_id,
|
|
6435
6567
|
)
|
|
6436
6568
|
|
|
6569
|
+
try:
|
|
6570
|
+
node_before_sha = (
|
|
6571
|
+
await self.workspace_manager._git(
|
|
6572
|
+
"rev-parse", "HEAD", cwd=workspace_path, timeout=30,
|
|
6573
|
+
)
|
|
6574
|
+
).strip()
|
|
6575
|
+
except Exception as _before_sha_err:
|
|
6576
|
+
logger.debug(
|
|
6577
|
+
"Could not resolve pre-node HEAD for task %s: %s",
|
|
6578
|
+
task.task_id,
|
|
6579
|
+
_before_sha_err,
|
|
6580
|
+
)
|
|
6581
|
+
|
|
6437
6582
|
# 3. Run agent with real-time output streaming + periodic progress heartbeat
|
|
6438
6583
|
await reporter.report_progress(task.task_id, 10, "running_agent")
|
|
6439
6584
|
|
|
@@ -6506,7 +6651,11 @@ class RuntimeDaemon:
|
|
|
6506
6651
|
_skip_fallback = False
|
|
6507
6652
|
if self.process_manager.is_rate_limited(result):
|
|
6508
6653
|
_pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
|
|
6509
|
-
_pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(
|
|
6654
|
+
_pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(
|
|
6655
|
+
workspace_path,
|
|
6656
|
+
default_branch=default_branch,
|
|
6657
|
+
before_sha=node_before_sha,
|
|
6658
|
+
)
|
|
6510
6659
|
has_workspace_changes = (
|
|
6511
6660
|
bool(_pre_fallback_git.get("files_changed"))
|
|
6512
6661
|
or bool(_pre_fallback_committed.get("files_changed"))
|
|
@@ -6617,7 +6766,11 @@ class RuntimeDaemon:
|
|
|
6617
6766
|
# (e.g., after rate-limit fallback, or API errors not caught by output parsing).
|
|
6618
6767
|
if result.status == "success" and task.node_type in ("coding", "fix", "testing"):
|
|
6619
6768
|
has_uncommitted = bool(pre_commit_git.get("files_changed"))
|
|
6620
|
-
committed_git = await self.process_manager._collect_git_info_vs_parent(
|
|
6769
|
+
committed_git = await self.process_manager._collect_git_info_vs_parent(
|
|
6770
|
+
workspace_path,
|
|
6771
|
+
default_branch=default_branch,
|
|
6772
|
+
before_sha=node_before_sha,
|
|
6773
|
+
)
|
|
6621
6774
|
has_committed = bool(committed_git.get("files_changed"))
|
|
6622
6775
|
has_tokens = (
|
|
6623
6776
|
int(result.metrics.get("token_input", 0) or 0)
|
|
@@ -6655,7 +6808,11 @@ class RuntimeDaemon:
|
|
|
6655
6808
|
)
|
|
6656
6809
|
if can_attempt_recovery:
|
|
6657
6810
|
original_exit_code = result.exit_code
|
|
6658
|
-
committed_git = await self.process_manager._collect_git_info_vs_parent(
|
|
6811
|
+
committed_git = await self.process_manager._collect_git_info_vs_parent(
|
|
6812
|
+
workspace_path,
|
|
6813
|
+
default_branch=default_branch,
|
|
6814
|
+
before_sha=node_before_sha,
|
|
6815
|
+
)
|
|
6659
6816
|
has_committed_changes = bool(committed_git.get("files_changed"))
|
|
6660
6817
|
has_uncommitted_changes = bool(pre_commit_git.get("files_changed"))
|
|
6661
6818
|
has_no_uncommitted = not has_uncommitted_changes
|
|
@@ -6779,7 +6936,11 @@ class RuntimeDaemon:
|
|
|
6779
6936
|
# 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
|
|
6780
6937
|
# Existing files from a prior iteration are not sufficient evidence.
|
|
6781
6938
|
if result.status == "success" and task.node_type in ("analysis", "design", "fix"):
|
|
6782
|
-
committed_git = await self.process_manager._collect_git_info_vs_parent(
|
|
6939
|
+
committed_git = await self.process_manager._collect_git_info_vs_parent(
|
|
6940
|
+
workspace_path,
|
|
6941
|
+
default_branch=default_branch,
|
|
6942
|
+
before_sha=node_before_sha,
|
|
6943
|
+
)
|
|
6783
6944
|
git_check_passed = self.process_manager._has_required_deliverable_updates(
|
|
6784
6945
|
task,
|
|
6785
6946
|
pre_commit_git.get("files_changed"),
|
|
@@ -6875,14 +7036,29 @@ class RuntimeDaemon:
|
|
|
6875
7036
|
# as failed so the orchestrator can retry (transient network).
|
|
6876
7037
|
if commit_result.get("push_error"):
|
|
6877
7038
|
push_err = commit_result["push_error"]
|
|
6878
|
-
logger.error(
|
|
6879
|
-
"Task %s: push failed, marking as failed so retry can attempt push again: %s",
|
|
6880
|
-
task.task_id, push_err,
|
|
6881
|
-
)
|
|
6882
7039
|
result.status = "failed"
|
|
6883
|
-
|
|
7040
|
+
if commit_result.get("push_error_code") == "forbidden_branch_merge":
|
|
7041
|
+
logger.error(
|
|
7042
|
+
"Task %s: push blocked by requirement-branch merge protection: %s",
|
|
7043
|
+
task.task_id, push_err,
|
|
7044
|
+
)
|
|
7045
|
+
result.failure_code = "forbidden_branch_merge"
|
|
7046
|
+
result.error = (
|
|
7047
|
+
commit_result.get("push_error_display")
|
|
7048
|
+
or f"Push blocked: {push_err}"
|
|
7049
|
+
)
|
|
7050
|
+
else:
|
|
7051
|
+
logger.error(
|
|
7052
|
+
"Task %s: push failed, marking as failed so retry can attempt push again: %s",
|
|
7053
|
+
task.task_id, push_err,
|
|
7054
|
+
)
|
|
7055
|
+
result.error = f"Git push failed: {push_err}"
|
|
6884
7056
|
# Re-collect git info after commit (compare with parent)
|
|
6885
|
-
post_commit_git = await self.process_manager._collect_git_info_vs_parent(
|
|
7057
|
+
post_commit_git = await self.process_manager._collect_git_info_vs_parent(
|
|
7058
|
+
workspace_path,
|
|
7059
|
+
default_branch=default_branch,
|
|
7060
|
+
before_sha=node_before_sha,
|
|
7061
|
+
)
|
|
6886
7062
|
# Merge: use the pre-commit file list if post-commit is empty
|
|
6887
7063
|
result.git = post_commit_git
|
|
6888
7064
|
if not result.git.get("files_changed") and pre_commit_git.get("files_changed"):
|
|
@@ -7117,12 +7293,14 @@ class RuntimeDaemon:
|
|
|
7117
7293
|
|
|
7118
7294
|
elif node_type == "design":
|
|
7119
7295
|
doc_dir = (task.input_data or {}).get("output_dir", "")
|
|
7296
|
+
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
7120
7297
|
if doc_dir:
|
|
7121
|
-
|
|
7298
|
+
design_filename = _get_design_output_for_type(req_type)
|
|
7299
|
+
design_path = workspace_path / doc_dir / design_filename
|
|
7122
7300
|
if not design_path.exists():
|
|
7123
|
-
issues.append(f"Design document missing: {doc_dir}/
|
|
7301
|
+
issues.append(f"Design document missing: {doc_dir}/{design_filename}")
|
|
7124
7302
|
elif design_path.stat().st_size == 0:
|
|
7125
|
-
issues.append(f"Design document is empty: {doc_dir}/
|
|
7303
|
+
issues.append(f"Design document is empty: {doc_dir}/{design_filename}")
|
|
7126
7304
|
|
|
7127
7305
|
elif node_type in ("coding", "testing", "fix", "review"):
|
|
7128
7306
|
# Syntax-check modified Python and JS/TS files
|
|
@@ -7860,11 +8038,11 @@ class RuntimeDaemon:
|
|
|
7860
8038
|
Some agents (e.g. claude) commit changes internally, so we must
|
|
7861
8039
|
also push even when the working directory is clean.
|
|
7862
8040
|
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
8041
|
+
For non-requirement branches, we may integrate the latest
|
|
8042
|
+
``origin/{default_branch}`` before push. Requirement branches are
|
|
8043
|
+
explicitly excluded because silently rebasing/merging the default
|
|
8044
|
+
branch into a requirement branch widens the change scope, pollutes gate
|
|
8045
|
+
review diffs, and can import unrelated requirements into the fix.
|
|
7868
8046
|
|
|
7869
8047
|
Returns a dict with commit/push status info (empty on success).
|
|
7870
8048
|
"""
|
|
@@ -7979,6 +8157,35 @@ class RuntimeDaemon:
|
|
|
7979
8157
|
)
|
|
7980
8158
|
return {"push_error": f"Would push to default branch {current_branch}"}
|
|
7981
8159
|
|
|
8160
|
+
if task.requirement_key and current_branch:
|
|
8161
|
+
forbidden_merges = await self._list_unpushed_merge_commits(
|
|
8162
|
+
workspace_path, current_branch, default_branch,
|
|
8163
|
+
)
|
|
8164
|
+
if forbidden_merges:
|
|
8165
|
+
short_merges = ", ".join(sha[:12] for sha in forbidden_merges[:5])
|
|
8166
|
+
logger.error(
|
|
8167
|
+
"Requirement branch %s has %d new merge commit(s) pending push [%s] — "
|
|
8168
|
+
"aborting push to prevent cross-requirement contamination",
|
|
8169
|
+
current_branch, len(forbidden_merges), short_merges,
|
|
8170
|
+
)
|
|
8171
|
+
display_error = (
|
|
8172
|
+
f"Push blocked: requirement branch '{current_branch}' contains "
|
|
8173
|
+
f"merge commit(s) pending push ({short_merges}). "
|
|
8174
|
+
"This would import default-branch or unrelated requirement changes "
|
|
8175
|
+
"into the current execution scope. Roll back or recreate this fix "
|
|
8176
|
+
"on a clean requirement branch. Do not merge, rebase, or pull the "
|
|
8177
|
+
"default branch into requirement branches."
|
|
8178
|
+
)
|
|
8179
|
+
return {
|
|
8180
|
+
"push_error_code": "forbidden_branch_merge",
|
|
8181
|
+
"push_error": (
|
|
8182
|
+
"Requirement branch contains merge commit(s) pending push: "
|
|
8183
|
+
f"{short_merges}. Merging default/other branches into "
|
|
8184
|
+
"requirement branches is forbidden."
|
|
8185
|
+
),
|
|
8186
|
+
"push_error_display": display_error,
|
|
8187
|
+
}
|
|
8188
|
+
|
|
7982
8189
|
# ── Push ──
|
|
7983
8190
|
push_error = await self._push_branch(workspace_path, project_key, task=task)
|
|
7984
8191
|
if push_error:
|
|
@@ -8792,6 +8999,11 @@ class RuntimeDaemon:
|
|
|
8792
8999
|
):
|
|
8793
9000
|
"""Integrate the current branch with ``origin/{default_branch}``.
|
|
8794
9001
|
|
|
9002
|
+
Requirement branches are intentionally excluded. They already sync to the
|
|
9003
|
+
latest remote requirement branch during workspace preparation, so pulling
|
|
9004
|
+
in ``origin/{default_branch}`` here only risks importing unrelated work
|
|
9005
|
+
into the requirement/fix scope.
|
|
9006
|
+
|
|
8795
9007
|
Strategy:
|
|
8796
9008
|
- If the current branch already exists on remote (was previously pushed):
|
|
8797
9009
|
Skip rebase entirely and use merge only. Rebase rewrites commit
|
|
@@ -8807,6 +9019,15 @@ class RuntimeDaemon:
|
|
|
8807
9019
|
target = f"origin/{default_branch}"
|
|
8808
9020
|
_git_name, _git_email = _resolve_git_author(task.project)
|
|
8809
9021
|
|
|
9022
|
+
if task.requirement_key:
|
|
9023
|
+
logger.info(
|
|
9024
|
+
"Skipping automatic integration of %s into requirement branch %s "
|
|
9025
|
+
"to preserve task scope and avoid cross-requirement contamination",
|
|
9026
|
+
target,
|
|
9027
|
+
task.requirement_key,
|
|
9028
|
+
)
|
|
9029
|
+
return
|
|
9030
|
+
|
|
8810
9031
|
# Fetch latest remote state
|
|
8811
9032
|
try:
|
|
8812
9033
|
await git("fetch", "origin", cwd=workspace_path,
|
|
@@ -8890,6 +9111,38 @@ class RuntimeDaemon:
|
|
|
8890
9111
|
# ── Tier 3: AI-assisted conflict resolution ──
|
|
8891
9112
|
await self._ai_resolve_conflicts(workspace_path, default_branch, task)
|
|
8892
9113
|
|
|
9114
|
+
async def _list_unpushed_merge_commits(
|
|
9115
|
+
self,
|
|
9116
|
+
workspace_path: Path,
|
|
9117
|
+
current_branch: str,
|
|
9118
|
+
default_branch: str,
|
|
9119
|
+
) -> list[str]:
|
|
9120
|
+
"""Return merge commits introduced locally and not yet on the remote branch.
|
|
9121
|
+
|
|
9122
|
+
Requirement branches must stay linear and requirement-scoped. Any new
|
|
9123
|
+
merge commit pending push indicates an explicit merge/rebase workflow that
|
|
9124
|
+
can import unrelated changes (for example, merging origin/develop into a
|
|
9125
|
+
fix branch). Those pushes are rejected by the caller.
|
|
9126
|
+
"""
|
|
9127
|
+
git = self.workspace_manager._git
|
|
9128
|
+
|
|
9129
|
+
try:
|
|
9130
|
+
await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
|
|
9131
|
+
out = await git(
|
|
9132
|
+
"rev-list", "--min-parents=2", f"origin/{current_branch}..HEAD",
|
|
9133
|
+
cwd=workspace_path,
|
|
9134
|
+
)
|
|
9135
|
+
except RuntimeError:
|
|
9136
|
+
try:
|
|
9137
|
+
out = await git(
|
|
9138
|
+
"rev-list", "--min-parents=2", "HEAD", "--not", f"origin/{default_branch}",
|
|
9139
|
+
cwd=workspace_path,
|
|
9140
|
+
)
|
|
9141
|
+
except RuntimeError:
|
|
9142
|
+
return []
|
|
9143
|
+
|
|
9144
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
9145
|
+
|
|
8893
9146
|
async def _ai_resolve_conflicts(
|
|
8894
9147
|
self, workspace_path: Path, default_branch: str, task: TaskInfo,
|
|
8895
9148
|
):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|