@hunter-harness/workflow-harness 0.4.0 → 0.4.1
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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/pi/.harness-build.json +6 -0
- package/harness/bundles/general/pi/CONTEXT.md +98 -0
- package/harness/bundles/general/pi/README.md +478 -0
- package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
- package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
- package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/general/pi/harness-review/reference.md +114 -0
- package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
- package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
- package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/general/pi/harness-run/reference.md +886 -0
- package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
- package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
- package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
- package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/general/pi/harness-test/reference.md +880 -0
- package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/pi/.harness-build.json +6 -0
- package/harness/bundles/java/pi/CONTEXT.md +98 -0
- package/harness/bundles/java/pi/README.md +478 -0
- package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
- package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
- package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
- package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
- package/harness/bundles/java/pi/harness-package/reference.md +210 -0
- package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
- package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
- package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/java/pi/harness-review/reference.md +114 -0
- package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
- package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
- package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/java/pi/harness-run/reference.md +820 -0
- package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
- package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
- package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
- package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
- package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/java/pi/harness-test/reference.md +792 -0
- package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/manifests/general/claude-code.json +17 -17
- package/harness/manifests/general/codebuddy.json +17 -17
- package/harness/manifests/general/codex.json +17 -17
- package/harness/manifests/general/cursor.json +17 -17
- package/harness/manifests/general/pi.json +475 -0
- package/harness/manifests/java/claude-code.json +19 -19
- package/harness/manifests/java/codebuddy.json +19 -19
- package/harness/manifests/java/codex.json +19 -19
- package/harness/manifests/java/cursor.json +19 -19
- package/harness/manifests/java/pi.json +499 -0
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,3355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Harness service-session manager (D7).
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
ensure — reuse / restart / start / needs-user-decision
|
|
6
|
+
status — session + liveness + fingerprint match
|
|
7
|
+
stop — stop AI-managed service and clear session
|
|
8
|
+
|
|
9
|
+
Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
|
|
10
|
+
|
|
11
|
+
Safety: never kill a process that cannot be verified as AI-started.
|
|
12
|
+
Missing or corrupt session → treat as user process → needs-user-decision.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import signal
|
|
24
|
+
import socket
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
from urllib.parse import urlparse
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
37
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
38
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
39
|
+
|
|
40
|
+
from harness_ledger import compute_inputs_hash # noqa: E402
|
|
41
|
+
import harness_process as _process_provider # noqa: E402
|
|
42
|
+
from harness_profile import ( # noqa: E402
|
|
43
|
+
normalize_service_start,
|
|
44
|
+
resolve_service_argv,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
49
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
50
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
51
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
|
|
55
|
+
SESSION_REL = Path("runtime") / "service-session.json"
|
|
56
|
+
LOG_REL = Path("logs") / "service-start.log"
|
|
57
|
+
MUTATION_LOCK_REL = Path("runtime") / "service-mutation.lock"
|
|
58
|
+
MUTATION_STATE_REL = Path("runtime") / "service-mutation.json"
|
|
59
|
+
HEARTBEAT_TTL_SECONDS = 30
|
|
60
|
+
|
|
61
|
+
FATAL_KEYWORDS = (
|
|
62
|
+
"BindException",
|
|
63
|
+
"Could not resolve placeholder",
|
|
64
|
+
"BeanCreationException",
|
|
65
|
+
"BUILD FAILURE",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Process create-time vs session.startedAt tolerance (seconds).
|
|
69
|
+
IDENTITY_TOLERANCE_SEC = 5.0
|
|
70
|
+
STOP_CONFIRM_TIMEOUT_SEC = 5.0
|
|
71
|
+
|
|
72
|
+
# Windows process flags
|
|
73
|
+
_DETACHED_PROCESS = 0x00000008
|
|
74
|
+
_CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
75
|
+
_CREATE_NO_WINDOW = 0x08000000
|
|
76
|
+
_CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
|
77
|
+
_STILL_ACTIVE = 259
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def now_iso() -> str:
|
|
81
|
+
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def emit_json(payload: dict[str, Any], *, as_json: bool) -> None:
|
|
85
|
+
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
86
|
+
if as_json:
|
|
87
|
+
sys.stdout.write(text)
|
|
88
|
+
else:
|
|
89
|
+
action = payload.get("action") or payload.get("status")
|
|
90
|
+
ok = payload.get("ok", True)
|
|
91
|
+
sys.stdout.write(f"ok={ok} action={action}\n")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def emit_error(message: str, *, as_json: bool, code: int = 1, **extra: Any) -> int:
|
|
95
|
+
payload: dict[str, Any] = {"ok": False, "error": message}
|
|
96
|
+
payload.update(extra)
|
|
97
|
+
if as_json:
|
|
98
|
+
sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
99
|
+
else:
|
|
100
|
+
sys.stderr.write(f"error: {message}\n")
|
|
101
|
+
return code
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_path(raw: str | Path) -> Path:
|
|
105
|
+
return Path(raw).expanduser().resolve()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_files_arg(raw: str | None) -> list[str]:
|
|
109
|
+
if raw is None or not str(raw).strip():
|
|
110
|
+
return []
|
|
111
|
+
parts = [p.strip() for p in str(raw).split(",")]
|
|
112
|
+
return [p for p in parts if p]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sha256_text(text: str) -> str:
|
|
116
|
+
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def session_path(change_dir: Path) -> Path:
|
|
120
|
+
return change_dir / SESSION_REL
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def log_path(change_dir: Path) -> Path:
|
|
124
|
+
return change_dir / LOG_REL
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def mutation_lock_path(change_dir: Path) -> Path:
|
|
128
|
+
return change_dir / MUTATION_LOCK_REL
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def mutation_state_path(change_dir: Path) -> Path:
|
|
132
|
+
return change_dir / MUTATION_STATE_REL
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _allocate_service_generation(change_dir: Path) -> int:
|
|
136
|
+
"""Allocate a monotonic incarnation number under the per-change lock."""
|
|
137
|
+
|
|
138
|
+
previous: list[int] = []
|
|
139
|
+
try:
|
|
140
|
+
current = load_session(change_dir)
|
|
141
|
+
except SessionCorrupt:
|
|
142
|
+
current = None
|
|
143
|
+
if isinstance(current, dict):
|
|
144
|
+
value = current.get("serviceGeneration")
|
|
145
|
+
if value is not None:
|
|
146
|
+
previous.append(_normalize_service_generation(value))
|
|
147
|
+
state_path = mutation_state_path(change_dir)
|
|
148
|
+
if state_path.is_file():
|
|
149
|
+
try:
|
|
150
|
+
state = read_json(state_path)
|
|
151
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
152
|
+
state = None
|
|
153
|
+
if isinstance(state, dict) and state.get("serviceGeneration") is not None:
|
|
154
|
+
previous.append(_normalize_service_generation(state.get("serviceGeneration")))
|
|
155
|
+
retired_root = change_dir / "runtime" / "retired-service-sessions"
|
|
156
|
+
if retired_root.is_dir():
|
|
157
|
+
for receipt_path in retired_root.glob("*.receipt.json"):
|
|
158
|
+
try:
|
|
159
|
+
receipt = read_json(receipt_path)
|
|
160
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
161
|
+
continue
|
|
162
|
+
if isinstance(receipt, dict) and receipt.get("oldGeneration") is not None:
|
|
163
|
+
previous.append(_normalize_service_generation(receipt.get("oldGeneration")))
|
|
164
|
+
now_generation = max(1, int(time.time_ns() // 1_000_000))
|
|
165
|
+
return max([now_generation, *(value + 1 for value in previous)])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ServiceMutationLock:
|
|
169
|
+
"""Small CAS-style lock for ensure/stop/retire writers.
|
|
170
|
+
|
|
171
|
+
The lock record is diagnostic only; ownership is the exclusive file
|
|
172
|
+
descriptor plus a random token. A dead owner may be reclaimed, while a
|
|
173
|
+
live owner always produces a typed conflict and no state mutation.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, change_dir: Path, *, operation: str) -> None:
|
|
177
|
+
self.change_dir = change_dir.resolve()
|
|
178
|
+
self.operation = operation
|
|
179
|
+
self.path = mutation_lock_path(self.change_dir)
|
|
180
|
+
self.token = uuid.uuid4().hex
|
|
181
|
+
self._owned = False
|
|
182
|
+
|
|
183
|
+
def acquire(self) -> dict[str, Any] | None:
|
|
184
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
payload = {
|
|
186
|
+
"schemaVersion": 1,
|
|
187
|
+
"operation": self.operation,
|
|
188
|
+
"operationId": self.token,
|
|
189
|
+
"pid": os.getpid(),
|
|
190
|
+
"startedAt": now_iso(),
|
|
191
|
+
}
|
|
192
|
+
encoded = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
|
|
193
|
+
for _attempt in range(2):
|
|
194
|
+
try:
|
|
195
|
+
fd = os.open(
|
|
196
|
+
self.path,
|
|
197
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
198
|
+
0o600,
|
|
199
|
+
)
|
|
200
|
+
except FileExistsError:
|
|
201
|
+
try:
|
|
202
|
+
current = read_json(self.path)
|
|
203
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
204
|
+
current = None
|
|
205
|
+
owner_pid = current.get("pid") if isinstance(current, dict) else None
|
|
206
|
+
if (
|
|
207
|
+
isinstance(owner_pid, int)
|
|
208
|
+
and owner_pid > 0
|
|
209
|
+
and not _process_provider.is_pid_alive(owner_pid)
|
|
210
|
+
):
|
|
211
|
+
try:
|
|
212
|
+
self.path.unlink()
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
pass
|
|
215
|
+
continue
|
|
216
|
+
return {
|
|
217
|
+
"ok": False,
|
|
218
|
+
"code": "SERVICE_MUTATION_CONFLICT",
|
|
219
|
+
"path": str(self.path),
|
|
220
|
+
"owner": current,
|
|
221
|
+
}
|
|
222
|
+
else:
|
|
223
|
+
try:
|
|
224
|
+
with os.fdopen(fd, "wb") as handle:
|
|
225
|
+
handle.write(encoded)
|
|
226
|
+
handle.flush()
|
|
227
|
+
os.fsync(handle.fileno())
|
|
228
|
+
except BaseException:
|
|
229
|
+
self.path.unlink(missing_ok=True)
|
|
230
|
+
raise
|
|
231
|
+
self._owned = True
|
|
232
|
+
return None
|
|
233
|
+
return {
|
|
234
|
+
"ok": False,
|
|
235
|
+
"code": "SERVICE_MUTATION_CONFLICT",
|
|
236
|
+
"path": str(self.path),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
def release(self) -> None:
|
|
240
|
+
if not self._owned:
|
|
241
|
+
return
|
|
242
|
+
try:
|
|
243
|
+
current = read_json(self.path)
|
|
244
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
245
|
+
current = None
|
|
246
|
+
if isinstance(current, dict) and current.get("operationId") == self.token:
|
|
247
|
+
self.path.unlink(missing_ok=True)
|
|
248
|
+
self._owned = False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def profile_path(project: Path) -> Path:
|
|
252
|
+
return project / PROFILE_REL
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def read_json(path: Path) -> Any:
|
|
256
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def write_json(path: Path, data: Any) -> None:
|
|
260
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
262
|
+
# 原子写 temp+os.replace:崩溃后不留半写文件(与 runtime-helpers.mjs writeJsonUtf8NoBom 一致)。
|
|
263
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
264
|
+
try:
|
|
265
|
+
tmp.write_text(text, encoding="utf-8", newline="\n")
|
|
266
|
+
os.replace(tmp, path)
|
|
267
|
+
except BaseException:
|
|
268
|
+
tmp.unlink(missing_ok=True)
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def load_build_profile(project: Path) -> dict[str, Any]:
|
|
273
|
+
path = profile_path(project)
|
|
274
|
+
if not path.is_file():
|
|
275
|
+
raise FileNotFoundError(
|
|
276
|
+
f"build-profile.json missing: {path}; run harness_preflight.py detect first"
|
|
277
|
+
)
|
|
278
|
+
data = read_json(path)
|
|
279
|
+
if not isinstance(data, dict):
|
|
280
|
+
raise ValueError(f"build-profile.json must be an object: {path}")
|
|
281
|
+
return data
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def get_service_start(profile: dict[str, Any]) -> dict[str, Any]:
|
|
285
|
+
svc = profile.get("serviceStart")
|
|
286
|
+
if not isinstance(svc, dict):
|
|
287
|
+
raise ValueError("build-profile.json missing serviceStart object")
|
|
288
|
+
command = svc.get("command")
|
|
289
|
+
argv_template = svc.get("argvTemplate")
|
|
290
|
+
if not (
|
|
291
|
+
isinstance(command, str)
|
|
292
|
+
and command.strip()
|
|
293
|
+
) and not (
|
|
294
|
+
isinstance(argv_template, list)
|
|
295
|
+
and argv_template
|
|
296
|
+
and all(isinstance(item, str) and "\0" not in item for item in argv_template)
|
|
297
|
+
):
|
|
298
|
+
raise ValueError(
|
|
299
|
+
"build-profile.json serviceStart.command/argvTemplate is empty; "
|
|
300
|
+
"configure the service start argv explicitly (will not guess)"
|
|
301
|
+
)
|
|
302
|
+
return svc
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# Worktree/change 路径标记:持久 profile 的 serviceStart 不得含这些具体路径。
|
|
306
|
+
# runtime resolve 把具体 overlay/profile 注入到 session,不写回持久 profile
|
|
307
|
+
# (spec §3.1 持久 profile 只保存模板;§3.4 修复输入端陈旧 profile)。
|
|
308
|
+
STALE_WORKTREE_MARKERS: tuple[str, ...] = (
|
|
309
|
+
".worktrees/",
|
|
310
|
+
".claude/worktrees/",
|
|
311
|
+
".cursor/worktrees/",
|
|
312
|
+
".codeium/worktrees/",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _detect_stale_persistent_values(service_start: dict[str, Any]) -> list[str]:
|
|
317
|
+
"""检测 serviceStart 是否含具体旧 worktree/change 持久路径。
|
|
318
|
+
|
|
319
|
+
持久 profile 只保存模板;command/overlayPath/profile 嵌入 worktree 路径
|
|
320
|
+
说明是 v1 残留或被错误写回的已解析 overlay,必须拒绝(spec §3.4)。
|
|
321
|
+
"""
|
|
322
|
+
stale: list[str] = []
|
|
323
|
+
for field in ("command", "argvTemplate", "overlayPath", "profile"):
|
|
324
|
+
val = service_start.get(field)
|
|
325
|
+
if isinstance(val, list):
|
|
326
|
+
values = [item for item in val if isinstance(item, str)]
|
|
327
|
+
else:
|
|
328
|
+
values = [val] if isinstance(val, str) else []
|
|
329
|
+
if not values:
|
|
330
|
+
continue
|
|
331
|
+
for value in values:
|
|
332
|
+
for marker in STALE_WORKTREE_MARKERS:
|
|
333
|
+
if marker in value:
|
|
334
|
+
stale.append(f"{field} contains stale worktree path: {marker}")
|
|
335
|
+
break
|
|
336
|
+
return stale
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def resolve_service_start(
|
|
340
|
+
profile: dict[str, Any],
|
|
341
|
+
*,
|
|
342
|
+
change_name: str | None = None,
|
|
343
|
+
worktree_root: Path | None = None,
|
|
344
|
+
overlay_path: str | None = None,
|
|
345
|
+
leased_port: int | None = None,
|
|
346
|
+
lease_owner: str | None = None,
|
|
347
|
+
) -> dict[str, Any]:
|
|
348
|
+
"""从模板 serviceStart + runtime context 生成 resolved serviceStart。
|
|
349
|
+
|
|
350
|
+
spec §3.1:持久 profile 的 serviceStart 是模板(profile/overlayPath 留空);
|
|
351
|
+
runtime 注入具体 overlay/profile 到返回值,写入 session,**不写回持久 profile**。
|
|
352
|
+
spec §3.4:含 worktree/change 陈旧持久值时拒绝(修复输入端陈旧 profile)。
|
|
353
|
+
|
|
354
|
+
向后兼容:持久 profile.profile 非空(如 v1 残留 "local-dev")→ 保留;
|
|
355
|
+
overlay_path 未提供 → 保留持久 overlayPath(可能为空)。
|
|
356
|
+
"""
|
|
357
|
+
service_start = get_service_start(profile) # 校验结构化 argv/legacy command 非空
|
|
358
|
+
|
|
359
|
+
stale = _detect_stale_persistent_values(service_start)
|
|
360
|
+
if stale:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
"serviceStart contains stale persistent worktree/change values; "
|
|
363
|
+
"run harness_profile.py migrate to clear: " + "; ".join(stale)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
resolved = dict(service_start)
|
|
367
|
+
# runtime overlay 注入:显式 overlay_path 覆盖;否则保留持久模板值
|
|
368
|
+
if overlay_path is not None:
|
|
369
|
+
resolved["overlayPath"] = overlay_path
|
|
370
|
+
# runtime profile 注入:持久 profile 留空时用 change_name;非空则保留
|
|
371
|
+
if not str(resolved.get("profile") or "").strip():
|
|
372
|
+
resolved["profile"] = change_name or "local-dev"
|
|
373
|
+
if leased_port is not None:
|
|
374
|
+
if not lease_owner:
|
|
375
|
+
raise ValueError("lease owner is required when leased port is provided")
|
|
376
|
+
resolved["leasedPort"] = leased_port
|
|
377
|
+
resolved["leaseOwner"] = lease_owner
|
|
378
|
+
resolved["port"] = leased_port
|
|
379
|
+
for field in ("command", "healthUrl", "healthFile", "overlayPath"):
|
|
380
|
+
value = resolved.get(field)
|
|
381
|
+
if isinstance(value, str):
|
|
382
|
+
resolved[field] = value.replace("{leasedPort}", str(leased_port))
|
|
383
|
+
# Resolve legacy commands through the tested platform parser and resolve
|
|
384
|
+
# placeholders element-by-element. The consumer below only receives
|
|
385
|
+
# ``argv`` and never invokes a shell.
|
|
386
|
+
resolved["argv"] = resolve_service_argv(
|
|
387
|
+
resolved,
|
|
388
|
+
{"{leasedPort}": str(leased_port)} if leased_port is not None else {},
|
|
389
|
+
)
|
|
390
|
+
# worktree_root 预留给未来相对 overlay 路径解析(spec §3.6 state snapshot);
|
|
391
|
+
# 不写入返回值,避免污染 session。
|
|
392
|
+
_ = worktree_root
|
|
393
|
+
return resolved
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def resolve_service_input_files(
|
|
397
|
+
project: Path,
|
|
398
|
+
service_start: dict[str, Any],
|
|
399
|
+
cli_files: list[str],
|
|
400
|
+
) -> list[str]:
|
|
401
|
+
"""Union of CLI ``--files`` and ``serviceStart.inputFiles`` globs.
|
|
402
|
+
|
|
403
|
+
Globs expand relative to project; only files inside project are kept
|
|
404
|
+
(deduped, path-sorted). Empty result raises ValueError -- never produce a
|
|
405
|
+
reusable empty fingerprint (§5.1/§5.2). Never globs outside project.
|
|
406
|
+
"""
|
|
407
|
+
base = project.resolve()
|
|
408
|
+
seen: set[str] = set()
|
|
409
|
+
|
|
410
|
+
for raw in cli_files:
|
|
411
|
+
p = Path(raw).expanduser()
|
|
412
|
+
if not p.is_absolute():
|
|
413
|
+
p = base / p
|
|
414
|
+
try:
|
|
415
|
+
p = p.resolve()
|
|
416
|
+
except OSError:
|
|
417
|
+
continue
|
|
418
|
+
if not p.is_file():
|
|
419
|
+
continue
|
|
420
|
+
try:
|
|
421
|
+
p.relative_to(base)
|
|
422
|
+
except ValueError:
|
|
423
|
+
continue # reject project-external path
|
|
424
|
+
seen.add(p.as_posix())
|
|
425
|
+
|
|
426
|
+
input_files = service_start.get("inputFiles")
|
|
427
|
+
if isinstance(input_files, list):
|
|
428
|
+
for pat in input_files:
|
|
429
|
+
if not isinstance(pat, str) or not pat.strip():
|
|
430
|
+
continue
|
|
431
|
+
for match in base.glob(pat):
|
|
432
|
+
if not match.is_file():
|
|
433
|
+
continue
|
|
434
|
+
resolved = match.resolve()
|
|
435
|
+
try:
|
|
436
|
+
resolved.relative_to(base)
|
|
437
|
+
except ValueError:
|
|
438
|
+
continue # reject project-external glob escape
|
|
439
|
+
seen.add(resolved.as_posix())
|
|
440
|
+
|
|
441
|
+
result = sorted(seen)
|
|
442
|
+
if not result:
|
|
443
|
+
raise ValueError(
|
|
444
|
+
"service inputs are empty; configure serviceStart.inputFiles "
|
|
445
|
+
"(or pass --files) so the service fingerprint covers real source"
|
|
446
|
+
)
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
# Process liveness / identity (stdlib-first; ctypes best-effort on Windows)
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def is_pid_alive(pid: int) -> bool:
|
|
456
|
+
"""Compatibility re-export; process facts come from the shared provider."""
|
|
457
|
+
return _process_provider.is_pid_alive(pid)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _windows_pid_alive(pid: int) -> bool:
|
|
461
|
+
try:
|
|
462
|
+
import ctypes
|
|
463
|
+
from ctypes import wintypes
|
|
464
|
+
|
|
465
|
+
kernel32 = ctypes.windll.kernel32
|
|
466
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
467
|
+
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
468
|
+
if not handle:
|
|
469
|
+
handle = kernel32.OpenProcess(0x0400, False, pid) # PROCESS_QUERY_INFORMATION
|
|
470
|
+
if not handle:
|
|
471
|
+
return False
|
|
472
|
+
try:
|
|
473
|
+
exit_code = wintypes.DWORD()
|
|
474
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
475
|
+
return False
|
|
476
|
+
return int(exit_code.value) == _STILL_ACTIVE
|
|
477
|
+
finally:
|
|
478
|
+
kernel32.CloseHandle(handle)
|
|
479
|
+
except Exception:
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def get_process_create_time(pid: int) -> dt.datetime | None:
|
|
484
|
+
"""Best-effort process create time (timezone-aware). None if unavailable."""
|
|
485
|
+
return _process_provider.get_process_create_time(pid)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def get_process_executable(pid: int) -> str | None:
|
|
489
|
+
"""Best-effort executable identity for PID-reuse protection."""
|
|
490
|
+
return _process_provider.get_process_executable(pid)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _windows_process_create_time(pid: int) -> dt.datetime | None:
|
|
494
|
+
try:
|
|
495
|
+
import ctypes
|
|
496
|
+
from ctypes import wintypes
|
|
497
|
+
|
|
498
|
+
kernel32 = ctypes.windll.kernel32
|
|
499
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
500
|
+
if not handle:
|
|
501
|
+
handle = kernel32.OpenProcess(0x0400, False, pid)
|
|
502
|
+
if not handle:
|
|
503
|
+
return None
|
|
504
|
+
try:
|
|
505
|
+
creation = wintypes.FILETIME()
|
|
506
|
+
exit_time = wintypes.FILETIME()
|
|
507
|
+
kernel_time = wintypes.FILETIME()
|
|
508
|
+
user_time = wintypes.FILETIME()
|
|
509
|
+
ok = kernel32.GetProcessTimes(
|
|
510
|
+
handle,
|
|
511
|
+
ctypes.byref(creation),
|
|
512
|
+
ctypes.byref(exit_time),
|
|
513
|
+
ctypes.byref(kernel_time),
|
|
514
|
+
ctypes.byref(user_time),
|
|
515
|
+
)
|
|
516
|
+
if not ok:
|
|
517
|
+
return None
|
|
518
|
+
val = (creation.dwHighDateTime << 32) | creation.dwLowDateTime
|
|
519
|
+
# FILETIME: 100-ns since 1601-01-01 UTC
|
|
520
|
+
unix_sec = (val - 116444736000000000) / 10_000_000
|
|
521
|
+
return dt.datetime.fromtimestamp(unix_sec, tz=dt.timezone.utc)
|
|
522
|
+
finally:
|
|
523
|
+
kernel32.CloseHandle(handle)
|
|
524
|
+
except Exception:
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _posix_process_create_time(pid: int) -> dt.datetime | None:
|
|
529
|
+
try:
|
|
530
|
+
stat_path = Path(f"/proc/{pid}/stat")
|
|
531
|
+
if not stat_path.is_file():
|
|
532
|
+
# macOS / other: no reliable stdlib create-time
|
|
533
|
+
return None
|
|
534
|
+
text = stat_path.read_text(encoding="utf-8", errors="replace")
|
|
535
|
+
# comm may contain spaces/parens — split after last ')'
|
|
536
|
+
rparen = text.rfind(")")
|
|
537
|
+
if rparen < 0:
|
|
538
|
+
return None
|
|
539
|
+
fields = text[rparen + 2 :].split()
|
|
540
|
+
# field index 20 in remaining = starttime (clock ticks since boot)
|
|
541
|
+
start_ticks = int(fields[19])
|
|
542
|
+
ticks = os.sysconf(os.sysconf_names.get("SC_CLK_TCK", "SC_CLK_TCK"))
|
|
543
|
+
if not ticks:
|
|
544
|
+
ticks = 100
|
|
545
|
+
boot = _linux_boot_time()
|
|
546
|
+
if boot is None:
|
|
547
|
+
return None
|
|
548
|
+
return dt.datetime.fromtimestamp(boot + start_ticks / ticks, tz=dt.timezone.utc)
|
|
549
|
+
except Exception:
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _linux_boot_time() -> float | None:
|
|
554
|
+
try:
|
|
555
|
+
for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines():
|
|
556
|
+
if line.startswith("btime "):
|
|
557
|
+
return float(line.split()[1])
|
|
558
|
+
except Exception:
|
|
559
|
+
return None
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def parse_iso(value: Any) -> dt.datetime | None:
|
|
564
|
+
if not isinstance(value, str) or not value.strip():
|
|
565
|
+
return None
|
|
566
|
+
text = value.strip()
|
|
567
|
+
try:
|
|
568
|
+
# Support "...Z" and space separator
|
|
569
|
+
if text.endswith("Z"):
|
|
570
|
+
text = text[:-1] + "+00:00"
|
|
571
|
+
parsed = dt.datetime.fromisoformat(text)
|
|
572
|
+
if parsed.tzinfo is None:
|
|
573
|
+
parsed = parsed.astimezone()
|
|
574
|
+
return parsed
|
|
575
|
+
except ValueError:
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def verify_process_identity(session: dict[str, Any]) -> bool | None:
|
|
580
|
+
"""True=same process, False=definitely different/dead, None=cannot verify.
|
|
581
|
+
|
|
582
|
+
Requires pid alive AND create-time within tolerance of session.startedAt.
|
|
583
|
+
If create-time unavailable → None (conservative → needs-user-decision).
|
|
584
|
+
"""
|
|
585
|
+
pid = session.get("pid")
|
|
586
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
587
|
+
return False
|
|
588
|
+
process_identity = session.get("processIdentity")
|
|
589
|
+
process_identity = process_identity if isinstance(process_identity, dict) else {}
|
|
590
|
+
process_attestation = session.get("processAttestation")
|
|
591
|
+
process_attestation = (
|
|
592
|
+
process_attestation if isinstance(process_attestation, dict) else {}
|
|
593
|
+
)
|
|
594
|
+
attested_identity = process_attestation or process_identity
|
|
595
|
+
expected: dict[str, Any] = {
|
|
596
|
+
"schemaVersion": 1,
|
|
597
|
+
"pid": pid,
|
|
598
|
+
"createdAt": attested_identity.get("createdAt")
|
|
599
|
+
or process_identity.get("startedAt")
|
|
600
|
+
or session.get("startedAt"),
|
|
601
|
+
"executable": attested_identity.get("executable")
|
|
602
|
+
or process_identity.get("executable"),
|
|
603
|
+
"argvHash": attested_identity.get("argvHash")
|
|
604
|
+
or process_identity.get("argvHash")
|
|
605
|
+
or session.get("argvHash"),
|
|
606
|
+
"workingDirectory": attested_identity.get("workingDirectory")
|
|
607
|
+
or process_identity.get("workingDirectory")
|
|
608
|
+
or session.get("workingDirectory"),
|
|
609
|
+
"parentIdentity": attested_identity.get("parentIdentity")
|
|
610
|
+
or process_identity.get("parentIdentity"),
|
|
611
|
+
"ownerTokenHash": attested_identity.get("ownerTokenHash")
|
|
612
|
+
or process_identity.get("ownerTokenHash")
|
|
613
|
+
or session.get("ownerTokenHash"),
|
|
614
|
+
"fieldProvenance": attested_identity.get("fieldProvenance")
|
|
615
|
+
or process_identity.get("fieldProvenance")
|
|
616
|
+
or {},
|
|
617
|
+
}
|
|
618
|
+
if expected["createdAt"] is None:
|
|
619
|
+
return None
|
|
620
|
+
observed = _process_provider.observe_process_identity(pid)
|
|
621
|
+
# Keep the historical service re-export seam observable for callers and
|
|
622
|
+
# tests that temporarily disable a platform capability. In normal use
|
|
623
|
+
# these wrappers delegate to the provider, so this does not create a
|
|
624
|
+
# second identity implementation.
|
|
625
|
+
compatibility_created = get_process_create_time(pid)
|
|
626
|
+
if compatibility_created is None:
|
|
627
|
+
observed["createdAt"] = None
|
|
628
|
+
observed["fieldProvenance"]["createdAt"] = "UNAVAILABLE"
|
|
629
|
+
observed["capabilities"]["canObserveCreateTime"] = False
|
|
630
|
+
else:
|
|
631
|
+
observed["createdAt"] = compatibility_created.astimezone(
|
|
632
|
+
dt.timezone.utc
|
|
633
|
+
).isoformat(timespec="microseconds")
|
|
634
|
+
observed["fieldProvenance"]["createdAt"] = "OBSERVED"
|
|
635
|
+
compatibility_executable = get_process_executable(pid)
|
|
636
|
+
if compatibility_executable is None:
|
|
637
|
+
observed["executable"] = None
|
|
638
|
+
observed["fieldProvenance"]["executable"] = "UNAVAILABLE"
|
|
639
|
+
observed["capabilities"]["canObserveExecutable"] = False
|
|
640
|
+
else:
|
|
641
|
+
observed["executable"] = compatibility_executable
|
|
642
|
+
observed["fieldProvenance"]["executable"] = "OBSERVED"
|
|
643
|
+
observed["capabilities"]["canObserveExecutable"] = True
|
|
644
|
+
decision = _process_provider.verify_process_identity(expected, observed)
|
|
645
|
+
if decision.get("ok") is True:
|
|
646
|
+
return True
|
|
647
|
+
if decision.get("reasonCode") in {
|
|
648
|
+
"PROCESS_IDENTITY_MISMATCH",
|
|
649
|
+
"PROCESS_CREATE_TIME_MISMATCH",
|
|
650
|
+
"PROCESS_EXECUTABLE_MISMATCH",
|
|
651
|
+
"PROCESS_ARGV_MISMATCH",
|
|
652
|
+
"PROCESS_CWD_MISMATCH",
|
|
653
|
+
"PROCESS_PARENT_MISMATCH",
|
|
654
|
+
"PROCESS_OWNER_MISMATCH",
|
|
655
|
+
}:
|
|
656
|
+
return False
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def terminate_process_tree(
|
|
661
|
+
pid: int,
|
|
662
|
+
*,
|
|
663
|
+
expected_attestation: dict[str, Any] | None = None,
|
|
664
|
+
ownership_proof: dict[str, Any] | None = None,
|
|
665
|
+
timeout_policy: dict[str, Any] | None = None,
|
|
666
|
+
) -> dict[str, Any]:
|
|
667
|
+
"""Compatibility facade requiring a complete attestation and proof.
|
|
668
|
+
|
|
669
|
+
The old PID-only API is intentionally diagnostic-only. Callers that
|
|
670
|
+
cannot supply a platform ownership proof receive a fail-closed result and
|
|
671
|
+
no signal is sent.
|
|
672
|
+
"""
|
|
673
|
+
if not isinstance(expected_attestation, dict):
|
|
674
|
+
expected_attestation = {"pid": pid}
|
|
675
|
+
return _process_provider.terminate_owned_tree(
|
|
676
|
+
expected_attestation,
|
|
677
|
+
ownership_proof,
|
|
678
|
+
timeout_policy,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# ---------------------------------------------------------------------------
|
|
683
|
+
# Port / health probing
|
|
684
|
+
# ---------------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def extract_port(service_start: dict[str, Any]) -> int | None:
|
|
688
|
+
for key in ("port", "listenPort"):
|
|
689
|
+
val = service_start.get(key)
|
|
690
|
+
if isinstance(val, int) and 0 < val < 65536:
|
|
691
|
+
return val
|
|
692
|
+
if isinstance(val, str) and val.strip().isdigit():
|
|
693
|
+
p = int(val.strip())
|
|
694
|
+
if 0 < p < 65536:
|
|
695
|
+
return p
|
|
696
|
+
|
|
697
|
+
health = service_start.get("healthUrl") or service_start.get("healthFile") or ""
|
|
698
|
+
if not isinstance(health, str) or not health.strip():
|
|
699
|
+
return None
|
|
700
|
+
return port_from_health_spec(health.strip())
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def port_from_health_spec(spec: str) -> int | None:
|
|
704
|
+
lower = spec.lower()
|
|
705
|
+
if lower.startswith("file:") or lower.startswith("path:"):
|
|
706
|
+
return None
|
|
707
|
+
# bare path (Windows drive or relative) — not a URL
|
|
708
|
+
if re.match(r"^[A-Za-z]:[\\/]", spec) or (os.sep in spec and "://" not in spec):
|
|
709
|
+
return None
|
|
710
|
+
if lower.startswith("tcp://") or lower.startswith("socket://"):
|
|
711
|
+
rest = spec.split("://", 1)[1]
|
|
712
|
+
host_port = rest.split("/", 1)[0]
|
|
713
|
+
if ":" in host_port:
|
|
714
|
+
try:
|
|
715
|
+
return int(host_port.rsplit(":", 1)[1])
|
|
716
|
+
except ValueError:
|
|
717
|
+
return None
|
|
718
|
+
return None
|
|
719
|
+
try:
|
|
720
|
+
parsed = urlparse(spec if "://" in spec else f"http://{spec}")
|
|
721
|
+
if parsed.port:
|
|
722
|
+
return int(parsed.port)
|
|
723
|
+
except ValueError:
|
|
724
|
+
return None
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|
729
|
+
"""True if something is accepting connections on host:port."""
|
|
730
|
+
try:
|
|
731
|
+
with socket.create_connection((host, port), timeout=0.5):
|
|
732
|
+
return True
|
|
733
|
+
except OSError:
|
|
734
|
+
return False
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def resolve_health_file(spec: str) -> Path | None:
|
|
738
|
+
lower = spec.lower()
|
|
739
|
+
if lower.startswith("file:"):
|
|
740
|
+
raw = spec[5:]
|
|
741
|
+
# file:///C:/path or file:C:/path or file:/path
|
|
742
|
+
if raw.startswith("///"):
|
|
743
|
+
raw = raw[3:]
|
|
744
|
+
elif raw.startswith("//"):
|
|
745
|
+
raw = raw[2:]
|
|
746
|
+
return Path(raw)
|
|
747
|
+
if lower.startswith("path:"):
|
|
748
|
+
return Path(spec[5:])
|
|
749
|
+
if re.match(r"^[A-Za-z]:[\\/]", spec) or (spec.startswith(".") and "://" not in spec):
|
|
750
|
+
return Path(spec)
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def probe_health(
|
|
755
|
+
service_start: dict[str, Any],
|
|
756
|
+
*,
|
|
757
|
+
expected_instance_token: str | None = None,
|
|
758
|
+
) -> bool:
|
|
759
|
+
"""Return True if service appears healthy per serviceStart health config."""
|
|
760
|
+
# Prefer explicit healthFile
|
|
761
|
+
health_file = service_start.get("healthFile")
|
|
762
|
+
if isinstance(health_file, str) and health_file.strip():
|
|
763
|
+
path = Path(health_file.strip())
|
|
764
|
+
if not path.is_file():
|
|
765
|
+
return False
|
|
766
|
+
if expected_instance_token:
|
|
767
|
+
try:
|
|
768
|
+
return expected_instance_token in path.read_text(
|
|
769
|
+
encoding="utf-8", errors="replace"
|
|
770
|
+
)
|
|
771
|
+
except OSError:
|
|
772
|
+
return False
|
|
773
|
+
return True
|
|
774
|
+
|
|
775
|
+
spec = service_start.get("healthUrl")
|
|
776
|
+
if not isinstance(spec, str) or not spec.strip():
|
|
777
|
+
# No health probe configured → treat as healthy once process is up
|
|
778
|
+
# (caller should still have started the process). For wait loop, require
|
|
779
|
+
# at least that we have a running pid — handled by caller.
|
|
780
|
+
return True
|
|
781
|
+
|
|
782
|
+
spec = spec.strip()
|
|
783
|
+
file_path = resolve_health_file(spec)
|
|
784
|
+
if file_path is not None:
|
|
785
|
+
if not file_path.is_file():
|
|
786
|
+
return False
|
|
787
|
+
if expected_instance_token:
|
|
788
|
+
try:
|
|
789
|
+
return expected_instance_token in file_path.read_text(
|
|
790
|
+
encoding="utf-8", errors="replace"
|
|
791
|
+
)
|
|
792
|
+
except OSError:
|
|
793
|
+
return False
|
|
794
|
+
return True
|
|
795
|
+
|
|
796
|
+
lower = spec.lower()
|
|
797
|
+
if lower.startswith("tcp://") or lower.startswith("socket://"):
|
|
798
|
+
rest = spec.split("://", 1)[1]
|
|
799
|
+
host_port = rest.split("/", 1)[0]
|
|
800
|
+
if ":" not in host_port:
|
|
801
|
+
return False
|
|
802
|
+
host, port_s = host_port.rsplit(":", 1)
|
|
803
|
+
try:
|
|
804
|
+
port = int(port_s)
|
|
805
|
+
except ValueError:
|
|
806
|
+
return False
|
|
807
|
+
return is_port_in_use(port, host or "127.0.0.1")
|
|
808
|
+
|
|
809
|
+
# HTTP(S)
|
|
810
|
+
url = spec if "://" in spec else f"http://{spec}"
|
|
811
|
+
try:
|
|
812
|
+
headers = (
|
|
813
|
+
{"X-Harness-Instance-Token": expected_instance_token}
|
|
814
|
+
if expected_instance_token
|
|
815
|
+
else {}
|
|
816
|
+
)
|
|
817
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
818
|
+
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
|
819
|
+
if not 200 <= int(getattr(resp, "status", 200)) < 300:
|
|
820
|
+
return False
|
|
821
|
+
if not expected_instance_token:
|
|
822
|
+
return True
|
|
823
|
+
response_token = resp.headers.get("X-Harness-Instance-Token")
|
|
824
|
+
if response_token == expected_instance_token:
|
|
825
|
+
return True
|
|
826
|
+
body = resp.read().decode("utf-8", errors="replace")
|
|
827
|
+
return expected_instance_token in body
|
|
828
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
|
|
829
|
+
return False
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _listener_identity_is_owned(
|
|
833
|
+
session: dict[str, Any],
|
|
834
|
+
service_start: dict[str, Any],
|
|
835
|
+
) -> bool:
|
|
836
|
+
"""Require an owned process-tree proof before reusing tokenless listeners."""
|
|
837
|
+
if extract_port(service_start) is None:
|
|
838
|
+
return True
|
|
839
|
+
attestation = session.get("processAttestation")
|
|
840
|
+
proof = session.get("ownershipProof")
|
|
841
|
+
if not isinstance(attestation, dict) or not isinstance(proof, dict):
|
|
842
|
+
return False
|
|
843
|
+
try:
|
|
844
|
+
observed = _process_provider._observe_proof_members(proof)
|
|
845
|
+
decision = _process_provider.validate_ownership_proof(
|
|
846
|
+
attestation,
|
|
847
|
+
proof,
|
|
848
|
+
observed,
|
|
849
|
+
)
|
|
850
|
+
return decision.get("ok") is True
|
|
851
|
+
except (OSError, TypeError, ValueError):
|
|
852
|
+
return False
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def log_has_fatal(path: Path) -> str | None:
|
|
856
|
+
if not path.is_file():
|
|
857
|
+
return None
|
|
858
|
+
try:
|
|
859
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
860
|
+
except OSError:
|
|
861
|
+
return None
|
|
862
|
+
# Check last ~200 lines for fatal keywords
|
|
863
|
+
lines = text.splitlines()
|
|
864
|
+
tail = "\n".join(lines[-200:])
|
|
865
|
+
for kw in FATAL_KEYWORDS:
|
|
866
|
+
if kw in tail:
|
|
867
|
+
return kw
|
|
868
|
+
return None
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
class ServiceStartError(Exception):
|
|
872
|
+
def __init__(self, message: str, *, fatal_keyword: str | None = None) -> None:
|
|
873
|
+
super().__init__(message)
|
|
874
|
+
self.fatal_keyword = fatal_keyword
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def wait_for_healthy(
|
|
878
|
+
service_start: dict[str, Any],
|
|
879
|
+
log_file: Path,
|
|
880
|
+
*,
|
|
881
|
+
pid: int | None = None,
|
|
882
|
+
expected_instance_token: str | None = None,
|
|
883
|
+
) -> None:
|
|
884
|
+
"""Startup wait state machine: 0–30s /2s, 30–120s /5s; fatal keywords abort."""
|
|
885
|
+
timeout_sec = service_start.get("startTimeoutSec", 120)
|
|
886
|
+
try:
|
|
887
|
+
timeout_sec = float(timeout_sec)
|
|
888
|
+
except (TypeError, ValueError):
|
|
889
|
+
timeout_sec = 120.0
|
|
890
|
+
if timeout_sec <= 0:
|
|
891
|
+
timeout_sec = 120.0
|
|
892
|
+
|
|
893
|
+
start = time.monotonic()
|
|
894
|
+
while True:
|
|
895
|
+
elapsed = time.monotonic() - start
|
|
896
|
+
fatal = log_has_fatal(log_file)
|
|
897
|
+
if fatal:
|
|
898
|
+
raise ServiceStartError(
|
|
899
|
+
f"service start aborted: fatal keyword in log: {fatal}",
|
|
900
|
+
fatal_keyword=fatal,
|
|
901
|
+
)
|
|
902
|
+
if pid is not None and not is_pid_alive(pid):
|
|
903
|
+
raise ServiceStartError(f"service process exited early (pid={pid})")
|
|
904
|
+
if probe_health(
|
|
905
|
+
service_start,
|
|
906
|
+
expected_instance_token=expected_instance_token,
|
|
907
|
+
):
|
|
908
|
+
return
|
|
909
|
+
if elapsed >= timeout_sec:
|
|
910
|
+
raise ServiceStartError(
|
|
911
|
+
f"service start timed out after {timeout_sec:.0f}s "
|
|
912
|
+
f"(health probe not ready; see {log_file})"
|
|
913
|
+
)
|
|
914
|
+
# 0–30s every 2s; 30–120s every 5s
|
|
915
|
+
if elapsed < 30.0:
|
|
916
|
+
time.sleep(2.0)
|
|
917
|
+
else:
|
|
918
|
+
time.sleep(5.0)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
# ---------------------------------------------------------------------------
|
|
922
|
+
# Session I/O
|
|
923
|
+
# ---------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
class SessionCorrupt(Exception):
|
|
927
|
+
pass
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
class ServiceTransitionConflict(Exception):
|
|
931
|
+
"""Raised only when a caller explicitly requests an illegal service edge."""
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
_SERVICE_REASON_ALIASES = {
|
|
935
|
+
"SERVICE_STARTING": "SERVICE_START_REQUESTED",
|
|
936
|
+
"SERVICE_HEALTH_FAILED": "SERVICE_START_FAILED",
|
|
937
|
+
"SERVICE_STOPPING": "SERVICE_STOP_REQUESTED",
|
|
938
|
+
"SERVICE_STOP_CONFIRMED": "SERVICE_STOPPED",
|
|
939
|
+
"STALE_IDENTITY_MISMATCH": "SERVICE_IDENTITY_STALE",
|
|
940
|
+
"STALE_IDENTITY_RETIRED": "SERVICE_RETIRED",
|
|
941
|
+
"PROCESS_TREE_EXIT_UNCONFIRMED": "SERVICE_STOP_FAILED",
|
|
942
|
+
"OWNED_PORT_RELEASE_UNCONFIRMED": "SERVICE_CLEANUP_INCOMPLETE",
|
|
943
|
+
"LISTENER_IDENTITY_UNVERIFIED": "LISTENER_IDENTITY_UNVERIFIABLE",
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
_SERVICE_TRANSITIONS: dict[str | None, set[str]] = {
|
|
947
|
+
# ``READY``/``FAILED`` are retained for legacy sessions that predate the
|
|
948
|
+
# explicit STARTING record; new writers always persist STARTING first.
|
|
949
|
+
None: {"STARTING", "READY", "FAILED", "STALE_IDENTITY_MISMATCH"},
|
|
950
|
+
"STARTING": {"READY", "FAILED", "STALE_IDENTITY_MISMATCH"},
|
|
951
|
+
"READY": {"READY", "STOPPING", "STALE_IDENTITY_MISMATCH"},
|
|
952
|
+
"STOPPING": {"STOPPED", "FAILED", "STALE_IDENTITY_MISMATCH"},
|
|
953
|
+
"FAILED": {"RETIRED"},
|
|
954
|
+
"STALE_IDENTITY_MISMATCH": {"RETIRED"},
|
|
955
|
+
"STOPPED": {"RETIRED"},
|
|
956
|
+
"RETIRED": set(),
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _canonical_service_reason(reason_code: str) -> str:
|
|
961
|
+
return _SERVICE_REASON_ALIASES.get(reason_code, reason_code)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _normalize_service_generation(value: Any) -> int:
|
|
965
|
+
if isinstance(value, bool):
|
|
966
|
+
return 1
|
|
967
|
+
if isinstance(value, int) and value > 0:
|
|
968
|
+
return value
|
|
969
|
+
if isinstance(value, str):
|
|
970
|
+
try:
|
|
971
|
+
parsed = int(value)
|
|
972
|
+
if parsed > 0:
|
|
973
|
+
return parsed
|
|
974
|
+
except ValueError:
|
|
975
|
+
if value.strip():
|
|
976
|
+
# Legacy writers used opaque ``generation-*`` strings. Keep
|
|
977
|
+
# their identity stable while moving the durable contract to
|
|
978
|
+
# a positive integer; this is migration-only, not a new
|
|
979
|
+
# generation allocator.
|
|
980
|
+
return max(1, int(hashlib.sha256(value.encode("utf-8")).hexdigest()[:15], 16))
|
|
981
|
+
return 1
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def load_session(change_dir: Path) -> dict[str, Any] | None:
|
|
985
|
+
"""Return session dict, None if missing, raise SessionCorrupt if damaged."""
|
|
986
|
+
path = session_path(change_dir)
|
|
987
|
+
if not path.is_file():
|
|
988
|
+
return None
|
|
989
|
+
try:
|
|
990
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
991
|
+
except OSError as exc:
|
|
992
|
+
raise SessionCorrupt(f"cannot read session: {exc}") from exc
|
|
993
|
+
if not text.strip():
|
|
994
|
+
raise SessionCorrupt("session file is empty")
|
|
995
|
+
try:
|
|
996
|
+
data = json.loads(text)
|
|
997
|
+
except json.JSONDecodeError as exc:
|
|
998
|
+
raise SessionCorrupt(f"session JSON corrupt: {exc}") from exc
|
|
999
|
+
if not isinstance(data, dict):
|
|
1000
|
+
raise SessionCorrupt("session must be a JSON object")
|
|
1001
|
+
# Minimal required fields for a usable AI session
|
|
1002
|
+
pid = data.get("pid")
|
|
1003
|
+
if not isinstance(pid, int):
|
|
1004
|
+
raise SessionCorrupt("session.pid missing or not an int")
|
|
1005
|
+
if "startedBy" not in data:
|
|
1006
|
+
raise SessionCorrupt("session.startedBy missing")
|
|
1007
|
+
if "startedAt" not in data:
|
|
1008
|
+
raise SessionCorrupt("session.startedAt missing")
|
|
1009
|
+
if "identityCompleteness" not in data:
|
|
1010
|
+
data["identityCompleteness"] = (
|
|
1011
|
+
"COMPLETE"
|
|
1012
|
+
if isinstance(data.get("processAttestation"), dict)
|
|
1013
|
+
and isinstance(data.get("ownershipProof"), dict)
|
|
1014
|
+
else "PARTIAL"
|
|
1015
|
+
)
|
|
1016
|
+
if "serviceGeneration" in data:
|
|
1017
|
+
data["serviceGeneration"] = _normalize_service_generation(
|
|
1018
|
+
data.get("serviceGeneration")
|
|
1019
|
+
)
|
|
1020
|
+
# Legacy sessions may have persisted a raw health token. Keep only its
|
|
1021
|
+
# digest in the durable session; the configured profile remains the sole
|
|
1022
|
+
# source from which a probe may obtain the token value.
|
|
1023
|
+
legacy_token = data.pop("instanceToken", None)
|
|
1024
|
+
if isinstance(legacy_token, str) and legacy_token:
|
|
1025
|
+
data.setdefault("instanceTokenHash", sha256_text(legacy_token))
|
|
1026
|
+
return data
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def clear_session(change_dir: Path) -> None:
|
|
1030
|
+
path = session_path(change_dir)
|
|
1031
|
+
if path.is_file():
|
|
1032
|
+
try:
|
|
1033
|
+
path.unlink()
|
|
1034
|
+
except OSError:
|
|
1035
|
+
pass
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def write_session(change_dir: Path, session: dict[str, Any]) -> Path:
|
|
1039
|
+
path = session_path(change_dir)
|
|
1040
|
+
write_json(path, session)
|
|
1041
|
+
return path
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _service_operation_id(session: dict[str, Any] | None = None) -> str:
|
|
1045
|
+
value = session.get("operationId") if isinstance(session, dict) else None
|
|
1046
|
+
return str(value or uuid.uuid4().hex)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _transition(
|
|
1050
|
+
session: dict[str, Any],
|
|
1051
|
+
status: str,
|
|
1052
|
+
*,
|
|
1053
|
+
reason_code: str,
|
|
1054
|
+
operation_id: str,
|
|
1055
|
+
) -> dict[str, Any]:
|
|
1056
|
+
previous = session.get("status")
|
|
1057
|
+
allowed = _SERVICE_TRANSITIONS.get(previous)
|
|
1058
|
+
if allowed is None or status not in allowed:
|
|
1059
|
+
return {
|
|
1060
|
+
"ok": False,
|
|
1061
|
+
"code": "SERVICE_TRANSITION_CONFLICT",
|
|
1062
|
+
"from": previous,
|
|
1063
|
+
"to": status,
|
|
1064
|
+
"operationId": operation_id,
|
|
1065
|
+
}
|
|
1066
|
+
revision = session.get("stateRevision")
|
|
1067
|
+
try:
|
|
1068
|
+
revision_number = int(revision)
|
|
1069
|
+
except (TypeError, ValueError):
|
|
1070
|
+
revision_number = 0
|
|
1071
|
+
session["stateRevision"] = revision_number + 1
|
|
1072
|
+
session["status"] = status
|
|
1073
|
+
session["reasonCode"] = _canonical_service_reason(reason_code)
|
|
1074
|
+
session["operationId"] = operation_id
|
|
1075
|
+
history = session.setdefault("transitionHistory", [])
|
|
1076
|
+
if not isinstance(history, list):
|
|
1077
|
+
history = []
|
|
1078
|
+
session["transitionHistory"] = history
|
|
1079
|
+
history.append(
|
|
1080
|
+
{
|
|
1081
|
+
"from": previous,
|
|
1082
|
+
"to": status,
|
|
1083
|
+
"reasonCode": _canonical_service_reason(reason_code),
|
|
1084
|
+
"operationId": operation_id,
|
|
1085
|
+
"at": now_iso(),
|
|
1086
|
+
"revision": session["stateRevision"],
|
|
1087
|
+
}
|
|
1088
|
+
)
|
|
1089
|
+
return {
|
|
1090
|
+
"ok": True,
|
|
1091
|
+
"from": previous,
|
|
1092
|
+
"to": status,
|
|
1093
|
+
"reasonCode": session["reasonCode"],
|
|
1094
|
+
"revision": session["stateRevision"],
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _ensure_session_state(
|
|
1099
|
+
session: dict[str, Any],
|
|
1100
|
+
*,
|
|
1101
|
+
operation_id: str,
|
|
1102
|
+
generation: int | None = None,
|
|
1103
|
+
) -> None:
|
|
1104
|
+
# Legacy durable service sessions did not persist an explicit status. A
|
|
1105
|
+
# session carrying a process identity is an existing READY incarnation,
|
|
1106
|
+
# while synthetic transition fixtures intentionally remain status-less so
|
|
1107
|
+
# they can exercise the ``None -> STARTING`` edge.
|
|
1108
|
+
if "pid" in session and not isinstance(session.get("status"), str):
|
|
1109
|
+
session["status"] = "READY"
|
|
1110
|
+
session.setdefault("reasonCode", "SERVICE_READY")
|
|
1111
|
+
if not isinstance(session.get("serviceGeneration"), int) or session.get(
|
|
1112
|
+
"serviceGeneration"
|
|
1113
|
+
) <= 0:
|
|
1114
|
+
session["serviceGeneration"] = _normalize_service_generation(generation)
|
|
1115
|
+
if not isinstance(session.get("stateRevision"), int):
|
|
1116
|
+
session["stateRevision"] = 0
|
|
1117
|
+
session.setdefault("transitionHistory", [])
|
|
1118
|
+
session["heartbeat"] = {
|
|
1119
|
+
"kind": "SUPERVISOR",
|
|
1120
|
+
"writerIdentity": _process_provider.canonical_argv_hash(
|
|
1121
|
+
[str(Path(sys.executable).resolve()), str(os.getpid())]
|
|
1122
|
+
),
|
|
1123
|
+
"generation": session["serviceGeneration"],
|
|
1124
|
+
"lastSeenAt": now_iso(),
|
|
1125
|
+
"ttlSeconds": HEARTBEAT_TTL_SECONDS,
|
|
1126
|
+
"staleReason": None,
|
|
1127
|
+
}
|
|
1128
|
+
legacy_token = session.pop("instanceToken", None)
|
|
1129
|
+
if isinstance(legacy_token, str) and legacy_token:
|
|
1130
|
+
session.setdefault("instanceTokenHash", sha256_text(legacy_token))
|
|
1131
|
+
session.setdefault("instanceTokenHash", None)
|
|
1132
|
+
session.setdefault("identityCompleteness", "COMPLETE")
|
|
1133
|
+
session["operationId"] = operation_id
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _heartbeat_status(session: dict[str, Any]) -> dict[str, Any]:
|
|
1137
|
+
heartbeat = session.get("heartbeat")
|
|
1138
|
+
if not isinstance(heartbeat, dict):
|
|
1139
|
+
return {"status": "UNKNOWN", "stale": False, "reasonCode": None}
|
|
1140
|
+
raw = heartbeat.get("lastSeenAt")
|
|
1141
|
+
ttl = heartbeat.get("ttlSeconds")
|
|
1142
|
+
try:
|
|
1143
|
+
seen = dt.datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
1144
|
+
if seen.tzinfo is None:
|
|
1145
|
+
seen = seen.replace(tzinfo=dt.timezone.utc)
|
|
1146
|
+
age = (dt.datetime.now(dt.timezone.utc) - seen.astimezone(dt.timezone.utc)).total_seconds()
|
|
1147
|
+
stale = age > float(ttl)
|
|
1148
|
+
except (TypeError, ValueError):
|
|
1149
|
+
age = float("inf")
|
|
1150
|
+
stale = True
|
|
1151
|
+
return {
|
|
1152
|
+
"status": "STALE" if stale else "FRESH",
|
|
1153
|
+
"stale": stale,
|
|
1154
|
+
"ageSeconds": age,
|
|
1155
|
+
"reasonCode": "SERVICE_HEARTBEAT_STALE" if stale else None,
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _same_path(left: Any, right: Path | None) -> bool:
|
|
1160
|
+
if right is None or not isinstance(left, str) or not left.strip():
|
|
1161
|
+
return False
|
|
1162
|
+
try:
|
|
1163
|
+
return Path(left).resolve() == right.resolve()
|
|
1164
|
+
except OSError:
|
|
1165
|
+
return False
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def session_is_owned(
|
|
1169
|
+
session: dict[str, Any],
|
|
1170
|
+
*,
|
|
1171
|
+
change_id: str | None = None,
|
|
1172
|
+
execution_root: Path | None = None,
|
|
1173
|
+
worktree_root: Path | None = None,
|
|
1174
|
+
) -> bool:
|
|
1175
|
+
"""Return true for an AI session owned by the supplied change/root filters.
|
|
1176
|
+
|
|
1177
|
+
``change_id`` is required when provided. Root filters match if the session's
|
|
1178
|
+
``executionRoot`` or ``worktreeRoot`` equals any supplied root (OR).
|
|
1179
|
+
"""
|
|
1180
|
+
if session.get("startedBy") != "AI":
|
|
1181
|
+
return False
|
|
1182
|
+
if change_id is not None and session.get("changeId") != change_id:
|
|
1183
|
+
return False
|
|
1184
|
+
root_matches: list[bool] = []
|
|
1185
|
+
if execution_root is not None:
|
|
1186
|
+
root_matches.append(_same_path(session.get("executionRoot"), execution_root))
|
|
1187
|
+
root_matches.append(_same_path(session.get("worktreeRoot"), execution_root))
|
|
1188
|
+
if worktree_root is not None:
|
|
1189
|
+
root_matches.append(_same_path(session.get("worktreeRoot"), worktree_root))
|
|
1190
|
+
root_matches.append(_same_path(session.get("executionRoot"), worktree_root))
|
|
1191
|
+
if root_matches and not any(root_matches):
|
|
1192
|
+
return False
|
|
1193
|
+
return True
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def find_owned_sessions(
|
|
1197
|
+
project_root: Path,
|
|
1198
|
+
*,
|
|
1199
|
+
change_id: str | None = None,
|
|
1200
|
+
execution_root: Path | None = None,
|
|
1201
|
+
worktree_root: Path | None = None,
|
|
1202
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
1203
|
+
"""Discover persisted sessions and classify them without stopping anything."""
|
|
1204
|
+
project = resolve_path(project_root)
|
|
1205
|
+
candidate_dirs: list[Path] = []
|
|
1206
|
+
if change_id:
|
|
1207
|
+
candidate_dirs.extend(
|
|
1208
|
+
[
|
|
1209
|
+
project / ".harness" / "changes" / change_id,
|
|
1210
|
+
project / ".harness" / "state" / "changes" / change_id,
|
|
1211
|
+
]
|
|
1212
|
+
)
|
|
1213
|
+
else:
|
|
1214
|
+
for root in (
|
|
1215
|
+
project / ".harness" / "changes",
|
|
1216
|
+
project / ".harness" / "state" / "changes",
|
|
1217
|
+
):
|
|
1218
|
+
if root.is_dir():
|
|
1219
|
+
candidate_dirs.extend(path for path in root.iterdir() if path.is_dir())
|
|
1220
|
+
|
|
1221
|
+
owned: list[dict[str, Any]] = []
|
|
1222
|
+
reported: list[dict[str, Any]] = []
|
|
1223
|
+
seen: set[Path] = set()
|
|
1224
|
+
for change_dir in candidate_dirs:
|
|
1225
|
+
path = session_path(change_dir)
|
|
1226
|
+
try:
|
|
1227
|
+
path = path.resolve()
|
|
1228
|
+
except OSError:
|
|
1229
|
+
continue
|
|
1230
|
+
if path in seen or not path.is_file():
|
|
1231
|
+
continue
|
|
1232
|
+
seen.add(path)
|
|
1233
|
+
try:
|
|
1234
|
+
session = load_session(change_dir)
|
|
1235
|
+
except SessionCorrupt as exc:
|
|
1236
|
+
reported.append(
|
|
1237
|
+
{
|
|
1238
|
+
"sessionPath": str(path),
|
|
1239
|
+
"reason": "session-corrupt",
|
|
1240
|
+
"detail": str(exc),
|
|
1241
|
+
}
|
|
1242
|
+
)
|
|
1243
|
+
continue
|
|
1244
|
+
if session is None:
|
|
1245
|
+
continue
|
|
1246
|
+
item = {"changeDir": str(change_dir.resolve()), "session": session}
|
|
1247
|
+
if session_is_owned(
|
|
1248
|
+
session,
|
|
1249
|
+
change_id=change_id,
|
|
1250
|
+
execution_root=execution_root,
|
|
1251
|
+
worktree_root=worktree_root,
|
|
1252
|
+
):
|
|
1253
|
+
owned.append(item)
|
|
1254
|
+
else:
|
|
1255
|
+
reported.append(
|
|
1256
|
+
{
|
|
1257
|
+
"sessionPath": str(path),
|
|
1258
|
+
"reason": "not-owned",
|
|
1259
|
+
"startedBy": session.get("startedBy"),
|
|
1260
|
+
"pid": session.get("pid"),
|
|
1261
|
+
"leasedPort": session.get("leasedPort"),
|
|
1262
|
+
}
|
|
1263
|
+
)
|
|
1264
|
+
return {"owned": owned, "reported": reported}
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def stop_owned_sessions(
|
|
1268
|
+
project_root: Path,
|
|
1269
|
+
*,
|
|
1270
|
+
change_id: str | None = None,
|
|
1271
|
+
execution_root: Path | None = None,
|
|
1272
|
+
worktree_root: Path | None = None,
|
|
1273
|
+
) -> dict[str, Any]:
|
|
1274
|
+
"""Stop only discovered AI sessions whose persisted ownership still matches."""
|
|
1275
|
+
discovered = find_owned_sessions(
|
|
1276
|
+
project_root,
|
|
1277
|
+
change_id=change_id,
|
|
1278
|
+
execution_root=execution_root,
|
|
1279
|
+
worktree_root=worktree_root,
|
|
1280
|
+
)
|
|
1281
|
+
stopped: list[dict[str, Any]] = []
|
|
1282
|
+
blocked: list[dict[str, Any]] = []
|
|
1283
|
+
ports_still_in_use: list[int] = []
|
|
1284
|
+
for item in discovered["owned"]:
|
|
1285
|
+
change_dir = Path(item["changeDir"])
|
|
1286
|
+
session = item["session"]
|
|
1287
|
+
if not session_is_owned(
|
|
1288
|
+
session,
|
|
1289
|
+
change_id=change_id,
|
|
1290
|
+
execution_root=execution_root,
|
|
1291
|
+
worktree_root=worktree_root,
|
|
1292
|
+
):
|
|
1293
|
+
blocked.append(
|
|
1294
|
+
{"sessionPath": str(session_path(change_dir)), "reason": "ownership-changed"}
|
|
1295
|
+
)
|
|
1296
|
+
continue
|
|
1297
|
+
result = stop_ai_session(change_dir, session, require_identity=True)
|
|
1298
|
+
result["sessionPath"] = str(session_path(change_dir))
|
|
1299
|
+
if result.get("action") == "needs-user-decision":
|
|
1300
|
+
blocked.append(result)
|
|
1301
|
+
continue
|
|
1302
|
+
pid = session.get("pid")
|
|
1303
|
+
if isinstance(pid, int) and is_pid_alive(pid):
|
|
1304
|
+
blocked.append(
|
|
1305
|
+
{
|
|
1306
|
+
"sessionPath": str(session_path(change_dir)),
|
|
1307
|
+
"reason": "owned-process-still-alive",
|
|
1308
|
+
"pid": pid,
|
|
1309
|
+
}
|
|
1310
|
+
)
|
|
1311
|
+
continue
|
|
1312
|
+
stopped.append(result)
|
|
1313
|
+
port = session.get("leasedPort")
|
|
1314
|
+
if isinstance(port, int) and is_port_in_use(port):
|
|
1315
|
+
ports_still_in_use.append(port)
|
|
1316
|
+
return {
|
|
1317
|
+
"ok": not blocked,
|
|
1318
|
+
"ownedSessions": len(discovered["owned"]),
|
|
1319
|
+
"stopped": stopped,
|
|
1320
|
+
"blocked": blocked,
|
|
1321
|
+
"reported": discovered["reported"],
|
|
1322
|
+
"portsStillInUse": ports_still_in_use,
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
# ---------------------------------------------------------------------------
|
|
1327
|
+
# Start / stop helpers
|
|
1328
|
+
# ---------------------------------------------------------------------------
|
|
1329
|
+
|
|
1330
|
+
_WIN_LAUNCHER_SOURCE = """\
|
|
1331
|
+
#!/usr/bin/env python3
|
|
1332
|
+
\"\"\"Harness Windows service launcher (internal).\"\"\"
|
|
1333
|
+
from __future__ import annotations
|
|
1334
|
+
|
|
1335
|
+
import ctypes
|
|
1336
|
+
import json
|
|
1337
|
+
import subprocess
|
|
1338
|
+
import sys
|
|
1339
|
+
from pathlib import Path
|
|
1340
|
+
|
|
1341
|
+
_CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
1342
|
+
_CREATE_NO_WINDOW = 0x08000000
|
|
1343
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
1344
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
class _IoCounters(ctypes.Structure):
|
|
1348
|
+
_fields_ = [
|
|
1349
|
+
("ReadOperationCount", ctypes.c_uint64),
|
|
1350
|
+
("WriteOperationCount", ctypes.c_uint64),
|
|
1351
|
+
("OtherOperationCount", ctypes.c_uint64),
|
|
1352
|
+
("ReadTransferCount", ctypes.c_uint64),
|
|
1353
|
+
("WriteTransferCount", ctypes.c_uint64),
|
|
1354
|
+
("OtherTransferCount", ctypes.c_uint64),
|
|
1355
|
+
]
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
class _BasicLimitInformation(ctypes.Structure):
|
|
1359
|
+
_fields_ = [
|
|
1360
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
1361
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
1362
|
+
("LimitFlags", ctypes.c_uint32),
|
|
1363
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
1364
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
1365
|
+
("ActiveProcessLimit", ctypes.c_uint32),
|
|
1366
|
+
("Affinity", ctypes.c_size_t),
|
|
1367
|
+
("PriorityClass", ctypes.c_uint32),
|
|
1368
|
+
("SchedulingClass", ctypes.c_uint32),
|
|
1369
|
+
]
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
class _ExtendedLimitInformation(ctypes.Structure):
|
|
1373
|
+
_fields_ = [
|
|
1374
|
+
("BasicLimitInformation", _BasicLimitInformation),
|
|
1375
|
+
("IoInfo", _IoCounters),
|
|
1376
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
1377
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
1378
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
1379
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
1380
|
+
]
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _create_job(name: str) -> int:
|
|
1384
|
+
kernel32 = ctypes.windll.kernel32
|
|
1385
|
+
kernel32.CreateJobObjectW.restype = ctypes.c_void_p
|
|
1386
|
+
handle = kernel32.CreateJobObjectW(None, name)
|
|
1387
|
+
if not handle:
|
|
1388
|
+
raise OSError("CreateJobObjectW failed")
|
|
1389
|
+
info = _ExtendedLimitInformation()
|
|
1390
|
+
info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
1391
|
+
configured = kernel32.SetInformationJobObject(
|
|
1392
|
+
ctypes.c_void_p(handle),
|
|
1393
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
|
|
1394
|
+
ctypes.byref(info),
|
|
1395
|
+
ctypes.sizeof(info),
|
|
1396
|
+
)
|
|
1397
|
+
if not configured:
|
|
1398
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
1399
|
+
raise OSError("SetInformationJobObject failed")
|
|
1400
|
+
return int(handle)
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def main() -> int:
|
|
1404
|
+
log_path = Path(sys.argv[1])
|
|
1405
|
+
argv_path = Path(sys.argv[2])
|
|
1406
|
+
pid_path = Path(sys.argv[3])
|
|
1407
|
+
job_name = sys.argv[4]
|
|
1408
|
+
argv = json.loads(argv_path.read_text(encoding="utf-8"))
|
|
1409
|
+
if not isinstance(argv, list) or not argv or any(
|
|
1410
|
+
not isinstance(item, str) or "\\0" in item for item in argv
|
|
1411
|
+
):
|
|
1412
|
+
raise SystemExit("empty or invalid service argv")
|
|
1413
|
+
|
|
1414
|
+
job_handle = _create_job(job_name)
|
|
1415
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1416
|
+
log_handle = log_path.open("w", encoding="utf-8", errors="replace")
|
|
1417
|
+
try:
|
|
1418
|
+
proc = subprocess.Popen(
|
|
1419
|
+
argv,
|
|
1420
|
+
shell=False,
|
|
1421
|
+
stdout=log_handle,
|
|
1422
|
+
stderr=subprocess.STDOUT,
|
|
1423
|
+
stdin=subprocess.DEVNULL,
|
|
1424
|
+
creationflags=(_CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW),
|
|
1425
|
+
close_fds=False,
|
|
1426
|
+
)
|
|
1427
|
+
finally:
|
|
1428
|
+
log_handle.close()
|
|
1429
|
+
|
|
1430
|
+
kernel32 = ctypes.windll.kernel32
|
|
1431
|
+
assigned = kernel32.AssignProcessToJobObject(
|
|
1432
|
+
ctypes.c_void_p(job_handle),
|
|
1433
|
+
ctypes.c_void_p(int(proc._handle)),
|
|
1434
|
+
)
|
|
1435
|
+
if not assigned:
|
|
1436
|
+
proc.terminate()
|
|
1437
|
+
kernel32.CloseHandle(ctypes.c_void_p(job_handle))
|
|
1438
|
+
raise OSError("AssignProcessToJobObject failed")
|
|
1439
|
+
pid_path.write_text(str(proc.pid), encoding="utf-8")
|
|
1440
|
+
try:
|
|
1441
|
+
return int(proc.wait())
|
|
1442
|
+
finally:
|
|
1443
|
+
kernel32.CloseHandle(ctypes.c_void_p(job_handle))
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
if __name__ == "__main__":
|
|
1447
|
+
raise SystemExit(main())
|
|
1448
|
+
"""
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _runtime_dir(change_dir: Path) -> Path:
|
|
1452
|
+
path = change_dir / "runtime"
|
|
1453
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
1454
|
+
return path
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def _launcher_pid_path(change_dir: Path) -> Path:
|
|
1458
|
+
return _runtime_dir(change_dir) / "_harness_service.launcher.pid"
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _job_id_path(change_dir: Path) -> Path:
|
|
1462
|
+
return _runtime_dir(change_dir) / "_harness_service.job.id"
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _breakaway_note_path(change_dir: Path) -> Path:
|
|
1466
|
+
return _runtime_dir(change_dir) / "_harness_service.breakaway.json"
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def _child_pid_path(change_dir: Path) -> Path:
|
|
1470
|
+
return _runtime_dir(change_dir) / "_harness_service.child.pid"
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _cleanup_windows_launcher(change_dir: Path) -> None:
|
|
1474
|
+
if os.name != "nt":
|
|
1475
|
+
return
|
|
1476
|
+
path = _launcher_pid_path(change_dir)
|
|
1477
|
+
if not path.is_file():
|
|
1478
|
+
return
|
|
1479
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
1480
|
+
try:
|
|
1481
|
+
path.unlink()
|
|
1482
|
+
except OSError:
|
|
1483
|
+
pass
|
|
1484
|
+
if text.isdigit():
|
|
1485
|
+
pid = int(text)
|
|
1486
|
+
if is_pid_alive(pid):
|
|
1487
|
+
terminate_process_tree(pid)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _wait_for_windows_launcher_exit(
|
|
1491
|
+
change_dir: Path,
|
|
1492
|
+
*,
|
|
1493
|
+
timeout_sec: float = STOP_CONFIRM_TIMEOUT_SEC,
|
|
1494
|
+
) -> bool:
|
|
1495
|
+
"""Wait for the detached Job owner to release log/file handles.
|
|
1496
|
+
|
|
1497
|
+
The launcher is deliberately not killed by PID. Its named Job has
|
|
1498
|
+
already been terminated through the ownership proof; this wait merely
|
|
1499
|
+
observes the launcher completing its ``proc.wait()`` and avoids a race
|
|
1500
|
+
with archive/temp-directory cleanup.
|
|
1501
|
+
"""
|
|
1502
|
+
pid = _read_positive_int(_launcher_pid_path(change_dir))
|
|
1503
|
+
if pid is None:
|
|
1504
|
+
return True
|
|
1505
|
+
deadline = time.monotonic() + max(0.0, timeout_sec)
|
|
1506
|
+
while is_pid_alive(pid) and time.monotonic() < deadline:
|
|
1507
|
+
time.sleep(0.05)
|
|
1508
|
+
return not is_pid_alive(pid)
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _write_windows_launcher(change_dir: Path) -> Path:
|
|
1512
|
+
launcher = _runtime_dir(change_dir) / "_harness_service_launcher.py"
|
|
1513
|
+
launcher.write_text(_WIN_LAUNCHER_SOURCE, encoding="utf-8", newline="\n")
|
|
1514
|
+
return launcher
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _wait_for_child_pid(pid_path: Path, *, timeout_sec: float = 15.0) -> int:
|
|
1518
|
+
deadline = time.monotonic() + timeout_sec
|
|
1519
|
+
while time.monotonic() < deadline:
|
|
1520
|
+
if pid_path.is_file():
|
|
1521
|
+
text = pid_path.read_text(encoding="utf-8").strip()
|
|
1522
|
+
if text.isdigit():
|
|
1523
|
+
return int(text)
|
|
1524
|
+
time.sleep(0.05)
|
|
1525
|
+
raise TimeoutError(f"service child pid not recorded within {timeout_sec:.0f}s ({pid_path})")
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _read_positive_int(path: Path) -> int | None:
|
|
1529
|
+
try:
|
|
1530
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
1531
|
+
except OSError:
|
|
1532
|
+
return None
|
|
1533
|
+
return int(text) if text.isdigit() and int(text) > 0 else None
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _read_optional_text(path: Path) -> str | None:
|
|
1537
|
+
try:
|
|
1538
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
1539
|
+
except OSError:
|
|
1540
|
+
return None
|
|
1541
|
+
return text or None
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _start_detached_service_windows(
|
|
1545
|
+
argv: list[str],
|
|
1546
|
+
*,
|
|
1547
|
+
change_dir: Path,
|
|
1548
|
+
cwd: Path,
|
|
1549
|
+
log_file: Path,
|
|
1550
|
+
owner_token: str,
|
|
1551
|
+
) -> _process_provider.SpawnedProcess:
|
|
1552
|
+
runtime = _runtime_dir(change_dir)
|
|
1553
|
+
launcher = _write_windows_launcher(change_dir)
|
|
1554
|
+
command_path = runtime / "_harness_service.command.txt"
|
|
1555
|
+
pid_path = _child_pid_path(change_dir)
|
|
1556
|
+
owner_hash = _process_provider._token_hash(owner_token)
|
|
1557
|
+
proof_id = "proof-" + uuid.uuid4().hex
|
|
1558
|
+
job_id = (
|
|
1559
|
+
"Local\\HunterHarness-"
|
|
1560
|
+
+ owner_hash.split(":", 1)[1][:16]
|
|
1561
|
+
+ "-"
|
|
1562
|
+
+ proof_id
|
|
1563
|
+
)
|
|
1564
|
+
command_path.write_text(
|
|
1565
|
+
json.dumps(argv, ensure_ascii=False, separators=(",", ":")),
|
|
1566
|
+
encoding="utf-8",
|
|
1567
|
+
newline="\n",
|
|
1568
|
+
)
|
|
1569
|
+
_job_id_path(change_dir).write_text(job_id, encoding="utf-8", newline="\n")
|
|
1570
|
+
if pid_path.exists():
|
|
1571
|
+
pid_path.unlink()
|
|
1572
|
+
|
|
1573
|
+
launcher_args = [
|
|
1574
|
+
sys.executable,
|
|
1575
|
+
str(launcher),
|
|
1576
|
+
str(log_file),
|
|
1577
|
+
str(command_path),
|
|
1578
|
+
str(pid_path),
|
|
1579
|
+
job_id,
|
|
1580
|
+
]
|
|
1581
|
+
base_flags = _DETACHED_PROCESS | _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW
|
|
1582
|
+
# CREATE_BREAKAWAY_FROM_JOB fails with ERROR_ACCESS_DENIED (WinError 5)
|
|
1583
|
+
# whenever the *caller* already runs inside a Job Object that was created
|
|
1584
|
+
# without JOB_OBJECT_LIMIT_BREAKAWAY_OK — which is exactly how agent CLIs
|
|
1585
|
+
# (Claude Code / CodeBuddy / Codex) contain their child processes. Breakaway
|
|
1586
|
+
# is an optimisation (the service outlives the agent session), not a
|
|
1587
|
+
# requirement, so fall back to spawning inside the caller's job rather than
|
|
1588
|
+
# failing the whole start.
|
|
1589
|
+
launcher_proc = None
|
|
1590
|
+
breakaway = True
|
|
1591
|
+
try:
|
|
1592
|
+
launcher_proc = subprocess.Popen(
|
|
1593
|
+
launcher_args,
|
|
1594
|
+
cwd=str(cwd),
|
|
1595
|
+
stdin=subprocess.DEVNULL,
|
|
1596
|
+
stdout=subprocess.DEVNULL,
|
|
1597
|
+
stderr=subprocess.DEVNULL,
|
|
1598
|
+
creationflags=base_flags | _CREATE_BREAKAWAY_FROM_JOB,
|
|
1599
|
+
close_fds=False,
|
|
1600
|
+
)
|
|
1601
|
+
except OSError as exc:
|
|
1602
|
+
if getattr(exc, "winerror", None) != 5:
|
|
1603
|
+
raise
|
|
1604
|
+
breakaway = False
|
|
1605
|
+
launcher_proc = subprocess.Popen(
|
|
1606
|
+
launcher_args,
|
|
1607
|
+
cwd=str(cwd),
|
|
1608
|
+
stdin=subprocess.DEVNULL,
|
|
1609
|
+
stdout=subprocess.DEVNULL,
|
|
1610
|
+
stderr=subprocess.DEVNULL,
|
|
1611
|
+
creationflags=base_flags,
|
|
1612
|
+
close_fds=False,
|
|
1613
|
+
)
|
|
1614
|
+
_breakaway_note_path(change_dir).write_text(
|
|
1615
|
+
json.dumps(
|
|
1616
|
+
{
|
|
1617
|
+
"breakawayFromJob": breakaway,
|
|
1618
|
+
"note": (
|
|
1619
|
+
None
|
|
1620
|
+
if breakaway
|
|
1621
|
+
else "spawned inside the caller's Job Object "
|
|
1622
|
+
"(CREATE_BREAKAWAY_FROM_JOB denied); the service is "
|
|
1623
|
+
"terminated when the agent session ends"
|
|
1624
|
+
),
|
|
1625
|
+
},
|
|
1626
|
+
ensure_ascii=False,
|
|
1627
|
+
)
|
|
1628
|
+
+ "\n",
|
|
1629
|
+
encoding="utf-8",
|
|
1630
|
+
newline="\n",
|
|
1631
|
+
)
|
|
1632
|
+
_launcher_pid_path(change_dir).write_text(str(launcher_proc.pid), encoding="utf-8")
|
|
1633
|
+
_wait_for_child_pid(pid_path)
|
|
1634
|
+
# The launcher intentionally remains alive as the Job Object owner. Close
|
|
1635
|
+
# only this controller's process handle so Popen does not warn at teardown.
|
|
1636
|
+
launcher_handle = getattr(launcher_proc, "_handle", None)
|
|
1637
|
+
if launcher_handle is not None:
|
|
1638
|
+
launcher_handle.Close()
|
|
1639
|
+
launcher_proc.returncode = 0
|
|
1640
|
+
child_pid = _read_positive_int(pid_path)
|
|
1641
|
+
if child_pid is None:
|
|
1642
|
+
raise TimeoutError(f"service child pid is invalid ({pid_path})")
|
|
1643
|
+
observed = _process_provider.observe_process_identity(child_pid)
|
|
1644
|
+
attestation = json.loads(json.dumps(observed))
|
|
1645
|
+
attestation["argvHash"] = _process_provider.canonical_argv_hash(argv)
|
|
1646
|
+
attestation["workingDirectory"] = str(cwd.resolve())
|
|
1647
|
+
attestation["ownerTokenHash"] = owner_hash
|
|
1648
|
+
attestation["fieldProvenance"]["argvHash"] = (
|
|
1649
|
+
"OBSERVED" if observed.get("argvHash") is not None else "ATTESTED"
|
|
1650
|
+
)
|
|
1651
|
+
attestation["fieldProvenance"]["workingDirectory"] = (
|
|
1652
|
+
"OBSERVED"
|
|
1653
|
+
if observed.get("workingDirectory") is not None
|
|
1654
|
+
else "ATTESTED"
|
|
1655
|
+
)
|
|
1656
|
+
attestation["fieldProvenance"]["ownerTokenHash"] = "ATTESTED"
|
|
1657
|
+
attestation["capabilities"]["canVerifyOwnership"] = True
|
|
1658
|
+
proof = {
|
|
1659
|
+
"schemaVersion": 1,
|
|
1660
|
+
"proofId": proof_id,
|
|
1661
|
+
"kind": "WINDOWS_NAMED_JOB",
|
|
1662
|
+
"jobName": job_id,
|
|
1663
|
+
"ownershipTokenHash": owner_hash,
|
|
1664
|
+
"leaderPid": child_pid,
|
|
1665
|
+
"leaderCreatedAt": attestation.get("createdAt"),
|
|
1666
|
+
"members": [attestation],
|
|
1667
|
+
"membersComplete": True,
|
|
1668
|
+
"leaderExited": False,
|
|
1669
|
+
}
|
|
1670
|
+
return _process_provider.SpawnedProcess(
|
|
1671
|
+
process=launcher_proc,
|
|
1672
|
+
attestation=attestation,
|
|
1673
|
+
observedIdentity=observed,
|
|
1674
|
+
ownershipProof=proof,
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def start_detached_service(
|
|
1679
|
+
argv: list[str] | tuple[str, ...] | str,
|
|
1680
|
+
*,
|
|
1681
|
+
change_dir: Path,
|
|
1682
|
+
cwd: Path,
|
|
1683
|
+
) -> _process_provider.SpawnedProcess:
|
|
1684
|
+
"""Start service with structured argv; log to logs/service-start.log.
|
|
1685
|
+
|
|
1686
|
+
``str`` is retained only for callers from the legacy facade and is parsed
|
|
1687
|
+
by the same profile parser; no caller is allowed to reach ``shell=True``.
|
|
1688
|
+
"""
|
|
1689
|
+
if isinstance(argv, str):
|
|
1690
|
+
argv = resolve_service_argv({"command": argv})
|
|
1691
|
+
argv = list(argv)
|
|
1692
|
+
if not argv:
|
|
1693
|
+
raise ValueError("ARGUMENT_INVALID: service argv is empty")
|
|
1694
|
+
log_file = log_path(change_dir)
|
|
1695
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1696
|
+
if log_file.is_file():
|
|
1697
|
+
log_file.unlink()
|
|
1698
|
+
|
|
1699
|
+
owner_token = f"service:{change_dir.resolve()}:{uuid.uuid4().hex}"
|
|
1700
|
+
if os.name == "nt":
|
|
1701
|
+
return _start_detached_service_windows(
|
|
1702
|
+
argv,
|
|
1703
|
+
change_dir=change_dir,
|
|
1704
|
+
cwd=cwd,
|
|
1705
|
+
log_file=log_file,
|
|
1706
|
+
owner_token=owner_token,
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
log_handle = log_file.open("w", encoding="utf-8", errors="replace")
|
|
1710
|
+
try:
|
|
1711
|
+
return _process_provider.spawn_structured_argv(
|
|
1712
|
+
argv,
|
|
1713
|
+
cwd=cwd,
|
|
1714
|
+
environment={
|
|
1715
|
+
"PYTHONUTF8": "1",
|
|
1716
|
+
"PYTHONIOENCODING": "utf-8",
|
|
1717
|
+
},
|
|
1718
|
+
owner_token=owner_token,
|
|
1719
|
+
stdout=log_handle,
|
|
1720
|
+
stderr=subprocess.STDOUT,
|
|
1721
|
+
)
|
|
1722
|
+
finally:
|
|
1723
|
+
try:
|
|
1724
|
+
log_handle.close()
|
|
1725
|
+
except OSError:
|
|
1726
|
+
pass
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def build_session(
|
|
1730
|
+
*,
|
|
1731
|
+
pid: int,
|
|
1732
|
+
module_inputs_hash: str,
|
|
1733
|
+
module_inputs_files: list[str],
|
|
1734
|
+
command: str,
|
|
1735
|
+
service_start: dict[str, Any],
|
|
1736
|
+
argv: list[str] | None = None,
|
|
1737
|
+
process_attestation: dict[str, Any] | None = None,
|
|
1738
|
+
ownership_proof: dict[str, Any] | None = None,
|
|
1739
|
+
started_at: str | None = None,
|
|
1740
|
+
worktree_root: Path | None = None,
|
|
1741
|
+
execution_root: Path | None = None,
|
|
1742
|
+
change_id: str | None = None,
|
|
1743
|
+
attempt_id: str | None = None,
|
|
1744
|
+
service_pid: int | None = None,
|
|
1745
|
+
job_id: str | None = None,
|
|
1746
|
+
) -> dict[str, Any]:
|
|
1747
|
+
profile_name = service_start.get("profile") or "local-dev"
|
|
1748
|
+
overlay = service_start.get("overlayPath") or ""
|
|
1749
|
+
effective_started_at = started_at or now_iso()
|
|
1750
|
+
executable = get_process_executable(pid) or (
|
|
1751
|
+
sys.executable if os.name == "nt" else os.environ.get("SHELL", "/bin/sh")
|
|
1752
|
+
)
|
|
1753
|
+
owned_ports: set[int] = set()
|
|
1754
|
+
port = extract_port(service_start)
|
|
1755
|
+
if port is not None:
|
|
1756
|
+
owned_ports.add(port)
|
|
1757
|
+
configured_ports = service_start.get("ownedPorts")
|
|
1758
|
+
if isinstance(configured_ports, list):
|
|
1759
|
+
for item in configured_ports:
|
|
1760
|
+
try:
|
|
1761
|
+
candidate = int(item)
|
|
1762
|
+
except (TypeError, ValueError):
|
|
1763
|
+
continue
|
|
1764
|
+
if 0 < candidate < 65536:
|
|
1765
|
+
owned_ports.add(candidate)
|
|
1766
|
+
resolved_argv = list(argv or [])
|
|
1767
|
+
argv_hash = (
|
|
1768
|
+
_process_provider.canonical_argv_hash(resolved_argv)
|
|
1769
|
+
if resolved_argv
|
|
1770
|
+
else sha256_text(command)
|
|
1771
|
+
)
|
|
1772
|
+
attestation = (
|
|
1773
|
+
json.loads(json.dumps(process_attestation))
|
|
1774
|
+
if isinstance(process_attestation, dict)
|
|
1775
|
+
else None
|
|
1776
|
+
)
|
|
1777
|
+
process_identity = {
|
|
1778
|
+
"executable": str(executable),
|
|
1779
|
+
"commandHash": argv_hash,
|
|
1780
|
+
"argvHash": argv_hash,
|
|
1781
|
+
"startedAt": effective_started_at,
|
|
1782
|
+
"parentChain": [
|
|
1783
|
+
{
|
|
1784
|
+
"pid": os.getpid(),
|
|
1785
|
+
"executable": str(Path(sys.executable).resolve()),
|
|
1786
|
+
}
|
|
1787
|
+
],
|
|
1788
|
+
}
|
|
1789
|
+
if attestation is not None:
|
|
1790
|
+
process_identity.update(attestation)
|
|
1791
|
+
# Preserve the service's public legacy field names while keeping the
|
|
1792
|
+
# provider attestation as the source of truth for destructive actions.
|
|
1793
|
+
process_identity["commandHash"] = argv_hash
|
|
1794
|
+
process_identity["argvHash"] = attestation.get("argvHash", argv_hash)
|
|
1795
|
+
session = {
|
|
1796
|
+
"schemaVersion": 1,
|
|
1797
|
+
"serviceId": str(
|
|
1798
|
+
service_start.get("serviceId")
|
|
1799
|
+
or change_id
|
|
1800
|
+
or profile_name
|
|
1801
|
+
),
|
|
1802
|
+
"sessionId": "service-" + uuid.uuid4().hex,
|
|
1803
|
+
"status": "STARTING",
|
|
1804
|
+
"reasonCode": "SERVICE_START_REQUESTED",
|
|
1805
|
+
"pid": pid,
|
|
1806
|
+
"startedBy": "AI",
|
|
1807
|
+
"moduleInputsHash": module_inputs_hash,
|
|
1808
|
+
"moduleInputsFiles": module_inputs_files,
|
|
1809
|
+
"profile": profile_name,
|
|
1810
|
+
"startCommandHash": argv_hash,
|
|
1811
|
+
"overlayPath": overlay,
|
|
1812
|
+
"startedAt": effective_started_at,
|
|
1813
|
+
"command": command,
|
|
1814
|
+
"argv": resolved_argv,
|
|
1815
|
+
"ownedPorts": sorted(owned_ports),
|
|
1816
|
+
"processIdentity": process_identity,
|
|
1817
|
+
"fingerprint": module_inputs_hash,
|
|
1818
|
+
"cleanupComplete": False,
|
|
1819
|
+
"supersedesSessionId": None,
|
|
1820
|
+
"leaseIdentity": None,
|
|
1821
|
+
}
|
|
1822
|
+
if attestation is not None:
|
|
1823
|
+
session["processAttestation"] = attestation
|
|
1824
|
+
if isinstance(ownership_proof, dict):
|
|
1825
|
+
session["ownershipProof"] = json.loads(json.dumps(ownership_proof))
|
|
1826
|
+
if isinstance(service_pid, int) and service_pid > 0:
|
|
1827
|
+
session["servicePid"] = service_pid
|
|
1828
|
+
if job_id:
|
|
1829
|
+
session["jobId"] = str(job_id)
|
|
1830
|
+
if isinstance(service_start.get("leasedPort"), int):
|
|
1831
|
+
session["leasedPort"] = service_start["leasedPort"]
|
|
1832
|
+
session["leaseOwner"] = service_start.get("leaseOwner")
|
|
1833
|
+
lease_id = service_start.get("leaseId")
|
|
1834
|
+
lease_change_id = service_start.get("leaseChangeId") or change_id
|
|
1835
|
+
lease_run_id = service_start.get("runId") or attempt_id
|
|
1836
|
+
lease_expires_at = service_start.get("leaseExpiresAt")
|
|
1837
|
+
lease_generation = service_start.get("leaseGeneration")
|
|
1838
|
+
if (
|
|
1839
|
+
isinstance(lease_id, str)
|
|
1840
|
+
and lease_id
|
|
1841
|
+
and isinstance(lease_change_id, str)
|
|
1842
|
+
and lease_change_id
|
|
1843
|
+
and isinstance(lease_run_id, str)
|
|
1844
|
+
and lease_run_id
|
|
1845
|
+
and isinstance(lease_expires_at, str)
|
|
1846
|
+
and lease_expires_at
|
|
1847
|
+
):
|
|
1848
|
+
session["leaseIdentity"] = {
|
|
1849
|
+
"leaseId": lease_id,
|
|
1850
|
+
"changeId": lease_change_id,
|
|
1851
|
+
"runId": lease_run_id,
|
|
1852
|
+
"expiresAt": lease_expires_at,
|
|
1853
|
+
"generation": _normalize_service_generation(lease_generation),
|
|
1854
|
+
}
|
|
1855
|
+
if isinstance(service_start.get("listenerIdentity"), dict):
|
|
1856
|
+
session["leaseIdentity"]["listenerIdentity"] = json.loads(
|
|
1857
|
+
json.dumps(service_start["listenerIdentity"], ensure_ascii=False)
|
|
1858
|
+
)
|
|
1859
|
+
if worktree_root is not None:
|
|
1860
|
+
session["worktreeRoot"] = str(worktree_root.resolve())
|
|
1861
|
+
if execution_root is not None:
|
|
1862
|
+
session["executionRoot"] = str(execution_root.resolve())
|
|
1863
|
+
if change_id:
|
|
1864
|
+
session["changeId"] = change_id
|
|
1865
|
+
if attempt_id:
|
|
1866
|
+
session["attemptId"] = attempt_id
|
|
1867
|
+
return session
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def compute_module_hash(
|
|
1871
|
+
files: list[str],
|
|
1872
|
+
session: dict[str, Any] | None = None,
|
|
1873
|
+
) -> tuple[str, list[str]]:
|
|
1874
|
+
"""Compute inputsHash from --files, else session.moduleInputsFiles, else empty."""
|
|
1875
|
+
use_files = list(files)
|
|
1876
|
+
if not use_files and session is not None:
|
|
1877
|
+
stored = session.get("moduleInputsFiles")
|
|
1878
|
+
if isinstance(stored, list):
|
|
1879
|
+
use_files = [str(x) for x in stored if str(x).strip()]
|
|
1880
|
+
if not use_files:
|
|
1881
|
+
# Empty set → stable empty hash (order-independent)
|
|
1882
|
+
return compute_inputs_hash([])
|
|
1883
|
+
return compute_inputs_hash(use_files)
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def needs_user_decision(
|
|
1887
|
+
*,
|
|
1888
|
+
reason: str,
|
|
1889
|
+
as_json: bool,
|
|
1890
|
+
**extra: Any,
|
|
1891
|
+
) -> int:
|
|
1892
|
+
payload: dict[str, Any] = {
|
|
1893
|
+
"ok": True,
|
|
1894
|
+
"action": "needs-user-decision",
|
|
1895
|
+
"reason": reason,
|
|
1896
|
+
}
|
|
1897
|
+
payload.update(extra)
|
|
1898
|
+
emit_json(payload, as_json=as_json)
|
|
1899
|
+
return 0
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
def _confirm_service_shutdown(session: dict[str, Any]) -> dict[str, Any]:
|
|
1903
|
+
tracked_pids = sorted(
|
|
1904
|
+
{
|
|
1905
|
+
value
|
|
1906
|
+
for value in (session.get("pid"), session.get("servicePid"))
|
|
1907
|
+
if isinstance(value, int) and value > 0
|
|
1908
|
+
}
|
|
1909
|
+
)
|
|
1910
|
+
ports = sorted(
|
|
1911
|
+
{
|
|
1912
|
+
int(value)
|
|
1913
|
+
for value in (session.get("ownedPorts") or [])
|
|
1914
|
+
if isinstance(value, int) and 0 < value < 65536
|
|
1915
|
+
}
|
|
1916
|
+
)
|
|
1917
|
+
deadline = time.monotonic() + STOP_CONFIRM_TIMEOUT_SEC
|
|
1918
|
+
while True:
|
|
1919
|
+
alive_pids = [pid for pid in tracked_pids if is_pid_alive(pid)]
|
|
1920
|
+
occupied_ports = [port for port in ports if is_port_in_use(port)]
|
|
1921
|
+
if not alive_pids and not occupied_ports:
|
|
1922
|
+
return {
|
|
1923
|
+
"ok": True,
|
|
1924
|
+
"alivePids": [],
|
|
1925
|
+
"occupiedPorts": [],
|
|
1926
|
+
}
|
|
1927
|
+
if time.monotonic() >= deadline:
|
|
1928
|
+
return {
|
|
1929
|
+
"ok": False,
|
|
1930
|
+
"alivePids": alive_pids,
|
|
1931
|
+
"occupiedPorts": occupied_ports,
|
|
1932
|
+
}
|
|
1933
|
+
time.sleep(0.05)
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def _remove_windows_service_metadata(change_dir: Path) -> None:
|
|
1937
|
+
if os.name != "nt":
|
|
1938
|
+
return
|
|
1939
|
+
runtime = change_dir / "runtime"
|
|
1940
|
+
for name in (
|
|
1941
|
+
"_harness_service.launcher.pid",
|
|
1942
|
+
"_harness_service.child.pid",
|
|
1943
|
+
"_harness_service.job.id",
|
|
1944
|
+
"_harness_service.command.txt",
|
|
1945
|
+
"_harness_service_launcher.py",
|
|
1946
|
+
):
|
|
1947
|
+
try:
|
|
1948
|
+
(runtime / name).unlink(missing_ok=True)
|
|
1949
|
+
except OSError:
|
|
1950
|
+
pass
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
def stop_ai_session(
|
|
1954
|
+
change_dir: Path,
|
|
1955
|
+
session: dict[str, Any],
|
|
1956
|
+
*,
|
|
1957
|
+
require_identity: bool = True,
|
|
1958
|
+
) -> dict[str, Any]:
|
|
1959
|
+
"""Stop verified AI session process and clear session file."""
|
|
1960
|
+
operation_id = _service_operation_id(session)
|
|
1961
|
+
_ensure_session_state(session, operation_id=operation_id)
|
|
1962
|
+
pid = session.get("pid")
|
|
1963
|
+
if require_identity:
|
|
1964
|
+
identity = verify_process_identity(session)
|
|
1965
|
+
if identity is not True:
|
|
1966
|
+
_transition(
|
|
1967
|
+
session,
|
|
1968
|
+
"STALE_IDENTITY_MISMATCH" if identity is False else "FAILED",
|
|
1969
|
+
reason_code=(
|
|
1970
|
+
"STALE_IDENTITY_MISMATCH"
|
|
1971
|
+
if identity is False
|
|
1972
|
+
else "IDENTITY_UNVERIFIABLE"
|
|
1973
|
+
),
|
|
1974
|
+
operation_id=operation_id,
|
|
1975
|
+
)
|
|
1976
|
+
write_session(change_dir, session)
|
|
1977
|
+
return {
|
|
1978
|
+
"ok": True,
|
|
1979
|
+
"action": "needs-user-decision",
|
|
1980
|
+
"reasonCode": (
|
|
1981
|
+
"PROCESS_IDENTITY_MISMATCH"
|
|
1982
|
+
if identity is False
|
|
1983
|
+
else "IDENTITY_UNVERIFIABLE"
|
|
1984
|
+
),
|
|
1985
|
+
"reason": (
|
|
1986
|
+
"cannot-verify-process-identity"
|
|
1987
|
+
if identity is None
|
|
1988
|
+
else "process-identity-mismatch"
|
|
1989
|
+
),
|
|
1990
|
+
"pid": pid,
|
|
1991
|
+
"killed": False,
|
|
1992
|
+
}
|
|
1993
|
+
stopping_transition = _transition(
|
|
1994
|
+
session,
|
|
1995
|
+
"STOPPING",
|
|
1996
|
+
reason_code="SERVICE_STOPPING",
|
|
1997
|
+
operation_id=operation_id,
|
|
1998
|
+
)
|
|
1999
|
+
if not stopping_transition.get("ok"):
|
|
2000
|
+
return {
|
|
2001
|
+
"ok": False,
|
|
2002
|
+
"code": "SERVICE_TRANSITION_CONFLICT",
|
|
2003
|
+
"reasonCode": "SERVICE_TRANSITION_CONFLICT",
|
|
2004
|
+
"action": "stop-refused",
|
|
2005
|
+
"from": stopping_transition.get("from"),
|
|
2006
|
+
"to": stopping_transition.get("to"),
|
|
2007
|
+
"operationId": operation_id,
|
|
2008
|
+
"killed": False,
|
|
2009
|
+
"sessionCleared": False,
|
|
2010
|
+
}
|
|
2011
|
+
write_session(change_dir, session)
|
|
2012
|
+
termination: dict[str, Any] | None = None
|
|
2013
|
+
if isinstance(pid, int) and is_pid_alive(pid):
|
|
2014
|
+
termination = terminate_process_tree(
|
|
2015
|
+
pid,
|
|
2016
|
+
expected_attestation=(
|
|
2017
|
+
session.get("processAttestation")
|
|
2018
|
+
if isinstance(session.get("processAttestation"), dict)
|
|
2019
|
+
else None
|
|
2020
|
+
),
|
|
2021
|
+
ownership_proof=(
|
|
2022
|
+
session.get("ownershipProof")
|
|
2023
|
+
if isinstance(session.get("ownershipProof"), dict)
|
|
2024
|
+
else None
|
|
2025
|
+
),
|
|
2026
|
+
timeout_policy={"graceSeconds": STOP_CONFIRM_TIMEOUT_SEC},
|
|
2027
|
+
)
|
|
2028
|
+
if (
|
|
2029
|
+
isinstance(termination, dict)
|
|
2030
|
+
and termination.get("reasonCode") == "IDENTITY_UNVERIFIABLE"
|
|
2031
|
+
):
|
|
2032
|
+
_transition(
|
|
2033
|
+
session,
|
|
2034
|
+
"STALE_IDENTITY_MISMATCH",
|
|
2035
|
+
reason_code="STALE_IDENTITY_MISMATCH",
|
|
2036
|
+
operation_id=operation_id,
|
|
2037
|
+
)
|
|
2038
|
+
write_session(change_dir, session)
|
|
2039
|
+
return {
|
|
2040
|
+
"ok": True,
|
|
2041
|
+
"action": "needs-user-decision",
|
|
2042
|
+
"reasonCode": "IDENTITY_UNVERIFIABLE",
|
|
2043
|
+
"reason": "ownership-proof-unavailable",
|
|
2044
|
+
"pid": pid,
|
|
2045
|
+
"killed": False,
|
|
2046
|
+
}
|
|
2047
|
+
confirmation = _confirm_service_shutdown(session)
|
|
2048
|
+
if confirmation["alivePids"]:
|
|
2049
|
+
_transition(
|
|
2050
|
+
session,
|
|
2051
|
+
"FAILED",
|
|
2052
|
+
reason_code="PROCESS_TREE_EXIT_UNCONFIRMED",
|
|
2053
|
+
operation_id=operation_id,
|
|
2054
|
+
)
|
|
2055
|
+
write_session(change_dir, session)
|
|
2056
|
+
return {
|
|
2057
|
+
"ok": False,
|
|
2058
|
+
"code": "PROCESS_TREE_EXIT_UNCONFIRMED",
|
|
2059
|
+
"reasonCode": "SERVICE_STOP_FAILED",
|
|
2060
|
+
"action": "cleanup-unconfirmed",
|
|
2061
|
+
"pid": pid,
|
|
2062
|
+
"alivePids": confirmation["alivePids"],
|
|
2063
|
+
"occupiedPorts": confirmation["occupiedPorts"],
|
|
2064
|
+
"killed": True,
|
|
2065
|
+
"sessionCleared": False,
|
|
2066
|
+
}
|
|
2067
|
+
if confirmation["occupiedPorts"]:
|
|
2068
|
+
_transition(
|
|
2069
|
+
session,
|
|
2070
|
+
"FAILED",
|
|
2071
|
+
reason_code="OWNED_PORT_RELEASE_UNCONFIRMED",
|
|
2072
|
+
operation_id=operation_id,
|
|
2073
|
+
)
|
|
2074
|
+
write_session(change_dir, session)
|
|
2075
|
+
return {
|
|
2076
|
+
"ok": False,
|
|
2077
|
+
"code": "OWNED_PORT_RELEASE_UNCONFIRMED",
|
|
2078
|
+
"reasonCode": "SERVICE_CLEANUP_INCOMPLETE",
|
|
2079
|
+
"action": "cleanup-unconfirmed",
|
|
2080
|
+
"pid": pid,
|
|
2081
|
+
"alivePids": [],
|
|
2082
|
+
"occupiedPorts": confirmation["occupiedPorts"],
|
|
2083
|
+
"killed": True,
|
|
2084
|
+
"sessionCleared": False,
|
|
2085
|
+
}
|
|
2086
|
+
if os.name == "nt" and not _wait_for_windows_launcher_exit(change_dir):
|
|
2087
|
+
_transition(
|
|
2088
|
+
session,
|
|
2089
|
+
"FAILED",
|
|
2090
|
+
reason_code="SERVICE_STOP_TIMEOUT",
|
|
2091
|
+
operation_id=operation_id,
|
|
2092
|
+
)
|
|
2093
|
+
write_session(change_dir, session)
|
|
2094
|
+
return {
|
|
2095
|
+
"ok": False,
|
|
2096
|
+
"code": "SERVICE_STOP_TIMEOUT",
|
|
2097
|
+
"reasonCode": "SERVICE_STOP_TIMEOUT",
|
|
2098
|
+
"action": "cleanup-unconfirmed",
|
|
2099
|
+
"pid": pid,
|
|
2100
|
+
"alivePids": [],
|
|
2101
|
+
"occupiedPorts": [],
|
|
2102
|
+
"launcherStillAlive": True,
|
|
2103
|
+
"killed": True,
|
|
2104
|
+
"sessionCleared": False,
|
|
2105
|
+
}
|
|
2106
|
+
stopped_transition = _transition(
|
|
2107
|
+
session,
|
|
2108
|
+
"STOPPED",
|
|
2109
|
+
reason_code="SERVICE_STOP_CONFIRMED",
|
|
2110
|
+
operation_id=operation_id,
|
|
2111
|
+
)
|
|
2112
|
+
if not stopped_transition.get("ok"):
|
|
2113
|
+
write_session(change_dir, session)
|
|
2114
|
+
return {
|
|
2115
|
+
"ok": False,
|
|
2116
|
+
"code": "SERVICE_TRANSITION_CONFLICT",
|
|
2117
|
+
"reasonCode": "SERVICE_TRANSITION_CONFLICT",
|
|
2118
|
+
"action": "cleanup-unconfirmed",
|
|
2119
|
+
"pid": pid,
|
|
2120
|
+
"killed": True,
|
|
2121
|
+
"sessionCleared": False,
|
|
2122
|
+
"alivePids": [],
|
|
2123
|
+
"occupiedPorts": [],
|
|
2124
|
+
}
|
|
2125
|
+
_remove_windows_service_metadata(change_dir)
|
|
2126
|
+
clear_session(change_dir)
|
|
2127
|
+
return {
|
|
2128
|
+
"ok": True,
|
|
2129
|
+
"code": "SERVICE_STOP_CONFIRMED",
|
|
2130
|
+
"reasonCode": "SERVICE_STOPPED",
|
|
2131
|
+
"action": "stopped",
|
|
2132
|
+
"pid": pid,
|
|
2133
|
+
"killed": True,
|
|
2134
|
+
"sessionCleared": True,
|
|
2135
|
+
"alivePids": [],
|
|
2136
|
+
"occupiedPorts": [],
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
|
|
2140
|
+
# ---------------------------------------------------------------------------
|
|
2141
|
+
# Subcommands
|
|
2142
|
+
# ---------------------------------------------------------------------------
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
def cmd_ensure(args: argparse.Namespace) -> int:
|
|
2146
|
+
"""Serialize ensure mutations and expose a stable conflict receipt."""
|
|
2147
|
+
|
|
2148
|
+
change_dir = resolve_path(args.change_dir)
|
|
2149
|
+
lock = ServiceMutationLock(change_dir, operation="ensure")
|
|
2150
|
+
conflict = lock.acquire()
|
|
2151
|
+
if conflict is not None:
|
|
2152
|
+
emit_json(conflict, as_json=bool(args.json))
|
|
2153
|
+
return 4
|
|
2154
|
+
setattr(args, "_service_operation_id", lock.token)
|
|
2155
|
+
try:
|
|
2156
|
+
return _cmd_ensure_impl(args)
|
|
2157
|
+
finally:
|
|
2158
|
+
lock.release()
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
def _cmd_ensure_impl(args: argparse.Namespace) -> int:
|
|
2162
|
+
as_json = bool(args.json)
|
|
2163
|
+
change_dir = resolve_path(args.change_dir)
|
|
2164
|
+
project = resolve_path(args.project)
|
|
2165
|
+
files = parse_files_arg(getattr(args, "files", None))
|
|
2166
|
+
|
|
2167
|
+
try:
|
|
2168
|
+
profile = load_build_profile(project)
|
|
2169
|
+
except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError) as exc:
|
|
2170
|
+
return emit_error(str(exc), as_json=as_json)
|
|
2171
|
+
|
|
2172
|
+
# cluster 3 (spec §3.1/§3.4): resolve 模板 serviceStart + runtime context。
|
|
2173
|
+
# 持久 profile 只保存模板;runtime overlay/profile 注入到 session,不写回持久 profile。
|
|
2174
|
+
# 含 worktree/change 陈旧持久值时拒绝(修复输入端陈旧 profile)。
|
|
2175
|
+
change_name = getattr(args, "change_name", None) or change_dir.name
|
|
2176
|
+
worktree_root_raw = getattr(args, "worktree_root", None)
|
|
2177
|
+
execution_root_raw = getattr(args, "execution_root", None)
|
|
2178
|
+
attempt_id = str(getattr(args, "attempt_id", None) or "").strip() or None
|
|
2179
|
+
worktree_root = (
|
|
2180
|
+
resolve_path(worktree_root_raw) if worktree_root_raw else project
|
|
2181
|
+
)
|
|
2182
|
+
execution_root = (
|
|
2183
|
+
resolve_path(execution_root_raw) if execution_root_raw else None
|
|
2184
|
+
)
|
|
2185
|
+
overlay = getattr(args, "overlay", None)
|
|
2186
|
+
leased_port = getattr(args, "leased_port", None)
|
|
2187
|
+
lease_owner = str(getattr(args, "lease_owner", None) or "").strip() or None
|
|
2188
|
+
try:
|
|
2189
|
+
service_start = resolve_service_start(
|
|
2190
|
+
profile,
|
|
2191
|
+
change_name=change_name,
|
|
2192
|
+
worktree_root=project,
|
|
2193
|
+
overlay_path=overlay,
|
|
2194
|
+
leased_port=leased_port,
|
|
2195
|
+
lease_owner=lease_owner,
|
|
2196
|
+
)
|
|
2197
|
+
except ValueError as exc:
|
|
2198
|
+
return emit_error(str(exc), as_json=as_json)
|
|
2199
|
+
|
|
2200
|
+
argv = list(service_start.get("argv") or [])
|
|
2201
|
+
if not argv:
|
|
2202
|
+
return emit_error(
|
|
2203
|
+
"serviceStart resolved to an empty argv; run profile migration and review",
|
|
2204
|
+
as_json=as_json,
|
|
2205
|
+
)
|
|
2206
|
+
command = str(service_start.get("command") or " ".join(argv)).strip()
|
|
2207
|
+
port = extract_port(service_start)
|
|
2208
|
+
|
|
2209
|
+
# Resolve service input file set (CLI --files ∪ serviceStart.inputFiles).
|
|
2210
|
+
# Empty result is deferred: _start_and_record rejects it before generating a
|
|
2211
|
+
# reusable empty fingerprint; the port-occupied / reuse paths still proceed.
|
|
2212
|
+
try:
|
|
2213
|
+
files = resolve_service_input_files(project, service_start, files)
|
|
2214
|
+
except ValueError:
|
|
2215
|
+
files = []
|
|
2216
|
+
|
|
2217
|
+
try:
|
|
2218
|
+
session = load_session(change_dir)
|
|
2219
|
+
except SessionCorrupt as exc:
|
|
2220
|
+
return needs_user_decision(
|
|
2221
|
+
reason=f"session-corrupt: {exc}",
|
|
2222
|
+
as_json=as_json,
|
|
2223
|
+
detail="missing or corrupt session is treated as a user process; will not kill",
|
|
2224
|
+
)
|
|
2225
|
+
|
|
2226
|
+
# --- Branch 1: existing AI session with live verified process ---
|
|
2227
|
+
if session is not None:
|
|
2228
|
+
pid = session["pid"]
|
|
2229
|
+
alive = is_pid_alive(pid)
|
|
2230
|
+
if alive:
|
|
2231
|
+
identity = verify_process_identity(session)
|
|
2232
|
+
if identity is not True:
|
|
2233
|
+
return needs_user_decision(
|
|
2234
|
+
reason=(
|
|
2235
|
+
"cannot-verify-process-identity"
|
|
2236
|
+
if identity is None
|
|
2237
|
+
else "process-identity-mismatch"
|
|
2238
|
+
),
|
|
2239
|
+
as_json=as_json,
|
|
2240
|
+
pid=pid,
|
|
2241
|
+
reasonCode=(
|
|
2242
|
+
"IDENTITY_UNVERIFIABLE"
|
|
2243
|
+
if identity is None
|
|
2244
|
+
else "PROCESS_IDENTITY_MISMATCH"
|
|
2245
|
+
),
|
|
2246
|
+
sessionPath=str(session_path(change_dir)),
|
|
2247
|
+
detail=(
|
|
2248
|
+
"pid is alive but create-time/cmdline identity could not be "
|
|
2249
|
+
"confirmed against session; refusing to reuse or kill"
|
|
2250
|
+
),
|
|
2251
|
+
)
|
|
2252
|
+
|
|
2253
|
+
try:
|
|
2254
|
+
current_hash, current_files = compute_module_hash(files, session)
|
|
2255
|
+
except (OSError, FileNotFoundError) as exc:
|
|
2256
|
+
return emit_error(f"inputsHash failed: {exc}", as_json=as_json)
|
|
2257
|
+
|
|
2258
|
+
stored_hash = session.get("moduleInputsHash")
|
|
2259
|
+
# §5.3: reuse must compare inputsHash + startCommandHash + profile
|
|
2260
|
+
# + overlayPath (process identity already verified above). Any
|
|
2261
|
+
# change -> restart; never reuse on a partial match.
|
|
2262
|
+
current_cmd_hash = _process_provider.canonical_argv_hash(argv)
|
|
2263
|
+
current_profile = service_start.get("profile") or "local-dev"
|
|
2264
|
+
current_overlay = service_start.get("overlayPath") or ""
|
|
2265
|
+
health_match = probe_health(
|
|
2266
|
+
service_start,
|
|
2267
|
+
expected_instance_token=(
|
|
2268
|
+
str(service_start.get("instanceToken"))
|
|
2269
|
+
if service_start.get("requireInstanceToken") is True
|
|
2270
|
+
and service_start.get("instanceToken")
|
|
2271
|
+
else None
|
|
2272
|
+
),
|
|
2273
|
+
)
|
|
2274
|
+
configured_token = service_start.get("instanceToken")
|
|
2275
|
+
if (
|
|
2276
|
+
service_start.get("requireInstanceToken") is True
|
|
2277
|
+
and isinstance(configured_token, str)
|
|
2278
|
+
and session.get("instanceTokenHash") is not None
|
|
2279
|
+
and session.get("instanceTokenHash") != sha256_text(configured_token)
|
|
2280
|
+
):
|
|
2281
|
+
health_match = False
|
|
2282
|
+
if (
|
|
2283
|
+
health_match
|
|
2284
|
+
and service_start.get("requireInstanceToken") is not True
|
|
2285
|
+
and extract_port(service_start) is not None
|
|
2286
|
+
):
|
|
2287
|
+
health_match = _listener_identity_is_owned(session, service_start)
|
|
2288
|
+
fingerprint_match = (
|
|
2289
|
+
stored_hash == current_hash
|
|
2290
|
+
and session.get("startCommandHash") == current_cmd_hash
|
|
2291
|
+
and session.get("profile") == current_profile
|
|
2292
|
+
and session.get("overlayPath") == current_overlay
|
|
2293
|
+
and session.get("leasedPort") == service_start.get("leasedPort")
|
|
2294
|
+
and session.get("leaseOwner") == service_start.get("leaseOwner")
|
|
2295
|
+
and health_match
|
|
2296
|
+
)
|
|
2297
|
+
if fingerprint_match:
|
|
2298
|
+
operation_id = str(
|
|
2299
|
+
getattr(args, "_service_operation_id", "") or uuid.uuid4().hex
|
|
2300
|
+
)
|
|
2301
|
+
_ensure_session_state(
|
|
2302
|
+
session,
|
|
2303
|
+
operation_id=operation_id,
|
|
2304
|
+
)
|
|
2305
|
+
_transition(
|
|
2306
|
+
session,
|
|
2307
|
+
"READY",
|
|
2308
|
+
reason_code="SERVICE_REUSED",
|
|
2309
|
+
operation_id=operation_id,
|
|
2310
|
+
)
|
|
2311
|
+
write_session(change_dir, session)
|
|
2312
|
+
payload = {
|
|
2313
|
+
"ok": True,
|
|
2314
|
+
"action": "reused",
|
|
2315
|
+
"pid": pid,
|
|
2316
|
+
"moduleInputsHash": current_hash,
|
|
2317
|
+
"moduleInputsFiles": current_files,
|
|
2318
|
+
"sessionPath": str(session_path(change_dir)),
|
|
2319
|
+
}
|
|
2320
|
+
emit_json(payload, as_json=as_json)
|
|
2321
|
+
return 0
|
|
2322
|
+
|
|
2323
|
+
# Fingerprint mismatch → stop old, start new
|
|
2324
|
+
stop_result = stop_ai_session(change_dir, session, require_identity=True)
|
|
2325
|
+
if stop_result.get("action") == "needs-user-decision":
|
|
2326
|
+
emit_json(stop_result, as_json=as_json)
|
|
2327
|
+
return 0
|
|
2328
|
+
if not stop_result.get("ok"):
|
|
2329
|
+
emit_json(stop_result, as_json=as_json)
|
|
2330
|
+
return 1
|
|
2331
|
+
|
|
2332
|
+
return _start_and_record(
|
|
2333
|
+
change_dir=change_dir,
|
|
2334
|
+
project=project,
|
|
2335
|
+
service_start=service_start,
|
|
2336
|
+
command=command,
|
|
2337
|
+
argv=argv,
|
|
2338
|
+
files=files if files else current_files,
|
|
2339
|
+
as_json=as_json,
|
|
2340
|
+
action="restarted",
|
|
2341
|
+
previousPid=pid,
|
|
2342
|
+
worktree_root=worktree_root,
|
|
2343
|
+
execution_root=execution_root,
|
|
2344
|
+
change_id=change_name,
|
|
2345
|
+
attempt_id=attempt_id,
|
|
2346
|
+
operation_id=str(getattr(args, "_service_operation_id", "") or uuid.uuid4().hex),
|
|
2347
|
+
)
|
|
2348
|
+
|
|
2349
|
+
# pid dead → stale session; clear and fall through
|
|
2350
|
+
clear_session(change_dir)
|
|
2351
|
+
|
|
2352
|
+
# --- Branch 2: no usable session; port occupied → user decision ---
|
|
2353
|
+
if port is not None and is_port_in_use(port):
|
|
2354
|
+
return needs_user_decision(
|
|
2355
|
+
reason="port-occupied-without-ai-session",
|
|
2356
|
+
as_json=as_json,
|
|
2357
|
+
port=port,
|
|
2358
|
+
detail=(
|
|
2359
|
+
"port is in use but no verified AI service-session exists; "
|
|
2360
|
+
"treated as user process — will not kill"
|
|
2361
|
+
),
|
|
2362
|
+
)
|
|
2363
|
+
|
|
2364
|
+
# --- Branch 3: start fresh ---
|
|
2365
|
+
return _start_and_record(
|
|
2366
|
+
change_dir=change_dir,
|
|
2367
|
+
project=project,
|
|
2368
|
+
service_start=service_start,
|
|
2369
|
+
command=command,
|
|
2370
|
+
argv=argv,
|
|
2371
|
+
files=files,
|
|
2372
|
+
as_json=as_json,
|
|
2373
|
+
action="started",
|
|
2374
|
+
worktree_root=worktree_root,
|
|
2375
|
+
execution_root=execution_root,
|
|
2376
|
+
change_id=change_name,
|
|
2377
|
+
attempt_id=attempt_id,
|
|
2378
|
+
operation_id=str(getattr(args, "_service_operation_id", "") or uuid.uuid4().hex),
|
|
2379
|
+
)
|
|
2380
|
+
|
|
2381
|
+
|
|
2382
|
+
def _start_and_record(
|
|
2383
|
+
*,
|
|
2384
|
+
change_dir: Path,
|
|
2385
|
+
project: Path,
|
|
2386
|
+
service_start: dict[str, Any],
|
|
2387
|
+
command: str,
|
|
2388
|
+
argv: list[str],
|
|
2389
|
+
files: list[str],
|
|
2390
|
+
as_json: bool,
|
|
2391
|
+
action: str,
|
|
2392
|
+
worktree_root: Path | None = None,
|
|
2393
|
+
execution_root: Path | None = None,
|
|
2394
|
+
change_id: str | None = None,
|
|
2395
|
+
attempt_id: str | None = None,
|
|
2396
|
+
operation_id: str | None = None,
|
|
2397
|
+
**extra: Any,
|
|
2398
|
+
) -> int:
|
|
2399
|
+
if not files:
|
|
2400
|
+
# §5.1/§5.2: never generate a reusable empty service fingerprint.
|
|
2401
|
+
return emit_error(
|
|
2402
|
+
"service inputs are empty; configure serviceStart.inputFiles "
|
|
2403
|
+
"(or pass --files) so the service fingerprint covers real source",
|
|
2404
|
+
as_json=as_json,
|
|
2405
|
+
)
|
|
2406
|
+
try:
|
|
2407
|
+
module_hash, module_files = compute_module_hash(files, None)
|
|
2408
|
+
except (OSError, FileNotFoundError) as exc:
|
|
2409
|
+
return emit_error(f"inputsHash failed: {exc}", as_json=as_json)
|
|
2410
|
+
|
|
2411
|
+
# Clear stale file-based health markers so wait_for_healthy cannot
|
|
2412
|
+
# succeed on a leftover marker from a previous process.
|
|
2413
|
+
_clear_file_health_markers(service_start)
|
|
2414
|
+
|
|
2415
|
+
started_at = now_iso()
|
|
2416
|
+
operation_id = operation_id or uuid.uuid4().hex
|
|
2417
|
+
generation = _allocate_service_generation(change_dir)
|
|
2418
|
+
mutation_state = {
|
|
2419
|
+
"schemaVersion": 1,
|
|
2420
|
+
"status": "STARTING",
|
|
2421
|
+
"reasonCode": "SERVICE_START_REQUESTED",
|
|
2422
|
+
"serviceGeneration": generation,
|
|
2423
|
+
"stateRevision": 1,
|
|
2424
|
+
"operationId": operation_id,
|
|
2425
|
+
"startedAt": started_at,
|
|
2426
|
+
"argvHash": _process_provider.canonical_argv_hash(argv),
|
|
2427
|
+
}
|
|
2428
|
+
write_json(mutation_state_path(change_dir), mutation_state)
|
|
2429
|
+
spawned: _process_provider.SpawnedProcess
|
|
2430
|
+
try:
|
|
2431
|
+
spawned = start_detached_service(argv, change_dir=change_dir, cwd=project)
|
|
2432
|
+
pid = int(spawned.attestation.get("pid") or spawned.process.pid)
|
|
2433
|
+
except (OSError, TimeoutError, ValueError, RuntimeError) as exc:
|
|
2434
|
+
_cleanup_windows_launcher(change_dir)
|
|
2435
|
+
mutation_state.update(
|
|
2436
|
+
{
|
|
2437
|
+
"status": "FAILED",
|
|
2438
|
+
"reasonCode": "SERVICE_START_FAILED",
|
|
2439
|
+
"endedAt": now_iso(),
|
|
2440
|
+
}
|
|
2441
|
+
)
|
|
2442
|
+
write_json(mutation_state_path(change_dir), mutation_state)
|
|
2443
|
+
if getattr(exc, "winerror", None) == 5:
|
|
2444
|
+
return emit_error(
|
|
2445
|
+
"failed to start service: Windows denied process creation "
|
|
2446
|
+
f"({exc}). The agent shell blocks detached spawns; start the "
|
|
2447
|
+
"service from a terminal the agent does not own (or use the "
|
|
2448
|
+
"documented nohup fallback) and re-run `ensure` so the harness "
|
|
2449
|
+
"adopts the running instance.",
|
|
2450
|
+
as_json=as_json,
|
|
2451
|
+
reasonCode="SERVICE_SPAWN_DENIED",
|
|
2452
|
+
)
|
|
2453
|
+
return emit_error(
|
|
2454
|
+
f"failed to start service: {exc}",
|
|
2455
|
+
as_json=as_json,
|
|
2456
|
+
reasonCode="SERVICE_START_FAILED",
|
|
2457
|
+
)
|
|
2458
|
+
|
|
2459
|
+
# Give the OS a moment to register the process before identity/create-time reads
|
|
2460
|
+
time.sleep(0.15)
|
|
2461
|
+
|
|
2462
|
+
try:
|
|
2463
|
+
wait_for_healthy(
|
|
2464
|
+
service_start,
|
|
2465
|
+
log_path(change_dir),
|
|
2466
|
+
pid=pid,
|
|
2467
|
+
expected_instance_token=(
|
|
2468
|
+
str(service_start.get("instanceToken"))
|
|
2469
|
+
if service_start.get("requireInstanceToken") is True
|
|
2470
|
+
and service_start.get("instanceToken")
|
|
2471
|
+
else None
|
|
2472
|
+
),
|
|
2473
|
+
)
|
|
2474
|
+
except ServiceStartError as exc:
|
|
2475
|
+
# Best-effort cleanup of the failed start
|
|
2476
|
+
if is_pid_alive(pid):
|
|
2477
|
+
_capture_owned_service_members(spawned)
|
|
2478
|
+
terminate_process_tree(
|
|
2479
|
+
pid,
|
|
2480
|
+
expected_attestation=spawned.attestation,
|
|
2481
|
+
ownership_proof=spawned.ownershipProof,
|
|
2482
|
+
)
|
|
2483
|
+
_cleanup_windows_launcher(change_dir)
|
|
2484
|
+
clear_session(change_dir)
|
|
2485
|
+
mutation_state.update(
|
|
2486
|
+
{
|
|
2487
|
+
"status": "FAILED",
|
|
2488
|
+
"reasonCode": "SERVICE_START_FAILED",
|
|
2489
|
+
"endedAt": now_iso(),
|
|
2490
|
+
}
|
|
2491
|
+
)
|
|
2492
|
+
write_json(mutation_state_path(change_dir), mutation_state)
|
|
2493
|
+
return emit_error(
|
|
2494
|
+
str(exc),
|
|
2495
|
+
as_json=as_json,
|
|
2496
|
+
action="start-failed",
|
|
2497
|
+
pid=pid,
|
|
2498
|
+
fatalKeyword=exc.fatal_keyword,
|
|
2499
|
+
)
|
|
2500
|
+
|
|
2501
|
+
_capture_owned_service_members(spawned)
|
|
2502
|
+
session = build_session(
|
|
2503
|
+
pid=pid,
|
|
2504
|
+
module_inputs_hash=module_hash,
|
|
2505
|
+
module_inputs_files=module_files,
|
|
2506
|
+
command=command,
|
|
2507
|
+
service_start=service_start,
|
|
2508
|
+
started_at=started_at,
|
|
2509
|
+
worktree_root=worktree_root,
|
|
2510
|
+
execution_root=execution_root,
|
|
2511
|
+
change_id=change_id,
|
|
2512
|
+
attempt_id=attempt_id,
|
|
2513
|
+
service_pid=(
|
|
2514
|
+
_read_positive_int(_child_pid_path(change_dir))
|
|
2515
|
+
if os.name == "nt"
|
|
2516
|
+
else pid
|
|
2517
|
+
),
|
|
2518
|
+
job_id=(
|
|
2519
|
+
_read_optional_text(_job_id_path(change_dir))
|
|
2520
|
+
if os.name == "nt"
|
|
2521
|
+
else None
|
|
2522
|
+
),
|
|
2523
|
+
process_attestation=spawned.attestation,
|
|
2524
|
+
ownership_proof=spawned.ownershipProof,
|
|
2525
|
+
argv=argv,
|
|
2526
|
+
)
|
|
2527
|
+
_ensure_session_state(
|
|
2528
|
+
session,
|
|
2529
|
+
operation_id=operation_id,
|
|
2530
|
+
generation=generation,
|
|
2531
|
+
)
|
|
2532
|
+
if isinstance(service_start.get("instanceToken"), str):
|
|
2533
|
+
session["instanceTokenHash"] = sha256_text(
|
|
2534
|
+
service_start["instanceToken"]
|
|
2535
|
+
)
|
|
2536
|
+
_transition(
|
|
2537
|
+
session,
|
|
2538
|
+
"READY",
|
|
2539
|
+
reason_code="SERVICE_READY",
|
|
2540
|
+
operation_id=operation_id,
|
|
2541
|
+
)
|
|
2542
|
+
write_session(change_dir, session)
|
|
2543
|
+
mutation_state.update(
|
|
2544
|
+
{
|
|
2545
|
+
"status": "READY",
|
|
2546
|
+
"reasonCode": "SERVICE_READY",
|
|
2547
|
+
"stateRevision": session.get("stateRevision"),
|
|
2548
|
+
"endedAt": now_iso(),
|
|
2549
|
+
}
|
|
2550
|
+
)
|
|
2551
|
+
write_json(mutation_state_path(change_dir), mutation_state)
|
|
2552
|
+
|
|
2553
|
+
payload: dict[str, Any] = {
|
|
2554
|
+
"ok": True,
|
|
2555
|
+
"action": action,
|
|
2556
|
+
"pid": pid,
|
|
2557
|
+
"moduleInputsHash": module_hash,
|
|
2558
|
+
"moduleInputsFiles": module_files,
|
|
2559
|
+
"sessionPath": str(session_path(change_dir)),
|
|
2560
|
+
"logPath": str(log_path(change_dir)),
|
|
2561
|
+
"startedAt": started_at,
|
|
2562
|
+
}
|
|
2563
|
+
payload.update(extra)
|
|
2564
|
+
emit_json(payload, as_json=as_json)
|
|
2565
|
+
return 0
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def _capture_owned_service_members(
|
|
2569
|
+
spawned: _process_provider.SpawnedProcess,
|
|
2570
|
+
) -> None:
|
|
2571
|
+
"""Materialize the provider's current owned members before persistence."""
|
|
2572
|
+
proof = spawned.ownershipProof
|
|
2573
|
+
if not isinstance(proof, dict):
|
|
2574
|
+
return
|
|
2575
|
+
_process_provider.capture_owned_members(spawned)
|
|
2576
|
+
|
|
2577
|
+
|
|
2578
|
+
def _clear_file_health_markers(service_start: dict[str, Any]) -> None:
|
|
2579
|
+
candidates: list[Path] = []
|
|
2580
|
+
hf = service_start.get("healthFile")
|
|
2581
|
+
if isinstance(hf, str) and hf.strip():
|
|
2582
|
+
candidates.append(Path(hf.strip()))
|
|
2583
|
+
spec = service_start.get("healthUrl")
|
|
2584
|
+
if isinstance(spec, str) and spec.strip():
|
|
2585
|
+
resolved = resolve_health_file(spec.strip())
|
|
2586
|
+
if resolved is not None:
|
|
2587
|
+
candidates.append(resolved)
|
|
2588
|
+
for path in candidates:
|
|
2589
|
+
try:
|
|
2590
|
+
if path.is_file():
|
|
2591
|
+
path.unlink()
|
|
2592
|
+
except OSError:
|
|
2593
|
+
pass
|
|
2594
|
+
|
|
2595
|
+
|
|
2596
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
2597
|
+
as_json = bool(args.json)
|
|
2598
|
+
change_dir = resolve_path(args.change_dir)
|
|
2599
|
+
files = parse_files_arg(getattr(args, "files", None))
|
|
2600
|
+
|
|
2601
|
+
try:
|
|
2602
|
+
session = load_session(change_dir)
|
|
2603
|
+
except SessionCorrupt as exc:
|
|
2604
|
+
payload = {
|
|
2605
|
+
"ok": True,
|
|
2606
|
+
"action": "status",
|
|
2607
|
+
"sessionPresent": True,
|
|
2608
|
+
"sessionCorrupt": True,
|
|
2609
|
+
"reason": str(exc),
|
|
2610
|
+
"alive": False,
|
|
2611
|
+
"identityVerified": False,
|
|
2612
|
+
"fingerprintMatch": None,
|
|
2613
|
+
"treatAsUserProcess": True,
|
|
2614
|
+
}
|
|
2615
|
+
emit_json(payload, as_json=as_json)
|
|
2616
|
+
return 0
|
|
2617
|
+
|
|
2618
|
+
if session is None:
|
|
2619
|
+
payload = {
|
|
2620
|
+
"ok": True,
|
|
2621
|
+
"action": "status",
|
|
2622
|
+
"sessionPresent": False,
|
|
2623
|
+
"alive": False,
|
|
2624
|
+
"identityVerified": False,
|
|
2625
|
+
"fingerprintMatch": None,
|
|
2626
|
+
}
|
|
2627
|
+
emit_json(payload, as_json=as_json)
|
|
2628
|
+
return 0
|
|
2629
|
+
|
|
2630
|
+
pid = session.get("pid")
|
|
2631
|
+
alive = isinstance(pid, int) and is_pid_alive(pid)
|
|
2632
|
+
identity = verify_process_identity(session) if alive else False
|
|
2633
|
+
heartbeat = _heartbeat_status(session)
|
|
2634
|
+
|
|
2635
|
+
fingerprint_match: bool | None = None
|
|
2636
|
+
current_hash: str | None = None
|
|
2637
|
+
try:
|
|
2638
|
+
current_hash, _ = compute_module_hash(files, session)
|
|
2639
|
+
stored = session.get("moduleInputsHash")
|
|
2640
|
+
if isinstance(stored, str) and stored:
|
|
2641
|
+
fingerprint_match = stored == current_hash
|
|
2642
|
+
except (OSError, FileNotFoundError):
|
|
2643
|
+
fingerprint_match = None
|
|
2644
|
+
|
|
2645
|
+
payload = {
|
|
2646
|
+
"ok": True,
|
|
2647
|
+
"action": "status",
|
|
2648
|
+
"sessionPresent": True,
|
|
2649
|
+
"sessionCorrupt": False,
|
|
2650
|
+
"session": session,
|
|
2651
|
+
"pid": pid,
|
|
2652
|
+
"alive": alive,
|
|
2653
|
+
"identityVerified": identity is True,
|
|
2654
|
+
"identityStatus": (
|
|
2655
|
+
"verified" if identity is True else ("unknown" if identity is None else "mismatch")
|
|
2656
|
+
),
|
|
2657
|
+
"reasonCode": (
|
|
2658
|
+
"SERVICE_READY"
|
|
2659
|
+
if identity is True
|
|
2660
|
+
else ("IDENTITY_UNVERIFIABLE" if identity is None else "PROCESS_IDENTITY_MISMATCH")
|
|
2661
|
+
),
|
|
2662
|
+
"fingerprintMatch": fingerprint_match,
|
|
2663
|
+
"currentModuleInputsHash": current_hash,
|
|
2664
|
+
"startedBy": session.get("startedBy"),
|
|
2665
|
+
"serviceGeneration": session.get("serviceGeneration"),
|
|
2666
|
+
"stateRevision": session.get("stateRevision"),
|
|
2667
|
+
"serviceStatus": session.get("status"),
|
|
2668
|
+
"heartbeat": heartbeat,
|
|
2669
|
+
}
|
|
2670
|
+
emit_json(payload, as_json=as_json)
|
|
2671
|
+
return 0
|
|
2672
|
+
|
|
2673
|
+
|
|
2674
|
+
def cmd_stop(args: argparse.Namespace) -> int:
|
|
2675
|
+
change_dir = resolve_path(args.change_dir)
|
|
2676
|
+
lock = ServiceMutationLock(change_dir, operation="stop")
|
|
2677
|
+
conflict = lock.acquire()
|
|
2678
|
+
if conflict is not None:
|
|
2679
|
+
emit_json(conflict, as_json=bool(args.json))
|
|
2680
|
+
return 4
|
|
2681
|
+
setattr(args, "_service_operation_id", lock.token)
|
|
2682
|
+
try:
|
|
2683
|
+
return _cmd_stop_impl(args)
|
|
2684
|
+
finally:
|
|
2685
|
+
lock.release()
|
|
2686
|
+
|
|
2687
|
+
|
|
2688
|
+
def _cmd_stop_impl(args: argparse.Namespace) -> int:
|
|
2689
|
+
as_json = bool(args.json)
|
|
2690
|
+
change_dir = resolve_path(args.change_dir)
|
|
2691
|
+
if_started_by_ai = bool(getattr(args, "if_started_by_ai", False))
|
|
2692
|
+
|
|
2693
|
+
try:
|
|
2694
|
+
session = load_session(change_dir)
|
|
2695
|
+
except SessionCorrupt as exc:
|
|
2696
|
+
return needs_user_decision(
|
|
2697
|
+
reason=f"session-corrupt: {exc}",
|
|
2698
|
+
as_json=as_json,
|
|
2699
|
+
detail="corrupt session treated as user process; will not kill",
|
|
2700
|
+
killed=False,
|
|
2701
|
+
)
|
|
2702
|
+
|
|
2703
|
+
if session is None:
|
|
2704
|
+
payload = {
|
|
2705
|
+
"ok": True,
|
|
2706
|
+
"action": "already-stopped",
|
|
2707
|
+
"killed": False,
|
|
2708
|
+
"sessionCleared": False,
|
|
2709
|
+
"detail": "no service-session.json",
|
|
2710
|
+
}
|
|
2711
|
+
emit_json(payload, as_json=as_json)
|
|
2712
|
+
return 0
|
|
2713
|
+
|
|
2714
|
+
started_by = session.get("startedBy")
|
|
2715
|
+
if if_started_by_ai and started_by != "AI":
|
|
2716
|
+
payload = {
|
|
2717
|
+
"ok": True,
|
|
2718
|
+
"action": "skipped",
|
|
2719
|
+
"reason": "not-started-by-ai",
|
|
2720
|
+
"startedBy": started_by,
|
|
2721
|
+
"killed": False,
|
|
2722
|
+
"sessionCleared": False,
|
|
2723
|
+
}
|
|
2724
|
+
emit_json(payload, as_json=as_json)
|
|
2725
|
+
return 0
|
|
2726
|
+
|
|
2727
|
+
# Default stop also refuses to kill non-AI / unverified processes
|
|
2728
|
+
if started_by != "AI":
|
|
2729
|
+
return needs_user_decision(
|
|
2730
|
+
reason="not-started-by-ai",
|
|
2731
|
+
as_json=as_json,
|
|
2732
|
+
startedBy=started_by,
|
|
2733
|
+
killed=False,
|
|
2734
|
+
detail="session not marked startedBy=AI; will not kill",
|
|
2735
|
+
)
|
|
2736
|
+
|
|
2737
|
+
result = stop_ai_session(change_dir, session, require_identity=True)
|
|
2738
|
+
emit_json(result, as_json=as_json)
|
|
2739
|
+
return 0 if result.get("ok") else 1
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
def retire_stale_session(change_dir: Path) -> dict[str, Any]:
|
|
2743
|
+
change_dir = resolve_path(change_dir)
|
|
2744
|
+
lock = ServiceMutationLock(change_dir, operation="retire-stale")
|
|
2745
|
+
conflict = lock.acquire()
|
|
2746
|
+
if conflict is not None:
|
|
2747
|
+
return conflict
|
|
2748
|
+
try:
|
|
2749
|
+
return _retire_stale_session_impl(change_dir, operation_id=lock.token)
|
|
2750
|
+
finally:
|
|
2751
|
+
lock.release()
|
|
2752
|
+
|
|
2753
|
+
|
|
2754
|
+
def _project_root_for_change(change_dir: Path) -> Path | None:
|
|
2755
|
+
resolved = resolve_path(change_dir)
|
|
2756
|
+
for candidate in (resolved, *resolved.parents):
|
|
2757
|
+
if candidate.name == "changes" and candidate.parent.name == ".harness":
|
|
2758
|
+
return candidate.parent.parent
|
|
2759
|
+
if (candidate / ".harness").is_dir():
|
|
2760
|
+
return candidate
|
|
2761
|
+
return None
|
|
2762
|
+
|
|
2763
|
+
|
|
2764
|
+
def _retirement_receipt_digest(receipt: dict[str, Any]) -> str:
|
|
2765
|
+
unsigned = {
|
|
2766
|
+
key: value for key, value in receipt.items() if key != "receiptDigest"
|
|
2767
|
+
}
|
|
2768
|
+
return sha256_text(
|
|
2769
|
+
json.dumps(unsigned, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
2770
|
+
)
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
def _retirement_lease_cleanup(
|
|
2774
|
+
change_dir: Path,
|
|
2775
|
+
session: dict[str, Any],
|
|
2776
|
+
) -> dict[str, Any]:
|
|
2777
|
+
"""Attempt lease cleanup only with a complete lease/listener identity."""
|
|
2778
|
+
|
|
2779
|
+
lease = session.get("leaseIdentity")
|
|
2780
|
+
if not isinstance(lease, dict):
|
|
2781
|
+
return {
|
|
2782
|
+
"status": "RETAINED",
|
|
2783
|
+
"reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
|
|
2784
|
+
"leaseId": None,
|
|
2785
|
+
}
|
|
2786
|
+
lease_id = lease.get("leaseId")
|
|
2787
|
+
change_id = lease.get("changeId") or session.get("changeId")
|
|
2788
|
+
run_id = lease.get("runId") or session.get("attemptId")
|
|
2789
|
+
port = session.get("leasedPort")
|
|
2790
|
+
if not (
|
|
2791
|
+
isinstance(lease_id, str)
|
|
2792
|
+
and lease_id
|
|
2793
|
+
and isinstance(change_id, str)
|
|
2794
|
+
and change_id
|
|
2795
|
+
and isinstance(run_id, str)
|
|
2796
|
+
and run_id
|
|
2797
|
+
and isinstance(port, int)
|
|
2798
|
+
and 0 < port < 65536
|
|
2799
|
+
):
|
|
2800
|
+
return {
|
|
2801
|
+
"status": "UNVERIFIED",
|
|
2802
|
+
"reasonCode": "LEASE_CAS_MISMATCH",
|
|
2803
|
+
"leaseId": lease_id if isinstance(lease_id, str) else None,
|
|
2804
|
+
}
|
|
2805
|
+
# A lease without a separately persisted listener identity cannot be
|
|
2806
|
+
# released during stale retirement. The old process is precisely the
|
|
2807
|
+
# identity that is no longer trusted, so do not reconstruct it from PID.
|
|
2808
|
+
listener_identity = lease.get("listenerIdentity")
|
|
2809
|
+
if not isinstance(listener_identity, dict):
|
|
2810
|
+
return {
|
|
2811
|
+
"status": "UNVERIFIED",
|
|
2812
|
+
"reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
|
|
2813
|
+
"leaseId": lease_id,
|
|
2814
|
+
}
|
|
2815
|
+
project_root = _project_root_for_change(change_dir)
|
|
2816
|
+
if project_root is None:
|
|
2817
|
+
return {
|
|
2818
|
+
"status": "UNVERIFIED",
|
|
2819
|
+
"reasonCode": "LEASE_CAS_MISMATCH",
|
|
2820
|
+
"leaseId": lease_id,
|
|
2821
|
+
}
|
|
2822
|
+
try:
|
|
2823
|
+
import harness_change
|
|
2824
|
+
|
|
2825
|
+
release = harness_change.release_port(
|
|
2826
|
+
project_root,
|
|
2827
|
+
change_id=change_id,
|
|
2828
|
+
run_id=run_id,
|
|
2829
|
+
port=port,
|
|
2830
|
+
lease_id=lease_id,
|
|
2831
|
+
generation=_normalize_service_generation(lease.get("generation")),
|
|
2832
|
+
listener_identity=listener_identity,
|
|
2833
|
+
)
|
|
2834
|
+
except (ImportError, OSError, TypeError, ValueError) as exc:
|
|
2835
|
+
return {
|
|
2836
|
+
"status": "UNVERIFIED",
|
|
2837
|
+
"reasonCode": "LEASE_CAS_MISMATCH",
|
|
2838
|
+
"leaseId": lease_id,
|
|
2839
|
+
}
|
|
2840
|
+
if release.get("ok"):
|
|
2841
|
+
return {
|
|
2842
|
+
"status": "RELEASED",
|
|
2843
|
+
"reasonCode": "SERVICE_RETIRED",
|
|
2844
|
+
"leaseId": lease_id,
|
|
2845
|
+
}
|
|
2846
|
+
code = str(release.get("code") or "")
|
|
2847
|
+
reason = (
|
|
2848
|
+
"LISTENER_IDENTITY_UNVERIFIABLE"
|
|
2849
|
+
if "LISTENER" in code or code == "IDENTITY_UNVERIFIABLE"
|
|
2850
|
+
else "LEASE_CAS_MISMATCH"
|
|
2851
|
+
)
|
|
2852
|
+
return {
|
|
2853
|
+
"status": "RETAINED" if reason.startswith("LISTENER") else "UNVERIFIED",
|
|
2854
|
+
"reasonCode": reason,
|
|
2855
|
+
"leaseId": lease_id,
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def _retire_stale_session_impl(
|
|
2860
|
+
change_dir: Path,
|
|
2861
|
+
*,
|
|
2862
|
+
operation_id: str,
|
|
2863
|
+
) -> dict[str, Any]:
|
|
2864
|
+
"""Finalize one stale generation without touching an unknown process."""
|
|
2865
|
+
change_dir = resolve_path(change_dir)
|
|
2866
|
+
try:
|
|
2867
|
+
session = load_session(change_dir)
|
|
2868
|
+
except SessionCorrupt as exc:
|
|
2869
|
+
return {
|
|
2870
|
+
"ok": False,
|
|
2871
|
+
"code": "SERVICE_SESSION_CORRUPT",
|
|
2872
|
+
"action": "retire-refused",
|
|
2873
|
+
"error": str(exc),
|
|
2874
|
+
"unknownProcessUntouched": True,
|
|
2875
|
+
}
|
|
2876
|
+
if session is None:
|
|
2877
|
+
retired_root = change_dir / "runtime" / "retired-service-sessions"
|
|
2878
|
+
for receipt_path in sorted(retired_root.glob("*.receipt.json")):
|
|
2879
|
+
try:
|
|
2880
|
+
existing_receipt = read_json(receipt_path)
|
|
2881
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
2882
|
+
continue
|
|
2883
|
+
if isinstance(existing_receipt, dict) and (
|
|
2884
|
+
existing_receipt.get("state") == "FINALIZED"
|
|
2885
|
+
or existing_receipt.get("action") == "retired-stale"
|
|
2886
|
+
):
|
|
2887
|
+
evidence = retired_root / f"{receipt_path.name[:-len('.receipt.json')]}.json"
|
|
2888
|
+
return {
|
|
2889
|
+
"ok": True,
|
|
2890
|
+
"code": "SERVICE_RETIRED",
|
|
2891
|
+
"action": "already-retired",
|
|
2892
|
+
"receipt": existing_receipt,
|
|
2893
|
+
"retiredEvidence": str(evidence) if evidence.is_file() else None,
|
|
2894
|
+
"unknownProcessUntouched": True,
|
|
2895
|
+
}
|
|
2896
|
+
return {
|
|
2897
|
+
"ok": True,
|
|
2898
|
+
"code": "SERVICE_SESSION_ABSENT",
|
|
2899
|
+
"action": "already-retired",
|
|
2900
|
+
"unknownProcessUntouched": True,
|
|
2901
|
+
}
|
|
2902
|
+
identity = verify_process_identity(session)
|
|
2903
|
+
if identity is True:
|
|
2904
|
+
return {
|
|
2905
|
+
"ok": False,
|
|
2906
|
+
"code": "SERVICE_PROCESS_STILL_OWNED",
|
|
2907
|
+
"action": "retire-refused",
|
|
2908
|
+
"unknownProcessUntouched": True,
|
|
2909
|
+
}
|
|
2910
|
+
if identity is None:
|
|
2911
|
+
return {
|
|
2912
|
+
"ok": False,
|
|
2913
|
+
"code": "SERVICE_PROCESS_IDENTITY_UNVERIFIED",
|
|
2914
|
+
"action": "retire-refused",
|
|
2915
|
+
"unknownProcessUntouched": True,
|
|
2916
|
+
}
|
|
2917
|
+
service_id = str(session.get("serviceId") or change_dir.name)
|
|
2918
|
+
old_session_id = str(
|
|
2919
|
+
session.get("sessionId")
|
|
2920
|
+
or "legacy-" + sha256_text(json.dumps(session, sort_keys=True))[7:23]
|
|
2921
|
+
)
|
|
2922
|
+
old_generation = _normalize_service_generation(session.get("serviceGeneration"))
|
|
2923
|
+
retired_root = change_dir / "runtime" / "retired-service-sessions"
|
|
2924
|
+
retired_root.mkdir(parents=True, exist_ok=True)
|
|
2925
|
+
safe_service = re.sub(r"[^A-Za-z0-9._-]+", "-", service_id).strip("-") or "service"
|
|
2926
|
+
receipt_path = retired_root / f"{safe_service}-{old_generation}.receipt.json"
|
|
2927
|
+
if receipt_path.is_file():
|
|
2928
|
+
try:
|
|
2929
|
+
existing_receipt = read_json(receipt_path)
|
|
2930
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
2931
|
+
existing_receipt = None
|
|
2932
|
+
if (
|
|
2933
|
+
isinstance(existing_receipt, dict)
|
|
2934
|
+
and existing_receipt.get("serviceId") == service_id
|
|
2935
|
+
and _normalize_service_generation(existing_receipt.get("oldGeneration"))
|
|
2936
|
+
== old_generation
|
|
2937
|
+
):
|
|
2938
|
+
evidence = existing_receipt.get("retiredEvidence")
|
|
2939
|
+
return {
|
|
2940
|
+
"ok": True,
|
|
2941
|
+
"code": "SERVICE_RETIRED",
|
|
2942
|
+
"action": "already-retired",
|
|
2943
|
+
"receipt": existing_receipt,
|
|
2944
|
+
"retiredEvidence": evidence,
|
|
2945
|
+
"unknownProcessUntouched": True,
|
|
2946
|
+
}
|
|
2947
|
+
pending_path = change_dir / "runtime" / "retirement.pending.json"
|
|
2948
|
+
write_json(
|
|
2949
|
+
pending_path,
|
|
2950
|
+
{
|
|
2951
|
+
"schemaVersion": 1,
|
|
2952
|
+
"serviceId": service_id,
|
|
2953
|
+
"oldSessionId": old_session_id,
|
|
2954
|
+
"oldGeneration": old_generation,
|
|
2955
|
+
"operationId": operation_id,
|
|
2956
|
+
"state": "PENDING",
|
|
2957
|
+
},
|
|
2958
|
+
)
|
|
2959
|
+
lease_cleanup = _retirement_lease_cleanup(change_dir, session)
|
|
2960
|
+
retired_session = json.loads(json.dumps(session, ensure_ascii=False))
|
|
2961
|
+
_ensure_session_state(retired_session, operation_id=operation_id, generation=old_generation)
|
|
2962
|
+
if retired_session.get("status") not in {"STALE_IDENTITY_MISMATCH", "RETIRED"}:
|
|
2963
|
+
_transition(
|
|
2964
|
+
retired_session,
|
|
2965
|
+
"STALE_IDENTITY_MISMATCH",
|
|
2966
|
+
reason_code="SERVICE_IDENTITY_STALE",
|
|
2967
|
+
operation_id=operation_id,
|
|
2968
|
+
)
|
|
2969
|
+
if retired_session.get("status") != "RETIRED":
|
|
2970
|
+
_transition(
|
|
2971
|
+
retired_session,
|
|
2972
|
+
"RETIRED",
|
|
2973
|
+
reason_code="SERVICE_RETIRED",
|
|
2974
|
+
operation_id=operation_id,
|
|
2975
|
+
)
|
|
2976
|
+
retired_session["cleanupComplete"] = lease_cleanup["status"] == "RELEASED"
|
|
2977
|
+
retired_session["supersedesSessionId"] = None
|
|
2978
|
+
source = session_path(change_dir)
|
|
2979
|
+
destination = retired_root / f"{safe_service}-{old_generation}.json"
|
|
2980
|
+
quarantined = False
|
|
2981
|
+
if source.is_file():
|
|
2982
|
+
try:
|
|
2983
|
+
import harness_runtime as _runtime
|
|
2984
|
+
|
|
2985
|
+
sensitive = [
|
|
2986
|
+
item
|
|
2987
|
+
for item in _runtime.sensitive_evidence_candidates(source.parent)
|
|
2988
|
+
if item.get("path") == source.name
|
|
2989
|
+
]
|
|
2990
|
+
if sensitive:
|
|
2991
|
+
quarantine = _runtime.quarantine_sensitive_evidence(
|
|
2992
|
+
source,
|
|
2993
|
+
change_root=change_dir,
|
|
2994
|
+
reason="service retirement legacy evidence",
|
|
2995
|
+
)
|
|
2996
|
+
if not quarantine.get("ok"):
|
|
2997
|
+
return {
|
|
2998
|
+
"ok": False,
|
|
2999
|
+
"code": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
3000
|
+
"action": "retire-refused",
|
|
3001
|
+
"quarantine": quarantine,
|
|
3002
|
+
"unknownProcessUntouched": True,
|
|
3003
|
+
}
|
|
3004
|
+
quarantined = True
|
|
3005
|
+
except (ImportError, OSError, ValueError, TypeError) as exc:
|
|
3006
|
+
return {
|
|
3007
|
+
"ok": False,
|
|
3008
|
+
"code": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
3009
|
+
"action": "retire-refused",
|
|
3010
|
+
"error": str(exc),
|
|
3011
|
+
"unknownProcessUntouched": True,
|
|
3012
|
+
}
|
|
3013
|
+
if source.is_file():
|
|
3014
|
+
os.replace(source, destination)
|
|
3015
|
+
# Replace the moved legacy payload with the sanitized, typed retired
|
|
3016
|
+
# state only after the atomic move has succeeded.
|
|
3017
|
+
write_json(destination, retired_session)
|
|
3018
|
+
elif not destination.is_file() and not quarantined:
|
|
3019
|
+
return {
|
|
3020
|
+
"ok": False,
|
|
3021
|
+
"code": "SERVICE_SESSION_ABSENT",
|
|
3022
|
+
"action": "retire-refused",
|
|
3023
|
+
"unknownProcessUntouched": True,
|
|
3024
|
+
}
|
|
3025
|
+
receipt = {
|
|
3026
|
+
"schemaVersion": 1,
|
|
3027
|
+
"receiptId": f"retirement-{safe_service}-{old_generation}",
|
|
3028
|
+
"operationId": operation_id,
|
|
3029
|
+
"serviceId": service_id,
|
|
3030
|
+
"oldSessionId": old_session_id,
|
|
3031
|
+
"oldGeneration": old_generation,
|
|
3032
|
+
"state": "FINALIZED",
|
|
3033
|
+
"retirementStateCommit": {
|
|
3034
|
+
"status": "COMMITTED",
|
|
3035
|
+
"reasonCode": "SERVICE_RETIRED",
|
|
3036
|
+
},
|
|
3037
|
+
"leaseCleanup": lease_cleanup,
|
|
3038
|
+
"cleanupComplete": lease_cleanup["status"] == "RELEASED",
|
|
3039
|
+
"awaitingSuperseder": True,
|
|
3040
|
+
"supersededBySessionId": None,
|
|
3041
|
+
}
|
|
3042
|
+
receipt["receiptDigest"] = _retirement_receipt_digest(receipt)
|
|
3043
|
+
# Keep the evidence path in the response/pending journal, not in the
|
|
3044
|
+
# strict receipt contract; the destination is derived from the receipt.
|
|
3045
|
+
write_json(receipt_path, receipt)
|
|
3046
|
+
pending_path.unlink(missing_ok=True)
|
|
3047
|
+
return {
|
|
3048
|
+
"ok": True,
|
|
3049
|
+
"code": "SERVICE_RETIRED",
|
|
3050
|
+
"action": "retired-stale",
|
|
3051
|
+
"receipt": receipt,
|
|
3052
|
+
"retiredEvidence": str(destination),
|
|
3053
|
+
"unknownProcessUntouched": True,
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
|
|
3057
|
+
def cmd_retire_stale(args: argparse.Namespace) -> int:
|
|
3058
|
+
result = retire_stale_session(resolve_path(args.change_dir))
|
|
3059
|
+
emit_json(result, as_json=bool(args.json))
|
|
3060
|
+
return 0 if result.get("ok") else 1
|
|
3061
|
+
|
|
3062
|
+
|
|
3063
|
+
def _link_superseder_unlocked(
|
|
3064
|
+
change_dir: Path,
|
|
3065
|
+
*,
|
|
3066
|
+
retirement_receipt: Path,
|
|
3067
|
+
new_session: dict[str, Any],
|
|
3068
|
+
operation_id: str | None = None,
|
|
3069
|
+
) -> dict[str, Any]:
|
|
3070
|
+
"""CAS-link one future service generation to a finalized retirement."""
|
|
3071
|
+
|
|
3072
|
+
change_dir = resolve_path(change_dir)
|
|
3073
|
+
receipt_path = resolve_path(retirement_receipt)
|
|
3074
|
+
try:
|
|
3075
|
+
receipt = read_json(receipt_path)
|
|
3076
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
3077
|
+
return {"ok": False, "code": "RETIREMENT_RECEIPT_INVALID", "error": str(exc)}
|
|
3078
|
+
if not isinstance(receipt, dict):
|
|
3079
|
+
return {"ok": False, "code": "RETIREMENT_RECEIPT_INVALID"}
|
|
3080
|
+
raw_old_generation = receipt.get("oldGeneration")
|
|
3081
|
+
raw_new_generation = new_session.get("serviceGeneration")
|
|
3082
|
+
if raw_old_generation is None or raw_new_generation is None:
|
|
3083
|
+
return {"ok": False, "code": "SUPERSEDER_GENERATION_INVALID"}
|
|
3084
|
+
old_generation = _normalize_service_generation(raw_old_generation)
|
|
3085
|
+
new_generation = _normalize_service_generation(raw_new_generation)
|
|
3086
|
+
if old_generation == new_generation:
|
|
3087
|
+
return {"ok": False, "code": "SUPERSEDER_GENERATION_INVALID"}
|
|
3088
|
+
receipt["schemaVersion"] = 1
|
|
3089
|
+
receipt["oldGeneration"] = old_generation
|
|
3090
|
+
receipt.setdefault("receiptId", f"retirement-{change_dir.name}-{old_generation}")
|
|
3091
|
+
receipt.setdefault("operationId", operation_id or uuid.uuid4().hex)
|
|
3092
|
+
receipt.setdefault("serviceId", str(receipt.get("serviceId") or change_dir.name))
|
|
3093
|
+
receipt.setdefault("oldSessionId", str(receipt.get("sessionId") or "legacy-" + change_dir.name))
|
|
3094
|
+
receipt.setdefault("state", "FINALIZED")
|
|
3095
|
+
receipt.setdefault(
|
|
3096
|
+
"retirementStateCommit",
|
|
3097
|
+
{"status": "COMMITTED", "reasonCode": "SERVICE_RETIRED"},
|
|
3098
|
+
)
|
|
3099
|
+
receipt.setdefault(
|
|
3100
|
+
"leaseCleanup",
|
|
3101
|
+
{
|
|
3102
|
+
"status": "RETAINED",
|
|
3103
|
+
"reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
|
|
3104
|
+
"leaseId": None,
|
|
3105
|
+
},
|
|
3106
|
+
)
|
|
3107
|
+
receipt["cleanupComplete"] = receipt["leaseCleanup"].get("status") == "RELEASED"
|
|
3108
|
+
receipt.setdefault("supersededBySessionId", None)
|
|
3109
|
+
legacy_superseder = receipt.pop("superseder", None)
|
|
3110
|
+
if receipt.get("supersededBySessionId") is None and isinstance(legacy_superseder, dict):
|
|
3111
|
+
legacy_id = legacy_superseder.get("sessionId")
|
|
3112
|
+
if isinstance(legacy_id, str) and legacy_id:
|
|
3113
|
+
receipt["supersededBySessionId"] = legacy_id
|
|
3114
|
+
existing_id = receipt.get("supersededBySessionId")
|
|
3115
|
+
if isinstance(existing_id, str) and existing_id:
|
|
3116
|
+
if existing_id == new_session.get("sessionId"):
|
|
3117
|
+
return {
|
|
3118
|
+
"ok": True,
|
|
3119
|
+
"code": "SUPERSEDER_ALREADY_LINKED",
|
|
3120
|
+
"receipt": receipt,
|
|
3121
|
+
}
|
|
3122
|
+
return {
|
|
3123
|
+
"ok": False,
|
|
3124
|
+
"code": "SUPERSEDER_LINK_CONFLICT",
|
|
3125
|
+
"receipt": receipt,
|
|
3126
|
+
}
|
|
3127
|
+
if receipt.get("awaitingSuperseder") is False:
|
|
3128
|
+
return {
|
|
3129
|
+
"ok": False,
|
|
3130
|
+
"code": "SUPERSEDER_LINK_CONFLICT",
|
|
3131
|
+
"receipt": receipt,
|
|
3132
|
+
}
|
|
3133
|
+
operation_id = operation_id or uuid.uuid4().hex
|
|
3134
|
+
new_session["schemaVersion"] = 1
|
|
3135
|
+
new_session.setdefault("serviceId", receipt["serviceId"])
|
|
3136
|
+
new_session.setdefault("sessionId", "service-" + uuid.uuid4().hex)
|
|
3137
|
+
new_session.setdefault("status", "STARTING")
|
|
3138
|
+
new_session.setdefault("reasonCode", "SERVICE_START_REQUESTED")
|
|
3139
|
+
new_session.setdefault("stateRevision", 1)
|
|
3140
|
+
new_session.setdefault("operationId", operation_id)
|
|
3141
|
+
new_session.setdefault("fingerprint", sha256_text(""))
|
|
3142
|
+
new_session.setdefault("processIdentity", None)
|
|
3143
|
+
new_session.setdefault("heartbeat", None)
|
|
3144
|
+
new_session.setdefault("leaseIdentity", None)
|
|
3145
|
+
new_session.setdefault("transitionHistory", [])
|
|
3146
|
+
new_session.setdefault("cleanupComplete", False)
|
|
3147
|
+
new_session["serviceGeneration"] = new_generation
|
|
3148
|
+
new_session["supersedesSessionId"] = receipt["oldSessionId"]
|
|
3149
|
+
retirement_commit = receipt.get("retirementStateCommit")
|
|
3150
|
+
if not isinstance(retirement_commit, dict):
|
|
3151
|
+
retirement_commit = {}
|
|
3152
|
+
lease_cleanup = receipt.get("leaseCleanup")
|
|
3153
|
+
if not isinstance(lease_cleanup, dict):
|
|
3154
|
+
lease_cleanup = {}
|
|
3155
|
+
commit_status = str(retirement_commit.get("status") or "COMMITTED")
|
|
3156
|
+
if commit_status not in {"PENDING", "COMMITTED", "CONFLICT"}:
|
|
3157
|
+
commit_status = "COMMITTED"
|
|
3158
|
+
lease_status = str(lease_cleanup.get("status") or "RETAINED")
|
|
3159
|
+
if lease_status not in {"PENDING", "RELEASED", "RETAINED", "UNVERIFIED"}:
|
|
3160
|
+
lease_status = "UNVERIFIED"
|
|
3161
|
+
canonical_receipt = {
|
|
3162
|
+
"schemaVersion": 1,
|
|
3163
|
+
"receiptId": str(receipt["receiptId"]),
|
|
3164
|
+
"operationId": operation_id,
|
|
3165
|
+
"serviceId": str(receipt["serviceId"]),
|
|
3166
|
+
"oldSessionId": str(receipt["oldSessionId"]),
|
|
3167
|
+
"oldGeneration": old_generation,
|
|
3168
|
+
"state": "FINALIZED",
|
|
3169
|
+
"retirementStateCommit": {
|
|
3170
|
+
"status": commit_status,
|
|
3171
|
+
"reasonCode": _canonical_service_reason(
|
|
3172
|
+
str(retirement_commit.get("reasonCode") or "SERVICE_RETIRED")
|
|
3173
|
+
),
|
|
3174
|
+
},
|
|
3175
|
+
"leaseCleanup": {
|
|
3176
|
+
"status": lease_status,
|
|
3177
|
+
"reasonCode": _canonical_service_reason(
|
|
3178
|
+
str(lease_cleanup.get("reasonCode") or "LISTENER_IDENTITY_UNVERIFIABLE")
|
|
3179
|
+
),
|
|
3180
|
+
"leaseId": lease_cleanup.get("leaseId")
|
|
3181
|
+
if isinstance(lease_cleanup.get("leaseId"), str)
|
|
3182
|
+
else None,
|
|
3183
|
+
},
|
|
3184
|
+
"cleanupComplete": lease_status == "RELEASED",
|
|
3185
|
+
"awaitingSuperseder": False,
|
|
3186
|
+
"supersededBySessionId": new_session.get("sessionId"),
|
|
3187
|
+
}
|
|
3188
|
+
receipt = canonical_receipt
|
|
3189
|
+
receipt["receiptDigest"] = _retirement_receipt_digest(receipt)
|
|
3190
|
+
write_json(receipt_path, receipt)
|
|
3191
|
+
write_session(change_dir, new_session)
|
|
3192
|
+
return {
|
|
3193
|
+
"ok": True,
|
|
3194
|
+
"code": "SUPERSEDER_LINKED",
|
|
3195
|
+
"receipt": receipt,
|
|
3196
|
+
"session": new_session,
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
|
|
3200
|
+
def link_superseder(
|
|
3201
|
+
change_dir: Path,
|
|
3202
|
+
*,
|
|
3203
|
+
retirement_receipt: Path,
|
|
3204
|
+
new_session: dict[str, Any],
|
|
3205
|
+
operation_id: str | None = None,
|
|
3206
|
+
) -> dict[str, Any]:
|
|
3207
|
+
"""CAS-link one superseder while serializing writers for this service."""
|
|
3208
|
+
|
|
3209
|
+
change_dir = resolve_path(change_dir)
|
|
3210
|
+
lock = ServiceMutationLock(change_dir, operation="link-superseder")
|
|
3211
|
+
conflict = lock.acquire()
|
|
3212
|
+
if conflict is not None:
|
|
3213
|
+
return conflict
|
|
3214
|
+
try:
|
|
3215
|
+
return _link_superseder_unlocked(
|
|
3216
|
+
change_dir,
|
|
3217
|
+
retirement_receipt=retirement_receipt,
|
|
3218
|
+
new_session=new_session,
|
|
3219
|
+
operation_id=operation_id or lock.token,
|
|
3220
|
+
)
|
|
3221
|
+
finally:
|
|
3222
|
+
lock.release()
|
|
3223
|
+
|
|
3224
|
+
|
|
3225
|
+
def cmd_link_superseder(args: argparse.Namespace) -> int:
|
|
3226
|
+
change_dir = resolve_path(args.change_dir)
|
|
3227
|
+
try:
|
|
3228
|
+
new_session = load_session(change_dir)
|
|
3229
|
+
except SessionCorrupt as exc:
|
|
3230
|
+
return emit_error(str(exc), as_json=bool(args.json))
|
|
3231
|
+
if new_session is None:
|
|
3232
|
+
return emit_error(
|
|
3233
|
+
"new service session is missing",
|
|
3234
|
+
as_json=bool(args.json),
|
|
3235
|
+
code=4,
|
|
3236
|
+
)
|
|
3237
|
+
result = link_superseder(
|
|
3238
|
+
change_dir,
|
|
3239
|
+
retirement_receipt=resolve_path(args.retirement_receipt),
|
|
3240
|
+
new_session=new_session,
|
|
3241
|
+
)
|
|
3242
|
+
emit_json(result, as_json=bool(args.json))
|
|
3243
|
+
return 0 if result.get("ok") else 4
|
|
3244
|
+
|
|
3245
|
+
|
|
3246
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
3247
|
+
parser = argparse.ArgumentParser(
|
|
3248
|
+
prog="harness_service.py",
|
|
3249
|
+
description="Manage AI service-session lifecycle (ensure/status/stop)",
|
|
3250
|
+
)
|
|
3251
|
+
parser.add_argument(
|
|
3252
|
+
"--json",
|
|
3253
|
+
action="store_true",
|
|
3254
|
+
help="emit machine-readable JSON on stdout",
|
|
3255
|
+
)
|
|
3256
|
+
sub = parser.add_subparsers(dest="command_name", required=True)
|
|
3257
|
+
|
|
3258
|
+
p_ensure = sub.add_parser("ensure", help="reuse / restart / start service")
|
|
3259
|
+
p_ensure.add_argument("--change-dir", required=True)
|
|
3260
|
+
p_ensure.add_argument("--project", required=True)
|
|
3261
|
+
p_ensure.add_argument(
|
|
3262
|
+
"--files",
|
|
3263
|
+
default=None,
|
|
3264
|
+
help="comma-separated service module source files for inputsHash",
|
|
3265
|
+
)
|
|
3266
|
+
p_ensure.add_argument(
|
|
3267
|
+
"--change-name",
|
|
3268
|
+
default=None,
|
|
3269
|
+
help="change-name for runtime profile resolve (default: change-dir name)",
|
|
3270
|
+
)
|
|
3271
|
+
p_ensure.add_argument(
|
|
3272
|
+
"--overlay",
|
|
3273
|
+
default=None,
|
|
3274
|
+
help="runtime overlay path injected into resolved serviceStart",
|
|
3275
|
+
)
|
|
3276
|
+
p_ensure.add_argument(
|
|
3277
|
+
"--leased-port",
|
|
3278
|
+
type=int,
|
|
3279
|
+
default=None,
|
|
3280
|
+
help="port allocated by harness_change.py lease-port",
|
|
3281
|
+
)
|
|
3282
|
+
p_ensure.add_argument(
|
|
3283
|
+
"--lease-owner",
|
|
3284
|
+
default=None,
|
|
3285
|
+
help="run id that owns --leased-port",
|
|
3286
|
+
)
|
|
3287
|
+
p_ensure.add_argument(
|
|
3288
|
+
"--worktree-root",
|
|
3289
|
+
default=None,
|
|
3290
|
+
help="worktree that owns the service (default: --project)",
|
|
3291
|
+
)
|
|
3292
|
+
p_ensure.add_argument(
|
|
3293
|
+
"--execution-root",
|
|
3294
|
+
default=None,
|
|
3295
|
+
help="ephemeral execution worktree that owns the service",
|
|
3296
|
+
)
|
|
3297
|
+
p_ensure.add_argument(
|
|
3298
|
+
"--attempt-id",
|
|
3299
|
+
default=None,
|
|
3300
|
+
help="phase or transaction attempt that started the service",
|
|
3301
|
+
)
|
|
3302
|
+
p_ensure.add_argument("--json", action="store_true")
|
|
3303
|
+
p_ensure.set_defaults(func=cmd_ensure)
|
|
3304
|
+
|
|
3305
|
+
p_status = sub.add_parser("status", help="show session + liveness + fingerprint")
|
|
3306
|
+
p_status.add_argument("--change-dir", required=True)
|
|
3307
|
+
p_status.add_argument(
|
|
3308
|
+
"--files",
|
|
3309
|
+
default=None,
|
|
3310
|
+
help="optional files for current fingerprint comparison",
|
|
3311
|
+
)
|
|
3312
|
+
p_status.add_argument("--json", action="store_true")
|
|
3313
|
+
p_status.set_defaults(func=cmd_status)
|
|
3314
|
+
|
|
3315
|
+
p_stop = sub.add_parser("stop", help="stop service and clear session")
|
|
3316
|
+
p_stop.add_argument("--change-dir", required=True)
|
|
3317
|
+
p_stop.add_argument(
|
|
3318
|
+
"--if-started-by-ai",
|
|
3319
|
+
action="store_true",
|
|
3320
|
+
help="only stop when session.startedBy == AI (archive cleanup)",
|
|
3321
|
+
)
|
|
3322
|
+
p_stop.add_argument("--json", action="store_true")
|
|
3323
|
+
p_stop.set_defaults(func=cmd_stop)
|
|
3324
|
+
|
|
3325
|
+
p_retire = sub.add_parser(
|
|
3326
|
+
"retire-stale",
|
|
3327
|
+
help="retire stale Harness state without terminating unknown processes",
|
|
3328
|
+
)
|
|
3329
|
+
p_retire.add_argument("--change-dir", required=True)
|
|
3330
|
+
p_retire.add_argument("--json", action="store_true")
|
|
3331
|
+
p_retire.set_defaults(func=cmd_retire_stale)
|
|
3332
|
+
|
|
3333
|
+
p_link = sub.add_parser(
|
|
3334
|
+
"link-superseder",
|
|
3335
|
+
help="CAS-link a new service generation to a retirement receipt",
|
|
3336
|
+
)
|
|
3337
|
+
p_link.add_argument("--change-dir", required=True)
|
|
3338
|
+
p_link.add_argument("--retirement-receipt", required=True)
|
|
3339
|
+
p_link.add_argument("--json", action="store_true")
|
|
3340
|
+
p_link.set_defaults(func=cmd_link_superseder)
|
|
3341
|
+
|
|
3342
|
+
return parser
|
|
3343
|
+
|
|
3344
|
+
|
|
3345
|
+
def main(argv: list[str] | None = None) -> int:
|
|
3346
|
+
parser = build_parser()
|
|
3347
|
+
args = parser.parse_args(argv)
|
|
3348
|
+
# Allow top-level --json as well as subcommand --json
|
|
3349
|
+
if getattr(args, "json", False) is False and "--json" in (argv or sys.argv[1:]):
|
|
3350
|
+
args.json = True
|
|
3351
|
+
return int(args.func(args))
|
|
3352
|
+
|
|
3353
|
+
|
|
3354
|
+
if __name__ == "__main__":
|
|
3355
|
+
raise SystemExit(main())
|