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.

Files changed (130) hide show
  1. engineering_platform/ENGINEERING_PLATFORM_CONFIG.json +32 -0
  2. engineering_platform/ENGINEERING_PLATFORM_VERSION.json +15 -0
  3. engineering_platform/__init__.py +1 -0
  4. engineering_platform/__main__.py +7 -0
  5. engineering_platform/agent_state.py +530 -0
  6. engineering_platform/agent_trust.py +174 -0
  7. engineering_platform/assets/dashboard.css +1317 -0
  8. engineering_platform/assets/dashboard.js +8534 -0
  9. engineering_platform/assets/dashboard_locales.mjs +4049 -0
  10. engineering_platform/assets/dashboard_status_store.mjs +41 -0
  11. engineering_platform/assets/operations-console/apple-touch-icon-dark.png +0 -0
  12. engineering_platform/assets/operations-console/apple-touch-icon-light.png +0 -0
  13. engineering_platform/assets/operations-console/icon-dark.png +0 -0
  14. engineering_platform/assets/operations-console/icon-light.png +0 -0
  15. engineering_platform/assets/operations-console/icon-transparent.png +0 -0
  16. engineering_platform/assets/operations-console/manifest.webmanifest +11 -0
  17. engineering_platform/capability_preflight.py +285 -0
  18. engineering_platform/capability_review.py +261 -0
  19. engineering_platform/central_data_transfer.py +195 -0
  20. engineering_platform/central_database.py +245 -0
  21. engineering_platform/central_store_migration.py +1672 -0
  22. engineering_platform/codex_capacity.py +81 -0
  23. engineering_platform/codex_chat.py +226 -0
  24. engineering_platform/codex_observability.py +153 -0
  25. engineering_platform/component_lock.py +40 -0
  26. engineering_platform/component_logging.py +420 -0
  27. engineering_platform/console_presentation.py +14 -0
  28. engineering_platform/console_route_ownership.py +83 -0
  29. engineering_platform/contracts/__init__.py +38 -0
  30. engineering_platform/contracts/ep_consumer.py +391 -0
  31. engineering_platform/contracts/models.py +105 -0
  32. engineering_platform/contracts/projection.py +401 -0
  33. engineering_platform/dashboard_browser_validation.py +206 -0
  34. engineering_platform/dashboard_state.py +630 -0
  35. engineering_platform/dashboard_supervisor.swift +105 -0
  36. engineering_platform/dashboard_translation.py +129 -0
  37. engineering_platform/dependabot_producer.py +349 -0
  38. engineering_platform/drift_diagnostics.py +144 -0
  39. engineering_platform/emergency_recovery.py +268 -0
  40. engineering_platform/engineering_memory.py +139 -0
  41. engineering_platform/ep_consumer_credentials.py +473 -0
  42. engineering_platform/evidence_projection.py +213 -0
  43. engineering_platform/execution_activity.py +218 -0
  44. engineering_platform/execution_context.py +132 -0
  45. engineering_platform/execution_errors.py +42 -0
  46. engineering_platform/execution_evidence.py +24 -0
  47. engineering_platform/execution_executor.py +730 -0
  48. engineering_platform/execution_finalization.py +44 -0
  49. engineering_platform/execution_host.py +3306 -0
  50. engineering_platform/execution_lease.py +365 -0
  51. engineering_platform/execution_lifecycle.py +447 -0
  52. engineering_platform/execution_models.py +43 -0
  53. engineering_platform/execution_readiness.py +166 -0
  54. engineering_platform/execution_reporting.py +1607 -0
  55. engineering_platform/execution_repository.py +253 -0
  56. engineering_platform/execution_timeout_policy.py +56 -0
  57. engineering_platform/execution_timing.py +440 -0
  58. engineering_platform/execution_transaction.py +28 -0
  59. engineering_platform/external_producer_binding.py +235 -0
  60. engineering_platform/file_inbox.py +249 -0
  61. engineering_platform/forensic_attribution.py +338 -0
  62. engineering_platform/forensic_attribution_v2.py +134 -0
  63. engineering_platform/forensic_delta.py +299 -0
  64. engineering_platform/golden_scenario.py +63 -0
  65. engineering_platform/historical_dashboard_configuration.py +171 -0
  66. engineering_platform/host_admin.py +199 -0
  67. engineering_platform/host_preflight.py +231 -0
  68. engineering_platform/installation_relocation.py +122 -0
  69. engineering_platform/investigation_ledger.py +89 -0
  70. engineering_platform/legacy_inbox_migration.py +79 -0
  71. engineering_platform/lifecycle_worker.py +223 -0
  72. engineering_platform/live_status.py +267 -0
  73. engineering_platform/local_api.py +209 -0
  74. engineering_platform/local_api_keychain.py +51 -0
  75. engineering_platform/local_repository_binding.py +138 -0
  76. engineering_platform/managed_autonomy.py +509 -0
  77. engineering_platform/managed_codex_runtime.py +105 -0
  78. engineering_platform/parity_context.py +203 -0
  79. engineering_platform/parity_lifecycle_dispatcher.py +488 -0
  80. engineering_platform/platform_admin.py +13 -0
  81. engineering_platform/platform_api.py +428 -0
  82. engineering_platform/platform_bootstrap.py +385 -0
  83. engineering_platform/platform_components.py +65 -0
  84. engineering_platform/platform_version.py +171 -0
  85. engineering_platform/pr_check_repair.py +276 -0
  86. engineering_platform/pr_evidence_backfill.py +278 -0
  87. engineering_platform/producer.py +209 -0
  88. engineering_platform/project_agent.py +366 -0
  89. engineering_platform/project_agent_service.py +244 -0
  90. engineering_platform/project_topology.py +126 -0
  91. engineering_platform/prompt_history.py +591 -0
  92. engineering_platform/provider_context.py +136 -0
  93. engineering_platform/provider_context_benchmark.py +41 -0
  94. engineering_platform/provider_context_scope.py +90 -0
  95. engineering_platform/provider_interruption.py +168 -0
  96. engineering_platform/provider_process_identity.py +80 -0
  97. engineering_platform/provider_readiness.py +138 -0
  98. engineering_platform/provider_recovery.py +647 -0
  99. engineering_platform/provider_usage.py +497 -0
  100. engineering_platform/providers.py +471 -0
  101. engineering_platform/qualification.py +220 -0
  102. engineering_platform/recommendation_handoff.py +238 -0
  103. engineering_platform/report_analysis.py +193 -0
  104. engineering_platform/repository_attachment.py +171 -0
  105. engineering_platform/repository_handoff.py +95 -0
  106. engineering_platform/resources.py +38 -0
  107. engineering_platform/reviewer_evidence.py +70 -0
  108. engineering_platform/schemas/repository-attachment.schema.json +61 -0
  109. engineering_platform/server.py +3679 -0
  110. engineering_platform/server_console_services.py +2024 -0
  111. engineering_platform/server_relay.py +172 -0
  112. engineering_platform/server_service.py +122 -0
  113. engineering_platform/status_model.py +135 -0
  114. engineering_platform/status_reconciliation.py +34 -0
  115. engineering_platform/storage.py +2440 -0
  116. engineering_platform/submission_cli.py +77 -0
  117. engineering_platform/submission_intake.py +45 -0
  118. engineering_platform/submission_service.py +317 -0
  119. engineering_platform/telemetry.py +951 -0
  120. engineering_platform/templates/workspace-config.json +25 -0
  121. engineering_platform/validation_identity.py +50 -0
  122. engineering_platform/validation_profile.py +211 -0
  123. engineering_platform/workspace_preflight.py +263 -0
  124. engineering_platform/worktree_provenance.py +147 -0
  125. engineering_platform/worktree_tooling.py +18 -0
  126. engineering_platform-2.2.0.dist-info/METADATA +18 -0
  127. engineering_platform-2.2.0.dist-info/RECORD +130 -0
  128. engineering_platform-2.2.0.dist-info/WHEEL +5 -0
  129. engineering_platform-2.2.0.dist-info/entry_points.txt +6 -0
  130. 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
+ )