forgexa-cli 1.48.1__tar.gz → 1.48.3__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.
Files changed (28) hide show
  1. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/PKG-INFO +1 -1
  2. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/daemon.py +621 -16
  4. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/pyproject.toml +1 -1
  6. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/README.md +0 -0
  7. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/_local_bind.py +0 -0
  9. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/agent_core.py +0 -0
  10. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  15. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  16. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
  17. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/requires.txt +0 -0
  18. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/top_level.txt +0 -0
  19. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/setup.cfg +0 -0
  20. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_auth_and_runtime_commands.py +0 -0
  21. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_silent_install.py +0 -0
  28. {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.48.1
3
+ Version: 1.48.3
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.48.1"
2
+ __version__ = "1.48.3"
@@ -1120,7 +1120,7 @@ except (ImportError, ModuleNotFoundError):
1120
1120
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1121
1121
  # Kept in sync with pyproject.toml version via bump-version.sh.
1122
1122
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1123
- DAEMON_VERSION = "1.48.1"
1123
+ DAEMON_VERSION = "1.48.3"
1124
1124
 
1125
1125
 
1126
1126
  def _detect_client_type() -> str:
@@ -5189,6 +5189,10 @@ class _IdleTimeoutError(asyncio.TimeoutError):
5189
5189
  self.stderr = ""
5190
5190
 
5191
5191
 
5192
+ class _TaskAttachmentDownloadError(RuntimeError):
5193
+ """Raised when a required task attachment cannot be staged safely."""
5194
+
5195
+
5192
5196
  def _workspace_has_recent_activity(
5193
5197
  workspace_path: "Path", since_monotonic: float, max_depth: int = 4
5194
5198
  ) -> bool:
@@ -5444,18 +5448,22 @@ class ProcessManager:
5444
5448
  return "unable to run"
5445
5449
 
5446
5450
  @staticmethod
5447
- def is_silent_idle_timeout(result: "TaskResult") -> bool:
5448
- """Return whether an agent was killed before producing any evidence.
5449
-
5450
- A timeout after output or workspace changes can represent completed work
5451
- and must be handled by the normal recovery path. A completely silent
5452
- timeout instead means this specific CLI is stuck before it can begin,
5453
- so one alternate agent is safe to try.
5451
+ def is_idle_timeout_without_work(result: "TaskResult") -> bool:
5452
+ """Return whether an idle-killed agent left no work worth preserving.
5453
+
5454
+ The deciding evidence is workspace output (``files_changed``), NOT
5455
+ stdout/stderr chatter: an agent can emit an intro sentence and a
5456
+ todowrite event and then stall forever on a hung model request
5457
+ (SI-1453: OpenCode produced 2 output lines in 39 minutes, zero file
5458
+ changes). Streamed text is conversation, not work, so it must not
5459
+ block recovery. When no files were changed, trying an alternate
5460
+ agent is safe: the pre-fallback guard in the task runner re-checks
5461
+ the workspace (uncommitted AND committed changes since
5462
+ node_before_sha) before switching agents, and timeouts that occurred
5463
+ after real work still go through the normal recovery path.
5454
5464
  """
5455
5465
  return (
5456
5466
  result.failure_code == "agent_idle_timeout"
5457
- and not result.stdout.strip()
5458
- and not result.stderr.strip()
5459
5467
  and not result.files_changed
5460
5468
  )
5461
5469
 
@@ -8220,6 +8228,7 @@ class ServerConnection:
8220
8228
  "platform": platform.platform(),
8221
8229
  "python_version": platform.python_version(),
8222
8230
  "local_workspace_bindings": {"protocol_version": 3},
8231
+ "task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True},
8223
8232
  },
8224
8233
  "server_url": self.server_url,
8225
8234
  "expires_in_days": 90,
@@ -8619,7 +8628,7 @@ class ServerConnection:
8619
8628
  "python_version": platform.python_version(),
8620
8629
  "local_workspace_bindings": {"protocol_version": 3},
8621
8630
  "local_evidence": {"protocol_version": 1, "structured_summary": True},
8622
- "local_evidence": {"protocol_version": 1, "structured_summary": True},
8631
+ "task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True},
8623
8632
  },
8624
8633
  "server_url": self.server_url,
8625
8634
  }
@@ -8955,6 +8964,261 @@ def _validate_test_evidence(
8955
8964
  return issues
8956
8965
 
8957
8966
 
8967
+ _QA_TEST_LEVELS = frozenset({"unit", "integration", "e2e"})
8968
+
8969
+
8970
+ def _parse_frozen_qa_test_level_policy(
8971
+ snapshot: object,
8972
+ ) -> tuple[dict[str, Any] | None, list[str]]:
8973
+ """Parse a server-owned policy snapshot without backend-package imports.
8974
+
8975
+ The daemon also runs standalone in CLI/Desktop bundles, so this mirrors the
8976
+ server contract with stdlib-only validation rather than importing a backend
8977
+ service. A missing snapshot is a historical task and intentionally keeps
8978
+ legacy validation behavior.
8979
+ """
8980
+ if snapshot is None:
8981
+ return None, []
8982
+ if not isinstance(snapshot, dict):
8983
+ return None, [
8984
+ "qa_policy.configuration_invalid: frozen QA test-level policy snapshot must be an object."
8985
+ ]
8986
+ policy = snapshot.get("policy")
8987
+ if not isinstance(policy, dict):
8988
+ return None, [
8989
+ "qa_policy.configuration_invalid: frozen QA test-level policy snapshot is missing its policy."
8990
+ ]
8991
+ expected_fingerprint = hashlib.sha256(
8992
+ json.dumps(policy, sort_keys=True, separators=(",", ":")).encode("utf-8")
8993
+ ).hexdigest()
8994
+ if snapshot.get("fingerprint") != expected_fingerprint:
8995
+ return None, [
8996
+ "qa_policy.configuration_invalid: frozen QA test-level policy fingerprint does not match its payload."
8997
+ ]
8998
+ if set(policy) != {"schema_version", "enforcement", "levels"}:
8999
+ return None, [
9000
+ "qa_policy.configuration_invalid: frozen QA test-level policy has unexpected fields."
9001
+ ]
9002
+ if type(policy.get("schema_version")) is not int or policy["schema_version"] != 1:
9003
+ return None, [
9004
+ "qa_policy.configuration_invalid: frozen QA test-level policy schema_version is unsupported."
9005
+ ]
9006
+ enforcement = policy.get("enforcement")
9007
+ if enforcement not in {"legacy", "enforce"}:
9008
+ return None, [
9009
+ "qa_policy.configuration_invalid: frozen QA test-level policy enforcement is invalid."
9010
+ ]
9011
+ levels = policy.get("levels")
9012
+ if not isinstance(levels, dict) or set(levels) != _QA_TEST_LEVELS:
9013
+ return None, [
9014
+ "qa_policy.configuration_invalid: frozen QA test-level policy levels are invalid."
9015
+ ]
9016
+
9017
+ normalized_levels: dict[str, dict[str, bool]] = {}
9018
+ for level in _QA_TEST_LEVELS:
9019
+ config = levels.get(level)
9020
+ if not isinstance(config, dict) or set(config) != {"enabled", "workflow_auto_run"}:
9021
+ return None, [
9022
+ "qa_policy.configuration_invalid: frozen QA test-level policy level configuration is invalid."
9023
+ ]
9024
+ enabled = config.get("enabled")
9025
+ auto_run = config.get("workflow_auto_run")
9026
+ if type(enabled) is not bool or type(auto_run) is not bool:
9027
+ return None, [
9028
+ "qa_policy.configuration_invalid: frozen QA test-level policy switches must be boolean."
9029
+ ]
9030
+ if auto_run and not enabled:
9031
+ return None, [
9032
+ "qa_policy.configuration_invalid: workflow_auto_run requires an enabled test level."
9033
+ ]
9034
+ normalized_levels[level] = {
9035
+ "enabled": enabled,
9036
+ "workflow_auto_run": auto_run,
9037
+ }
9038
+ return {
9039
+ "enforcement": enforcement,
9040
+ "fingerprint": expected_fingerprint,
9041
+ "levels": normalized_levels,
9042
+ }, []
9043
+
9044
+
9045
+ def _validate_enforced_qa_test_level_artifacts(
9046
+ policy: dict[str, Any],
9047
+ test_intent_data: object,
9048
+ test_cases_data: object,
9049
+ workspace_path: Path,
9050
+ ) -> list[str]:
9051
+ """Validate a testing node's artifacts against its frozen policy snapshot."""
9052
+ if policy.get("enforcement") != "enforce":
9053
+ return []
9054
+
9055
+ issues: list[str] = []
9056
+ if not isinstance(test_intent_data, dict):
9057
+ return [
9058
+ "qa_policy.test_intent_missing: enforced QA policy requires test-intent.json."
9059
+ ]
9060
+ declared_fingerprint = test_intent_data.get("qa_test_level_policy_fingerprint")
9061
+ if not isinstance(declared_fingerprint, str) or not declared_fingerprint.strip():
9062
+ issues.append(
9063
+ "qa_policy.fingerprint_missing: test-intent.json must echo the frozen QA policy fingerprint."
9064
+ )
9065
+ elif declared_fingerprint.strip() != policy["fingerprint"]:
9066
+ issues.append(
9067
+ "qa_policy.fingerprint_mismatch: test-intent.json does not match the frozen QA policy."
9068
+ )
9069
+
9070
+ raw_intents = test_intent_data.get("intents")
9071
+ if not isinstance(raw_intents, list):
9072
+ return issues + [
9073
+ "qa_policy.test_intent_invalid: test-intent.json intents must be an array."
9074
+ ]
9075
+
9076
+ automated_intent_ids: dict[str, set[str]] = {
9077
+ level: set() for level in _QA_TEST_LEVELS
9078
+ }
9079
+ manual_intent_reasons: dict[str, str] = {}
9080
+ for index, raw_intent in enumerate(raw_intents[:200]):
9081
+ if not isinstance(raw_intent, dict):
9082
+ issues.append(f"qa_policy.test_intent_invalid: intent {index} must be an object.")
9083
+ continue
9084
+ intent_id = raw_intent.get("id") or raw_intent.get("test_id") or raw_intent.get("intent_id")
9085
+ if not isinstance(intent_id, str) or not intent_id.strip():
9086
+ issues.append(f"qa_policy.test_intent_invalid: intent {index} is missing id.")
9087
+ continue
9088
+ intent_id = intent_id.strip()
9089
+ raw_target = raw_intent.get("automation_target")
9090
+ target = raw_target.strip().lower() if isinstance(raw_target, str) else ""
9091
+ if target == "manual":
9092
+ reason = raw_intent.get("manual_reason_code")
9093
+ if not isinstance(reason, str) or not reason.strip():
9094
+ issues.append(
9095
+ f"qa_policy.manual_reason_code_missing: manual intent {intent_id} must define manual_reason_code."
9096
+ )
9097
+ else:
9098
+ manual_intent_reasons[intent_id] = reason.strip()
9099
+ elif target not in _QA_TEST_LEVELS:
9100
+ issues.append(
9101
+ f"qa_policy.test_level_unclassified: intent {intent_id} has unsupported automation_target {target!r}."
9102
+ )
9103
+ elif not policy["levels"][target]["enabled"]:
9104
+ issues.append(
9105
+ f"qa_policy.level_disabled: intent {intent_id} uses disabled automation_target {target!r}."
9106
+ )
9107
+ else:
9108
+ automated_intent_ids[target].add(intent_id)
9109
+
9110
+ if not isinstance(test_cases_data, dict):
9111
+ return issues + [
9112
+ "qa_policy.test_cases_missing: enforced QA policy requires test-cases.json."
9113
+ ]
9114
+ raw_cases = test_cases_data.get("test_cases")
9115
+ if not isinstance(raw_cases, list):
9116
+ return issues + [
9117
+ "qa_policy.test_cases_invalid: test-cases.json test_cases must be an array."
9118
+ ]
9119
+
9120
+ covered_automated_intents: dict[str, set[str]] = {
9121
+ level: set() for level in _QA_TEST_LEVELS
9122
+ }
9123
+ covered_manual_intents: set[str] = set()
9124
+ for index, raw_case in enumerate(raw_cases[:200]):
9125
+ if not isinstance(raw_case, dict):
9126
+ issues.append(f"qa_policy.test_case_invalid: test case {index} must be an object.")
9127
+ continue
9128
+ case_id = raw_case.get("id")
9129
+ case_label = str(case_id).strip() if case_id else f"index {index}"
9130
+ raw_type = raw_case.get("test_type")
9131
+ case_type = raw_type.strip().lower() if isinstance(raw_type, str) else ""
9132
+ intent_id = raw_case.get("intent_id") or raw_case.get("test_intent_id")
9133
+ intent_id = intent_id.strip() if isinstance(intent_id, str) else ""
9134
+
9135
+ if case_type == "manual":
9136
+ if intent_id not in manual_intent_reasons:
9137
+ issues.append(
9138
+ f"qa_policy.manual_case_intent_mismatch: manual test case {case_label} must reference a manual intent."
9139
+ )
9140
+ else:
9141
+ covered_manual_intents.add(intent_id)
9142
+ reason = raw_case.get("manual_reason_code")
9143
+ if not isinstance(reason, str) or reason.strip() != manual_intent_reasons[intent_id]:
9144
+ issues.append(
9145
+ f"qa_policy.manual_reason_code_mismatch: manual test case {case_label} must preserve its intent reason."
9146
+ )
9147
+ if not isinstance(raw_case.get("acceptance_criteria_refs"), list) or not raw_case["acceptance_criteria_refs"]:
9148
+ issues.append(
9149
+ f"qa_policy.manual_case_missing_acceptance_refs: manual test case {case_label} must list acceptance_criteria_refs."
9150
+ )
9151
+ if not isinstance(raw_case.get("steps"), list) or not raw_case["steps"]:
9152
+ issues.append(
9153
+ f"qa_policy.manual_case_missing_steps: manual test case {case_label} must define executable steps."
9154
+ )
9155
+ if raw_case.get("script_path") or str(raw_case.get("script_content") or "").strip():
9156
+ issues.append(
9157
+ f"qa_policy.manual_case_has_script: manual test case {case_label} must not define a script."
9158
+ )
9159
+ continue
9160
+
9161
+ if case_type not in _QA_TEST_LEVELS:
9162
+ issues.append(
9163
+ f"qa_policy.test_level_unclassified: test case {case_label} has unsupported test_type {case_type!r}."
9164
+ )
9165
+ continue
9166
+ if not policy["levels"][case_type]["enabled"]:
9167
+ issues.append(
9168
+ f"qa_policy.level_disabled: test case {case_label} uses disabled test_type {case_type!r}."
9169
+ )
9170
+ continue
9171
+ if intent_id:
9172
+ covered_automated_intents[case_type].add(intent_id)
9173
+
9174
+ runner_family = raw_case.get("runner_family")
9175
+ runner = runner_family.strip().lower() if isinstance(runner_family, str) else ""
9176
+ if not runner:
9177
+ issues.append(
9178
+ f"qa_policy.runner_family_missing: automated test case {case_label} must define runner_family."
9179
+ )
9180
+ elif (case_type == "e2e" and runner != "playwright") or (
9181
+ case_type in {"unit", "integration"} and runner == "playwright"
9182
+ ):
9183
+ issues.append(
9184
+ f"qa_policy.runner_level_mismatch: test case {case_label} runner_family {runner!r} conflicts with test_type {case_type!r}."
9185
+ )
9186
+
9187
+ script_content = raw_case.get("script_content")
9188
+ if not isinstance(script_content, str) or not script_content.strip():
9189
+ issues.append(
9190
+ f"qa_policy.script_content_missing: automated test case {case_label} must define non-empty script_content."
9191
+ )
9192
+ script_path = raw_case.get("script_path")
9193
+ if not isinstance(script_path, str) or not script_path.strip():
9194
+ issues.append(
9195
+ f"qa_policy.script_path_missing: automated test case {case_label} must define script_path."
9196
+ )
9197
+ else:
9198
+ candidate_path = Path(script_path)
9199
+ if candidate_path.is_absolute() or ".." in candidate_path.parts:
9200
+ issues.append(
9201
+ f"qa_policy.script_path_invalid: automated test case {case_label} script_path must stay inside the workspace."
9202
+ )
9203
+ elif not (workspace_path / candidate_path).is_file():
9204
+ issues.append(
9205
+ f"qa_policy.script_path_missing: automated test case {case_label} script_path '{script_path}' does not exist on disk."
9206
+ )
9207
+
9208
+ for level, intent_ids in automated_intent_ids.items():
9209
+ missing = sorted(intent_ids - covered_automated_intents[level])
9210
+ if missing:
9211
+ issues.append(
9212
+ f"qa_policy.test_case_missing: test-cases.json lacks {level} test cases for intents {missing}."
9213
+ )
9214
+ missing_manual = sorted(set(manual_intent_reasons) - covered_manual_intents)
9215
+ if missing_manual:
9216
+ issues.append(
9217
+ f"qa_policy.manual_case_missing: test-cases.json lacks manual test cases for intents {missing_manual}."
9218
+ )
9219
+ return issues
9220
+
9221
+
8958
9222
  def _configured_daemon_api_token() -> str:
8959
9223
  token = str(settings.DAEMON_API_TOKEN or "").strip()
8960
9224
  if token.startswith("pat_"):
@@ -8998,6 +9262,10 @@ class RuntimeDaemon:
8998
9262
  """
8999
9263
 
9000
9264
  AGENT_REFRESH_INTERVAL_SECONDS = 60
9265
+ _TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS = 120
9266
+ _TASK_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
9267
+ _TASK_ATTACHMENT_MAX_COUNT = 20
9268
+ _TASK_ATTACHMENT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
9001
9269
 
9002
9270
  def __init__(self):
9003
9271
  # Compute stable hardware fingerprint from machine-id / MAC address.
@@ -9189,6 +9457,296 @@ class RuntimeDaemon:
9189
9457
 
9190
9458
  target_dir.mkdir(parents=True, exist_ok=True)
9191
9459
 
9460
+ @staticmethod
9461
+ def _task_attachments_to_stage(task: TaskInfo) -> list[dict]:
9462
+ input_data = task.input_data if isinstance(task.input_data, dict) else {}
9463
+ attachment_groups = [input_data.get("attachments")]
9464
+ context = input_data.get("context")
9465
+ if isinstance(context, dict):
9466
+ attachment_groups.append(context.get("attachments"))
9467
+ fix_context = input_data.get("fix_context")
9468
+ if isinstance(fix_context, dict):
9469
+ attachment_groups.append(fix_context.get("gate_attachments"))
9470
+
9471
+ attachments: list[dict] = []
9472
+ attachments_by_artifact_id: dict[str, dict] = {}
9473
+ attachments_by_url: dict[str, dict] = {}
9474
+ for group in attachment_groups:
9475
+ if not isinstance(group, list):
9476
+ continue
9477
+ for attachment in group:
9478
+ if not isinstance(attachment, dict):
9479
+ continue
9480
+ artifact_id = str(attachment.get("artifact_id") or attachment.get("id") or "").strip()
9481
+ raw_download_url = attachment.get("download_url")
9482
+ download_url = raw_download_url.strip() if isinstance(raw_download_url, str) else ""
9483
+ if not artifact_id and not download_url:
9484
+ continue
9485
+ existing = (
9486
+ attachments_by_artifact_id.get(artifact_id)
9487
+ if artifact_id else None
9488
+ ) or (attachments_by_url.get(download_url) if download_url else None)
9489
+ if existing is None:
9490
+ existing = dict(attachment)
9491
+ attachments.append(existing)
9492
+ else:
9493
+ for field, value in attachment.items():
9494
+ if value and not existing.get(field):
9495
+ existing[field] = value
9496
+ existing_artifact_id = str(
9497
+ existing.get("artifact_id") or existing.get("id") or ""
9498
+ ).strip()
9499
+ if existing_artifact_id:
9500
+ attachments_by_artifact_id[existing_artifact_id] = existing
9501
+ if download_url:
9502
+ attachments_by_url[download_url] = existing
9503
+
9504
+ def _artifact_id_is_valid(attachment: dict) -> bool:
9505
+ try:
9506
+ UUID(str(attachment.get("artifact_id") or attachment.get("id") or ""))
9507
+ return True
9508
+ except (TypeError, ValueError, AttributeError):
9509
+ return False
9510
+
9511
+ def _legacy_key(attachment: dict) -> tuple[str, str, int]:
9512
+ try:
9513
+ size_bytes = int(attachment.get("size_bytes") or 0)
9514
+ except (TypeError, ValueError):
9515
+ size_bytes = 0
9516
+ return (
9517
+ str(attachment.get("file_path") or attachment.get("file_name") or ""),
9518
+ str(attachment.get("mime_type") or ""),
9519
+ size_bytes,
9520
+ )
9521
+
9522
+ identified_by_key: dict[tuple[str, str, int], list[dict]] = {}
9523
+ for attachment in attachments:
9524
+ if _artifact_id_is_valid(attachment):
9525
+ identified_by_key.setdefault(_legacy_key(attachment), []).append(attachment)
9526
+
9527
+ deduplicated: list[dict] = []
9528
+ for attachment in attachments:
9529
+ if _artifact_id_is_valid(attachment):
9530
+ deduplicated.append(attachment)
9531
+ continue
9532
+ candidates = identified_by_key.get(_legacy_key(attachment), [])
9533
+ if len(candidates) != 1:
9534
+ deduplicated.append(attachment)
9535
+ continue
9536
+ identified = candidates[0]
9537
+ for field, value in attachment.items():
9538
+ if value and not identified.get(field):
9539
+ identified[field] = value
9540
+ return deduplicated
9541
+
9542
+ @staticmethod
9543
+ def _task_attachment_filename(attachment: dict, position: int) -> str:
9544
+ raw_name = str(
9545
+ attachment.get("file_path") or attachment.get("file_name") or "attachment"
9546
+ )
9547
+ file_name = Path(raw_name.replace("\\", "/")).name
9548
+ file_name = re.sub(r"[^A-Za-z0-9._-]+", "_", file_name).strip("._")
9549
+ return f"{position:02d}-{file_name[:160] or 'attachment'}"
9550
+
9551
+ @staticmethod
9552
+ def _rewrite_staged_attachment_references(
9553
+ prompt: str,
9554
+ staged: list[tuple[str, str, str]],
9555
+ ) -> str:
9556
+ if not staged:
9557
+ return prompt
9558
+
9559
+ local_files = []
9560
+ for file_name, download_url, local_path in staged:
9561
+ if download_url:
9562
+ command_pattern = r"`curl -L '" + re.escape(download_url) + r"' -o /tmp/[^`]+`"
9563
+ prompt = re.sub(command_pattern, lambda _match, path=local_path: f"`{path}`", prompt)
9564
+ prompt = prompt.replace(f"Download: {download_url}", f"Staged local file: `{local_path}`")
9565
+ prompt = prompt.replace(download_url, local_path)
9566
+ local_files.append(f"- `{local_path}` ({file_name})")
9567
+
9568
+ preamble = "\n".join([
9569
+ "## Runtime-Prepared Attachments",
9570
+ "The runtime downloaded these files before this task began. Use the local copies directly; do not download attachment URLs yourself.",
9571
+ *local_files,
9572
+ ])
9573
+ return f"{preamble}\n\n{prompt}" if prompt else preamble
9574
+
9575
+ async def _download_task_attachment(
9576
+ self,
9577
+ client: httpx.AsyncClient,
9578
+ url: str,
9579
+ destination: Path,
9580
+ *,
9581
+ attachment_name: str,
9582
+ expected_size: int | None,
9583
+ expected_checksum: str | None,
9584
+ max_bytes: int,
9585
+ timeout_seconds: float,
9586
+ ) -> None:
9587
+ parsed_url = urlparse(url)
9588
+ if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
9589
+ raise _TaskAttachmentDownloadError(
9590
+ f"Attachment '{attachment_name}' has an unsupported download URL"
9591
+ )
9592
+ if expected_size is not None and expected_size < 0:
9593
+ raise _TaskAttachmentDownloadError(
9594
+ f"Attachment '{attachment_name}' has an invalid size"
9595
+ )
9596
+ if expected_size is not None and expected_size > max_bytes:
9597
+ raise _TaskAttachmentDownloadError(
9598
+ f"Attachment '{attachment_name}' exceeds the task download budget"
9599
+ )
9600
+ if expected_checksum and not re.fullmatch(r"[0-9a-fA-F]{64}", expected_checksum):
9601
+ raise _TaskAttachmentDownloadError(
9602
+ f"Attachment '{attachment_name}' has an invalid integrity checksum"
9603
+ )
9604
+
9605
+ temporary_path = destination.with_name(f"{destination.name}.part")
9606
+
9607
+ async def download() -> None:
9608
+ try:
9609
+ async with client.stream(
9610
+ "GET",
9611
+ url,
9612
+ timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
9613
+ ) as response:
9614
+ if not 200 <= response.status_code < 300:
9615
+ raise _TaskAttachmentDownloadError(
9616
+ f"Attachment '{attachment_name}' download failed with HTTP {response.status_code}"
9617
+ )
9618
+ content_length = response.headers.get("content-length")
9619
+ if content_length:
9620
+ try:
9621
+ if int(content_length) > max_bytes:
9622
+ raise _TaskAttachmentDownloadError(
9623
+ f"Attachment '{attachment_name}' exceeds the task download budget"
9624
+ )
9625
+ except ValueError:
9626
+ pass
9627
+
9628
+ total_bytes = 0
9629
+ digest = hashlib.sha256()
9630
+ with temporary_path.open("wb") as output:
9631
+ try:
9632
+ os.chmod(temporary_path, 0o600)
9633
+ except OSError:
9634
+ pass
9635
+ async for chunk in response.aiter_bytes():
9636
+ total_bytes += len(chunk)
9637
+ if total_bytes > max_bytes:
9638
+ raise _TaskAttachmentDownloadError(
9639
+ f"Attachment '{attachment_name}' exceeds the task download budget"
9640
+ )
9641
+ output.write(chunk)
9642
+ digest.update(chunk)
9643
+ if expected_checksum:
9644
+ if not secrets.compare_digest(digest.hexdigest(), expected_checksum.lower()):
9645
+ raise _TaskAttachmentDownloadError(
9646
+ f"Attachment '{attachment_name}' failed its integrity check"
9647
+ )
9648
+ temporary_path.replace(destination)
9649
+ except _TaskAttachmentDownloadError:
9650
+ raise
9651
+ except httpx.HTTPError as exc:
9652
+ raise _TaskAttachmentDownloadError(
9653
+ f"Attachment '{attachment_name}' download failed ({type(exc).__name__})"
9654
+ ) from exc
9655
+ except OSError as exc:
9656
+ raise _TaskAttachmentDownloadError(
9657
+ f"Attachment '{attachment_name}' could not be saved ({exc})"
9658
+ ) from exc
9659
+
9660
+ try:
9661
+ await asyncio.wait_for(
9662
+ download(),
9663
+ timeout=timeout_seconds,
9664
+ )
9665
+ except asyncio.TimeoutError as exc:
9666
+ raise _TaskAttachmentDownloadError(
9667
+ f"Attachment '{attachment_name}' download timed out after "
9668
+ f"{timeout_seconds:.0f}s"
9669
+ ) from exc
9670
+ finally:
9671
+ temporary_path.unlink(missing_ok=True)
9672
+
9673
+ async def _stage_task_attachments(
9674
+ self,
9675
+ task: TaskInfo,
9676
+ conn: ServerConnection,
9677
+ ) -> Path | None:
9678
+ attachments = self._task_attachments_to_stage(task)
9679
+ if not attachments:
9680
+ return None
9681
+ if len(attachments) > self._TASK_ATTACHMENT_MAX_COUNT:
9682
+ raise _TaskAttachmentDownloadError(
9683
+ f"Task has {len(attachments)} attachments; the runtime limit is "
9684
+ f"{self._TASK_ATTACHMENT_MAX_COUNT}"
9685
+ )
9686
+
9687
+ staging_dir = Path(tempfile.mkdtemp(prefix=f"forgexa-attachments-{task.task_id[:8]}-"))
9688
+ staged: list[tuple[str, str, str]] = []
9689
+ deadline = time.monotonic() + self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS
9690
+ remaining_bytes = self._TASK_ATTACHMENT_MAX_TOTAL_BYTES
9691
+ try:
9692
+ for position, attachment in enumerate(attachments, start=1):
9693
+ file_name = str(
9694
+ attachment.get("file_path") or attachment.get("file_name") or "attachment"
9695
+ )
9696
+ download_url = str(attachment.get("download_url") or "")
9697
+ destination = staging_dir / self._task_attachment_filename(attachment, position)
9698
+ try:
9699
+ expected_size = int(attachment.get("size_bytes") or 0)
9700
+ except (TypeError, ValueError):
9701
+ expected_size = 0
9702
+ if expected_size > remaining_bytes:
9703
+ raise _TaskAttachmentDownloadError(
9704
+ f"Attachment '{file_name}' exceeds the task download budget"
9705
+ )
9706
+ artifact_id = str(attachment.get("artifact_id") or attachment.get("id") or "")
9707
+ try:
9708
+ UUID(artifact_id)
9709
+ except (TypeError, ValueError, AttributeError):
9710
+ raise _TaskAttachmentDownloadError(
9711
+ f"Attachment '{file_name}' cannot be staged securely because it has no artifact ID"
9712
+ )
9713
+ remaining_seconds = deadline - time.monotonic()
9714
+ if remaining_seconds <= 0:
9715
+ raise _TaskAttachmentDownloadError(
9716
+ f"Task attachment staging timed out after "
9717
+ f"{self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS}s"
9718
+ )
9719
+ if not conn.runtime_id:
9720
+ raise _TaskAttachmentDownloadError(
9721
+ f"Attachment '{file_name}' cannot be staged because this runtime is not registered"
9722
+ )
9723
+ source_url = (
9724
+ f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/"
9725
+ f"tasks/{task.task_id}/attachments/{artifact_id}"
9726
+ )
9727
+ await self._download_task_attachment(
9728
+ conn.client,
9729
+ source_url,
9730
+ destination,
9731
+ attachment_name=file_name,
9732
+ expected_size=expected_size or None,
9733
+ expected_checksum=str(attachment.get("checksum") or ""),
9734
+ max_bytes=min(self._TASK_ATTACHMENT_MAX_BYTES, remaining_bytes),
9735
+ timeout_seconds=remaining_seconds,
9736
+ )
9737
+ remaining_bytes -= destination.stat().st_size
9738
+ staged.append((file_name, download_url, str(destination)))
9739
+ except Exception:
9740
+ shutil.rmtree(staging_dir, ignore_errors=True)
9741
+ raise
9742
+
9743
+ task.input_prompt = self._rewrite_staged_attachment_references(task.input_prompt, staged)
9744
+ input_data = dict(task.input_data or {})
9745
+ input_data["prompt"] = task.input_prompt
9746
+ task.input_data = input_data
9747
+ logger.info("Prepared %d attachment(s) for task %s", len(staged), task.task_id)
9748
+ return staging_dir
9749
+
9192
9750
  @staticmethod
9193
9751
  async def _mint_local_dev_token() -> str:
9194
9752
  """Mint a JWT for local development when no explicit token is configured.
@@ -10033,6 +10591,7 @@ class RuntimeDaemon:
10033
10591
  await project_lock.acquire()
10034
10592
  workspace_execution_lock: _WorkspaceFileLock | None = None
10035
10593
  local_bind_file_lock = None
10594
+ attachment_staging_dir: Path | None = None
10036
10595
  if is_local_bind and task.local_workspace_binding_id:
10037
10596
  # OS-level file lock keyed by binding_id (docs/designs/forgexa-
10038
10597
  # ide-design.md §6.3): the DB lease (orchestrator.py) prevents
@@ -10128,6 +10687,34 @@ class RuntimeDaemon:
10128
10687
  logger.info("Workspace ready: %s", workspace_path)
10129
10688
  self._ensure_task_output_dir(workspace_path, task)
10130
10689
 
10690
+ try:
10691
+ if self._task_attachments_to_stage(task):
10692
+ await reporter.report_progress(
10693
+ task.task_id,
10694
+ 8,
10695
+ "preparing_attachments",
10696
+ output_lines=["[daemon] Preparing task attachments"],
10697
+ )
10698
+ attachment_staging_dir = await self._stage_task_attachments(task, conn)
10699
+ except _TaskAttachmentDownloadError as exc:
10700
+ logger.warning("Task %s could not stage attachments: %s", task.task_id, exc)
10701
+ await reporter.report_progress(
10702
+ task.task_id,
10703
+ 8,
10704
+ "attachment_download_failed",
10705
+ output_lines=["[daemon] Required task attachment could not be downloaded"],
10706
+ )
10707
+ await report_task_complete(TaskResult(
10708
+ status="failed",
10709
+ exit_code=-1,
10710
+ stdout="",
10711
+ stderr="",
10712
+ error=str(exc),
10713
+ failure_code="attachment_download_failed",
10714
+ execution_attempt_id=task.execution_attempt_id,
10715
+ ))
10716
+ return
10717
+
10131
10718
  # 2.1 Workspace health check: detect broken checkout — skipped for
10132
10719
  # local_bind (the user's own real directory, not a disposable
10133
10720
  # checkout the daemon controls). See
@@ -10254,7 +10841,7 @@ class RuntimeDaemon:
10254
10841
 
10255
10842
  tried_agents.add(agent.agent_id)
10256
10843
 
10257
- # ── Agent fallback: try another CLI after a backend failure or a fully silent hang ──
10844
+ # ── Agent fallback: try another CLI after a backend failure or a work-free hang ──
10258
10845
  # Guard: if the agent already produced file changes in the workspace, it DID
10259
10846
  # meaningful work — don't trigger fallback even if it crashed after completing.
10260
10847
  # Let the recovery logic (step 4.1) handle non-zero exit with committed work.
@@ -10268,12 +10855,12 @@ class RuntimeDaemon:
10268
10855
  else ProcessManager.agent_failure_kind(result)
10269
10856
  )
10270
10857
  is_agent_fallback_eligible = agent_failure_kind is not None
10271
- is_silent_idle_timeout = self.process_manager.is_silent_idle_timeout(result)
10858
+ is_idle_timeout_without_work = self.process_manager.is_idle_timeout_without_work(result)
10272
10859
  fallback_reason = (
10273
10860
  agent_failure_kind
10274
10861
  if is_agent_fallback_eligible
10275
10862
  else "agent_idle_timeout"
10276
- if is_silent_idle_timeout
10863
+ if is_idle_timeout_without_work
10277
10864
  else ""
10278
10865
  )
10279
10866
  fallback_label = (
@@ -10872,6 +11459,8 @@ class RuntimeDaemon:
10872
11459
  execution_attempt_id=task.execution_attempt_id,
10873
11460
  ))
10874
11461
  finally:
11462
+ if attachment_staging_dir is not None:
11463
+ shutil.rmtree(attachment_staging_dir, ignore_errors=True)
10875
11464
  if workspace_execution_lock is not None:
10876
11465
  workspace_execution_lock.release()
10877
11466
  project_lock.release()
@@ -11396,6 +11985,10 @@ class RuntimeDaemon:
11396
11985
 
11397
11986
  if not _skip_test_artifacts:
11398
11987
  _input = task.input_data or {}
11988
+ _qa_policy, _qa_policy_issues = _parse_frozen_qa_test_level_policy(
11989
+ _input.get("qa_test_level_policy_snapshot")
11990
+ )
11991
+ issues.extend(_qa_policy_issues)
11399
11992
  doc_dir = (
11400
11993
  _input.get("output_dir")
11401
11994
  or _input.get("context", {}).get("output_dir")
@@ -11411,6 +12004,8 @@ class RuntimeDaemon:
11411
12004
  # intents gets the same E2E chain validation as feature/improvement.
11412
12005
  _e2e_intent_ids: set[str] = set()
11413
12006
  _e2e_case_ids: list[str] = []
12007
+ _ti_data: dict[str, Any] | None = None
12008
+ _tc_data: dict[str, Any] | None = None
11414
12009
  _analysis_dir = (
11415
12010
  _input.get("analysis_output_dir")
11416
12011
  or (_input.get("context") or {}).get("analysis_output_dir")
@@ -11424,6 +12019,8 @@ class RuntimeDaemon:
11424
12019
  _ti_path.read_text(encoding="utf-8")
11425
12020
  )
11426
12021
  for _ti in _ti_data.get("intents", []):
12022
+ if not isinstance(_ti, dict):
12023
+ continue
11427
12024
  if (_ti.get("automation_target") or "").lower() == "e2e":
11428
12025
  _ti_id = (
11429
12026
  _ti.get("id")
@@ -11445,8 +12042,8 @@ class RuntimeDaemon:
11445
12042
  tc_path = base / "test-cases.json"
11446
12043
  if tc_path.exists():
11447
12044
  try:
11448
- tc_data = _json.loads(tc_path.read_text(encoding="utf-8"))
11449
- cases = tc_data.get("test_cases", [])
12045
+ _tc_data = _json.loads(tc_path.read_text(encoding="utf-8"))
12046
+ cases = _tc_data.get("test_cases", [])
11450
12047
  _e2e_case_ids = [
11451
12048
  str(_tc.get("id"))
11452
12049
  for _tc in cases
@@ -11526,6 +12123,14 @@ class RuntimeDaemon:
11526
12123
  elif _requires_structured_artifacts or _requires_e2e_artifacts:
11527
12124
  issues.append(f"test-cases.json not found in {doc_dir or 'workspace root'}")
11528
12125
 
12126
+ if _qa_policy is not None:
12127
+ issues.extend(_validate_enforced_qa_test_level_artifacts(
12128
+ _qa_policy,
12129
+ _ti_data,
12130
+ _tc_data,
12131
+ workspace_path,
12132
+ ))
12133
+
11529
12134
  # --- coverage-matrix.json validation ---
11530
12135
  cm_path = base / "coverage-matrix.json"
11531
12136
  if cm_path.exists():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.48.1
3
+ Version: 1.48.3
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.48.1"
3
+ version = "1.48.3"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes