forgexa-cli 1.85.1__tar.gz → 1.85.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 (34) hide show
  1. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/PKG-INFO +1 -1
  2. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/agent_core.py +258 -6
  4. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/daemon.py +35 -11
  5. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/pyproject.toml +1 -1
  7. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/README.md +0 -0
  8. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/aws_evidence.py +0 -0
  12. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/aws_logs_evidence.py +0 -0
  13. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/main.py +0 -0
  14. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/observability_contract.py +0 -0
  15. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/observability_evidence.py +0 -0
  16. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/py.typed +0 -0
  17. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/runtime_evidence.py +0 -0
  18. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli/zcode_protocol.py +0 -0
  19. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  20. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  21. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
  22. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli.egg-info/requires.txt +0 -0
  23. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/forgexa_cli.egg-info/top_level.txt +0 -0
  24. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/setup.cfg +0 -0
  25. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_auth_and_runtime_commands.py +0 -0
  26. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_autoupgrade.py +0 -0
  27. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_check_command.py +0 -0
  28. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
  29. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_local_bind_commands.py +0 -0
  30. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_publish_script.py +0 -0
  31. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_runtime_credentials.py +0 -0
  32. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_session_credentials.py +0 -0
  33. {forgexa_cli-1.85.1 → forgexa_cli-1.85.3}/tests/test_silent_install.py +0 -0
  34. {forgexa_cli-1.85.1 → forgexa_cli-1.85.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.85.1
3
+ Version: 1.85.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.85.1"
2
+ __version__ = "1.85.3"
@@ -68,16 +68,125 @@ except ImportError:
68
68
  logger = logging.getLogger("agent_core")
69
69
 
70
70
  IMPLEMENTATION_AUDIT_PROTOCOL_VERSION = 3
71
+ IMPLEMENTATION_ENVIRONMENT_BLOCKERS = frozenset({
72
+ "environment_unavailable", "service_not_running", "dependency_unavailable",
73
+ "permission_required", "test_data_unavailable", "target_mismatch",
74
+ })
75
+ TASK_FAILURE_DOMAINS = frozenset({"code_quality", "execution", "environment", "evidence", "delivery", "plan", "verification"})
71
76
  LOCAL_BIND_PRIVATE_VALIDATION_METRIC_KEYS = frozenset({
72
77
  "validation_issues", "validation_issue_details", "timeout_recovery_validation_issues",
73
78
  "timeout_recovery_original_failure", "task_delivery_issue",
74
79
  })
75
80
 
76
81
 
77
- def assess_approved_plan_conformance(baseline: dict, report: object) -> dict:
82
+ def verification_obligation_fingerprint(task: dict) -> str:
83
+ task_input = task.get("input") if isinstance(task.get("input"), dict) else {}
84
+ responsibility = {
85
+ "id": task.get("id"), "type": task.get("type"), "description": task.get("description") or "",
86
+ "items": task.get("items") or [], "depends_on": task.get("depends_on") or [],
87
+ "files": task_input.get("files") or task.get("files") or [],
88
+ }
89
+ return hashlib.sha256(json.dumps(responsibility, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode()).hexdigest()
90
+
91
+
92
+ def approved_contribution_review_instructions(baseline: dict, audit_contract: dict | None) -> str:
93
+ task = baseline.get("task") or {}
94
+ obligations = baseline.get("verification_obligations") or {}
95
+ if (
96
+ not isinstance(audit_contract, dict) or audit_contract.get("version") != 2
97
+ or audit_contract.get("plan_id") != baseline.get("plan_id") or audit_contract.get("subject_task_id") != task.get("id")
98
+ or audit_contract.get("final_verification") is not False or task.get("type") not in ("coding", "fix")
99
+ or not isinstance(obligations, dict) or type(obligations.get("version")) is not int or obligations["version"] != 1
100
+ ):
101
+ return ""
102
+ verifiers = [check for check in obligations.get("checks") or [] if isinstance(check, dict)
103
+ and check.get("type") in ("testing", "verify") and check.get("task_id") != task["id"]]
104
+ if not verifiers:
105
+ return ""
106
+ example = {"contribution_check": {"check_id": str(task["id"]) + ":assigned", "status": "unverified",
107
+ "evidence": ["<independent source and every assigned check evidence>"]},
108
+ "pending_checks": [{"check_id": verifiers[0]["check_id"], "task_id": verifiers[0]["task_id"],
109
+ "status": "unverified", "evidence": ["<observed outstanding approved verification>"]}]}
110
+ return (
111
+ "\n## Assigned Contribution and Pending Verification\n"
112
+ "Within the bound plan_conformance object, contribution_check covers all requirements and mandatory checks of this task, "
113
+ "not only static source. When independently evidenced as conformant, other approved verification owners may remain pending. "
114
+ "Use the exact frozen check_id/task_id pairs; this does not authorize moving an own check to another owner, "
115
+ "inventing an owner, waiving a check or changing its phase. Keep the overall status unverified while pending_checks remain. "
116
+ "The score and verdict assess the assigned contribution, not unfinished work assigned elsewhere. "
117
+ "A real own gap stays deviation or unverified. The default example is not completion evidence.\n"
118
+ "Scoped contribution fields example:\n" + json.dumps(example, ensure_ascii=True, separators=(",", ":"))
119
+ )
120
+
121
+
122
+ def _assess_contribution_conformance(baseline: dict, report: dict, audit_contract: dict | None, result: dict) -> dict:
123
+ obligations = baseline.get("verification_obligations")
124
+ subject = result["task_id"]
125
+ if (
126
+ not isinstance(audit_contract, dict) or audit_contract.get("version") != 2
127
+ or audit_contract.get("plan_id") != baseline.get("plan_id")
128
+ or audit_contract.get("subject_task_id") != subject or audit_contract.get("final_verification") is not False
129
+ or (baseline.get("task") or {}).get("type") not in ("coding", "fix")
130
+ or not isinstance(obligations, dict) or type(obligations.get("version")) is not int or obligations["version"] != 1
131
+ ):
132
+ return result
133
+ checks = obligations.get("checks")
134
+ if not isinstance(checks, list) or any(not isinstance(check, dict) for check in checks):
135
+ return result
136
+ by_id = {check.get("check_id"): check for check in checks if isinstance(check.get("check_id"), str)}
137
+ own = by_id.get(str(subject) + ":assigned")
138
+ tasks = audit_contract.get("tasks")
139
+ if not isinstance(tasks, list) or any(not isinstance(task, dict) or not isinstance(task.get("id"), str) for task in tasks):
140
+ return result
141
+ approved = {task["id"]: task for task in tasks}
142
+ contribution = report.get("contribution_check")
143
+ pending = report.get("pending_checks")
144
+ if (
145
+ len(by_id) != len(checks) or not isinstance(own, dict) or own.get("task_id") != subject
146
+ or subject not in approved or own.get("task_fingerprint") != verification_obligation_fingerprint(approved[subject])
147
+ or own.get("task_fingerprint") != verification_obligation_fingerprint(baseline["task"])
148
+ or own.get("required") is not True or not isinstance(contribution, dict)
149
+ or contribution.get("check_id") != own["check_id"] or contribution.get("status") != "conformant"
150
+ or contribution.get("plan_revision") is not None
151
+ or not isinstance(pending, list) or not pending
152
+ ):
153
+ return result
154
+ evidence = contribution.get("evidence")
155
+ if not isinstance(evidence, list) or not evidence or any(not isinstance(item, str) or not item.strip() for item in evidence):
156
+ return result
157
+ normalized = []
158
+ seen = set()
159
+ for check in pending:
160
+ if not isinstance(check, dict) or not isinstance(check.get("check_id"), str):
161
+ return result
162
+ binding = by_id.get(check["check_id"])
163
+ references = check.get("evidence")
164
+ reason = check.get("blocking_reason")
165
+ if (
166
+ not isinstance(binding, dict) or check["check_id"] in seen or binding.get("required") is not True
167
+ or check.get("task_id") == subject or binding.get("task_id") != check.get("task_id")
168
+ or binding.get("type") not in ("testing", "verify") or approved.get(check.get("task_id"), {}).get("type") != binding.get("type")
169
+ or binding.get("task_fingerprint") != verification_obligation_fingerprint(approved.get(check.get("task_id"), {}))
170
+ or check.get("status") not in ("blocked", "unverified") or check.get("plan_revision") is not None
171
+ or not isinstance(references, list) or not references
172
+ or any(not isinstance(item, str) or not item.strip() for item in references)
173
+ or reason is not None and (not isinstance(reason, str) or reason not in IMPLEMENTATION_ENVIRONMENT_BLOCKERS)
174
+ ):
175
+ return result
176
+ seen.add(check["check_id"])
177
+ normalized.append({"check_id": check["check_id"], "task_id": check["task_id"], "status": check["status"],
178
+ "evidence_refs": [hashlib.sha256(item.encode()).hexdigest() for item in references],
179
+ **({"blocking_reason": reason} if reason is not None else {})})
180
+ result.update(node_complete=True, contribution_check={"check_id": own["check_id"], "status": "conformant",
181
+ "evidence_refs": [hashlib.sha256(item.encode()).hexdigest() for item in evidence]}, pending_checks=normalized,
182
+ verification_pending=sorted({check["task_id"] for check in normalized}))
183
+ return result
184
+
185
+
186
+ def assess_approved_plan_conformance(baseline: dict, report: object, *, audit_contract: dict | None = None) -> dict:
78
187
  result = {
79
188
  "status": "unverified", "baseline_fingerprint": baseline.get("fingerprint"),
80
- "task_id": (baseline.get("task") or {}).get("id"), "evidence_refs": [],
189
+ "task_id": (baseline.get("task") or {}).get("id"), "evidence_refs": [], "node_complete": False,
81
190
  }
82
191
  expected = {key: value for key, value in baseline.items() if key != "fingerprint"}
83
192
  fingerprint = hashlib.sha256(json.dumps(
@@ -109,8 +218,20 @@ def assess_approved_plan_conformance(baseline: dict, report: object) -> dict:
109
218
  result["reason"] = "approved_plan_review_evidence_missing"
110
219
  result["diagnostic_code"] = "approved_plan_review_evidence_missing"
111
220
  return result
221
+ contribution = report.get("contribution_check")
222
+ if report["status"] == "conformant" and (
223
+ bool(report.get("pending_checks"))
224
+ or "pending_checks" in report and not isinstance(report["pending_checks"], list)
225
+ or contribution is not None and (not isinstance(contribution, dict) or contribution.get("status") != "conformant")
226
+ ):
227
+ result["reason"] = "approved_plan_review_missing_or_mismatched"
228
+ result["diagnostic_code"] = "approved_plan_review_contribution_conflict"
229
+ return result
112
230
  result["status"] = report["status"]
113
231
  result["evidence_refs"] = [hashlib.sha256(item.encode()).hexdigest() for item in evidence]
232
+ result["node_complete"] = report["status"] == "conformant"
233
+ if report["status"] == "unverified" and report.get("contribution_check") is not None:
234
+ return _assess_contribution_conformance(baseline, report, audit_contract, result)
114
235
  return result
115
236
 
116
237
 
@@ -238,6 +359,8 @@ def redact_implementation_audit(contract: dict, report: object) -> dict | None:
238
359
  check = {"status": row["status"], "evidence": evidence}
239
360
  if row["status"] != "covered":
240
361
  check.update(description="Local implementation issue remains unresolved; inspect local task evidence.", files=[])
362
+ if row.get("blocking_reason") in IMPLEMENTATION_ENVIRONMENT_BLOCKERS:
363
+ check["blocking_reason"] = row["blocking_reason"]
241
364
  if row.get("plan_revision"):
242
365
  check["plan_revision"] = {
243
366
  "reason": "Local evidence conflicts with the approved plan",
@@ -281,7 +404,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
281
404
  checks.append(row)
282
405
  continue
283
406
  check = {"task_id": row.get("task_id"), **{
284
- key: implementation[key] for key in ("status", "evidence", "description", "files", "plan_revision")
407
+ key: implementation[key] for key in ("status", "evidence", "description", "files", "plan_revision", "blocking_reason")
285
408
  if key in implementation
286
409
  }}
287
410
  if row.get("plan_revision") is not None and implementation.get("status") != "covered":
@@ -296,6 +419,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
296
419
  "task_id": task_id, "status": row.get("status"),
297
420
  "description": row.get("description") or "Runtime acceptance remains pending",
298
421
  "files": [], "evidence": row.get("evidence") or [],
422
+ **({"blocking_reason": row["blocking_reason"]} if row.get("blocking_reason") else {}),
299
423
  } for task_id in pending_verification_ids)
300
424
  implementation_statuses = {row["task_id"]: row["status"] for row in checks}
301
425
  deferred_contract = contract.get("deferred_acceptance_verification")
@@ -363,6 +487,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
363
487
  pending.extend({
364
488
  "task_id": task_id, "status": row["status"], "description": row["description"],
365
489
  "files": row.get("files") or [], "evidence": row["evidence"],
490
+ **({"blocking_reason": row["blocking_reason"]} if row.get("blocking_reason") else {}),
366
491
  } for task_id in pending_verification_ids)
367
492
  acceptance_checks.append(row)
368
493
  result = assess_implementation_audit({
@@ -443,6 +568,13 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
443
568
  ):
444
569
  issues.append(f"Implementation {kind} check requires an explicit status and concrete evidence")
445
570
  continue
571
+ blocking_reason = row.get("blocking_reason")
572
+ if blocking_reason is not None and (
573
+ not isinstance(blocking_reason, str) or blocking_reason not in IMPLEMENTATION_ENVIRONMENT_BLOCKERS
574
+ or status not in ("blocked", "unverified") or row.get("plan_revision") is not None
575
+ ):
576
+ issues.append("Implementation blocking_reason must identify an allowed unmet environment prerequisite on a blocked or unverified check")
577
+ continue
446
578
  if status == "covered" and row.get("plan_revision") is not None:
447
579
  issues.append(
448
580
  "A covered implementation check cannot request a plan revision; "
@@ -541,6 +673,7 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
541
673
  "task_id": task_id, "status": status, "description": description,
542
674
  "files": paths, "evidence": evidence,
543
675
  **({"plan_revision": revision} if revision is not None else {}),
676
+ **({"blocking_reason": blocking_reason} if blocking_reason is not None else {}),
544
677
  } for task_id in related)
545
678
  required = expected if contract.get("final_verification") else {subject}
546
679
  if required - checked.keys():
@@ -563,6 +696,57 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
563
696
  return result
564
697
 
565
698
 
699
+ def implementation_audit_failure_domain(contract: dict, assessment: dict) -> str | None:
700
+ if not assessment.get("report_valid"):
701
+ return "evidence"
702
+ if assessment.get("node_complete"):
703
+ return None
704
+ if assessment.get("status") == "plan_revision_required":
705
+ return "plan"
706
+ subject = contract.get("subject_task_id")
707
+ findings = [finding for finding in assessment.get("findings") or [] if finding.get("task_id") == subject]
708
+ if findings and all(
709
+ finding.get("status") in ("blocked", "unverified")
710
+ and finding.get("blocking_reason") in IMPLEMENTATION_ENVIRONMENT_BLOCKERS for finding in findings
711
+ ):
712
+ return "environment"
713
+ role = next((task.get("type") for task in contract.get("tasks") or [] if task.get("id") == subject), None)
714
+ if role in ("coding", "fix") and any(finding.get("status") == "gap" for finding in findings):
715
+ return "code_quality"
716
+ return "verification"
717
+
718
+
719
+ def task_failure_domain(
720
+ failure_code: str | None, *, audit_contract: dict | None = None,
721
+ audit_assessment: dict | None = None, conformance: dict | None = None,
722
+ payload_failed: bool = False,
723
+ ) -> str:
724
+ if failure_code in {
725
+ "planned_task_incomplete", "planned_task_baseline_invalid", "approved_plan_baseline_modified",
726
+ "remediation_scope_violation", "verification_work_item_scope_violation", "uncommitted_workspace_changes",
727
+ "forbidden_branch_merge",
728
+ }:
729
+ return "delivery"
730
+ if failure_code == "implementation_plan_revision_required":
731
+ return "plan"
732
+ if payload_failed:
733
+ return "execution"
734
+ if failure_code == "implementation_audit_incomplete":
735
+ return implementation_audit_failure_domain(audit_contract or {}, audit_assessment or {}) or "verification"
736
+ if failure_code in {"approved_plan_deviation", "approved_plan_conformance_unverified"}:
737
+ assessment = conformance or {}
738
+ if assessment.get("reason") == "approved_plan_review_unavailable":
739
+ return "execution"
740
+ if assessment.get("reason") or assessment.get("diagnostic_code"):
741
+ return "evidence"
742
+ return "code_quality" if assessment.get("status") == "deviation" else "verification"
743
+ if failure_code == "acceptance_criteria_uncovered":
744
+ return "verification"
745
+ if failure_code in {"validation_unavailable", "validation_retry_exhausted"}:
746
+ return "evidence"
747
+ return "execution"
748
+
749
+
566
750
  _WORKSPACE_INSTRUCTION_PATHS = (
567
751
  "AGENTS.md",
568
752
  "CLAUDE.md",
@@ -1889,7 +2073,7 @@ def has_agent_token_usage(*sources: object) -> bool:
1889
2073
  return False
1890
2074
 
1891
2075
 
1892
- def _extract_output_signals(text: str) -> dict[str, Any]:
2076
+ def _extract_output_signals(text: str, usage_source: object = None) -> dict[str, Any]:
1893
2077
  """Parse stdout/stderr-like streams for success and failure signals.
1894
2078
 
1895
2079
  Uses structural signal detection — NOT keyword matching on result text.
@@ -1933,6 +2117,8 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
1933
2117
 
1934
2118
  if ev_type == "turn.completed":
1935
2119
  has_turn_completed = True
2120
+ has_turn_failed = False
2121
+ error_messages.clear()
1936
2122
  elif ev_type == "turn.failed":
1937
2123
  has_turn_failed = True
1938
2124
  err = data.get("error") or {}
@@ -1962,10 +2148,18 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
1962
2148
  # Claude result format: {"type":"result","result":"...","is_error":false,...}
1963
2149
  if "exitCode" in data:
1964
2150
  # Copilot JSONL result
1965
- exit_code = int(data.get("exitCode") or 0)
2151
+ raw_exit_code = data["exitCode"]
2152
+ if type(raw_exit_code) is not int and not (
2153
+ isinstance(raw_exit_code, str) and re.fullmatch(r"[+-]?[0-9]+", raw_exit_code)
2154
+ ):
2155
+ error_messages.append("Agent result has an invalid exitCode")
2156
+ continue
2157
+ exit_code = int(raw_exit_code)
1966
2158
  if exit_code == 0:
1967
2159
  has_result = True
1968
2160
  has_meaningful_content = True
2161
+ has_turn_failed = False
2162
+ error_messages.clear()
1969
2163
  else:
1970
2164
  error_messages.append(f"Copilot exited with code {exit_code}")
1971
2165
  else:
@@ -1996,12 +2190,14 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
1996
2190
  # events appeared, the CLI never made an API call. The result
1997
2191
  # text is a CLI-level error (e.g. "API Error: Connection error.")
1998
2192
  # rather than the agent's actual work output.
1999
- no_api_call = not has_agent_token_usage(data) and not has_assistant_events
2193
+ no_api_call = not has_agent_token_usage(data, usage_source) and not has_assistant_events
2000
2194
  if no_api_call and result_text:
2001
2195
  error_messages.append(result_text)
2002
2196
  else:
2003
2197
  has_result = True
2004
2198
  has_meaningful_content = True
2199
+ has_turn_failed = False
2200
+ error_messages.clear()
2005
2201
  elif ev_type == "session.error":
2006
2202
  err = data.get("data") or {}
2007
2203
  if isinstance(err, dict):
@@ -4988,6 +5184,62 @@ def is_copilot_model_discovery_auth_error(*messages: str | None) -> bool:
4988
5184
  )
4989
5185
 
4990
5186
 
5187
+ # Transport failures a plain re-run can recover from. 421 Misdirected Request
5188
+ # is deliberately absent: it keeps its own recovery (retry without token env
5189
+ # vars) because it has historically indicated a credential-routing mismatch,
5190
+ # not a network blip.
5191
+ COPILOT_TRANSIENT_TRANSPORT_ERROR_PATTERNS = (
5192
+ "timed out",
5193
+ "timeout",
5194
+ "unexpected eof",
5195
+ "error sending request",
5196
+ "connection reset",
5197
+ "connection refused",
5198
+ "connection timed out",
5199
+ "connection error",
5200
+ "connection closed",
5201
+ "connection terminated",
5202
+ "network is unreachable",
5203
+ "no such host",
5204
+ "temporary failure in name resolution",
5205
+ )
5206
+
5207
+ COPILOT_STABLE_AUTH_ERROR_PATTERNS = (
5208
+ "unauthorized",
5209
+ "forbidden",
5210
+ "http 401",
5211
+ "http 403",
5212
+ "not logged in",
5213
+ "authentication required",
5214
+ "invalid api key",
5215
+ "your github token may be invalid",
5216
+ "rejected the cli github credential",
5217
+ )
5218
+
5219
+
5220
+ def is_copilot_model_discovery_transient_transport_error(*messages: str | None) -> bool:
5221
+ """Return whether Copilot failed model discovery on a transient transport error.
5222
+
5223
+ Matches connect timeouts, unexpected EOF, resets and DNS hiccups during
5224
+ model discovery — the class of failures observed as intermittent on
5225
+ unstable routes (a daemon-side rerun typically succeeds, and the same
5226
+ login works interactively), as opposed to stable auth/config breakage.
5227
+ Copilot appends its remediation boilerplate ("To resolve this, ... run
5228
+ '/login' ...") to every discovery failure, so auth patterns are only
5229
+ trusted when they appear before that boilerplate.
5230
+ """
5231
+ detail = " ".join(str(message or "").lower() for message in messages)
5232
+ detail = detail.partition("to resolve this")[0]
5233
+ if not (
5234
+ "retrieve the list of available models" in detail
5235
+ or "failed to load models" in detail
5236
+ ):
5237
+ return False
5238
+ if any(pattern in detail for pattern in COPILOT_STABLE_AUTH_ERROR_PATTERNS):
5239
+ return False
5240
+ return any(pattern in detail for pattern in COPILOT_TRANSIENT_TRANSPORT_ERROR_PATTERNS)
5241
+
5242
+
4991
5243
  def is_runtime_resource_exhaustion_error(*messages: str | None) -> bool:
4992
5244
  """Return whether the OS refused an agent process, thread, or file watch."""
4993
5245
  detail = " ".join(str(message or "").lower() for message in messages)
@@ -1234,7 +1234,7 @@ except (ImportError, ModuleNotFoundError):
1234
1234
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1235
1235
  # Kept in sync with pyproject.toml version via bump-version.sh.
1236
1236
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1237
- DAEMON_VERSION = "1.85.1"
1237
+ DAEMON_VERSION = "1.85.3"
1238
1238
 
1239
1239
 
1240
1240
  def _detect_client_type() -> str:
@@ -1809,6 +1809,7 @@ def _build_runtime_critic_prompt(
1809
1809
  "failed checks remain blocking. State the concrete missing evidence; never invent a run, "
1810
1810
  "treat asset existence as proof of correctness, or claim final QA or release acceptance."
1811
1811
  )
1812
+ parts.append(agent_core.approved_contribution_review_instructions(baseline, audit_contract))
1812
1813
  else:
1813
1814
  planned_context = {
1814
1815
  key: (task.input_data or {}).get(key)
@@ -6439,8 +6440,8 @@ class ProcessManager:
6439
6440
  int(result.metrics.get("token_input", 0) or 0)
6440
6441
  + int(result.metrics.get("token_output", 0) or 0)
6441
6442
  ) > 0
6442
- signals = ProcessManager._extract_output_signals(
6443
- "\n".join(part for part in (stdout, stderr) if part)
6443
+ signals = agent_core._extract_output_signals(
6444
+ "\n".join(part for part in (stdout, stderr) if part), result.metrics,
6444
6445
  )
6445
6446
  if not has_token_usage and ProcessManager._should_scan_short_success_stdout(stdout, signals):
6446
6447
  error_channels = "\n".join(filter(None, [error_channels, stdout.strip()]))
@@ -8484,15 +8485,18 @@ class ProcessManager:
8484
8485
 
8485
8486
  model_discovery_detail = "\n".join([*structured_errors, stderr]).lower()
8486
8487
  if (
8487
- "could not retrieve the list of available models" in model_discovery_detail
8488
- and "timed out" in model_discovery_detail
8488
+ agent_core.is_copilot_model_discovery_transient_transport_error(
8489
+ *structured_errors, stderr,
8490
+ )
8491
+ and "misdirected request" not in model_discovery_detail
8489
8492
  and not _retry_model_discovery_timeout
8490
8493
  and not (copilot_exit == 0 and returncode == 0)
8491
8494
  and not signals["has_meaningful_content"]
8492
8495
  and not self._copilot_called_any_tools(stdout)
8493
8496
  ):
8494
8497
  logger.warning(
8495
- "Copilot model discovery timed out before work for task %s; "
8498
+ "Copilot model discovery hit a transient transport error "
8499
+ "(timeout / connect reset) before work for task %s; "
8496
8500
  "retrying once with the same credentials and configuration",
8497
8501
  task_id,
8498
8502
  )
@@ -14289,7 +14293,8 @@ class RuntimeDaemon:
14289
14293
  **metadata,
14290
14294
  }
14291
14295
  conformance = (
14292
- agent_core.assess_approved_plan_conformance(baseline, structured_result.get("plan_conformance"))
14296
+ agent_core.assess_approved_plan_conformance(baseline, structured_result.get("plan_conformance"),
14297
+ audit_contract=(task.input_data or {}).get("implementation_audit_contract"))
14293
14298
  if isinstance(baseline, dict) else None
14294
14299
  )
14295
14300
  if conformance is not None and conformance["status"] != "conformant":
@@ -14317,6 +14322,15 @@ class RuntimeDaemon:
14317
14322
  "evidence": conformance["evidence_refs"],
14318
14323
  },
14319
14324
  }
14325
+ if conformance.get("node_complete") and conformance["status"] == "unverified":
14326
+ contribution = conformance["contribution_check"]
14327
+ structured_result["plan_conformance"]["contribution_check"] = {
14328
+ "check_id": contribution["check_id"], "status": contribution["status"], "evidence": contribution["evidence_refs"],
14329
+ }
14330
+ structured_result["plan_conformance"]["pending_checks"] = [{
14331
+ "check_id": check["check_id"], "task_id": check["task_id"], "status": check["status"], "evidence": check["evidence_refs"],
14332
+ **({"blocking_reason": check["blocking_reason"]} if "blocking_reason" in check else {}),
14333
+ } for check in conformance["pending_checks"]]
14320
14334
  encoded_result = json.dumps(
14321
14335
  structured_result,
14322
14336
  ensure_ascii=True,
@@ -14466,6 +14480,7 @@ class RuntimeDaemon:
14466
14480
  audit_contract = input_data.get("implementation_audit_contract")
14467
14481
  result.metrics.pop("implementation_audit_node_complete", None)
14468
14482
  result.metrics.pop("implementation_audit_subject_blocked", None)
14483
+ result.metrics.pop("failure_domain", None)
14469
14484
  if isinstance(audit_contract, dict):
14470
14485
  agent_id = str(result.metrics.get("actual_agent") or getattr(task, "agent_type", "") or "")
14471
14486
  audit_report = agent_core.extract_implementation_audit(_daemon_extract_agent_output(agent_id, result.stdout))
@@ -14476,13 +14491,17 @@ class RuntimeDaemon:
14476
14491
  if isinstance(audit_report, dict):
14477
14492
  result.observations.append({"type": "implementation_audit", "data": audit_report})
14478
14493
  audit_result = agent_core.assess_implementation_audit(audit_contract, audit_report)
14494
+ failure_domain = agent_core.implementation_audit_failure_domain(audit_contract, audit_result)
14495
+ if failure_domain is not None:
14496
+ result.metrics["failure_domain"] = failure_domain
14479
14497
  result.metrics["implementation_audit_node_complete"] = bool(
14480
14498
  audit_result["report_valid"] and audit_result["node_complete"]
14481
14499
  )
14482
14500
  result.metrics["implementation_audit_subject_blocked"] = bool(
14483
14501
  audit_result["report_valid"] and not audit_result["node_complete"]
14484
- and any(finding.get("task_id") == audit_contract.get("subject_task_id") and finding.get("status") == "blocked"
14485
- for finding in audit_result.get("findings") or [])
14502
+ and (failure_domain == "environment" or any(
14503
+ finding.get("task_id") == audit_contract.get("subject_task_id") and finding.get("status") == "blocked"
14504
+ for finding in audit_result.get("findings") or []))
14486
14505
  )
14487
14506
  if audit_result["report_valid"] and audit_result["status"] == "plan_revision_required":
14488
14507
  result.status = "failed"
@@ -14504,6 +14523,7 @@ class RuntimeDaemon:
14504
14523
  delivered_files = list(result.files_changed)
14505
14524
  delivered_files.extend(input_data.get("planned_task_predecessor_files") or [])
14506
14525
  for missing_path in agent_core.missing_planned_task_files(input_data, delivered_files):
14526
+ result.metrics["failure_domain"] = "delivery"
14507
14527
  issues.append(_OutputValidationIssue(
14508
14528
  "planned_task_incomplete",
14509
14529
  f"Planned task file has no implementation evidence: {missing_path}. "
@@ -16508,7 +16528,10 @@ class RuntimeDaemon:
16508
16528
  ])
16509
16529
 
16510
16530
  if (
16511
- agent_core.is_implementation_testing_task(task.node_type, task.input_data or {})
16531
+ result.metrics.get("failure_domain") == "environment" and all(
16532
+ issue.code in {"implementation_audit_incomplete", "acceptance_criteria_uncovered"} for issue in issues
16533
+ )
16534
+ or agent_core.is_implementation_testing_task(task.node_type, task.input_data or {})
16512
16535
  and result.metrics.get("implementation_audit_subject_blocked") is True
16513
16536
  ):
16514
16537
  result.status = "failed"
@@ -16711,7 +16734,8 @@ class RuntimeDaemon:
16711
16734
  result.failure_code = "implementation_audit_incomplete"
16712
16735
  result.error = "; ".join(remaining[:3])
16713
16736
  if (
16714
- agent_core.is_implementation_testing_task(task.node_type, task.input_data or {})
16737
+ result.metrics.get("failure_domain") == "environment"
16738
+ or agent_core.is_implementation_testing_task(task.node_type, task.input_data or {})
16715
16739
  and result.metrics.get("implementation_audit_subject_blocked") is True
16716
16740
  ):
16717
16741
  result.error += ". Resolve the reported verification prerequisites before retrying."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.85.1
3
+ Version: 1.85.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.85.1"
3
+ version = "1.85.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