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.
Potentially problematic release.
This version of engineering-platform might be problematic. Click here for more details.
- 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,136 @@
|
|
|
1
|
+
"""Bounded, role-specific provider context for Engineering Platform work.
|
|
2
|
+
|
|
3
|
+
The execution host remains the authority for lifecycle and deterministic
|
|
4
|
+
admission. This module only decides whether a *provider action* is meaningful
|
|
5
|
+
and projects the already-authoritative prompt into a role-appropriate input.
|
|
6
|
+
It never stores prompt text or command output.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from .provider_context_scope import POLICY_ID
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProviderRole(StrEnum):
|
|
19
|
+
SPECIALIST_REVIEW = "SPECIALIST_REVIEW"
|
|
20
|
+
IMPLEMENTATION = "IMPLEMENTATION"
|
|
21
|
+
QUALITY_REVIEW = "QUALITY_REVIEW"
|
|
22
|
+
REPAIR = "REPAIR"
|
|
23
|
+
FINALIZATION = "FINALIZATION"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ProviderNeedDecision:
|
|
28
|
+
required: bool
|
|
29
|
+
reason: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ContextProjection:
|
|
34
|
+
role: ProviderRole
|
|
35
|
+
text: str
|
|
36
|
+
source_item_count: int
|
|
37
|
+
omitted_low_priority_count: int
|
|
38
|
+
budget_version: str = POLICY_ID
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def telemetry(self) -> dict[str, int]:
|
|
42
|
+
return {
|
|
43
|
+
"context_source_item_count": self.source_item_count,
|
|
44
|
+
"context_omitted_low_priority_count": self.omitted_low_priority_count,
|
|
45
|
+
"context_projected_bytes": len(self.text.encode("utf-8")),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_ROLE_BUDGETS = {
|
|
50
|
+
ProviderRole.SPECIALIST_REVIEW: 18_000,
|
|
51
|
+
ProviderRole.IMPLEMENTATION: 60_000,
|
|
52
|
+
ProviderRole.QUALITY_REVIEW: 22_000,
|
|
53
|
+
ProviderRole.REPAIR: 18_000,
|
|
54
|
+
ProviderRole.FINALIZATION: 18_000,
|
|
55
|
+
}
|
|
56
|
+
_MANDATORY_HEADINGS = re.compile(
|
|
57
|
+
r"\b(?:objective|doel|acceptance|acceptatie|constraint|beperking|"
|
|
58
|
+
r"safety|veilig|authority|autoriteit|validation|validatie|required|"
|
|
59
|
+
r"verplicht|non-negotiable|niet-onderhandelbaar|scope|niet wijzigen|do not)\b",
|
|
60
|
+
re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
_HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*$")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def provider_need_for_phase(phase: str, *, passive_observation: bool = False) -> ProviderNeedDecision:
|
|
66
|
+
"""Make provider need explicit; passive/deterministic phases never need one."""
|
|
67
|
+
if passive_observation:
|
|
68
|
+
return ProviderNeedDecision(False, "passive observation is deterministic")
|
|
69
|
+
if phase in {"EXECUTE_AGENT", "LOCAL_REPOSITORY_VALIDATION"}:
|
|
70
|
+
return ProviderNeedDecision(True, "bounded implementation work requires reasoning")
|
|
71
|
+
if phase == "QUALITY_CONTROL_AGENT":
|
|
72
|
+
return ProviderNeedDecision(True, "autonomous quality review requires reasoning")
|
|
73
|
+
if phase == "REPAIR_AGENT":
|
|
74
|
+
return ProviderNeedDecision(True, "scoped repair requires reasoning")
|
|
75
|
+
if phase in {"FINALIZE_AGENT", "RECONCILE_AGENT"}:
|
|
76
|
+
return ProviderNeedDecision(True, "bounded finalization work requires reasoning")
|
|
77
|
+
return ProviderNeedDecision(False, "deterministic lifecycle transition")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def role_for_phase(phase: str, *, repair: bool = False, quality: bool = False) -> ProviderRole:
|
|
81
|
+
if repair or phase == "REPAIR_AGENT":
|
|
82
|
+
return ProviderRole.REPAIR
|
|
83
|
+
if quality or phase == "QUALITY_CONTROL_AGENT":
|
|
84
|
+
return ProviderRole.QUALITY_REVIEW
|
|
85
|
+
if phase in {"FINALIZE_AGENT", "RECONCILE_AGENT"}:
|
|
86
|
+
return ProviderRole.FINALIZATION
|
|
87
|
+
return ProviderRole.IMPLEMENTATION
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def project_context(role: ProviderRole, objective: str) -> ContextProjection:
|
|
91
|
+
"""Keep all mandatory sections while omitting lower-priority prompt history.
|
|
92
|
+
|
|
93
|
+
Prompts without recognisable Markdown sections are deliberately retained in
|
|
94
|
+
full: safety beats an unproven token reduction. Initial implementation
|
|
95
|
+
also receives the complete prompt once; downstream roles receive the
|
|
96
|
+
mandatory contract rather than replaying the complete transcript.
|
|
97
|
+
"""
|
|
98
|
+
if role == ProviderRole.IMPLEMENTATION:
|
|
99
|
+
return ContextProjection(role, objective, 1, 0)
|
|
100
|
+
sections = _markdown_sections(objective)
|
|
101
|
+
selected = [section for heading, section in sections if heading == "preamble" or _MANDATORY_HEADINGS.search(heading)]
|
|
102
|
+
if not selected:
|
|
103
|
+
return ContextProjection(role, objective, 1, 0)
|
|
104
|
+
budget = _ROLE_BUDGETS[role]
|
|
105
|
+
included: list[str] = []
|
|
106
|
+
used = 0
|
|
107
|
+
for section in selected:
|
|
108
|
+
size = len(section.encode("utf-8"))
|
|
109
|
+
# Mandatory material is never silently truncated. A single oversized
|
|
110
|
+
# mandatory section is kept whole and may exceed the nominal budget.
|
|
111
|
+
if included and used + size > budget:
|
|
112
|
+
continue
|
|
113
|
+
included.append(section)
|
|
114
|
+
used += size
|
|
115
|
+
text = "\n\n".join(included)
|
|
116
|
+
return ContextProjection(role, text, len(sections), max(0, len(sections) - len(included)))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _markdown_sections(value: str) -> list[tuple[str, str]]:
|
|
120
|
+
lines = value.splitlines()
|
|
121
|
+
starts: list[tuple[int, str]] = []
|
|
122
|
+
for index, line in enumerate(lines):
|
|
123
|
+
match = _HEADING.match(line)
|
|
124
|
+
if match:
|
|
125
|
+
starts.append((index, match.group(1)))
|
|
126
|
+
if not starts:
|
|
127
|
+
return []
|
|
128
|
+
result: list[tuple[str, str]] = []
|
|
129
|
+
if starts[0][0]:
|
|
130
|
+
preamble = "\n".join(lines[:starts[0][0]]).strip()
|
|
131
|
+
if preamble:
|
|
132
|
+
result.append(("preamble", preamble))
|
|
133
|
+
for ordinal, (start, heading) in enumerate(starts):
|
|
134
|
+
end = starts[ordinal + 1][0] if ordinal + 1 < len(starts) else len(lines)
|
|
135
|
+
result.append((heading, "\n".join(lines[start:end]).strip()))
|
|
136
|
+
return result
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Deterministic structural benchmark for provider-context reduction.
|
|
2
|
+
|
|
3
|
+
This is deliberately a shape benchmark: it makes no production token or cost
|
|
4
|
+
claim and never invokes a provider.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .provider_context import ProviderRole, project_context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def benchmark_shape(objective: str) -> dict[str, dict[str, int]]:
|
|
13
|
+
"""Return comparable byte/call proxies for representative role scenarios."""
|
|
14
|
+
full = len(objective.encode("utf-8"))
|
|
15
|
+
return {
|
|
16
|
+
"deterministic_preflight_blocker": {
|
|
17
|
+
"provider_calls": 0,
|
|
18
|
+
"context_bytes": 0,
|
|
19
|
+
"repeated_reads": 0,
|
|
20
|
+
"injected_output_bytes": 0,
|
|
21
|
+
},
|
|
22
|
+
"implementation": {
|
|
23
|
+
"provider_calls": 1,
|
|
24
|
+
"context_bytes": project_context(ProviderRole.IMPLEMENTATION, objective).telemetry["context_projected_bytes"],
|
|
25
|
+
"repeated_reads": 0,
|
|
26
|
+
"injected_output_bytes": 0,
|
|
27
|
+
},
|
|
28
|
+
"repair": {
|
|
29
|
+
"provider_calls": 1,
|
|
30
|
+
"context_bytes": project_context(ProviderRole.REPAIR, objective).telemetry["context_projected_bytes"],
|
|
31
|
+
"repeated_reads": 0,
|
|
32
|
+
"injected_output_bytes": 0,
|
|
33
|
+
},
|
|
34
|
+
"passive_merge_wait": {
|
|
35
|
+
"provider_calls": 0,
|
|
36
|
+
"context_bytes": 0,
|
|
37
|
+
"repeated_reads": 0,
|
|
38
|
+
"injected_output_bytes": 0,
|
|
39
|
+
},
|
|
40
|
+
"baseline_full_replay_bytes": {"context_bytes": full * 2},
|
|
41
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Provider-neutral, current-delta-first execution context policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
POLICY_ID = "provider-context-v1"
|
|
11
|
+
MAX_HISTORICAL_COMMITS = 10
|
|
12
|
+
MAX_HISTORICAL_PULL_REQUESTS = 10
|
|
13
|
+
MAX_HISTORICAL_CONTEXT_BYTES = 65_536
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContextScope(StrEnum):
|
|
17
|
+
NORMAL = "NORMAL"
|
|
18
|
+
RETRY_REPAIR = "RETRY_REPAIR"
|
|
19
|
+
INVESTIGATION = "INVESTIGATION"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ContextEscalationReason(StrEnum):
|
|
23
|
+
REGRESSION_ORIGIN_UNKNOWN = "REGRESSION_ORIGIN_UNKNOWN"
|
|
24
|
+
MERGE_ANCESTRY_REQUIRED = "MERGE_ANCESTRY_REQUIRED"
|
|
25
|
+
DIRECT_LINEAGE_REQUIRED = "DIRECT_LINEAGE_REQUIRED"
|
|
26
|
+
CONTRACT_HISTORY_REQUIRED = "CONTRACT_HISTORY_REQUIRED"
|
|
27
|
+
BLAME_REQUIRED = "BLAME_REQUIRED"
|
|
28
|
+
OPERATOR_REQUESTED_AUDIT = "OPERATOR_REQUESTED_AUDIT"
|
|
29
|
+
OTHER_BOUNDED_INVESTIGATION = "OTHER_BOUNDED_INVESTIGATION"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HistoryBoundaryKind(StrEnum):
|
|
33
|
+
COMMITS_TOUCHING_PATH = "COMMITS_TOUCHING_PATH"
|
|
34
|
+
DIRECT_ANCESTRY = "DIRECT_ANCESTRY"
|
|
35
|
+
DIRECT_PREDECESSOR = "DIRECT_PREDECESSOR"
|
|
36
|
+
REFERENCED_PULL_REQUESTS = "REFERENCED_PULL_REQUESTS"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ContextEscalationRequest:
|
|
41
|
+
"""One bounded, invocation-local admission to historical evidence."""
|
|
42
|
+
|
|
43
|
+
reason: ContextEscalationReason
|
|
44
|
+
boundary_kind: HistoryBoundaryKind
|
|
45
|
+
boundary: str
|
|
46
|
+
limit: int
|
|
47
|
+
diagnostic: str
|
|
48
|
+
|
|
49
|
+
def validate(self) -> "ContextEscalationRequest":
|
|
50
|
+
if not self.boundary.strip() or len(self.boundary) > 240:
|
|
51
|
+
raise ValueError("A non-empty bounded history boundary is required.")
|
|
52
|
+
if not self.diagnostic.strip() or len(self.diagnostic) > 240:
|
|
53
|
+
raise ValueError("A bounded evidence-gap diagnostic is required.")
|
|
54
|
+
maximum = (
|
|
55
|
+
MAX_HISTORICAL_PULL_REQUESTS
|
|
56
|
+
if self.boundary_kind == HistoryBoundaryKind.REFERENCED_PULL_REQUESTS
|
|
57
|
+
else MAX_HISTORICAL_COMMITS
|
|
58
|
+
)
|
|
59
|
+
if not 1 <= self.limit <= maximum:
|
|
60
|
+
raise ValueError(f"History limit must be between 1 and {maximum}.")
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def initial_context_scope(*, phase: str, repair_iterations: int = 0, objective: str = "") -> ContextScope:
|
|
65
|
+
"""Choose scope from lifecycle evidence, never producer identity."""
|
|
66
|
+
if phase == "REPAIR_AGENT" or repair_iterations > 0:
|
|
67
|
+
return ContextScope.RETRY_REPAIR
|
|
68
|
+
# An explicit audit/history task is itself the bounded objective. This is
|
|
69
|
+
# intentionally narrow; ordinary mentions of history do not broaden scope.
|
|
70
|
+
if re.search(r"\b(?:historical\s+(?:audit|investigation)|(?:audit|investigate)\s+(?:history|historical))\b", objective, re.I):
|
|
71
|
+
return ContextScope.INVESTIGATION
|
|
72
|
+
return ContextScope.NORMAL
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def provider_instruction(scope: ContextScope) -> str:
|
|
76
|
+
"""Return the short operational contract injected into every provider turn."""
|
|
77
|
+
direct_lineage = (
|
|
78
|
+
"Direct predecessor run, terminal diagnostic, failed controls, and its direct delta are admitted; do not load older ancestors."
|
|
79
|
+
if scope == ContextScope.RETRY_REPAIR
|
|
80
|
+
else "No predecessor lineage is admitted unless the lifecycle supplies it."
|
|
81
|
+
)
|
|
82
|
+
return (
|
|
83
|
+
f"Provider Context Scope: {scope.value}; Policy: {POLICY_ID}. "
|
|
84
|
+
"Start with the supplied objective, current branch/worktree/status, merge-base delta against canonical base, current source, relevant tests and configuration. "
|
|
85
|
+
"Do not enumerate historical pull requests or broad git history for orientation. "
|
|
86
|
+
f"{direct_lineage} "
|
|
87
|
+
"If current evidence has a concrete gap, before a historical query run "
|
|
88
|
+
"`engineering-platform-context-escalate REASON BOUNDARY_KIND BOUNDARY LIMIT --diagnostic 'evidence gap'`; "
|
|
89
|
+
"use only its admitted boundary (maximum 10 commits or 10 PRs) and continue."
|
|
90
|
+
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Recovery of provider-proven interrupted transactions after host exit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sqlite3
|
|
10
|
+
|
|
11
|
+
from .agent_state import StateError, StateStore, TransactionState
|
|
12
|
+
from .execution_lease import release_terminal_lease
|
|
13
|
+
from .execution_timing import reconcile_interrupted_phases
|
|
14
|
+
from .live_status import write_live_status
|
|
15
|
+
from .storage import open_storage
|
|
16
|
+
from .provider_recovery import create_recovery_available, load_recovery_state
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
INTERRUPTION_CLASSIFICATION = "provider_turn_interrupted"
|
|
20
|
+
TERMINAL_DIAGNOSTIC = (
|
|
21
|
+
"Provider turn interrupted before returning the required structured AgentResult."
|
|
22
|
+
)
|
|
23
|
+
LOGGER = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _latest_interrupted_invocation(
|
|
27
|
+
root: Path, run_id: str, *, central_database: Path | None = None,
|
|
28
|
+
) -> tuple[str, str] | None:
|
|
29
|
+
"""Return only durable, allow-listed provider interruption evidence.
|
|
30
|
+
|
|
31
|
+
Some providers terminate after streaming an interrupted child command but
|
|
32
|
+
before emitting their final JSONL ``turn_aborted`` event. That remains a
|
|
33
|
+
proven interruption only when usage is unavailable and a child span was
|
|
34
|
+
interrupted under the same provider boundary.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
connection = open_storage(root) if central_database is None else sqlite3.connect(central_database.resolve(), isolation_level=None)
|
|
38
|
+
try:
|
|
39
|
+
row = connection.execute(
|
|
40
|
+
"SELECT invocation_id,churn,usage_authority FROM provider_invocations WHERE run_id=? "
|
|
41
|
+
"ORDER BY ordinal DESC LIMIT 1",
|
|
42
|
+
(run_id,),
|
|
43
|
+
).fetchone()
|
|
44
|
+
finally:
|
|
45
|
+
connection.close()
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
if row is None:
|
|
49
|
+
return None
|
|
50
|
+
invocation_id = str(row[0]) if isinstance(row[0], str) else None
|
|
51
|
+
if invocation_id is None:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
churn = json.loads(row[1])
|
|
55
|
+
except (TypeError, json.JSONDecodeError):
|
|
56
|
+
churn = {}
|
|
57
|
+
if isinstance(churn, dict) and churn.get("interruption_classification") == INTERRUPTION_CLASSIFICATION:
|
|
58
|
+
return invocation_id, "provider_reported_abort"
|
|
59
|
+
if row[2] != "UNAVAILABLE":
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
connection = open_storage(root) if central_database is None else sqlite3.connect(central_database.resolve(), isolation_level=None)
|
|
63
|
+
try:
|
|
64
|
+
interrupted_child = connection.execute(
|
|
65
|
+
"SELECT 1 FROM execution_phase_spans AS child "
|
|
66
|
+
"JOIN execution_phase_spans AS provider ON provider.phase_id=child.parent_phase_id "
|
|
67
|
+
"WHERE child.run_id=? AND child.outcome='INTERRUPTED' "
|
|
68
|
+
"AND provider.phase_name='PROVIDER_EXECUTION' LIMIT 1",
|
|
69
|
+
(run_id,),
|
|
70
|
+
).fetchone()
|
|
71
|
+
finally:
|
|
72
|
+
connection.close()
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
if interrupted_child is None:
|
|
76
|
+
return None
|
|
77
|
+
return invocation_id, "interrupted_child_span_without_provider_result"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def terminalize_after_host_exit(
|
|
81
|
+
root: Path, run_id: str, *, central_database: Path | None = None,
|
|
82
|
+
) -> TransactionState | None:
|
|
83
|
+
"""Close a non-terminal run only when its latest provider evidence proves interruption.
|
|
84
|
+
|
|
85
|
+
This is intentionally a watcher-side recovery boundary: the detached
|
|
86
|
+
Execution Host has already exited, so releasing its lease cannot terminate
|
|
87
|
+
active provider work. Generic stale leases remain recoverable and are not
|
|
88
|
+
converted into failures here.
|
|
89
|
+
"""
|
|
90
|
+
recovery = load_recovery_state(root, run_id, central_database=central_database)
|
|
91
|
+
# Durable recovery state takes precedence over retrospective provider
|
|
92
|
+
# evidence. Only exhausted/unsafe recovery reaches the old terminalizer.
|
|
93
|
+
if isinstance(recovery, dict) and recovery.get("state") in {
|
|
94
|
+
"RECOVERY_AVAILABLE", "RECOVERY_STARTING", "RECOVERY_IN_PROGRESS", "RECOVERED",
|
|
95
|
+
}:
|
|
96
|
+
return None
|
|
97
|
+
evidence = _latest_interrupted_invocation(root, run_id, central_database=central_database)
|
|
98
|
+
if evidence is None:
|
|
99
|
+
return None
|
|
100
|
+
invocation_id, classification = evidence
|
|
101
|
+
store = StateStore(root / ".engineering" / "engineering-runs", central_database=central_database, emit_local_projection=central_database is None)
|
|
102
|
+
try:
|
|
103
|
+
state = store.load(run_id)
|
|
104
|
+
except StateError:
|
|
105
|
+
return None
|
|
106
|
+
if state.terminal:
|
|
107
|
+
return state
|
|
108
|
+
terminal = replace(
|
|
109
|
+
state,
|
|
110
|
+
phase="FAILED",
|
|
111
|
+
terminal=True,
|
|
112
|
+
next_action="NONE",
|
|
113
|
+
terminal_condition=INTERRUPTION_CLASSIFICATION,
|
|
114
|
+
diagnostic=(
|
|
115
|
+
f"{TERMINAL_DIAGNOSTIC} Provider invocation: {invocation_id}. "
|
|
116
|
+
f"Interruption evidence: {classification}."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
store.save(terminal)
|
|
120
|
+
reconcile_interrupted_phases(root, run_id, outcome="INTERRUPTED", central_database=central_database)
|
|
121
|
+
# The checkpoint is durable before cleanup. A cleanup failure therefore
|
|
122
|
+
# cannot overwrite the proven failure outcome.
|
|
123
|
+
try:
|
|
124
|
+
release_terminal_lease(root, run_id, central_database=central_database)
|
|
125
|
+
except Exception:
|
|
126
|
+
# Lease cleanup is secondary evidence. The durable checkpoint stays
|
|
127
|
+
# authoritative and normal stale-lease reconciliation can record the
|
|
128
|
+
# separate cleanup concern on a later cycle.
|
|
129
|
+
LOGGER.exception("Terminal provider-interruption lease release failed for run %s", run_id)
|
|
130
|
+
write_live_status(root, terminal, terminal.next_action)
|
|
131
|
+
return terminal
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def prepare_same_run_recovery_after_host_exit(
|
|
135
|
+
root: Path, run_id: str, *, central_database: Path | None = None,
|
|
136
|
+
) -> TransactionState | None:
|
|
137
|
+
"""Persist the sole watcher-side continuation before it launches a host.
|
|
138
|
+
|
|
139
|
+
This intentionally accepts only the exact evidence already accepted by
|
|
140
|
+
``terminalize_after_host_exit``. It never creates a submission, branch,
|
|
141
|
+
or lease; the normal resumed host owns those operations.
|
|
142
|
+
"""
|
|
143
|
+
evidence = _latest_interrupted_invocation(root, run_id, central_database=central_database)
|
|
144
|
+
if evidence is None:
|
|
145
|
+
return None
|
|
146
|
+
invocation_id, _classification = evidence
|
|
147
|
+
store = StateStore(root / ".engineering" / "engineering-runs", central_database=central_database, emit_local_projection=central_database is None)
|
|
148
|
+
try:
|
|
149
|
+
state = store.load(run_id)
|
|
150
|
+
except StateError:
|
|
151
|
+
return None
|
|
152
|
+
if state.terminal or load_recovery_state(root, run_id, central_database=central_database) is not None:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
create_recovery_available(
|
|
156
|
+
root, run_id=run_id, triggering_invocation_id=invocation_id,
|
|
157
|
+
lifecycle_phase=state.phase, branch=state.branch,
|
|
158
|
+
worktree_identity=str(root.resolve()), lease_id=None,
|
|
159
|
+
central_database=central_database,
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
LOGGER.exception("Provider recovery evidence could not be persisted for %s", run_id)
|
|
163
|
+
return None
|
|
164
|
+
# This compact projection has no authority over recovery. It merely keeps
|
|
165
|
+
# existing status readers coherent until they read the durable row.
|
|
166
|
+
recovering = replace(state, next_action="recover_provider_turn", diagnostic="Provider interruption recovery is pending under the same execution.")
|
|
167
|
+
write_live_status(root, recovering, "Provider interrupted — recovering automatically (1/1)")
|
|
168
|
+
return recovering
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Bounded, provider-neutral OS process identity evidence.
|
|
2
|
+
|
|
3
|
+
The recovery controller never treats a PID as identity. This adapter captures
|
|
4
|
+
the process birth marker and executable path without retaining arguments or
|
|
5
|
+
environment values that could contain prompts or credentials.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ProcessIdentity:
|
|
18
|
+
pid: int
|
|
19
|
+
process_group: int
|
|
20
|
+
start_fingerprint: str
|
|
21
|
+
executable_identity: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _canonical_executable_identity(value: str) -> str:
|
|
25
|
+
"""Normalize equivalent macOS Python framework executable spellings."""
|
|
26
|
+
path = Path(value).resolve()
|
|
27
|
+
parts = path.parts
|
|
28
|
+
try:
|
|
29
|
+
framework = parts.index("Python.framework")
|
|
30
|
+
except ValueError:
|
|
31
|
+
return str(path)
|
|
32
|
+
version_root = Path(*parts[: framework + 3])
|
|
33
|
+
suffix = parts[framework + 3 :]
|
|
34
|
+
if suffix == ("bin", path.name) or suffix == ("Resources", "Python.app", "Contents", "MacOS", "Python"):
|
|
35
|
+
return str(version_root / "python-runtime")
|
|
36
|
+
return str(path)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def capture_process_identity(pid: int, process_group: int | None = None) -> ProcessIdentity | None:
|
|
40
|
+
"""Capture portable `ps` birth/executable evidence for a live process."""
|
|
41
|
+
if pid <= 0:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
observed_group = os.getpgid(pid)
|
|
45
|
+
if process_group is not None and observed_group != process_group:
|
|
46
|
+
return None
|
|
47
|
+
# `lstart` is a process-birth value on macOS and common POSIX hosts;
|
|
48
|
+
# `comm` is the executable identity, not command arguments.
|
|
49
|
+
completed = subprocess.run(
|
|
50
|
+
("ps", "-o", "lstart=", "-o", "comm=", "-p", str(pid)),
|
|
51
|
+
check=False, capture_output=True, text=True, timeout=2,
|
|
52
|
+
)
|
|
53
|
+
except (OSError, subprocess.SubprocessError):
|
|
54
|
+
return None
|
|
55
|
+
if completed.returncode != 0:
|
|
56
|
+
return None
|
|
57
|
+
line = completed.stdout.strip()
|
|
58
|
+
if not line:
|
|
59
|
+
return None
|
|
60
|
+
# lstart is exactly five fields; the remainder is the executable path.
|
|
61
|
+
parts = line.split(maxsplit=5)
|
|
62
|
+
if len(parts) != 6:
|
|
63
|
+
return None
|
|
64
|
+
birth = " ".join(parts[:5])
|
|
65
|
+
executable = _canonical_executable_identity(parts[5][:512])
|
|
66
|
+
fingerprint = hashlib.sha256(f"{pid}|{observed_group}|{birth}".encode("utf-8")).hexdigest()
|
|
67
|
+
return ProcessIdentity(pid, observed_group, fingerprint, executable)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def verify_process_identity(expected: ProcessIdentity) -> str:
|
|
71
|
+
"""Return MATCH, NOT_ACTIVE, or MISMATCH without trusting PID alone."""
|
|
72
|
+
observed = capture_process_identity(expected.pid, expected.process_group)
|
|
73
|
+
if observed is None:
|
|
74
|
+
return "NOT_ACTIVE"
|
|
75
|
+
if (
|
|
76
|
+
observed.start_fingerprint == expected.start_fingerprint
|
|
77
|
+
and observed.executable_identity == expected.executable_identity
|
|
78
|
+
):
|
|
79
|
+
return "MATCH"
|
|
80
|
+
return "MISMATCH"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Token-free readiness checks for Engineering Platform host providers.
|
|
2
|
+
|
|
3
|
+
The result is intentionally small and safe to persist or project. Repairs are
|
|
4
|
+
always explicit dashboard actions; this module never opens a login flow or
|
|
5
|
+
retries authentication on behalf of an execution.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from .providers import CodexCliProvider, LocalProcessProvider, codex_cli_executable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_VERSION = re.compile(r"\b\d+(?:\.\d+)+(?:[-+][0-9A-Za-z.]+)?\b")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _classify(result: subprocess.CompletedProcess[str] | None) -> str:
|
|
21
|
+
if result is None:
|
|
22
|
+
return "CHECK_FAILED"
|
|
23
|
+
if result.returncode == 0:
|
|
24
|
+
return "READY"
|
|
25
|
+
detail = f"{result.stdout}\n{result.stderr}".casefold()
|
|
26
|
+
return "AUTH_REQUIRED" if any(word in detail for word in (
|
|
27
|
+
"login", "auth", "credential", "token", "not logged in", "logged out", "not signed in",
|
|
28
|
+
)) else "CHECK_FAILED"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _repository_classify(result: subprocess.CompletedProcess[str] | None) -> str:
|
|
32
|
+
"""Separate denied repository access from temporary GitHub API failures."""
|
|
33
|
+
if result is None:
|
|
34
|
+
return "CHECK_FAILED"
|
|
35
|
+
if result.returncode == 0:
|
|
36
|
+
return "READY"
|
|
37
|
+
detail = f"{result.stdout}\n{result.stderr}".casefold()
|
|
38
|
+
if any(word in detail for word in (
|
|
39
|
+
"network", "timed out", "timeout", "resolve host", "connection", "rate limit", "api",
|
|
40
|
+
)):
|
|
41
|
+
return "CHECK_FAILED"
|
|
42
|
+
return "AUTH_REQUIRED"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _version(result: subprocess.CompletedProcess[str] | None) -> str:
|
|
46
|
+
"""Return just a CLI version, never command output or diagnostics."""
|
|
47
|
+
if result is None or result.returncode:
|
|
48
|
+
return ""
|
|
49
|
+
match = _VERSION.search(f"{result.stdout}\n{result.stderr}")
|
|
50
|
+
return match.group(0) if match else ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def runtime_details(root: Path) -> dict[str, dict[str, str]]:
|
|
54
|
+
"""Project token-free CLI provenance for the host-wide Console projection."""
|
|
55
|
+
codex_path = codex_cli_executable() or ""
|
|
56
|
+
try:
|
|
57
|
+
codex_version = _version(CodexCliProvider().command("--version")) if codex_path else ""
|
|
58
|
+
except OSError:
|
|
59
|
+
codex_version = ""
|
|
60
|
+
github_path = shutil.which("gh") or ""
|
|
61
|
+
try:
|
|
62
|
+
github_version = _version(LocalProcessProvider().execute(root, (github_path, "--version"))) if github_path else ""
|
|
63
|
+
except OSError:
|
|
64
|
+
github_version = ""
|
|
65
|
+
return {
|
|
66
|
+
"codex": {"executable": codex_path, "version": codex_version},
|
|
67
|
+
"github": {"executable": github_path, "version": github_version},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def host_status(root: Path, *, require_github: bool = True) -> dict[str, dict[str, str]]:
|
|
72
|
+
"""Check host authentication without deriving any checkout authority.
|
|
73
|
+
|
|
74
|
+
This is the Server/CENTRAL projection used before a project is selected.
|
|
75
|
+
It deliberately asks GitHub only whether the local CLI has an active
|
|
76
|
+
account. Repository access belongs to project admission, where an actual
|
|
77
|
+
canonical repository identity is available.
|
|
78
|
+
"""
|
|
79
|
+
codex = CodexCliProvider()
|
|
80
|
+
codex_installed = codex.status().qualified
|
|
81
|
+
try:
|
|
82
|
+
codex_result = codex.command("login", "status") if codex_installed else None
|
|
83
|
+
except OSError:
|
|
84
|
+
codex_result = None
|
|
85
|
+
result = {
|
|
86
|
+
"codex": {"provider": "CODEX", "state": "UNAVAILABLE" if not codex_installed else _classify(codex_result)},
|
|
87
|
+
}
|
|
88
|
+
if not require_github:
|
|
89
|
+
return result
|
|
90
|
+
github_path = shutil.which("gh")
|
|
91
|
+
if github_path is None:
|
|
92
|
+
result["github"] = {"provider": "GITHUB", "state": "UNAVAILABLE"}
|
|
93
|
+
return result
|
|
94
|
+
try:
|
|
95
|
+
github_result = LocalProcessProvider().execute(
|
|
96
|
+
root, (github_path, "auth", "status", "--hostname", "github.com"),
|
|
97
|
+
)
|
|
98
|
+
except OSError:
|
|
99
|
+
github_result = None
|
|
100
|
+
result["github"] = {"provider": "GITHUB", "state": _classify(github_result)}
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def status(root: Path, *, require_github: bool = True) -> dict[str, dict[str, str]]:
|
|
105
|
+
"""Return provider readiness without session details, tokens, or diagnostics."""
|
|
106
|
+
result = host_status(root, require_github=False)
|
|
107
|
+
if not require_github:
|
|
108
|
+
return result
|
|
109
|
+
if shutil.which("gh") is None:
|
|
110
|
+
result["github"] = {"provider": "GITHUB", "state": "UNAVAILABLE"}
|
|
111
|
+
return result
|
|
112
|
+
try:
|
|
113
|
+
github_result = LocalProcessProvider().execute(root, ("gh", "auth", "status", "--hostname", "github.com"))
|
|
114
|
+
except OSError:
|
|
115
|
+
github_result = None
|
|
116
|
+
github_state = _classify(github_result)
|
|
117
|
+
if github_state == "READY":
|
|
118
|
+
try:
|
|
119
|
+
# `gh repo view --json` uses GitHub's GraphQL quota. Readiness only
|
|
120
|
+
# needs a cheap repository-access proof, so use the REST endpoint
|
|
121
|
+
# and avoid turning an exhausted GraphQL quota into a login repair.
|
|
122
|
+
repository_result = LocalProcessProvider().execute(
|
|
123
|
+
root, ("gh", "api", "repos/{owner}/{repo}", "--jq", ".full_name")
|
|
124
|
+
)
|
|
125
|
+
except OSError:
|
|
126
|
+
repository_result = None
|
|
127
|
+
github_state = _repository_classify(repository_result)
|
|
128
|
+
result["github"] = {"provider": "GITHUB", "state": github_state}
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def failures(root: Path, *, require_github: bool) -> tuple[str, ...]:
|
|
133
|
+
"""Return the provider names that must be repaired before admission."""
|
|
134
|
+
return tuple(
|
|
135
|
+
value["provider"]
|
|
136
|
+
for value in status(root, require_github=require_github).values()
|
|
137
|
+
if value["state"] != "READY"
|
|
138
|
+
)
|