@hunter-harness/workflow-harness 0.2.42 → 0.2.44
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 +156 -1
- 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-archive/reference.md +1 -0
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +28 -0
- 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-run/reference.md +6 -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 +8 -2
- package/harness/bundles/general/claude-code/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/claude-code/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +271 -22
- package/harness/bundles/general/claude-code/scripts/harness_change.py +137 -2
- package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/claude-code/scripts/harness_environment.py +760 -46
- package/harness/bundles/general/claude-code/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/claude-code/scripts/harness_fixback.py +742 -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 +278 -4
- package/harness/bundles/general/claude-code/scripts/harness_runtime.py +1576 -3
- package/harness/bundles/general/claude-code/scripts/harness_service.py +1528 -177
- package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +224 -146
- package/harness/bundles/general/claude-code/scripts/harness_verification.py +487 -8
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +156 -1
- 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-archive/reference.md +1 -0
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
- 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-run/reference.md +6 -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 +8 -2
- package/harness/bundles/general/codebuddy/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/codebuddy/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +271 -22
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +137 -2
- package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/codebuddy/scripts/harness_environment.py +760 -46
- package/harness/bundles/general/codebuddy/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +742 -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 +278 -4
- package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +1576 -3
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +1528 -177
- package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +224 -146
- package/harness/bundles/general/codebuddy/scripts/harness_verification.py +487 -8
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +156 -1
- 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-archive/reference.md +1 -0
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +28 -0
- 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-run/reference.md +6 -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 +8 -2
- package/harness/bundles/general/codex/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/codex/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/codex/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +271 -22
- package/harness/bundles/general/codex/scripts/harness_change.py +137 -2
- package/harness/bundles/general/codex/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/codex/scripts/harness_environment.py +760 -46
- package/harness/bundles/general/codex/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/codex/scripts/harness_fixback.py +742 -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 +278 -4
- package/harness/bundles/general/codex/scripts/harness_runtime.py +1576 -3
- package/harness/bundles/general/codex/scripts/harness_service.py +1528 -177
- package/harness/bundles/general/codex/scripts/harness_test_runner.py +224 -146
- package/harness/bundles/general/codex/scripts/harness_verification.py +487 -8
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +156 -1
- 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-archive/reference.md +1 -0
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +28 -0
- 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-run/reference.md +6 -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 +8 -2
- package/harness/bundles/general/cursor/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/cursor/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +271 -22
- package/harness/bundles/general/cursor/scripts/harness_change.py +137 -2
- package/harness/bundles/general/cursor/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/cursor/scripts/harness_environment.py +760 -46
- package/harness/bundles/general/cursor/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/cursor/scripts/harness_fixback.py +742 -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 +278 -4
- package/harness/bundles/general/cursor/scripts/harness_runtime.py +1576 -3
- package/harness/bundles/general/cursor/scripts/harness_service.py +1528 -177
- package/harness/bundles/general/cursor/scripts/harness_test_runner.py +224 -146
- package/harness/bundles/general/cursor/scripts/harness_verification.py +487 -8
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +156 -1
- 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-archive/reference.md +1 -0
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +28 -0
- 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 +8 -2
- package/harness/bundles/java/claude-code/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/claude-code/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +271 -22
- package/harness/bundles/java/claude-code/scripts/harness_change.py +137 -2
- package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/claude-code/scripts/harness_environment.py +760 -46
- package/harness/bundles/java/claude-code/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/claude-code/scripts/harness_fixback.py +742 -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 +278 -4
- package/harness/bundles/java/claude-code/scripts/harness_runtime.py +1576 -3
- package/harness/bundles/java/claude-code/scripts/harness_service.py +1528 -177
- package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +224 -146
- package/harness/bundles/java/claude-code/scripts/harness_verification.py +487 -8
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +156 -1
- 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-archive/reference.md +1 -0
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
- 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 +8 -2
- package/harness/bundles/java/codebuddy/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/codebuddy/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +271 -22
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +137 -2
- package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/codebuddy/scripts/harness_environment.py +760 -46
- package/harness/bundles/java/codebuddy/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +742 -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 +278 -4
- package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +1576 -3
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +1528 -177
- package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +224 -146
- package/harness/bundles/java/codebuddy/scripts/harness_verification.py +487 -8
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +156 -1
- 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-archive/reference.md +1 -0
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +28 -0
- 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 +8 -2
- package/harness/bundles/java/codex/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/codex/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/codex/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +271 -22
- package/harness/bundles/java/codex/scripts/harness_change.py +137 -2
- package/harness/bundles/java/codex/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/codex/scripts/harness_environment.py +760 -46
- package/harness/bundles/java/codex/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/codex/scripts/harness_fixback.py +742 -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 +278 -4
- package/harness/bundles/java/codex/scripts/harness_runtime.py +1576 -3
- package/harness/bundles/java/codex/scripts/harness_service.py +1528 -177
- package/harness/bundles/java/codex/scripts/harness_test_runner.py +224 -146
- package/harness/bundles/java/codex/scripts/harness_verification.py +487 -8
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +156 -1
- 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-archive/reference.md +1 -0
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +28 -0
- 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 +8 -2
- package/harness/bundles/java/cursor/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/cursor/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +271 -22
- package/harness/bundles/java/cursor/scripts/harness_change.py +137 -2
- package/harness/bundles/java/cursor/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/cursor/scripts/harness_environment.py +760 -46
- package/harness/bundles/java/cursor/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/cursor/scripts/harness_fixback.py +742 -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 +278 -4
- package/harness/bundles/java/cursor/scripts/harness_runtime.py +1576 -3
- package/harness/bundles/java/cursor/scripts/harness_service.py +1528 -177
- package/harness/bundles/java/cursor/scripts/harness_test_runner.py +224 -146
- package/harness/bundles/java/cursor/scripts/harness_verification.py +487 -8
- package/harness/manifests/general/claude-code.json +76 -31
- package/harness/manifests/general/codebuddy.json +76 -31
- package/harness/manifests/general/codex.json +76 -31
- package/harness/manifests/general/cursor.json +76 -31
- package/harness/manifests/java/claude-code.json +77 -32
- package/harness/manifests/java/codebuddy.json +77 -32
- package/harness/manifests/java/codex.json +77 -32
- package/harness/manifests/java/cursor.json +77 -32
- package/hunter-workflow-family.json +6 -4
- package/package.json +1 -1
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
import contextlib
|
|
7
8
|
import datetime as dt
|
|
9
|
+
import hashlib
|
|
8
10
|
import json
|
|
9
11
|
import os
|
|
10
12
|
import re
|
|
@@ -12,16 +14,31 @@ import shutil
|
|
|
12
14
|
import subprocess
|
|
13
15
|
import sys
|
|
14
16
|
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
15
20
|
from pathlib import Path
|
|
16
21
|
from typing import Any, Callable
|
|
17
22
|
|
|
23
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
24
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
25
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
26
|
+
|
|
18
27
|
if hasattr(sys.stdout, "reconfigure"):
|
|
19
28
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
20
29
|
if hasattr(sys.stderr, "reconfigure"):
|
|
21
30
|
sys.stderr.reconfigure(encoding="utf-8")
|
|
22
31
|
|
|
23
32
|
SCHEMA_VERSION = 1
|
|
33
|
+
RUN_SESSION_SCHEMA_VERSION = 1
|
|
34
|
+
RUN_TERMINAL_STATUSES = {"OK", "FAIL", "INCOMPLETE", "CANCELLED"}
|
|
24
35
|
_CHANGE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
36
|
+
SECRET_SCAN_RULES_VERSION = "secret-scan-v1"
|
|
37
|
+
SECRET_SCAN_RECEIPT_REL = Path("meta") / "secret-scan-receipt.json"
|
|
38
|
+
_SENSITIVE_ASSIGNMENT = re.compile(
|
|
39
|
+
rb"(?i)[\"']?(?:password|passwd|token|secret|cookie|authorization|database[_-]?url)"
|
|
40
|
+
rb"[\"']?\s*[:=]\s*[\"']?[^\s\"']{4,}"
|
|
41
|
+
)
|
|
25
42
|
_ADAPTERS = {
|
|
26
43
|
"claude-code": (".worktrees", "harness/"),
|
|
27
44
|
"codex": (".worktrees", "harness/"),
|
|
@@ -34,6 +51,40 @@ def now_iso() -> str:
|
|
|
34
51
|
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
35
52
|
|
|
36
53
|
|
|
54
|
+
def _stage_transition(receipt: dict[str, Any], next_stage: str) -> None:
|
|
55
|
+
ended_at = now_iso()
|
|
56
|
+
started_at = str(receipt.get("stageStartedAt") or ended_at)
|
|
57
|
+
try:
|
|
58
|
+
started = dt.datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
59
|
+
ended = dt.datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
|
60
|
+
wall_ms = max(0, int((ended - started).total_seconds() * 1000))
|
|
61
|
+
except ValueError:
|
|
62
|
+
wall_ms = 0
|
|
63
|
+
stage = str(receipt.get("stage") or "unknown")
|
|
64
|
+
timings = receipt.setdefault("stageTimings", [])
|
|
65
|
+
if isinstance(timings, list):
|
|
66
|
+
timings.append(
|
|
67
|
+
{
|
|
68
|
+
"stage": stage,
|
|
69
|
+
"createdAt": started_at,
|
|
70
|
+
"endedAt": ended_at,
|
|
71
|
+
"wallClockMs": wall_ms,
|
|
72
|
+
"activeTimeMs": 0 if stage == "resource-wait" else wall_ms,
|
|
73
|
+
"resourceWaitMs": wall_ms if stage == "resource-wait" else 0,
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
receipt["stage"] = next_stage
|
|
77
|
+
receipt["stageStartedAt"] = ended_at
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _close_current_stage(receipt: dict[str, Any]) -> None:
|
|
81
|
+
current = str(receipt.get("stage") or "record")
|
|
82
|
+
_stage_transition(receipt, current)
|
|
83
|
+
timings = receipt.get("stageTimings")
|
|
84
|
+
if isinstance(timings, list) and timings:
|
|
85
|
+
receipt["stageStartedAt"] = timings[-1].get("endedAt")
|
|
86
|
+
|
|
87
|
+
|
|
37
88
|
def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
38
89
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
90
|
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
@@ -46,11 +97,1472 @@ def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
|
46
97
|
handle.write(text)
|
|
47
98
|
handle.flush()
|
|
48
99
|
os.fsync(handle.fileno())
|
|
49
|
-
|
|
100
|
+
# Windows can transiently deny replacement while another process has
|
|
101
|
+
# just finished reading the receipt. Keep the atomic replace contract,
|
|
102
|
+
# but retry the sharing violation within a small bounded window.
|
|
103
|
+
for attempt in range(20):
|
|
104
|
+
try:
|
|
105
|
+
os.replace(tmp, path)
|
|
106
|
+
break
|
|
107
|
+
except PermissionError:
|
|
108
|
+
if attempt == 19:
|
|
109
|
+
raise
|
|
110
|
+
time.sleep(0.01 * (attempt + 1))
|
|
50
111
|
finally:
|
|
51
112
|
tmp.unlink(missing_ok=True)
|
|
52
113
|
|
|
53
114
|
|
|
115
|
+
def _sha256_json(value: Any) -> str:
|
|
116
|
+
encoded = json.dumps(
|
|
117
|
+
value,
|
|
118
|
+
ensure_ascii=False,
|
|
119
|
+
sort_keys=True,
|
|
120
|
+
separators=(",", ":"),
|
|
121
|
+
).encode("utf-8")
|
|
122
|
+
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _publishable_tree_digest(root: Path) -> str:
|
|
126
|
+
"""Hash publishable bytes while excluding the self-referential scan receipt."""
|
|
127
|
+
root = root.expanduser().resolve()
|
|
128
|
+
digest = hashlib.sha256()
|
|
129
|
+
receipt = (root / SECRET_SCAN_RECEIPT_REL).resolve()
|
|
130
|
+
if not root.is_dir():
|
|
131
|
+
raise OSError(f"publishable evidence root not found: {root}")
|
|
132
|
+
for path in sorted(root.rglob("*")):
|
|
133
|
+
if not path.is_file() or path.resolve() == receipt:
|
|
134
|
+
continue
|
|
135
|
+
relative = path.relative_to(root).as_posix()
|
|
136
|
+
data = path.read_bytes()
|
|
137
|
+
digest.update(relative.encode("utf-8"))
|
|
138
|
+
digest.update(b"\0")
|
|
139
|
+
digest.update(str(len(data)).encode("ascii"))
|
|
140
|
+
digest.update(b"\0")
|
|
141
|
+
digest.update(hashlib.sha256(data).digest())
|
|
142
|
+
digest.update(b"\0")
|
|
143
|
+
return "sha256:" + digest.hexdigest()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _sensitive_evidence_receipt_path(change_root: Path) -> Path:
|
|
147
|
+
return change_root.expanduser().resolve() / SECRET_SCAN_RECEIPT_REL
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _private_evidence_root(explicit: Path | None = None) -> Path:
|
|
151
|
+
configured = os.environ.get("HARNESS_PRIVATE_EVIDENCE_ROOT")
|
|
152
|
+
return (explicit if explicit is not None else Path(configured) if configured else
|
|
153
|
+
Path.home() / ".harness" / "private-evidence").expanduser().resolve()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _secure_private_path(path: Path, *, directory: bool) -> str:
|
|
157
|
+
"""Best-effort owner-only permissions, with an explicit ACL result on Windows."""
|
|
158
|
+
mode = 0o700 if directory else 0o600
|
|
159
|
+
try:
|
|
160
|
+
path.chmod(mode)
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
if os.name != "nt":
|
|
164
|
+
return "POSIX_OWNER_ONLY"
|
|
165
|
+
try:
|
|
166
|
+
account = os.environ.get("USERNAME") or os.environ.get("USER")
|
|
167
|
+
if not account:
|
|
168
|
+
account = subprocess.check_output(
|
|
169
|
+
["whoami"], stderr=subprocess.DEVNULL, text=True, encoding="utf-8"
|
|
170
|
+
).strip()
|
|
171
|
+
result = subprocess.run(
|
|
172
|
+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{account}:F"],
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
encoding="utf-8",
|
|
176
|
+
errors="replace",
|
|
177
|
+
check=False,
|
|
178
|
+
timeout=10,
|
|
179
|
+
)
|
|
180
|
+
return "WINDOWS_CURRENT_USER_ONLY" if result.returncode == 0 else "WINDOWS_ACL_UNVERIFIED"
|
|
181
|
+
except (OSError, subprocess.SubprocessError):
|
|
182
|
+
return "WINDOWS_ACL_UNVERIFIED"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _sensitive_candidates(root: Path) -> list[dict[str, Any]]:
|
|
186
|
+
"""Find high-confidence plaintext assignments without treating prose as a secret."""
|
|
187
|
+
receipt = _sensitive_evidence_receipt_path(root)
|
|
188
|
+
candidates: list[dict[str, Any]] = []
|
|
189
|
+
for path in sorted(root.rglob("*")):
|
|
190
|
+
if not path.is_file() or path.resolve() == receipt.resolve():
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
raw = path.read_bytes()
|
|
194
|
+
except OSError:
|
|
195
|
+
continue
|
|
196
|
+
match = _SENSITIVE_ASSIGNMENT.search(raw)
|
|
197
|
+
if match is None:
|
|
198
|
+
continue
|
|
199
|
+
candidates.append({
|
|
200
|
+
"path": path.relative_to(root).as_posix(),
|
|
201
|
+
"reasonCode": "PLAINTEXT_SENSITIVE_ASSIGNMENT",
|
|
202
|
+
"digest": "sha256:" + hashlib.sha256(raw).hexdigest(),
|
|
203
|
+
})
|
|
204
|
+
return candidates
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def publishable_tree_digest(root: Path) -> str:
|
|
208
|
+
"""Public digest helper shared by the archive publication gate."""
|
|
209
|
+
return _publishable_tree_digest(root)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def sensitive_evidence_receipt_path(change_root: Path) -> Path:
|
|
213
|
+
return _sensitive_evidence_receipt_path(change_root)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def sensitive_evidence_candidates(root: Path) -> list[dict[str, Any]]:
|
|
217
|
+
return _sensitive_candidates(root)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _write_sensitive_scan_receipt(
|
|
221
|
+
change_root: Path,
|
|
222
|
+
*,
|
|
223
|
+
entries: list[dict[str, Any]],
|
|
224
|
+
unresolved: list[dict[str, Any]],
|
|
225
|
+
status: str,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
change_root = change_root.expanduser().resolve()
|
|
228
|
+
receipt = {
|
|
229
|
+
"schemaVersion": 1,
|
|
230
|
+
"rulesVersion": SECRET_SCAN_RULES_VERSION,
|
|
231
|
+
"changeId": change_root.name,
|
|
232
|
+
"status": status,
|
|
233
|
+
"unresolvedFailures": unresolved,
|
|
234
|
+
"entries": entries,
|
|
235
|
+
"publishableTreeDigest": _publishable_tree_digest(change_root),
|
|
236
|
+
"publicationExcluded": True,
|
|
237
|
+
"recordedAt": now_iso(),
|
|
238
|
+
}
|
|
239
|
+
atomic_write_json(_sensitive_evidence_receipt_path(change_root), receipt)
|
|
240
|
+
return receipt
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def quarantine_sensitive_evidence(
|
|
244
|
+
source: Path,
|
|
245
|
+
*,
|
|
246
|
+
change_root: Path,
|
|
247
|
+
reason: str = "sensitive evidence",
|
|
248
|
+
private_root: Path | None = None,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
"""Atomically move legacy plaintext evidence outside any publishable tree."""
|
|
251
|
+
source = source.expanduser().resolve()
|
|
252
|
+
change_root = change_root.expanduser().resolve()
|
|
253
|
+
private = _private_evidence_root(private_root)
|
|
254
|
+
if not source.is_file():
|
|
255
|
+
return {"ok": False, "reasonCode": "SENSITIVE_EVIDENCE_SOURCE_MISSING", "sourcePath": str(source)}
|
|
256
|
+
if private == change_root or private.is_relative_to(change_root):
|
|
257
|
+
return {
|
|
258
|
+
"ok": False,
|
|
259
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
260
|
+
"error": "private quarantine root must be outside the publishable change root",
|
|
261
|
+
}
|
|
262
|
+
try:
|
|
263
|
+
raw_digest = "sha256:" + hashlib.sha256(source.read_bytes()).hexdigest()
|
|
264
|
+
quarantine_dir = private / uuid.uuid4().hex
|
|
265
|
+
quarantine_dir.mkdir(parents=True, exist_ok=False)
|
|
266
|
+
acl_dir = _secure_private_path(quarantine_dir, directory=True)
|
|
267
|
+
target = quarantine_dir / "payload.bin"
|
|
268
|
+
os.replace(source, target)
|
|
269
|
+
acl_file = _secure_private_path(target, directory=False)
|
|
270
|
+
if os.name == "nt" and (
|
|
271
|
+
acl_dir != "WINDOWS_CURRENT_USER_ONLY"
|
|
272
|
+
or acl_file != "WINDOWS_CURRENT_USER_ONLY"
|
|
273
|
+
):
|
|
274
|
+
os.replace(target, source)
|
|
275
|
+
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
276
|
+
return {
|
|
277
|
+
"ok": False,
|
|
278
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
279
|
+
"error": "private quarantine ACL could not be restricted to the current user",
|
|
280
|
+
}
|
|
281
|
+
moved_digest = "sha256:" + hashlib.sha256(target.read_bytes()).hexdigest()
|
|
282
|
+
if moved_digest != raw_digest:
|
|
283
|
+
os.replace(target, source)
|
|
284
|
+
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
285
|
+
return {
|
|
286
|
+
"ok": False,
|
|
287
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
288
|
+
"error": "quarantine digest verification failed",
|
|
289
|
+
}
|
|
290
|
+
try:
|
|
291
|
+
prior = json.loads(
|
|
292
|
+
_sensitive_evidence_receipt_path(change_root).read_text(
|
|
293
|
+
encoding="utf-8-sig"
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
except (OSError, json.JSONDecodeError):
|
|
297
|
+
prior = {}
|
|
298
|
+
entries = list(prior.get("entries") or []) if isinstance(prior, dict) else []
|
|
299
|
+
entries.append({
|
|
300
|
+
"sourcePath": (
|
|
301
|
+
source.relative_to(change_root).as_posix()
|
|
302
|
+
if source.is_relative_to(change_root)
|
|
303
|
+
else source.name
|
|
304
|
+
),
|
|
305
|
+
"sourceDigest": raw_digest,
|
|
306
|
+
"privatePath": str(target),
|
|
307
|
+
"reason": reason[:160],
|
|
308
|
+
"status": "QUARANTINED",
|
|
309
|
+
"acl": {"directory": acl_dir, "file": acl_file},
|
|
310
|
+
"movedAt": now_iso(),
|
|
311
|
+
})
|
|
312
|
+
try:
|
|
313
|
+
receipt = _write_sensitive_scan_receipt(
|
|
314
|
+
change_root,
|
|
315
|
+
entries=entries,
|
|
316
|
+
unresolved=[],
|
|
317
|
+
status="QUARANTINED",
|
|
318
|
+
)
|
|
319
|
+
except BaseException as exc:
|
|
320
|
+
try:
|
|
321
|
+
os.replace(target, source)
|
|
322
|
+
except OSError:
|
|
323
|
+
pass
|
|
324
|
+
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
325
|
+
return {
|
|
326
|
+
"ok": False,
|
|
327
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
328
|
+
"error": str(exc),
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
"ok": True,
|
|
332
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINED",
|
|
333
|
+
"sourceDigest": raw_digest,
|
|
334
|
+
"privatePath": str(target),
|
|
335
|
+
"receiptPath": str(_sensitive_evidence_receipt_path(change_root)),
|
|
336
|
+
"receipt": receipt,
|
|
337
|
+
}
|
|
338
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
339
|
+
# No copy is retained on failure. If os.replace crossed filesystems it
|
|
340
|
+
# fails before removing the source, which is the required fail-closed path.
|
|
341
|
+
return {
|
|
342
|
+
"ok": False,
|
|
343
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
344
|
+
"error": str(exc),
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def ensure_sensitive_evidence_scan_receipt(change_root: Path) -> dict[str, Any]:
|
|
349
|
+
"""Create a digest-bound scan receipt for a tree with no plaintext findings."""
|
|
350
|
+
change_root = change_root.expanduser().resolve()
|
|
351
|
+
unresolved = _sensitive_candidates(change_root)
|
|
352
|
+
if unresolved:
|
|
353
|
+
return _write_sensitive_scan_receipt(
|
|
354
|
+
change_root,
|
|
355
|
+
entries=[],
|
|
356
|
+
unresolved=unresolved,
|
|
357
|
+
status="FAIL",
|
|
358
|
+
)
|
|
359
|
+
return _write_sensitive_scan_receipt(
|
|
360
|
+
change_root,
|
|
361
|
+
entries=[],
|
|
362
|
+
unresolved=[],
|
|
363
|
+
status="OK",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _run_sessions_root(state_root: Path) -> Path:
|
|
368
|
+
return state_root.expanduser().resolve() / "runtime" / "run-sessions"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _run_session_root(state_root: Path, session_id: str) -> Path:
|
|
372
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", session_id):
|
|
373
|
+
raise ValueError(f"RUN_SESSION_ID_INVALID: {session_id}")
|
|
374
|
+
return _run_sessions_root(state_root) / session_id
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _run_receipt_path(state_root: Path, session_id: str) -> Path:
|
|
378
|
+
return _run_session_root(state_root, session_id) / "session.json"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _resource_lock_secrets_path(state_root: Path, session_id: str) -> Path:
|
|
382
|
+
return _run_session_root(state_root, session_id) / "resource-lock-secrets.json"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _load_run_receipt(state_root: Path, session_id: str) -> dict[str, Any]:
|
|
386
|
+
path = _run_receipt_path(state_root, session_id)
|
|
387
|
+
last_error: OSError | None = None
|
|
388
|
+
for attempt in range(20):
|
|
389
|
+
try:
|
|
390
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
391
|
+
break
|
|
392
|
+
except FileNotFoundError as exc:
|
|
393
|
+
raise ValueError(f"RUN_SESSION_NOT_FOUND: {session_id}") from exc
|
|
394
|
+
except PermissionError as exc:
|
|
395
|
+
last_error = exc
|
|
396
|
+
if attempt == 19:
|
|
397
|
+
raise ValueError(
|
|
398
|
+
f"RUN_SESSION_RECEIPT_BUSY: {session_id}"
|
|
399
|
+
) from exc
|
|
400
|
+
time.sleep(0.01 * (attempt + 1))
|
|
401
|
+
else:
|
|
402
|
+
raise ValueError(f"RUN_SESSION_RECEIPT_BUSY: {session_id}") from last_error
|
|
403
|
+
if not isinstance(value, dict):
|
|
404
|
+
raise ValueError(f"RUN_SESSION_CORRUPT: {session_id}")
|
|
405
|
+
return value
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _load_resource_lock_tokens(
|
|
409
|
+
state_root: Path,
|
|
410
|
+
session_id: str,
|
|
411
|
+
receipt: dict[str, Any],
|
|
412
|
+
) -> dict[str, str]:
|
|
413
|
+
"""Load lock tokens from a private sidecar; legacy receipts may inline them."""
|
|
414
|
+
path = _resource_lock_secrets_path(state_root, session_id)
|
|
415
|
+
try:
|
|
416
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
417
|
+
if isinstance(value, dict) and isinstance(value.get("tokens"), dict):
|
|
418
|
+
return {
|
|
419
|
+
str(name): str(token)
|
|
420
|
+
for name, token in value["tokens"].items()
|
|
421
|
+
if isinstance(name, str) and isinstance(token, str)
|
|
422
|
+
}
|
|
423
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
424
|
+
pass
|
|
425
|
+
legacy = receipt.get("resourceLockTokens")
|
|
426
|
+
if isinstance(legacy, dict):
|
|
427
|
+
return {
|
|
428
|
+
str(name): str(token)
|
|
429
|
+
for name, token in legacy.items()
|
|
430
|
+
if isinstance(name, str) and isinstance(token, str)
|
|
431
|
+
}
|
|
432
|
+
return {}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _receipt_identity_state(
|
|
436
|
+
receipt: dict[str, Any],
|
|
437
|
+
*,
|
|
438
|
+
field: str = "workerIdentity",
|
|
439
|
+
) -> bool | None:
|
|
440
|
+
"""Verify a canonical provider attestation, with legacy facade fallback."""
|
|
441
|
+
identity = receipt.get(field)
|
|
442
|
+
pid = identity.get("pid") if isinstance(identity, dict) else receipt.get("workerPid")
|
|
443
|
+
if isinstance(identity, dict) and {
|
|
444
|
+
"schemaVersion",
|
|
445
|
+
"createdAt",
|
|
446
|
+
"fieldProvenance",
|
|
447
|
+
"capabilities",
|
|
448
|
+
} <= set(identity):
|
|
449
|
+
from harness_process import observe_process_identity, verify_process_identity
|
|
450
|
+
|
|
451
|
+
observed = observe_process_identity(pid)
|
|
452
|
+
decision = verify_process_identity(identity, observed)
|
|
453
|
+
if decision.get("ok") is True:
|
|
454
|
+
return True
|
|
455
|
+
if decision.get("reasonCode") == "IDENTITY_UNVERIFIABLE":
|
|
456
|
+
return None
|
|
457
|
+
return False
|
|
458
|
+
from harness_service import verify_process_identity
|
|
459
|
+
|
|
460
|
+
legacy = {
|
|
461
|
+
"pid": pid,
|
|
462
|
+
"startedAt": (
|
|
463
|
+
identity.get("startedAt")
|
|
464
|
+
if isinstance(identity, dict)
|
|
465
|
+
else None
|
|
466
|
+
),
|
|
467
|
+
"processIdentity": {
|
|
468
|
+
"executable": (
|
|
469
|
+
identity.get("executable")
|
|
470
|
+
if isinstance(identity, dict)
|
|
471
|
+
else None
|
|
472
|
+
)
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
return verify_process_identity(legacy)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _capture_provider_members(spawned: Any) -> None:
|
|
479
|
+
"""Persist current provider-owned members before a later cleanup."""
|
|
480
|
+
proof = getattr(spawned, "ownershipProof", None)
|
|
481
|
+
leader = getattr(spawned, "attestation", None)
|
|
482
|
+
if not isinstance(proof, dict) or not isinstance(leader, dict):
|
|
483
|
+
return
|
|
484
|
+
from harness_process import capture_owned_members
|
|
485
|
+
|
|
486
|
+
capture_owned_members(spawned)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _resource_lock_root() -> Path:
|
|
490
|
+
return Path(
|
|
491
|
+
os.environ.get(
|
|
492
|
+
"HARNESS_RESOURCE_LOCK_ROOT",
|
|
493
|
+
str(Path(tempfile.gettempdir()) / "hunter-harness-resource-locks"),
|
|
494
|
+
)
|
|
495
|
+
).expanduser().resolve()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _resource_lock_path(_state_root: Path, lock_name: str) -> Path:
|
|
499
|
+
digest = hashlib.sha256(lock_name.encode("utf-8")).hexdigest()
|
|
500
|
+
return _resource_lock_root() / f"{digest}.json"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _lock_owner_identity(owner: Any) -> bool | None:
|
|
504
|
+
if not isinstance(owner, dict):
|
|
505
|
+
return None
|
|
506
|
+
pid = owner.get("pid")
|
|
507
|
+
started_at = owner.get("startedAt")
|
|
508
|
+
executable = owner.get("executable")
|
|
509
|
+
if (
|
|
510
|
+
not isinstance(pid, int)
|
|
511
|
+
or pid <= 0
|
|
512
|
+
or not isinstance(started_at, str)
|
|
513
|
+
or not started_at
|
|
514
|
+
or not isinstance(executable, str)
|
|
515
|
+
or not executable
|
|
516
|
+
):
|
|
517
|
+
return None
|
|
518
|
+
from harness_service import verify_process_identity
|
|
519
|
+
|
|
520
|
+
return verify_process_identity(
|
|
521
|
+
{
|
|
522
|
+
"pid": pid,
|
|
523
|
+
"startedAt": started_at,
|
|
524
|
+
"processIdentity": {"executable": executable},
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _valid_resource_lock_record(
|
|
530
|
+
record: Any,
|
|
531
|
+
*,
|
|
532
|
+
lock_name: str,
|
|
533
|
+
) -> bool:
|
|
534
|
+
return bool(
|
|
535
|
+
isinstance(record, dict)
|
|
536
|
+
and record.get("schemaVersion") == 2
|
|
537
|
+
and record.get("lockName") == lock_name
|
|
538
|
+
and isinstance(record.get("sessionId"), str)
|
|
539
|
+
and record.get("sessionId")
|
|
540
|
+
and isinstance(record.get("token"), str)
|
|
541
|
+
and record.get("token")
|
|
542
|
+
and isinstance(record.get("heartbeatAtUnix"), (int, float))
|
|
543
|
+
and isinstance(record.get("expiresAtUnix"), (int, float))
|
|
544
|
+
and _lock_owner_identity(record.get("owner")) is not None
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _write_resource_lock(
|
|
549
|
+
path: Path,
|
|
550
|
+
*,
|
|
551
|
+
lock_name: str,
|
|
552
|
+
session_id: str,
|
|
553
|
+
token: str,
|
|
554
|
+
owner: dict[str, Any],
|
|
555
|
+
acquired_at: str,
|
|
556
|
+
) -> None:
|
|
557
|
+
now = time.time()
|
|
558
|
+
atomic_write_json(
|
|
559
|
+
path,
|
|
560
|
+
{
|
|
561
|
+
"schemaVersion": 2,
|
|
562
|
+
"lockName": lock_name,
|
|
563
|
+
"sessionId": session_id,
|
|
564
|
+
"token": token,
|
|
565
|
+
"acquiredAt": acquired_at,
|
|
566
|
+
"heartbeatAtUnix": now,
|
|
567
|
+
"expiresAtUnix": now + 300.0,
|
|
568
|
+
"owner": owner,
|
|
569
|
+
},
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _refresh_resource_locks(
|
|
574
|
+
state_root: Path,
|
|
575
|
+
session_id: str,
|
|
576
|
+
tokens: dict[str, str],
|
|
577
|
+
owner: dict[str, Any],
|
|
578
|
+
) -> bool:
|
|
579
|
+
refreshed = True
|
|
580
|
+
for lock_name, token in sorted(tokens.items()):
|
|
581
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
582
|
+
try:
|
|
583
|
+
record = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
584
|
+
except (OSError, json.JSONDecodeError):
|
|
585
|
+
refreshed = False
|
|
586
|
+
continue
|
|
587
|
+
if (
|
|
588
|
+
not _valid_resource_lock_record(record, lock_name=lock_name)
|
|
589
|
+
or record.get("sessionId") != session_id
|
|
590
|
+
or record.get("token") != token
|
|
591
|
+
):
|
|
592
|
+
refreshed = False
|
|
593
|
+
continue
|
|
594
|
+
_write_resource_lock(
|
|
595
|
+
path,
|
|
596
|
+
lock_name=lock_name,
|
|
597
|
+
session_id=session_id,
|
|
598
|
+
token=token,
|
|
599
|
+
owner=owner,
|
|
600
|
+
acquired_at=str(record["acquiredAt"]),
|
|
601
|
+
)
|
|
602
|
+
return refreshed
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _release_resource_locks(
|
|
606
|
+
state_root: Path,
|
|
607
|
+
session_id: str,
|
|
608
|
+
tokens: dict[str, str],
|
|
609
|
+
) -> bool:
|
|
610
|
+
released = True
|
|
611
|
+
for lock_name, token in sorted(tokens.items()):
|
|
612
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
613
|
+
try:
|
|
614
|
+
record = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
615
|
+
except FileNotFoundError:
|
|
616
|
+
continue
|
|
617
|
+
except (OSError, json.JSONDecodeError):
|
|
618
|
+
released = False
|
|
619
|
+
continue
|
|
620
|
+
if (
|
|
621
|
+
not isinstance(record, dict)
|
|
622
|
+
or record.get("sessionId") != session_id
|
|
623
|
+
or record.get("token") != token
|
|
624
|
+
):
|
|
625
|
+
released = False
|
|
626
|
+
continue
|
|
627
|
+
try:
|
|
628
|
+
path.unlink()
|
|
629
|
+
except OSError:
|
|
630
|
+
released = False
|
|
631
|
+
return released
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _acquire_resource_locks(
|
|
635
|
+
state_root: Path,
|
|
636
|
+
session_id: str,
|
|
637
|
+
lock_names: list[str],
|
|
638
|
+
) -> tuple[dict[str, str], dict[str, Any] | None]:
|
|
639
|
+
tokens: dict[str, str] = {}
|
|
640
|
+
for lock_name in lock_names:
|
|
641
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
642
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
643
|
+
token = uuid.uuid4().hex
|
|
644
|
+
owner_identity = _process_identity(os.getpid(), sys.executable)
|
|
645
|
+
now = time.time()
|
|
646
|
+
record = {
|
|
647
|
+
"schemaVersion": 2,
|
|
648
|
+
"lockName": lock_name,
|
|
649
|
+
"sessionId": session_id,
|
|
650
|
+
"token": token,
|
|
651
|
+
"acquiredAt": now_iso(),
|
|
652
|
+
"heartbeatAtUnix": now,
|
|
653
|
+
"expiresAtUnix": now + 300.0,
|
|
654
|
+
"owner": owner_identity,
|
|
655
|
+
}
|
|
656
|
+
encoded = (
|
|
657
|
+
json.dumps(record, ensure_ascii=False, indent=2) + "\n"
|
|
658
|
+
).encode("utf-8")
|
|
659
|
+
for attempt in range(2):
|
|
660
|
+
try:
|
|
661
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
662
|
+
except FileExistsError:
|
|
663
|
+
owner: dict[str, Any] | None = None
|
|
664
|
+
try:
|
|
665
|
+
loaded = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
666
|
+
owner = loaded if isinstance(loaded, dict) else None
|
|
667
|
+
except (OSError, json.JSONDecodeError):
|
|
668
|
+
owner = None
|
|
669
|
+
reclaimable = bool(
|
|
670
|
+
_valid_resource_lock_record(owner, lock_name=lock_name)
|
|
671
|
+
and _lock_owner_identity(owner.get("owner")) is False
|
|
672
|
+
)
|
|
673
|
+
if reclaimable and attempt == 0:
|
|
674
|
+
try:
|
|
675
|
+
current = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
676
|
+
if current.get("token") == owner.get("token"):
|
|
677
|
+
path.unlink()
|
|
678
|
+
continue
|
|
679
|
+
except (OSError, json.JSONDecodeError):
|
|
680
|
+
pass
|
|
681
|
+
_release_resource_locks(state_root, session_id, tokens)
|
|
682
|
+
return {}, {
|
|
683
|
+
"lockName": lock_name,
|
|
684
|
+
"ownerSessionId": (
|
|
685
|
+
owner.get("sessionId") if owner is not None else None
|
|
686
|
+
),
|
|
687
|
+
"lockRecordValid": bool(
|
|
688
|
+
_valid_resource_lock_record(owner, lock_name=lock_name)
|
|
689
|
+
),
|
|
690
|
+
"ownerIdentity": (
|
|
691
|
+
_lock_owner_identity(owner.get("owner"))
|
|
692
|
+
if owner is not None
|
|
693
|
+
else None
|
|
694
|
+
),
|
|
695
|
+
}
|
|
696
|
+
else:
|
|
697
|
+
break
|
|
698
|
+
else:
|
|
699
|
+
raise RuntimeError("RESOURCE_LOCK_ACQUIRE_RETRY_EXHAUSTED")
|
|
700
|
+
try:
|
|
701
|
+
with os.fdopen(fd, "wb") as handle:
|
|
702
|
+
handle.write(encoded)
|
|
703
|
+
handle.flush()
|
|
704
|
+
os.fsync(handle.fileno())
|
|
705
|
+
except BaseException:
|
|
706
|
+
path.unlink(missing_ok=True)
|
|
707
|
+
_release_resource_locks(state_root, session_id, tokens)
|
|
708
|
+
raise
|
|
709
|
+
tokens[lock_name] = token
|
|
710
|
+
return tokens, None
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _write_run_receipt(state_root: Path, receipt: dict[str, Any]) -> None:
|
|
714
|
+
_update_receipt_contract(receipt)
|
|
715
|
+
publishable = json.loads(json.dumps(receipt, ensure_ascii=False))
|
|
716
|
+
raw_tokens = publishable.pop("resourceLockTokens", {})
|
|
717
|
+
if isinstance(raw_tokens, dict):
|
|
718
|
+
publishable["resourceLockTokenHashes"] = {
|
|
719
|
+
str(name): _sha256_json(str(token))
|
|
720
|
+
for name, token in raw_tokens.items()
|
|
721
|
+
if isinstance(name, str) and isinstance(token, str)
|
|
722
|
+
}
|
|
723
|
+
atomic_write_json(
|
|
724
|
+
_run_receipt_path(state_root, str(receipt["sessionId"])),
|
|
725
|
+
publishable,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _process_identity(pid: int, executable_hint: str) -> dict[str, Any]:
|
|
730
|
+
from harness_process import observe_process_identity
|
|
731
|
+
|
|
732
|
+
observed = observe_process_identity(pid)
|
|
733
|
+
if not observed.get("executable"):
|
|
734
|
+
observed["executable"] = _absolute_executable(executable_hint)
|
|
735
|
+
observed["fieldProvenance"]["executable"] = "ATTESTED"
|
|
736
|
+
if not observed.get("createdAt"):
|
|
737
|
+
observed["createdAt"] = now_iso()
|
|
738
|
+
observed["fieldProvenance"]["createdAt"] = "ATTESTED"
|
|
739
|
+
# ``startedAt`` is a compatibility alias used by legacy session readers;
|
|
740
|
+
# canonical identity comparisons use ``createdAt`` and provenance.
|
|
741
|
+
observed["startedAt"] = observed.get("createdAt")
|
|
742
|
+
return observed
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _log_contract(path: Path) -> dict[str, Any]:
|
|
746
|
+
raw = b""
|
|
747
|
+
if path.is_file():
|
|
748
|
+
try:
|
|
749
|
+
raw = path.read_bytes()
|
|
750
|
+
except OSError:
|
|
751
|
+
raw = b""
|
|
752
|
+
try:
|
|
753
|
+
raw.decode("utf-8")
|
|
754
|
+
decode_status = "OK"
|
|
755
|
+
except UnicodeDecodeError:
|
|
756
|
+
decode_status = "LOG_DECODE_DEGRADED"
|
|
757
|
+
return {
|
|
758
|
+
"cursor": len(raw),
|
|
759
|
+
"rawDigest": "sha256:" + hashlib.sha256(raw).hexdigest(),
|
|
760
|
+
"decodeStatus": decode_status,
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _update_receipt_contract(receipt: dict[str, Any]) -> None:
|
|
765
|
+
worker = receipt.get("workerIdentity")
|
|
766
|
+
writer_source = worker if isinstance(worker, dict) else {
|
|
767
|
+
"pid": receipt.get("workerPid") or receipt.get("launcherPid")
|
|
768
|
+
}
|
|
769
|
+
heartbeat_kind = "WORKER" if isinstance(worker, dict) else "SUPERVISOR"
|
|
770
|
+
receipt["heartbeat"] = {
|
|
771
|
+
"kind": heartbeat_kind,
|
|
772
|
+
"writerIdentity": _sha256_json(writer_source),
|
|
773
|
+
"lastSeenAt": str(receipt.get("lastHeartbeatAt") or now_iso()),
|
|
774
|
+
"ttlSeconds": max(1, int(float(receipt.get("heartbeatSeconds") or 30))),
|
|
775
|
+
"staleReason": (
|
|
776
|
+
"SERVICE_HEARTBEAT_STALE"
|
|
777
|
+
if receipt.get("reasonCode") == "HEARTBEAT_LOST"
|
|
778
|
+
else None
|
|
779
|
+
),
|
|
780
|
+
}
|
|
781
|
+
receipt["logs"] = {
|
|
782
|
+
"stdout": _log_contract(Path(str(receipt.get("stdoutPath") or ""))),
|
|
783
|
+
"stderr": _log_contract(Path(str(receipt.get("stderrPath") or ""))),
|
|
784
|
+
}
|
|
785
|
+
cleanup_status = str(receipt.get("cleanupStatus") or "")
|
|
786
|
+
complete = cleanup_status in {
|
|
787
|
+
"NO_CHILD",
|
|
788
|
+
"PROCESS_EXITED",
|
|
789
|
+
"PROCESS_TREE_TERMINATED",
|
|
790
|
+
}
|
|
791
|
+
receipt["cleanup"] = {
|
|
792
|
+
"complete": complete,
|
|
793
|
+
"reasonCode": None if complete else receipt.get("reasonCode"),
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _spawn_detached_worker(
|
|
798
|
+
argv: list[str],
|
|
799
|
+
*,
|
|
800
|
+
cwd: Path,
|
|
801
|
+
env: dict[str, str],
|
|
802
|
+
output: Any = subprocess.DEVNULL,
|
|
803
|
+
) -> subprocess.Popen[bytes]:
|
|
804
|
+
kwargs: dict[str, Any] = {
|
|
805
|
+
"cwd": str(cwd),
|
|
806
|
+
"env": env,
|
|
807
|
+
"stdin": subprocess.DEVNULL,
|
|
808
|
+
"stdout": output,
|
|
809
|
+
"stderr": output,
|
|
810
|
+
"close_fds": True,
|
|
811
|
+
}
|
|
812
|
+
if os.name == "nt":
|
|
813
|
+
detached = 0x00000008
|
|
814
|
+
new_group = 0x00000200
|
|
815
|
+
no_window = 0x08000000
|
|
816
|
+
breakaway = 0x01000000
|
|
817
|
+
try:
|
|
818
|
+
return subprocess.Popen(
|
|
819
|
+
argv,
|
|
820
|
+
creationflags=detached | new_group | no_window | breakaway,
|
|
821
|
+
**kwargs,
|
|
822
|
+
)
|
|
823
|
+
except OSError:
|
|
824
|
+
return subprocess.Popen(
|
|
825
|
+
argv,
|
|
826
|
+
creationflags=detached | new_group | no_window,
|
|
827
|
+
**kwargs,
|
|
828
|
+
)
|
|
829
|
+
return subprocess.Popen(argv, start_new_session=True, **kwargs)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def start_run_session(
|
|
833
|
+
*,
|
|
834
|
+
state_root: Path,
|
|
835
|
+
verification: str,
|
|
836
|
+
argv: list[str],
|
|
837
|
+
working_directory: Path,
|
|
838
|
+
environment: dict[str, str] | None = None,
|
|
839
|
+
timeout_seconds: float | None = None,
|
|
840
|
+
heartbeat_seconds: float = 30.0,
|
|
841
|
+
expected_duration_seconds: float | None = None,
|
|
842
|
+
product_identity: str | None = None,
|
|
843
|
+
resource_locks: list[str] | None = None,
|
|
844
|
+
run_id: str | None = None,
|
|
845
|
+
session_id: str | None = None,
|
|
846
|
+
) -> dict[str, Any]:
|
|
847
|
+
"""Start a re-attachable managed command without shell re-parsing.
|
|
848
|
+
|
|
849
|
+
The short-lived launch specification is removed by the worker immediately
|
|
850
|
+
after spawn. Environment values are inherited by the worker and hashed in
|
|
851
|
+
the durable receipt; they are never serialized.
|
|
852
|
+
"""
|
|
853
|
+
if not verification.strip():
|
|
854
|
+
raise ValueError("RUN_SESSION_VERIFICATION_REQUIRED")
|
|
855
|
+
if not argv or not all(isinstance(item, str) and item for item in argv):
|
|
856
|
+
raise ValueError("RUN_SESSION_ARGV_REQUIRED")
|
|
857
|
+
if heartbeat_seconds <= 0:
|
|
858
|
+
raise ValueError("RUN_SESSION_HEARTBEAT_INVALID")
|
|
859
|
+
if timeout_seconds is not None and timeout_seconds <= 0:
|
|
860
|
+
raise ValueError("RUN_SESSION_TIMEOUT_INVALID")
|
|
861
|
+
normalized_resource_locks: list[str] = []
|
|
862
|
+
for raw_lock in resource_locks or []:
|
|
863
|
+
if (
|
|
864
|
+
not isinstance(raw_lock, str)
|
|
865
|
+
or not raw_lock.strip()
|
|
866
|
+
or len(raw_lock.strip()) > 256
|
|
867
|
+
):
|
|
868
|
+
raise ValueError("RUN_SESSION_RESOURCE_LOCK_INVALID")
|
|
869
|
+
normalized_resource_locks.append(raw_lock.strip())
|
|
870
|
+
normalized_resource_locks = sorted(set(normalized_resource_locks))
|
|
871
|
+
|
|
872
|
+
state_root = state_root.expanduser().resolve()
|
|
873
|
+
working_directory = working_directory.expanduser().resolve()
|
|
874
|
+
if not working_directory.is_dir():
|
|
875
|
+
raise ValueError(f"RUN_SESSION_WORKDIR_MISSING: {working_directory}")
|
|
876
|
+
session_id = session_id or f"run-{uuid.uuid4().hex}"
|
|
877
|
+
run_id = run_id or session_id
|
|
878
|
+
session_root = _run_session_root(state_root, session_id)
|
|
879
|
+
session_root.mkdir(parents=True, exist_ok=False)
|
|
880
|
+
created_at = now_iso()
|
|
881
|
+
stdout_path = session_root / "stdout.log"
|
|
882
|
+
stderr_path = session_root / "stderr.log"
|
|
883
|
+
worker_log_path = session_root / "worker.log"
|
|
884
|
+
spec_path = session_root / "launch-spec.json"
|
|
885
|
+
overrides = dict(environment or {})
|
|
886
|
+
command_hash = _sha256_json(argv)
|
|
887
|
+
environment_hash = _sha256_json(overrides)
|
|
888
|
+
receipt: dict[str, Any] = {
|
|
889
|
+
"schemaVersion": RUN_SESSION_SCHEMA_VERSION,
|
|
890
|
+
"sessionId": session_id,
|
|
891
|
+
"runId": run_id,
|
|
892
|
+
"verification": verification,
|
|
893
|
+
"commandHash": command_hash,
|
|
894
|
+
"workingDirectory": str(working_directory),
|
|
895
|
+
"environmentHash": environment_hash,
|
|
896
|
+
"launcherPid": os.getpid(),
|
|
897
|
+
"workerPid": None,
|
|
898
|
+
"servicePid": None,
|
|
899
|
+
"processIdentity": None,
|
|
900
|
+
"createdAt": created_at,
|
|
901
|
+
"lastHeartbeatAt": created_at,
|
|
902
|
+
"startedAt": None,
|
|
903
|
+
"endedAt": None,
|
|
904
|
+
"exitCode": None,
|
|
905
|
+
"status": "STARTING",
|
|
906
|
+
"reasonCode": "WORKER_STARTING",
|
|
907
|
+
"stage": "prepare",
|
|
908
|
+
"stageStartedAt": created_at,
|
|
909
|
+
"stageTimings": [],
|
|
910
|
+
"stdoutPath": str(stdout_path),
|
|
911
|
+
"stderrPath": str(stderr_path),
|
|
912
|
+
"workerLogPath": str(worker_log_path),
|
|
913
|
+
"resultDigest": None,
|
|
914
|
+
"cleanupStatus": "PENDING",
|
|
915
|
+
"testProcessStarted": False,
|
|
916
|
+
"timeoutSeconds": timeout_seconds,
|
|
917
|
+
"heartbeatSeconds": heartbeat_seconds,
|
|
918
|
+
"expectedDurationSeconds": expected_duration_seconds,
|
|
919
|
+
"productIdentity": product_identity,
|
|
920
|
+
"resourceLocks": normalized_resource_locks,
|
|
921
|
+
"resourceLockTokens": {},
|
|
922
|
+
"resourceWaitMs": 0,
|
|
923
|
+
"activeTimeMs": 0,
|
|
924
|
+
"wallClockMs": 0,
|
|
925
|
+
"diagnosticPath": None,
|
|
926
|
+
}
|
|
927
|
+
_write_run_receipt(state_root, receipt)
|
|
928
|
+
lock_tokens, lock_conflict = _acquire_resource_locks(
|
|
929
|
+
state_root,
|
|
930
|
+
session_id,
|
|
931
|
+
normalized_resource_locks,
|
|
932
|
+
)
|
|
933
|
+
if lock_conflict is not None:
|
|
934
|
+
receipt.update(
|
|
935
|
+
{
|
|
936
|
+
"status": "INCOMPLETE",
|
|
937
|
+
"reasonCode": "RESOURCE_LOCK_BUSY",
|
|
938
|
+
"endedAt": now_iso(),
|
|
939
|
+
"cleanupStatus": "NO_CHILD",
|
|
940
|
+
"resourceLockConflict": lock_conflict,
|
|
941
|
+
}
|
|
942
|
+
)
|
|
943
|
+
_write_run_receipt(state_root, receipt)
|
|
944
|
+
return receipt
|
|
945
|
+
receipt["resourceLockTokens"] = lock_tokens
|
|
946
|
+
atomic_write_json(
|
|
947
|
+
_resource_lock_secrets_path(state_root, session_id),
|
|
948
|
+
{"schemaVersion": 1, "tokens": lock_tokens},
|
|
949
|
+
)
|
|
950
|
+
try:
|
|
951
|
+
_resource_lock_secrets_path(state_root, session_id).chmod(0o600)
|
|
952
|
+
except OSError:
|
|
953
|
+
pass
|
|
954
|
+
_write_run_receipt(state_root, receipt)
|
|
955
|
+
atomic_write_json(
|
|
956
|
+
spec_path,
|
|
957
|
+
{
|
|
958
|
+
"schemaVersion": 1,
|
|
959
|
+
"stateRoot": str(state_root),
|
|
960
|
+
"sessionId": session_id,
|
|
961
|
+
"argv": argv,
|
|
962
|
+
"heartbeatSeconds": heartbeat_seconds,
|
|
963
|
+
},
|
|
964
|
+
)
|
|
965
|
+
try:
|
|
966
|
+
spec_path.chmod(0o600)
|
|
967
|
+
except OSError:
|
|
968
|
+
pass
|
|
969
|
+
|
|
970
|
+
worker_env = {**os.environ, **overrides}
|
|
971
|
+
worker_env["PYTHONUTF8"] = "1"
|
|
972
|
+
worker_env["PYTHONIOENCODING"] = "utf-8"
|
|
973
|
+
worker_env["HARNESS_RUN_SESSION_WORKER"] = "1"
|
|
974
|
+
worker_argv = [
|
|
975
|
+
_absolute_executable(sys.executable),
|
|
976
|
+
str(Path(__file__).resolve()),
|
|
977
|
+
"_worker",
|
|
978
|
+
"--state-root",
|
|
979
|
+
str(state_root),
|
|
980
|
+
"--session-id",
|
|
981
|
+
session_id,
|
|
982
|
+
]
|
|
983
|
+
try:
|
|
984
|
+
worker_log_path.touch()
|
|
985
|
+
worker = _spawn_detached_worker(
|
|
986
|
+
worker_argv,
|
|
987
|
+
cwd=working_directory,
|
|
988
|
+
env=worker_env,
|
|
989
|
+
)
|
|
990
|
+
except OSError as exc:
|
|
991
|
+
spec_path.unlink(missing_ok=True)
|
|
992
|
+
_resource_lock_secrets_path(state_root, session_id).unlink(missing_ok=True)
|
|
993
|
+
locks_released = _release_resource_locks(
|
|
994
|
+
state_root,
|
|
995
|
+
session_id,
|
|
996
|
+
lock_tokens,
|
|
997
|
+
)
|
|
998
|
+
receipt.update(
|
|
999
|
+
{
|
|
1000
|
+
"status": "INCOMPLETE",
|
|
1001
|
+
"reasonCode": "WORKER_LAUNCH_FAILED",
|
|
1002
|
+
"endedAt": now_iso(),
|
|
1003
|
+
"cleanupStatus": (
|
|
1004
|
+
"NO_CHILD"
|
|
1005
|
+
if locks_released
|
|
1006
|
+
else "RESOURCE_LOCK_RELEASE_FAILED"
|
|
1007
|
+
),
|
|
1008
|
+
"error": str(exc),
|
|
1009
|
+
}
|
|
1010
|
+
)
|
|
1011
|
+
_write_run_receipt(state_root, receipt)
|
|
1012
|
+
return receipt
|
|
1013
|
+
receipt["workerPid"] = worker.pid
|
|
1014
|
+
receipt["workerIdentity"] = _process_identity(worker.pid, sys.executable)
|
|
1015
|
+
# Best-effort early handoff. The worker repeats and verifies this transfer
|
|
1016
|
+
# before spawning the managed command. Do not rewrite the session receipt
|
|
1017
|
+
# here: the detached worker may already have advanced it to RUNNING.
|
|
1018
|
+
_refresh_resource_locks(
|
|
1019
|
+
state_root,
|
|
1020
|
+
session_id,
|
|
1021
|
+
lock_tokens,
|
|
1022
|
+
dict(receipt["workerIdentity"]),
|
|
1023
|
+
)
|
|
1024
|
+
threading.Thread(target=worker.wait, daemon=True).start()
|
|
1025
|
+
return receipt
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _terminate_managed_child(
|
|
1029
|
+
process: subprocess.Popen[bytes],
|
|
1030
|
+
job: Any,
|
|
1031
|
+
*,
|
|
1032
|
+
attestation: dict[str, Any] | None = None,
|
|
1033
|
+
ownership_proof: dict[str, Any] | None = None,
|
|
1034
|
+
) -> bool:
|
|
1035
|
+
from harness_process import terminate_owned_tree
|
|
1036
|
+
|
|
1037
|
+
if isinstance(attestation, dict) and isinstance(ownership_proof, dict):
|
|
1038
|
+
result = terminate_owned_tree(
|
|
1039
|
+
attestation,
|
|
1040
|
+
ownership_proof,
|
|
1041
|
+
{"graceSeconds": 3.0},
|
|
1042
|
+
)
|
|
1043
|
+
if not result.get("cleanupComplete"):
|
|
1044
|
+
return False
|
|
1045
|
+
elif job is not None and getattr(job, "handle", None) is not None:
|
|
1046
|
+
# Compatibility for callers still passing the old Windows job object;
|
|
1047
|
+
# this branch is only used for an already-owned handle, never a PID.
|
|
1048
|
+
job.terminate_and_wait()
|
|
1049
|
+
else:
|
|
1050
|
+
# PID-only cleanup is intentionally forbidden by the process provider.
|
|
1051
|
+
return False
|
|
1052
|
+
try:
|
|
1053
|
+
process.wait(timeout=5)
|
|
1054
|
+
except subprocess.TimeoutExpired:
|
|
1055
|
+
return False
|
|
1056
|
+
return process.poll() is not None
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _run_session_worker(state_root: Path, session_id: str) -> int:
|
|
1060
|
+
from harness_service import verify_process_identity
|
|
1061
|
+
|
|
1062
|
+
session_root = _run_session_root(state_root, session_id)
|
|
1063
|
+
spec_path = session_root / "launch-spec.json"
|
|
1064
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1065
|
+
receipt["resourceLockTokens"] = _load_resource_lock_tokens(
|
|
1066
|
+
state_root,
|
|
1067
|
+
session_id,
|
|
1068
|
+
receipt,
|
|
1069
|
+
)
|
|
1070
|
+
try:
|
|
1071
|
+
spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
|
|
1072
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1073
|
+
_release_resource_locks(
|
|
1074
|
+
state_root,
|
|
1075
|
+
session_id,
|
|
1076
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
1077
|
+
)
|
|
1078
|
+
receipt.update(
|
|
1079
|
+
{
|
|
1080
|
+
"status": "INCOMPLETE",
|
|
1081
|
+
"reasonCode": "LAUNCH_SPEC_INVALID",
|
|
1082
|
+
"endedAt": now_iso(),
|
|
1083
|
+
"cleanupStatus": "NO_CHILD",
|
|
1084
|
+
"error": str(exc),
|
|
1085
|
+
}
|
|
1086
|
+
)
|
|
1087
|
+
_write_run_receipt(state_root, receipt)
|
|
1088
|
+
return 1
|
|
1089
|
+
argv = [str(item) for item in spec.get("argv", [])]
|
|
1090
|
+
heartbeat_seconds = max(float(spec.get("heartbeatSeconds") or 30.0), 0.01)
|
|
1091
|
+
spec_path.unlink(missing_ok=True)
|
|
1092
|
+
started_monotonic = time.monotonic()
|
|
1093
|
+
_stage_transition(receipt, "spawn")
|
|
1094
|
+
receipt.update(
|
|
1095
|
+
{
|
|
1096
|
+
"workerPid": os.getpid(),
|
|
1097
|
+
"workerIdentity": _process_identity(os.getpid(), sys.executable),
|
|
1098
|
+
"lastHeartbeatAt": now_iso(),
|
|
1099
|
+
}
|
|
1100
|
+
)
|
|
1101
|
+
if not _refresh_resource_locks(
|
|
1102
|
+
state_root,
|
|
1103
|
+
session_id,
|
|
1104
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
1105
|
+
dict(receipt["workerIdentity"]),
|
|
1106
|
+
):
|
|
1107
|
+
receipt.update(
|
|
1108
|
+
{
|
|
1109
|
+
"status": "INCOMPLETE",
|
|
1110
|
+
"reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
1111
|
+
"endedAt": now_iso(),
|
|
1112
|
+
"cleanupStatus": "NO_CHILD",
|
|
1113
|
+
}
|
|
1114
|
+
)
|
|
1115
|
+
_write_run_receipt(state_root, receipt)
|
|
1116
|
+
return 1
|
|
1117
|
+
_write_run_receipt(state_root, receipt)
|
|
1118
|
+
|
|
1119
|
+
stdout_path = Path(str(receipt["stdoutPath"]))
|
|
1120
|
+
stderr_path = Path(str(receipt["stderrPath"]))
|
|
1121
|
+
stdout_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1122
|
+
job = None
|
|
1123
|
+
spawned = None
|
|
1124
|
+
ownership_proof: dict[str, Any] | None = None
|
|
1125
|
+
identity: dict[str, Any] | None = None
|
|
1126
|
+
process: subprocess.Popen[bytes] | None = None
|
|
1127
|
+
try:
|
|
1128
|
+
with stdout_path.open("wb", buffering=0) as stdout, stderr_path.open(
|
|
1129
|
+
"wb", buffering=0
|
|
1130
|
+
) as stderr:
|
|
1131
|
+
try:
|
|
1132
|
+
from harness_process import spawn_structured_argv
|
|
1133
|
+
|
|
1134
|
+
spawned = spawn_structured_argv(
|
|
1135
|
+
argv,
|
|
1136
|
+
cwd=Path(str(receipt["workingDirectory"])),
|
|
1137
|
+
environment={
|
|
1138
|
+
"PYTHONUTF8": "1",
|
|
1139
|
+
"PYTHONIOENCODING": "utf-8",
|
|
1140
|
+
},
|
|
1141
|
+
owner_token=f"run:{session_id}",
|
|
1142
|
+
stdout=stdout,
|
|
1143
|
+
stderr=stderr,
|
|
1144
|
+
)
|
|
1145
|
+
process = spawned.process
|
|
1146
|
+
ownership_proof = spawned.ownershipProof
|
|
1147
|
+
_capture_provider_members(spawned)
|
|
1148
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
1149
|
+
receipt.update(
|
|
1150
|
+
{
|
|
1151
|
+
"status": "INCOMPLETE",
|
|
1152
|
+
"reasonCode": "LAUNCHER_FAILED",
|
|
1153
|
+
"endedAt": now_iso(),
|
|
1154
|
+
"exitCode": None,
|
|
1155
|
+
"testProcessStarted": False,
|
|
1156
|
+
"cleanupStatus": "NO_CHILD",
|
|
1157
|
+
"error": str(exc),
|
|
1158
|
+
}
|
|
1159
|
+
)
|
|
1160
|
+
return 1
|
|
1161
|
+
assert spawned is not None and process is not None
|
|
1162
|
+
identity = spawned.attestation
|
|
1163
|
+
receipt["processOwnershipProof"] = ownership_proof
|
|
1164
|
+
_stage_transition(receipt, "execute")
|
|
1165
|
+
receipt.update(
|
|
1166
|
+
{
|
|
1167
|
+
"servicePid": process.pid,
|
|
1168
|
+
"processIdentity": identity,
|
|
1169
|
+
"startedAt": identity.get("createdAt"),
|
|
1170
|
+
"status": "RUNNING",
|
|
1171
|
+
"reasonCode": "CHILD_RUNNING",
|
|
1172
|
+
"testProcessStarted": True,
|
|
1173
|
+
"processTreeIsolated": ownership_proof is not None,
|
|
1174
|
+
}
|
|
1175
|
+
)
|
|
1176
|
+
_write_run_receipt(state_root, receipt)
|
|
1177
|
+
|
|
1178
|
+
timeout_seconds = receipt.get("timeoutSeconds")
|
|
1179
|
+
next_heartbeat = time.monotonic()
|
|
1180
|
+
termination_reason: str | None = None
|
|
1181
|
+
while process.poll() is None:
|
|
1182
|
+
now = time.monotonic()
|
|
1183
|
+
if timeout_seconds is not None and (
|
|
1184
|
+
now - started_monotonic >= float(timeout_seconds)
|
|
1185
|
+
):
|
|
1186
|
+
termination_reason = "TIMEOUT"
|
|
1187
|
+
cancel_path = session_root / "cancel-request.json"
|
|
1188
|
+
if cancel_path.is_file():
|
|
1189
|
+
termination_reason = "CANCEL_REQUESTED"
|
|
1190
|
+
if termination_reason is not None:
|
|
1191
|
+
diagnostic_path = session_root / "diagnostic.json"
|
|
1192
|
+
from harness_process import (
|
|
1193
|
+
observe_process_identity,
|
|
1194
|
+
verify_process_identity as verify_attestation,
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
verified = verify_attestation(
|
|
1198
|
+
identity,
|
|
1199
|
+
observe_process_identity(process.pid),
|
|
1200
|
+
).get("ok") is True
|
|
1201
|
+
job_owned = ownership_proof is not None
|
|
1202
|
+
if spawned is not None:
|
|
1203
|
+
_capture_provider_members(spawned)
|
|
1204
|
+
diagnostic = {
|
|
1205
|
+
"schemaVersion": 1,
|
|
1206
|
+
"sessionId": session_id,
|
|
1207
|
+
"reasonCode": termination_reason,
|
|
1208
|
+
"observedAt": now_iso(),
|
|
1209
|
+
"processIdentityVerified": verified,
|
|
1210
|
+
"terminationAuthority": (
|
|
1211
|
+
"JOB_OBJECT_OWNED"
|
|
1212
|
+
if job_owned
|
|
1213
|
+
else (
|
|
1214
|
+
"PROCESS_IDENTITY_VERIFIED"
|
|
1215
|
+
if verified
|
|
1216
|
+
else "NONE"
|
|
1217
|
+
)
|
|
1218
|
+
),
|
|
1219
|
+
"pid": process.pid,
|
|
1220
|
+
"stdoutBytes": (
|
|
1221
|
+
stdout_path.stat().st_size if stdout_path.exists() else 0
|
|
1222
|
+
),
|
|
1223
|
+
"stderrBytes": (
|
|
1224
|
+
stderr_path.stat().st_size if stderr_path.exists() else 0
|
|
1225
|
+
),
|
|
1226
|
+
}
|
|
1227
|
+
atomic_write_json(diagnostic_path, diagnostic)
|
|
1228
|
+
receipt["diagnosticPath"] = str(diagnostic_path)
|
|
1229
|
+
if job_owned and verified:
|
|
1230
|
+
terminated = _terminate_managed_child(
|
|
1231
|
+
process,
|
|
1232
|
+
job,
|
|
1233
|
+
attestation=identity,
|
|
1234
|
+
ownership_proof=ownership_proof,
|
|
1235
|
+
)
|
|
1236
|
+
job = None
|
|
1237
|
+
if not terminated:
|
|
1238
|
+
receipt.update(
|
|
1239
|
+
{
|
|
1240
|
+
"status": "INCOMPLETE",
|
|
1241
|
+
"reasonCode": "PROCESS_TERMINATION_UNCONFIRMED",
|
|
1242
|
+
"cleanupStatus": "PROCESS_TERMINATION_UNCONFIRMED",
|
|
1243
|
+
}
|
|
1244
|
+
)
|
|
1245
|
+
else:
|
|
1246
|
+
receipt.update(
|
|
1247
|
+
{
|
|
1248
|
+
"status": "INCOMPLETE",
|
|
1249
|
+
"reasonCode": "PROCESS_IDENTITY_UNVERIFIED",
|
|
1250
|
+
"cleanupStatus": "UNKNOWN_PROCESS_UNTOUCHED",
|
|
1251
|
+
}
|
|
1252
|
+
)
|
|
1253
|
+
break
|
|
1254
|
+
if now >= next_heartbeat:
|
|
1255
|
+
receipt["lastHeartbeatAt"] = now_iso()
|
|
1256
|
+
receipt["activeTimeMs"] = int(
|
|
1257
|
+
(now - started_monotonic) * 1000
|
|
1258
|
+
)
|
|
1259
|
+
receipt["wallClockMs"] = receipt["activeTimeMs"]
|
|
1260
|
+
if not _refresh_resource_locks(
|
|
1261
|
+
state_root,
|
|
1262
|
+
session_id,
|
|
1263
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
1264
|
+
dict(receipt["workerIdentity"]),
|
|
1265
|
+
):
|
|
1266
|
+
receipt.update(
|
|
1267
|
+
{
|
|
1268
|
+
"status": "INCOMPLETE",
|
|
1269
|
+
"reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
1270
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATION_REQUIRED",
|
|
1271
|
+
}
|
|
1272
|
+
)
|
|
1273
|
+
if spawned is not None:
|
|
1274
|
+
_capture_provider_members(spawned)
|
|
1275
|
+
_terminate_managed_child(
|
|
1276
|
+
process,
|
|
1277
|
+
job,
|
|
1278
|
+
attestation=identity,
|
|
1279
|
+
ownership_proof=ownership_proof,
|
|
1280
|
+
)
|
|
1281
|
+
job = None
|
|
1282
|
+
break
|
|
1283
|
+
_write_run_receipt(state_root, receipt)
|
|
1284
|
+
next_heartbeat = now + heartbeat_seconds
|
|
1285
|
+
time.sleep(min(heartbeat_seconds, 0.05))
|
|
1286
|
+
|
|
1287
|
+
exit_code = process.poll()
|
|
1288
|
+
_stage_transition(receipt, "summarize")
|
|
1289
|
+
if termination_reason is not None and receipt["reasonCode"] not in {
|
|
1290
|
+
"PROCESS_IDENTITY_UNVERIFIED",
|
|
1291
|
+
"PROCESS_TERMINATION_UNCONFIRMED",
|
|
1292
|
+
"RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
1293
|
+
}:
|
|
1294
|
+
receipt.update(
|
|
1295
|
+
{
|
|
1296
|
+
"status": "CANCELLED",
|
|
1297
|
+
"reasonCode": termination_reason,
|
|
1298
|
+
"exitCode": exit_code,
|
|
1299
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATED",
|
|
1300
|
+
}
|
|
1301
|
+
)
|
|
1302
|
+
elif receipt["reasonCode"] not in {
|
|
1303
|
+
"PROCESS_IDENTITY_UNVERIFIED",
|
|
1304
|
+
"PROCESS_TERMINATION_UNCONFIRMED",
|
|
1305
|
+
"RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
1306
|
+
}:
|
|
1307
|
+
receipt.update(
|
|
1308
|
+
{
|
|
1309
|
+
"status": "OK" if exit_code == 0 else "FAIL",
|
|
1310
|
+
"reasonCode": (
|
|
1311
|
+
"CHILD_EXIT_ZERO"
|
|
1312
|
+
if exit_code == 0
|
|
1313
|
+
else "CHILD_EXIT_NONZERO"
|
|
1314
|
+
),
|
|
1315
|
+
"exitCode": exit_code,
|
|
1316
|
+
"cleanupStatus": "PROCESS_EXITED",
|
|
1317
|
+
}
|
|
1318
|
+
)
|
|
1319
|
+
except BaseException as exc:
|
|
1320
|
+
receipt.update(
|
|
1321
|
+
{
|
|
1322
|
+
"status": "INCOMPLETE",
|
|
1323
|
+
"reasonCode": "WORKER_FAILED",
|
|
1324
|
+
"exitCode": None if process is None else process.poll(),
|
|
1325
|
+
"cleanupStatus": "WORKER_EXCEPTION",
|
|
1326
|
+
"error": str(exc),
|
|
1327
|
+
}
|
|
1328
|
+
)
|
|
1329
|
+
finally:
|
|
1330
|
+
if process is not None and process.poll() is None and ownership_proof is not None:
|
|
1331
|
+
if spawned is not None:
|
|
1332
|
+
_capture_provider_members(spawned)
|
|
1333
|
+
tree_drained = _terminate_managed_child(
|
|
1334
|
+
process,
|
|
1335
|
+
job,
|
|
1336
|
+
attestation=identity if isinstance(identity, dict) else None,
|
|
1337
|
+
ownership_proof=ownership_proof,
|
|
1338
|
+
)
|
|
1339
|
+
if not tree_drained:
|
|
1340
|
+
receipt.update(
|
|
1341
|
+
{
|
|
1342
|
+
"status": "INCOMPLETE",
|
|
1343
|
+
"reasonCode": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
|
|
1344
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
|
|
1345
|
+
}
|
|
1346
|
+
)
|
|
1347
|
+
elif receipt.get("cleanupStatus") == "PROCESS_EXITED":
|
|
1348
|
+
receipt["cleanupStatus"] = "PROCESS_TREE_TERMINATED"
|
|
1349
|
+
ended = time.monotonic()
|
|
1350
|
+
_stage_transition(receipt, "record")
|
|
1351
|
+
receipt["endedAt"] = now_iso()
|
|
1352
|
+
receipt["lastHeartbeatAt"] = receipt["endedAt"]
|
|
1353
|
+
receipt["activeTimeMs"] = int((ended - started_monotonic) * 1000)
|
|
1354
|
+
receipt["wallClockMs"] = receipt["activeTimeMs"] + int(
|
|
1355
|
+
receipt.get("resourceWaitMs") or 0
|
|
1356
|
+
)
|
|
1357
|
+
digest = hashlib.sha256()
|
|
1358
|
+
for path in (stdout_path, stderr_path):
|
|
1359
|
+
if path.is_file():
|
|
1360
|
+
digest.update(path.read_bytes())
|
|
1361
|
+
receipt["resultDigest"] = "sha256:" + digest.hexdigest()
|
|
1362
|
+
locks_released = _release_resource_locks(
|
|
1363
|
+
state_root,
|
|
1364
|
+
session_id,
|
|
1365
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
1366
|
+
)
|
|
1367
|
+
try:
|
|
1368
|
+
_resource_lock_secrets_path(state_root, session_id).unlink(missing_ok=True)
|
|
1369
|
+
except OSError:
|
|
1370
|
+
locks_released = False
|
|
1371
|
+
if not locks_released:
|
|
1372
|
+
receipt.update(
|
|
1373
|
+
{
|
|
1374
|
+
"status": "INCOMPLETE",
|
|
1375
|
+
"reasonCode": "RESOURCE_LOCK_RELEASE_FAILED",
|
|
1376
|
+
"cleanupStatus": "RESOURCE_LOCK_RELEASE_FAILED",
|
|
1377
|
+
}
|
|
1378
|
+
)
|
|
1379
|
+
_close_current_stage(receipt)
|
|
1380
|
+
_write_run_receipt(state_root, receipt)
|
|
1381
|
+
return 0 if receipt.get("status") == "OK" else 1
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _run_session_worker_with_log(state_root: Path, session_id: str) -> int:
|
|
1385
|
+
worker_log_path = _run_session_root(state_root, session_id) / "worker.log"
|
|
1386
|
+
try:
|
|
1387
|
+
worker_log = worker_log_path.open(
|
|
1388
|
+
"a",
|
|
1389
|
+
encoding="utf-8",
|
|
1390
|
+
errors="replace",
|
|
1391
|
+
buffering=1,
|
|
1392
|
+
)
|
|
1393
|
+
except OSError:
|
|
1394
|
+
return _run_session_worker(state_root, session_id)
|
|
1395
|
+
with worker_log:
|
|
1396
|
+
with (
|
|
1397
|
+
contextlib.redirect_stdout(worker_log),
|
|
1398
|
+
contextlib.redirect_stderr(worker_log),
|
|
1399
|
+
):
|
|
1400
|
+
return _run_session_worker(state_root, session_id)
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def run_session_status(state_root: Path, session_id: str) -> dict[str, Any]:
|
|
1404
|
+
from harness_service import is_pid_alive
|
|
1405
|
+
|
|
1406
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1407
|
+
worker_pid = receipt.get("workerPid")
|
|
1408
|
+
worker_identity = receipt.get("workerIdentity")
|
|
1409
|
+
if receipt.get("status") in RUN_TERMINAL_STATUSES:
|
|
1410
|
+
if (
|
|
1411
|
+
isinstance(worker_pid, int)
|
|
1412
|
+
and worker_pid > 0
|
|
1413
|
+
and is_pid_alive(worker_pid)
|
|
1414
|
+
and _receipt_identity_state(receipt) is True
|
|
1415
|
+
):
|
|
1416
|
+
finalizing = dict(receipt)
|
|
1417
|
+
finalizing.update(
|
|
1418
|
+
{
|
|
1419
|
+
"status": "FINALIZING",
|
|
1420
|
+
"reasonCode": "WORKER_FINALIZING",
|
|
1421
|
+
}
|
|
1422
|
+
)
|
|
1423
|
+
return finalizing
|
|
1424
|
+
return receipt
|
|
1425
|
+
heartbeat_text = str(receipt.get("lastHeartbeatAt") or "")
|
|
1426
|
+
try:
|
|
1427
|
+
heartbeat_at = dt.datetime.fromisoformat(
|
|
1428
|
+
heartbeat_text.replace("Z", "+00:00")
|
|
1429
|
+
)
|
|
1430
|
+
if heartbeat_at.tzinfo is None:
|
|
1431
|
+
heartbeat_at = heartbeat_at.astimezone()
|
|
1432
|
+
heartbeat_age = (
|
|
1433
|
+
dt.datetime.now().astimezone() - heartbeat_at
|
|
1434
|
+
).total_seconds()
|
|
1435
|
+
except ValueError:
|
|
1436
|
+
heartbeat_age = float("inf")
|
|
1437
|
+
grace = max(float(receipt.get("heartbeatSeconds") or 30.0) * 4, 2.0)
|
|
1438
|
+
startup_grace = max(grace, 10.0)
|
|
1439
|
+
# The detached worker owns the durable identity record. The launcher can
|
|
1440
|
+
# return before the worker has published it, so STARTING is intentionally
|
|
1441
|
+
# treated as a bounded handshake window rather than an identity failure.
|
|
1442
|
+
if (
|
|
1443
|
+
receipt.get("status") == "STARTING"
|
|
1444
|
+
and receipt.get("workerIdentity") is None
|
|
1445
|
+
and heartbeat_age < startup_grace
|
|
1446
|
+
):
|
|
1447
|
+
return receipt
|
|
1448
|
+
identity_state = _receipt_identity_state(receipt)
|
|
1449
|
+
worker_gone = (
|
|
1450
|
+
isinstance(worker_pid, int)
|
|
1451
|
+
and worker_pid > 0
|
|
1452
|
+
and not is_pid_alive(worker_pid)
|
|
1453
|
+
)
|
|
1454
|
+
if worker_gone or identity_state is False or heartbeat_age >= grace:
|
|
1455
|
+
latest = _load_run_receipt(state_root, session_id)
|
|
1456
|
+
if latest.get("status") in RUN_TERMINAL_STATUSES:
|
|
1457
|
+
return latest
|
|
1458
|
+
if heartbeat_age < grace and worker_gone and identity_state is not False:
|
|
1459
|
+
return latest
|
|
1460
|
+
receipt.update(
|
|
1461
|
+
{
|
|
1462
|
+
"status": "INCOMPLETE",
|
|
1463
|
+
"reasonCode": (
|
|
1464
|
+
"WORKER_IDENTITY_MISMATCH"
|
|
1465
|
+
if identity_state is False and not worker_gone
|
|
1466
|
+
else "HEARTBEAT_LOST"
|
|
1467
|
+
),
|
|
1468
|
+
"endedAt": now_iso(),
|
|
1469
|
+
"cleanupStatus": "WORKER_EXITED_WITHOUT_FINAL_RECEIPT",
|
|
1470
|
+
}
|
|
1471
|
+
)
|
|
1472
|
+
_write_run_receipt(state_root, receipt)
|
|
1473
|
+
return receipt
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def read_run_session_log(
|
|
1477
|
+
state_root: Path,
|
|
1478
|
+
session_id: str,
|
|
1479
|
+
*,
|
|
1480
|
+
stream: str = "stdout",
|
|
1481
|
+
cursor: int = 0,
|
|
1482
|
+
max_bytes: int = 64 * 1024,
|
|
1483
|
+
) -> dict[str, Any]:
|
|
1484
|
+
if stream not in {"stdout", "stderr"}:
|
|
1485
|
+
raise ValueError(f"RUN_SESSION_STREAM_INVALID: {stream}")
|
|
1486
|
+
if cursor < 0 or max_bytes <= 0:
|
|
1487
|
+
raise ValueError("RUN_SESSION_CURSOR_INVALID")
|
|
1488
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1489
|
+
path = Path(str(receipt[f"{stream}Path"]))
|
|
1490
|
+
raw = b""
|
|
1491
|
+
decode_status = "OK"
|
|
1492
|
+
size = path.stat().st_size if path.is_file() else 0
|
|
1493
|
+
if path.is_file() and cursor < size:
|
|
1494
|
+
with path.open("rb") as handle:
|
|
1495
|
+
handle.seek(cursor)
|
|
1496
|
+
raw = handle.read(max_bytes)
|
|
1497
|
+
while raw:
|
|
1498
|
+
try:
|
|
1499
|
+
text = raw.decode("utf-8")
|
|
1500
|
+
break
|
|
1501
|
+
except UnicodeDecodeError as exc:
|
|
1502
|
+
if exc.end != len(raw):
|
|
1503
|
+
# An invalid sequence in the middle is durable evidence,
|
|
1504
|
+
# so consume it and expose replacement text instead of
|
|
1505
|
+
# silently dropping bytes from the cursor stream.
|
|
1506
|
+
text = raw.decode("utf-8", errors="replace")
|
|
1507
|
+
decode_status = "LOG_DECODE_DEGRADED"
|
|
1508
|
+
break
|
|
1509
|
+
if (
|
|
1510
|
+
receipt.get("status") in RUN_TERMINAL_STATUSES
|
|
1511
|
+
and cursor + len(raw) >= size
|
|
1512
|
+
):
|
|
1513
|
+
# Once the producer is terminal, an incomplete trailing
|
|
1514
|
+
# sequence cannot be completed by a later write. Preserve
|
|
1515
|
+
# and consume it with an explicit degraded status.
|
|
1516
|
+
text = raw.decode("utf-8", errors="replace")
|
|
1517
|
+
decode_status = "LOG_DECODE_DEGRADED"
|
|
1518
|
+
break
|
|
1519
|
+
# A live writer may have split a multibyte character across
|
|
1520
|
+
# this read. Keep the incomplete suffix for the next cursor
|
|
1521
|
+
# request so callers never see duplicate replacement glyphs.
|
|
1522
|
+
raw = raw[: exc.start]
|
|
1523
|
+
else:
|
|
1524
|
+
text = ""
|
|
1525
|
+
else:
|
|
1526
|
+
text = ""
|
|
1527
|
+
next_cursor = cursor + len(raw)
|
|
1528
|
+
terminal = receipt.get("status") in RUN_TERMINAL_STATUSES
|
|
1529
|
+
return {
|
|
1530
|
+
"ok": True,
|
|
1531
|
+
"sessionId": session_id,
|
|
1532
|
+
"stream": stream,
|
|
1533
|
+
"cursor": cursor,
|
|
1534
|
+
"nextCursor": next_cursor,
|
|
1535
|
+
"text": text,
|
|
1536
|
+
"eof": terminal and next_cursor >= size,
|
|
1537
|
+
"status": receipt.get("status"),
|
|
1538
|
+
"rawDigest": "sha256:" + hashlib.sha256(raw).hexdigest(),
|
|
1539
|
+
"decodeStatus": decode_status,
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def cancel_run_session(
|
|
1544
|
+
state_root: Path,
|
|
1545
|
+
session_id: str,
|
|
1546
|
+
*,
|
|
1547
|
+
reason: str = "CANCEL_REQUESTED",
|
|
1548
|
+
) -> dict[str, Any]:
|
|
1549
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1550
|
+
if receipt.get("status") in RUN_TERMINAL_STATUSES:
|
|
1551
|
+
return receipt
|
|
1552
|
+
if _receipt_identity_state(receipt) is not True:
|
|
1553
|
+
return {
|
|
1554
|
+
**receipt,
|
|
1555
|
+
"ok": False,
|
|
1556
|
+
"code": "WORKER_IDENTITY_UNVERIFIED",
|
|
1557
|
+
"unknownProcessUntouched": True,
|
|
1558
|
+
}
|
|
1559
|
+
atomic_write_json(
|
|
1560
|
+
_run_session_root(state_root, session_id) / "cancel-request.json",
|
|
1561
|
+
{"reasonCode": reason, "requestedAt": now_iso()},
|
|
1562
|
+
)
|
|
1563
|
+
return {**receipt, "ok": True, "action": "cancel-requested"}
|
|
1564
|
+
|
|
1565
|
+
|
|
54
1566
|
def adapter_worktree(agent: str, change_id: str) -> dict[str, str]:
|
|
55
1567
|
if agent not in _ADAPTERS:
|
|
56
1568
|
raise ValueError(f"ADAPTER_UNKNOWN: {agent}")
|
|
@@ -221,6 +1733,35 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
221
1733
|
p_adapter.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
|
|
222
1734
|
p_adapter.add_argument("--change", required=True)
|
|
223
1735
|
p_adapter.add_argument("--json", action="store_true")
|
|
1736
|
+
p_start = sub.add_parser("run-start", help="start a managed background run")
|
|
1737
|
+
p_start.add_argument("--state-root", required=True)
|
|
1738
|
+
p_start.add_argument("--verification", required=True)
|
|
1739
|
+
p_start.add_argument("--working-directory", required=True)
|
|
1740
|
+
p_start.add_argument("--timeout-seconds", type=float)
|
|
1741
|
+
p_start.add_argument("--heartbeat-seconds", type=float, default=30.0)
|
|
1742
|
+
p_start.add_argument("--expected-duration-seconds", type=float)
|
|
1743
|
+
p_start.add_argument("--product-identity")
|
|
1744
|
+
p_start.add_argument("--resource-lock", action="append", default=[])
|
|
1745
|
+
p_start.add_argument("--json", action="store_true")
|
|
1746
|
+
p_start.add_argument("argv", nargs=argparse.REMAINDER)
|
|
1747
|
+
p_status = sub.add_parser("run-status", help="read a managed run receipt")
|
|
1748
|
+
p_status.add_argument("--state-root", required=True)
|
|
1749
|
+
p_status.add_argument("--session-id", required=True)
|
|
1750
|
+
p_status.add_argument("--json", action="store_true")
|
|
1751
|
+
p_log = sub.add_parser("run-log", help="read an incremental log page")
|
|
1752
|
+
p_log.add_argument("--state-root", required=True)
|
|
1753
|
+
p_log.add_argument("--session-id", required=True)
|
|
1754
|
+
p_log.add_argument("--stream", choices=("stdout", "stderr"), default="stdout")
|
|
1755
|
+
p_log.add_argument("--cursor", type=int, default=0)
|
|
1756
|
+
p_log.add_argument("--max-bytes", type=int, default=64 * 1024)
|
|
1757
|
+
p_log.add_argument("--json", action="store_true")
|
|
1758
|
+
p_cancel = sub.add_parser("run-cancel", help="request identity-safe cancellation")
|
|
1759
|
+
p_cancel.add_argument("--state-root", required=True)
|
|
1760
|
+
p_cancel.add_argument("--session-id", required=True)
|
|
1761
|
+
p_cancel.add_argument("--json", action="store_true")
|
|
1762
|
+
p_worker = sub.add_parser("_worker", help=argparse.SUPPRESS)
|
|
1763
|
+
p_worker.add_argument("--state-root", required=True)
|
|
1764
|
+
p_worker.add_argument("--session-id", required=True)
|
|
224
1765
|
return parser
|
|
225
1766
|
|
|
226
1767
|
|
|
@@ -229,15 +1770,47 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
229
1770
|
try:
|
|
230
1771
|
if args.command == "doctor":
|
|
231
1772
|
result = doctor(Path(args.project), Path(args.change_dir), agent=args.agent)
|
|
232
|
-
|
|
1773
|
+
elif args.command == "adapter":
|
|
233
1774
|
result = {"ok": True, "action": "adapter", **adapter_worktree(args.agent, args.change)}
|
|
1775
|
+
elif args.command == "_worker":
|
|
1776
|
+
return _run_session_worker_with_log(
|
|
1777
|
+
Path(args.state_root),
|
|
1778
|
+
args.session_id,
|
|
1779
|
+
)
|
|
1780
|
+
elif args.command == "run-start":
|
|
1781
|
+
command_argv = list(args.argv)
|
|
1782
|
+
if command_argv and command_argv[0] == "--":
|
|
1783
|
+
command_argv = command_argv[1:]
|
|
1784
|
+
result = start_run_session(
|
|
1785
|
+
state_root=Path(args.state_root),
|
|
1786
|
+
verification=args.verification,
|
|
1787
|
+
argv=command_argv,
|
|
1788
|
+
working_directory=Path(args.working_directory),
|
|
1789
|
+
timeout_seconds=args.timeout_seconds,
|
|
1790
|
+
heartbeat_seconds=args.heartbeat_seconds,
|
|
1791
|
+
expected_duration_seconds=args.expected_duration_seconds,
|
|
1792
|
+
product_identity=args.product_identity,
|
|
1793
|
+
resource_locks=list(args.resource_lock),
|
|
1794
|
+
)
|
|
1795
|
+
elif args.command == "run-status":
|
|
1796
|
+
result = run_session_status(Path(args.state_root), args.session_id)
|
|
1797
|
+
elif args.command == "run-log":
|
|
1798
|
+
result = read_run_session_log(
|
|
1799
|
+
Path(args.state_root),
|
|
1800
|
+
args.session_id,
|
|
1801
|
+
stream=args.stream,
|
|
1802
|
+
cursor=args.cursor,
|
|
1803
|
+
max_bytes=args.max_bytes,
|
|
1804
|
+
)
|
|
1805
|
+
else:
|
|
1806
|
+
result = cancel_run_session(Path(args.state_root), args.session_id)
|
|
234
1807
|
except (OSError, ValueError) as exc:
|
|
235
1808
|
code = str(exc).split(":", 1)[0]
|
|
236
1809
|
result = {"ok": False, "code": code, "error": str(exc)}
|
|
237
1810
|
stream = sys.stderr
|
|
238
1811
|
stream.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
239
1812
|
return 1
|
|
240
|
-
if args
|
|
1813
|
+
if getattr(args, "json", False):
|
|
241
1814
|
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
|
|
242
1815
|
else:
|
|
243
1816
|
sys.stdout.write(str(result.get("action")) + "\n")
|