engineering-platform 2.2.0__py3-none-any.whl
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.
- engineering_platform/ENGINEERING_PLATFORM_CONFIG.json +32 -0
- engineering_platform/ENGINEERING_PLATFORM_VERSION.json +15 -0
- engineering_platform/__init__.py +1 -0
- engineering_platform/__main__.py +7 -0
- engineering_platform/agent_state.py +530 -0
- engineering_platform/agent_trust.py +174 -0
- engineering_platform/assets/dashboard.css +1317 -0
- engineering_platform/assets/dashboard.js +8534 -0
- engineering_platform/assets/dashboard_locales.mjs +4049 -0
- engineering_platform/assets/dashboard_status_store.mjs +41 -0
- engineering_platform/assets/operations-console/apple-touch-icon-dark.png +0 -0
- engineering_platform/assets/operations-console/apple-touch-icon-light.png +0 -0
- engineering_platform/assets/operations-console/icon-dark.png +0 -0
- engineering_platform/assets/operations-console/icon-light.png +0 -0
- engineering_platform/assets/operations-console/icon-transparent.png +0 -0
- engineering_platform/assets/operations-console/manifest.webmanifest +11 -0
- engineering_platform/capability_preflight.py +285 -0
- engineering_platform/capability_review.py +261 -0
- engineering_platform/central_data_transfer.py +195 -0
- engineering_platform/central_database.py +245 -0
- engineering_platform/central_store_migration.py +1672 -0
- engineering_platform/codex_capacity.py +81 -0
- engineering_platform/codex_chat.py +226 -0
- engineering_platform/codex_observability.py +153 -0
- engineering_platform/component_lock.py +40 -0
- engineering_platform/component_logging.py +420 -0
- engineering_platform/console_presentation.py +14 -0
- engineering_platform/console_route_ownership.py +83 -0
- engineering_platform/contracts/__init__.py +38 -0
- engineering_platform/contracts/ep_consumer.py +391 -0
- engineering_platform/contracts/models.py +105 -0
- engineering_platform/contracts/projection.py +401 -0
- engineering_platform/dashboard_browser_validation.py +206 -0
- engineering_platform/dashboard_state.py +630 -0
- engineering_platform/dashboard_supervisor.swift +105 -0
- engineering_platform/dashboard_translation.py +129 -0
- engineering_platform/dependabot_producer.py +349 -0
- engineering_platform/drift_diagnostics.py +144 -0
- engineering_platform/emergency_recovery.py +268 -0
- engineering_platform/engineering_memory.py +139 -0
- engineering_platform/ep_consumer_credentials.py +473 -0
- engineering_platform/evidence_projection.py +213 -0
- engineering_platform/execution_activity.py +218 -0
- engineering_platform/execution_context.py +132 -0
- engineering_platform/execution_errors.py +42 -0
- engineering_platform/execution_evidence.py +24 -0
- engineering_platform/execution_executor.py +730 -0
- engineering_platform/execution_finalization.py +44 -0
- engineering_platform/execution_host.py +3306 -0
- engineering_platform/execution_lease.py +365 -0
- engineering_platform/execution_lifecycle.py +447 -0
- engineering_platform/execution_models.py +43 -0
- engineering_platform/execution_readiness.py +166 -0
- engineering_platform/execution_reporting.py +1607 -0
- engineering_platform/execution_repository.py +253 -0
- engineering_platform/execution_timeout_policy.py +56 -0
- engineering_platform/execution_timing.py +440 -0
- engineering_platform/execution_transaction.py +28 -0
- engineering_platform/external_producer_binding.py +235 -0
- engineering_platform/file_inbox.py +249 -0
- engineering_platform/forensic_attribution.py +338 -0
- engineering_platform/forensic_attribution_v2.py +134 -0
- engineering_platform/forensic_delta.py +299 -0
- engineering_platform/golden_scenario.py +63 -0
- engineering_platform/historical_dashboard_configuration.py +171 -0
- engineering_platform/host_admin.py +199 -0
- engineering_platform/host_preflight.py +231 -0
- engineering_platform/installation_relocation.py +122 -0
- engineering_platform/investigation_ledger.py +89 -0
- engineering_platform/legacy_inbox_migration.py +79 -0
- engineering_platform/lifecycle_worker.py +223 -0
- engineering_platform/live_status.py +267 -0
- engineering_platform/local_api.py +209 -0
- engineering_platform/local_api_keychain.py +51 -0
- engineering_platform/local_repository_binding.py +138 -0
- engineering_platform/managed_autonomy.py +509 -0
- engineering_platform/managed_codex_runtime.py +105 -0
- engineering_platform/parity_context.py +203 -0
- engineering_platform/parity_lifecycle_dispatcher.py +488 -0
- engineering_platform/platform_admin.py +13 -0
- engineering_platform/platform_api.py +428 -0
- engineering_platform/platform_bootstrap.py +385 -0
- engineering_platform/platform_components.py +65 -0
- engineering_platform/platform_version.py +171 -0
- engineering_platform/pr_check_repair.py +276 -0
- engineering_platform/pr_evidence_backfill.py +278 -0
- engineering_platform/producer.py +209 -0
- engineering_platform/project_agent.py +366 -0
- engineering_platform/project_agent_service.py +244 -0
- engineering_platform/project_topology.py +126 -0
- engineering_platform/prompt_history.py +591 -0
- engineering_platform/provider_context.py +136 -0
- engineering_platform/provider_context_benchmark.py +41 -0
- engineering_platform/provider_context_scope.py +90 -0
- engineering_platform/provider_interruption.py +168 -0
- engineering_platform/provider_process_identity.py +80 -0
- engineering_platform/provider_readiness.py +138 -0
- engineering_platform/provider_recovery.py +647 -0
- engineering_platform/provider_usage.py +497 -0
- engineering_platform/providers.py +471 -0
- engineering_platform/qualification.py +220 -0
- engineering_platform/recommendation_handoff.py +238 -0
- engineering_platform/report_analysis.py +193 -0
- engineering_platform/repository_attachment.py +171 -0
- engineering_platform/repository_handoff.py +95 -0
- engineering_platform/resources.py +38 -0
- engineering_platform/reviewer_evidence.py +70 -0
- engineering_platform/schemas/repository-attachment.schema.json +61 -0
- engineering_platform/server.py +3679 -0
- engineering_platform/server_console_services.py +2024 -0
- engineering_platform/server_relay.py +172 -0
- engineering_platform/server_service.py +122 -0
- engineering_platform/status_model.py +135 -0
- engineering_platform/status_reconciliation.py +34 -0
- engineering_platform/storage.py +2440 -0
- engineering_platform/submission_cli.py +77 -0
- engineering_platform/submission_intake.py +45 -0
- engineering_platform/submission_service.py +317 -0
- engineering_platform/telemetry.py +951 -0
- engineering_platform/templates/workspace-config.json +25 -0
- engineering_platform/validation_identity.py +50 -0
- engineering_platform/validation_profile.py +211 -0
- engineering_platform/workspace_preflight.py +263 -0
- engineering_platform/worktree_provenance.py +147 -0
- engineering_platform/worktree_tooling.py +18 -0
- engineering_platform-2.2.0.dist-info/METADATA +18 -0
- engineering_platform-2.2.0.dist-info/RECORD +130 -0
- engineering_platform-2.2.0.dist-info/WHEEL +5 -0
- engineering_platform-2.2.0.dist-info/entry_points.txt +6 -0
- engineering_platform-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""Codex execution evidence helpers, isolated from lifecycle coordination."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import signal
|
|
12
|
+
import sqlite3
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from threading import Event, Thread
|
|
17
|
+
from typing import Callable, Mapping
|
|
18
|
+
|
|
19
|
+
from .capability_review import ReviewerResult, ReviewerSelection, reviewer_prompt
|
|
20
|
+
from .codex_observability import codex_final_message as _codex_final_message, extract_codex_runtime_metadata, extract_codex_usage
|
|
21
|
+
from .evidence_projection import ToolProxyEnvironment
|
|
22
|
+
from .execution_context import additional_workspace_write_roots
|
|
23
|
+
from .execution_errors import CodexHandoffTimeout, CodexInvocationError, RunnerError
|
|
24
|
+
from .execution_timeout_policy import SPECIALIST_REVIEW
|
|
25
|
+
from .execution_models import AgentResult
|
|
26
|
+
from .platform_version import detected_codex_cli_version
|
|
27
|
+
from .provider_usage import churn_from_jsonl, usage_from_jsonl, usage_snapshots_from_jsonl
|
|
28
|
+
from .providers import CodexCliProvider
|
|
29
|
+
from .reviewer_evidence import ReviewerEvidence
|
|
30
|
+
from .storage import EngineeringStorageError, open_storage, record_artifact, verify_artifact_integrity
|
|
31
|
+
from .agent_state import redact_diagnostic
|
|
32
|
+
from .component_logging import component_logger, log_event
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_LIVE_ACTION_NAME_DISALLOWED = re.compile(r"(?:https?://|[/\\\\`]|\b(?:api[_ -]?key|token|secret|password|authorization|bearer)\b)", re.IGNORECASE)
|
|
36
|
+
_CODEX_USAGE_LIMIT = re.compile(
|
|
37
|
+
r"(?:you(?:'ve| have) hit your usage limit|purchase more credits|try again at)",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
_QUALITY_EVIDENCE_ACTIVITIES = frozenset({"REFACTOR", "TEST_COVERAGE", "DOCUMENTATION", "VALIDATION", "NO_CHANGE_REQUIRED"})
|
|
41
|
+
MAX_RETAINED_VALIDATION_OUTPUT_CHARACTERS = 8_000
|
|
42
|
+
REVIEWER_INVOCATION_TIMEOUT_SECONDS = SPECIALIST_REVIEW.seconds
|
|
43
|
+
_VALIDATION_STREAM_LIMIT = MAX_RETAINED_VALIDATION_OUTPUT_CHARACTERS // 2
|
|
44
|
+
_UNITTEST_FAILURE = re.compile(r"^(?:FAIL|ERROR): [^(]+ \(([^)]+)\)$", re.MULTILINE)
|
|
45
|
+
_UNITTEST_COUNTS = re.compile(r"FAILED \((?P<details>[^)]*)\)")
|
|
46
|
+
_UNITTEST_COUNT = re.compile(r"\b(?P<name>failures|errors)=(?P<count>\d+)\b")
|
|
47
|
+
_TURN_ABORTED = re.compile(r'"type"\s*:\s*"turn_aborted"[^\n]*"reason"\s*:\s*"interrupted"', re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def provider_turn_interruption(stdout: str, stderr: str) -> str | None:
|
|
51
|
+
"""Classify only provider-proven interrupted turns without inventing a result."""
|
|
52
|
+
if _TURN_ABORTED.search(f"{stdout}\n{stderr}"):
|
|
53
|
+
return "interrupted"
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def codex_failure_disposition(
|
|
58
|
+
exit_code: int, stdout: str, stderr: str
|
|
59
|
+
) -> tuple[str, str, str]:
|
|
60
|
+
"""Return the safe action and checkpoint status for a Codex CLI failure.
|
|
61
|
+
|
|
62
|
+
A provider-side quota is not an implementation failure and, crucially, is
|
|
63
|
+
not evidence that a prior pull request still awaits an operator. Keep the
|
|
64
|
+
provider wording out of durable state while retaining a specific recovery
|
|
65
|
+
path for the Operations Console.
|
|
66
|
+
"""
|
|
67
|
+
if _CODEX_USAGE_LIMIT.search(f"{stderr}\n{stdout}"):
|
|
68
|
+
return (
|
|
69
|
+
"resolve_codex_usage_limit",
|
|
70
|
+
"codex_usage_limit_reached",
|
|
71
|
+
"Codex usage limit reached. Add Codex credits or resume after the account limit resets.",
|
|
72
|
+
)
|
|
73
|
+
return (
|
|
74
|
+
"inspect_codex_cli",
|
|
75
|
+
"codex_invocation_failed",
|
|
76
|
+
f"Codex CLI exited with code {exit_code}; inspect this invocation's console output.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def project_codex_activity(event: object) -> str | None:
|
|
81
|
+
"""Map one JSONL event to an approved, prompt-free activity label."""
|
|
82
|
+
if not isinstance(event, dict) or event.get("type") not in {"item.started", "item.updated"}:
|
|
83
|
+
return None
|
|
84
|
+
item = event.get("item")
|
|
85
|
+
if not isinstance(item, dict):
|
|
86
|
+
return None
|
|
87
|
+
return {
|
|
88
|
+
"reasoning": "Codex plant de volgende stap",
|
|
89
|
+
"command_execution": "Codex voert een opdracht uit",
|
|
90
|
+
"file_change": "Codex bewerkt bestanden",
|
|
91
|
+
"web_search": "Codex onderzoekt referentiemateriaal",
|
|
92
|
+
"mcp_tool_call": "Codex gebruikt een ontwikkeltool",
|
|
93
|
+
"agent_message": "Codex formuleert het resultaat",
|
|
94
|
+
}.get(item.get("type"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def project_codex_live_action_name(event: object) -> str | None:
|
|
98
|
+
"""Return a short, transient Codex reasoning title when it is safe to show.
|
|
99
|
+
|
|
100
|
+
This is intentionally separate from the persisted activity category. The
|
|
101
|
+
value is only rendered in the live status file and is removed when the run
|
|
102
|
+
reaches a terminal phase; reports, history, diagnostics and the database
|
|
103
|
+
never receive it.
|
|
104
|
+
"""
|
|
105
|
+
if not isinstance(event, dict) or event.get("type") not in {"item.started", "item.updated"}:
|
|
106
|
+
return None
|
|
107
|
+
item = event.get("item")
|
|
108
|
+
if not isinstance(item, dict) or item.get("type") != "reasoning":
|
|
109
|
+
return None
|
|
110
|
+
text = item.get("text")
|
|
111
|
+
if not isinstance(text, str):
|
|
112
|
+
return None
|
|
113
|
+
title = redact_diagnostic(text, limit=160)
|
|
114
|
+
if len(title) < 4 or "[REDACTED]" in title or _LIVE_ACTION_NAME_DISALLOWED.search(title):
|
|
115
|
+
return None
|
|
116
|
+
return title
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def project_codex_command_event(event: object) -> tuple[str, str, str, int | None] | None:
|
|
120
|
+
"""Expose direct command boundaries without retaining command content.
|
|
121
|
+
|
|
122
|
+
Codex JSONL identifies command-execution items by a stable item id. The
|
|
123
|
+
host uses this small projection only to classify known validation tools and
|
|
124
|
+
record their observed start/complete boundaries. It must not persist the
|
|
125
|
+
raw command, its output, or any arguments.
|
|
126
|
+
"""
|
|
127
|
+
if not isinstance(event, dict) or event.get("type") not in {"item.started", "item.completed"}:
|
|
128
|
+
return None
|
|
129
|
+
item = event.get("item")
|
|
130
|
+
if not isinstance(item, dict) or item.get("type") != "command_execution":
|
|
131
|
+
return None
|
|
132
|
+
item_id = item.get("id")
|
|
133
|
+
if not isinstance(item_id, str) or not item_id:
|
|
134
|
+
return None
|
|
135
|
+
if event["type"] == "item.completed":
|
|
136
|
+
exit_code = item.get("exit_code")
|
|
137
|
+
return ("completed", item_id, "", exit_code if isinstance(exit_code, int) and not isinstance(exit_code, bool) else None)
|
|
138
|
+
command = item.get("command")
|
|
139
|
+
if not isinstance(command, str):
|
|
140
|
+
return None
|
|
141
|
+
return ("started", item_id, command, None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def redacted_cli_tail(value: str, prompt: str, *, limit: int = 1_200) -> str:
|
|
145
|
+
without_prompt = value.replace(prompt, "[PROMPT_OMITTED]") if prompt else value
|
|
146
|
+
return redact_diagnostic("\n".join(without_prompt.splitlines()[-60:]), limit=limit) or "(empty)"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def format_cli_failure(exit_code: int, stderr: str, stdout: str, prompt: str = "") -> str:
|
|
150
|
+
return "\n".join((f"Codex CLI exit code: {exit_code}", f"stderr tail: {redacted_cli_tail(stderr, prompt)}", f"stdout tail: {redacted_cli_tail(stdout, prompt)}"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def record_redacted_codex_cli_diagnostic(
|
|
154
|
+
root: Path, run_id: str, detail: str, *, central_database: Path,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Persist a bounded CLI failure diagnostic through CENTRAL only.
|
|
157
|
+
|
|
158
|
+
The former checkout-local ``.engineering/logs/codex`` file was a second
|
|
159
|
+
durable operational log surface. The Execution Host is owned by the
|
|
160
|
+
lifecycle worker, so its diagnostic belongs to that canonical component
|
|
161
|
+
identity and to the run that produced it.
|
|
162
|
+
"""
|
|
163
|
+
logger = component_logger(root, "lifecycle_worker", central_database=central_database)
|
|
164
|
+
log_event(
|
|
165
|
+
logger,
|
|
166
|
+
logging.WARNING,
|
|
167
|
+
"codex_cli_diagnostic",
|
|
168
|
+
run_id=run_id,
|
|
169
|
+
diagnostic=redact_diagnostic(detail, limit=3_000),
|
|
170
|
+
context={"target_component": "lifecycle_worker"},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _bounded_redacted_validation_tail(value: str | None, *, limit: int = _VALIDATION_STREAM_LIMIT) -> tuple[str | None, bool, bool]:
|
|
175
|
+
if not isinstance(value, str):
|
|
176
|
+
return None, False, False
|
|
177
|
+
tail = value[-limit:]
|
|
178
|
+
# Apply the repository's existing redactor line-by-line so traceback
|
|
179
|
+
# boundaries remain useful while its secret policy remains authoritative.
|
|
180
|
+
rendered = "\n".join(redact_diagnostic(line, limit=limit) for line in tail.replace("\x00", " ").splitlines())
|
|
181
|
+
if len(rendered) > limit:
|
|
182
|
+
rendered = rendered[-limit:]
|
|
183
|
+
return rendered, len(value) > limit, rendered != tail
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def validation_failure_artifact_id(command_id: str) -> str:
|
|
187
|
+
return f"validation-failure-diagnostic-{command_id}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def persist_validation_failure_diagnostic(
|
|
191
|
+
root: Path, *, run_id: str, command_id: str, validation_id: str,
|
|
192
|
+
control_identity: str, exit_code: int | None, stdout: str | None,
|
|
193
|
+
stderr: str | None, capture_available: bool, captured_at: str | None = None,
|
|
194
|
+
central_database: Path | None = None, artifact_root: Path | None = None,
|
|
195
|
+
) -> str:
|
|
196
|
+
"""Persist bounded, redacted, supplementary output for any failed control."""
|
|
197
|
+
# Extract stable unittest identifiers/counts before the generic redactor
|
|
198
|
+
# treats ``name=value`` failure summaries as environment assignments. Raw
|
|
199
|
+
# output remains in process memory only and is never persisted wholesale.
|
|
200
|
+
raw_combined = "\n".join(value for value in (stdout, stderr) if isinstance(value, str))
|
|
201
|
+
identities = list(dict.fromkeys(_UNITTEST_FAILURE.findall(raw_combined)))[:20]
|
|
202
|
+
details = _UNITTEST_COUNTS.search(raw_combined)
|
|
203
|
+
counts = {match.group("name"): int(match.group("count")) for match in _UNITTEST_COUNT.finditer(details.group("details"))} if details else {}
|
|
204
|
+
stdout_tail, stdout_truncated, stdout_redacted = _bounded_redacted_validation_tail(stdout)
|
|
205
|
+
stderr_tail, stderr_truncated, stderr_redacted = _bounded_redacted_validation_tail(stderr)
|
|
206
|
+
capture_is_available = capture_available and stdout_tail is not None and stderr_tail is not None
|
|
207
|
+
created_at = captured_at or datetime.now(timezone.utc).isoformat()
|
|
208
|
+
artifact_id = validation_failure_artifact_id(command_id)
|
|
209
|
+
payload = {
|
|
210
|
+
"schema": "deterministic-validation-failure-diagnostic-v1",
|
|
211
|
+
"validation_id": validation_id, "command_id": command_id,
|
|
212
|
+
"control_identity": control_identity, "authoritative_exit_code": exit_code,
|
|
213
|
+
"captured_at": created_at,
|
|
214
|
+
"capture_status": "AVAILABLE" if capture_is_available else "UNAVAILABLE",
|
|
215
|
+
"stdout_tail": stdout_tail, "stderr_tail": stderr_tail,
|
|
216
|
+
"stdout_truncated": stdout_truncated, "stderr_truncated": stderr_truncated,
|
|
217
|
+
"retained_output_characters": sum(len(value or "") for value in (stdout_tail, stderr_tail)),
|
|
218
|
+
"maximum_retained_output_characters": MAX_RETAINED_VALIDATION_OUTPUT_CHARACTERS,
|
|
219
|
+
"truncation_strategy": "tail_per_stream",
|
|
220
|
+
"redaction_applied": stdout_redacted or stderr_redacted,
|
|
221
|
+
"redaction_policy": "agent_state.redact_diagnostic/v1",
|
|
222
|
+
"failing_test_identities": identities,
|
|
223
|
+
"failure_count": counts.get("failures"), "error_count": counts.get("errors"),
|
|
224
|
+
}
|
|
225
|
+
directory = ((artifact_root / "validation-failure-diagnostics") if artifact_root else
|
|
226
|
+
(root / ".engineering" / "artifacts" / "validation-failure-diagnostics"))
|
|
227
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
228
|
+
path = directory / f"{artifact_id}.json"
|
|
229
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{artifact_id}.", suffix=".tmp", dir=directory)
|
|
230
|
+
try:
|
|
231
|
+
os.fchmod(descriptor, 0o600)
|
|
232
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
233
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
234
|
+
handle.write("\n")
|
|
235
|
+
handle.flush()
|
|
236
|
+
os.fsync(handle.fileno())
|
|
237
|
+
os.replace(temporary, path)
|
|
238
|
+
finally:
|
|
239
|
+
Path(temporary).unlink(missing_ok=True)
|
|
240
|
+
try:
|
|
241
|
+
record_artifact(
|
|
242
|
+
root, path, artifact_id=artifact_id,
|
|
243
|
+
artifact_type="VALIDATION_FAILURE_DIAGNOSTIC",
|
|
244
|
+
content_type="application/json", created_at=created_at, run_id=run_id,
|
|
245
|
+
execution_id=command_id,
|
|
246
|
+
central_database=central_database, artifact_root=artifact_root,
|
|
247
|
+
)
|
|
248
|
+
except EngineeringStorageError:
|
|
249
|
+
path.unlink(missing_ok=True)
|
|
250
|
+
raise
|
|
251
|
+
return f"artifact:{artifact_id}"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def load_validation_failure_diagnostic(
|
|
255
|
+
root: Path, artifact_reference: str, *, central_database: Path | None = None,
|
|
256
|
+
artifact_root: Path | None = None,
|
|
257
|
+
) -> dict[str, object] | None:
|
|
258
|
+
"""Read a bound diagnostic only after its immutable artifact verifies."""
|
|
259
|
+
if not artifact_reference.startswith("artifact:"):
|
|
260
|
+
return None
|
|
261
|
+
artifact_id = artifact_reference.removeprefix("artifact:")
|
|
262
|
+
if not verify_artifact_integrity(
|
|
263
|
+
root, artifact_id, central_database=central_database, artifact_root=artifact_root,
|
|
264
|
+
):
|
|
265
|
+
return None
|
|
266
|
+
connection = open_storage(root) if central_database is None else sqlite3.connect(central_database.resolve(), isolation_level=None)
|
|
267
|
+
try:
|
|
268
|
+
row = connection.execute(
|
|
269
|
+
"SELECT storage_location,artifact_type FROM execution_artifact_records WHERE artifact_id=?",
|
|
270
|
+
(artifact_id,),
|
|
271
|
+
).fetchone()
|
|
272
|
+
finally:
|
|
273
|
+
connection.close()
|
|
274
|
+
if not row or row[1] != "VALIDATION_FAILURE_DIAGNOSTIC":
|
|
275
|
+
return None
|
|
276
|
+
try:
|
|
277
|
+
authority_root = artifact_root.resolve() if artifact_root is not None else (root / ".engineering").resolve()
|
|
278
|
+
payload_path = (authority_root / str(row[0])).resolve()
|
|
279
|
+
payload_path.relative_to(authority_root)
|
|
280
|
+
payload = json.loads(payload_path.read_text(encoding="utf-8"))
|
|
281
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
282
|
+
return None
|
|
283
|
+
return payload if isinstance(payload, dict) else None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
_format_cli_failure = format_cli_failure
|
|
287
|
+
|
|
288
|
+
# A managed Engineering transaction has already passed host-owned admission,
|
|
289
|
+
# repository synchronization and an exclusive execution lease. It must be
|
|
290
|
+
# able to create its bounded branch, commit, and draft PR; `workspace-write`
|
|
291
|
+
# deliberately rejects Git index writes and therefore cannot complete that
|
|
292
|
+
# contract. Review-only invocations remain read-only below.
|
|
293
|
+
MANAGED_EXECUTION_SANDBOX = "danger-full-access"
|
|
294
|
+
|
|
295
|
+
class CodexCliClient:
|
|
296
|
+
def __init__(self, provider: CodexCliProvider | None = None) -> None:
|
|
297
|
+
self.provider = provider or CodexCliProvider()
|
|
298
|
+
self.last_usage: dict[str, int | float | str] = {}
|
|
299
|
+
self.last_usage_snapshots: tuple[dict[str, int], ...] = ()
|
|
300
|
+
self.last_churn: dict[str, int] = {}
|
|
301
|
+
self.last_context_escalations: tuple[dict[str, object], ...] = ()
|
|
302
|
+
self.last_execution_seconds: float | None = None
|
|
303
|
+
self.last_runtime_metadata = self._runtime_metadata()
|
|
304
|
+
# This deliberately contains only aggregate counters, plus the
|
|
305
|
+
# approved EP-managed CLI prefix as invocation provenance. Command
|
|
306
|
+
# text and command output are never retained in execution metadata.
|
|
307
|
+
self.last_execution_metadata: dict[str, int] = {
|
|
308
|
+
"modified": 0,
|
|
309
|
+
"created": 0,
|
|
310
|
+
"deleted": 0,
|
|
311
|
+
"codex_commands_executed": 0,
|
|
312
|
+
}
|
|
313
|
+
self._activity_callback: Callable[[str], None] | None = None
|
|
314
|
+
self._transient_action_callback: Callable[[str], None] | None = None
|
|
315
|
+
self._process_callback: Callable[[dict[str, int] | None], None] | None = None
|
|
316
|
+
self._runtime_metadata_callback: Callable[[dict[str, str]], None] | None = None
|
|
317
|
+
self._command_callback: Callable[..., None] | None = None
|
|
318
|
+
self._workspace_progress_callback: Callable[[dict[str, int]], None] | None = None
|
|
319
|
+
self._handoff_deadline_callback: Callable[[], bool] | None = None
|
|
320
|
+
|
|
321
|
+
def _runtime_metadata(self) -> dict[str, str]:
|
|
322
|
+
metadata = {"runtime_provider": "codex_cli"}
|
|
323
|
+
installation_path_reader = getattr(self.provider, "managed_installation_path", None)
|
|
324
|
+
installation_path = installation_path_reader() if callable(installation_path_reader) else None
|
|
325
|
+
if isinstance(installation_path, str) and installation_path:
|
|
326
|
+
metadata["codex_cli_installation_path"] = installation_path
|
|
327
|
+
return metadata
|
|
328
|
+
|
|
329
|
+
def set_activity_callback(self, callback: Callable[[str], None] | None) -> None:
|
|
330
|
+
"""Set the optional local-only sink for safe live activity labels."""
|
|
331
|
+
self._activity_callback = callback
|
|
332
|
+
|
|
333
|
+
def set_transient_action_callback(self, callback: Callable[[str], None] | None) -> None:
|
|
334
|
+
"""Set the non-persistent sink for a safe live Codex action name."""
|
|
335
|
+
self._transient_action_callback = callback
|
|
336
|
+
|
|
337
|
+
def set_process_callback(self, callback: Callable[[dict[str, int] | None], None] | None) -> None:
|
|
338
|
+
"""Set the owned foreground Codex-process sink for runtime metrics."""
|
|
339
|
+
self._process_callback = callback
|
|
340
|
+
|
|
341
|
+
def set_runtime_metadata_callback(
|
|
342
|
+
self, callback: Callable[[dict[str, str]], None] | None
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Publish only explicitly reported runtime settings during a live run."""
|
|
345
|
+
self._runtime_metadata_callback = callback
|
|
346
|
+
|
|
347
|
+
def set_command_callback(self, callback: Callable[..., None] | None) -> None:
|
|
348
|
+
"""Set a direct JSONL command-boundary sink for execution telemetry."""
|
|
349
|
+
self._command_callback = callback
|
|
350
|
+
|
|
351
|
+
def set_workspace_progress_callback(
|
|
352
|
+
self, callback: Callable[[dict[str, int]], None] | None
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Set a bounded, filename-free workspace change counter sink."""
|
|
355
|
+
self._workspace_progress_callback = callback
|
|
356
|
+
|
|
357
|
+
def set_handoff_deadline_callback(self, callback: Callable[[], bool] | None) -> None:
|
|
358
|
+
"""Set a host-owned deadline check for an externally observable hand-off."""
|
|
359
|
+
self._handoff_deadline_callback = callback
|
|
360
|
+
|
|
361
|
+
def available(self) -> bool:
|
|
362
|
+
return self.provider.command("--version").returncode == 0
|
|
363
|
+
|
|
364
|
+
def version(self) -> str:
|
|
365
|
+
completed = self.provider.command("--version")
|
|
366
|
+
if completed.returncode:
|
|
367
|
+
raise RunnerError("Codex CLI version could not be detected")
|
|
368
|
+
return detected_codex_cli_version(completed.stdout)
|
|
369
|
+
|
|
370
|
+
def review(
|
|
371
|
+
self,
|
|
372
|
+
root: Path,
|
|
373
|
+
selection: ReviewerSelection,
|
|
374
|
+
objective: str,
|
|
375
|
+
evidence: ReviewerEvidence | None = None,
|
|
376
|
+
) -> ReviewerResult:
|
|
377
|
+
self.last_usage = {}
|
|
378
|
+
self.last_churn = {}
|
|
379
|
+
self.last_context_escalations = ()
|
|
380
|
+
self.last_execution_seconds = None
|
|
381
|
+
self.last_runtime_metadata = self._runtime_metadata()
|
|
382
|
+
schema = {
|
|
383
|
+
"type": "object",
|
|
384
|
+
"additionalProperties": False,
|
|
385
|
+
"required": ["contribution", "recommendations"],
|
|
386
|
+
"properties": {
|
|
387
|
+
"contribution": {"type": "string", "maxLength": 240},
|
|
388
|
+
"recommendations": {
|
|
389
|
+
"type": "array",
|
|
390
|
+
"maxItems": 3,
|
|
391
|
+
"items": {"type": "string", "maxLength": 240},
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
state_directory = root / ".engineering"
|
|
396
|
+
state_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
397
|
+
with tempfile.NamedTemporaryFile(
|
|
398
|
+
"w", encoding="utf-8", suffix=".json", dir=state_directory, delete=False
|
|
399
|
+
) as handle:
|
|
400
|
+
json.dump(schema, handle)
|
|
401
|
+
schema_path = Path(handle.name)
|
|
402
|
+
try:
|
|
403
|
+
started = time.monotonic()
|
|
404
|
+
proxy = ToolProxyEnvironment()
|
|
405
|
+
with proxy as environment:
|
|
406
|
+
completed = self.provider.invoke(
|
|
407
|
+
root,
|
|
408
|
+
(
|
|
409
|
+
"codex",
|
|
410
|
+
"exec",
|
|
411
|
+
"--sandbox",
|
|
412
|
+
"read-only",
|
|
413
|
+
"-C",
|
|
414
|
+
str(root),
|
|
415
|
+
"--json",
|
|
416
|
+
"--output-schema",
|
|
417
|
+
str(schema_path),
|
|
418
|
+
reviewer_prompt(selection, objective, evidence),
|
|
419
|
+
), environment=environment, timeout=REVIEWER_INVOCATION_TIMEOUT_SECONDS,
|
|
420
|
+
)
|
|
421
|
+
self.last_context_escalations = proxy.context_escalations()
|
|
422
|
+
self.last_execution_seconds = round(time.monotonic() - started, 3)
|
|
423
|
+
self.last_usage = extract_codex_usage(completed.stdout, completed.stderr)
|
|
424
|
+
self.last_usage.update(usage_from_jsonl(completed.stdout, completed.stderr))
|
|
425
|
+
self.last_usage_snapshots = usage_snapshots_from_jsonl(completed.stdout, completed.stderr)
|
|
426
|
+
self.last_churn = churn_from_jsonl(completed.stdout, completed.stderr)
|
|
427
|
+
self.last_runtime_metadata.update(extract_codex_runtime_metadata(
|
|
428
|
+
completed.stdout, completed.stderr
|
|
429
|
+
))
|
|
430
|
+
finally:
|
|
431
|
+
schema_path.unlink(missing_ok=True)
|
|
432
|
+
if completed.returncode:
|
|
433
|
+
return ReviewerResult(
|
|
434
|
+
selection.reviewer,
|
|
435
|
+
"Reviewer invocation failed; primary review continues.",
|
|
436
|
+
failed=True,
|
|
437
|
+
usage=dict(self.last_usage), runtime_metadata=dict(self.last_runtime_metadata),
|
|
438
|
+
churn=dict(self.last_churn), duration_seconds=self.last_execution_seconds,
|
|
439
|
+
usage_snapshots=self.last_usage_snapshots,
|
|
440
|
+
)
|
|
441
|
+
try:
|
|
442
|
+
raw = json.loads(_codex_final_message(completed.stdout))
|
|
443
|
+
return ReviewerResult(
|
|
444
|
+
selection.reviewer,
|
|
445
|
+
str(raw["contribution"]),
|
|
446
|
+
tuple(str(value) for value in raw["recommendations"]),
|
|
447
|
+
usage=dict(self.last_usage), runtime_metadata=dict(self.last_runtime_metadata),
|
|
448
|
+
churn=dict(self.last_churn), duration_seconds=self.last_execution_seconds,
|
|
449
|
+
usage_snapshots=self.last_usage_snapshots,
|
|
450
|
+
)
|
|
451
|
+
except (IndexError, KeyError, TypeError, json.JSONDecodeError):
|
|
452
|
+
return ReviewerResult(
|
|
453
|
+
selection.reviewer,
|
|
454
|
+
"Reviewer returned invalid advice; primary review continues.",
|
|
455
|
+
failed=True,
|
|
456
|
+
usage=dict(self.last_usage), runtime_metadata=dict(self.last_runtime_metadata),
|
|
457
|
+
churn=dict(self.last_churn), duration_seconds=self.last_execution_seconds,
|
|
458
|
+
usage_snapshots=self.last_usage_snapshots,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def invoke(self, root: Path, prompt: str) -> AgentResult:
|
|
462
|
+
self.last_usage = {}
|
|
463
|
+
self.last_usage_snapshots = ()
|
|
464
|
+
self.last_churn = {}
|
|
465
|
+
self.last_context_escalations = ()
|
|
466
|
+
self.last_execution_seconds = None
|
|
467
|
+
self.last_runtime_metadata = self._runtime_metadata()
|
|
468
|
+
schema = {
|
|
469
|
+
"type": "object",
|
|
470
|
+
"additionalProperties": False,
|
|
471
|
+
"required": [
|
|
472
|
+
"terminal_state",
|
|
473
|
+
"branch",
|
|
474
|
+
"pull_request",
|
|
475
|
+
"terminal_condition",
|
|
476
|
+
"diagnostic",
|
|
477
|
+
"repository_path",
|
|
478
|
+
"commit_sha",
|
|
479
|
+
"validation_evidence",
|
|
480
|
+
"quality_evidence",
|
|
481
|
+
"validation_disposition",
|
|
482
|
+
],
|
|
483
|
+
"properties": {
|
|
484
|
+
"terminal_state": {
|
|
485
|
+
"type": "string",
|
|
486
|
+
"enum": ["COMPLETE", "WAITING", "BLOCKED", "FAILED"],
|
|
487
|
+
},
|
|
488
|
+
"branch": {"type": ["string", "null"]},
|
|
489
|
+
"pull_request": {"type": ["integer", "null"]},
|
|
490
|
+
"terminal_condition": {
|
|
491
|
+
"type": "string",
|
|
492
|
+
"enum": [
|
|
493
|
+
"repository_reconciled",
|
|
494
|
+
"open_pr_checks_terminal",
|
|
495
|
+
"external_blocked",
|
|
496
|
+
"local_commit_reconciled",
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
"diagnostic": {"type": "string", "maxLength": 500},
|
|
500
|
+
"repository_path": {"type": ["string", "null"]},
|
|
501
|
+
"commit_sha": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"},
|
|
502
|
+
"validation_evidence": {
|
|
503
|
+
"type": "array", "maxItems": 12,
|
|
504
|
+
"items": {"type": "object", "additionalProperties": False,
|
|
505
|
+
"required": ["command", "result"],
|
|
506
|
+
"properties": {"command": {"type": "string", "maxLength": 240}, "result": {"type": "string", "maxLength": 240}}},
|
|
507
|
+
},
|
|
508
|
+
"quality_evidence": {
|
|
509
|
+
"type": "array", "maxItems": 8,
|
|
510
|
+
"items": {"type": "object", "additionalProperties": False,
|
|
511
|
+
"required": ["activity", "result"],
|
|
512
|
+
"properties": {"activity": {"type": "string", "enum": sorted(_QUALITY_EVIDENCE_ACTIVITIES)}, "result": {"type": "string", "maxLength": 240}}},
|
|
513
|
+
},
|
|
514
|
+
"validation_disposition": {
|
|
515
|
+
"type": "string",
|
|
516
|
+
"enum": ["product_failure", "environmental_instability"],
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
}
|
|
520
|
+
with tempfile.NamedTemporaryFile(
|
|
521
|
+
"w", encoding="utf-8", suffix=".json", delete=False
|
|
522
|
+
) as handle:
|
|
523
|
+
json.dump(schema, handle)
|
|
524
|
+
schema_path = Path(handle.name)
|
|
525
|
+
try:
|
|
526
|
+
extra_roots = additional_workspace_write_roots(root)
|
|
527
|
+
command = [
|
|
528
|
+
"codex",
|
|
529
|
+
"exec",
|
|
530
|
+
"--sandbox",
|
|
531
|
+
MANAGED_EXECUTION_SANDBOX,
|
|
532
|
+
"-C",
|
|
533
|
+
str(root),
|
|
534
|
+
"--json",
|
|
535
|
+
]
|
|
536
|
+
for extra_root in extra_roots:
|
|
537
|
+
command.extend(("--add-dir", str(extra_root)))
|
|
538
|
+
command.extend(("--output-schema", str(schema_path), prompt))
|
|
539
|
+
started = time.monotonic()
|
|
540
|
+
proxy = ToolProxyEnvironment()
|
|
541
|
+
with proxy as environment:
|
|
542
|
+
completed = self._run_invocation(tuple(command), root, environment)
|
|
543
|
+
self.last_context_escalations = proxy.context_escalations()
|
|
544
|
+
self.last_execution_seconds = round(time.monotonic() - started, 3)
|
|
545
|
+
self.last_usage = extract_codex_usage(completed.stdout, completed.stderr)
|
|
546
|
+
# Prefer one final explicit usage snapshot; legacy extraction stays
|
|
547
|
+
# in place for compatibility with older Codex JSONL variants.
|
|
548
|
+
self.last_usage.update(usage_from_jsonl(completed.stdout, completed.stderr))
|
|
549
|
+
self.last_usage_snapshots = usage_snapshots_from_jsonl(completed.stdout, completed.stderr)
|
|
550
|
+
self.last_churn = churn_from_jsonl(completed.stdout, completed.stderr)
|
|
551
|
+
self.last_runtime_metadata.update(extract_codex_runtime_metadata(
|
|
552
|
+
completed.stdout, completed.stderr
|
|
553
|
+
))
|
|
554
|
+
finally:
|
|
555
|
+
schema_path.unlink(missing_ok=True)
|
|
556
|
+
if completed.returncode:
|
|
557
|
+
detail = _format_cli_failure(completed.returncode, completed.stderr, completed.stdout, prompt)
|
|
558
|
+
next_action, terminal_condition, diagnostic = codex_failure_disposition(
|
|
559
|
+
completed.returncode, completed.stdout, completed.stderr
|
|
560
|
+
)
|
|
561
|
+
interruption = provider_turn_interruption(completed.stdout, completed.stderr)
|
|
562
|
+
raise CodexInvocationError(
|
|
563
|
+
diagnostic,
|
|
564
|
+
detail,
|
|
565
|
+
next_action="NONE" if interruption else next_action,
|
|
566
|
+
terminal_condition="provider_turn_interrupted" if interruption else terminal_condition,
|
|
567
|
+
interruption_reason=interruption,
|
|
568
|
+
)
|
|
569
|
+
try:
|
|
570
|
+
raw = json.loads(_codex_final_message(completed.stdout))
|
|
571
|
+
result = AgentResult(**raw)
|
|
572
|
+
if not isinstance(result.validation_evidence, (list, tuple)) or not isinstance(result.quality_evidence, (list, tuple)):
|
|
573
|
+
raise TypeError("execution evidence must be a list")
|
|
574
|
+
if result.validation_disposition not in {"product_failure", "environmental_instability"}:
|
|
575
|
+
raise TypeError("validation disposition is invalid")
|
|
576
|
+
if any(
|
|
577
|
+
not isinstance(item, dict) or item.get("activity") not in _QUALITY_EVIDENCE_ACTIVITIES
|
|
578
|
+
or not item.get("result")
|
|
579
|
+
for item in result.quality_evidence
|
|
580
|
+
):
|
|
581
|
+
raise TypeError("quality evidence is invalid")
|
|
582
|
+
result = replace(
|
|
583
|
+
result,
|
|
584
|
+
validation_evidence=tuple(
|
|
585
|
+
{"command": redact_diagnostic(item.get("command", ""), limit=240), "result": redact_diagnostic(item.get("result", ""), limit=240)}
|
|
586
|
+
for item in result.validation_evidence
|
|
587
|
+
if isinstance(item, dict) and item.get("command") and item.get("result")
|
|
588
|
+
),
|
|
589
|
+
quality_evidence=tuple(
|
|
590
|
+
{"activity": str(item["activity"]), "result": redact_diagnostic(str(item["result"]), limit=240)}
|
|
591
|
+
for item in result.quality_evidence
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
if result.diagnostic is not None:
|
|
595
|
+
result = replace(result, diagnostic=redact_diagnostic(result.diagnostic))
|
|
596
|
+
return result
|
|
597
|
+
except (IndexError, json.JSONDecodeError, TypeError) as error:
|
|
598
|
+
interruption = provider_turn_interruption(completed.stdout, completed.stderr)
|
|
599
|
+
raise CodexInvocationError(
|
|
600
|
+
"Provider turn interrupted before returning the required structured terminal result."
|
|
601
|
+
if interruption else "Codex CLI did not return the required structured terminal result.",
|
|
602
|
+
_format_cli_failure(completed.returncode, completed.stderr, completed.stdout, prompt),
|
|
603
|
+
next_action="NONE" if interruption else "inspect_codex_cli",
|
|
604
|
+
terminal_condition="provider_turn_interrupted" if interruption else "codex_invocation_failed",
|
|
605
|
+
interruption_reason=interruption,
|
|
606
|
+
) from error
|
|
607
|
+
|
|
608
|
+
def _run_invocation(
|
|
609
|
+
self, command: tuple[str, ...], root: Path, environment: Mapping[str, str] | None = None
|
|
610
|
+
) -> subprocess.CompletedProcess[str]:
|
|
611
|
+
"""Run Codex, streaming only the approved activity projection when enabled."""
|
|
612
|
+
self.last_execution_metadata = {
|
|
613
|
+
"modified": 0,
|
|
614
|
+
"created": 0,
|
|
615
|
+
"deleted": 0,
|
|
616
|
+
"codex_commands_executed": 0,
|
|
617
|
+
}
|
|
618
|
+
if (
|
|
619
|
+
self._activity_callback is None
|
|
620
|
+
and self._transient_action_callback is None
|
|
621
|
+
and self._command_callback is None
|
|
622
|
+
and self._runtime_metadata_callback is None
|
|
623
|
+
and self._workspace_progress_callback is None
|
|
624
|
+
and self._handoff_deadline_callback is None
|
|
625
|
+
):
|
|
626
|
+
return self.provider.invoke(root, command, environment=environment)
|
|
627
|
+
process = self.provider.spawn_invocation(root, command, environment=environment)
|
|
628
|
+
if self._process_callback is not None:
|
|
629
|
+
try:
|
|
630
|
+
self._process_callback({"pid": process.pid, "process_group": os.getpgid(process.pid)})
|
|
631
|
+
except OSError:
|
|
632
|
+
self._process_callback(None)
|
|
633
|
+
lines: list[str] = []
|
|
634
|
+
last_workspace_progress: dict[str, int] | None = None
|
|
635
|
+
observed_command_ids: set[str] = set()
|
|
636
|
+
watchdog_stop = Event()
|
|
637
|
+
handoff_timed_out = Event()
|
|
638
|
+
|
|
639
|
+
def watchdog() -> None:
|
|
640
|
+
while not watchdog_stop.wait(1):
|
|
641
|
+
if self._handoff_deadline_callback is None or not self._handoff_deadline_callback():
|
|
642
|
+
continue
|
|
643
|
+
handoff_timed_out.set()
|
|
644
|
+
# Invocation processes start their own session. Stopping only
|
|
645
|
+
# the CLI parent can leave stdout open in a child and strand
|
|
646
|
+
# the runner in its read loop after the deadline.
|
|
647
|
+
try:
|
|
648
|
+
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
|
649
|
+
except (OSError, ProcessLookupError):
|
|
650
|
+
process.terminate()
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
watchdog_thread = (
|
|
654
|
+
Thread(target=watchdog, name="engineering-pr-handoff-watchdog", daemon=True)
|
|
655
|
+
if self._handoff_deadline_callback is not None else None
|
|
656
|
+
)
|
|
657
|
+
if watchdog_thread is not None:
|
|
658
|
+
watchdog_thread.start()
|
|
659
|
+
try:
|
|
660
|
+
assert process.stdout is not None
|
|
661
|
+
for line in process.stdout:
|
|
662
|
+
if handoff_timed_out.is_set():
|
|
663
|
+
raise CodexHandoffTimeout("Agent did not return after the host-owned PR hand-off deadline.")
|
|
664
|
+
lines.append(line)
|
|
665
|
+
if self._workspace_progress_callback is not None:
|
|
666
|
+
progress = workspace_change_summary(root)
|
|
667
|
+
self.last_execution_metadata.update(progress)
|
|
668
|
+
if progress != last_workspace_progress:
|
|
669
|
+
self._workspace_progress_callback(dict(self.last_execution_metadata))
|
|
670
|
+
last_workspace_progress = progress
|
|
671
|
+
observed_metadata = extract_codex_runtime_metadata(line)
|
|
672
|
+
if len(observed_metadata) > 1:
|
|
673
|
+
self.last_runtime_metadata.update(observed_metadata)
|
|
674
|
+
if self._runtime_metadata_callback is not None:
|
|
675
|
+
self._runtime_metadata_callback(dict(self.last_runtime_metadata))
|
|
676
|
+
try:
|
|
677
|
+
event = json.loads(line)
|
|
678
|
+
activity = project_codex_activity(event)
|
|
679
|
+
except json.JSONDecodeError:
|
|
680
|
+
activity = None
|
|
681
|
+
event = None
|
|
682
|
+
if activity is not None and self._activity_callback is not None:
|
|
683
|
+
self._activity_callback(activity)
|
|
684
|
+
transient_action = project_codex_live_action_name(event)
|
|
685
|
+
if transient_action is not None and self._transient_action_callback is not None:
|
|
686
|
+
self._transient_action_callback(transient_action)
|
|
687
|
+
if self._command_callback is not None:
|
|
688
|
+
command_event = project_codex_command_event(event)
|
|
689
|
+
if command_event is not None:
|
|
690
|
+
if command_event[0] == "started" and command_event[1] not in observed_command_ids:
|
|
691
|
+
observed_command_ids.add(command_event[1])
|
|
692
|
+
self.last_execution_metadata["codex_commands_executed"] += 1
|
|
693
|
+
if self._workspace_progress_callback is not None:
|
|
694
|
+
self._workspace_progress_callback(dict(self.last_execution_metadata))
|
|
695
|
+
self._command_callback(*command_event)
|
|
696
|
+
if handoff_timed_out.is_set():
|
|
697
|
+
raise CodexHandoffTimeout("Agent did not return after the host-owned PR hand-off deadline.")
|
|
698
|
+
return subprocess.CompletedProcess(command, process.wait(), "".join(lines), "")
|
|
699
|
+
finally:
|
|
700
|
+
watchdog_stop.set()
|
|
701
|
+
if watchdog_thread is not None:
|
|
702
|
+
watchdog_thread.join(timeout=1)
|
|
703
|
+
if self._process_callback is not None:
|
|
704
|
+
self._process_callback(None)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def workspace_change_summary(root: Path) -> dict[str, int]:
|
|
708
|
+
"""Return only aggregate Git worktree changes for the live status surface."""
|
|
709
|
+
try:
|
|
710
|
+
completed = subprocess.run(
|
|
711
|
+
("git", "status", "--porcelain=v1", "--untracked-files=all"),
|
|
712
|
+
cwd=root,
|
|
713
|
+
capture_output=True,
|
|
714
|
+
check=False,
|
|
715
|
+
text=True,
|
|
716
|
+
)
|
|
717
|
+
except OSError:
|
|
718
|
+
return {"modified": 0, "created": 0, "deleted": 0}
|
|
719
|
+
if completed.returncode:
|
|
720
|
+
return {"modified": 0, "created": 0, "deleted": 0}
|
|
721
|
+
summary = {"modified": 0, "created": 0, "deleted": 0}
|
|
722
|
+
for line in completed.stdout.splitlines():
|
|
723
|
+
status = line[:2]
|
|
724
|
+
if status == "??" or "A" in status:
|
|
725
|
+
summary["created"] += 1
|
|
726
|
+
elif "D" in status:
|
|
727
|
+
summary["deleted"] += 1
|
|
728
|
+
elif status.strip():
|
|
729
|
+
summary["modified"] += 1
|
|
730
|
+
return summary
|