forgexa-cli 1.14.3__tar.gz → 1.14.4__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.4}/PKG-INFO +1 -1
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli/daemon.py +233 -36
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/pyproject.toml +1 -1
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/README.md +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/setup.cfg +0 -0
- {forgexa_cli-1.14.3 → forgexa_cli-1.14.4}/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.4"
|
|
@@ -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.4"
|
|
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"]
|
|
@@ -5177,10 +5210,36 @@ class ProcessManager:
|
|
|
5177
5210
|
class ProgressReporter:
|
|
5178
5211
|
"""Reports task progress back to the server."""
|
|
5179
5212
|
|
|
5213
|
+
_MIN_HEARTBEAT_INTERVAL_SECONDS = 5.0
|
|
5214
|
+
_MIN_PROGRESS_DELTA = 5
|
|
5215
|
+
|
|
5180
5216
|
def __init__(self, client: httpx.AsyncClient, server_url: str, runtime_id: str):
|
|
5181
5217
|
self.client = client
|
|
5182
5218
|
self.server_url = server_url.rstrip("/")
|
|
5183
5219
|
self.runtime_id = runtime_id
|
|
5220
|
+
self._last_progress_sent: dict[str, dict[str, Any]] = {}
|
|
5221
|
+
|
|
5222
|
+
def _should_skip_progress(
|
|
5223
|
+
self,
|
|
5224
|
+
task_id: str,
|
|
5225
|
+
progress_percent: int,
|
|
5226
|
+
current_step: str,
|
|
5227
|
+
output_lines: list[str],
|
|
5228
|
+
files_changed: list[str],
|
|
5229
|
+
lines_added: int,
|
|
5230
|
+
lines_removed: int,
|
|
5231
|
+
) -> bool:
|
|
5232
|
+
if output_lines or files_changed or lines_added or lines_removed:
|
|
5233
|
+
return False
|
|
5234
|
+
|
|
5235
|
+
last = self._last_progress_sent.get(task_id)
|
|
5236
|
+
if not last:
|
|
5237
|
+
return False
|
|
5238
|
+
if last["current_step"] != current_step:
|
|
5239
|
+
return False
|
|
5240
|
+
if progress_percent - int(last["progress_percent"]) >= self._MIN_PROGRESS_DELTA:
|
|
5241
|
+
return False
|
|
5242
|
+
return (time.monotonic() - float(last["sent_at"])) < self._MIN_HEARTBEAT_INTERVAL_SECONDS
|
|
5184
5243
|
|
|
5185
5244
|
async def report_progress(
|
|
5186
5245
|
self,
|
|
@@ -5192,24 +5251,43 @@ class ProgressReporter:
|
|
|
5192
5251
|
lines_added: int = 0,
|
|
5193
5252
|
lines_removed: int = 0,
|
|
5194
5253
|
):
|
|
5254
|
+
output_lines = output_lines or []
|
|
5255
|
+
files_changed = files_changed or []
|
|
5256
|
+
if self._should_skip_progress(
|
|
5257
|
+
task_id,
|
|
5258
|
+
progress_percent,
|
|
5259
|
+
current_step,
|
|
5260
|
+
output_lines,
|
|
5261
|
+
files_changed,
|
|
5262
|
+
lines_added,
|
|
5263
|
+
lines_removed,
|
|
5264
|
+
):
|
|
5265
|
+
return
|
|
5266
|
+
|
|
5195
5267
|
try:
|
|
5196
5268
|
await self.client.post(
|
|
5197
5269
|
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/{task_id}/progress",
|
|
5198
5270
|
json={
|
|
5199
5271
|
"progress_percent": progress_percent,
|
|
5200
5272
|
"current_step": current_step,
|
|
5201
|
-
"output_lines": output_lines
|
|
5202
|
-
"files_changed": files_changed
|
|
5273
|
+
"output_lines": output_lines,
|
|
5274
|
+
"files_changed": files_changed,
|
|
5203
5275
|
"lines_added": lines_added,
|
|
5204
5276
|
"lines_removed": lines_removed,
|
|
5205
5277
|
},
|
|
5206
5278
|
timeout=10,
|
|
5207
5279
|
)
|
|
5280
|
+
self._last_progress_sent[task_id] = {
|
|
5281
|
+
"progress_percent": progress_percent,
|
|
5282
|
+
"current_step": current_step,
|
|
5283
|
+
"sent_at": time.monotonic(),
|
|
5284
|
+
}
|
|
5208
5285
|
except Exception as e:
|
|
5209
5286
|
logger.warning("Failed to report progress for task %s: %s", task_id, e)
|
|
5210
5287
|
|
|
5211
5288
|
async def report_complete(self, task_id: str, result: TaskResult):
|
|
5212
5289
|
"""Report task completion with retry."""
|
|
5290
|
+
self._last_progress_sent.pop(task_id, None)
|
|
5213
5291
|
payload = {
|
|
5214
5292
|
"status": result.status,
|
|
5215
5293
|
"exit_code": result.exit_code,
|
|
@@ -5403,6 +5481,8 @@ class LogUploader:
|
|
|
5403
5481
|
class TaskPoller:
|
|
5404
5482
|
"""Polls the server for assigned tasks."""
|
|
5405
5483
|
|
|
5484
|
+
_EMPTY_BACKOFF_STEPS = (0, 0, 1, 2, 3, 5)
|
|
5485
|
+
|
|
5406
5486
|
def __init__(
|
|
5407
5487
|
self,
|
|
5408
5488
|
client: httpx.AsyncClient,
|
|
@@ -5416,6 +5496,16 @@ class TaskPoller:
|
|
|
5416
5496
|
self.interval = interval
|
|
5417
5497
|
self._on_success = lambda: None # Callbacks set by ServerConnection
|
|
5418
5498
|
self._on_auth_failure = lambda: None
|
|
5499
|
+
self._empty_cycles = 0
|
|
5500
|
+
|
|
5501
|
+
def note_activity(self, *, had_work: bool) -> None:
|
|
5502
|
+
if had_work:
|
|
5503
|
+
self._empty_cycles = 0
|
|
5504
|
+
return
|
|
5505
|
+
self._empty_cycles = min(self._empty_cycles + 1, len(self._EMPTY_BACKOFF_STEPS) - 1)
|
|
5506
|
+
|
|
5507
|
+
def next_delay_seconds(self) -> int:
|
|
5508
|
+
return self.interval + self._EMPTY_BACKOFF_STEPS[self._empty_cycles]
|
|
5419
5509
|
|
|
5420
5510
|
async def poll(self) -> list[TaskInfo]:
|
|
5421
5511
|
"""Poll for tasks assigned to this daemon."""
|
|
@@ -6146,15 +6236,28 @@ class RuntimeDaemon:
|
|
|
6146
6236
|
# 3. Main loop
|
|
6147
6237
|
try:
|
|
6148
6238
|
while not self._shutdown:
|
|
6149
|
-
await self._poll_and_execute()
|
|
6150
|
-
|
|
6239
|
+
had_work = await self._poll_and_execute()
|
|
6240
|
+
next_delay = self.poll_interval
|
|
6241
|
+
if self.connections:
|
|
6242
|
+
next_delay = min(
|
|
6243
|
+
conn.poller.next_delay_seconds()
|
|
6244
|
+
for conn in self.connections
|
|
6245
|
+
if conn.poller is not None
|
|
6246
|
+
)
|
|
6247
|
+
logger.debug(
|
|
6248
|
+
"Daemon poll cycle complete (had_work=%s, next_delay=%ss)",
|
|
6249
|
+
had_work,
|
|
6250
|
+
next_delay,
|
|
6251
|
+
)
|
|
6252
|
+
await asyncio.sleep(next_delay)
|
|
6151
6253
|
except asyncio.CancelledError:
|
|
6152
6254
|
pass
|
|
6153
6255
|
finally:
|
|
6154
6256
|
await self._shutdown_gracefully()
|
|
6155
6257
|
|
|
6156
|
-
async def _poll_and_execute(self):
|
|
6258
|
+
async def _poll_and_execute(self) -> bool:
|
|
6157
6259
|
"""Poll for tasks from all connected servers and execute them."""
|
|
6260
|
+
had_work = False
|
|
6158
6261
|
# Clean up completed tasks
|
|
6159
6262
|
for task_id in list(self.active_tasks):
|
|
6160
6263
|
if self.active_tasks[task_id].done():
|
|
@@ -6169,7 +6272,7 @@ class RuntimeDaemon:
|
|
|
6169
6272
|
|
|
6170
6273
|
# Check capacity
|
|
6171
6274
|
if total_active >= self.max_concurrent:
|
|
6172
|
-
return
|
|
6275
|
+
return True
|
|
6173
6276
|
|
|
6174
6277
|
# Poll from all connected servers
|
|
6175
6278
|
for conn in self.connections:
|
|
@@ -6177,6 +6280,12 @@ class RuntimeDaemon:
|
|
|
6177
6280
|
break
|
|
6178
6281
|
|
|
6179
6282
|
tasks = await conn.poller.poll()
|
|
6283
|
+
ai_jobs: list[dict] = []
|
|
6284
|
+
if len(self.active_tasks) < self.max_concurrent:
|
|
6285
|
+
ai_jobs = await conn.poller.poll_ai_jobs()
|
|
6286
|
+
|
|
6287
|
+
conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
|
|
6288
|
+
had_work = had_work or bool(tasks or ai_jobs)
|
|
6180
6289
|
|
|
6181
6290
|
for task in tasks:
|
|
6182
6291
|
if task.task_id in self.active_tasks:
|
|
@@ -6194,21 +6303,21 @@ class RuntimeDaemon:
|
|
|
6194
6303
|
)
|
|
6195
6304
|
|
|
6196
6305
|
# Poll for AIJobs (workspace-mode tasks)
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
self.
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6306
|
+
for aj in ai_jobs:
|
|
6307
|
+
job_id = aj.get("job_id", "")
|
|
6308
|
+
ai_task_key = f"aijob_{job_id}"
|
|
6309
|
+
if ai_task_key in self.active_tasks:
|
|
6310
|
+
continue
|
|
6311
|
+
if len(self.active_tasks) >= self.max_concurrent:
|
|
6312
|
+
break
|
|
6313
|
+
logger.info("[%s] Starting AIJob %s (type=%s)",
|
|
6314
|
+
conn.label, job_id, aj.get("task_type"))
|
|
6315
|
+
self._task_connections[ai_task_key] = conn
|
|
6316
|
+
self.active_tasks[ai_task_key] = asyncio.create_task(
|
|
6317
|
+
self._execute_ai_job(aj, conn)
|
|
6318
|
+
)
|
|
6319
|
+
|
|
6320
|
+
return had_work
|
|
6212
6321
|
|
|
6213
6322
|
def _recover_timeout_from_workspace_changes(
|
|
6214
6323
|
self,
|
|
@@ -6875,12 +6984,23 @@ class RuntimeDaemon:
|
|
|
6875
6984
|
# as failed so the orchestrator can retry (transient network).
|
|
6876
6985
|
if commit_result.get("push_error"):
|
|
6877
6986
|
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
6987
|
result.status = "failed"
|
|
6883
|
-
|
|
6988
|
+
if commit_result.get("push_error_code") == "forbidden_branch_merge":
|
|
6989
|
+
logger.error(
|
|
6990
|
+
"Task %s: push blocked by requirement-branch merge protection: %s",
|
|
6991
|
+
task.task_id, push_err,
|
|
6992
|
+
)
|
|
6993
|
+
result.failure_code = "forbidden_branch_merge"
|
|
6994
|
+
result.error = (
|
|
6995
|
+
commit_result.get("push_error_display")
|
|
6996
|
+
or f"Push blocked: {push_err}"
|
|
6997
|
+
)
|
|
6998
|
+
else:
|
|
6999
|
+
logger.error(
|
|
7000
|
+
"Task %s: push failed, marking as failed so retry can attempt push again: %s",
|
|
7001
|
+
task.task_id, push_err,
|
|
7002
|
+
)
|
|
7003
|
+
result.error = f"Git push failed: {push_err}"
|
|
6884
7004
|
# Re-collect git info after commit (compare with parent)
|
|
6885
7005
|
post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
|
|
6886
7006
|
# Merge: use the pre-commit file list if post-commit is empty
|
|
@@ -7117,12 +7237,14 @@ class RuntimeDaemon:
|
|
|
7117
7237
|
|
|
7118
7238
|
elif node_type == "design":
|
|
7119
7239
|
doc_dir = (task.input_data or {}).get("output_dir", "")
|
|
7240
|
+
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
7120
7241
|
if doc_dir:
|
|
7121
|
-
|
|
7242
|
+
design_filename = _get_design_output_for_type(req_type)
|
|
7243
|
+
design_path = workspace_path / doc_dir / design_filename
|
|
7122
7244
|
if not design_path.exists():
|
|
7123
|
-
issues.append(f"Design document missing: {doc_dir}/
|
|
7245
|
+
issues.append(f"Design document missing: {doc_dir}/{design_filename}")
|
|
7124
7246
|
elif design_path.stat().st_size == 0:
|
|
7125
|
-
issues.append(f"Design document is empty: {doc_dir}/
|
|
7247
|
+
issues.append(f"Design document is empty: {doc_dir}/{design_filename}")
|
|
7126
7248
|
|
|
7127
7249
|
elif node_type in ("coding", "testing", "fix", "review"):
|
|
7128
7250
|
# Syntax-check modified Python and JS/TS files
|
|
@@ -7860,11 +7982,11 @@ class RuntimeDaemon:
|
|
|
7860
7982
|
Some agents (e.g. claude) commit changes internally, so we must
|
|
7861
7983
|
also push even when the working directory is clean.
|
|
7862
7984
|
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7985
|
+
For non-requirement branches, we may integrate the latest
|
|
7986
|
+
``origin/{default_branch}`` before push. Requirement branches are
|
|
7987
|
+
explicitly excluded because silently rebasing/merging the default
|
|
7988
|
+
branch into a requirement branch widens the change scope, pollutes gate
|
|
7989
|
+
review diffs, and can import unrelated requirements into the fix.
|
|
7868
7990
|
|
|
7869
7991
|
Returns a dict with commit/push status info (empty on success).
|
|
7870
7992
|
"""
|
|
@@ -7979,6 +8101,35 @@ class RuntimeDaemon:
|
|
|
7979
8101
|
)
|
|
7980
8102
|
return {"push_error": f"Would push to default branch {current_branch}"}
|
|
7981
8103
|
|
|
8104
|
+
if task.requirement_key and current_branch:
|
|
8105
|
+
forbidden_merges = await self._list_unpushed_merge_commits(
|
|
8106
|
+
workspace_path, current_branch, default_branch,
|
|
8107
|
+
)
|
|
8108
|
+
if forbidden_merges:
|
|
8109
|
+
short_merges = ", ".join(sha[:12] for sha in forbidden_merges[:5])
|
|
8110
|
+
logger.error(
|
|
8111
|
+
"Requirement branch %s has %d new merge commit(s) pending push [%s] — "
|
|
8112
|
+
"aborting push to prevent cross-requirement contamination",
|
|
8113
|
+
current_branch, len(forbidden_merges), short_merges,
|
|
8114
|
+
)
|
|
8115
|
+
display_error = (
|
|
8116
|
+
f"Push blocked: requirement branch '{current_branch}' contains "
|
|
8117
|
+
f"merge commit(s) pending push ({short_merges}). "
|
|
8118
|
+
"This would import default-branch or unrelated requirement changes "
|
|
8119
|
+
"into the current execution scope. Roll back or recreate this fix "
|
|
8120
|
+
"on a clean requirement branch. Do not merge, rebase, or pull the "
|
|
8121
|
+
"default branch into requirement branches."
|
|
8122
|
+
)
|
|
8123
|
+
return {
|
|
8124
|
+
"push_error_code": "forbidden_branch_merge",
|
|
8125
|
+
"push_error": (
|
|
8126
|
+
"Requirement branch contains merge commit(s) pending push: "
|
|
8127
|
+
f"{short_merges}. Merging default/other branches into "
|
|
8128
|
+
"requirement branches is forbidden."
|
|
8129
|
+
),
|
|
8130
|
+
"push_error_display": display_error,
|
|
8131
|
+
}
|
|
8132
|
+
|
|
7982
8133
|
# ── Push ──
|
|
7983
8134
|
push_error = await self._push_branch(workspace_path, project_key, task=task)
|
|
7984
8135
|
if push_error:
|
|
@@ -8792,6 +8943,11 @@ class RuntimeDaemon:
|
|
|
8792
8943
|
):
|
|
8793
8944
|
"""Integrate the current branch with ``origin/{default_branch}``.
|
|
8794
8945
|
|
|
8946
|
+
Requirement branches are intentionally excluded. They already sync to the
|
|
8947
|
+
latest remote requirement branch during workspace preparation, so pulling
|
|
8948
|
+
in ``origin/{default_branch}`` here only risks importing unrelated work
|
|
8949
|
+
into the requirement/fix scope.
|
|
8950
|
+
|
|
8795
8951
|
Strategy:
|
|
8796
8952
|
- If the current branch already exists on remote (was previously pushed):
|
|
8797
8953
|
Skip rebase entirely and use merge only. Rebase rewrites commit
|
|
@@ -8807,6 +8963,15 @@ class RuntimeDaemon:
|
|
|
8807
8963
|
target = f"origin/{default_branch}"
|
|
8808
8964
|
_git_name, _git_email = _resolve_git_author(task.project)
|
|
8809
8965
|
|
|
8966
|
+
if task.requirement_key:
|
|
8967
|
+
logger.info(
|
|
8968
|
+
"Skipping automatic integration of %s into requirement branch %s "
|
|
8969
|
+
"to preserve task scope and avoid cross-requirement contamination",
|
|
8970
|
+
target,
|
|
8971
|
+
task.requirement_key,
|
|
8972
|
+
)
|
|
8973
|
+
return
|
|
8974
|
+
|
|
8810
8975
|
# Fetch latest remote state
|
|
8811
8976
|
try:
|
|
8812
8977
|
await git("fetch", "origin", cwd=workspace_path,
|
|
@@ -8890,6 +9055,38 @@ class RuntimeDaemon:
|
|
|
8890
9055
|
# ── Tier 3: AI-assisted conflict resolution ──
|
|
8891
9056
|
await self._ai_resolve_conflicts(workspace_path, default_branch, task)
|
|
8892
9057
|
|
|
9058
|
+
async def _list_unpushed_merge_commits(
|
|
9059
|
+
self,
|
|
9060
|
+
workspace_path: Path,
|
|
9061
|
+
current_branch: str,
|
|
9062
|
+
default_branch: str,
|
|
9063
|
+
) -> list[str]:
|
|
9064
|
+
"""Return merge commits introduced locally and not yet on the remote branch.
|
|
9065
|
+
|
|
9066
|
+
Requirement branches must stay linear and requirement-scoped. Any new
|
|
9067
|
+
merge commit pending push indicates an explicit merge/rebase workflow that
|
|
9068
|
+
can import unrelated changes (for example, merging origin/develop into a
|
|
9069
|
+
fix branch). Those pushes are rejected by the caller.
|
|
9070
|
+
"""
|
|
9071
|
+
git = self.workspace_manager._git
|
|
9072
|
+
|
|
9073
|
+
try:
|
|
9074
|
+
await git("rev-parse", "--verify", f"origin/{current_branch}", cwd=workspace_path)
|
|
9075
|
+
out = await git(
|
|
9076
|
+
"rev-list", "--min-parents=2", f"origin/{current_branch}..HEAD",
|
|
9077
|
+
cwd=workspace_path,
|
|
9078
|
+
)
|
|
9079
|
+
except RuntimeError:
|
|
9080
|
+
try:
|
|
9081
|
+
out = await git(
|
|
9082
|
+
"rev-list", "--min-parents=2", "HEAD", "--not", f"origin/{default_branch}",
|
|
9083
|
+
cwd=workspace_path,
|
|
9084
|
+
)
|
|
9085
|
+
except RuntimeError:
|
|
9086
|
+
return []
|
|
9087
|
+
|
|
9088
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
9089
|
+
|
|
8893
9090
|
async def _ai_resolve_conflicts(
|
|
8894
9091
|
self, workspace_path: Path, default_branch: str, task: TaskInfo,
|
|
8895
9092
|
):
|
|
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
|