@hunter-harness/workflow-harness 0.2.43 → 0.2.45
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/contracts/build-profile-v3.schema.json +100 -0
- package/harness/bundles/general/claude-code/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/claude-code/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/claude-code/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/claude-code/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/claude-code/contracts/service-session.schema.json +147 -0
- 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-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-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-submit/checklist.md +12 -0
- 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 +324 -28
- package/harness/bundles/general/claude-code/scripts/harness_change.py +137 -2
- package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +153 -0
- package/harness/bundles/general/claude-code/scripts/harness_environment.py +55 -25
- package/harness/bundles/general/claude-code/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +289 -52
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +5 -0
- package/harness/bundles/general/claude-code/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/claude-code/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +244 -4
- package/harness/bundles/general/claude-code/scripts/harness_runtime.py +646 -132
- package/harness/bundles/general/claude-code/scripts/harness_service.py +1469 -194
- package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +107 -117
- package/harness/bundles/general/claude-code/scripts/harness_verification.py +79 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +100 -0
- package/harness/bundles/general/codebuddy/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/codebuddy/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/codebuddy/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/codebuddy/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/codebuddy/contracts/service-session.schema.json +147 -0
- 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-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-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-submit/checklist.md +12 -0
- 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 +324 -28
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +137 -2
- package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +153 -0
- package/harness/bundles/general/codebuddy/scripts/harness_environment.py +55 -25
- package/harness/bundles/general/codebuddy/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +289 -52
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +5 -0
- package/harness/bundles/general/codebuddy/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/codebuddy/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +244 -4
- package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +646 -132
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +1469 -194
- package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +107 -117
- package/harness/bundles/general/codebuddy/scripts/harness_verification.py +79 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +100 -0
- package/harness/bundles/general/codex/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/codex/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/codex/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/codex/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/codex/contracts/service-session.schema.json +147 -0
- 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-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-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-submit/checklist.md +12 -0
- 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 +324 -28
- package/harness/bundles/general/codex/scripts/harness_change.py +137 -2
- package/harness/bundles/general/codex/scripts/harness_efficiency.py +153 -0
- package/harness/bundles/general/codex/scripts/harness_environment.py +55 -25
- package/harness/bundles/general/codex/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/codex/scripts/harness_integration.py +289 -52
- package/harness/bundles/general/codex/scripts/harness_ledger.py +5 -0
- package/harness/bundles/general/codex/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/codex/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +244 -4
- package/harness/bundles/general/codex/scripts/harness_runtime.py +646 -132
- package/harness/bundles/general/codex/scripts/harness_service.py +1469 -194
- package/harness/bundles/general/codex/scripts/harness_test_runner.py +107 -117
- package/harness/bundles/general/codex/scripts/harness_verification.py +79 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +100 -0
- package/harness/bundles/general/cursor/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/cursor/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/cursor/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/cursor/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/cursor/contracts/service-session.schema.json +147 -0
- 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-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-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-submit/checklist.md +12 -0
- 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 +324 -28
- package/harness/bundles/general/cursor/scripts/harness_change.py +137 -2
- package/harness/bundles/general/cursor/scripts/harness_efficiency.py +153 -0
- package/harness/bundles/general/cursor/scripts/harness_environment.py +55 -25
- package/harness/bundles/general/cursor/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/cursor/scripts/harness_integration.py +289 -52
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +5 -0
- package/harness/bundles/general/cursor/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/cursor/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +244 -4
- package/harness/bundles/general/cursor/scripts/harness_runtime.py +646 -132
- package/harness/bundles/general/cursor/scripts/harness_service.py +1469 -194
- package/harness/bundles/general/cursor/scripts/harness_test_runner.py +107 -117
- package/harness/bundles/general/cursor/scripts/harness_verification.py +79 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +100 -0
- package/harness/bundles/java/claude-code/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/claude-code/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/claude-code/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/claude-code/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/claude-code/contracts/service-session.schema.json +147 -0
- 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-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-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-submit/checklist.md +12 -0
- 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 +324 -28
- package/harness/bundles/java/claude-code/scripts/harness_change.py +137 -2
- package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +153 -0
- package/harness/bundles/java/claude-code/scripts/harness_environment.py +55 -25
- package/harness/bundles/java/claude-code/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +289 -52
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +5 -0
- package/harness/bundles/java/claude-code/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/claude-code/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +244 -4
- package/harness/bundles/java/claude-code/scripts/harness_runtime.py +646 -132
- package/harness/bundles/java/claude-code/scripts/harness_service.py +1469 -194
- package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +107 -117
- package/harness/bundles/java/claude-code/scripts/harness_verification.py +79 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +100 -0
- package/harness/bundles/java/codebuddy/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/codebuddy/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/codebuddy/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/codebuddy/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/codebuddy/contracts/service-session.schema.json +147 -0
- 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-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-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-submit/checklist.md +12 -0
- 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 +324 -28
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +137 -2
- package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +153 -0
- package/harness/bundles/java/codebuddy/scripts/harness_environment.py +55 -25
- package/harness/bundles/java/codebuddy/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +289 -52
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +5 -0
- package/harness/bundles/java/codebuddy/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/codebuddy/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +244 -4
- package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +646 -132
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +1469 -194
- package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +107 -117
- package/harness/bundles/java/codebuddy/scripts/harness_verification.py +79 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +100 -0
- package/harness/bundles/java/codex/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/codex/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/codex/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/codex/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/codex/contracts/service-session.schema.json +147 -0
- 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-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-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-submit/checklist.md +12 -0
- 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 +324 -28
- package/harness/bundles/java/codex/scripts/harness_change.py +137 -2
- package/harness/bundles/java/codex/scripts/harness_efficiency.py +153 -0
- package/harness/bundles/java/codex/scripts/harness_environment.py +55 -25
- package/harness/bundles/java/codex/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/codex/scripts/harness_integration.py +289 -52
- package/harness/bundles/java/codex/scripts/harness_ledger.py +5 -0
- package/harness/bundles/java/codex/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/codex/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +244 -4
- package/harness/bundles/java/codex/scripts/harness_runtime.py +646 -132
- package/harness/bundles/java/codex/scripts/harness_service.py +1469 -194
- package/harness/bundles/java/codex/scripts/harness_test_runner.py +107 -117
- package/harness/bundles/java/codex/scripts/harness_verification.py +79 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +100 -0
- package/harness/bundles/java/cursor/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/cursor/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/cursor/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/cursor/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/cursor/contracts/service-session.schema.json +147 -0
- 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-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-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-submit/checklist.md +12 -0
- 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 +324 -28
- package/harness/bundles/java/cursor/scripts/harness_change.py +137 -2
- package/harness/bundles/java/cursor/scripts/harness_efficiency.py +153 -0
- package/harness/bundles/java/cursor/scripts/harness_environment.py +55 -25
- package/harness/bundles/java/cursor/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/cursor/scripts/harness_integration.py +289 -52
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +5 -0
- package/harness/bundles/java/cursor/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/cursor/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +244 -4
- package/harness/bundles/java/cursor/scripts/harness_runtime.py +646 -132
- package/harness/bundles/java/cursor/scripts/harness_service.py +1469 -194
- package/harness/bundles/java/cursor/scripts/harness_test_runner.py +107 -117
- package/harness/bundles/java/cursor/scripts/harness_verification.py +79 -0
- package/harness/manifests/general/claude-code.json +58 -26
- package/harness/manifests/general/codebuddy.json +58 -26
- package/harness/manifests/general/codex.json +58 -26
- package/harness/manifests/general/cursor.json +58 -26
- package/harness/manifests/java/claude-code.json +60 -28
- package/harness/manifests/java/codebuddy.json +60 -28
- package/harness/manifests/java/codex.json +60 -28
- package/harness/manifests/java/cursor.json +60 -28
- package/hunter-workflow-family.json +4 -4
- package/package.json +1 -1
|
@@ -38,6 +38,11 @@ if str(SCRIPTS_DIR) not in sys.path:
|
|
|
38
38
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
39
39
|
|
|
40
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
|
+
)
|
|
41
46
|
|
|
42
47
|
|
|
43
48
|
if hasattr(sys.stdout, "reconfigure"):
|
|
@@ -49,6 +54,9 @@ if hasattr(sys.stderr, "reconfigure"):
|
|
|
49
54
|
PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
|
|
50
55
|
SESSION_REL = Path("runtime") / "service-session.json"
|
|
51
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
|
|
52
60
|
|
|
53
61
|
FATAL_KEYWORDS = (
|
|
54
62
|
"BindException",
|
|
@@ -116,6 +124,130 @@ def log_path(change_dir: Path) -> Path:
|
|
|
116
124
|
return change_dir / LOG_REL
|
|
117
125
|
|
|
118
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
|
+
|
|
119
251
|
def profile_path(project: Path) -> Path:
|
|
120
252
|
return project / PROFILE_REL
|
|
121
253
|
|
|
@@ -154,10 +286,18 @@ def get_service_start(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
154
286
|
if not isinstance(svc, dict):
|
|
155
287
|
raise ValueError("build-profile.json missing serviceStart object")
|
|
156
288
|
command = svc.get("command")
|
|
157
|
-
|
|
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
|
+
):
|
|
158
298
|
raise ValueError(
|
|
159
|
-
"build-profile.json serviceStart.command is empty; "
|
|
160
|
-
"configure the service start
|
|
299
|
+
"build-profile.json serviceStart.command/argvTemplate is empty; "
|
|
300
|
+
"configure the service start argv explicitly (will not guess)"
|
|
161
301
|
)
|
|
162
302
|
return svc
|
|
163
303
|
|
|
@@ -180,14 +320,19 @@ def _detect_stale_persistent_values(service_start: dict[str, Any]) -> list[str]:
|
|
|
180
320
|
说明是 v1 残留或被错误写回的已解析 overlay,必须拒绝(spec §3.4)。
|
|
181
321
|
"""
|
|
182
322
|
stale: list[str] = []
|
|
183
|
-
for field in ("command", "overlayPath", "profile"):
|
|
323
|
+
for field in ("command", "argvTemplate", "overlayPath", "profile"):
|
|
184
324
|
val = service_start.get(field)
|
|
185
|
-
if
|
|
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:
|
|
186
330
|
continue
|
|
187
|
-
for
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
191
336
|
return stale
|
|
192
337
|
|
|
193
338
|
|
|
@@ -209,7 +354,7 @@ def resolve_service_start(
|
|
|
209
354
|
向后兼容:持久 profile.profile 非空(如 v1 残留 "local-dev")→ 保留;
|
|
210
355
|
overlay_path 未提供 → 保留持久 overlayPath(可能为空)。
|
|
211
356
|
"""
|
|
212
|
-
service_start = get_service_start(profile) #
|
|
357
|
+
service_start = get_service_start(profile) # 校验结构化 argv/legacy command 非空
|
|
213
358
|
|
|
214
359
|
stale = _detect_stale_persistent_values(service_start)
|
|
215
360
|
if stale:
|
|
@@ -235,6 +380,13 @@ def resolve_service_start(
|
|
|
235
380
|
value = resolved.get(field)
|
|
236
381
|
if isinstance(value, str):
|
|
237
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
|
+
)
|
|
238
390
|
# worktree_root 预留给未来相对 overlay 路径解析(spec §3.6 state snapshot);
|
|
239
391
|
# 不写入返回值,避免污染 session。
|
|
240
392
|
_ = worktree_root
|
|
@@ -301,19 +453,8 @@ def resolve_service_input_files(
|
|
|
301
453
|
|
|
302
454
|
|
|
303
455
|
def is_pid_alive(pid: int) -> bool:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if os.name == "nt":
|
|
307
|
-
return _windows_pid_alive(pid)
|
|
308
|
-
try:
|
|
309
|
-
os.kill(pid, 0)
|
|
310
|
-
return True
|
|
311
|
-
except ProcessLookupError:
|
|
312
|
-
return False
|
|
313
|
-
except PermissionError:
|
|
314
|
-
return True
|
|
315
|
-
except OSError:
|
|
316
|
-
return False
|
|
456
|
+
"""Compatibility re-export; process facts come from the shared provider."""
|
|
457
|
+
return _process_provider.is_pid_alive(pid)
|
|
317
458
|
|
|
318
459
|
|
|
319
460
|
def _windows_pid_alive(pid: int) -> bool:
|
|
@@ -341,38 +482,12 @@ def _windows_pid_alive(pid: int) -> bool:
|
|
|
341
482
|
|
|
342
483
|
def get_process_create_time(pid: int) -> dt.datetime | None:
|
|
343
484
|
"""Best-effort process create time (timezone-aware). None if unavailable."""
|
|
344
|
-
|
|
345
|
-
return _windows_process_create_time(pid)
|
|
346
|
-
return _posix_process_create_time(pid)
|
|
485
|
+
return _process_provider.get_process_create_time(pid)
|
|
347
486
|
|
|
348
487
|
|
|
349
488
|
def get_process_executable(pid: int) -> str | None:
|
|
350
489
|
"""Best-effort executable identity for PID-reuse protection."""
|
|
351
|
-
|
|
352
|
-
try:
|
|
353
|
-
import ctypes
|
|
354
|
-
from ctypes import wintypes
|
|
355
|
-
|
|
356
|
-
kernel32 = ctypes.windll.kernel32
|
|
357
|
-
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
358
|
-
if not handle:
|
|
359
|
-
return None
|
|
360
|
-
try:
|
|
361
|
-
capacity = wintypes.DWORD(32768)
|
|
362
|
-
buffer = ctypes.create_unicode_buffer(capacity.value)
|
|
363
|
-
if not kernel32.QueryFullProcessImageNameW(
|
|
364
|
-
handle, 0, buffer, ctypes.byref(capacity)
|
|
365
|
-
):
|
|
366
|
-
return None
|
|
367
|
-
return str(Path(buffer.value).resolve())
|
|
368
|
-
finally:
|
|
369
|
-
kernel32.CloseHandle(handle)
|
|
370
|
-
except Exception:
|
|
371
|
-
return None
|
|
372
|
-
try:
|
|
373
|
-
return str(Path(f"/proc/{pid}/exe").resolve(strict=True))
|
|
374
|
-
except OSError:
|
|
375
|
-
return None
|
|
490
|
+
return _process_provider.get_process_executable(pid)
|
|
376
491
|
|
|
377
492
|
|
|
378
493
|
def _windows_process_create_time(pid: int) -> dt.datetime | None:
|
|
@@ -470,82 +585,98 @@ def verify_process_identity(session: dict[str, Any]) -> bool | None:
|
|
|
470
585
|
pid = session.get("pid")
|
|
471
586
|
if not isinstance(pid, int) or pid <= 0:
|
|
472
587
|
return False
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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:
|
|
482
619
|
return None
|
|
483
|
-
|
|
484
|
-
#
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
+
}:
|
|
489
656
|
return False
|
|
490
|
-
|
|
491
|
-
if isinstance(process_identity, dict):
|
|
492
|
-
expected_executable = str(
|
|
493
|
-
process_identity.get("executable") or ""
|
|
494
|
-
).strip()
|
|
495
|
-
if not expected_executable:
|
|
496
|
-
return None
|
|
497
|
-
actual_executable = get_process_executable(pid)
|
|
498
|
-
if actual_executable is None:
|
|
499
|
-
return None
|
|
500
|
-
try:
|
|
501
|
-
if Path(actual_executable).resolve() != Path(
|
|
502
|
-
expected_executable
|
|
503
|
-
).resolve():
|
|
504
|
-
return False
|
|
505
|
-
except OSError:
|
|
506
|
-
return None
|
|
507
|
-
return True
|
|
657
|
+
return None
|
|
508
658
|
|
|
509
659
|
|
|
510
|
-
def terminate_process_tree(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
try:
|
|
531
|
-
os.killpg(pid, signal.SIGTERM)
|
|
532
|
-
except OSError:
|
|
533
|
-
try:
|
|
534
|
-
os.kill(pid, signal.SIGTERM)
|
|
535
|
-
except OSError:
|
|
536
|
-
pass
|
|
537
|
-
# Brief wait then SIGKILL
|
|
538
|
-
deadline = time.monotonic() + 3.0
|
|
539
|
-
while time.monotonic() < deadline and is_pid_alive(pid):
|
|
540
|
-
time.sleep(0.1)
|
|
541
|
-
if is_pid_alive(pid):
|
|
542
|
-
try:
|
|
543
|
-
os.killpg(pid, signal.SIGKILL)
|
|
544
|
-
except OSError:
|
|
545
|
-
try:
|
|
546
|
-
os.kill(pid, signal.SIGKILL)
|
|
547
|
-
except OSError:
|
|
548
|
-
pass
|
|
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
|
+
)
|
|
549
680
|
|
|
550
681
|
|
|
551
682
|
# ---------------------------------------------------------------------------
|
|
@@ -620,12 +751,26 @@ def resolve_health_file(spec: str) -> Path | None:
|
|
|
620
751
|
return None
|
|
621
752
|
|
|
622
753
|
|
|
623
|
-
def probe_health(
|
|
754
|
+
def probe_health(
|
|
755
|
+
service_start: dict[str, Any],
|
|
756
|
+
*,
|
|
757
|
+
expected_instance_token: str | None = None,
|
|
758
|
+
) -> bool:
|
|
624
759
|
"""Return True if service appears healthy per serviceStart health config."""
|
|
625
760
|
# Prefer explicit healthFile
|
|
626
761
|
health_file = service_start.get("healthFile")
|
|
627
762
|
if isinstance(health_file, str) and health_file.strip():
|
|
628
|
-
|
|
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
|
|
629
774
|
|
|
630
775
|
spec = service_start.get("healthUrl")
|
|
631
776
|
if not isinstance(spec, str) or not spec.strip():
|
|
@@ -637,7 +782,16 @@ def probe_health(service_start: dict[str, Any]) -> bool:
|
|
|
637
782
|
spec = spec.strip()
|
|
638
783
|
file_path = resolve_health_file(spec)
|
|
639
784
|
if file_path is not None:
|
|
640
|
-
|
|
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
|
|
641
795
|
|
|
642
796
|
lower = spec.lower()
|
|
643
797
|
if lower.startswith("tcp://") or lower.startswith("socket://"):
|
|
@@ -655,13 +809,49 @@ def probe_health(service_start: dict[str, Any]) -> bool:
|
|
|
655
809
|
# HTTP(S)
|
|
656
810
|
url = spec if "://" in spec else f"http://{spec}"
|
|
657
811
|
try:
|
|
658
|
-
|
|
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")
|
|
659
818
|
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
|
660
|
-
|
|
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
|
|
661
828
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
|
|
662
829
|
return False
|
|
663
830
|
|
|
664
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
|
+
|
|
665
855
|
def log_has_fatal(path: Path) -> str | None:
|
|
666
856
|
if not path.is_file():
|
|
667
857
|
return None
|
|
@@ -689,6 +879,7 @@ def wait_for_healthy(
|
|
|
689
879
|
log_file: Path,
|
|
690
880
|
*,
|
|
691
881
|
pid: int | None = None,
|
|
882
|
+
expected_instance_token: str | None = None,
|
|
692
883
|
) -> None:
|
|
693
884
|
"""Startup wait state machine: 0–30s /2s, 30–120s /5s; fatal keywords abort."""
|
|
694
885
|
timeout_sec = service_start.get("startTimeoutSec", 120)
|
|
@@ -710,7 +901,10 @@ def wait_for_healthy(
|
|
|
710
901
|
)
|
|
711
902
|
if pid is not None and not is_pid_alive(pid):
|
|
712
903
|
raise ServiceStartError(f"service process exited early (pid={pid})")
|
|
713
|
-
if probe_health(
|
|
904
|
+
if probe_health(
|
|
905
|
+
service_start,
|
|
906
|
+
expected_instance_token=expected_instance_token,
|
|
907
|
+
):
|
|
714
908
|
return
|
|
715
909
|
if elapsed >= timeout_sec:
|
|
716
910
|
raise ServiceStartError(
|
|
@@ -733,6 +927,60 @@ class SessionCorrupt(Exception):
|
|
|
733
927
|
pass
|
|
734
928
|
|
|
735
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
|
+
|
|
736
984
|
def load_session(change_dir: Path) -> dict[str, Any] | None:
|
|
737
985
|
"""Return session dict, None if missing, raise SessionCorrupt if damaged."""
|
|
738
986
|
path = session_path(change_dir)
|
|
@@ -758,6 +1006,23 @@ def load_session(change_dir: Path) -> dict[str, Any] | None:
|
|
|
758
1006
|
raise SessionCorrupt("session.startedBy missing")
|
|
759
1007
|
if "startedAt" not in data:
|
|
760
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))
|
|
761
1026
|
return data
|
|
762
1027
|
|
|
763
1028
|
|
|
@@ -776,6 +1041,121 @@ def write_session(change_dir: Path, session: dict[str, Any]) -> Path:
|
|
|
776
1041
|
return path
|
|
777
1042
|
|
|
778
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
|
+
|
|
779
1159
|
def _same_path(left: Any, right: Path | None) -> bool:
|
|
780
1160
|
if right is None or not isinstance(left, str) or not left.strip():
|
|
781
1161
|
return False
|
|
@@ -953,6 +1333,7 @@ _WIN_LAUNCHER_SOURCE = """\
|
|
|
953
1333
|
from __future__ import annotations
|
|
954
1334
|
|
|
955
1335
|
import ctypes
|
|
1336
|
+
import json
|
|
956
1337
|
import subprocess
|
|
957
1338
|
import sys
|
|
958
1339
|
from pathlib import Path
|
|
@@ -1021,20 +1402,22 @@ def _create_job(name: str) -> int:
|
|
|
1021
1402
|
|
|
1022
1403
|
def main() -> int:
|
|
1023
1404
|
log_path = Path(sys.argv[1])
|
|
1024
|
-
|
|
1405
|
+
argv_path = Path(sys.argv[2])
|
|
1025
1406
|
pid_path = Path(sys.argv[3])
|
|
1026
1407
|
job_name = sys.argv[4]
|
|
1027
|
-
|
|
1028
|
-
if not
|
|
1029
|
-
|
|
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")
|
|
1030
1413
|
|
|
1031
1414
|
job_handle = _create_job(job_name)
|
|
1032
1415
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1033
1416
|
log_handle = log_path.open("w", encoding="utf-8", errors="replace")
|
|
1034
1417
|
try:
|
|
1035
1418
|
proc = subprocess.Popen(
|
|
1036
|
-
|
|
1037
|
-
shell=
|
|
1419
|
+
argv,
|
|
1420
|
+
shell=False,
|
|
1038
1421
|
stdout=log_handle,
|
|
1039
1422
|
stderr=subprocess.STDOUT,
|
|
1040
1423
|
stdin=subprocess.DEVNULL,
|
|
@@ -1100,6 +1483,27 @@ def _cleanup_windows_launcher(change_dir: Path) -> None:
|
|
|
1100
1483
|
terminate_process_tree(pid)
|
|
1101
1484
|
|
|
1102
1485
|
|
|
1486
|
+
def _wait_for_windows_launcher_exit(
|
|
1487
|
+
change_dir: Path,
|
|
1488
|
+
*,
|
|
1489
|
+
timeout_sec: float = STOP_CONFIRM_TIMEOUT_SEC,
|
|
1490
|
+
) -> bool:
|
|
1491
|
+
"""Wait for the detached Job owner to release log/file handles.
|
|
1492
|
+
|
|
1493
|
+
The launcher is deliberately not killed by PID. Its named Job has
|
|
1494
|
+
already been terminated through the ownership proof; this wait merely
|
|
1495
|
+
observes the launcher completing its ``proc.wait()`` and avoids a race
|
|
1496
|
+
with archive/temp-directory cleanup.
|
|
1497
|
+
"""
|
|
1498
|
+
pid = _read_positive_int(_launcher_pid_path(change_dir))
|
|
1499
|
+
if pid is None:
|
|
1500
|
+
return True
|
|
1501
|
+
deadline = time.monotonic() + max(0.0, timeout_sec)
|
|
1502
|
+
while is_pid_alive(pid) and time.monotonic() < deadline:
|
|
1503
|
+
time.sleep(0.05)
|
|
1504
|
+
return not is_pid_alive(pid)
|
|
1505
|
+
|
|
1506
|
+
|
|
1103
1507
|
def _write_windows_launcher(change_dir: Path) -> Path:
|
|
1104
1508
|
launcher = _runtime_dir(change_dir) / "_harness_service_launcher.py"
|
|
1105
1509
|
launcher.write_text(_WIN_LAUNCHER_SOURCE, encoding="utf-8", newline="\n")
|
|
@@ -1134,18 +1538,30 @@ def _read_optional_text(path: Path) -> str | None:
|
|
|
1134
1538
|
|
|
1135
1539
|
|
|
1136
1540
|
def _start_detached_service_windows(
|
|
1137
|
-
|
|
1541
|
+
argv: list[str],
|
|
1138
1542
|
*,
|
|
1139
1543
|
change_dir: Path,
|
|
1140
1544
|
cwd: Path,
|
|
1141
1545
|
log_file: Path,
|
|
1142
|
-
|
|
1546
|
+
owner_token: str,
|
|
1547
|
+
) -> _process_provider.SpawnedProcess:
|
|
1143
1548
|
runtime = _runtime_dir(change_dir)
|
|
1144
1549
|
launcher = _write_windows_launcher(change_dir)
|
|
1145
1550
|
command_path = runtime / "_harness_service.command.txt"
|
|
1146
1551
|
pid_path = _child_pid_path(change_dir)
|
|
1147
|
-
|
|
1148
|
-
|
|
1552
|
+
owner_hash = _process_provider._token_hash(owner_token)
|
|
1553
|
+
proof_id = "proof-" + uuid.uuid4().hex
|
|
1554
|
+
job_id = (
|
|
1555
|
+
"Local\\HunterHarness-"
|
|
1556
|
+
+ owner_hash.split(":", 1)[1][:16]
|
|
1557
|
+
+ "-"
|
|
1558
|
+
+ proof_id
|
|
1559
|
+
)
|
|
1560
|
+
command_path.write_text(
|
|
1561
|
+
json.dumps(argv, ensure_ascii=False, separators=(",", ":")),
|
|
1562
|
+
encoding="utf-8",
|
|
1563
|
+
newline="\n",
|
|
1564
|
+
)
|
|
1149
1565
|
_job_id_path(change_dir).write_text(job_id, encoding="utf-8", newline="\n")
|
|
1150
1566
|
if pid_path.exists():
|
|
1151
1567
|
pid_path.unlink()
|
|
@@ -1175,57 +1591,100 @@ def _start_detached_service_windows(
|
|
|
1175
1591
|
)
|
|
1176
1592
|
_launcher_pid_path(change_dir).write_text(str(launcher_proc.pid), encoding="utf-8")
|
|
1177
1593
|
_wait_for_child_pid(pid_path)
|
|
1178
|
-
root_pid = int(launcher_proc.pid)
|
|
1179
1594
|
# The launcher intentionally remains alive as the Job Object owner. Close
|
|
1180
1595
|
# only this controller's process handle so Popen does not warn at teardown.
|
|
1181
1596
|
launcher_handle = getattr(launcher_proc, "_handle", None)
|
|
1182
1597
|
if launcher_handle is not None:
|
|
1183
1598
|
launcher_handle.Close()
|
|
1184
1599
|
launcher_proc.returncode = 0
|
|
1185
|
-
|
|
1600
|
+
child_pid = _read_positive_int(pid_path)
|
|
1601
|
+
if child_pid is None:
|
|
1602
|
+
raise TimeoutError(f"service child pid is invalid ({pid_path})")
|
|
1603
|
+
observed = _process_provider.observe_process_identity(child_pid)
|
|
1604
|
+
attestation = json.loads(json.dumps(observed))
|
|
1605
|
+
attestation["argvHash"] = _process_provider.canonical_argv_hash(argv)
|
|
1606
|
+
attestation["workingDirectory"] = str(cwd.resolve())
|
|
1607
|
+
attestation["ownerTokenHash"] = owner_hash
|
|
1608
|
+
attestation["fieldProvenance"]["argvHash"] = (
|
|
1609
|
+
"OBSERVED" if observed.get("argvHash") is not None else "ATTESTED"
|
|
1610
|
+
)
|
|
1611
|
+
attestation["fieldProvenance"]["workingDirectory"] = (
|
|
1612
|
+
"OBSERVED"
|
|
1613
|
+
if observed.get("workingDirectory") is not None
|
|
1614
|
+
else "ATTESTED"
|
|
1615
|
+
)
|
|
1616
|
+
attestation["fieldProvenance"]["ownerTokenHash"] = "ATTESTED"
|
|
1617
|
+
attestation["capabilities"]["canVerifyOwnership"] = True
|
|
1618
|
+
proof = {
|
|
1619
|
+
"schemaVersion": 1,
|
|
1620
|
+
"proofId": proof_id,
|
|
1621
|
+
"kind": "WINDOWS_NAMED_JOB",
|
|
1622
|
+
"jobName": job_id,
|
|
1623
|
+
"ownershipTokenHash": owner_hash,
|
|
1624
|
+
"leaderPid": child_pid,
|
|
1625
|
+
"leaderCreatedAt": attestation.get("createdAt"),
|
|
1626
|
+
"members": [attestation],
|
|
1627
|
+
"membersComplete": True,
|
|
1628
|
+
"leaderExited": False,
|
|
1629
|
+
}
|
|
1630
|
+
return _process_provider.SpawnedProcess(
|
|
1631
|
+
process=launcher_proc,
|
|
1632
|
+
attestation=attestation,
|
|
1633
|
+
observedIdentity=observed,
|
|
1634
|
+
ownershipProof=proof,
|
|
1635
|
+
)
|
|
1186
1636
|
|
|
1187
1637
|
|
|
1188
1638
|
def start_detached_service(
|
|
1189
|
-
|
|
1639
|
+
argv: list[str] | tuple[str, ...] | str,
|
|
1190
1640
|
*,
|
|
1191
1641
|
change_dir: Path,
|
|
1192
1642
|
cwd: Path,
|
|
1193
|
-
) ->
|
|
1194
|
-
"""Start service
|
|
1643
|
+
) -> _process_provider.SpawnedProcess:
|
|
1644
|
+
"""Start service with structured argv; log to logs/service-start.log.
|
|
1645
|
+
|
|
1646
|
+
``str`` is retained only for callers from the legacy facade and is parsed
|
|
1647
|
+
by the same profile parser; no caller is allowed to reach ``shell=True``.
|
|
1648
|
+
"""
|
|
1649
|
+
if isinstance(argv, str):
|
|
1650
|
+
argv = resolve_service_argv({"command": argv})
|
|
1651
|
+
argv = list(argv)
|
|
1652
|
+
if not argv:
|
|
1653
|
+
raise ValueError("ARGUMENT_INVALID: service argv is empty")
|
|
1195
1654
|
log_file = log_path(change_dir)
|
|
1196
1655
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1197
1656
|
if log_file.is_file():
|
|
1198
1657
|
log_file.unlink()
|
|
1199
1658
|
|
|
1659
|
+
owner_token = f"service:{change_dir.resolve()}:{uuid.uuid4().hex}"
|
|
1200
1660
|
if os.name == "nt":
|
|
1201
1661
|
return _start_detached_service_windows(
|
|
1202
|
-
|
|
1662
|
+
argv,
|
|
1203
1663
|
change_dir=change_dir,
|
|
1204
1664
|
cwd=cwd,
|
|
1205
1665
|
log_file=log_file,
|
|
1666
|
+
owner_token=owner_token,
|
|
1206
1667
|
)
|
|
1207
1668
|
|
|
1208
1669
|
log_handle = log_file.open("w", encoding="utf-8", errors="replace")
|
|
1209
|
-
popen_kwargs: dict[str, Any] = {
|
|
1210
|
-
"args": command,
|
|
1211
|
-
"shell": True,
|
|
1212
|
-
"stdout": log_handle,
|
|
1213
|
-
"stderr": subprocess.STDOUT,
|
|
1214
|
-
"cwd": str(cwd),
|
|
1215
|
-
"stdin": subprocess.DEVNULL,
|
|
1216
|
-
"start_new_session": True,
|
|
1217
|
-
"close_fds": True,
|
|
1218
|
-
}
|
|
1219
1670
|
try:
|
|
1220
|
-
|
|
1671
|
+
return _process_provider.spawn_structured_argv(
|
|
1672
|
+
argv,
|
|
1673
|
+
cwd=cwd,
|
|
1674
|
+
environment={
|
|
1675
|
+
"PYTHONUTF8": "1",
|
|
1676
|
+
"PYTHONIOENCODING": "utf-8",
|
|
1677
|
+
},
|
|
1678
|
+
owner_token=owner_token,
|
|
1679
|
+
stdout=log_handle,
|
|
1680
|
+
stderr=subprocess.STDOUT,
|
|
1681
|
+
)
|
|
1221
1682
|
finally:
|
|
1222
1683
|
try:
|
|
1223
1684
|
log_handle.close()
|
|
1224
1685
|
except OSError:
|
|
1225
1686
|
pass
|
|
1226
1687
|
|
|
1227
|
-
return int(proc.pid)
|
|
1228
|
-
|
|
1229
1688
|
|
|
1230
1689
|
def build_session(
|
|
1231
1690
|
*,
|
|
@@ -1234,6 +1693,9 @@ def build_session(
|
|
|
1234
1693
|
module_inputs_files: list[str],
|
|
1235
1694
|
command: str,
|
|
1236
1695
|
service_start: dict[str, Any],
|
|
1696
|
+
argv: list[str] | None = None,
|
|
1697
|
+
process_attestation: dict[str, Any] | None = None,
|
|
1698
|
+
ownership_proof: dict[str, Any] | None = None,
|
|
1237
1699
|
started_at: str | None = None,
|
|
1238
1700
|
worktree_root: Path | None = None,
|
|
1239
1701
|
execution_root: Path | None = None,
|
|
@@ -1261,29 +1723,66 @@ def build_session(
|
|
|
1261
1723
|
continue
|
|
1262
1724
|
if 0 < candidate < 65536:
|
|
1263
1725
|
owned_ports.add(candidate)
|
|
1726
|
+
resolved_argv = list(argv or [])
|
|
1727
|
+
argv_hash = (
|
|
1728
|
+
_process_provider.canonical_argv_hash(resolved_argv)
|
|
1729
|
+
if resolved_argv
|
|
1730
|
+
else sha256_text(command)
|
|
1731
|
+
)
|
|
1732
|
+
attestation = (
|
|
1733
|
+
json.loads(json.dumps(process_attestation))
|
|
1734
|
+
if isinstance(process_attestation, dict)
|
|
1735
|
+
else None
|
|
1736
|
+
)
|
|
1737
|
+
process_identity = {
|
|
1738
|
+
"executable": str(executable),
|
|
1739
|
+
"commandHash": argv_hash,
|
|
1740
|
+
"argvHash": argv_hash,
|
|
1741
|
+
"startedAt": effective_started_at,
|
|
1742
|
+
"parentChain": [
|
|
1743
|
+
{
|
|
1744
|
+
"pid": os.getpid(),
|
|
1745
|
+
"executable": str(Path(sys.executable).resolve()),
|
|
1746
|
+
}
|
|
1747
|
+
],
|
|
1748
|
+
}
|
|
1749
|
+
if attestation is not None:
|
|
1750
|
+
process_identity.update(attestation)
|
|
1751
|
+
# Preserve the service's public legacy field names while keeping the
|
|
1752
|
+
# provider attestation as the source of truth for destructive actions.
|
|
1753
|
+
process_identity["commandHash"] = argv_hash
|
|
1754
|
+
process_identity["argvHash"] = attestation.get("argvHash", argv_hash)
|
|
1264
1755
|
session = {
|
|
1756
|
+
"schemaVersion": 1,
|
|
1757
|
+
"serviceId": str(
|
|
1758
|
+
service_start.get("serviceId")
|
|
1759
|
+
or change_id
|
|
1760
|
+
or profile_name
|
|
1761
|
+
),
|
|
1762
|
+
"sessionId": "service-" + uuid.uuid4().hex,
|
|
1763
|
+
"status": "STARTING",
|
|
1764
|
+
"reasonCode": "SERVICE_START_REQUESTED",
|
|
1265
1765
|
"pid": pid,
|
|
1266
1766
|
"startedBy": "AI",
|
|
1267
1767
|
"moduleInputsHash": module_inputs_hash,
|
|
1268
1768
|
"moduleInputsFiles": module_inputs_files,
|
|
1269
1769
|
"profile": profile_name,
|
|
1270
|
-
"startCommandHash":
|
|
1770
|
+
"startCommandHash": argv_hash,
|
|
1271
1771
|
"overlayPath": overlay,
|
|
1272
1772
|
"startedAt": effective_started_at,
|
|
1273
1773
|
"command": command,
|
|
1774
|
+
"argv": resolved_argv,
|
|
1274
1775
|
"ownedPorts": sorted(owned_ports),
|
|
1275
|
-
"processIdentity":
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
{
|
|
1281
|
-
"pid": os.getpid(),
|
|
1282
|
-
"executable": str(Path(sys.executable).resolve()),
|
|
1283
|
-
}
|
|
1284
|
-
],
|
|
1285
|
-
},
|
|
1776
|
+
"processIdentity": process_identity,
|
|
1777
|
+
"fingerprint": module_inputs_hash,
|
|
1778
|
+
"cleanupComplete": False,
|
|
1779
|
+
"supersedesSessionId": None,
|
|
1780
|
+
"leaseIdentity": None,
|
|
1286
1781
|
}
|
|
1782
|
+
if attestation is not None:
|
|
1783
|
+
session["processAttestation"] = attestation
|
|
1784
|
+
if isinstance(ownership_proof, dict):
|
|
1785
|
+
session["ownershipProof"] = json.loads(json.dumps(ownership_proof))
|
|
1287
1786
|
if isinstance(service_pid, int) and service_pid > 0:
|
|
1288
1787
|
session["servicePid"] = service_pid
|
|
1289
1788
|
if job_id:
|
|
@@ -1291,6 +1790,32 @@ def build_session(
|
|
|
1291
1790
|
if isinstance(service_start.get("leasedPort"), int):
|
|
1292
1791
|
session["leasedPort"] = service_start["leasedPort"]
|
|
1293
1792
|
session["leaseOwner"] = service_start.get("leaseOwner")
|
|
1793
|
+
lease_id = service_start.get("leaseId")
|
|
1794
|
+
lease_change_id = service_start.get("leaseChangeId") or change_id
|
|
1795
|
+
lease_run_id = service_start.get("runId") or attempt_id
|
|
1796
|
+
lease_expires_at = service_start.get("leaseExpiresAt")
|
|
1797
|
+
lease_generation = service_start.get("leaseGeneration")
|
|
1798
|
+
if (
|
|
1799
|
+
isinstance(lease_id, str)
|
|
1800
|
+
and lease_id
|
|
1801
|
+
and isinstance(lease_change_id, str)
|
|
1802
|
+
and lease_change_id
|
|
1803
|
+
and isinstance(lease_run_id, str)
|
|
1804
|
+
and lease_run_id
|
|
1805
|
+
and isinstance(lease_expires_at, str)
|
|
1806
|
+
and lease_expires_at
|
|
1807
|
+
):
|
|
1808
|
+
session["leaseIdentity"] = {
|
|
1809
|
+
"leaseId": lease_id,
|
|
1810
|
+
"changeId": lease_change_id,
|
|
1811
|
+
"runId": lease_run_id,
|
|
1812
|
+
"expiresAt": lease_expires_at,
|
|
1813
|
+
"generation": _normalize_service_generation(lease_generation),
|
|
1814
|
+
}
|
|
1815
|
+
if isinstance(service_start.get("listenerIdentity"), dict):
|
|
1816
|
+
session["leaseIdentity"]["listenerIdentity"] = json.loads(
|
|
1817
|
+
json.dumps(service_start["listenerIdentity"], ensure_ascii=False)
|
|
1818
|
+
)
|
|
1294
1819
|
if worktree_root is not None:
|
|
1295
1820
|
session["worktreeRoot"] = str(worktree_root.resolve())
|
|
1296
1821
|
if execution_root is not None:
|
|
@@ -1392,13 +1917,31 @@ def stop_ai_session(
|
|
|
1392
1917
|
require_identity: bool = True,
|
|
1393
1918
|
) -> dict[str, Any]:
|
|
1394
1919
|
"""Stop verified AI session process and clear session file."""
|
|
1920
|
+
operation_id = _service_operation_id(session)
|
|
1921
|
+
_ensure_session_state(session, operation_id=operation_id)
|
|
1395
1922
|
pid = session.get("pid")
|
|
1396
1923
|
if require_identity:
|
|
1397
1924
|
identity = verify_process_identity(session)
|
|
1398
1925
|
if identity is not True:
|
|
1926
|
+
_transition(
|
|
1927
|
+
session,
|
|
1928
|
+
"STALE_IDENTITY_MISMATCH" if identity is False else "FAILED",
|
|
1929
|
+
reason_code=(
|
|
1930
|
+
"STALE_IDENTITY_MISMATCH"
|
|
1931
|
+
if identity is False
|
|
1932
|
+
else "IDENTITY_UNVERIFIABLE"
|
|
1933
|
+
),
|
|
1934
|
+
operation_id=operation_id,
|
|
1935
|
+
)
|
|
1936
|
+
write_session(change_dir, session)
|
|
1399
1937
|
return {
|
|
1400
1938
|
"ok": True,
|
|
1401
1939
|
"action": "needs-user-decision",
|
|
1940
|
+
"reasonCode": (
|
|
1941
|
+
"PROCESS_IDENTITY_MISMATCH"
|
|
1942
|
+
if identity is False
|
|
1943
|
+
else "IDENTITY_UNVERIFIABLE"
|
|
1944
|
+
),
|
|
1402
1945
|
"reason": (
|
|
1403
1946
|
"cannot-verify-process-identity"
|
|
1404
1947
|
if identity is None
|
|
@@ -1407,13 +1950,73 @@ def stop_ai_session(
|
|
|
1407
1950
|
"pid": pid,
|
|
1408
1951
|
"killed": False,
|
|
1409
1952
|
}
|
|
1953
|
+
stopping_transition = _transition(
|
|
1954
|
+
session,
|
|
1955
|
+
"STOPPING",
|
|
1956
|
+
reason_code="SERVICE_STOPPING",
|
|
1957
|
+
operation_id=operation_id,
|
|
1958
|
+
)
|
|
1959
|
+
if not stopping_transition.get("ok"):
|
|
1960
|
+
return {
|
|
1961
|
+
"ok": False,
|
|
1962
|
+
"code": "SERVICE_TRANSITION_CONFLICT",
|
|
1963
|
+
"reasonCode": "SERVICE_TRANSITION_CONFLICT",
|
|
1964
|
+
"action": "stop-refused",
|
|
1965
|
+
"from": stopping_transition.get("from"),
|
|
1966
|
+
"to": stopping_transition.get("to"),
|
|
1967
|
+
"operationId": operation_id,
|
|
1968
|
+
"killed": False,
|
|
1969
|
+
"sessionCleared": False,
|
|
1970
|
+
}
|
|
1971
|
+
write_session(change_dir, session)
|
|
1972
|
+
termination: dict[str, Any] | None = None
|
|
1410
1973
|
if isinstance(pid, int) and is_pid_alive(pid):
|
|
1411
|
-
terminate_process_tree(
|
|
1974
|
+
termination = terminate_process_tree(
|
|
1975
|
+
pid,
|
|
1976
|
+
expected_attestation=(
|
|
1977
|
+
session.get("processAttestation")
|
|
1978
|
+
if isinstance(session.get("processAttestation"), dict)
|
|
1979
|
+
else None
|
|
1980
|
+
),
|
|
1981
|
+
ownership_proof=(
|
|
1982
|
+
session.get("ownershipProof")
|
|
1983
|
+
if isinstance(session.get("ownershipProof"), dict)
|
|
1984
|
+
else None
|
|
1985
|
+
),
|
|
1986
|
+
timeout_policy={"graceSeconds": STOP_CONFIRM_TIMEOUT_SEC},
|
|
1987
|
+
)
|
|
1988
|
+
if (
|
|
1989
|
+
isinstance(termination, dict)
|
|
1990
|
+
and termination.get("reasonCode") == "IDENTITY_UNVERIFIABLE"
|
|
1991
|
+
):
|
|
1992
|
+
_transition(
|
|
1993
|
+
session,
|
|
1994
|
+
"STALE_IDENTITY_MISMATCH",
|
|
1995
|
+
reason_code="STALE_IDENTITY_MISMATCH",
|
|
1996
|
+
operation_id=operation_id,
|
|
1997
|
+
)
|
|
1998
|
+
write_session(change_dir, session)
|
|
1999
|
+
return {
|
|
2000
|
+
"ok": True,
|
|
2001
|
+
"action": "needs-user-decision",
|
|
2002
|
+
"reasonCode": "IDENTITY_UNVERIFIABLE",
|
|
2003
|
+
"reason": "ownership-proof-unavailable",
|
|
2004
|
+
"pid": pid,
|
|
2005
|
+
"killed": False,
|
|
2006
|
+
}
|
|
1412
2007
|
confirmation = _confirm_service_shutdown(session)
|
|
1413
2008
|
if confirmation["alivePids"]:
|
|
2009
|
+
_transition(
|
|
2010
|
+
session,
|
|
2011
|
+
"FAILED",
|
|
2012
|
+
reason_code="PROCESS_TREE_EXIT_UNCONFIRMED",
|
|
2013
|
+
operation_id=operation_id,
|
|
2014
|
+
)
|
|
2015
|
+
write_session(change_dir, session)
|
|
1414
2016
|
return {
|
|
1415
2017
|
"ok": False,
|
|
1416
2018
|
"code": "PROCESS_TREE_EXIT_UNCONFIRMED",
|
|
2019
|
+
"reasonCode": "SERVICE_STOP_FAILED",
|
|
1417
2020
|
"action": "cleanup-unconfirmed",
|
|
1418
2021
|
"pid": pid,
|
|
1419
2022
|
"alivePids": confirmation["alivePids"],
|
|
@@ -1422,9 +2025,17 @@ def stop_ai_session(
|
|
|
1422
2025
|
"sessionCleared": False,
|
|
1423
2026
|
}
|
|
1424
2027
|
if confirmation["occupiedPorts"]:
|
|
2028
|
+
_transition(
|
|
2029
|
+
session,
|
|
2030
|
+
"FAILED",
|
|
2031
|
+
reason_code="OWNED_PORT_RELEASE_UNCONFIRMED",
|
|
2032
|
+
operation_id=operation_id,
|
|
2033
|
+
)
|
|
2034
|
+
write_session(change_dir, session)
|
|
1425
2035
|
return {
|
|
1426
2036
|
"ok": False,
|
|
1427
2037
|
"code": "OWNED_PORT_RELEASE_UNCONFIRMED",
|
|
2038
|
+
"reasonCode": "SERVICE_CLEANUP_INCOMPLETE",
|
|
1428
2039
|
"action": "cleanup-unconfirmed",
|
|
1429
2040
|
"pid": pid,
|
|
1430
2041
|
"alivePids": [],
|
|
@@ -1432,11 +2043,51 @@ def stop_ai_session(
|
|
|
1432
2043
|
"killed": True,
|
|
1433
2044
|
"sessionCleared": False,
|
|
1434
2045
|
}
|
|
2046
|
+
if os.name == "nt" and not _wait_for_windows_launcher_exit(change_dir):
|
|
2047
|
+
_transition(
|
|
2048
|
+
session,
|
|
2049
|
+
"FAILED",
|
|
2050
|
+
reason_code="SERVICE_STOP_TIMEOUT",
|
|
2051
|
+
operation_id=operation_id,
|
|
2052
|
+
)
|
|
2053
|
+
write_session(change_dir, session)
|
|
2054
|
+
return {
|
|
2055
|
+
"ok": False,
|
|
2056
|
+
"code": "SERVICE_STOP_TIMEOUT",
|
|
2057
|
+
"reasonCode": "SERVICE_STOP_TIMEOUT",
|
|
2058
|
+
"action": "cleanup-unconfirmed",
|
|
2059
|
+
"pid": pid,
|
|
2060
|
+
"alivePids": [],
|
|
2061
|
+
"occupiedPorts": [],
|
|
2062
|
+
"launcherStillAlive": True,
|
|
2063
|
+
"killed": True,
|
|
2064
|
+
"sessionCleared": False,
|
|
2065
|
+
}
|
|
2066
|
+
stopped_transition = _transition(
|
|
2067
|
+
session,
|
|
2068
|
+
"STOPPED",
|
|
2069
|
+
reason_code="SERVICE_STOP_CONFIRMED",
|
|
2070
|
+
operation_id=operation_id,
|
|
2071
|
+
)
|
|
2072
|
+
if not stopped_transition.get("ok"):
|
|
2073
|
+
write_session(change_dir, session)
|
|
2074
|
+
return {
|
|
2075
|
+
"ok": False,
|
|
2076
|
+
"code": "SERVICE_TRANSITION_CONFLICT",
|
|
2077
|
+
"reasonCode": "SERVICE_TRANSITION_CONFLICT",
|
|
2078
|
+
"action": "cleanup-unconfirmed",
|
|
2079
|
+
"pid": pid,
|
|
2080
|
+
"killed": True,
|
|
2081
|
+
"sessionCleared": False,
|
|
2082
|
+
"alivePids": [],
|
|
2083
|
+
"occupiedPorts": [],
|
|
2084
|
+
}
|
|
1435
2085
|
_remove_windows_service_metadata(change_dir)
|
|
1436
2086
|
clear_session(change_dir)
|
|
1437
2087
|
return {
|
|
1438
2088
|
"ok": True,
|
|
1439
2089
|
"code": "SERVICE_STOP_CONFIRMED",
|
|
2090
|
+
"reasonCode": "SERVICE_STOPPED",
|
|
1440
2091
|
"action": "stopped",
|
|
1441
2092
|
"pid": pid,
|
|
1442
2093
|
"killed": True,
|
|
@@ -1452,6 +2103,22 @@ def stop_ai_session(
|
|
|
1452
2103
|
|
|
1453
2104
|
|
|
1454
2105
|
def cmd_ensure(args: argparse.Namespace) -> int:
|
|
2106
|
+
"""Serialize ensure mutations and expose a stable conflict receipt."""
|
|
2107
|
+
|
|
2108
|
+
change_dir = resolve_path(args.change_dir)
|
|
2109
|
+
lock = ServiceMutationLock(change_dir, operation="ensure")
|
|
2110
|
+
conflict = lock.acquire()
|
|
2111
|
+
if conflict is not None:
|
|
2112
|
+
emit_json(conflict, as_json=bool(args.json))
|
|
2113
|
+
return 4
|
|
2114
|
+
setattr(args, "_service_operation_id", lock.token)
|
|
2115
|
+
try:
|
|
2116
|
+
return _cmd_ensure_impl(args)
|
|
2117
|
+
finally:
|
|
2118
|
+
lock.release()
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def _cmd_ensure_impl(args: argparse.Namespace) -> int:
|
|
1455
2122
|
as_json = bool(args.json)
|
|
1456
2123
|
change_dir = resolve_path(args.change_dir)
|
|
1457
2124
|
project = resolve_path(args.project)
|
|
@@ -1490,7 +2157,13 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1490
2157
|
except ValueError as exc:
|
|
1491
2158
|
return emit_error(str(exc), as_json=as_json)
|
|
1492
2159
|
|
|
1493
|
-
|
|
2160
|
+
argv = list(service_start.get("argv") or [])
|
|
2161
|
+
if not argv:
|
|
2162
|
+
return emit_error(
|
|
2163
|
+
"serviceStart resolved to an empty argv; run profile migration and review",
|
|
2164
|
+
as_json=as_json,
|
|
2165
|
+
)
|
|
2166
|
+
command = str(service_start.get("command") or " ".join(argv)).strip()
|
|
1494
2167
|
port = extract_port(service_start)
|
|
1495
2168
|
|
|
1496
2169
|
# Resolve service input file set (CLI --files ∪ serviceStart.inputFiles).
|
|
@@ -1525,6 +2198,11 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1525
2198
|
),
|
|
1526
2199
|
as_json=as_json,
|
|
1527
2200
|
pid=pid,
|
|
2201
|
+
reasonCode=(
|
|
2202
|
+
"IDENTITY_UNVERIFIABLE"
|
|
2203
|
+
if identity is None
|
|
2204
|
+
else "PROCESS_IDENTITY_MISMATCH"
|
|
2205
|
+
),
|
|
1528
2206
|
sessionPath=str(session_path(change_dir)),
|
|
1529
2207
|
detail=(
|
|
1530
2208
|
"pid is alive but create-time/cmdline identity could not be "
|
|
@@ -1541,9 +2219,32 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1541
2219
|
# §5.3: reuse must compare inputsHash + startCommandHash + profile
|
|
1542
2220
|
# + overlayPath (process identity already verified above). Any
|
|
1543
2221
|
# change -> restart; never reuse on a partial match.
|
|
1544
|
-
current_cmd_hash =
|
|
2222
|
+
current_cmd_hash = _process_provider.canonical_argv_hash(argv)
|
|
1545
2223
|
current_profile = service_start.get("profile") or "local-dev"
|
|
1546
2224
|
current_overlay = service_start.get("overlayPath") or ""
|
|
2225
|
+
health_match = probe_health(
|
|
2226
|
+
service_start,
|
|
2227
|
+
expected_instance_token=(
|
|
2228
|
+
str(service_start.get("instanceToken"))
|
|
2229
|
+
if service_start.get("requireInstanceToken") is True
|
|
2230
|
+
and service_start.get("instanceToken")
|
|
2231
|
+
else None
|
|
2232
|
+
),
|
|
2233
|
+
)
|
|
2234
|
+
configured_token = service_start.get("instanceToken")
|
|
2235
|
+
if (
|
|
2236
|
+
service_start.get("requireInstanceToken") is True
|
|
2237
|
+
and isinstance(configured_token, str)
|
|
2238
|
+
and session.get("instanceTokenHash") is not None
|
|
2239
|
+
and session.get("instanceTokenHash") != sha256_text(configured_token)
|
|
2240
|
+
):
|
|
2241
|
+
health_match = False
|
|
2242
|
+
if (
|
|
2243
|
+
health_match
|
|
2244
|
+
and service_start.get("requireInstanceToken") is not True
|
|
2245
|
+
and extract_port(service_start) is not None
|
|
2246
|
+
):
|
|
2247
|
+
health_match = _listener_identity_is_owned(session, service_start)
|
|
1547
2248
|
fingerprint_match = (
|
|
1548
2249
|
stored_hash == current_hash
|
|
1549
2250
|
and session.get("startCommandHash") == current_cmd_hash
|
|
@@ -1551,8 +2252,23 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1551
2252
|
and session.get("overlayPath") == current_overlay
|
|
1552
2253
|
and session.get("leasedPort") == service_start.get("leasedPort")
|
|
1553
2254
|
and session.get("leaseOwner") == service_start.get("leaseOwner")
|
|
2255
|
+
and health_match
|
|
1554
2256
|
)
|
|
1555
2257
|
if fingerprint_match:
|
|
2258
|
+
operation_id = str(
|
|
2259
|
+
getattr(args, "_service_operation_id", "") or uuid.uuid4().hex
|
|
2260
|
+
)
|
|
2261
|
+
_ensure_session_state(
|
|
2262
|
+
session,
|
|
2263
|
+
operation_id=operation_id,
|
|
2264
|
+
)
|
|
2265
|
+
_transition(
|
|
2266
|
+
session,
|
|
2267
|
+
"READY",
|
|
2268
|
+
reason_code="SERVICE_REUSED",
|
|
2269
|
+
operation_id=operation_id,
|
|
2270
|
+
)
|
|
2271
|
+
write_session(change_dir, session)
|
|
1556
2272
|
payload = {
|
|
1557
2273
|
"ok": True,
|
|
1558
2274
|
"action": "reused",
|
|
@@ -1578,6 +2294,7 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1578
2294
|
project=project,
|
|
1579
2295
|
service_start=service_start,
|
|
1580
2296
|
command=command,
|
|
2297
|
+
argv=argv,
|
|
1581
2298
|
files=files if files else current_files,
|
|
1582
2299
|
as_json=as_json,
|
|
1583
2300
|
action="restarted",
|
|
@@ -1586,6 +2303,7 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1586
2303
|
execution_root=execution_root,
|
|
1587
2304
|
change_id=change_name,
|
|
1588
2305
|
attempt_id=attempt_id,
|
|
2306
|
+
operation_id=str(getattr(args, "_service_operation_id", "") or uuid.uuid4().hex),
|
|
1589
2307
|
)
|
|
1590
2308
|
|
|
1591
2309
|
# pid dead → stale session; clear and fall through
|
|
@@ -1609,6 +2327,7 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1609
2327
|
project=project,
|
|
1610
2328
|
service_start=service_start,
|
|
1611
2329
|
command=command,
|
|
2330
|
+
argv=argv,
|
|
1612
2331
|
files=files,
|
|
1613
2332
|
as_json=as_json,
|
|
1614
2333
|
action="started",
|
|
@@ -1616,6 +2335,7 @@ def cmd_ensure(args: argparse.Namespace) -> int:
|
|
|
1616
2335
|
execution_root=execution_root,
|
|
1617
2336
|
change_id=change_name,
|
|
1618
2337
|
attempt_id=attempt_id,
|
|
2338
|
+
operation_id=str(getattr(args, "_service_operation_id", "") or uuid.uuid4().hex),
|
|
1619
2339
|
)
|
|
1620
2340
|
|
|
1621
2341
|
|
|
@@ -1625,6 +2345,7 @@ def _start_and_record(
|
|
|
1625
2345
|
project: Path,
|
|
1626
2346
|
service_start: dict[str, Any],
|
|
1627
2347
|
command: str,
|
|
2348
|
+
argv: list[str],
|
|
1628
2349
|
files: list[str],
|
|
1629
2350
|
as_json: bool,
|
|
1630
2351
|
action: str,
|
|
@@ -1632,6 +2353,7 @@ def _start_and_record(
|
|
|
1632
2353
|
execution_root: Path | None = None,
|
|
1633
2354
|
change_id: str | None = None,
|
|
1634
2355
|
attempt_id: str | None = None,
|
|
2356
|
+
operation_id: str | None = None,
|
|
1635
2357
|
**extra: Any,
|
|
1636
2358
|
) -> int:
|
|
1637
2359
|
if not files:
|
|
@@ -1651,23 +2373,69 @@ def _start_and_record(
|
|
|
1651
2373
|
_clear_file_health_markers(service_start)
|
|
1652
2374
|
|
|
1653
2375
|
started_at = now_iso()
|
|
2376
|
+
operation_id = operation_id or uuid.uuid4().hex
|
|
2377
|
+
generation = _allocate_service_generation(change_dir)
|
|
2378
|
+
mutation_state = {
|
|
2379
|
+
"schemaVersion": 1,
|
|
2380
|
+
"status": "STARTING",
|
|
2381
|
+
"reasonCode": "SERVICE_START_REQUESTED",
|
|
2382
|
+
"serviceGeneration": generation,
|
|
2383
|
+
"stateRevision": 1,
|
|
2384
|
+
"operationId": operation_id,
|
|
2385
|
+
"startedAt": started_at,
|
|
2386
|
+
"argvHash": _process_provider.canonical_argv_hash(argv),
|
|
2387
|
+
}
|
|
2388
|
+
write_json(mutation_state_path(change_dir), mutation_state)
|
|
2389
|
+
spawned: _process_provider.SpawnedProcess
|
|
1654
2390
|
try:
|
|
1655
|
-
|
|
1656
|
-
|
|
2391
|
+
spawned = start_detached_service(argv, change_dir=change_dir, cwd=project)
|
|
2392
|
+
pid = int(spawned.attestation.get("pid") or spawned.process.pid)
|
|
2393
|
+
except (OSError, TimeoutError, ValueError, RuntimeError) as exc:
|
|
1657
2394
|
_cleanup_windows_launcher(change_dir)
|
|
2395
|
+
mutation_state.update(
|
|
2396
|
+
{
|
|
2397
|
+
"status": "FAILED",
|
|
2398
|
+
"reasonCode": "SERVICE_START_FAILED",
|
|
2399
|
+
"endedAt": now_iso(),
|
|
2400
|
+
}
|
|
2401
|
+
)
|
|
2402
|
+
write_json(mutation_state_path(change_dir), mutation_state)
|
|
1658
2403
|
return emit_error(f"failed to start service: {exc}", as_json=as_json)
|
|
1659
2404
|
|
|
1660
2405
|
# Give the OS a moment to register the process before identity/create-time reads
|
|
1661
2406
|
time.sleep(0.15)
|
|
1662
2407
|
|
|
1663
2408
|
try:
|
|
1664
|
-
wait_for_healthy(
|
|
2409
|
+
wait_for_healthy(
|
|
2410
|
+
service_start,
|
|
2411
|
+
log_path(change_dir),
|
|
2412
|
+
pid=pid,
|
|
2413
|
+
expected_instance_token=(
|
|
2414
|
+
str(service_start.get("instanceToken"))
|
|
2415
|
+
if service_start.get("requireInstanceToken") is True
|
|
2416
|
+
and service_start.get("instanceToken")
|
|
2417
|
+
else None
|
|
2418
|
+
),
|
|
2419
|
+
)
|
|
1665
2420
|
except ServiceStartError as exc:
|
|
1666
2421
|
# Best-effort cleanup of the failed start
|
|
1667
2422
|
if is_pid_alive(pid):
|
|
1668
|
-
|
|
2423
|
+
_capture_owned_service_members(spawned)
|
|
2424
|
+
terminate_process_tree(
|
|
2425
|
+
pid,
|
|
2426
|
+
expected_attestation=spawned.attestation,
|
|
2427
|
+
ownership_proof=spawned.ownershipProof,
|
|
2428
|
+
)
|
|
1669
2429
|
_cleanup_windows_launcher(change_dir)
|
|
1670
2430
|
clear_session(change_dir)
|
|
2431
|
+
mutation_state.update(
|
|
2432
|
+
{
|
|
2433
|
+
"status": "FAILED",
|
|
2434
|
+
"reasonCode": "SERVICE_START_FAILED",
|
|
2435
|
+
"endedAt": now_iso(),
|
|
2436
|
+
}
|
|
2437
|
+
)
|
|
2438
|
+
write_json(mutation_state_path(change_dir), mutation_state)
|
|
1671
2439
|
return emit_error(
|
|
1672
2440
|
str(exc),
|
|
1673
2441
|
as_json=as_json,
|
|
@@ -1676,6 +2444,7 @@ def _start_and_record(
|
|
|
1676
2444
|
fatalKeyword=exc.fatal_keyword,
|
|
1677
2445
|
)
|
|
1678
2446
|
|
|
2447
|
+
_capture_owned_service_members(spawned)
|
|
1679
2448
|
session = build_session(
|
|
1680
2449
|
pid=pid,
|
|
1681
2450
|
module_inputs_hash=module_hash,
|
|
@@ -1697,8 +2466,35 @@ def _start_and_record(
|
|
|
1697
2466
|
if os.name == "nt"
|
|
1698
2467
|
else None
|
|
1699
2468
|
),
|
|
2469
|
+
process_attestation=spawned.attestation,
|
|
2470
|
+
ownership_proof=spawned.ownershipProof,
|
|
2471
|
+
argv=argv,
|
|
2472
|
+
)
|
|
2473
|
+
_ensure_session_state(
|
|
2474
|
+
session,
|
|
2475
|
+
operation_id=operation_id,
|
|
2476
|
+
generation=generation,
|
|
2477
|
+
)
|
|
2478
|
+
if isinstance(service_start.get("instanceToken"), str):
|
|
2479
|
+
session["instanceTokenHash"] = sha256_text(
|
|
2480
|
+
service_start["instanceToken"]
|
|
2481
|
+
)
|
|
2482
|
+
_transition(
|
|
2483
|
+
session,
|
|
2484
|
+
"READY",
|
|
2485
|
+
reason_code="SERVICE_READY",
|
|
2486
|
+
operation_id=operation_id,
|
|
1700
2487
|
)
|
|
1701
2488
|
write_session(change_dir, session)
|
|
2489
|
+
mutation_state.update(
|
|
2490
|
+
{
|
|
2491
|
+
"status": "READY",
|
|
2492
|
+
"reasonCode": "SERVICE_READY",
|
|
2493
|
+
"stateRevision": session.get("stateRevision"),
|
|
2494
|
+
"endedAt": now_iso(),
|
|
2495
|
+
}
|
|
2496
|
+
)
|
|
2497
|
+
write_json(mutation_state_path(change_dir), mutation_state)
|
|
1702
2498
|
|
|
1703
2499
|
payload: dict[str, Any] = {
|
|
1704
2500
|
"ok": True,
|
|
@@ -1715,6 +2511,16 @@ def _start_and_record(
|
|
|
1715
2511
|
return 0
|
|
1716
2512
|
|
|
1717
2513
|
|
|
2514
|
+
def _capture_owned_service_members(
|
|
2515
|
+
spawned: _process_provider.SpawnedProcess,
|
|
2516
|
+
) -> None:
|
|
2517
|
+
"""Materialize the provider's current owned members before persistence."""
|
|
2518
|
+
proof = spawned.ownershipProof
|
|
2519
|
+
if not isinstance(proof, dict):
|
|
2520
|
+
return
|
|
2521
|
+
_process_provider.capture_owned_members(spawned)
|
|
2522
|
+
|
|
2523
|
+
|
|
1718
2524
|
def _clear_file_health_markers(service_start: dict[str, Any]) -> None:
|
|
1719
2525
|
candidates: list[Path] = []
|
|
1720
2526
|
hf = service_start.get("healthFile")
|
|
@@ -1770,6 +2576,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
1770
2576
|
pid = session.get("pid")
|
|
1771
2577
|
alive = isinstance(pid, int) and is_pid_alive(pid)
|
|
1772
2578
|
identity = verify_process_identity(session) if alive else False
|
|
2579
|
+
heartbeat = _heartbeat_status(session)
|
|
1773
2580
|
|
|
1774
2581
|
fingerprint_match: bool | None = None
|
|
1775
2582
|
current_hash: str | None = None
|
|
@@ -1793,15 +2600,38 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
1793
2600
|
"identityStatus": (
|
|
1794
2601
|
"verified" if identity is True else ("unknown" if identity is None else "mismatch")
|
|
1795
2602
|
),
|
|
2603
|
+
"reasonCode": (
|
|
2604
|
+
"SERVICE_READY"
|
|
2605
|
+
if identity is True
|
|
2606
|
+
else ("IDENTITY_UNVERIFIABLE" if identity is None else "PROCESS_IDENTITY_MISMATCH")
|
|
2607
|
+
),
|
|
1796
2608
|
"fingerprintMatch": fingerprint_match,
|
|
1797
2609
|
"currentModuleInputsHash": current_hash,
|
|
1798
2610
|
"startedBy": session.get("startedBy"),
|
|
2611
|
+
"serviceGeneration": session.get("serviceGeneration"),
|
|
2612
|
+
"stateRevision": session.get("stateRevision"),
|
|
2613
|
+
"serviceStatus": session.get("status"),
|
|
2614
|
+
"heartbeat": heartbeat,
|
|
1799
2615
|
}
|
|
1800
2616
|
emit_json(payload, as_json=as_json)
|
|
1801
2617
|
return 0
|
|
1802
2618
|
|
|
1803
2619
|
|
|
1804
2620
|
def cmd_stop(args: argparse.Namespace) -> int:
|
|
2621
|
+
change_dir = resolve_path(args.change_dir)
|
|
2622
|
+
lock = ServiceMutationLock(change_dir, operation="stop")
|
|
2623
|
+
conflict = lock.acquire()
|
|
2624
|
+
if conflict is not None:
|
|
2625
|
+
emit_json(conflict, as_json=bool(args.json))
|
|
2626
|
+
return 4
|
|
2627
|
+
setattr(args, "_service_operation_id", lock.token)
|
|
2628
|
+
try:
|
|
2629
|
+
return _cmd_stop_impl(args)
|
|
2630
|
+
finally:
|
|
2631
|
+
lock.release()
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def _cmd_stop_impl(args: argparse.Namespace) -> int:
|
|
1805
2635
|
as_json = bool(args.json)
|
|
1806
2636
|
change_dir = resolve_path(args.change_dir)
|
|
1807
2637
|
if_started_by_ai = bool(getattr(args, "if_started_by_ai", False))
|
|
@@ -1856,12 +2686,128 @@ def cmd_stop(args: argparse.Namespace) -> int:
|
|
|
1856
2686
|
|
|
1857
2687
|
|
|
1858
2688
|
def retire_stale_session(change_dir: Path) -> dict[str, Any]:
|
|
1859
|
-
|
|
2689
|
+
change_dir = resolve_path(change_dir)
|
|
2690
|
+
lock = ServiceMutationLock(change_dir, operation="retire-stale")
|
|
2691
|
+
conflict = lock.acquire()
|
|
2692
|
+
if conflict is not None:
|
|
2693
|
+
return conflict
|
|
2694
|
+
try:
|
|
2695
|
+
return _retire_stale_session_impl(change_dir, operation_id=lock.token)
|
|
2696
|
+
finally:
|
|
2697
|
+
lock.release()
|
|
1860
2698
|
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
2699
|
+
|
|
2700
|
+
def _project_root_for_change(change_dir: Path) -> Path | None:
|
|
2701
|
+
resolved = resolve_path(change_dir)
|
|
2702
|
+
for candidate in (resolved, *resolved.parents):
|
|
2703
|
+
if candidate.name == "changes" and candidate.parent.name == ".harness":
|
|
2704
|
+
return candidate.parent.parent
|
|
2705
|
+
if (candidate / ".harness").is_dir():
|
|
2706
|
+
return candidate
|
|
2707
|
+
return None
|
|
2708
|
+
|
|
2709
|
+
|
|
2710
|
+
def _retirement_receipt_digest(receipt: dict[str, Any]) -> str:
|
|
2711
|
+
unsigned = {
|
|
2712
|
+
key: value for key, value in receipt.items() if key != "receiptDigest"
|
|
2713
|
+
}
|
|
2714
|
+
return sha256_text(
|
|
2715
|
+
json.dumps(unsigned, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
2716
|
+
)
|
|
2717
|
+
|
|
2718
|
+
|
|
2719
|
+
def _retirement_lease_cleanup(
|
|
2720
|
+
change_dir: Path,
|
|
2721
|
+
session: dict[str, Any],
|
|
2722
|
+
) -> dict[str, Any]:
|
|
2723
|
+
"""Attempt lease cleanup only with a complete lease/listener identity."""
|
|
2724
|
+
|
|
2725
|
+
lease = session.get("leaseIdentity")
|
|
2726
|
+
if not isinstance(lease, dict):
|
|
2727
|
+
return {
|
|
2728
|
+
"status": "RETAINED",
|
|
2729
|
+
"reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
|
|
2730
|
+
"leaseId": None,
|
|
2731
|
+
}
|
|
2732
|
+
lease_id = lease.get("leaseId")
|
|
2733
|
+
change_id = lease.get("changeId") or session.get("changeId")
|
|
2734
|
+
run_id = lease.get("runId") or session.get("attemptId")
|
|
2735
|
+
port = session.get("leasedPort")
|
|
2736
|
+
if not (
|
|
2737
|
+
isinstance(lease_id, str)
|
|
2738
|
+
and lease_id
|
|
2739
|
+
and isinstance(change_id, str)
|
|
2740
|
+
and change_id
|
|
2741
|
+
and isinstance(run_id, str)
|
|
2742
|
+
and run_id
|
|
2743
|
+
and isinstance(port, int)
|
|
2744
|
+
and 0 < port < 65536
|
|
2745
|
+
):
|
|
2746
|
+
return {
|
|
2747
|
+
"status": "UNVERIFIED",
|
|
2748
|
+
"reasonCode": "LEASE_CAS_MISMATCH",
|
|
2749
|
+
"leaseId": lease_id if isinstance(lease_id, str) else None,
|
|
2750
|
+
}
|
|
2751
|
+
# A lease without a separately persisted listener identity cannot be
|
|
2752
|
+
# released during stale retirement. The old process is precisely the
|
|
2753
|
+
# identity that is no longer trusted, so do not reconstruct it from PID.
|
|
2754
|
+
listener_identity = lease.get("listenerIdentity")
|
|
2755
|
+
if not isinstance(listener_identity, dict):
|
|
2756
|
+
return {
|
|
2757
|
+
"status": "UNVERIFIED",
|
|
2758
|
+
"reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
|
|
2759
|
+
"leaseId": lease_id,
|
|
2760
|
+
}
|
|
2761
|
+
project_root = _project_root_for_change(change_dir)
|
|
2762
|
+
if project_root is None:
|
|
2763
|
+
return {
|
|
2764
|
+
"status": "UNVERIFIED",
|
|
2765
|
+
"reasonCode": "LEASE_CAS_MISMATCH",
|
|
2766
|
+
"leaseId": lease_id,
|
|
2767
|
+
}
|
|
2768
|
+
try:
|
|
2769
|
+
import harness_change
|
|
2770
|
+
|
|
2771
|
+
release = harness_change.release_port(
|
|
2772
|
+
project_root,
|
|
2773
|
+
change_id=change_id,
|
|
2774
|
+
run_id=run_id,
|
|
2775
|
+
port=port,
|
|
2776
|
+
lease_id=lease_id,
|
|
2777
|
+
generation=_normalize_service_generation(lease.get("generation")),
|
|
2778
|
+
listener_identity=listener_identity,
|
|
2779
|
+
)
|
|
2780
|
+
except (ImportError, OSError, TypeError, ValueError) as exc:
|
|
2781
|
+
return {
|
|
2782
|
+
"status": "UNVERIFIED",
|
|
2783
|
+
"reasonCode": "LEASE_CAS_MISMATCH",
|
|
2784
|
+
"leaseId": lease_id,
|
|
2785
|
+
}
|
|
2786
|
+
if release.get("ok"):
|
|
2787
|
+
return {
|
|
2788
|
+
"status": "RELEASED",
|
|
2789
|
+
"reasonCode": "SERVICE_RETIRED",
|
|
2790
|
+
"leaseId": lease_id,
|
|
2791
|
+
}
|
|
2792
|
+
code = str(release.get("code") or "")
|
|
2793
|
+
reason = (
|
|
2794
|
+
"LISTENER_IDENTITY_UNVERIFIABLE"
|
|
2795
|
+
if "LISTENER" in code or code == "IDENTITY_UNVERIFIABLE"
|
|
2796
|
+
else "LEASE_CAS_MISMATCH"
|
|
2797
|
+
)
|
|
2798
|
+
return {
|
|
2799
|
+
"status": "RETAINED" if reason.startswith("LISTENER") else "UNVERIFIED",
|
|
2800
|
+
"reasonCode": reason,
|
|
2801
|
+
"leaseId": lease_id,
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
|
|
2805
|
+
def _retire_stale_session_impl(
|
|
2806
|
+
change_dir: Path,
|
|
2807
|
+
*,
|
|
2808
|
+
operation_id: str,
|
|
2809
|
+
) -> dict[str, Any]:
|
|
2810
|
+
"""Finalize one stale generation without touching an unknown process."""
|
|
1865
2811
|
change_dir = resolve_path(change_dir)
|
|
1866
2812
|
try:
|
|
1867
2813
|
session = load_session(change_dir)
|
|
@@ -1874,6 +2820,25 @@ def retire_stale_session(change_dir: Path) -> dict[str, Any]:
|
|
|
1874
2820
|
"unknownProcessUntouched": True,
|
|
1875
2821
|
}
|
|
1876
2822
|
if session is None:
|
|
2823
|
+
retired_root = change_dir / "runtime" / "retired-service-sessions"
|
|
2824
|
+
for receipt_path in sorted(retired_root.glob("*.receipt.json")):
|
|
2825
|
+
try:
|
|
2826
|
+
existing_receipt = read_json(receipt_path)
|
|
2827
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
2828
|
+
continue
|
|
2829
|
+
if isinstance(existing_receipt, dict) and (
|
|
2830
|
+
existing_receipt.get("state") == "FINALIZED"
|
|
2831
|
+
or existing_receipt.get("action") == "retired-stale"
|
|
2832
|
+
):
|
|
2833
|
+
evidence = retired_root / f"{receipt_path.name[:-len('.receipt.json')]}.json"
|
|
2834
|
+
return {
|
|
2835
|
+
"ok": True,
|
|
2836
|
+
"code": "SERVICE_RETIRED",
|
|
2837
|
+
"action": "already-retired",
|
|
2838
|
+
"receipt": existing_receipt,
|
|
2839
|
+
"retiredEvidence": str(evidence) if evidence.is_file() else None,
|
|
2840
|
+
"unknownProcessUntouched": True,
|
|
2841
|
+
}
|
|
1877
2842
|
return {
|
|
1878
2843
|
"ok": True,
|
|
1879
2844
|
"code": "SERVICE_SESSION_ABSENT",
|
|
@@ -1895,26 +2860,144 @@ def retire_stale_session(change_dir: Path) -> dict[str, Any]:
|
|
|
1895
2860
|
"action": "retire-refused",
|
|
1896
2861
|
"unknownProcessUntouched": True,
|
|
1897
2862
|
}
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2863
|
+
service_id = str(session.get("serviceId") or change_dir.name)
|
|
2864
|
+
old_session_id = str(
|
|
2865
|
+
session.get("sessionId")
|
|
2866
|
+
or "legacy-" + sha256_text(json.dumps(session, sort_keys=True))[7:23]
|
|
2867
|
+
)
|
|
2868
|
+
old_generation = _normalize_service_generation(session.get("serviceGeneration"))
|
|
1903
2869
|
retired_root = change_dir / "runtime" / "retired-service-sessions"
|
|
1904
2870
|
retired_root.mkdir(parents=True, exist_ok=True)
|
|
1905
|
-
|
|
1906
|
-
|
|
2871
|
+
safe_service = re.sub(r"[^A-Za-z0-9._-]+", "-", service_id).strip("-") or "service"
|
|
2872
|
+
receipt_path = retired_root / f"{safe_service}-{old_generation}.receipt.json"
|
|
2873
|
+
if receipt_path.is_file():
|
|
2874
|
+
try:
|
|
2875
|
+
existing_receipt = read_json(receipt_path)
|
|
2876
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
2877
|
+
existing_receipt = None
|
|
2878
|
+
if (
|
|
2879
|
+
isinstance(existing_receipt, dict)
|
|
2880
|
+
and existing_receipt.get("serviceId") == service_id
|
|
2881
|
+
and _normalize_service_generation(existing_receipt.get("oldGeneration"))
|
|
2882
|
+
== old_generation
|
|
2883
|
+
):
|
|
2884
|
+
evidence = existing_receipt.get("retiredEvidence")
|
|
2885
|
+
return {
|
|
2886
|
+
"ok": True,
|
|
2887
|
+
"code": "SERVICE_RETIRED",
|
|
2888
|
+
"action": "already-retired",
|
|
2889
|
+
"receipt": existing_receipt,
|
|
2890
|
+
"retiredEvidence": evidence,
|
|
2891
|
+
"unknownProcessUntouched": True,
|
|
2892
|
+
}
|
|
2893
|
+
pending_path = change_dir / "runtime" / "retirement.pending.json"
|
|
2894
|
+
write_json(
|
|
2895
|
+
pending_path,
|
|
2896
|
+
{
|
|
2897
|
+
"schemaVersion": 1,
|
|
2898
|
+
"serviceId": service_id,
|
|
2899
|
+
"oldSessionId": old_session_id,
|
|
2900
|
+
"oldGeneration": old_generation,
|
|
2901
|
+
"operationId": operation_id,
|
|
2902
|
+
"state": "PENDING",
|
|
2903
|
+
},
|
|
2904
|
+
)
|
|
2905
|
+
lease_cleanup = _retirement_lease_cleanup(change_dir, session)
|
|
2906
|
+
retired_session = json.loads(json.dumps(session, ensure_ascii=False))
|
|
2907
|
+
_ensure_session_state(retired_session, operation_id=operation_id, generation=old_generation)
|
|
2908
|
+
if retired_session.get("status") not in {"STALE_IDENTITY_MISMATCH", "RETIRED"}:
|
|
2909
|
+
_transition(
|
|
2910
|
+
retired_session,
|
|
2911
|
+
"STALE_IDENTITY_MISMATCH",
|
|
2912
|
+
reason_code="SERVICE_IDENTITY_STALE",
|
|
2913
|
+
operation_id=operation_id,
|
|
2914
|
+
)
|
|
2915
|
+
if retired_session.get("status") != "RETIRED":
|
|
2916
|
+
_transition(
|
|
2917
|
+
retired_session,
|
|
2918
|
+
"RETIRED",
|
|
2919
|
+
reason_code="SERVICE_RETIRED",
|
|
2920
|
+
operation_id=operation_id,
|
|
2921
|
+
)
|
|
2922
|
+
retired_session["cleanupComplete"] = lease_cleanup["status"] == "RELEASED"
|
|
2923
|
+
retired_session["supersedesSessionId"] = None
|
|
2924
|
+
source = session_path(change_dir)
|
|
2925
|
+
destination = retired_root / f"{safe_service}-{old_generation}.json"
|
|
2926
|
+
quarantined = False
|
|
2927
|
+
if source.is_file():
|
|
2928
|
+
try:
|
|
2929
|
+
import harness_runtime as _runtime
|
|
2930
|
+
|
|
2931
|
+
sensitive = [
|
|
2932
|
+
item
|
|
2933
|
+
for item in _runtime.sensitive_evidence_candidates(source.parent)
|
|
2934
|
+
if item.get("path") == source.name
|
|
2935
|
+
]
|
|
2936
|
+
if sensitive:
|
|
2937
|
+
quarantine = _runtime.quarantine_sensitive_evidence(
|
|
2938
|
+
source,
|
|
2939
|
+
change_root=change_dir,
|
|
2940
|
+
reason="service retirement legacy evidence",
|
|
2941
|
+
)
|
|
2942
|
+
if not quarantine.get("ok"):
|
|
2943
|
+
return {
|
|
2944
|
+
"ok": False,
|
|
2945
|
+
"code": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
2946
|
+
"action": "retire-refused",
|
|
2947
|
+
"quarantine": quarantine,
|
|
2948
|
+
"unknownProcessUntouched": True,
|
|
2949
|
+
}
|
|
2950
|
+
quarantined = True
|
|
2951
|
+
except (ImportError, OSError, ValueError, TypeError) as exc:
|
|
2952
|
+
return {
|
|
2953
|
+
"ok": False,
|
|
2954
|
+
"code": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
2955
|
+
"action": "retire-refused",
|
|
2956
|
+
"error": str(exc),
|
|
2957
|
+
"unknownProcessUntouched": True,
|
|
2958
|
+
}
|
|
2959
|
+
if source.is_file():
|
|
2960
|
+
os.replace(source, destination)
|
|
2961
|
+
# Replace the moved legacy payload with the sanitized, typed retired
|
|
2962
|
+
# state only after the atomic move has succeeded.
|
|
2963
|
+
write_json(destination, retired_session)
|
|
2964
|
+
elif not destination.is_file() and not quarantined:
|
|
2965
|
+
return {
|
|
2966
|
+
"ok": False,
|
|
2967
|
+
"code": "SERVICE_SESSION_ABSENT",
|
|
2968
|
+
"action": "retire-refused",
|
|
2969
|
+
"unknownProcessUntouched": True,
|
|
2970
|
+
}
|
|
1907
2971
|
receipt = {
|
|
1908
2972
|
"schemaVersion": 1,
|
|
2973
|
+
"receiptId": f"retirement-{safe_service}-{old_generation}",
|
|
2974
|
+
"operationId": operation_id,
|
|
2975
|
+
"serviceId": service_id,
|
|
2976
|
+
"oldSessionId": old_session_id,
|
|
2977
|
+
"oldGeneration": old_generation,
|
|
2978
|
+
"state": "FINALIZED",
|
|
2979
|
+
"retirementStateCommit": {
|
|
2980
|
+
"status": "COMMITTED",
|
|
2981
|
+
"reasonCode": "SERVICE_RETIRED",
|
|
2982
|
+
},
|
|
2983
|
+
"leaseCleanup": lease_cleanup,
|
|
2984
|
+
"cleanupComplete": lease_cleanup["status"] == "RELEASED",
|
|
2985
|
+
"awaitingSuperseder": True,
|
|
2986
|
+
"supersededBySessionId": None,
|
|
2987
|
+
}
|
|
2988
|
+
receipt["receiptDigest"] = _retirement_receipt_digest(receipt)
|
|
2989
|
+
# Keep the evidence path in the response/pending journal, not in the
|
|
2990
|
+
# strict receipt contract; the destination is derived from the receipt.
|
|
2991
|
+
write_json(receipt_path, receipt)
|
|
2992
|
+
pending_path.unlink(missing_ok=True)
|
|
2993
|
+
return {
|
|
2994
|
+
"ok": True,
|
|
2995
|
+
"code": "SERVICE_RETIRED",
|
|
1909
2996
|
"action": "retired-stale",
|
|
1910
|
-
"
|
|
1911
|
-
"retiredAt": now_iso(),
|
|
2997
|
+
"receipt": receipt,
|
|
1912
2998
|
"retiredEvidence": str(destination),
|
|
1913
2999
|
"unknownProcessUntouched": True,
|
|
1914
|
-
"sessionId": raw_id or None,
|
|
1915
3000
|
}
|
|
1916
|
-
write_json(retired_root / f"{destination.stem}.receipt.json", receipt)
|
|
1917
|
-
return {"ok": True, **receipt}
|
|
1918
3001
|
|
|
1919
3002
|
|
|
1920
3003
|
def cmd_retire_stale(args: argparse.Namespace) -> int:
|
|
@@ -1923,6 +3006,189 @@ def cmd_retire_stale(args: argparse.Namespace) -> int:
|
|
|
1923
3006
|
return 0 if result.get("ok") else 1
|
|
1924
3007
|
|
|
1925
3008
|
|
|
3009
|
+
def _link_superseder_unlocked(
|
|
3010
|
+
change_dir: Path,
|
|
3011
|
+
*,
|
|
3012
|
+
retirement_receipt: Path,
|
|
3013
|
+
new_session: dict[str, Any],
|
|
3014
|
+
operation_id: str | None = None,
|
|
3015
|
+
) -> dict[str, Any]:
|
|
3016
|
+
"""CAS-link one future service generation to a finalized retirement."""
|
|
3017
|
+
|
|
3018
|
+
change_dir = resolve_path(change_dir)
|
|
3019
|
+
receipt_path = resolve_path(retirement_receipt)
|
|
3020
|
+
try:
|
|
3021
|
+
receipt = read_json(receipt_path)
|
|
3022
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
3023
|
+
return {"ok": False, "code": "RETIREMENT_RECEIPT_INVALID", "error": str(exc)}
|
|
3024
|
+
if not isinstance(receipt, dict):
|
|
3025
|
+
return {"ok": False, "code": "RETIREMENT_RECEIPT_INVALID"}
|
|
3026
|
+
raw_old_generation = receipt.get("oldGeneration")
|
|
3027
|
+
raw_new_generation = new_session.get("serviceGeneration")
|
|
3028
|
+
if raw_old_generation is None or raw_new_generation is None:
|
|
3029
|
+
return {"ok": False, "code": "SUPERSEDER_GENERATION_INVALID"}
|
|
3030
|
+
old_generation = _normalize_service_generation(raw_old_generation)
|
|
3031
|
+
new_generation = _normalize_service_generation(raw_new_generation)
|
|
3032
|
+
if old_generation == new_generation:
|
|
3033
|
+
return {"ok": False, "code": "SUPERSEDER_GENERATION_INVALID"}
|
|
3034
|
+
receipt["schemaVersion"] = 1
|
|
3035
|
+
receipt["oldGeneration"] = old_generation
|
|
3036
|
+
receipt.setdefault("receiptId", f"retirement-{change_dir.name}-{old_generation}")
|
|
3037
|
+
receipt.setdefault("operationId", operation_id or uuid.uuid4().hex)
|
|
3038
|
+
receipt.setdefault("serviceId", str(receipt.get("serviceId") or change_dir.name))
|
|
3039
|
+
receipt.setdefault("oldSessionId", str(receipt.get("sessionId") or "legacy-" + change_dir.name))
|
|
3040
|
+
receipt.setdefault("state", "FINALIZED")
|
|
3041
|
+
receipt.setdefault(
|
|
3042
|
+
"retirementStateCommit",
|
|
3043
|
+
{"status": "COMMITTED", "reasonCode": "SERVICE_RETIRED"},
|
|
3044
|
+
)
|
|
3045
|
+
receipt.setdefault(
|
|
3046
|
+
"leaseCleanup",
|
|
3047
|
+
{
|
|
3048
|
+
"status": "RETAINED",
|
|
3049
|
+
"reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
|
|
3050
|
+
"leaseId": None,
|
|
3051
|
+
},
|
|
3052
|
+
)
|
|
3053
|
+
receipt["cleanupComplete"] = receipt["leaseCleanup"].get("status") == "RELEASED"
|
|
3054
|
+
receipt.setdefault("supersededBySessionId", None)
|
|
3055
|
+
legacy_superseder = receipt.pop("superseder", None)
|
|
3056
|
+
if receipt.get("supersededBySessionId") is None and isinstance(legacy_superseder, dict):
|
|
3057
|
+
legacy_id = legacy_superseder.get("sessionId")
|
|
3058
|
+
if isinstance(legacy_id, str) and legacy_id:
|
|
3059
|
+
receipt["supersededBySessionId"] = legacy_id
|
|
3060
|
+
existing_id = receipt.get("supersededBySessionId")
|
|
3061
|
+
if isinstance(existing_id, str) and existing_id:
|
|
3062
|
+
if existing_id == new_session.get("sessionId"):
|
|
3063
|
+
return {
|
|
3064
|
+
"ok": True,
|
|
3065
|
+
"code": "SUPERSEDER_ALREADY_LINKED",
|
|
3066
|
+
"receipt": receipt,
|
|
3067
|
+
}
|
|
3068
|
+
return {
|
|
3069
|
+
"ok": False,
|
|
3070
|
+
"code": "SUPERSEDER_LINK_CONFLICT",
|
|
3071
|
+
"receipt": receipt,
|
|
3072
|
+
}
|
|
3073
|
+
if receipt.get("awaitingSuperseder") is False:
|
|
3074
|
+
return {
|
|
3075
|
+
"ok": False,
|
|
3076
|
+
"code": "SUPERSEDER_LINK_CONFLICT",
|
|
3077
|
+
"receipt": receipt,
|
|
3078
|
+
}
|
|
3079
|
+
operation_id = operation_id or uuid.uuid4().hex
|
|
3080
|
+
new_session["schemaVersion"] = 1
|
|
3081
|
+
new_session.setdefault("serviceId", receipt["serviceId"])
|
|
3082
|
+
new_session.setdefault("sessionId", "service-" + uuid.uuid4().hex)
|
|
3083
|
+
new_session.setdefault("status", "STARTING")
|
|
3084
|
+
new_session.setdefault("reasonCode", "SERVICE_START_REQUESTED")
|
|
3085
|
+
new_session.setdefault("stateRevision", 1)
|
|
3086
|
+
new_session.setdefault("operationId", operation_id)
|
|
3087
|
+
new_session.setdefault("fingerprint", sha256_text(""))
|
|
3088
|
+
new_session.setdefault("processIdentity", None)
|
|
3089
|
+
new_session.setdefault("heartbeat", None)
|
|
3090
|
+
new_session.setdefault("leaseIdentity", None)
|
|
3091
|
+
new_session.setdefault("transitionHistory", [])
|
|
3092
|
+
new_session.setdefault("cleanupComplete", False)
|
|
3093
|
+
new_session["serviceGeneration"] = new_generation
|
|
3094
|
+
new_session["supersedesSessionId"] = receipt["oldSessionId"]
|
|
3095
|
+
retirement_commit = receipt.get("retirementStateCommit")
|
|
3096
|
+
if not isinstance(retirement_commit, dict):
|
|
3097
|
+
retirement_commit = {}
|
|
3098
|
+
lease_cleanup = receipt.get("leaseCleanup")
|
|
3099
|
+
if not isinstance(lease_cleanup, dict):
|
|
3100
|
+
lease_cleanup = {}
|
|
3101
|
+
commit_status = str(retirement_commit.get("status") or "COMMITTED")
|
|
3102
|
+
if commit_status not in {"PENDING", "COMMITTED", "CONFLICT"}:
|
|
3103
|
+
commit_status = "COMMITTED"
|
|
3104
|
+
lease_status = str(lease_cleanup.get("status") or "RETAINED")
|
|
3105
|
+
if lease_status not in {"PENDING", "RELEASED", "RETAINED", "UNVERIFIED"}:
|
|
3106
|
+
lease_status = "UNVERIFIED"
|
|
3107
|
+
canonical_receipt = {
|
|
3108
|
+
"schemaVersion": 1,
|
|
3109
|
+
"receiptId": str(receipt["receiptId"]),
|
|
3110
|
+
"operationId": operation_id,
|
|
3111
|
+
"serviceId": str(receipt["serviceId"]),
|
|
3112
|
+
"oldSessionId": str(receipt["oldSessionId"]),
|
|
3113
|
+
"oldGeneration": old_generation,
|
|
3114
|
+
"state": "FINALIZED",
|
|
3115
|
+
"retirementStateCommit": {
|
|
3116
|
+
"status": commit_status,
|
|
3117
|
+
"reasonCode": _canonical_service_reason(
|
|
3118
|
+
str(retirement_commit.get("reasonCode") or "SERVICE_RETIRED")
|
|
3119
|
+
),
|
|
3120
|
+
},
|
|
3121
|
+
"leaseCleanup": {
|
|
3122
|
+
"status": lease_status,
|
|
3123
|
+
"reasonCode": _canonical_service_reason(
|
|
3124
|
+
str(lease_cleanup.get("reasonCode") or "LISTENER_IDENTITY_UNVERIFIABLE")
|
|
3125
|
+
),
|
|
3126
|
+
"leaseId": lease_cleanup.get("leaseId")
|
|
3127
|
+
if isinstance(lease_cleanup.get("leaseId"), str)
|
|
3128
|
+
else None,
|
|
3129
|
+
},
|
|
3130
|
+
"cleanupComplete": lease_status == "RELEASED",
|
|
3131
|
+
"awaitingSuperseder": False,
|
|
3132
|
+
"supersededBySessionId": new_session.get("sessionId"),
|
|
3133
|
+
}
|
|
3134
|
+
receipt = canonical_receipt
|
|
3135
|
+
receipt["receiptDigest"] = _retirement_receipt_digest(receipt)
|
|
3136
|
+
write_json(receipt_path, receipt)
|
|
3137
|
+
write_session(change_dir, new_session)
|
|
3138
|
+
return {
|
|
3139
|
+
"ok": True,
|
|
3140
|
+
"code": "SUPERSEDER_LINKED",
|
|
3141
|
+
"receipt": receipt,
|
|
3142
|
+
"session": new_session,
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
|
|
3146
|
+
def link_superseder(
|
|
3147
|
+
change_dir: Path,
|
|
3148
|
+
*,
|
|
3149
|
+
retirement_receipt: Path,
|
|
3150
|
+
new_session: dict[str, Any],
|
|
3151
|
+
operation_id: str | None = None,
|
|
3152
|
+
) -> dict[str, Any]:
|
|
3153
|
+
"""CAS-link one superseder while serializing writers for this service."""
|
|
3154
|
+
|
|
3155
|
+
change_dir = resolve_path(change_dir)
|
|
3156
|
+
lock = ServiceMutationLock(change_dir, operation="link-superseder")
|
|
3157
|
+
conflict = lock.acquire()
|
|
3158
|
+
if conflict is not None:
|
|
3159
|
+
return conflict
|
|
3160
|
+
try:
|
|
3161
|
+
return _link_superseder_unlocked(
|
|
3162
|
+
change_dir,
|
|
3163
|
+
retirement_receipt=retirement_receipt,
|
|
3164
|
+
new_session=new_session,
|
|
3165
|
+
operation_id=operation_id or lock.token,
|
|
3166
|
+
)
|
|
3167
|
+
finally:
|
|
3168
|
+
lock.release()
|
|
3169
|
+
|
|
3170
|
+
|
|
3171
|
+
def cmd_link_superseder(args: argparse.Namespace) -> int:
|
|
3172
|
+
change_dir = resolve_path(args.change_dir)
|
|
3173
|
+
try:
|
|
3174
|
+
new_session = load_session(change_dir)
|
|
3175
|
+
except SessionCorrupt as exc:
|
|
3176
|
+
return emit_error(str(exc), as_json=bool(args.json))
|
|
3177
|
+
if new_session is None:
|
|
3178
|
+
return emit_error(
|
|
3179
|
+
"new service session is missing",
|
|
3180
|
+
as_json=bool(args.json),
|
|
3181
|
+
code=4,
|
|
3182
|
+
)
|
|
3183
|
+
result = link_superseder(
|
|
3184
|
+
change_dir,
|
|
3185
|
+
retirement_receipt=resolve_path(args.retirement_receipt),
|
|
3186
|
+
new_session=new_session,
|
|
3187
|
+
)
|
|
3188
|
+
emit_json(result, as_json=bool(args.json))
|
|
3189
|
+
return 0 if result.get("ok") else 4
|
|
3190
|
+
|
|
3191
|
+
|
|
1926
3192
|
def build_parser() -> argparse.ArgumentParser:
|
|
1927
3193
|
parser = argparse.ArgumentParser(
|
|
1928
3194
|
prog="harness_service.py",
|
|
@@ -2010,6 +3276,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2010
3276
|
p_retire.add_argument("--json", action="store_true")
|
|
2011
3277
|
p_retire.set_defaults(func=cmd_retire_stale)
|
|
2012
3278
|
|
|
3279
|
+
p_link = sub.add_parser(
|
|
3280
|
+
"link-superseder",
|
|
3281
|
+
help="CAS-link a new service generation to a retirement receipt",
|
|
3282
|
+
)
|
|
3283
|
+
p_link.add_argument("--change-dir", required=True)
|
|
3284
|
+
p_link.add_argument("--retirement-receipt", required=True)
|
|
3285
|
+
p_link.add_argument("--json", action="store_true")
|
|
3286
|
+
p_link.set_defaults(func=cmd_link_superseder)
|
|
3287
|
+
|
|
2013
3288
|
return parser
|
|
2014
3289
|
|
|
2015
3290
|
|