@hunter-harness/workflow-harness 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/pi/.harness-build.json +6 -0
- package/harness/bundles/general/pi/CONTEXT.md +98 -0
- package/harness/bundles/general/pi/README.md +478 -0
- package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
- package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
- package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/general/pi/harness-review/reference.md +114 -0
- package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
- package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
- package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/general/pi/harness-run/reference.md +886 -0
- package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
- package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
- package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
- package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/general/pi/harness-test/reference.md +880 -0
- package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/pi/.harness-build.json +6 -0
- package/harness/bundles/java/pi/CONTEXT.md +98 -0
- package/harness/bundles/java/pi/README.md +478 -0
- package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
- package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
- package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
- package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
- package/harness/bundles/java/pi/harness-package/reference.md +210 -0
- package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
- package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
- package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/java/pi/harness-review/reference.md +114 -0
- package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
- package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
- package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/java/pi/harness-run/reference.md +820 -0
- package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
- package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
- package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
- package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
- package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/java/pi/harness-test/reference.md +792 -0
- package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/manifests/general/claude-code.json +17 -17
- package/harness/manifests/general/codebuddy.json +17 -17
- package/harness/manifests/general/codex.json +17 -17
- package/harness/manifests/general/cursor.json +17 -17
- package/harness/manifests/general/pi.json +475 -0
- package/harness/manifests/java/claude-code.json +19 -19
- package/harness/manifests/java/codebuddy.json +19 -19
- package/harness/manifests/java/codex.json +19 -19
- package/harness/manifests/java/cursor.json +19 -19
- package/harness/manifests/java/pi.json +499 -0
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1586 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resource-safe test command runner for Hunter Harness workflows.
|
|
3
|
+
|
|
4
|
+
The runner keeps test commands sequential, lowers their scheduling priority,
|
|
5
|
+
enforces a single active run per project, and tears down the managed process
|
|
6
|
+
tree after every command. Resource-intensive profiles require an explicit
|
|
7
|
+
confirmation outside CI.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import ctypes
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import signal
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Mapping, Sequence
|
|
28
|
+
|
|
29
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
30
|
+
if str(SCRIPT_DIR) not in sys.path:
|
|
31
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
32
|
+
|
|
33
|
+
import harness_environment as henv
|
|
34
|
+
import harness_process as hprocess
|
|
35
|
+
|
|
36
|
+
DEFAULT_MAX_WORKERS = 2
|
|
37
|
+
DEFAULT_TIMEOUT_SECONDS = 300.0
|
|
38
|
+
RESOURCE_INTENSIVE_PROFILES = frozenset({"system", "full"})
|
|
39
|
+
DEFAULT_RESOURCE_MODULES = (
|
|
40
|
+
"test_harness_integration.py",
|
|
41
|
+
"test_harness_service.py",
|
|
42
|
+
)
|
|
43
|
+
DEFAULT_DETACHED_SERVICE_MODULES = (
|
|
44
|
+
"test_harness_service.py",
|
|
45
|
+
"test_harness_runtime.py",
|
|
46
|
+
)
|
|
47
|
+
TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class TestModule:
|
|
52
|
+
"""One isolated unittest module."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
path: Path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class CommandResult:
|
|
60
|
+
"""Result of one managed command."""
|
|
61
|
+
|
|
62
|
+
returncode: int
|
|
63
|
+
timed_out: bool
|
|
64
|
+
duration_seconds: float
|
|
65
|
+
process_tree_isolated: bool
|
|
66
|
+
output_tail: str = ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestRunAlreadyActive(RuntimeError):
|
|
70
|
+
"""Raised when another resource-safe runner owns the project lock."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ManagedCommandNotFound(FileNotFoundError):
|
|
74
|
+
"""Raised when a structured command cannot be resolved without a shell."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ManagedCommandUnsafe(ValueError):
|
|
78
|
+
"""Raised when a Windows batch argument could be interpreted by cmd.exe."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_WINDOWS_BATCH_SUFFIXES = {".bat", ".cmd"}
|
|
82
|
+
_WINDOWS_CMD_META = re.compile(r'[\r\n&|<>^%!\"]')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_managed_argv(
|
|
86
|
+
argv: Sequence[str], *, platform_name: str | None = None
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Fail closed when Windows would reinterpret a batch argv boundary."""
|
|
89
|
+
|
|
90
|
+
platform = platform_name or os.name
|
|
91
|
+
if (
|
|
92
|
+
platform != "nt"
|
|
93
|
+
or not argv
|
|
94
|
+
or Path(argv[0]).suffix.casefold() not in _WINDOWS_BATCH_SUFFIXES
|
|
95
|
+
):
|
|
96
|
+
return
|
|
97
|
+
for index, argument in enumerate(argv):
|
|
98
|
+
if _WINDOWS_CMD_META.search(str(argument)):
|
|
99
|
+
raise ManagedCommandUnsafe(
|
|
100
|
+
"MANAGED_BATCH_ARGUMENT_UNSAFE: "
|
|
101
|
+
f"Windows 批处理命令的第 {index} 个参数包含命令解释符;"
|
|
102
|
+
"请改用原生可执行文件或不含命令解释符的参数"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_managed_argv(
|
|
107
|
+
argv: Sequence[str],
|
|
108
|
+
*,
|
|
109
|
+
environ: Mapping[str, str],
|
|
110
|
+
platform_name: str | None = None,
|
|
111
|
+
) -> list[str]:
|
|
112
|
+
"""Resolve argv[0] through PATH/PATHEXT while preserving argv boundaries."""
|
|
113
|
+
|
|
114
|
+
if not argv or not isinstance(argv[0], str) or not argv[0].strip():
|
|
115
|
+
raise ManagedCommandNotFound("MANAGED_COMMAND_NOT_FOUND: 命令不能为空")
|
|
116
|
+
command = argv[0].strip()
|
|
117
|
+
platform = platform_name or os.name
|
|
118
|
+
windows_extensions = [
|
|
119
|
+
item.strip() if item.strip().startswith(".") else f".{item.strip()}"
|
|
120
|
+
for item in str(
|
|
121
|
+
environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD"
|
|
122
|
+
).split(";")
|
|
123
|
+
if item.strip()
|
|
124
|
+
] if platform == "nt" else []
|
|
125
|
+
windows_extension_set = {item.casefold() for item in windows_extensions}
|
|
126
|
+
has_path = Path(command).is_absolute() or "/" in command or "\\" in command
|
|
127
|
+
if has_path:
|
|
128
|
+
requested = Path(command).expanduser()
|
|
129
|
+
suffix = requested.suffix.casefold()
|
|
130
|
+
candidates = (
|
|
131
|
+
[Path(str(requested) + extension) for extension in windows_extensions]
|
|
132
|
+
+ [requested]
|
|
133
|
+
if platform == "nt" and suffix not in windows_extension_set
|
|
134
|
+
else [requested]
|
|
135
|
+
)
|
|
136
|
+
for candidate in candidates:
|
|
137
|
+
if candidate.is_file():
|
|
138
|
+
return [str(candidate.resolve()), *list(argv[1:])]
|
|
139
|
+
raise ManagedCommandNotFound(
|
|
140
|
+
f"MANAGED_COMMAND_NOT_FOUND: 找不到可执行命令 {command}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
path_value = str(environ.get("PATH") or "")
|
|
144
|
+
resolved: str | None = None
|
|
145
|
+
if platform == "nt":
|
|
146
|
+
suffix = Path(command).suffix.casefold()
|
|
147
|
+
names = (
|
|
148
|
+
[command]
|
|
149
|
+
if suffix and suffix in windows_extension_set
|
|
150
|
+
else [*(command + item for item in windows_extensions), command]
|
|
151
|
+
)
|
|
152
|
+
for raw_directory in path_value.split(";"):
|
|
153
|
+
directory = raw_directory.strip().strip('"') or "."
|
|
154
|
+
for name in names:
|
|
155
|
+
candidate = Path(directory) / name
|
|
156
|
+
if candidate.is_file():
|
|
157
|
+
resolved = str(candidate.resolve())
|
|
158
|
+
break
|
|
159
|
+
if resolved is not None:
|
|
160
|
+
break
|
|
161
|
+
else:
|
|
162
|
+
resolved = shutil.which(command, path=path_value or None)
|
|
163
|
+
|
|
164
|
+
if resolved is None:
|
|
165
|
+
raise ManagedCommandNotFound(
|
|
166
|
+
f"MANAGED_COMMAND_NOT_FOUND: 找不到可执行命令 {command}"
|
|
167
|
+
)
|
|
168
|
+
return [resolved, *list(argv[1:])]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _truthy(value: object) -> bool:
|
|
172
|
+
return str(value or "").strip().lower() in TRUTHY_VALUES
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def bounded_worker_count(
|
|
176
|
+
requested: int,
|
|
177
|
+
environ: Mapping[str, str] | None = None,
|
|
178
|
+
) -> int:
|
|
179
|
+
"""Return a positive worker count that never exceeds the global safe cap."""
|
|
180
|
+
|
|
181
|
+
env = os.environ if environ is None else environ
|
|
182
|
+
raw_limit = env.get("HARNESS_TEST_MAX_WORKERS", str(DEFAULT_MAX_WORKERS))
|
|
183
|
+
try:
|
|
184
|
+
configured = int(raw_limit)
|
|
185
|
+
except (TypeError, ValueError):
|
|
186
|
+
configured = DEFAULT_MAX_WORKERS
|
|
187
|
+
configured = max(1, min(DEFAULT_MAX_WORKERS, configured))
|
|
188
|
+
return max(1, min(int(requested), configured))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resource_profile_allowed(
|
|
192
|
+
profile: str,
|
|
193
|
+
confirmed: bool,
|
|
194
|
+
environ: Mapping[str, str] | None = None,
|
|
195
|
+
) -> bool:
|
|
196
|
+
"""Return whether a profile may execute in the current environment."""
|
|
197
|
+
|
|
198
|
+
if profile not in RESOURCE_INTENSIVE_PROFILES:
|
|
199
|
+
return True
|
|
200
|
+
env = os.environ if environ is None else environ
|
|
201
|
+
return bool(
|
|
202
|
+
confirmed
|
|
203
|
+
or _truthy(env.get("CI"))
|
|
204
|
+
or _truthy(env.get("HARNESS_ALLOW_RESOURCE_INTENSIVE_TESTS"))
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def discover_test_modules(tests_dir: Path) -> list[TestModule]:
|
|
209
|
+
"""Discover deterministic top-level unittest modules."""
|
|
210
|
+
|
|
211
|
+
root = Path(tests_dir).resolve()
|
|
212
|
+
if not root.is_dir():
|
|
213
|
+
raise FileNotFoundError(f"test directory does not exist: {root}")
|
|
214
|
+
return [
|
|
215
|
+
TestModule(path.name, path)
|
|
216
|
+
for path in sorted(root.glob("test_*.py"), key=lambda item: item.name)
|
|
217
|
+
if path.is_file()
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def build_unittest_plan(
|
|
222
|
+
tests_dir: Path,
|
|
223
|
+
profile: str,
|
|
224
|
+
*,
|
|
225
|
+
resource_modules: Sequence[str] = DEFAULT_RESOURCE_MODULES,
|
|
226
|
+
) -> list[TestModule]:
|
|
227
|
+
"""Partition unittest modules into safe, system, or full execution plans."""
|
|
228
|
+
|
|
229
|
+
if profile not in {"safe", "system", "full"}:
|
|
230
|
+
raise ValueError(f"unsupported test profile: {profile}")
|
|
231
|
+
modules = discover_test_modules(tests_dir)
|
|
232
|
+
resource_names = {Path(item).name for item in resource_modules}
|
|
233
|
+
regular = [item for item in modules if item.name not in resource_names]
|
|
234
|
+
intensive = [item for item in modules if item.name in resource_names]
|
|
235
|
+
if profile == "safe":
|
|
236
|
+
return regular
|
|
237
|
+
if profile == "system":
|
|
238
|
+
return intensive
|
|
239
|
+
return [*regular, *intensive]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _pid_is_running(pid: int) -> bool:
|
|
243
|
+
return hprocess.is_pid_alive(pid)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _verify_recorded_owner(owner: Mapping[str, object]) -> bool | None:
|
|
247
|
+
"""Verify a legacy lock owner through the canonical process provider."""
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
pid = int(owner.get("pid") or 0)
|
|
251
|
+
except (TypeError, ValueError):
|
|
252
|
+
return None
|
|
253
|
+
observed = hprocess.observe_process_identity(pid)
|
|
254
|
+
decision = hprocess.verify_process_identity(
|
|
255
|
+
{
|
|
256
|
+
"pid": pid,
|
|
257
|
+
"createdAt": owner.get("startedAt"),
|
|
258
|
+
"executable": owner.get("executable"),
|
|
259
|
+
},
|
|
260
|
+
observed,
|
|
261
|
+
)
|
|
262
|
+
if decision.get("ok") is True:
|
|
263
|
+
return True
|
|
264
|
+
if decision.get("reasonCode") == "IDENTITY_UNVERIFIABLE":
|
|
265
|
+
return None
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def classify_test_lock(path: Path) -> dict[str, object]:
|
|
270
|
+
"""Classify one lock without mutating it or terminating a process."""
|
|
271
|
+
try:
|
|
272
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
273
|
+
except FileNotFoundError:
|
|
274
|
+
return {"classification": "ABSENT", "path": str(path)}
|
|
275
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
276
|
+
return {
|
|
277
|
+
"classification": "LOCK_UNREADABLE",
|
|
278
|
+
"path": str(path),
|
|
279
|
+
"message": str(exc),
|
|
280
|
+
}
|
|
281
|
+
if not isinstance(payload, dict):
|
|
282
|
+
return {"classification": "LOCK_UNREADABLE", "path": str(path)}
|
|
283
|
+
owner = payload.get("owner")
|
|
284
|
+
expires = payload.get("expiresAtUnix")
|
|
285
|
+
if not isinstance(expires, (int, float)) or isinstance(expires, bool):
|
|
286
|
+
return {
|
|
287
|
+
"classification": "OWNER_IDENTITY_INCOMPLETE",
|
|
288
|
+
"path": str(path),
|
|
289
|
+
"token": payload.get("token"),
|
|
290
|
+
}
|
|
291
|
+
unexpired = time.time() < float(expires)
|
|
292
|
+
if not isinstance(owner, dict) or any(
|
|
293
|
+
not owner.get(field) for field in ("pid", "executable", "startedAt")
|
|
294
|
+
):
|
|
295
|
+
if unexpired:
|
|
296
|
+
return {
|
|
297
|
+
"classification": "ACTIVE",
|
|
298
|
+
"path": str(path),
|
|
299
|
+
"token": payload.get("token"),
|
|
300
|
+
"owner": owner,
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
"classification": "OWNER_IDENTITY_INCOMPLETE",
|
|
304
|
+
"path": str(path),
|
|
305
|
+
"token": payload.get("token"),
|
|
306
|
+
}
|
|
307
|
+
try:
|
|
308
|
+
owner_pid = int(owner["pid"])
|
|
309
|
+
except (TypeError, ValueError):
|
|
310
|
+
if unexpired:
|
|
311
|
+
return {
|
|
312
|
+
"classification": "ACTIVE",
|
|
313
|
+
"path": str(path),
|
|
314
|
+
"token": payload.get("token"),
|
|
315
|
+
"owner": owner,
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
"classification": "OWNER_IDENTITY_INCOMPLETE",
|
|
319
|
+
"path": str(path),
|
|
320
|
+
"token": payload.get("token"),
|
|
321
|
+
}
|
|
322
|
+
if not _pid_is_running(owner_pid):
|
|
323
|
+
return {
|
|
324
|
+
"classification": "RECLAIMABLE",
|
|
325
|
+
"path": str(path),
|
|
326
|
+
"token": payload.get("token"),
|
|
327
|
+
"owner": owner,
|
|
328
|
+
}
|
|
329
|
+
if unexpired:
|
|
330
|
+
return {
|
|
331
|
+
"classification": "ACTIVE",
|
|
332
|
+
"path": str(path),
|
|
333
|
+
"token": payload.get("token"),
|
|
334
|
+
"owner": owner,
|
|
335
|
+
}
|
|
336
|
+
verified = _verify_recorded_owner(owner)
|
|
337
|
+
classification = (
|
|
338
|
+
"OWNER_ACTIVE"
|
|
339
|
+
if verified is True
|
|
340
|
+
else (
|
|
341
|
+
"OWNER_IDENTITY_UNCONFIRMED"
|
|
342
|
+
if verified is None
|
|
343
|
+
else "OWNER_IDENTITY_MISMATCH"
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
return {
|
|
347
|
+
"classification": classification,
|
|
348
|
+
"path": str(path),
|
|
349
|
+
"token": payload.get("token"),
|
|
350
|
+
"owner": owner,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def reap_test_lock(path: Path) -> dict[str, object]:
|
|
355
|
+
"""Reap only a lock whose exact recorded owner is definitely absent."""
|
|
356
|
+
classification = classify_test_lock(path)
|
|
357
|
+
reaped = classification.get("classification") == "RECLAIMABLE"
|
|
358
|
+
if reaped:
|
|
359
|
+
try:
|
|
360
|
+
path.unlink()
|
|
361
|
+
except FileNotFoundError:
|
|
362
|
+
pass
|
|
363
|
+
receipt = {
|
|
364
|
+
"schemaVersion": 1,
|
|
365
|
+
"action": "test-run-lock-reap",
|
|
366
|
+
"status": "REAPED",
|
|
367
|
+
"completedAtUnix": time.time(),
|
|
368
|
+
"lockPath": str(path),
|
|
369
|
+
"prior": classification,
|
|
370
|
+
}
|
|
371
|
+
receipt_dir = path.parent / "receipts"
|
|
372
|
+
receipt_dir.mkdir(parents=True, exist_ok=True)
|
|
373
|
+
target = receipt_dir / f"{path.stem}-reap-{uuid.uuid4().hex}.json"
|
|
374
|
+
target.write_text(
|
|
375
|
+
json.dumps(receipt, ensure_ascii=False, indent=2) + "\n",
|
|
376
|
+
encoding="utf-8",
|
|
377
|
+
newline="\n",
|
|
378
|
+
)
|
|
379
|
+
return {
|
|
380
|
+
"ok": True,
|
|
381
|
+
"code": "TEST_RUN_LOCK_REAPED" if reaped else "TEST_RUN_LOCK_REPORT_ONLY",
|
|
382
|
+
"reaped": reaped,
|
|
383
|
+
"lock": classification,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class TestRunLock:
|
|
388
|
+
"""Atomic per-project single-instance lock with stale-owner recovery."""
|
|
389
|
+
|
|
390
|
+
def __init__(self, project: Path, *, lock_root: Path | None = None) -> None:
|
|
391
|
+
resolved = str(Path(project).resolve())
|
|
392
|
+
if os.name == "nt":
|
|
393
|
+
resolved = resolved.casefold()
|
|
394
|
+
digest = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:24]
|
|
395
|
+
root = (
|
|
396
|
+
Path(lock_root)
|
|
397
|
+
if lock_root is not None
|
|
398
|
+
else Path(
|
|
399
|
+
os.environ.get(
|
|
400
|
+
"HARNESS_TEST_LOCK_ROOT",
|
|
401
|
+
str(Path(tempfile.gettempdir()) / "hunter-harness-test-locks"),
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
self.path = root / f"{digest}.lock"
|
|
406
|
+
self.token = uuid.uuid4().hex
|
|
407
|
+
self._owned = False
|
|
408
|
+
self._ttl_seconds = 3600.0
|
|
409
|
+
|
|
410
|
+
def _owner(self) -> dict[str, object]:
|
|
411
|
+
try:
|
|
412
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
413
|
+
except (OSError, json.JSONDecodeError):
|
|
414
|
+
return {}
|
|
415
|
+
|
|
416
|
+
def acquire(self) -> None:
|
|
417
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
for attempt in range(2):
|
|
419
|
+
try:
|
|
420
|
+
descriptor = os.open(
|
|
421
|
+
self.path,
|
|
422
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
423
|
+
0o600,
|
|
424
|
+
)
|
|
425
|
+
except FileExistsError:
|
|
426
|
+
classification = classify_test_lock(self.path)
|
|
427
|
+
if classification.get("classification") != "RECLAIMABLE":
|
|
428
|
+
raise TestRunAlreadyActive(
|
|
429
|
+
"TEST_RUN_ALREADY_ACTIVE: "
|
|
430
|
+
f"lock={self.path} "
|
|
431
|
+
f"classification={classification.get('classification')}"
|
|
432
|
+
)
|
|
433
|
+
reap_test_lock(self.path)
|
|
434
|
+
if attempt == 0:
|
|
435
|
+
continue
|
|
436
|
+
raise TestRunAlreadyActive(
|
|
437
|
+
f"TEST_RUN_ALREADY_ACTIVE: could not reclaim {self.path}"
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
now = time.time()
|
|
441
|
+
created = hprocess.get_process_create_time(os.getpid())
|
|
442
|
+
executable = (
|
|
443
|
+
hprocess.get_process_executable(os.getpid())
|
|
444
|
+
or str(Path(sys.executable).resolve())
|
|
445
|
+
)
|
|
446
|
+
payload = {
|
|
447
|
+
"schemaVersion": 2,
|
|
448
|
+
"token": self.token,
|
|
449
|
+
"status": "ACTIVE",
|
|
450
|
+
"createdAtUnix": now,
|
|
451
|
+
"heartbeatAtUnix": now,
|
|
452
|
+
"expiresAtUnix": now + self._ttl_seconds,
|
|
453
|
+
"owner": {
|
|
454
|
+
"pid": os.getpid(),
|
|
455
|
+
"executable": executable,
|
|
456
|
+
"startedAt": (
|
|
457
|
+
created.isoformat(timespec="seconds")
|
|
458
|
+
if created is not None
|
|
459
|
+
else ""
|
|
460
|
+
),
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
464
|
+
json.dump(payload, stream, ensure_ascii=False)
|
|
465
|
+
stream.flush()
|
|
466
|
+
self._owned = True
|
|
467
|
+
return
|
|
468
|
+
raise TestRunAlreadyActive(f"TEST_RUN_ALREADY_ACTIVE: {self.path}")
|
|
469
|
+
|
|
470
|
+
def heartbeat(self) -> None:
|
|
471
|
+
if not self._owned:
|
|
472
|
+
return
|
|
473
|
+
owner = self._owner()
|
|
474
|
+
if owner.get("token") != self.token:
|
|
475
|
+
raise TestRunAlreadyActive(
|
|
476
|
+
f"TEST_RUN_LOCK_OWNERSHIP_LOST: lock={self.path}"
|
|
477
|
+
)
|
|
478
|
+
now = time.time()
|
|
479
|
+
owner["heartbeatAtUnix"] = now
|
|
480
|
+
owner["expiresAtUnix"] = now + self._ttl_seconds
|
|
481
|
+
with self.path.open("w", encoding="utf-8", newline="\n") as stream:
|
|
482
|
+
json.dump(owner, stream, ensure_ascii=False)
|
|
483
|
+
stream.flush()
|
|
484
|
+
os.fsync(stream.fileno())
|
|
485
|
+
|
|
486
|
+
def _write_closeout_receipt(self, *, status: str) -> None:
|
|
487
|
+
receipt = {
|
|
488
|
+
"schemaVersion": 1,
|
|
489
|
+
"action": "test-run-lock-closeout",
|
|
490
|
+
"status": status,
|
|
491
|
+
"token": self.token,
|
|
492
|
+
"lockPath": str(self.path),
|
|
493
|
+
"closedAtUnix": time.time(),
|
|
494
|
+
"ownerPid": os.getpid(),
|
|
495
|
+
}
|
|
496
|
+
receipt_dir = self.path.parent / "receipts"
|
|
497
|
+
receipt_dir.mkdir(parents=True, exist_ok=True)
|
|
498
|
+
target = receipt_dir / f"{self.path.stem}-{self.token}.json"
|
|
499
|
+
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
|
500
|
+
temporary.write_text(
|
|
501
|
+
json.dumps(receipt, ensure_ascii=False, indent=2) + "\n",
|
|
502
|
+
encoding="utf-8",
|
|
503
|
+
newline="\n",
|
|
504
|
+
)
|
|
505
|
+
os.replace(temporary, target)
|
|
506
|
+
|
|
507
|
+
def release(self, *, status: str = "COMPLETED") -> None:
|
|
508
|
+
if not self._owned:
|
|
509
|
+
return
|
|
510
|
+
owner = self._owner()
|
|
511
|
+
if owner.get("token") == self.token:
|
|
512
|
+
self._write_closeout_receipt(status=status)
|
|
513
|
+
try:
|
|
514
|
+
self.path.unlink()
|
|
515
|
+
except FileNotFoundError:
|
|
516
|
+
pass
|
|
517
|
+
self._owned = False
|
|
518
|
+
|
|
519
|
+
def __enter__(self) -> "TestRunLock":
|
|
520
|
+
self.acquire()
|
|
521
|
+
return self
|
|
522
|
+
|
|
523
|
+
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
524
|
+
self.release(status="ABNORMAL" if exc_type is not None else "COMPLETED")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def lower_process_priority() -> bool:
|
|
528
|
+
"""Best-effort scheduling priority reduction inherited by child processes."""
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
if os.name == "nt":
|
|
532
|
+
below_normal_priority_class = 0x00004000
|
|
533
|
+
kernel32 = ctypes.windll.kernel32
|
|
534
|
+
return bool(
|
|
535
|
+
kernel32.SetPriorityClass(
|
|
536
|
+
kernel32.GetCurrentProcess(),
|
|
537
|
+
below_normal_priority_class,
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
os.nice(5)
|
|
541
|
+
return True
|
|
542
|
+
except (AttributeError, OSError):
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class _WindowsIoCounters(ctypes.Structure):
|
|
547
|
+
_fields_ = [
|
|
548
|
+
("ReadOperationCount", ctypes.c_uint64),
|
|
549
|
+
("WriteOperationCount", ctypes.c_uint64),
|
|
550
|
+
("OtherOperationCount", ctypes.c_uint64),
|
|
551
|
+
("ReadTransferCount", ctypes.c_uint64),
|
|
552
|
+
("WriteTransferCount", ctypes.c_uint64),
|
|
553
|
+
("OtherTransferCount", ctypes.c_uint64),
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class _WindowsBasicLimitInformation(ctypes.Structure):
|
|
558
|
+
_fields_ = [
|
|
559
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
560
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
561
|
+
("LimitFlags", ctypes.c_uint32),
|
|
562
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
563
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
564
|
+
("ActiveProcessLimit", ctypes.c_uint32),
|
|
565
|
+
("Affinity", ctypes.c_size_t),
|
|
566
|
+
("PriorityClass", ctypes.c_uint32),
|
|
567
|
+
("SchedulingClass", ctypes.c_uint32),
|
|
568
|
+
]
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class _WindowsExtendedLimitInformation(ctypes.Structure):
|
|
572
|
+
_fields_ = [
|
|
573
|
+
("BasicLimitInformation", _WindowsBasicLimitInformation),
|
|
574
|
+
("IoInfo", _WindowsIoCounters),
|
|
575
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
576
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
577
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
578
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
579
|
+
]
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class _WindowsBasicAccountingInformation(ctypes.Structure):
|
|
583
|
+
_fields_ = [
|
|
584
|
+
("TotalUserTime", ctypes.c_int64),
|
|
585
|
+
("TotalKernelTime", ctypes.c_int64),
|
|
586
|
+
("ThisPeriodTotalUserTime", ctypes.c_int64),
|
|
587
|
+
("ThisPeriodTotalKernelTime", ctypes.c_int64),
|
|
588
|
+
("TotalPageFaultCount", ctypes.c_uint32),
|
|
589
|
+
("TotalProcesses", ctypes.c_uint32),
|
|
590
|
+
("ActiveProcesses", ctypes.c_uint32),
|
|
591
|
+
("TotalTerminatedProcesses", ctypes.c_uint32),
|
|
592
|
+
]
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
_WINDOWS_JOB_PROCESS_ID_CAPACITY = 4096
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class _WindowsJobProcessIdList(ctypes.Structure):
|
|
599
|
+
_fields_ = [
|
|
600
|
+
("NumberOfAssignedProcesses", ctypes.c_uint32),
|
|
601
|
+
("NumberOfProcessIdsInList", ctypes.c_uint32),
|
|
602
|
+
(
|
|
603
|
+
"ProcessIdList",
|
|
604
|
+
ctypes.c_size_t * _WINDOWS_JOB_PROCESS_ID_CAPACITY,
|
|
605
|
+
),
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class _WindowsKillOnCloseJob:
|
|
610
|
+
"""Best-effort Windows Job Object that kills descendants when closed."""
|
|
611
|
+
|
|
612
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
613
|
+
_JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800
|
|
614
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
|
|
615
|
+
_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS = 1
|
|
616
|
+
_JOB_OBJECT_BASIC_PROCESS_ID_LIST_CLASS = 3
|
|
617
|
+
|
|
618
|
+
def __init__(self, *, allow_breakaway: bool = False) -> None:
|
|
619
|
+
self.handle: int | None = None
|
|
620
|
+
self.allow_breakaway = allow_breakaway
|
|
621
|
+
|
|
622
|
+
def assign(self, process: subprocess.Popen[object]) -> bool:
|
|
623
|
+
if os.name != "nt":
|
|
624
|
+
return False
|
|
625
|
+
kernel32 = ctypes.windll.kernel32
|
|
626
|
+
kernel32.CreateJobObjectW.restype = ctypes.c_void_p
|
|
627
|
+
handle = kernel32.CreateJobObjectW(None, None)
|
|
628
|
+
if not handle:
|
|
629
|
+
return False
|
|
630
|
+
info = _WindowsExtendedLimitInformation()
|
|
631
|
+
info.BasicLimitInformation.LimitFlags = (
|
|
632
|
+
self._JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
633
|
+
| (
|
|
634
|
+
self._JOB_OBJECT_LIMIT_BREAKAWAY_OK
|
|
635
|
+
if self.allow_breakaway
|
|
636
|
+
else 0
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
configured = kernel32.SetInformationJobObject(
|
|
640
|
+
ctypes.c_void_p(handle),
|
|
641
|
+
self._JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
|
|
642
|
+
ctypes.byref(info),
|
|
643
|
+
ctypes.sizeof(info),
|
|
644
|
+
)
|
|
645
|
+
process_handle = getattr(process, "_handle", None)
|
|
646
|
+
assigned = bool(
|
|
647
|
+
configured
|
|
648
|
+
and process_handle
|
|
649
|
+
and kernel32.AssignProcessToJobObject(
|
|
650
|
+
ctypes.c_void_p(handle),
|
|
651
|
+
ctypes.c_void_p(int(process_handle)),
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
if not assigned:
|
|
655
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
656
|
+
return False
|
|
657
|
+
self.handle = int(handle)
|
|
658
|
+
return True
|
|
659
|
+
|
|
660
|
+
def close(self) -> None:
|
|
661
|
+
if self.handle is None:
|
|
662
|
+
return
|
|
663
|
+
ctypes.windll.kernel32.CloseHandle(ctypes.c_void_p(self.handle))
|
|
664
|
+
self.handle = None
|
|
665
|
+
|
|
666
|
+
def terminate_and_wait(self, timeout_seconds: float = 5.0) -> bool:
|
|
667
|
+
if self.handle is None:
|
|
668
|
+
return True
|
|
669
|
+
kernel32 = ctypes.windll.kernel32
|
|
670
|
+
handle = ctypes.c_void_p(self.handle)
|
|
671
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
672
|
+
process_ids = _WindowsJobProcessIdList()
|
|
673
|
+
process_list_queried = kernel32.QueryInformationJobObject(
|
|
674
|
+
handle,
|
|
675
|
+
self._JOB_OBJECT_BASIC_PROCESS_ID_LIST_CLASS,
|
|
676
|
+
ctypes.byref(process_ids),
|
|
677
|
+
ctypes.sizeof(process_ids),
|
|
678
|
+
None,
|
|
679
|
+
)
|
|
680
|
+
process_list_complete = bool(
|
|
681
|
+
process_list_queried
|
|
682
|
+
and process_ids.NumberOfAssignedProcesses
|
|
683
|
+
== process_ids.NumberOfProcessIdsInList
|
|
684
|
+
)
|
|
685
|
+
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
686
|
+
process_handles: list[int] = []
|
|
687
|
+
if process_list_complete:
|
|
688
|
+
for index in range(int(process_ids.NumberOfProcessIdsInList)):
|
|
689
|
+
process_handle = kernel32.OpenProcess(
|
|
690
|
+
0x00100000,
|
|
691
|
+
False,
|
|
692
|
+
int(process_ids.ProcessIdList[index]),
|
|
693
|
+
)
|
|
694
|
+
if process_handle:
|
|
695
|
+
process_handles.append(int(process_handle))
|
|
696
|
+
else:
|
|
697
|
+
process_list_complete = False
|
|
698
|
+
terminated = bool(kernel32.TerminateJobObject(handle, 1))
|
|
699
|
+
handles_signaled = process_list_complete
|
|
700
|
+
try:
|
|
701
|
+
for process_handle in process_handles:
|
|
702
|
+
remaining_ms = max(
|
|
703
|
+
0,
|
|
704
|
+
int((deadline - time.monotonic()) * 1000),
|
|
705
|
+
)
|
|
706
|
+
if (
|
|
707
|
+
kernel32.WaitForSingleObject(
|
|
708
|
+
ctypes.c_void_p(process_handle),
|
|
709
|
+
remaining_ms,
|
|
710
|
+
)
|
|
711
|
+
!= 0
|
|
712
|
+
):
|
|
713
|
+
handles_signaled = False
|
|
714
|
+
accounting_drained = False
|
|
715
|
+
while time.monotonic() < deadline:
|
|
716
|
+
accounting = _WindowsBasicAccountingInformation()
|
|
717
|
+
queried = kernel32.QueryInformationJobObject(
|
|
718
|
+
handle,
|
|
719
|
+
self._JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS,
|
|
720
|
+
ctypes.byref(accounting),
|
|
721
|
+
ctypes.sizeof(accounting),
|
|
722
|
+
None,
|
|
723
|
+
)
|
|
724
|
+
if queried and accounting.ActiveProcesses == 0:
|
|
725
|
+
accounting_drained = True
|
|
726
|
+
break
|
|
727
|
+
time.sleep(0.02)
|
|
728
|
+
finally:
|
|
729
|
+
for process_handle in process_handles:
|
|
730
|
+
kernel32.CloseHandle(ctypes.c_void_p(process_handle))
|
|
731
|
+
self.close()
|
|
732
|
+
return terminated and handles_signaled and accounting_drained
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
class _WindowsProcessEntry(ctypes.Structure):
|
|
736
|
+
_fields_ = [
|
|
737
|
+
("dwSize", ctypes.c_uint32),
|
|
738
|
+
("cntUsage", ctypes.c_uint32),
|
|
739
|
+
("th32ProcessID", ctypes.c_uint32),
|
|
740
|
+
("th32DefaultHeapID", ctypes.c_size_t),
|
|
741
|
+
("th32ModuleID", ctypes.c_uint32),
|
|
742
|
+
("cntThreads", ctypes.c_uint32),
|
|
743
|
+
("th32ParentProcessID", ctypes.c_uint32),
|
|
744
|
+
("pcPriClassBase", ctypes.c_long),
|
|
745
|
+
("dwFlags", ctypes.c_uint32),
|
|
746
|
+
("szExeFile", ctypes.c_wchar * 260),
|
|
747
|
+
]
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _windows_process_parents() -> dict[int, int] | None:
|
|
751
|
+
if os.name != "nt":
|
|
752
|
+
return {}
|
|
753
|
+
kernel32 = ctypes.windll.kernel32
|
|
754
|
+
create_snapshot = kernel32.CreateToolhelp32Snapshot
|
|
755
|
+
create_snapshot.restype = ctypes.c_void_p
|
|
756
|
+
handle = create_snapshot(0x00000002, 0)
|
|
757
|
+
invalid_handle = ctypes.c_void_p(-1).value
|
|
758
|
+
if not handle or handle == invalid_handle:
|
|
759
|
+
return None
|
|
760
|
+
entry = _WindowsProcessEntry()
|
|
761
|
+
entry.dwSize = ctypes.sizeof(entry)
|
|
762
|
+
parents: dict[int, int] = {}
|
|
763
|
+
try:
|
|
764
|
+
if not kernel32.Process32FirstW(ctypes.c_void_p(handle), ctypes.byref(entry)):
|
|
765
|
+
return None
|
|
766
|
+
while True:
|
|
767
|
+
parents[int(entry.th32ProcessID)] = int(entry.th32ParentProcessID)
|
|
768
|
+
if not kernel32.Process32NextW(
|
|
769
|
+
ctypes.c_void_p(handle),
|
|
770
|
+
ctypes.byref(entry),
|
|
771
|
+
):
|
|
772
|
+
break
|
|
773
|
+
return parents
|
|
774
|
+
finally:
|
|
775
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _update_windows_descendants(
|
|
779
|
+
root_pid: int,
|
|
780
|
+
known_descendants: set[int],
|
|
781
|
+
) -> bool:
|
|
782
|
+
parents = _windows_process_parents()
|
|
783
|
+
if parents is None:
|
|
784
|
+
return False
|
|
785
|
+
changed = True
|
|
786
|
+
while changed:
|
|
787
|
+
changed = False
|
|
788
|
+
for pid, parent_pid in parents.items():
|
|
789
|
+
if pid == root_pid or pid in known_descendants:
|
|
790
|
+
continue
|
|
791
|
+
if parent_pid == root_pid or parent_pid in known_descendants:
|
|
792
|
+
known_descendants.add(pid)
|
|
793
|
+
changed = True
|
|
794
|
+
return True
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _terminate_tracked_windows_descendants(pids: set[int]) -> None:
|
|
798
|
+
targeted: list[int] = []
|
|
799
|
+
kernel32 = ctypes.windll.kernel32
|
|
800
|
+
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
801
|
+
for pid in sorted(pids, reverse=True):
|
|
802
|
+
if not _pid_is_running(pid):
|
|
803
|
+
continue
|
|
804
|
+
targeted.append(pid)
|
|
805
|
+
handle = kernel32.OpenProcess(0x00100001, False, pid)
|
|
806
|
+
terminated = False
|
|
807
|
+
if handle:
|
|
808
|
+
try:
|
|
809
|
+
terminated = bool(
|
|
810
|
+
kernel32.TerminateProcess(ctypes.c_void_p(handle), 1)
|
|
811
|
+
)
|
|
812
|
+
if terminated:
|
|
813
|
+
kernel32.WaitForSingleObject(ctypes.c_void_p(handle), 5000)
|
|
814
|
+
finally:
|
|
815
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
816
|
+
if not terminated:
|
|
817
|
+
subprocess.run(
|
|
818
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
819
|
+
stdout=subprocess.DEVNULL,
|
|
820
|
+
stderr=subprocess.DEVNULL,
|
|
821
|
+
check=False,
|
|
822
|
+
timeout=10,
|
|
823
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
824
|
+
)
|
|
825
|
+
deadline = time.monotonic() + 5.0
|
|
826
|
+
while targeted and time.monotonic() < deadline:
|
|
827
|
+
targeted = [pid for pid in targeted if _pid_is_running(pid)]
|
|
828
|
+
if targeted:
|
|
829
|
+
time.sleep(0.05)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def detached_process_capability(cwd: Path) -> tuple[bool, str]:
|
|
833
|
+
"""Probe the nested Windows breakaway pattern used by service tests."""
|
|
834
|
+
|
|
835
|
+
if os.name != "nt":
|
|
836
|
+
return True, "not-windows"
|
|
837
|
+
flags = 0x00000008 | 0x00000200 | 0x08000000 | 0x01000000
|
|
838
|
+
with tempfile.TemporaryDirectory(prefix="harness-detached-probe-") as raw_tmp:
|
|
839
|
+
marker = Path(raw_tmp) / "nested-breakaway.ok"
|
|
840
|
+
probe_source = (
|
|
841
|
+
"import pathlib,subprocess,sys;"
|
|
842
|
+
"flags=0x00000008|0x00000200|0x08000000|0x01000000;"
|
|
843
|
+
"child=subprocess.Popen([sys.executable,'-c','pass'],"
|
|
844
|
+
"creationflags=flags,stdin=subprocess.DEVNULL,"
|
|
845
|
+
"stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);"
|
|
846
|
+
"code=child.wait(timeout=5);"
|
|
847
|
+
"pathlib.Path(sys.argv[1]).write_text(str(code),encoding='utf-8')"
|
|
848
|
+
)
|
|
849
|
+
try:
|
|
850
|
+
process = subprocess.Popen(
|
|
851
|
+
[sys.executable, "-c", probe_source, str(marker)],
|
|
852
|
+
cwd=str(Path(cwd).resolve()),
|
|
853
|
+
stdin=subprocess.DEVNULL,
|
|
854
|
+
stdout=subprocess.PIPE,
|
|
855
|
+
stderr=subprocess.STDOUT,
|
|
856
|
+
creationflags=flags,
|
|
857
|
+
text=True,
|
|
858
|
+
encoding="utf-8",
|
|
859
|
+
errors="replace",
|
|
860
|
+
)
|
|
861
|
+
except OSError as exc:
|
|
862
|
+
return False, str(exc)
|
|
863
|
+
try:
|
|
864
|
+
output, _ = process.communicate(timeout=8)
|
|
865
|
+
except subprocess.TimeoutExpired:
|
|
866
|
+
subprocess.run(
|
|
867
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
868
|
+
stdout=subprocess.DEVNULL,
|
|
869
|
+
stderr=subprocess.DEVNULL,
|
|
870
|
+
check=False,
|
|
871
|
+
timeout=10,
|
|
872
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
873
|
+
)
|
|
874
|
+
return False, "nested breakaway probe timed out"
|
|
875
|
+
if process.returncode == 0 and marker.is_file():
|
|
876
|
+
return True, "nested breakaway available"
|
|
877
|
+
detail_lines = [line.strip() for line in (output or "").splitlines() if line.strip()]
|
|
878
|
+
detail = detail_lines[-1] if detail_lines else ""
|
|
879
|
+
return False, detail[-500:] or f"probe exit={process.returncode}"
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _terminate_process_tree(
|
|
883
|
+
process: subprocess.Popen[object],
|
|
884
|
+
*,
|
|
885
|
+
windows_job: _WindowsKillOnCloseJob | None,
|
|
886
|
+
) -> None:
|
|
887
|
+
"""Legacy seam retained for callers; ownership must be provider-backed."""
|
|
888
|
+
|
|
889
|
+
if windows_job is not None and windows_job.handle is not None:
|
|
890
|
+
windows_job.terminate_and_wait()
|
|
891
|
+
else:
|
|
892
|
+
raise RuntimeError("PROCESS_OWNERSHIP_REQUIRED")
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _cleanup_descendants_after_success(
|
|
896
|
+
process: subprocess.Popen[object],
|
|
897
|
+
*,
|
|
898
|
+
windows_job: _WindowsKillOnCloseJob | None,
|
|
899
|
+
) -> bool:
|
|
900
|
+
if windows_job is not None and windows_job.handle is not None:
|
|
901
|
+
return windows_job.terminate_and_wait()
|
|
902
|
+
return False
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def run_managed_command(
|
|
906
|
+
argv: Sequence[str],
|
|
907
|
+
*,
|
|
908
|
+
cwd: Path,
|
|
909
|
+
timeout_seconds: float,
|
|
910
|
+
environ: Mapping[str, str] | None = None,
|
|
911
|
+
capture_output: bool = False,
|
|
912
|
+
allow_detached_processes: bool = False,
|
|
913
|
+
) -> CommandResult:
|
|
914
|
+
"""Run one command with a timeout and guaranteed process-tree cleanup."""
|
|
915
|
+
|
|
916
|
+
if not argv:
|
|
917
|
+
raise ValueError("managed command must not be empty")
|
|
918
|
+
child_env = os.environ.copy()
|
|
919
|
+
if environ:
|
|
920
|
+
child_env.update({str(key): str(value) for key, value in environ.items()})
|
|
921
|
+
child_env.setdefault("PYTHONUNBUFFERED", "1")
|
|
922
|
+
resolved_argv = resolve_managed_argv(argv, environ=child_env)
|
|
923
|
+
validate_managed_argv(resolved_argv)
|
|
924
|
+
lower_process_priority()
|
|
925
|
+
|
|
926
|
+
output_stream = tempfile.TemporaryFile(mode="w+b") if capture_output else None
|
|
927
|
+
started = time.monotonic()
|
|
928
|
+
spawned = None
|
|
929
|
+
process: subprocess.Popen[object] | None = None
|
|
930
|
+
cleanup_complete = False
|
|
931
|
+
try:
|
|
932
|
+
spawned = hprocess.spawn_structured_argv(
|
|
933
|
+
resolved_argv,
|
|
934
|
+
cwd=Path(cwd).resolve(),
|
|
935
|
+
environment=child_env,
|
|
936
|
+
owner_token=f"test-runner:{uuid.uuid4().hex}",
|
|
937
|
+
stdout=output_stream,
|
|
938
|
+
stderr=(subprocess.STDOUT if output_stream is not None else None),
|
|
939
|
+
)
|
|
940
|
+
process = spawned.process
|
|
941
|
+
isolated = bool(
|
|
942
|
+
isinstance(spawned.ownershipProof, dict)
|
|
943
|
+
and spawned.ownershipProof.get("membersComplete") is True
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
def provider_cleanup() -> bool:
|
|
947
|
+
hprocess.capture_owned_members(spawned)
|
|
948
|
+
result = hprocess.terminate_owned_tree(
|
|
949
|
+
spawned.attestation,
|
|
950
|
+
spawned.ownershipProof,
|
|
951
|
+
{"graceSeconds": 3.0},
|
|
952
|
+
)
|
|
953
|
+
return bool(result.get("cleanupComplete"))
|
|
954
|
+
|
|
955
|
+
def wait_owned_members_gone() -> bool:
|
|
956
|
+
proof = spawned.ownershipProof
|
|
957
|
+
if not isinstance(proof, dict):
|
|
958
|
+
return False
|
|
959
|
+
members = proof.get("members")
|
|
960
|
+
if not isinstance(members, list):
|
|
961
|
+
return False
|
|
962
|
+
pids = {
|
|
963
|
+
int(item["pid"])
|
|
964
|
+
for item in members
|
|
965
|
+
if isinstance(item, dict) and isinstance(item.get("pid"), int)
|
|
966
|
+
}
|
|
967
|
+
deadline = time.monotonic() + 5.0
|
|
968
|
+
while pids and time.monotonic() < deadline:
|
|
969
|
+
pids = {pid for pid in pids if hprocess.is_pid_alive(pid)}
|
|
970
|
+
if pids:
|
|
971
|
+
time.sleep(0.05)
|
|
972
|
+
return not pids
|
|
973
|
+
|
|
974
|
+
timed_out = False
|
|
975
|
+
try:
|
|
976
|
+
deadline = time.monotonic() + max(0.01, float(timeout_seconds))
|
|
977
|
+
while True:
|
|
978
|
+
polled = process.poll()
|
|
979
|
+
if polled is not None:
|
|
980
|
+
returncode = polled
|
|
981
|
+
break
|
|
982
|
+
if time.monotonic() >= deadline:
|
|
983
|
+
timed_out = True
|
|
984
|
+
cleanup_complete = provider_cleanup()
|
|
985
|
+
returncode = 124
|
|
986
|
+
break
|
|
987
|
+
time.sleep(0.05)
|
|
988
|
+
except KeyboardInterrupt:
|
|
989
|
+
cleanup_complete = provider_cleanup()
|
|
990
|
+
raise
|
|
991
|
+
if not timed_out:
|
|
992
|
+
cleanup_complete = provider_cleanup()
|
|
993
|
+
# A named Windows Job remains an owned capability after the leader
|
|
994
|
+
# exits. Closing that provider handle drains any attested children;
|
|
995
|
+
# PID-only or POSIX group fallbacks stay fail-closed.
|
|
996
|
+
if not cleanup_complete and os.name == "nt":
|
|
997
|
+
spawned.close()
|
|
998
|
+
cleanup_complete = wait_owned_members_gone()
|
|
999
|
+
if process.poll() is None:
|
|
1000
|
+
try:
|
|
1001
|
+
process.wait(timeout=5)
|
|
1002
|
+
except subprocess.TimeoutExpired:
|
|
1003
|
+
cleanup_complete = False
|
|
1004
|
+
duration = time.monotonic() - started
|
|
1005
|
+
output_tail = ""
|
|
1006
|
+
if output_stream is not None:
|
|
1007
|
+
output_stream.flush()
|
|
1008
|
+
output_stream.seek(0, os.SEEK_END)
|
|
1009
|
+
size = output_stream.tell()
|
|
1010
|
+
output_stream.seek(max(0, size - 65536))
|
|
1011
|
+
output_tail = output_stream.read().decode("utf-8", errors="replace")
|
|
1012
|
+
return CommandResult(
|
|
1013
|
+
returncode=returncode,
|
|
1014
|
+
timed_out=timed_out,
|
|
1015
|
+
duration_seconds=duration,
|
|
1016
|
+
process_tree_isolated=isolated and cleanup_complete,
|
|
1017
|
+
output_tail=output_tail,
|
|
1018
|
+
)
|
|
1019
|
+
finally:
|
|
1020
|
+
if (
|
|
1021
|
+
not cleanup_complete
|
|
1022
|
+
and process is not None
|
|
1023
|
+
and process.poll() is None
|
|
1024
|
+
and spawned is not None
|
|
1025
|
+
):
|
|
1026
|
+
try:
|
|
1027
|
+
hprocess.capture_owned_members(spawned)
|
|
1028
|
+
cleanup = hprocess.terminate_owned_tree(
|
|
1029
|
+
spawned.attestation,
|
|
1030
|
+
spawned.ownershipProof,
|
|
1031
|
+
{"graceSeconds": 3.0},
|
|
1032
|
+
)
|
|
1033
|
+
cleanup_complete = bool(cleanup.get("cleanupComplete"))
|
|
1034
|
+
except Exception:
|
|
1035
|
+
cleanup_complete = False
|
|
1036
|
+
if spawned is not None:
|
|
1037
|
+
spawned.close()
|
|
1038
|
+
if output_stream is not None:
|
|
1039
|
+
output_stream.close()
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _execution_environment(profile: str, max_workers: int) -> dict[str, str]:
|
|
1043
|
+
return {
|
|
1044
|
+
"HARNESS_TEST_PROFILE": profile,
|
|
1045
|
+
"HARNESS_TEST_MAX_WORKERS": str(
|
|
1046
|
+
bounded_worker_count(max_workers, os.environ)
|
|
1047
|
+
),
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def load_argv_envelope(path: Path) -> tuple[list[str], dict[str, str]]:
|
|
1052
|
+
"""Load a shell-neutral UTF-8 argument file without reparsing command text."""
|
|
1053
|
+
try:
|
|
1054
|
+
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
1055
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1056
|
+
raise ValueError(f"argument file is unreadable: {exc}") from exc
|
|
1057
|
+
argv = raw.get("argv") if isinstance(raw, dict) else None
|
|
1058
|
+
powershell = raw.get("powershell") if isinstance(raw, dict) else None
|
|
1059
|
+
if (
|
|
1060
|
+
not isinstance(raw, dict)
|
|
1061
|
+
or raw.get("schemaVersion") != 1
|
|
1062
|
+
or raw.get("transport") != "utf8-json-argument-file"
|
|
1063
|
+
or not isinstance(argv, list)
|
|
1064
|
+
or not argv
|
|
1065
|
+
or any(not isinstance(item, str) or "\0" in item for item in argv)
|
|
1066
|
+
or not isinstance(powershell, dict)
|
|
1067
|
+
):
|
|
1068
|
+
raise ValueError(
|
|
1069
|
+
"argument file must declare schemaVersion=1, "
|
|
1070
|
+
"transport=utf8-json-argument-file, argv[], and powershell metadata"
|
|
1071
|
+
)
|
|
1072
|
+
shell = {
|
|
1073
|
+
field: str(powershell.get(field) or "").strip()
|
|
1074
|
+
for field in ("edition", "version")
|
|
1075
|
+
}
|
|
1076
|
+
if not all(shell.values()):
|
|
1077
|
+
raise ValueError("argument file powershell edition/version are required")
|
|
1078
|
+
return list(argv), shell
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _is_persistent_service_command(command: Sequence[str]) -> bool:
|
|
1082
|
+
"""Recognize formal service-manager launches that must outlive this runner."""
|
|
1083
|
+
normalized = [str(item).replace("\\", "/").lower() for item in command]
|
|
1084
|
+
return "ensure" in normalized and any(
|
|
1085
|
+
item.rsplit("/", 1)[-1] == "harness_service.py" for item in normalized
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _write_exec_runtime_receipt(
|
|
1090
|
+
path: Path,
|
|
1091
|
+
*,
|
|
1092
|
+
command: Sequence[str],
|
|
1093
|
+
powershell: Mapping[str, str],
|
|
1094
|
+
result: CommandResult,
|
|
1095
|
+
) -> None:
|
|
1096
|
+
canonical_argv = json.dumps(
|
|
1097
|
+
list(command),
|
|
1098
|
+
ensure_ascii=False,
|
|
1099
|
+
separators=(",", ":"),
|
|
1100
|
+
).encode("utf-8")
|
|
1101
|
+
payload = {
|
|
1102
|
+
"schemaVersion": 1,
|
|
1103
|
+
"action": "managed-exec",
|
|
1104
|
+
"transport": "utf8-json-argument-file",
|
|
1105
|
+
"completedAtEpochSeconds": int(time.time()),
|
|
1106
|
+
"argvHash": "sha256:" + hashlib.sha256(canonical_argv).hexdigest(),
|
|
1107
|
+
"argumentCount": len(command),
|
|
1108
|
+
"powershell": {
|
|
1109
|
+
"edition": str(powershell["edition"]),
|
|
1110
|
+
"version": str(powershell["version"]),
|
|
1111
|
+
},
|
|
1112
|
+
"returncode": result.returncode,
|
|
1113
|
+
"timedOut": result.timed_out,
|
|
1114
|
+
"processTreeIsolated": result.process_tree_isolated,
|
|
1115
|
+
}
|
|
1116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1117
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
1118
|
+
temporary.write_text(
|
|
1119
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
1120
|
+
encoding="utf-8",
|
|
1121
|
+
newline="\n",
|
|
1122
|
+
)
|
|
1123
|
+
os.replace(temporary, path)
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _print_plan(profile: str, plan: Sequence[TestModule]) -> None:
|
|
1127
|
+
payload = {
|
|
1128
|
+
"profile": profile,
|
|
1129
|
+
"moduleCount": len(plan),
|
|
1130
|
+
"modules": [item.name for item in plan],
|
|
1131
|
+
"maxWorkers": bounded_worker_count(DEFAULT_MAX_WORKERS, os.environ),
|
|
1132
|
+
"processTreeCleanup": True,
|
|
1133
|
+
"singleInstance": True,
|
|
1134
|
+
}
|
|
1135
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _run_unittest(args: argparse.Namespace) -> int:
|
|
1139
|
+
project = Path(args.project).resolve()
|
|
1140
|
+
tests_dir = Path(args.tests_dir)
|
|
1141
|
+
if not tests_dir.is_absolute():
|
|
1142
|
+
tests_dir = project / tests_dir
|
|
1143
|
+
plan = build_unittest_plan(
|
|
1144
|
+
tests_dir,
|
|
1145
|
+
args.profile,
|
|
1146
|
+
resource_modules=args.resource_module,
|
|
1147
|
+
)
|
|
1148
|
+
if args.dry_run:
|
|
1149
|
+
_print_plan(args.profile, plan)
|
|
1150
|
+
return 0
|
|
1151
|
+
if not resource_profile_allowed(
|
|
1152
|
+
args.profile,
|
|
1153
|
+
args.confirm_resource_intensive,
|
|
1154
|
+
os.environ,
|
|
1155
|
+
):
|
|
1156
|
+
print(
|
|
1157
|
+
"RESOURCE_CONFIRMATION_REQUIRED: "
|
|
1158
|
+
f"profile={args.profile}; rerun with --confirm-resource-intensive",
|
|
1159
|
+
file=sys.stderr,
|
|
1160
|
+
)
|
|
1161
|
+
return 2
|
|
1162
|
+
if not plan:
|
|
1163
|
+
print(f"NO_TEST_MODULES: profile={args.profile} testsDir={tests_dir}", file=sys.stderr)
|
|
1164
|
+
return 2
|
|
1165
|
+
|
|
1166
|
+
environment = _execution_environment(args.profile, args.max_workers)
|
|
1167
|
+
failures: list[str] = []
|
|
1168
|
+
failed_modules: set[str] = set()
|
|
1169
|
+
executed_count = 0
|
|
1170
|
+
started = time.monotonic()
|
|
1171
|
+
detached_service_modules = set(args.detached_service_module)
|
|
1172
|
+
detached_capability_checked = False
|
|
1173
|
+
try:
|
|
1174
|
+
with TestRunLock(project) as run_lock:
|
|
1175
|
+
for index, module in enumerate(plan, start=1):
|
|
1176
|
+
run_lock.heartbeat()
|
|
1177
|
+
if (
|
|
1178
|
+
module.name in detached_service_modules
|
|
1179
|
+
and not detached_capability_checked
|
|
1180
|
+
):
|
|
1181
|
+
available, detail = detached_process_capability(project)
|
|
1182
|
+
detached_capability_checked = True
|
|
1183
|
+
if not available:
|
|
1184
|
+
print(
|
|
1185
|
+
"DETACHED_PROCESS_CAPABILITY_UNAVAILABLE: "
|
|
1186
|
+
f"{detail}; rerun in an environment that permits "
|
|
1187
|
+
"nested Windows breakaway",
|
|
1188
|
+
file=sys.stderr,
|
|
1189
|
+
)
|
|
1190
|
+
return 5
|
|
1191
|
+
print(
|
|
1192
|
+
f"[harness-test] {index}/{len(plan)} {module.name} "
|
|
1193
|
+
f"profile={args.profile} maxWorkers={environment['HARNESS_TEST_MAX_WORKERS']}",
|
|
1194
|
+
flush=True,
|
|
1195
|
+
)
|
|
1196
|
+
command = [
|
|
1197
|
+
sys.executable,
|
|
1198
|
+
"-m",
|
|
1199
|
+
"unittest",
|
|
1200
|
+
"discover",
|
|
1201
|
+
"-s",
|
|
1202
|
+
str(tests_dir),
|
|
1203
|
+
"-p",
|
|
1204
|
+
module.name,
|
|
1205
|
+
]
|
|
1206
|
+
if args.verbosity == 0:
|
|
1207
|
+
command.append("-q")
|
|
1208
|
+
elif args.verbosity == 2:
|
|
1209
|
+
command.append("-v")
|
|
1210
|
+
result = run_managed_command(
|
|
1211
|
+
command,
|
|
1212
|
+
cwd=project,
|
|
1213
|
+
timeout_seconds=args.timeout_seconds,
|
|
1214
|
+
environ=environment,
|
|
1215
|
+
capture_output=args.verbosity == 0,
|
|
1216
|
+
allow_detached_processes=module.name in detached_service_modules,
|
|
1217
|
+
)
|
|
1218
|
+
run_lock.heartbeat()
|
|
1219
|
+
executed_count += 1
|
|
1220
|
+
module_failure_count = len(failures)
|
|
1221
|
+
if result.timed_out:
|
|
1222
|
+
failures.append(f"{module.name}:TIMEOUT")
|
|
1223
|
+
failed_modules.add(module.name)
|
|
1224
|
+
elif result.returncode != 0:
|
|
1225
|
+
failures.append(f"{module.name}:EXIT_{result.returncode}")
|
|
1226
|
+
failed_modules.add(module.name)
|
|
1227
|
+
if not result.process_tree_isolated:
|
|
1228
|
+
failures.append(f"{module.name}:PROCESS_TREE_ISOLATION_UNAVAILABLE")
|
|
1229
|
+
failed_modules.add(module.name)
|
|
1230
|
+
module_failed = len(failures) > module_failure_count
|
|
1231
|
+
if module_failed and result.output_tail:
|
|
1232
|
+
print(result.output_tail, file=sys.stderr, end="")
|
|
1233
|
+
if module_failed and not args.keep_going:
|
|
1234
|
+
break
|
|
1235
|
+
except TestRunAlreadyActive as exc:
|
|
1236
|
+
print(str(exc), file=sys.stderr)
|
|
1237
|
+
return 3
|
|
1238
|
+
except ManagedCommandNotFound as exc:
|
|
1239
|
+
print(
|
|
1240
|
+
json.dumps(
|
|
1241
|
+
{
|
|
1242
|
+
"ok": False,
|
|
1243
|
+
"code": "MANAGED_COMMAND_NOT_FOUND",
|
|
1244
|
+
"message": str(exc).split(":", 1)[-1].strip(),
|
|
1245
|
+
},
|
|
1246
|
+
ensure_ascii=False,
|
|
1247
|
+
),
|
|
1248
|
+
file=sys.stderr,
|
|
1249
|
+
)
|
|
1250
|
+
return 127
|
|
1251
|
+
except OSError as exc:
|
|
1252
|
+
print(
|
|
1253
|
+
json.dumps(
|
|
1254
|
+
{
|
|
1255
|
+
"ok": False,
|
|
1256
|
+
"code": "MANAGED_COMMAND_START_FAILED",
|
|
1257
|
+
"message": f"命令无法启动:{exc}",
|
|
1258
|
+
},
|
|
1259
|
+
ensure_ascii=False,
|
|
1260
|
+
),
|
|
1261
|
+
file=sys.stderr,
|
|
1262
|
+
)
|
|
1263
|
+
return 126
|
|
1264
|
+
|
|
1265
|
+
duration = time.monotonic() - started
|
|
1266
|
+
status = "PASS" if not failures else "FAIL"
|
|
1267
|
+
print(
|
|
1268
|
+
f"[harness-test] {status} profile={args.profile} "
|
|
1269
|
+
f"modulesPassed={executed_count - len(failed_modules)} "
|
|
1270
|
+
f"modulesExecuted={executed_count} modulesPlanned={len(plan)} "
|
|
1271
|
+
f"durationSeconds={duration:.2f}",
|
|
1272
|
+
flush=True,
|
|
1273
|
+
)
|
|
1274
|
+
if failures:
|
|
1275
|
+
print("[harness-test] failures=" + ",".join(failures), file=sys.stderr)
|
|
1276
|
+
return 1
|
|
1277
|
+
return 0
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _run_exec(args: argparse.Namespace) -> int:
|
|
1281
|
+
command = list(args.command)
|
|
1282
|
+
powershell: dict[str, str] | None = None
|
|
1283
|
+
if args.runtime_receipt and not args.argv_file:
|
|
1284
|
+
print(
|
|
1285
|
+
"RUNTIME_RECEIPT_REQUIRES_ARGUMENT_FILE: "
|
|
1286
|
+
"--runtime-receipt requires --argv-file",
|
|
1287
|
+
file=sys.stderr,
|
|
1288
|
+
)
|
|
1289
|
+
return 2
|
|
1290
|
+
if args.argv_file:
|
|
1291
|
+
if command:
|
|
1292
|
+
print(
|
|
1293
|
+
"ARGUMENT_TRANSPORT_CONFLICT: use either --argv-file or a command",
|
|
1294
|
+
file=sys.stderr,
|
|
1295
|
+
)
|
|
1296
|
+
return 2
|
|
1297
|
+
argv_file = Path(args.argv_file)
|
|
1298
|
+
project_for_path = Path(args.project).resolve()
|
|
1299
|
+
if not argv_file.is_absolute():
|
|
1300
|
+
argv_file = project_for_path / argv_file
|
|
1301
|
+
try:
|
|
1302
|
+
command, powershell = load_argv_envelope(argv_file)
|
|
1303
|
+
except ValueError as exc:
|
|
1304
|
+
print(f"ARGUMENT_FILE_INVALID: {exc}", file=sys.stderr)
|
|
1305
|
+
return 2
|
|
1306
|
+
elif command and command[0] == "--":
|
|
1307
|
+
command = command[1:]
|
|
1308
|
+
if not command:
|
|
1309
|
+
print(
|
|
1310
|
+
"managed exec requires a command after -- or --argv-file",
|
|
1311
|
+
file=sys.stderr,
|
|
1312
|
+
)
|
|
1313
|
+
return 2
|
|
1314
|
+
if _is_persistent_service_command(command):
|
|
1315
|
+
print(
|
|
1316
|
+
"PERSISTENT_SERVICE_MODE_REQUIRED: invoke harness_service.py ensure "
|
|
1317
|
+
"outside the bounded test runner",
|
|
1318
|
+
file=sys.stderr,
|
|
1319
|
+
)
|
|
1320
|
+
return 7
|
|
1321
|
+
if not resource_profile_allowed(
|
|
1322
|
+
args.profile,
|
|
1323
|
+
args.confirm_resource_intensive,
|
|
1324
|
+
os.environ,
|
|
1325
|
+
):
|
|
1326
|
+
print(
|
|
1327
|
+
"RESOURCE_CONFIRMATION_REQUIRED: "
|
|
1328
|
+
f"profile={args.profile}; rerun with --confirm-resource-intensive",
|
|
1329
|
+
file=sys.stderr,
|
|
1330
|
+
)
|
|
1331
|
+
return 2
|
|
1332
|
+
project = Path(args.project).resolve()
|
|
1333
|
+
environment = _execution_environment(args.profile, args.max_workers)
|
|
1334
|
+
required_environment = list(args.required_environment_field or [])
|
|
1335
|
+
if required_environment and not args.environment_receipt:
|
|
1336
|
+
print(
|
|
1337
|
+
json.dumps(
|
|
1338
|
+
{
|
|
1339
|
+
"ok": False,
|
|
1340
|
+
"code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
|
|
1341
|
+
"missing": sorted(set(required_environment)),
|
|
1342
|
+
"changed": [],
|
|
1343
|
+
},
|
|
1344
|
+
ensure_ascii=False,
|
|
1345
|
+
),
|
|
1346
|
+
file=sys.stderr,
|
|
1347
|
+
)
|
|
1348
|
+
return 6
|
|
1349
|
+
if args.environment_receipt:
|
|
1350
|
+
receipt_path = Path(args.environment_receipt)
|
|
1351
|
+
if not receipt_path.is_absolute():
|
|
1352
|
+
receipt_path = project / receipt_path
|
|
1353
|
+
try:
|
|
1354
|
+
receipt = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
|
|
1355
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1356
|
+
print(
|
|
1357
|
+
"VERIFICATION_ENVIRONMENT_INCOMPLETE: "
|
|
1358
|
+
f"environment receipt is unreadable: {exc}",
|
|
1359
|
+
file=sys.stderr,
|
|
1360
|
+
)
|
|
1361
|
+
return 6
|
|
1362
|
+
resolved = henv.resolve_verification_environment(
|
|
1363
|
+
receipt,
|
|
1364
|
+
required_fields=required_environment,
|
|
1365
|
+
source_environment=dict(os.environ),
|
|
1366
|
+
)
|
|
1367
|
+
if not resolved.get("ok"):
|
|
1368
|
+
print(json.dumps(resolved, ensure_ascii=False), file=sys.stderr)
|
|
1369
|
+
return 6
|
|
1370
|
+
environment.update(resolved["environment"])
|
|
1371
|
+
try:
|
|
1372
|
+
with TestRunLock(project) as run_lock:
|
|
1373
|
+
run_lock.heartbeat()
|
|
1374
|
+
if args.allow_detached_processes:
|
|
1375
|
+
available, detail = detached_process_capability(project)
|
|
1376
|
+
if not available:
|
|
1377
|
+
print(
|
|
1378
|
+
"DETACHED_PROCESS_CAPABILITY_UNAVAILABLE: "
|
|
1379
|
+
f"{detail}; rerun in an environment that permits "
|
|
1380
|
+
"nested Windows breakaway",
|
|
1381
|
+
file=sys.stderr,
|
|
1382
|
+
)
|
|
1383
|
+
return 5
|
|
1384
|
+
result = run_managed_command(
|
|
1385
|
+
command,
|
|
1386
|
+
cwd=project,
|
|
1387
|
+
timeout_seconds=args.timeout_seconds,
|
|
1388
|
+
environ=environment,
|
|
1389
|
+
allow_detached_processes=args.allow_detached_processes,
|
|
1390
|
+
)
|
|
1391
|
+
run_lock.heartbeat()
|
|
1392
|
+
except TestRunAlreadyActive as exc:
|
|
1393
|
+
print(str(exc), file=sys.stderr)
|
|
1394
|
+
return 3
|
|
1395
|
+
except ManagedCommandNotFound as exc:
|
|
1396
|
+
print(
|
|
1397
|
+
json.dumps(
|
|
1398
|
+
{
|
|
1399
|
+
"ok": False,
|
|
1400
|
+
"code": "MANAGED_COMMAND_NOT_FOUND",
|
|
1401
|
+
"message": str(exc).split(":", 1)[-1].strip(),
|
|
1402
|
+
},
|
|
1403
|
+
ensure_ascii=False,
|
|
1404
|
+
),
|
|
1405
|
+
file=sys.stderr,
|
|
1406
|
+
)
|
|
1407
|
+
return 127
|
|
1408
|
+
except OSError as exc:
|
|
1409
|
+
print(
|
|
1410
|
+
json.dumps(
|
|
1411
|
+
{
|
|
1412
|
+
"ok": False,
|
|
1413
|
+
"code": "MANAGED_COMMAND_START_FAILED",
|
|
1414
|
+
"message": f"命令无法启动:{exc}",
|
|
1415
|
+
},
|
|
1416
|
+
ensure_ascii=False,
|
|
1417
|
+
),
|
|
1418
|
+
file=sys.stderr,
|
|
1419
|
+
)
|
|
1420
|
+
return 126
|
|
1421
|
+
if not result.process_tree_isolated:
|
|
1422
|
+
print("PROCESS_TREE_ISOLATION_UNAVAILABLE", file=sys.stderr)
|
|
1423
|
+
return 4
|
|
1424
|
+
if result.timed_out:
|
|
1425
|
+
print(
|
|
1426
|
+
f"TEST_COMMAND_TIMEOUT: timeoutSeconds={args.timeout_seconds}",
|
|
1427
|
+
file=sys.stderr,
|
|
1428
|
+
)
|
|
1429
|
+
return 124
|
|
1430
|
+
if args.runtime_receipt:
|
|
1431
|
+
assert powershell is not None
|
|
1432
|
+
receipt_path = Path(args.runtime_receipt)
|
|
1433
|
+
if not receipt_path.is_absolute():
|
|
1434
|
+
receipt_path = project / receipt_path
|
|
1435
|
+
_write_exec_runtime_receipt(
|
|
1436
|
+
receipt_path,
|
|
1437
|
+
command=command,
|
|
1438
|
+
powershell=powershell,
|
|
1439
|
+
result=result,
|
|
1440
|
+
)
|
|
1441
|
+
return result.returncode
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _run_lock_status(args: argparse.Namespace) -> int:
|
|
1445
|
+
lock = TestRunLock(Path(args.project).resolve())
|
|
1446
|
+
print(
|
|
1447
|
+
json.dumps(
|
|
1448
|
+
{
|
|
1449
|
+
"ok": True,
|
|
1450
|
+
"action": "lock-status",
|
|
1451
|
+
"lock": classify_test_lock(lock.path),
|
|
1452
|
+
},
|
|
1453
|
+
ensure_ascii=False,
|
|
1454
|
+
indent=2,
|
|
1455
|
+
)
|
|
1456
|
+
)
|
|
1457
|
+
return 0
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _run_lock_reap(args: argparse.Namespace) -> int:
|
|
1461
|
+
lock = TestRunLock(Path(args.project).resolve())
|
|
1462
|
+
print(json.dumps(reap_test_lock(lock.path), ensure_ascii=False, indent=2))
|
|
1463
|
+
return 0
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1467
|
+
parser = argparse.ArgumentParser(
|
|
1468
|
+
description="Run tests with bounded workers, locking, timeout, and cleanup."
|
|
1469
|
+
)
|
|
1470
|
+
subparsers = parser.add_subparsers(dest="command_name", required=True)
|
|
1471
|
+
|
|
1472
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
1473
|
+
common.add_argument("--project", default=".")
|
|
1474
|
+
common.add_argument(
|
|
1475
|
+
"--profile",
|
|
1476
|
+
choices=("safe", "system", "full"),
|
|
1477
|
+
default="safe",
|
|
1478
|
+
)
|
|
1479
|
+
common.add_argument(
|
|
1480
|
+
"--timeout-seconds",
|
|
1481
|
+
type=float,
|
|
1482
|
+
default=DEFAULT_TIMEOUT_SECONDS,
|
|
1483
|
+
)
|
|
1484
|
+
common.add_argument(
|
|
1485
|
+
"--max-workers",
|
|
1486
|
+
type=int,
|
|
1487
|
+
default=DEFAULT_MAX_WORKERS,
|
|
1488
|
+
)
|
|
1489
|
+
common.add_argument("--confirm-resource-intensive", action="store_true")
|
|
1490
|
+
|
|
1491
|
+
unittest_parser = subparsers.add_parser(
|
|
1492
|
+
"unittest",
|
|
1493
|
+
parents=[common],
|
|
1494
|
+
help="Run discovered Python unittest modules in isolated processes.",
|
|
1495
|
+
)
|
|
1496
|
+
unittest_parser.add_argument(
|
|
1497
|
+
"--tests-dir",
|
|
1498
|
+
default="harness/scripts/tests",
|
|
1499
|
+
)
|
|
1500
|
+
unittest_parser.add_argument(
|
|
1501
|
+
"--resource-module",
|
|
1502
|
+
action="append",
|
|
1503
|
+
default=list(DEFAULT_RESOURCE_MODULES),
|
|
1504
|
+
help="Module reserved for system/full profiles; may be repeated.",
|
|
1505
|
+
)
|
|
1506
|
+
unittest_parser.add_argument(
|
|
1507
|
+
"--detached-service-module",
|
|
1508
|
+
action="append",
|
|
1509
|
+
default=list(DEFAULT_DETACHED_SERVICE_MODULES),
|
|
1510
|
+
help="Module that intentionally tests detached services; may be repeated.",
|
|
1511
|
+
)
|
|
1512
|
+
unittest_parser.add_argument("--keep-going", action="store_true")
|
|
1513
|
+
unittest_parser.add_argument("--dry-run", action="store_true")
|
|
1514
|
+
unittest_parser.add_argument(
|
|
1515
|
+
"--verbosity",
|
|
1516
|
+
type=int,
|
|
1517
|
+
choices=(0, 1, 2),
|
|
1518
|
+
default=1,
|
|
1519
|
+
)
|
|
1520
|
+
unittest_parser.set_defaults(handler=_run_unittest)
|
|
1521
|
+
|
|
1522
|
+
exec_parser = subparsers.add_parser(
|
|
1523
|
+
"exec",
|
|
1524
|
+
parents=[common],
|
|
1525
|
+
help="Run one arbitrary test command under the same safety controls.",
|
|
1526
|
+
)
|
|
1527
|
+
exec_parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
1528
|
+
exec_parser.add_argument(
|
|
1529
|
+
"--argv-file",
|
|
1530
|
+
help=(
|
|
1531
|
+
"Read argv from a UTF-8 JSON parameter file so PowerShell 5.1 and 7 "
|
|
1532
|
+
"use identical native-process argument semantics."
|
|
1533
|
+
),
|
|
1534
|
+
)
|
|
1535
|
+
exec_parser.add_argument(
|
|
1536
|
+
"--runtime-receipt",
|
|
1537
|
+
help="Write a secret-free argv hash and PowerShell runtime receipt.",
|
|
1538
|
+
)
|
|
1539
|
+
exec_parser.add_argument(
|
|
1540
|
+
"--environment-receipt",
|
|
1541
|
+
help="Typed environment receipt created by harness_environment.py prepare.",
|
|
1542
|
+
)
|
|
1543
|
+
exec_parser.add_argument(
|
|
1544
|
+
"--required-environment-field",
|
|
1545
|
+
action="append",
|
|
1546
|
+
default=[],
|
|
1547
|
+
help="Dynamic environment field required by the command; may be repeated.",
|
|
1548
|
+
)
|
|
1549
|
+
exec_parser.add_argument(
|
|
1550
|
+
"--allow-detached-processes",
|
|
1551
|
+
action="store_true",
|
|
1552
|
+
help=(
|
|
1553
|
+
"Permit explicit Windows Job breakaway while retaining ordinary "
|
|
1554
|
+
"descendants and tracking escaped process lineages."
|
|
1555
|
+
),
|
|
1556
|
+
)
|
|
1557
|
+
exec_parser.set_defaults(handler=_run_exec)
|
|
1558
|
+
|
|
1559
|
+
lock_status = subparsers.add_parser(
|
|
1560
|
+
"lock-status",
|
|
1561
|
+
help="Classify the project test lock without mutating it.",
|
|
1562
|
+
)
|
|
1563
|
+
lock_status.add_argument("--project", default=".")
|
|
1564
|
+
lock_status.set_defaults(handler=_run_lock_status)
|
|
1565
|
+
|
|
1566
|
+
lock_reap = subparsers.add_parser(
|
|
1567
|
+
"lock-reap",
|
|
1568
|
+
help="Reap only an expired lock whose exact owner is absent.",
|
|
1569
|
+
)
|
|
1570
|
+
lock_reap.add_argument("--project", default=".")
|
|
1571
|
+
lock_reap.set_defaults(handler=_run_lock_reap)
|
|
1572
|
+
return parser
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1576
|
+
parser = build_parser()
|
|
1577
|
+
args = parser.parse_args(argv)
|
|
1578
|
+
if getattr(args, "timeout_seconds", DEFAULT_TIMEOUT_SECONDS) <= 0:
|
|
1579
|
+
parser.error("--timeout-seconds must be positive")
|
|
1580
|
+
if getattr(args, "max_workers", DEFAULT_MAX_WORKERS) <= 0:
|
|
1581
|
+
parser.error("--max-workers must be positive")
|
|
1582
|
+
return int(args.handler(args))
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
if __name__ == "__main__":
|
|
1586
|
+
raise SystemExit(main())
|