forgexa-cli 1.18.4__tar.gz → 1.18.6__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.18.4 → forgexa_cli-1.18.6}/PKG-INFO +1 -1
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli/daemon.py +223 -77
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/pyproject.toml +1 -1
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/README.md +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/setup.cfg +0 -0
- {forgexa_cli-1.18.4 → forgexa_cli-1.18.6}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.18.
|
|
2
|
+
__version__ = "1.18.6"
|
|
@@ -104,6 +104,34 @@ def _save_cli_tokens(access_token: str, refresh_token: str | None = None) -> Non
|
|
|
104
104
|
token_path.chmod(0o600)
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def _extract_access_token_subject(token: str | None) -> str | None:
|
|
108
|
+
raw = str(token or "").strip()
|
|
109
|
+
if not raw:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
parts = raw.split(".")
|
|
113
|
+
if len(parts) != 3:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
payload = parts[1]
|
|
117
|
+
payload += "=" * (-len(payload) % 4)
|
|
118
|
+
try:
|
|
119
|
+
data = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")))
|
|
120
|
+
except Exception:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
subject = str(data.get("sub") or "").strip()
|
|
124
|
+
if not subject:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
UUID(subject)
|
|
129
|
+
except (TypeError, ValueError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
return subject
|
|
133
|
+
|
|
134
|
+
|
|
107
135
|
def _looks_like_pipx_environment(python_path: str | None = None) -> bool:
|
|
108
136
|
candidate = (python_path or sys.executable or "").replace("\\", "/").lower()
|
|
109
137
|
return "/pipx/venvs/" in candidate
|
|
@@ -527,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
527
555
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
528
556
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
529
557
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
530
|
-
DAEMON_VERSION = "1.18.
|
|
558
|
+
DAEMON_VERSION = "1.18.6"
|
|
531
559
|
|
|
532
560
|
|
|
533
561
|
def _detect_client_type() -> str:
|
|
@@ -1313,6 +1341,17 @@ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
|
|
|
1313
1341
|
return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
|
|
1314
1342
|
|
|
1315
1343
|
|
|
1344
|
+
def _is_requirement_analysis_path(path: str) -> bool:
|
|
1345
|
+
"""Recognize v1 and v2 requirement analysis paths without backend imports."""
|
|
1346
|
+
normalized = str(path or "").replace("\\", "/").lstrip("./")
|
|
1347
|
+
return (
|
|
1348
|
+
normalized.startswith("docs/requirements/")
|
|
1349
|
+
and "/implement/" not in normalized
|
|
1350
|
+
and "/bugfix/" not in normalized
|
|
1351
|
+
and "/delivery/" not in normalized
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
|
|
1316
1355
|
# Mirrors type_workflow_profiles.py — maps req type to the expected design-phase output filename.
|
|
1317
1356
|
# Most types use design.md; spike uses research.md (produced by _DESIGN_SPIKE prompt).
|
|
1318
1357
|
_DESIGN_OUTPUT_BY_TYPE: dict[str, str] = {
|
|
@@ -3574,6 +3613,16 @@ class ProcessManager:
|
|
|
3574
3613
|
"not found the model", # decoded Kimi 404 body
|
|
3575
3614
|
"provider.api_error", # Kimi SDK error prefix
|
|
3576
3615
|
"kimi authentication required", # pre-flight oauth check sentinel
|
|
3616
|
+
# Generic auth/login failures. These are evaluated only against failure
|
|
3617
|
+
# channels, so they are safe to treat as fallback-worthy.
|
|
3618
|
+
"not logged in",
|
|
3619
|
+
"please run /login",
|
|
3620
|
+
"please log in",
|
|
3621
|
+
"authentication failed",
|
|
3622
|
+
"authentication_failed",
|
|
3623
|
+
"authentication required",
|
|
3624
|
+
"authenticate with the github",
|
|
3625
|
+
"gh auth login",
|
|
3577
3626
|
]
|
|
3578
3627
|
|
|
3579
3628
|
def __init__(self):
|
|
@@ -4015,6 +4064,17 @@ class ProcessManager:
|
|
|
4015
4064
|
return normalized
|
|
4016
4065
|
|
|
4017
4066
|
def _required_deliverable_paths(self, task: TaskInfo) -> set[str]:
|
|
4067
|
+
input_data = task.input_data or {}
|
|
4068
|
+
if input_data.get("verification_work_item"):
|
|
4069
|
+
path_by_node_type = {
|
|
4070
|
+
"analysis": input_data.get("verification_analysis_doc_path", ""),
|
|
4071
|
+
"fix": input_data.get("bugfix_doc_path", ""),
|
|
4072
|
+
"testing": input_data.get("verification_test_report_path", ""),
|
|
4073
|
+
}
|
|
4074
|
+
required_path = str(path_by_node_type.get(task.node_type, "") or "")
|
|
4075
|
+
required_path = required_path.replace("\\", "/").lstrip("./")
|
|
4076
|
+
return {required_path} if required_path else set()
|
|
4077
|
+
|
|
4018
4078
|
# For analysis nodes, deliverables live in analysis_output_dir (docs/requirements/<key>/analysis)
|
|
4019
4079
|
# For delivery nodes, deliverables live in output_dir (docs/requirements/<key>/delivery)
|
|
4020
4080
|
# For other nodes, use output_dir (docs/requirements/<key>/implement)
|
|
@@ -4354,6 +4414,14 @@ class ProcessManager:
|
|
|
4354
4414
|
]
|
|
4355
4415
|
|
|
4356
4416
|
env = os.environ.copy()
|
|
4417
|
+
for key in (
|
|
4418
|
+
"XDG_CONFIG_HOME",
|
|
4419
|
+
"XDG_DATA_HOME",
|
|
4420
|
+
"XDG_CACHE_HOME",
|
|
4421
|
+
"XDG_STATE_HOME",
|
|
4422
|
+
"CLAUDE_CODE_DEBUG_LOGS_DIR",
|
|
4423
|
+
):
|
|
4424
|
+
env.pop(key, None)
|
|
4357
4425
|
model_override = os.environ.get("FACTORY_CLAUDE_MODEL")
|
|
4358
4426
|
if model_override:
|
|
4359
4427
|
env["ANTHROPIC_MODEL"] = model_override
|
|
@@ -4519,7 +4587,8 @@ class ProcessManager:
|
|
|
4519
4587
|
"""Run OpenCode CLI in non-interactive mode.
|
|
4520
4588
|
|
|
4521
4589
|
Uses `opencode run --format json --dir <cwd>` for headless execution.
|
|
4522
|
-
The
|
|
4590
|
+
The full prompt is attached through a temporary file so it never exceeds
|
|
4591
|
+
the operating system command-line length limit.
|
|
4523
4592
|
NOTE: `--dir` is the correct flag (not `--cwd` which is invalid).
|
|
4524
4593
|
|
|
4525
4594
|
Each invocation gets an isolated XDG_DATA_HOME so that concurrent
|
|
@@ -4566,16 +4635,18 @@ class ProcessManager:
|
|
|
4566
4635
|
model_override = os.environ.get("FACTORY_OPENCODE_MODEL")
|
|
4567
4636
|
if model_override:
|
|
4568
4637
|
cmd += ["--model", model_override]
|
|
4569
|
-
# -- ensures yargs treats everything after it as positional args, not flags.
|
|
4570
|
-
# Without this, prompts containing --flag-like text cause yargs to print help and exit 1.
|
|
4571
|
-
cmd += ["--", prompt]
|
|
4572
|
-
|
|
4573
4638
|
# Isolate each opencode run in its own data directory to prevent
|
|
4574
4639
|
# concurrent processes from racing on the shared SQLite WAL file.
|
|
4575
4640
|
tmp_data_root = tempfile.mkdtemp(prefix=f"opencode-{task_id[:8]}-")
|
|
4576
4641
|
try:
|
|
4577
4642
|
isolated_data_dir = Path(tmp_data_root) / "opencode"
|
|
4578
4643
|
isolated_data_dir.mkdir()
|
|
4644
|
+
prompt_file = Path(tmp_data_root) / "instructions.md"
|
|
4645
|
+
prompt_file.write_text(prompt, encoding="utf-8")
|
|
4646
|
+
cmd += [
|
|
4647
|
+
"--file", str(prompt_file),
|
|
4648
|
+
"--", "Read and follow the attached instructions.",
|
|
4649
|
+
]
|
|
4579
4650
|
# Copy auth.json so provider credentials are available.
|
|
4580
4651
|
auth_src = Path.home() / ".local" / "share" / "opencode" / "auth.json"
|
|
4581
4652
|
if auth_src.exists():
|
|
@@ -4760,49 +4831,45 @@ class ProcessManager:
|
|
|
4760
4831
|
def _prepare_claude_environment(
|
|
4761
4832
|
sandbox_root: "Path | None" = None,
|
|
4762
4833
|
) -> dict[str, str]:
|
|
4763
|
-
"""Prepare
|
|
4834
|
+
"""Prepare an isolated Claude home and mirror stable configuration files.
|
|
4764
4835
|
|
|
4765
4836
|
When *sandbox_root* is given (per-task isolation via mkdtemp), uses that
|
|
4766
4837
|
directory. When None, falls back to the shared legacy path under ~/.forgexa.
|
|
4767
4838
|
|
|
4768
4839
|
The daemon service runs with ProtectSystem=strict and only allows
|
|
4769
|
-
writes under ~/.forgexa plus the workspace root.
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4840
|
+
writes under ~/.forgexa plus the workspace root. Redirecting HOME lets
|
|
4841
|
+
Claude use its default config, cache, and session locations under a
|
|
4842
|
+
writable task sandbox.
|
|
4843
|
+
|
|
4844
|
+
Mirror only stable authentication and settings files. Runtime state such
|
|
4845
|
+
as session-env, projects, task locks, and history must never cross into
|
|
4846
|
+
an isolated daemon invocation. Do not set CLAUDE_CODE_DEBUG_LOGS_DIR:
|
|
4847
|
+
Claude Code 2.1.63 treats a directory supplied through that variable as
|
|
4848
|
+
a file after TodoWrite and exits with EISDIR.
|
|
4777
4849
|
"""
|
|
4778
4850
|
if sandbox_root is None:
|
|
4779
4851
|
sandbox_root = Path.home() / ".forgexa" / "claude-home"
|
|
4780
|
-
config_root = sandbox_root / "config"
|
|
4781
|
-
data_root = sandbox_root / "data"
|
|
4782
|
-
cache_root = sandbox_root / "cache"
|
|
4783
|
-
state_root = sandbox_root / "state"
|
|
4784
4852
|
home_claude_dir = sandbox_root / ".claude"
|
|
4785
|
-
claude_config_dir = config_root / "claude"
|
|
4786
4853
|
|
|
4787
4854
|
for path in (
|
|
4788
|
-
config_root,
|
|
4789
|
-
data_root,
|
|
4790
|
-
cache_root,
|
|
4791
|
-
state_root,
|
|
4792
4855
|
home_claude_dir,
|
|
4793
|
-
claude_config_dir,
|
|
4794
4856
|
):
|
|
4795
4857
|
path.mkdir(parents=True, exist_ok=True)
|
|
4796
4858
|
|
|
4797
4859
|
source_files = [
|
|
4798
4860
|
(Path.home() / ".claude.json", sandbox_root / ".claude.json"),
|
|
4799
|
-
(Path.home() / ".claude.json", claude_config_dir / ".claude.json"),
|
|
4800
4861
|
(Path.home() / ".claude" / "settings.json", home_claude_dir / "settings.json"),
|
|
4801
|
-
(Path.home() / ".claude" / "settings.json",
|
|
4862
|
+
(Path.home() / ".claude" / "settings.local.json", home_claude_dir / "settings.local.json"),
|
|
4802
4863
|
]
|
|
4803
4864
|
for source, dest in source_files:
|
|
4804
4865
|
try:
|
|
4805
|
-
if source.is_file()
|
|
4866
|
+
if not source.is_file():
|
|
4867
|
+
continue
|
|
4868
|
+
if dest.is_dir():
|
|
4869
|
+
shutil.rmtree(dest)
|
|
4870
|
+
elif dest.is_file() or dest.is_symlink():
|
|
4871
|
+
dest.unlink()
|
|
4872
|
+
if not dest.exists():
|
|
4806
4873
|
shutil.copy2(source, dest)
|
|
4807
4874
|
except Exception as exc:
|
|
4808
4875
|
logger.debug(
|
|
@@ -4812,19 +4879,11 @@ class ProcessManager:
|
|
|
4812
4879
|
|
|
4813
4880
|
env = {
|
|
4814
4881
|
"HOME": str(sandbox_root),
|
|
4815
|
-
"XDG_CONFIG_HOME": str(config_root),
|
|
4816
|
-
"XDG_DATA_HOME": str(data_root),
|
|
4817
|
-
"XDG_CACHE_HOME": str(cache_root),
|
|
4818
|
-
"XDG_STATE_HOME": str(state_root),
|
|
4819
4882
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": str(
|
|
4820
4883
|
os.environ.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
|
4821
4884
|
),
|
|
4822
4885
|
}
|
|
4823
4886
|
|
|
4824
|
-
if not os.environ.get("CLAUDE_CODE_DEBUG_LOGS_DIR"):
|
|
4825
|
-
env["CLAUDE_CODE_DEBUG_LOGS_DIR"] = str(cache_root / "claude-debug")
|
|
4826
|
-
Path(env["CLAUDE_CODE_DEBUG_LOGS_DIR"]).mkdir(parents=True, exist_ok=True)
|
|
4827
|
-
|
|
4828
4887
|
return env
|
|
4829
4888
|
|
|
4830
4889
|
@staticmethod
|
|
@@ -6228,6 +6287,7 @@ class TaskPoller:
|
|
|
6228
6287
|
self._on_success = lambda: None # Callbacks set by ServerConnection
|
|
6229
6288
|
self._on_auth_failure = lambda: None
|
|
6230
6289
|
self._empty_cycles = 0
|
|
6290
|
+
self.cancelled_ai_job_ids: list[str] = []
|
|
6231
6291
|
|
|
6232
6292
|
def note_activity(self, *, had_work: bool) -> None:
|
|
6233
6293
|
if had_work:
|
|
@@ -6288,12 +6348,20 @@ class TaskPoller:
|
|
|
6288
6348
|
)
|
|
6289
6349
|
if resp.status_code == 200:
|
|
6290
6350
|
self._on_success()
|
|
6291
|
-
|
|
6351
|
+
data = resp.json()
|
|
6352
|
+
self.cancelled_ai_job_ids = [
|
|
6353
|
+
str(job_id)
|
|
6354
|
+
for job_id in data.get("cancelled_job_ids", [])
|
|
6355
|
+
if job_id
|
|
6356
|
+
]
|
|
6357
|
+
return data.get("ai_jobs", [])
|
|
6292
6358
|
elif resp.status_code in (401, 403):
|
|
6293
6359
|
self._on_auth_failure()
|
|
6360
|
+
self.cancelled_ai_job_ids = []
|
|
6294
6361
|
return []
|
|
6295
6362
|
except Exception as e:
|
|
6296
6363
|
logger.debug("AIJob poll error: %s", e)
|
|
6364
|
+
self.cancelled_ai_job_ids = []
|
|
6297
6365
|
return []
|
|
6298
6366
|
|
|
6299
6367
|
|
|
@@ -6720,17 +6788,36 @@ class RuntimeDaemon:
|
|
|
6720
6788
|
except Exception:
|
|
6721
6789
|
from jose import jwt as jwt_module
|
|
6722
6790
|
|
|
6791
|
+
token_path = Path.home() / ".forgexa" / "token"
|
|
6792
|
+
preferred_user_id = None
|
|
6793
|
+
try:
|
|
6794
|
+
if token_path.exists():
|
|
6795
|
+
preferred_user_id = _extract_access_token_subject(token_path.read_text().strip())
|
|
6796
|
+
except OSError:
|
|
6797
|
+
preferred_user_id = None
|
|
6798
|
+
|
|
6723
6799
|
async with engine.connect() as conn:
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
.
|
|
6727
|
-
|
|
6728
|
-
User.
|
|
6800
|
+
uid = None
|
|
6801
|
+
if preferred_user_id:
|
|
6802
|
+
preferred_result = await conn.execute(
|
|
6803
|
+
select(User.id)
|
|
6804
|
+
.where(User.id == UUID(preferred_user_id))
|
|
6805
|
+
.limit(1)
|
|
6729
6806
|
)
|
|
6730
|
-
.
|
|
6731
|
-
|
|
6732
|
-
|
|
6733
|
-
|
|
6807
|
+
preferred_row = preferred_result.first()
|
|
6808
|
+
uid = str(preferred_row[0]) if preferred_row else None
|
|
6809
|
+
|
|
6810
|
+
if not uid:
|
|
6811
|
+
row = await conn.execute(
|
|
6812
|
+
select(User.id)
|
|
6813
|
+
.order_by(
|
|
6814
|
+
case((User.status == "active", 0), else_=1),
|
|
6815
|
+
User.created_at,
|
|
6816
|
+
)
|
|
6817
|
+
.limit(1)
|
|
6818
|
+
)
|
|
6819
|
+
r = row.first()
|
|
6820
|
+
uid = str(r[0]) if r else None
|
|
6734
6821
|
|
|
6735
6822
|
if uid:
|
|
6736
6823
|
token = jwt_module.encode(
|
|
@@ -7051,6 +7138,19 @@ class RuntimeDaemon:
|
|
|
7051
7138
|
async def _poll_and_execute(self) -> bool:
|
|
7052
7139
|
"""Poll for tasks from all connected servers and execute them."""
|
|
7053
7140
|
had_work = False
|
|
7141
|
+
|
|
7142
|
+
def _cancel_server_requested_ai_jobs(conn: ServerConnection) -> None:
|
|
7143
|
+
for job_id in conn.poller.cancelled_ai_job_ids:
|
|
7144
|
+
ai_task_key = f"aijob_{job_id}"
|
|
7145
|
+
active_task = self.active_tasks.get(ai_task_key)
|
|
7146
|
+
if not active_task or active_task.done():
|
|
7147
|
+
continue
|
|
7148
|
+
active_process = self.process_manager.active_processes.get(job_id)
|
|
7149
|
+
if active_process:
|
|
7150
|
+
_kill_proc(active_process)
|
|
7151
|
+
active_task.cancel()
|
|
7152
|
+
logger.info("[%s] Cancelled AIJob %s at server request", conn.label, job_id)
|
|
7153
|
+
|
|
7054
7154
|
# Clean up completed tasks
|
|
7055
7155
|
for task_id in list(self.active_tasks):
|
|
7056
7156
|
if self.active_tasks[task_id].done():
|
|
@@ -7063,24 +7163,21 @@ class RuntimeDaemon:
|
|
|
7063
7163
|
if conn.heartbeat:
|
|
7064
7164
|
conn.heartbeat.update(total_active, self._agents_as_dicts())
|
|
7065
7165
|
|
|
7066
|
-
# Check capacity
|
|
7067
|
-
if total_active >= self.max_concurrent:
|
|
7068
|
-
return True
|
|
7069
|
-
|
|
7070
7166
|
# Poll from all connected servers
|
|
7071
7167
|
for conn in self.connections:
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
ai_jobs
|
|
7077
|
-
|
|
7078
|
-
|
|
7168
|
+
has_capacity = total_active < self.max_concurrent
|
|
7169
|
+
tasks = await conn.poller.poll() if has_capacity else []
|
|
7170
|
+
# Always poll AI jobs so server-requested cancellations can stop a
|
|
7171
|
+
# process that is itself consuming the last available slot.
|
|
7172
|
+
ai_jobs = await conn.poller.poll_ai_jobs()
|
|
7173
|
+
_cancel_server_requested_ai_jobs(conn)
|
|
7174
|
+
total_active = len(self.active_tasks)
|
|
7175
|
+
has_capacity = total_active < self.max_concurrent
|
|
7079
7176
|
|
|
7080
7177
|
conn.poller.note_activity(had_work=bool(tasks or ai_jobs))
|
|
7081
7178
|
had_work = had_work or bool(tasks or ai_jobs)
|
|
7082
7179
|
|
|
7083
|
-
for task in tasks:
|
|
7180
|
+
for task in tasks if has_capacity else []:
|
|
7084
7181
|
if task.task_id in self.active_tasks:
|
|
7085
7182
|
continue # Already running
|
|
7086
7183
|
|
|
@@ -7096,7 +7193,7 @@ class RuntimeDaemon:
|
|
|
7096
7193
|
)
|
|
7097
7194
|
|
|
7098
7195
|
# Poll for AIJobs (workspace-mode tasks)
|
|
7099
|
-
for aj in ai_jobs:
|
|
7196
|
+
for aj in ai_jobs if has_capacity else []:
|
|
7100
7197
|
job_id = aj.get("job_id", "")
|
|
7101
7198
|
ai_task_key = f"aijob_{job_id}"
|
|
7102
7199
|
if ai_task_key in self.active_tasks:
|
|
@@ -7110,6 +7207,9 @@ class RuntimeDaemon:
|
|
|
7110
7207
|
self._execute_ai_job(aj, conn)
|
|
7111
7208
|
)
|
|
7112
7209
|
|
|
7210
|
+
total_active = len(self.active_tasks)
|
|
7211
|
+
has_capacity = total_active < self.max_concurrent
|
|
7212
|
+
|
|
7113
7213
|
return had_work
|
|
7114
7214
|
|
|
7115
7215
|
async def _workspace_checkout_health_error(self, workspace_path: Path) -> str | None:
|
|
@@ -7744,7 +7844,11 @@ class RuntimeDaemon:
|
|
|
7744
7844
|
|
|
7745
7845
|
# 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
|
|
7746
7846
|
# Existing files from a prior iteration are not sufficient evidence.
|
|
7747
|
-
|
|
7847
|
+
requires_current_run_deliverable = task.node_type in ("analysis", "design", "fix") or (
|
|
7848
|
+
task.node_type == "testing"
|
|
7849
|
+
and bool((task.input_data or {}).get("verification_work_item"))
|
|
7850
|
+
)
|
|
7851
|
+
if result.status == "success" and requires_current_run_deliverable:
|
|
7748
7852
|
committed_git = await self.process_manager._collect_git_info_vs_parent(
|
|
7749
7853
|
workspace_path,
|
|
7750
7854
|
default_branch=default_branch,
|
|
@@ -7807,7 +7911,11 @@ class RuntimeDaemon:
|
|
|
7807
7911
|
# This closes the mirror-sync gap: review agents update analysis.json
|
|
7808
7912
|
# (open_risks, phase_handoffs) and this step ensures the content reaches
|
|
7809
7913
|
# runtimes.py as an inline artifact regardless of push success.
|
|
7810
|
-
if
|
|
7914
|
+
if (
|
|
7915
|
+
result.status == "success"
|
|
7916
|
+
and task.node_type in ("review", "coding", "testing")
|
|
7917
|
+
and not bool((task.input_data or {}).get("verification_work_item"))
|
|
7918
|
+
):
|
|
7811
7919
|
analysis_dir = (
|
|
7812
7920
|
(task.input_data or {}).get("analysis_output_dir", "")
|
|
7813
7921
|
or ""
|
|
@@ -8011,7 +8119,40 @@ class RuntimeDaemon:
|
|
|
8011
8119
|
|
|
8012
8120
|
issues: list[str] = []
|
|
8013
8121
|
node_type = task.node_type
|
|
8014
|
-
|
|
8122
|
+
input_data = task.input_data or {}
|
|
8123
|
+
req_type = input_data.get("requirement_type", "feature")
|
|
8124
|
+
verification_work_item = bool(input_data.get("verification_work_item"))
|
|
8125
|
+
|
|
8126
|
+
if verification_work_item:
|
|
8127
|
+
forbidden_paths = sorted(
|
|
8128
|
+
path
|
|
8129
|
+
for path in ProcessManager._normalize_repo_paths(result.files_changed)
|
|
8130
|
+
if _is_requirement_analysis_path(path)
|
|
8131
|
+
)
|
|
8132
|
+
if forbidden_paths:
|
|
8133
|
+
issues.append(
|
|
8134
|
+
"Verification work item modified requirement analysis assets: "
|
|
8135
|
+
+ ", ".join(forbidden_paths[:5])
|
|
8136
|
+
)
|
|
8137
|
+
|
|
8138
|
+
if verification_work_item and node_type == "analysis":
|
|
8139
|
+
analysis_doc_path = str(input_data.get("verification_analysis_doc_path", "") or "")
|
|
8140
|
+
analysis_doc_path = analysis_doc_path.replace("\\", "/").lstrip("./")
|
|
8141
|
+
analysis_doc = workspace_path / analysis_doc_path
|
|
8142
|
+
if not analysis_doc_path or not analysis_doc.exists():
|
|
8143
|
+
issues.append(f"Verification analysis record missing: {analysis_doc_path}")
|
|
8144
|
+
elif analysis_doc.stat().st_size == 0:
|
|
8145
|
+
issues.append(f"Verification analysis record is empty: {analysis_doc_path}")
|
|
8146
|
+
return issues
|
|
8147
|
+
|
|
8148
|
+
if verification_work_item and node_type == "testing":
|
|
8149
|
+
test_report_path = str(input_data.get("verification_test_report_path", "") or "")
|
|
8150
|
+
test_report_path = test_report_path.replace("\\", "/").lstrip("./")
|
|
8151
|
+
test_report = workspace_path / test_report_path
|
|
8152
|
+
if not test_report_path or not test_report.exists():
|
|
8153
|
+
issues.append(f"Verification test report missing: {test_report_path}")
|
|
8154
|
+
elif test_report.stat().st_size == 0:
|
|
8155
|
+
issues.append(f"Verification test report is empty: {test_report_path}")
|
|
8015
8156
|
|
|
8016
8157
|
if node_type == "analysis":
|
|
8017
8158
|
# Use type profile to determine required analysis outputs
|
|
@@ -8182,7 +8323,7 @@ class RuntimeDaemon:
|
|
|
8182
8323
|
pass # analysis.json corrupt — already flagged elsewhere
|
|
8183
8324
|
|
|
8184
8325
|
# Testing-specific: validate structured test assets
|
|
8185
|
-
if node_type == "testing":
|
|
8326
|
+
if node_type == "testing" and not verification_work_item:
|
|
8186
8327
|
# Determine which checks to run for this requirement type.
|
|
8187
8328
|
#
|
|
8188
8329
|
# _skip_test_artifacts = True → skip ALL artifact checks
|
|
@@ -8777,21 +8918,26 @@ class RuntimeDaemon:
|
|
|
8777
8918
|
always receives the file contents via the completion report and gate
|
|
8778
8919
|
reviewers can see the analysis documents immediately.
|
|
8779
8920
|
"""
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8921
|
+
input_data = task.input_data or {}
|
|
8922
|
+
verification_work_item = bool(input_data.get("verification_work_item"))
|
|
8923
|
+
if verification_work_item:
|
|
8924
|
+
analysis_doc_path = str(input_data.get("verification_analysis_doc_path", "") or "")
|
|
8925
|
+
candidate_paths = [analysis_doc_path] if analysis_doc_path else []
|
|
8926
|
+
else:
|
|
8927
|
+
# Analysis deliverables live in analysis_output_dir (docs/requirements/...)
|
|
8928
|
+
doc_dir = input_data.get("analysis_output_dir", "") or input_data.get("output_dir", "")
|
|
8929
|
+
if not doc_dir:
|
|
8930
|
+
return
|
|
8931
|
+
base = workspace_path / doc_dir.lstrip("./")
|
|
8932
|
+
req_type = input_data.get("requirement_type", "feature")
|
|
8933
|
+
candidate_paths = [str(base / fname) for fname in _get_analysis_outputs_for_type(req_type)]
|
|
8787
8934
|
|
|
8788
|
-
base = workspace_path / doc_dir.lstrip("./")
|
|
8789
|
-
req_type = (task.input_data or {}).get("requirement_type", "feature")
|
|
8790
|
-
_ANALYSIS_FILES = _get_analysis_outputs_for_type(req_type)
|
|
8791
8935
|
existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
|
|
8792
8936
|
|
|
8793
|
-
for
|
|
8794
|
-
fpath =
|
|
8937
|
+
for candidate_path in candidate_paths:
|
|
8938
|
+
fpath = Path(candidate_path)
|
|
8939
|
+
if not fpath.is_absolute():
|
|
8940
|
+
fpath = workspace_path / fpath
|
|
8795
8941
|
if not fpath.exists() or fpath.stat().st_size == 0:
|
|
8796
8942
|
continue
|
|
8797
8943
|
try:
|
|
@@ -8799,7 +8945,7 @@ class RuntimeDaemon:
|
|
|
8799
8945
|
if rel_path in existing_artifact_paths:
|
|
8800
8946
|
continue # already attached
|
|
8801
8947
|
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
8802
|
-
mime = "text/markdown" if
|
|
8948
|
+
mime = "text/markdown" if rel_path.endswith(".md") else "application/json"
|
|
8803
8949
|
result.artifacts.append({
|
|
8804
8950
|
"path": rel_path,
|
|
8805
8951
|
"content": content,
|
|
@@ -8809,7 +8955,7 @@ class RuntimeDaemon:
|
|
|
8809
8955
|
"Attached analysis artifact inline: %s (%d bytes)", rel_path, len(content)
|
|
8810
8956
|
)
|
|
8811
8957
|
except Exception as e:
|
|
8812
|
-
logger.warning("Failed to read analysis artifact %s: %s",
|
|
8958
|
+
logger.warning("Failed to read analysis artifact %s: %s", candidate_path, e)
|
|
8813
8959
|
|
|
8814
8960
|
async def _collect_design_artifacts(
|
|
8815
8961
|
self, workspace_path: Path, task: TaskInfo, result: TaskResult
|
|
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
|