forgexa-cli 1.85.0__tar.gz → 1.85.2__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.85.0 → forgexa_cli-1.85.2}/PKG-INFO +1 -1
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/agent_core.py +178 -12
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/daemon.py +59 -12
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/pyproject.toml +1 -1
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/README.md +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/aws_evidence.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/aws_logs_evidence.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/observability_contract.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/observability_evidence.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/setup.cfg +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_publish_script.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.85.0 → forgexa_cli-1.85.2}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.85.
|
|
2
|
+
__version__ = "1.85.2"
|
|
@@ -68,6 +68,11 @@ 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",
|
|
@@ -211,6 +216,17 @@ def extract_implementation_audit(text: str) -> dict | None:
|
|
|
211
216
|
return report
|
|
212
217
|
|
|
213
218
|
|
|
219
|
+
def _implementation_acceptance_index(row: dict) -> int | None:
|
|
220
|
+
index = row.get("index", row.get("acceptance_index"))
|
|
221
|
+
if type(index) is not int:
|
|
222
|
+
return None
|
|
223
|
+
if "index" in row and "acceptance_index" in row and (
|
|
224
|
+
type(row["acceptance_index"]) is not int or row["acceptance_index"] != index
|
|
225
|
+
):
|
|
226
|
+
return None
|
|
227
|
+
return index
|
|
228
|
+
|
|
229
|
+
|
|
214
230
|
def redact_implementation_audit(contract: dict, report: object) -> dict | None:
|
|
215
231
|
"""Keep contract-owned status and hashed evidence without local free text."""
|
|
216
232
|
from hashlib import sha256
|
|
@@ -227,6 +243,8 @@ def redact_implementation_audit(contract: dict, report: object) -> dict | None:
|
|
|
227
243
|
check = {"status": row["status"], "evidence": evidence}
|
|
228
244
|
if row["status"] != "covered":
|
|
229
245
|
check.update(description="Local implementation issue remains unresolved; inspect local task evidence.", files=[])
|
|
246
|
+
if row.get("blocking_reason") in IMPLEMENTATION_ENVIRONMENT_BLOCKERS:
|
|
247
|
+
check["blocking_reason"] = row["blocking_reason"]
|
|
230
248
|
if row.get("plan_revision"):
|
|
231
249
|
check["plan_revision"] = {
|
|
232
250
|
"reason": "Local evidence conflicts with the approved plan",
|
|
@@ -244,7 +262,7 @@ def redact_implementation_audit(contract: dict, report: object) -> dict | None:
|
|
|
244
262
|
if contract.get("version") == 2 and row["task_id"] in coding_ids and isinstance(row.get("implementation_check"), dict):
|
|
245
263
|
check["implementation_check"] = redact_check(row["implementation_check"])
|
|
246
264
|
else:
|
|
247
|
-
check.update(index=row
|
|
265
|
+
check.update(index=_implementation_acceptance_index(row), task_ids=row["task_ids"])
|
|
248
266
|
summary[key].append(check)
|
|
249
267
|
return summary
|
|
250
268
|
|
|
@@ -270,7 +288,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
|
|
|
270
288
|
checks.append(row)
|
|
271
289
|
continue
|
|
272
290
|
check = {"task_id": row.get("task_id"), **{
|
|
273
|
-
key: implementation[key] for key in ("status", "evidence", "description", "files", "plan_revision")
|
|
291
|
+
key: implementation[key] for key in ("status", "evidence", "description", "files", "plan_revision", "blocking_reason")
|
|
274
292
|
if key in implementation
|
|
275
293
|
}}
|
|
276
294
|
if row.get("plan_revision") is not None and implementation.get("status") != "covered":
|
|
@@ -285,6 +303,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
|
|
|
285
303
|
"task_id": task_id, "status": row.get("status"),
|
|
286
304
|
"description": row.get("description") or "Runtime acceptance remains pending",
|
|
287
305
|
"files": [], "evidence": row.get("evidence") or [],
|
|
306
|
+
**({"blocking_reason": row["blocking_reason"]} if row.get("blocking_reason") else {}),
|
|
288
307
|
} for task_id in pending_verification_ids)
|
|
289
308
|
implementation_statuses = {row["task_id"]: row["status"] for row in checks}
|
|
290
309
|
deferred_contract = contract.get("deferred_acceptance_verification")
|
|
@@ -328,8 +347,9 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
|
|
|
328
347
|
and implementation_statuses.get(subject) == "covered"
|
|
329
348
|
)
|
|
330
349
|
for row in report["acceptance_checks"]:
|
|
350
|
+
index = _implementation_acceptance_index(row)
|
|
331
351
|
if (
|
|
332
|
-
|
|
352
|
+
index in reserved_indexes and row["status"] == "unverified" and row.get("plan_revision") is None
|
|
333
353
|
and (
|
|
334
354
|
all(implementation_statuses.get(task_id) == "covered" for task_id in row["task_ids"])
|
|
335
355
|
or prepared_verifier and subject in row["task_ids"] and all(
|
|
@@ -339,7 +359,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
|
|
|
339
359
|
)
|
|
340
360
|
)
|
|
341
361
|
):
|
|
342
|
-
deferred_acceptance.append(
|
|
362
|
+
deferred_acceptance.append(index)
|
|
343
363
|
continue
|
|
344
364
|
if (
|
|
345
365
|
pending_verification_ids and row["status"] in ("blocked", "unverified")
|
|
@@ -351,6 +371,7 @@ def _assess_implementation_contributions(contract: dict, report: object) -> dict
|
|
|
351
371
|
pending.extend({
|
|
352
372
|
"task_id": task_id, "status": row["status"], "description": row["description"],
|
|
353
373
|
"files": row.get("files") or [], "evidence": row["evidence"],
|
|
374
|
+
**({"blocking_reason": row["blocking_reason"]} if row.get("blocking_reason") else {}),
|
|
354
375
|
} for task_id in pending_verification_ids)
|
|
355
376
|
acceptance_checks.append(row)
|
|
356
377
|
result = assess_implementation_audit({
|
|
@@ -418,7 +439,7 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
|
|
|
418
439
|
checked_criteria: set[int] = set()
|
|
419
440
|
criterion_count = len(contract.get("acceptance_criteria") or [])
|
|
420
441
|
for kind, rows in (("task", task_checks), ("acceptance", acceptance_checks)):
|
|
421
|
-
for row in rows:
|
|
442
|
+
for position, row in enumerate(rows, 1):
|
|
422
443
|
if not isinstance(row, dict):
|
|
423
444
|
issues.append(f"Invalid implementation {kind} check")
|
|
424
445
|
continue
|
|
@@ -431,6 +452,13 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
|
|
|
431
452
|
):
|
|
432
453
|
issues.append(f"Implementation {kind} check requires an explicit status and concrete evidence")
|
|
433
454
|
continue
|
|
455
|
+
blocking_reason = row.get("blocking_reason")
|
|
456
|
+
if blocking_reason is not None and (
|
|
457
|
+
not isinstance(blocking_reason, str) or blocking_reason not in IMPLEMENTATION_ENVIRONMENT_BLOCKERS
|
|
458
|
+
or status not in ("blocked", "unverified") or row.get("plan_revision") is not None
|
|
459
|
+
):
|
|
460
|
+
issues.append("Implementation blocking_reason must identify an allowed unmet environment prerequisite on a blocked or unverified check")
|
|
461
|
+
continue
|
|
434
462
|
if status == "covered" and row.get("plan_revision") is not None:
|
|
435
463
|
issues.append(
|
|
436
464
|
"A covered implementation check cannot request a plan revision; "
|
|
@@ -445,8 +473,19 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
|
|
|
445
473
|
checked[task_id] = status
|
|
446
474
|
related = [task_id]
|
|
447
475
|
else:
|
|
448
|
-
index = row
|
|
476
|
+
index = _implementation_acceptance_index(row)
|
|
449
477
|
related = row.get("task_ids")
|
|
478
|
+
if index is None:
|
|
479
|
+
detail = (
|
|
480
|
+
"index and acceptance_index must be identical integers"
|
|
481
|
+
if "index" in row and "acceptance_index" in row else
|
|
482
|
+
"index must be a one-based integer; acceptance_index is an exact alias, but id is not an index"
|
|
483
|
+
)
|
|
484
|
+
issues.append(
|
|
485
|
+
f"Implementation audit has invalid acceptance criterion or task references; "
|
|
486
|
+
f"acceptance row {position}: {detail}"
|
|
487
|
+
)
|
|
488
|
+
continue
|
|
450
489
|
if type(index) is int and not 1 <= index <= criterion_count:
|
|
451
490
|
if criterion_count == 0:
|
|
452
491
|
issues.append(
|
|
@@ -462,11 +501,18 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
|
|
|
462
501
|
)
|
|
463
502
|
continue
|
|
464
503
|
if (
|
|
465
|
-
|
|
504
|
+
index in checked_criteria
|
|
466
505
|
or not isinstance(related, list) or not related
|
|
467
506
|
or any(not isinstance(task_id, str) or task_id not in expected for task_id in related)
|
|
468
507
|
):
|
|
469
|
-
|
|
508
|
+
detail = (
|
|
509
|
+
"duplicate index" if index in checked_criteria else
|
|
510
|
+
"task_ids must be a non-empty list of approved top-level task IDs"
|
|
511
|
+
)
|
|
512
|
+
issues.append(
|
|
513
|
+
f"Implementation audit has invalid acceptance criterion or task references; "
|
|
514
|
+
f"acceptance index {index}: {detail}"
|
|
515
|
+
)
|
|
470
516
|
continue
|
|
471
517
|
checked_criteria.add(index)
|
|
472
518
|
if len(related) > 1:
|
|
@@ -477,7 +523,7 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
|
|
|
477
523
|
description = row.get("description")
|
|
478
524
|
paths = row.get("files") or []
|
|
479
525
|
if not isinstance(description, str) or not description.strip() or not isinstance(paths, list):
|
|
480
|
-
reference = f"task '{row['task_id']}'" if kind == "task" else f"acceptance index {
|
|
526
|
+
reference = f"task '{row['task_id']}'" if kind == "task" else f"acceptance index {index}"
|
|
481
527
|
invalid_fields = []
|
|
482
528
|
if not isinstance(description, str) or not description.strip():
|
|
483
529
|
invalid_fields.append("description must be non-empty text")
|
|
@@ -511,6 +557,7 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
|
|
|
511
557
|
"task_id": task_id, "status": status, "description": description,
|
|
512
558
|
"files": paths, "evidence": evidence,
|
|
513
559
|
**({"plan_revision": revision} if revision is not None else {}),
|
|
560
|
+
**({"blocking_reason": blocking_reason} if blocking_reason is not None else {}),
|
|
514
561
|
} for task_id in related)
|
|
515
562
|
required = expected if contract.get("final_verification") else {subject}
|
|
516
563
|
if required - checked.keys():
|
|
@@ -533,6 +580,57 @@ def assess_implementation_audit(contract: dict, report: object) -> dict:
|
|
|
533
580
|
return result
|
|
534
581
|
|
|
535
582
|
|
|
583
|
+
def implementation_audit_failure_domain(contract: dict, assessment: dict) -> str | None:
|
|
584
|
+
if not assessment.get("report_valid"):
|
|
585
|
+
return "evidence"
|
|
586
|
+
if assessment.get("node_complete"):
|
|
587
|
+
return None
|
|
588
|
+
if assessment.get("status") == "plan_revision_required":
|
|
589
|
+
return "plan"
|
|
590
|
+
subject = contract.get("subject_task_id")
|
|
591
|
+
findings = [finding for finding in assessment.get("findings") or [] if finding.get("task_id") == subject]
|
|
592
|
+
if findings and all(
|
|
593
|
+
finding.get("status") in ("blocked", "unverified")
|
|
594
|
+
and finding.get("blocking_reason") in IMPLEMENTATION_ENVIRONMENT_BLOCKERS for finding in findings
|
|
595
|
+
):
|
|
596
|
+
return "environment"
|
|
597
|
+
role = next((task.get("type") for task in contract.get("tasks") or [] if task.get("id") == subject), None)
|
|
598
|
+
if role in ("coding", "fix") and any(finding.get("status") == "gap" for finding in findings):
|
|
599
|
+
return "code_quality"
|
|
600
|
+
return "verification"
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def task_failure_domain(
|
|
604
|
+
failure_code: str | None, *, audit_contract: dict | None = None,
|
|
605
|
+
audit_assessment: dict | None = None, conformance: dict | None = None,
|
|
606
|
+
payload_failed: bool = False,
|
|
607
|
+
) -> str:
|
|
608
|
+
if failure_code in {
|
|
609
|
+
"planned_task_incomplete", "planned_task_baseline_invalid", "approved_plan_baseline_modified",
|
|
610
|
+
"remediation_scope_violation", "verification_work_item_scope_violation", "uncommitted_workspace_changes",
|
|
611
|
+
"forbidden_branch_merge",
|
|
612
|
+
}:
|
|
613
|
+
return "delivery"
|
|
614
|
+
if failure_code == "implementation_plan_revision_required":
|
|
615
|
+
return "plan"
|
|
616
|
+
if payload_failed:
|
|
617
|
+
return "execution"
|
|
618
|
+
if failure_code == "implementation_audit_incomplete":
|
|
619
|
+
return implementation_audit_failure_domain(audit_contract or {}, audit_assessment or {}) or "verification"
|
|
620
|
+
if failure_code in {"approved_plan_deviation", "approved_plan_conformance_unverified"}:
|
|
621
|
+
assessment = conformance or {}
|
|
622
|
+
if assessment.get("reason") == "approved_plan_review_unavailable":
|
|
623
|
+
return "execution"
|
|
624
|
+
if assessment.get("reason") or assessment.get("diagnostic_code"):
|
|
625
|
+
return "evidence"
|
|
626
|
+
return "code_quality" if assessment.get("status") == "deviation" else "verification"
|
|
627
|
+
if failure_code == "acceptance_criteria_uncovered":
|
|
628
|
+
return "verification"
|
|
629
|
+
if failure_code in {"validation_unavailable", "validation_retry_exhausted"}:
|
|
630
|
+
return "evidence"
|
|
631
|
+
return "execution"
|
|
632
|
+
|
|
633
|
+
|
|
536
634
|
_WORKSPACE_INSTRUCTION_PATHS = (
|
|
537
635
|
"AGENTS.md",
|
|
538
636
|
"CLAUDE.md",
|
|
@@ -1859,7 +1957,7 @@ def has_agent_token_usage(*sources: object) -> bool:
|
|
|
1859
1957
|
return False
|
|
1860
1958
|
|
|
1861
1959
|
|
|
1862
|
-
def _extract_output_signals(text: str) -> dict[str, Any]:
|
|
1960
|
+
def _extract_output_signals(text: str, usage_source: object = None) -> dict[str, Any]:
|
|
1863
1961
|
"""Parse stdout/stderr-like streams for success and failure signals.
|
|
1864
1962
|
|
|
1865
1963
|
Uses structural signal detection — NOT keyword matching on result text.
|
|
@@ -1903,6 +2001,8 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
|
|
|
1903
2001
|
|
|
1904
2002
|
if ev_type == "turn.completed":
|
|
1905
2003
|
has_turn_completed = True
|
|
2004
|
+
has_turn_failed = False
|
|
2005
|
+
error_messages.clear()
|
|
1906
2006
|
elif ev_type == "turn.failed":
|
|
1907
2007
|
has_turn_failed = True
|
|
1908
2008
|
err = data.get("error") or {}
|
|
@@ -1932,10 +2032,18 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
|
|
|
1932
2032
|
# Claude result format: {"type":"result","result":"...","is_error":false,...}
|
|
1933
2033
|
if "exitCode" in data:
|
|
1934
2034
|
# Copilot JSONL result
|
|
1935
|
-
|
|
2035
|
+
raw_exit_code = data["exitCode"]
|
|
2036
|
+
if type(raw_exit_code) is not int and not (
|
|
2037
|
+
isinstance(raw_exit_code, str) and re.fullmatch(r"[+-]?[0-9]+", raw_exit_code)
|
|
2038
|
+
):
|
|
2039
|
+
error_messages.append("Agent result has an invalid exitCode")
|
|
2040
|
+
continue
|
|
2041
|
+
exit_code = int(raw_exit_code)
|
|
1936
2042
|
if exit_code == 0:
|
|
1937
2043
|
has_result = True
|
|
1938
2044
|
has_meaningful_content = True
|
|
2045
|
+
has_turn_failed = False
|
|
2046
|
+
error_messages.clear()
|
|
1939
2047
|
else:
|
|
1940
2048
|
error_messages.append(f"Copilot exited with code {exit_code}")
|
|
1941
2049
|
else:
|
|
@@ -1966,12 +2074,14 @@ def _extract_output_signals(text: str) -> dict[str, Any]:
|
|
|
1966
2074
|
# events appeared, the CLI never made an API call. The result
|
|
1967
2075
|
# text is a CLI-level error (e.g. "API Error: Connection error.")
|
|
1968
2076
|
# rather than the agent's actual work output.
|
|
1969
|
-
no_api_call = not has_agent_token_usage(data) and not has_assistant_events
|
|
2077
|
+
no_api_call = not has_agent_token_usage(data, usage_source) and not has_assistant_events
|
|
1970
2078
|
if no_api_call and result_text:
|
|
1971
2079
|
error_messages.append(result_text)
|
|
1972
2080
|
else:
|
|
1973
2081
|
has_result = True
|
|
1974
2082
|
has_meaningful_content = True
|
|
2083
|
+
has_turn_failed = False
|
|
2084
|
+
error_messages.clear()
|
|
1975
2085
|
elif ev_type == "session.error":
|
|
1976
2086
|
err = data.get("data") or {}
|
|
1977
2087
|
if isinstance(err, dict):
|
|
@@ -4958,6 +5068,62 @@ def is_copilot_model_discovery_auth_error(*messages: str | None) -> bool:
|
|
|
4958
5068
|
)
|
|
4959
5069
|
|
|
4960
5070
|
|
|
5071
|
+
# Transport failures a plain re-run can recover from. 421 Misdirected Request
|
|
5072
|
+
# is deliberately absent: it keeps its own recovery (retry without token env
|
|
5073
|
+
# vars) because it has historically indicated a credential-routing mismatch,
|
|
5074
|
+
# not a network blip.
|
|
5075
|
+
COPILOT_TRANSIENT_TRANSPORT_ERROR_PATTERNS = (
|
|
5076
|
+
"timed out",
|
|
5077
|
+
"timeout",
|
|
5078
|
+
"unexpected eof",
|
|
5079
|
+
"error sending request",
|
|
5080
|
+
"connection reset",
|
|
5081
|
+
"connection refused",
|
|
5082
|
+
"connection timed out",
|
|
5083
|
+
"connection error",
|
|
5084
|
+
"connection closed",
|
|
5085
|
+
"connection terminated",
|
|
5086
|
+
"network is unreachable",
|
|
5087
|
+
"no such host",
|
|
5088
|
+
"temporary failure in name resolution",
|
|
5089
|
+
)
|
|
5090
|
+
|
|
5091
|
+
COPILOT_STABLE_AUTH_ERROR_PATTERNS = (
|
|
5092
|
+
"unauthorized",
|
|
5093
|
+
"forbidden",
|
|
5094
|
+
"http 401",
|
|
5095
|
+
"http 403",
|
|
5096
|
+
"not logged in",
|
|
5097
|
+
"authentication required",
|
|
5098
|
+
"invalid api key",
|
|
5099
|
+
"your github token may be invalid",
|
|
5100
|
+
"rejected the cli github credential",
|
|
5101
|
+
)
|
|
5102
|
+
|
|
5103
|
+
|
|
5104
|
+
def is_copilot_model_discovery_transient_transport_error(*messages: str | None) -> bool:
|
|
5105
|
+
"""Return whether Copilot failed model discovery on a transient transport error.
|
|
5106
|
+
|
|
5107
|
+
Matches connect timeouts, unexpected EOF, resets and DNS hiccups during
|
|
5108
|
+
model discovery — the class of failures observed as intermittent on
|
|
5109
|
+
unstable routes (a daemon-side rerun typically succeeds, and the same
|
|
5110
|
+
login works interactively), as opposed to stable auth/config breakage.
|
|
5111
|
+
Copilot appends its remediation boilerplate ("To resolve this, ... run
|
|
5112
|
+
'/login' ...") to every discovery failure, so auth patterns are only
|
|
5113
|
+
trusted when they appear before that boilerplate.
|
|
5114
|
+
"""
|
|
5115
|
+
detail = " ".join(str(message or "").lower() for message in messages)
|
|
5116
|
+
detail = detail.partition("to resolve this")[0]
|
|
5117
|
+
if not (
|
|
5118
|
+
"retrieve the list of available models" in detail
|
|
5119
|
+
or "failed to load models" in detail
|
|
5120
|
+
):
|
|
5121
|
+
return False
|
|
5122
|
+
if any(pattern in detail for pattern in COPILOT_STABLE_AUTH_ERROR_PATTERNS):
|
|
5123
|
+
return False
|
|
5124
|
+
return any(pattern in detail for pattern in COPILOT_TRANSIENT_TRANSPORT_ERROR_PATTERNS)
|
|
5125
|
+
|
|
5126
|
+
|
|
4961
5127
|
def is_runtime_resource_exhaustion_error(*messages: str | None) -> bool:
|
|
4962
5128
|
"""Return whether the OS refused an agent process, thread, or file watch."""
|
|
4963
5129
|
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.
|
|
1237
|
+
DAEMON_VERSION = "1.85.2"
|
|
1238
1238
|
|
|
1239
1239
|
|
|
1240
1240
|
def _detect_client_type() -> str:
|
|
@@ -3040,7 +3040,7 @@ def _runtime_critic_reference_candidates(
|
|
|
3040
3040
|
if isinstance(related, dict):
|
|
3041
3041
|
texts.append(str(related.get("description") or ""))
|
|
3042
3042
|
texts.extend(str(item.get("description") or "") for item in related.get("items") or [] if isinstance(item, dict))
|
|
3043
|
-
if planned.get("type")
|
|
3043
|
+
if planned.get("type") in ("design", "testing", "review"):
|
|
3044
3044
|
texts.append(json.dumps({"analysis": baseline.get("approved_analysis"),
|
|
3045
3045
|
"documents": baseline.get("analysis_documents")}, ensure_ascii=True)[:200_000])
|
|
3046
3046
|
text = re.sub(r"https?://\S+", " ", "\n".join(texts))
|
|
@@ -3063,7 +3063,7 @@ def _runtime_critic_reference_candidates(
|
|
|
3063
3063
|
php_context = any(Path(path).suffix.lower() == ".php" for path in source_paths)
|
|
3064
3064
|
directories = {Path(path).parent.as_posix() for path in source_paths if safe_path(path)} - {"."}
|
|
3065
3065
|
explicit = set()
|
|
3066
|
-
for token in re.findall(r"(?<![
|
|
3066
|
+
for token in re.findall(r"(?<![A-Za-z0-9_./\\:-])[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+", text):
|
|
3067
3067
|
candidate = safe_path(token)
|
|
3068
3068
|
if candidate is None:
|
|
3069
3069
|
continue
|
|
@@ -6439,8 +6439,8 @@ class ProcessManager:
|
|
|
6439
6439
|
int(result.metrics.get("token_input", 0) or 0)
|
|
6440
6440
|
+ int(result.metrics.get("token_output", 0) or 0)
|
|
6441
6441
|
) > 0
|
|
6442
|
-
signals =
|
|
6443
|
-
"\n".join(part for part in (stdout, stderr) if part)
|
|
6442
|
+
signals = agent_core._extract_output_signals(
|
|
6443
|
+
"\n".join(part for part in (stdout, stderr) if part), result.metrics,
|
|
6444
6444
|
)
|
|
6445
6445
|
if not has_token_usage and ProcessManager._should_scan_short_success_stdout(stdout, signals):
|
|
6446
6446
|
error_channels = "\n".join(filter(None, [error_channels, stdout.strip()]))
|
|
@@ -8484,15 +8484,18 @@ class ProcessManager:
|
|
|
8484
8484
|
|
|
8485
8485
|
model_discovery_detail = "\n".join([*structured_errors, stderr]).lower()
|
|
8486
8486
|
if (
|
|
8487
|
-
|
|
8488
|
-
|
|
8487
|
+
agent_core.is_copilot_model_discovery_transient_transport_error(
|
|
8488
|
+
*structured_errors, stderr,
|
|
8489
|
+
)
|
|
8490
|
+
and "misdirected request" not in model_discovery_detail
|
|
8489
8491
|
and not _retry_model_discovery_timeout
|
|
8490
8492
|
and not (copilot_exit == 0 and returncode == 0)
|
|
8491
8493
|
and not signals["has_meaningful_content"]
|
|
8492
8494
|
and not self._copilot_called_any_tools(stdout)
|
|
8493
8495
|
):
|
|
8494
8496
|
logger.warning(
|
|
8495
|
-
"Copilot model discovery
|
|
8497
|
+
"Copilot model discovery hit a transient transport error "
|
|
8498
|
+
"(timeout / connect reset) before work for task %s; "
|
|
8496
8499
|
"retrying once with the same credentials and configuration",
|
|
8497
8500
|
task_id,
|
|
8498
8501
|
)
|
|
@@ -13998,6 +14001,7 @@ class RuntimeDaemon:
|
|
|
13998
14001
|
|
|
13999
14002
|
after_sha = str((result.git or {}).get("commit_sha") or before_sha or "").strip()
|
|
14000
14003
|
committed_sources: dict[str, str] = {}
|
|
14004
|
+
unavailable_sources: list[dict[str, str]] = []
|
|
14001
14005
|
source_bytes = 0
|
|
14002
14006
|
coverage = {
|
|
14003
14007
|
"candidate_files": 0, "full_source_files": 0, "unreadable_files": 0,
|
|
@@ -14026,6 +14030,27 @@ class RuntimeDaemon:
|
|
|
14026
14030
|
"test-cases.json", "coverage-matrix.json", "test-evidence.json", "test-report.md",
|
|
14027
14031
|
}
|
|
14028
14032
|
))
|
|
14033
|
+
if output_dir.startswith(f"docs/requirements/{task.requirement_key}/") and ".." not in Path(output_dir).parts:
|
|
14034
|
+
verification_dir = output_dir + "/verification"
|
|
14035
|
+
try:
|
|
14036
|
+
verification_paths = await self.workspace_manager._git(
|
|
14037
|
+
"ls-tree", "-r", "--name-only", "-z", after_sha, "--", *asset_paths, verification_dir,
|
|
14038
|
+
cwd=workspace_path, timeout=10,
|
|
14039
|
+
)
|
|
14040
|
+
if len(verification_paths.encode("utf-8")) > _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
|
|
14041
|
+
raise ValueError("Committed verification inventory exceeds its bounded evidence budget")
|
|
14042
|
+
existing_artifacts = set(verification_paths.split("\0"))
|
|
14043
|
+
asset_paths = [path for path in asset_paths if path in existing_artifacts]
|
|
14044
|
+
paths.extend(path for path in verification_paths.split("\0") if (
|
|
14045
|
+
path.startswith(verification_dir + "/") and ".." not in Path(path).parts
|
|
14046
|
+
and re.fullmatch(r"[A-Za-z0-9_./-]+", path)
|
|
14047
|
+
and Path(path).suffix.lower() in {".php", ".py", ".ts", ".js", ".json", ".md", ".txt"}
|
|
14048
|
+
and not _is_sensitive_local_path(path)
|
|
14049
|
+
and Path(path).name.lower() not in {"env.php", "auth.json", "credentials.json", "secrets.json", "service-account.json"}
|
|
14050
|
+
and not any(part in {".git", ".forgexa", ".aws", ".ssh", ".env"} or part.startswith(".env.") for part in Path(path).parts)
|
|
14051
|
+
))
|
|
14052
|
+
except Exception:
|
|
14053
|
+
coverage["verification_inventory_unavailable"] = True
|
|
14029
14054
|
paths.extend(asset_paths)
|
|
14030
14055
|
support_dirs = sorted({(Path(path).parent / "support").as_posix() for path in asset_paths if path.startswith("tests/")})
|
|
14031
14056
|
for directory in support_dirs[:5]:
|
|
@@ -14117,8 +14142,19 @@ class RuntimeDaemon:
|
|
|
14117
14142
|
source = await self.workspace_manager._git(
|
|
14118
14143
|
"show", f"{after_sha}:{path}", cwd=workspace_path, timeout=10,
|
|
14119
14144
|
)
|
|
14120
|
-
except Exception:
|
|
14145
|
+
except Exception as error:
|
|
14121
14146
|
coverage["unreadable_files"] += 1
|
|
14147
|
+
detail = str(error).lower()
|
|
14148
|
+
reason = "source_git_read_failed"
|
|
14149
|
+
if isinstance(error, UnicodeDecodeError):
|
|
14150
|
+
reason = "source_encoding_unavailable"
|
|
14151
|
+
elif isinstance(error, TimeoutError) or "timed out after" in detail:
|
|
14152
|
+
reason = "source_read_timeout"
|
|
14153
|
+
elif "cannot be mapped" in detail or "cannot allocate memory" in detail:
|
|
14154
|
+
reason = "source_memory_unavailable"
|
|
14155
|
+
elif "does not exist in" in detail or "exists on disk, but not in" in detail:
|
|
14156
|
+
reason = "source_path_unavailable"
|
|
14157
|
+
unavailable_sources.append({"path": path, "reason": reason})
|
|
14122
14158
|
continue
|
|
14123
14159
|
encoded_size = len(source.encode("utf-8"))
|
|
14124
14160
|
if source_bytes + encoded_size <= _RUNTIME_CRITIC_SOURCE_MAX_BYTES:
|
|
@@ -14136,7 +14172,10 @@ class RuntimeDaemon:
|
|
|
14136
14172
|
diff = (diff or "No new changes; review committed delivery instead.") + "".join(source_context)
|
|
14137
14173
|
coverage["full_source_files"] = len(committed_sources)
|
|
14138
14174
|
coverage["omitted_files"] = coverage["candidate_files"] - coverage["full_source_files"]
|
|
14139
|
-
coverage["capsule_incomplete"] =
|
|
14175
|
+
coverage["capsule_incomplete"] = (
|
|
14176
|
+
coverage["omitted_files"] > 0 or coverage.get("reference_inventory_unavailable", False)
|
|
14177
|
+
or coverage.get("verification_inventory_unavailable", False)
|
|
14178
|
+
)
|
|
14140
14179
|
result.metrics["runtime_critic_coverage"] = coverage
|
|
14141
14180
|
if not diff:
|
|
14142
14181
|
return {
|
|
@@ -14164,6 +14203,8 @@ class RuntimeDaemon:
|
|
|
14164
14203
|
work_item={},
|
|
14165
14204
|
)
|
|
14166
14205
|
review_task.input_prompt += "\n## Review Evidence Coverage\n" + json.dumps(coverage, sort_keys=True)
|
|
14206
|
+
if unavailable_sources:
|
|
14207
|
+
review_task.input_prompt += "\n## Unavailable Committed Sources\n" + json.dumps(unavailable_sources, sort_keys=True)
|
|
14167
14208
|
if coverage["capsule_incomplete"]:
|
|
14168
14209
|
review_task.input_prompt += (
|
|
14169
14210
|
"\nSome candidate files were not available as complete source. Assess the evidence you can inspect; "
|
|
@@ -14183,6 +14224,7 @@ class RuntimeDaemon:
|
|
|
14183
14224
|
"reviewed_commit_sha": after_sha,
|
|
14184
14225
|
"baseline_fingerprint": baseline.get("fingerprint") if isinstance(baseline, dict) else None,
|
|
14185
14226
|
"input_sha256": hashlib.sha256(review_task.input_prompt.encode("utf-8")).hexdigest(),
|
|
14227
|
+
"unavailable_sources": unavailable_sources,
|
|
14186
14228
|
"snapshots": [
|
|
14187
14229
|
{"path": path, "sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
|
|
14188
14230
|
"bytes": len(source.encode("utf-8"))}
|
|
@@ -14427,6 +14469,7 @@ class RuntimeDaemon:
|
|
|
14427
14469
|
audit_contract = input_data.get("implementation_audit_contract")
|
|
14428
14470
|
result.metrics.pop("implementation_audit_node_complete", None)
|
|
14429
14471
|
result.metrics.pop("implementation_audit_subject_blocked", None)
|
|
14472
|
+
result.metrics.pop("failure_domain", None)
|
|
14430
14473
|
if isinstance(audit_contract, dict):
|
|
14431
14474
|
agent_id = str(result.metrics.get("actual_agent") or getattr(task, "agent_type", "") or "")
|
|
14432
14475
|
audit_report = agent_core.extract_implementation_audit(_daemon_extract_agent_output(agent_id, result.stdout))
|
|
@@ -14437,13 +14480,17 @@ class RuntimeDaemon:
|
|
|
14437
14480
|
if isinstance(audit_report, dict):
|
|
14438
14481
|
result.observations.append({"type": "implementation_audit", "data": audit_report})
|
|
14439
14482
|
audit_result = agent_core.assess_implementation_audit(audit_contract, audit_report)
|
|
14483
|
+
failure_domain = agent_core.implementation_audit_failure_domain(audit_contract, audit_result)
|
|
14484
|
+
if failure_domain is not None:
|
|
14485
|
+
result.metrics["failure_domain"] = failure_domain
|
|
14440
14486
|
result.metrics["implementation_audit_node_complete"] = bool(
|
|
14441
14487
|
audit_result["report_valid"] and audit_result["node_complete"]
|
|
14442
14488
|
)
|
|
14443
14489
|
result.metrics["implementation_audit_subject_blocked"] = bool(
|
|
14444
14490
|
audit_result["report_valid"] and not audit_result["node_complete"]
|
|
14445
|
-
and
|
|
14446
|
-
|
|
14491
|
+
and (failure_domain == "environment" or any(
|
|
14492
|
+
finding.get("task_id") == audit_contract.get("subject_task_id") and finding.get("status") == "blocked"
|
|
14493
|
+
for finding in audit_result.get("findings") or []))
|
|
14447
14494
|
)
|
|
14448
14495
|
if audit_result["report_valid"] and audit_result["status"] == "plan_revision_required":
|
|
14449
14496
|
result.status = "failed"
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|