@hunter-harness/workflow-harness 0.2.41 → 0.2.43
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 +56 -1
- 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 +42 -16
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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-sync/SKILL.md +16 -10
- package/harness/bundles/general/claude-code/harness-sync/reference.md +34 -14
- 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 +4 -0
- package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/claude-code/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/claude-code/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/claude-code/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/claude-code/scripts/harness_service.py +76 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/claude-code/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +56 -1
- 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 +41 -15
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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-sync/SKILL.md +16 -10
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +34 -14
- 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 +4 -0
- package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/codebuddy/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +76 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/codebuddy/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +56 -1
- 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 +41 -15
- package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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-sync/SKILL.md +16 -10
- package/harness/bundles/general/codex/harness-sync/reference.md +34 -14
- 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 +4 -0
- package/harness/bundles/general/codex/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/codex/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/codex/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/codex/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/codex/scripts/harness_service.py +76 -0
- package/harness/bundles/general/codex/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/codex/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +56 -1
- 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 +41 -15
- package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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-sync/SKILL.md +16 -10
- package/harness/bundles/general/cursor/harness-sync/reference.md +34 -14
- 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 +4 -0
- package/harness/bundles/general/cursor/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/cursor/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/cursor/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/cursor/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/cursor/scripts/harness_service.py +76 -0
- package/harness/bundles/general/cursor/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/cursor/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +56 -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-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 +42 -16
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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-sync/SKILL.md +16 -10
- package/harness/bundles/java/claude-code/harness-sync/reference.md +34 -14
- 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 +4 -0
- package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/claude-code/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/claude-code/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/claude-code/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/claude-code/scripts/harness_service.py +76 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/claude-code/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +56 -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-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 +41 -15
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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-sync/SKILL.md +16 -10
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +34 -14
- 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 +4 -0
- package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/codebuddy/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +76 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/codebuddy/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +56 -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-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 +41 -15
- package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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-sync/SKILL.md +16 -10
- package/harness/bundles/java/codex/harness-sync/reference.md +34 -14
- 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 +4 -0
- package/harness/bundles/java/codex/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/codex/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/codex/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/codex/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/codex/scripts/harness_service.py +76 -0
- package/harness/bundles/java/codex/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/codex/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +56 -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-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 +41 -15
- package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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-sync/SKILL.md +16 -10
- package/harness/bundles/java/cursor/harness-sync/reference.md +34 -14
- 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 +4 -0
- package/harness/bundles/java/cursor/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/cursor/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/cursor/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/cursor/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/cursor/scripts/harness_service.py +76 -0
- package/harness/bundles/java/cursor/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/cursor/scripts/harness_verification.py +408 -8
- package/harness/manifests/general/claude-code.json +46 -33
- package/harness/manifests/general/codebuddy.json +46 -33
- package/harness/manifests/general/codex.json +46 -33
- package/harness/manifests/general/cursor.json +46 -33
- package/harness/manifests/java/claude-code.json +47 -34
- package/harness/manifests/java/codebuddy.json +47 -34
- package/harness/manifests/java/codex.json +47 -34
- package/harness/manifests/java/cursor.json +47 -34
- package/hunter-workflow-family.json +12 -10
- 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,15 +14,24 @@ 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}$")
|
|
25
36
|
_ADAPTERS = {
|
|
26
37
|
"claude-code": (".worktrees", "harness/"),
|
|
@@ -34,6 +45,40 @@ def now_iso() -> str:
|
|
|
34
45
|
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
35
46
|
|
|
36
47
|
|
|
48
|
+
def _stage_transition(receipt: dict[str, Any], next_stage: str) -> None:
|
|
49
|
+
ended_at = now_iso()
|
|
50
|
+
started_at = str(receipt.get("stageStartedAt") or ended_at)
|
|
51
|
+
try:
|
|
52
|
+
started = dt.datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
53
|
+
ended = dt.datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
|
54
|
+
wall_ms = max(0, int((ended - started).total_seconds() * 1000))
|
|
55
|
+
except ValueError:
|
|
56
|
+
wall_ms = 0
|
|
57
|
+
stage = str(receipt.get("stage") or "unknown")
|
|
58
|
+
timings = receipt.setdefault("stageTimings", [])
|
|
59
|
+
if isinstance(timings, list):
|
|
60
|
+
timings.append(
|
|
61
|
+
{
|
|
62
|
+
"stage": stage,
|
|
63
|
+
"createdAt": started_at,
|
|
64
|
+
"endedAt": ended_at,
|
|
65
|
+
"wallClockMs": wall_ms,
|
|
66
|
+
"activeTimeMs": 0 if stage == "resource-wait" else wall_ms,
|
|
67
|
+
"resourceWaitMs": wall_ms if stage == "resource-wait" else 0,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
receipt["stage"] = next_stage
|
|
71
|
+
receipt["stageStartedAt"] = ended_at
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _close_current_stage(receipt: dict[str, Any]) -> None:
|
|
75
|
+
current = str(receipt.get("stage") or "record")
|
|
76
|
+
_stage_transition(receipt, current)
|
|
77
|
+
timings = receipt.get("stageTimings")
|
|
78
|
+
if isinstance(timings, list) and timings:
|
|
79
|
+
receipt["stageStartedAt"] = timings[-1].get("endedAt")
|
|
80
|
+
|
|
81
|
+
|
|
37
82
|
def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
38
83
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
84
|
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
@@ -46,11 +91,1067 @@ def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
|
46
91
|
handle.write(text)
|
|
47
92
|
handle.flush()
|
|
48
93
|
os.fsync(handle.fileno())
|
|
49
|
-
|
|
94
|
+
# Windows can transiently deny replacement while another process has
|
|
95
|
+
# just finished reading the receipt. Keep the atomic replace contract,
|
|
96
|
+
# but retry the sharing violation within a small bounded window.
|
|
97
|
+
for attempt in range(20):
|
|
98
|
+
try:
|
|
99
|
+
os.replace(tmp, path)
|
|
100
|
+
break
|
|
101
|
+
except PermissionError:
|
|
102
|
+
if attempt == 19:
|
|
103
|
+
raise
|
|
104
|
+
time.sleep(0.01 * (attempt + 1))
|
|
50
105
|
finally:
|
|
51
106
|
tmp.unlink(missing_ok=True)
|
|
52
107
|
|
|
53
108
|
|
|
109
|
+
def _sha256_json(value: Any) -> str:
|
|
110
|
+
encoded = json.dumps(
|
|
111
|
+
value,
|
|
112
|
+
ensure_ascii=False,
|
|
113
|
+
sort_keys=True,
|
|
114
|
+
separators=(",", ":"),
|
|
115
|
+
).encode("utf-8")
|
|
116
|
+
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _run_sessions_root(state_root: Path) -> Path:
|
|
120
|
+
return state_root.expanduser().resolve() / "runtime" / "run-sessions"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _run_session_root(state_root: Path, session_id: str) -> Path:
|
|
124
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", session_id):
|
|
125
|
+
raise ValueError(f"RUN_SESSION_ID_INVALID: {session_id}")
|
|
126
|
+
return _run_sessions_root(state_root) / session_id
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run_receipt_path(state_root: Path, session_id: str) -> Path:
|
|
130
|
+
return _run_session_root(state_root, session_id) / "session.json"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_run_receipt(state_root: Path, session_id: str) -> dict[str, Any]:
|
|
134
|
+
path = _run_receipt_path(state_root, session_id)
|
|
135
|
+
try:
|
|
136
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
137
|
+
except FileNotFoundError as exc:
|
|
138
|
+
raise ValueError(f"RUN_SESSION_NOT_FOUND: {session_id}") from exc
|
|
139
|
+
if not isinstance(value, dict):
|
|
140
|
+
raise ValueError(f"RUN_SESSION_CORRUPT: {session_id}")
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resource_lock_root() -> Path:
|
|
145
|
+
return Path(
|
|
146
|
+
os.environ.get(
|
|
147
|
+
"HARNESS_RESOURCE_LOCK_ROOT",
|
|
148
|
+
str(Path(tempfile.gettempdir()) / "hunter-harness-resource-locks"),
|
|
149
|
+
)
|
|
150
|
+
).expanduser().resolve()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resource_lock_path(_state_root: Path, lock_name: str) -> Path:
|
|
154
|
+
digest = hashlib.sha256(lock_name.encode("utf-8")).hexdigest()
|
|
155
|
+
return _resource_lock_root() / f"{digest}.json"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _lock_owner_identity(owner: Any) -> bool | None:
|
|
159
|
+
if not isinstance(owner, dict):
|
|
160
|
+
return None
|
|
161
|
+
pid = owner.get("pid")
|
|
162
|
+
started_at = owner.get("startedAt")
|
|
163
|
+
executable = owner.get("executable")
|
|
164
|
+
if (
|
|
165
|
+
not isinstance(pid, int)
|
|
166
|
+
or pid <= 0
|
|
167
|
+
or not isinstance(started_at, str)
|
|
168
|
+
or not started_at
|
|
169
|
+
or not isinstance(executable, str)
|
|
170
|
+
or not executable
|
|
171
|
+
):
|
|
172
|
+
return None
|
|
173
|
+
from harness_service import verify_process_identity
|
|
174
|
+
|
|
175
|
+
return verify_process_identity(
|
|
176
|
+
{
|
|
177
|
+
"pid": pid,
|
|
178
|
+
"startedAt": started_at,
|
|
179
|
+
"processIdentity": {"executable": executable},
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _valid_resource_lock_record(
|
|
185
|
+
record: Any,
|
|
186
|
+
*,
|
|
187
|
+
lock_name: str,
|
|
188
|
+
) -> bool:
|
|
189
|
+
return bool(
|
|
190
|
+
isinstance(record, dict)
|
|
191
|
+
and record.get("schemaVersion") == 2
|
|
192
|
+
and record.get("lockName") == lock_name
|
|
193
|
+
and isinstance(record.get("sessionId"), str)
|
|
194
|
+
and record.get("sessionId")
|
|
195
|
+
and isinstance(record.get("token"), str)
|
|
196
|
+
and record.get("token")
|
|
197
|
+
and isinstance(record.get("heartbeatAtUnix"), (int, float))
|
|
198
|
+
and isinstance(record.get("expiresAtUnix"), (int, float))
|
|
199
|
+
and _lock_owner_identity(record.get("owner")) is not None
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _write_resource_lock(
|
|
204
|
+
path: Path,
|
|
205
|
+
*,
|
|
206
|
+
lock_name: str,
|
|
207
|
+
session_id: str,
|
|
208
|
+
token: str,
|
|
209
|
+
owner: dict[str, Any],
|
|
210
|
+
acquired_at: str,
|
|
211
|
+
) -> None:
|
|
212
|
+
now = time.time()
|
|
213
|
+
atomic_write_json(
|
|
214
|
+
path,
|
|
215
|
+
{
|
|
216
|
+
"schemaVersion": 2,
|
|
217
|
+
"lockName": lock_name,
|
|
218
|
+
"sessionId": session_id,
|
|
219
|
+
"token": token,
|
|
220
|
+
"acquiredAt": acquired_at,
|
|
221
|
+
"heartbeatAtUnix": now,
|
|
222
|
+
"expiresAtUnix": now + 300.0,
|
|
223
|
+
"owner": owner,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _refresh_resource_locks(
|
|
229
|
+
state_root: Path,
|
|
230
|
+
session_id: str,
|
|
231
|
+
tokens: dict[str, str],
|
|
232
|
+
owner: dict[str, Any],
|
|
233
|
+
) -> bool:
|
|
234
|
+
refreshed = True
|
|
235
|
+
for lock_name, token in sorted(tokens.items()):
|
|
236
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
237
|
+
try:
|
|
238
|
+
record = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
239
|
+
except (OSError, json.JSONDecodeError):
|
|
240
|
+
refreshed = False
|
|
241
|
+
continue
|
|
242
|
+
if (
|
|
243
|
+
not _valid_resource_lock_record(record, lock_name=lock_name)
|
|
244
|
+
or record.get("sessionId") != session_id
|
|
245
|
+
or record.get("token") != token
|
|
246
|
+
):
|
|
247
|
+
refreshed = False
|
|
248
|
+
continue
|
|
249
|
+
_write_resource_lock(
|
|
250
|
+
path,
|
|
251
|
+
lock_name=lock_name,
|
|
252
|
+
session_id=session_id,
|
|
253
|
+
token=token,
|
|
254
|
+
owner=owner,
|
|
255
|
+
acquired_at=str(record["acquiredAt"]),
|
|
256
|
+
)
|
|
257
|
+
return refreshed
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _release_resource_locks(
|
|
261
|
+
state_root: Path,
|
|
262
|
+
session_id: str,
|
|
263
|
+
tokens: dict[str, str],
|
|
264
|
+
) -> bool:
|
|
265
|
+
released = True
|
|
266
|
+
for lock_name, token in sorted(tokens.items()):
|
|
267
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
268
|
+
try:
|
|
269
|
+
record = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
270
|
+
except FileNotFoundError:
|
|
271
|
+
continue
|
|
272
|
+
except (OSError, json.JSONDecodeError):
|
|
273
|
+
released = False
|
|
274
|
+
continue
|
|
275
|
+
if (
|
|
276
|
+
not isinstance(record, dict)
|
|
277
|
+
or record.get("sessionId") != session_id
|
|
278
|
+
or record.get("token") != token
|
|
279
|
+
):
|
|
280
|
+
released = False
|
|
281
|
+
continue
|
|
282
|
+
try:
|
|
283
|
+
path.unlink()
|
|
284
|
+
except OSError:
|
|
285
|
+
released = False
|
|
286
|
+
return released
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _acquire_resource_locks(
|
|
290
|
+
state_root: Path,
|
|
291
|
+
session_id: str,
|
|
292
|
+
lock_names: list[str],
|
|
293
|
+
) -> tuple[dict[str, str], dict[str, Any] | None]:
|
|
294
|
+
tokens: dict[str, str] = {}
|
|
295
|
+
for lock_name in lock_names:
|
|
296
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
297
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
token = uuid.uuid4().hex
|
|
299
|
+
owner_identity = _process_identity(os.getpid(), sys.executable)
|
|
300
|
+
now = time.time()
|
|
301
|
+
record = {
|
|
302
|
+
"schemaVersion": 2,
|
|
303
|
+
"lockName": lock_name,
|
|
304
|
+
"sessionId": session_id,
|
|
305
|
+
"token": token,
|
|
306
|
+
"acquiredAt": now_iso(),
|
|
307
|
+
"heartbeatAtUnix": now,
|
|
308
|
+
"expiresAtUnix": now + 300.0,
|
|
309
|
+
"owner": owner_identity,
|
|
310
|
+
}
|
|
311
|
+
encoded = (
|
|
312
|
+
json.dumps(record, ensure_ascii=False, indent=2) + "\n"
|
|
313
|
+
).encode("utf-8")
|
|
314
|
+
for attempt in range(2):
|
|
315
|
+
try:
|
|
316
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
317
|
+
except FileExistsError:
|
|
318
|
+
owner: dict[str, Any] | None = None
|
|
319
|
+
try:
|
|
320
|
+
loaded = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
321
|
+
owner = loaded if isinstance(loaded, dict) else None
|
|
322
|
+
except (OSError, json.JSONDecodeError):
|
|
323
|
+
owner = None
|
|
324
|
+
reclaimable = bool(
|
|
325
|
+
_valid_resource_lock_record(owner, lock_name=lock_name)
|
|
326
|
+
and _lock_owner_identity(owner.get("owner")) is False
|
|
327
|
+
)
|
|
328
|
+
if reclaimable and attempt == 0:
|
|
329
|
+
try:
|
|
330
|
+
current = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
331
|
+
if current.get("token") == owner.get("token"):
|
|
332
|
+
path.unlink()
|
|
333
|
+
continue
|
|
334
|
+
except (OSError, json.JSONDecodeError):
|
|
335
|
+
pass
|
|
336
|
+
_release_resource_locks(state_root, session_id, tokens)
|
|
337
|
+
return {}, {
|
|
338
|
+
"lockName": lock_name,
|
|
339
|
+
"ownerSessionId": (
|
|
340
|
+
owner.get("sessionId") if owner is not None else None
|
|
341
|
+
),
|
|
342
|
+
"lockRecordValid": bool(
|
|
343
|
+
_valid_resource_lock_record(owner, lock_name=lock_name)
|
|
344
|
+
),
|
|
345
|
+
"ownerIdentity": (
|
|
346
|
+
_lock_owner_identity(owner.get("owner"))
|
|
347
|
+
if owner is not None
|
|
348
|
+
else None
|
|
349
|
+
),
|
|
350
|
+
}
|
|
351
|
+
else:
|
|
352
|
+
break
|
|
353
|
+
else:
|
|
354
|
+
raise RuntimeError("RESOURCE_LOCK_ACQUIRE_RETRY_EXHAUSTED")
|
|
355
|
+
try:
|
|
356
|
+
with os.fdopen(fd, "wb") as handle:
|
|
357
|
+
handle.write(encoded)
|
|
358
|
+
handle.flush()
|
|
359
|
+
os.fsync(handle.fileno())
|
|
360
|
+
except BaseException:
|
|
361
|
+
path.unlink(missing_ok=True)
|
|
362
|
+
_release_resource_locks(state_root, session_id, tokens)
|
|
363
|
+
raise
|
|
364
|
+
tokens[lock_name] = token
|
|
365
|
+
return tokens, None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _write_run_receipt(state_root: Path, receipt: dict[str, Any]) -> None:
|
|
369
|
+
atomic_write_json(
|
|
370
|
+
_run_receipt_path(state_root, str(receipt["sessionId"])),
|
|
371
|
+
receipt,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _process_identity(pid: int, executable_hint: str) -> dict[str, Any]:
|
|
376
|
+
from harness_service import get_process_create_time, get_process_executable
|
|
377
|
+
|
|
378
|
+
created = get_process_create_time(pid)
|
|
379
|
+
executable = get_process_executable(pid)
|
|
380
|
+
return {
|
|
381
|
+
"pid": pid,
|
|
382
|
+
"startedAt": (
|
|
383
|
+
created.astimezone().isoformat(timespec="milliseconds")
|
|
384
|
+
if created is not None
|
|
385
|
+
else now_iso()
|
|
386
|
+
),
|
|
387
|
+
"executable": executable or _absolute_executable(executable_hint),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _spawn_detached_worker(
|
|
392
|
+
argv: list[str],
|
|
393
|
+
*,
|
|
394
|
+
cwd: Path,
|
|
395
|
+
env: dict[str, str],
|
|
396
|
+
output: Any = subprocess.DEVNULL,
|
|
397
|
+
) -> subprocess.Popen[bytes]:
|
|
398
|
+
kwargs: dict[str, Any] = {
|
|
399
|
+
"cwd": str(cwd),
|
|
400
|
+
"env": env,
|
|
401
|
+
"stdin": subprocess.DEVNULL,
|
|
402
|
+
"stdout": output,
|
|
403
|
+
"stderr": output,
|
|
404
|
+
"close_fds": True,
|
|
405
|
+
}
|
|
406
|
+
if os.name == "nt":
|
|
407
|
+
detached = 0x00000008
|
|
408
|
+
new_group = 0x00000200
|
|
409
|
+
no_window = 0x08000000
|
|
410
|
+
breakaway = 0x01000000
|
|
411
|
+
try:
|
|
412
|
+
return subprocess.Popen(
|
|
413
|
+
argv,
|
|
414
|
+
creationflags=detached | new_group | no_window | breakaway,
|
|
415
|
+
**kwargs,
|
|
416
|
+
)
|
|
417
|
+
except OSError:
|
|
418
|
+
return subprocess.Popen(
|
|
419
|
+
argv,
|
|
420
|
+
creationflags=detached | new_group | no_window,
|
|
421
|
+
**kwargs,
|
|
422
|
+
)
|
|
423
|
+
return subprocess.Popen(argv, start_new_session=True, **kwargs)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def start_run_session(
|
|
427
|
+
*,
|
|
428
|
+
state_root: Path,
|
|
429
|
+
verification: str,
|
|
430
|
+
argv: list[str],
|
|
431
|
+
working_directory: Path,
|
|
432
|
+
environment: dict[str, str] | None = None,
|
|
433
|
+
timeout_seconds: float | None = None,
|
|
434
|
+
heartbeat_seconds: float = 30.0,
|
|
435
|
+
expected_duration_seconds: float | None = None,
|
|
436
|
+
product_identity: str | None = None,
|
|
437
|
+
resource_locks: list[str] | None = None,
|
|
438
|
+
run_id: str | None = None,
|
|
439
|
+
session_id: str | None = None,
|
|
440
|
+
) -> dict[str, Any]:
|
|
441
|
+
"""Start a re-attachable managed command without shell re-parsing.
|
|
442
|
+
|
|
443
|
+
The short-lived launch specification is removed by the worker immediately
|
|
444
|
+
after spawn. Environment values are inherited by the worker and hashed in
|
|
445
|
+
the durable receipt; they are never serialized.
|
|
446
|
+
"""
|
|
447
|
+
if not verification.strip():
|
|
448
|
+
raise ValueError("RUN_SESSION_VERIFICATION_REQUIRED")
|
|
449
|
+
if not argv or not all(isinstance(item, str) and item for item in argv):
|
|
450
|
+
raise ValueError("RUN_SESSION_ARGV_REQUIRED")
|
|
451
|
+
if heartbeat_seconds <= 0:
|
|
452
|
+
raise ValueError("RUN_SESSION_HEARTBEAT_INVALID")
|
|
453
|
+
if timeout_seconds is not None and timeout_seconds <= 0:
|
|
454
|
+
raise ValueError("RUN_SESSION_TIMEOUT_INVALID")
|
|
455
|
+
normalized_resource_locks: list[str] = []
|
|
456
|
+
for raw_lock in resource_locks or []:
|
|
457
|
+
if (
|
|
458
|
+
not isinstance(raw_lock, str)
|
|
459
|
+
or not raw_lock.strip()
|
|
460
|
+
or len(raw_lock.strip()) > 256
|
|
461
|
+
):
|
|
462
|
+
raise ValueError("RUN_SESSION_RESOURCE_LOCK_INVALID")
|
|
463
|
+
normalized_resource_locks.append(raw_lock.strip())
|
|
464
|
+
normalized_resource_locks = sorted(set(normalized_resource_locks))
|
|
465
|
+
|
|
466
|
+
state_root = state_root.expanduser().resolve()
|
|
467
|
+
working_directory = working_directory.expanduser().resolve()
|
|
468
|
+
if not working_directory.is_dir():
|
|
469
|
+
raise ValueError(f"RUN_SESSION_WORKDIR_MISSING: {working_directory}")
|
|
470
|
+
session_id = session_id or f"run-{uuid.uuid4().hex}"
|
|
471
|
+
run_id = run_id or session_id
|
|
472
|
+
session_root = _run_session_root(state_root, session_id)
|
|
473
|
+
session_root.mkdir(parents=True, exist_ok=False)
|
|
474
|
+
created_at = now_iso()
|
|
475
|
+
stdout_path = session_root / "stdout.log"
|
|
476
|
+
stderr_path = session_root / "stderr.log"
|
|
477
|
+
worker_log_path = session_root / "worker.log"
|
|
478
|
+
spec_path = session_root / "launch-spec.json"
|
|
479
|
+
overrides = dict(environment or {})
|
|
480
|
+
command_hash = _sha256_json(argv)
|
|
481
|
+
environment_hash = _sha256_json(overrides)
|
|
482
|
+
receipt: dict[str, Any] = {
|
|
483
|
+
"schemaVersion": RUN_SESSION_SCHEMA_VERSION,
|
|
484
|
+
"sessionId": session_id,
|
|
485
|
+
"runId": run_id,
|
|
486
|
+
"verification": verification,
|
|
487
|
+
"commandHash": command_hash,
|
|
488
|
+
"workingDirectory": str(working_directory),
|
|
489
|
+
"environmentHash": environment_hash,
|
|
490
|
+
"launcherPid": os.getpid(),
|
|
491
|
+
"workerPid": None,
|
|
492
|
+
"servicePid": None,
|
|
493
|
+
"processIdentity": None,
|
|
494
|
+
"createdAt": created_at,
|
|
495
|
+
"lastHeartbeatAt": created_at,
|
|
496
|
+
"startedAt": None,
|
|
497
|
+
"endedAt": None,
|
|
498
|
+
"exitCode": None,
|
|
499
|
+
"status": "STARTING",
|
|
500
|
+
"reasonCode": "WORKER_STARTING",
|
|
501
|
+
"stage": "prepare",
|
|
502
|
+
"stageStartedAt": created_at,
|
|
503
|
+
"stageTimings": [],
|
|
504
|
+
"stdoutPath": str(stdout_path),
|
|
505
|
+
"stderrPath": str(stderr_path),
|
|
506
|
+
"workerLogPath": str(worker_log_path),
|
|
507
|
+
"resultDigest": None,
|
|
508
|
+
"cleanupStatus": "PENDING",
|
|
509
|
+
"testProcessStarted": False,
|
|
510
|
+
"timeoutSeconds": timeout_seconds,
|
|
511
|
+
"heartbeatSeconds": heartbeat_seconds,
|
|
512
|
+
"expectedDurationSeconds": expected_duration_seconds,
|
|
513
|
+
"productIdentity": product_identity,
|
|
514
|
+
"resourceLocks": normalized_resource_locks,
|
|
515
|
+
"resourceLockTokens": {},
|
|
516
|
+
"resourceWaitMs": 0,
|
|
517
|
+
"activeTimeMs": 0,
|
|
518
|
+
"wallClockMs": 0,
|
|
519
|
+
"diagnosticPath": None,
|
|
520
|
+
}
|
|
521
|
+
_write_run_receipt(state_root, receipt)
|
|
522
|
+
lock_tokens, lock_conflict = _acquire_resource_locks(
|
|
523
|
+
state_root,
|
|
524
|
+
session_id,
|
|
525
|
+
normalized_resource_locks,
|
|
526
|
+
)
|
|
527
|
+
if lock_conflict is not None:
|
|
528
|
+
receipt.update(
|
|
529
|
+
{
|
|
530
|
+
"status": "INCOMPLETE",
|
|
531
|
+
"reasonCode": "RESOURCE_LOCK_BUSY",
|
|
532
|
+
"endedAt": now_iso(),
|
|
533
|
+
"cleanupStatus": "NO_CHILD",
|
|
534
|
+
"resourceLockConflict": lock_conflict,
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
_write_run_receipt(state_root, receipt)
|
|
538
|
+
return receipt
|
|
539
|
+
receipt["resourceLockTokens"] = lock_tokens
|
|
540
|
+
_write_run_receipt(state_root, receipt)
|
|
541
|
+
atomic_write_json(
|
|
542
|
+
spec_path,
|
|
543
|
+
{
|
|
544
|
+
"schemaVersion": 1,
|
|
545
|
+
"stateRoot": str(state_root),
|
|
546
|
+
"sessionId": session_id,
|
|
547
|
+
"argv": argv,
|
|
548
|
+
"heartbeatSeconds": heartbeat_seconds,
|
|
549
|
+
},
|
|
550
|
+
)
|
|
551
|
+
try:
|
|
552
|
+
spec_path.chmod(0o600)
|
|
553
|
+
except OSError:
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
worker_env = {**os.environ, **overrides}
|
|
557
|
+
worker_env["PYTHONUTF8"] = "1"
|
|
558
|
+
worker_env["PYTHONIOENCODING"] = "utf-8"
|
|
559
|
+
worker_env["HARNESS_RUN_SESSION_WORKER"] = "1"
|
|
560
|
+
worker_argv = [
|
|
561
|
+
_absolute_executable(sys.executable),
|
|
562
|
+
str(Path(__file__).resolve()),
|
|
563
|
+
"_worker",
|
|
564
|
+
"--state-root",
|
|
565
|
+
str(state_root),
|
|
566
|
+
"--session-id",
|
|
567
|
+
session_id,
|
|
568
|
+
]
|
|
569
|
+
try:
|
|
570
|
+
worker_log_path.touch()
|
|
571
|
+
worker = _spawn_detached_worker(
|
|
572
|
+
worker_argv,
|
|
573
|
+
cwd=working_directory,
|
|
574
|
+
env=worker_env,
|
|
575
|
+
)
|
|
576
|
+
except OSError as exc:
|
|
577
|
+
spec_path.unlink(missing_ok=True)
|
|
578
|
+
locks_released = _release_resource_locks(
|
|
579
|
+
state_root,
|
|
580
|
+
session_id,
|
|
581
|
+
lock_tokens,
|
|
582
|
+
)
|
|
583
|
+
receipt.update(
|
|
584
|
+
{
|
|
585
|
+
"status": "INCOMPLETE",
|
|
586
|
+
"reasonCode": "WORKER_LAUNCH_FAILED",
|
|
587
|
+
"endedAt": now_iso(),
|
|
588
|
+
"cleanupStatus": (
|
|
589
|
+
"NO_CHILD"
|
|
590
|
+
if locks_released
|
|
591
|
+
else "RESOURCE_LOCK_RELEASE_FAILED"
|
|
592
|
+
),
|
|
593
|
+
"error": str(exc),
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
_write_run_receipt(state_root, receipt)
|
|
597
|
+
return receipt
|
|
598
|
+
receipt["workerPid"] = worker.pid
|
|
599
|
+
receipt["workerIdentity"] = _process_identity(worker.pid, sys.executable)
|
|
600
|
+
# Best-effort early handoff. The worker repeats and verifies this transfer
|
|
601
|
+
# before spawning the managed command. Do not rewrite the session receipt
|
|
602
|
+
# here: the detached worker may already have advanced it to RUNNING.
|
|
603
|
+
_refresh_resource_locks(
|
|
604
|
+
state_root,
|
|
605
|
+
session_id,
|
|
606
|
+
lock_tokens,
|
|
607
|
+
dict(receipt["workerIdentity"]),
|
|
608
|
+
)
|
|
609
|
+
threading.Thread(target=worker.wait, daemon=True).start()
|
|
610
|
+
return receipt
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _terminate_managed_child(
|
|
614
|
+
process: subprocess.Popen[bytes],
|
|
615
|
+
job: Any,
|
|
616
|
+
) -> bool:
|
|
617
|
+
from harness_service import is_pid_alive, terminate_process_tree
|
|
618
|
+
|
|
619
|
+
if job is not None and getattr(job, "handle", None) is not None:
|
|
620
|
+
job.terminate_and_wait()
|
|
621
|
+
try:
|
|
622
|
+
process.wait(timeout=5)
|
|
623
|
+
except subprocess.TimeoutExpired:
|
|
624
|
+
return False
|
|
625
|
+
return not is_pid_alive(process.pid)
|
|
626
|
+
terminate_process_tree(process.pid)
|
|
627
|
+
try:
|
|
628
|
+
process.wait(timeout=5)
|
|
629
|
+
except subprocess.TimeoutExpired:
|
|
630
|
+
return False
|
|
631
|
+
return not is_pid_alive(process.pid)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _run_session_worker(state_root: Path, session_id: str) -> int:
|
|
635
|
+
from harness_service import verify_process_identity
|
|
636
|
+
|
|
637
|
+
session_root = _run_session_root(state_root, session_id)
|
|
638
|
+
spec_path = session_root / "launch-spec.json"
|
|
639
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
640
|
+
try:
|
|
641
|
+
spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
|
|
642
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
643
|
+
_release_resource_locks(
|
|
644
|
+
state_root,
|
|
645
|
+
session_id,
|
|
646
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
647
|
+
)
|
|
648
|
+
receipt.update(
|
|
649
|
+
{
|
|
650
|
+
"status": "INCOMPLETE",
|
|
651
|
+
"reasonCode": "LAUNCH_SPEC_INVALID",
|
|
652
|
+
"endedAt": now_iso(),
|
|
653
|
+
"cleanupStatus": "NO_CHILD",
|
|
654
|
+
"error": str(exc),
|
|
655
|
+
}
|
|
656
|
+
)
|
|
657
|
+
_write_run_receipt(state_root, receipt)
|
|
658
|
+
return 1
|
|
659
|
+
argv = [str(item) for item in spec.get("argv", [])]
|
|
660
|
+
heartbeat_seconds = max(float(spec.get("heartbeatSeconds") or 30.0), 0.01)
|
|
661
|
+
spec_path.unlink(missing_ok=True)
|
|
662
|
+
started_monotonic = time.monotonic()
|
|
663
|
+
_stage_transition(receipt, "spawn")
|
|
664
|
+
receipt.update(
|
|
665
|
+
{
|
|
666
|
+
"workerPid": os.getpid(),
|
|
667
|
+
"workerIdentity": _process_identity(os.getpid(), sys.executable),
|
|
668
|
+
"lastHeartbeatAt": now_iso(),
|
|
669
|
+
}
|
|
670
|
+
)
|
|
671
|
+
if not _refresh_resource_locks(
|
|
672
|
+
state_root,
|
|
673
|
+
session_id,
|
|
674
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
675
|
+
dict(receipt["workerIdentity"]),
|
|
676
|
+
):
|
|
677
|
+
receipt.update(
|
|
678
|
+
{
|
|
679
|
+
"status": "INCOMPLETE",
|
|
680
|
+
"reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
681
|
+
"endedAt": now_iso(),
|
|
682
|
+
"cleanupStatus": "NO_CHILD",
|
|
683
|
+
}
|
|
684
|
+
)
|
|
685
|
+
_write_run_receipt(state_root, receipt)
|
|
686
|
+
return 1
|
|
687
|
+
_write_run_receipt(state_root, receipt)
|
|
688
|
+
|
|
689
|
+
stdout_path = Path(str(receipt["stdoutPath"]))
|
|
690
|
+
stderr_path = Path(str(receipt["stderrPath"]))
|
|
691
|
+
stdout_path.parent.mkdir(parents=True, exist_ok=True)
|
|
692
|
+
job = None
|
|
693
|
+
process: subprocess.Popen[bytes] | None = None
|
|
694
|
+
try:
|
|
695
|
+
with stdout_path.open("wb", buffering=0) as stdout, stderr_path.open(
|
|
696
|
+
"wb", buffering=0
|
|
697
|
+
) as stderr:
|
|
698
|
+
popen_kwargs: dict[str, Any] = {
|
|
699
|
+
"cwd": str(receipt["workingDirectory"]),
|
|
700
|
+
"env": os.environ.copy(),
|
|
701
|
+
"stdin": subprocess.DEVNULL,
|
|
702
|
+
"stdout": stdout,
|
|
703
|
+
"stderr": stderr,
|
|
704
|
+
"shell": False,
|
|
705
|
+
"close_fds": True,
|
|
706
|
+
}
|
|
707
|
+
if os.name == "nt":
|
|
708
|
+
popen_kwargs["creationflags"] = 0x00000200
|
|
709
|
+
else:
|
|
710
|
+
popen_kwargs["start_new_session"] = True
|
|
711
|
+
try:
|
|
712
|
+
process = subprocess.Popen(argv, **popen_kwargs)
|
|
713
|
+
except OSError as exc:
|
|
714
|
+
receipt.update(
|
|
715
|
+
{
|
|
716
|
+
"status": "INCOMPLETE",
|
|
717
|
+
"reasonCode": "LAUNCHER_FAILED",
|
|
718
|
+
"endedAt": now_iso(),
|
|
719
|
+
"exitCode": None,
|
|
720
|
+
"testProcessStarted": False,
|
|
721
|
+
"cleanupStatus": "NO_CHILD",
|
|
722
|
+
"error": str(exc),
|
|
723
|
+
}
|
|
724
|
+
)
|
|
725
|
+
return 1
|
|
726
|
+
identity = _process_identity(process.pid, argv[0])
|
|
727
|
+
if os.name == "nt":
|
|
728
|
+
from harness_test_runner import _WindowsKillOnCloseJob
|
|
729
|
+
|
|
730
|
+
candidate = _WindowsKillOnCloseJob()
|
|
731
|
+
if candidate.assign(process):
|
|
732
|
+
job = candidate
|
|
733
|
+
else:
|
|
734
|
+
identity_session = {
|
|
735
|
+
"pid": process.pid,
|
|
736
|
+
"startedAt": identity["startedAt"],
|
|
737
|
+
"processIdentity": {
|
|
738
|
+
"executable": identity["executable"],
|
|
739
|
+
},
|
|
740
|
+
}
|
|
741
|
+
verified = verify_process_identity(identity_session) is True
|
|
742
|
+
terminated = (
|
|
743
|
+
_terminate_managed_child(process, None)
|
|
744
|
+
if verified
|
|
745
|
+
else False
|
|
746
|
+
)
|
|
747
|
+
receipt.update(
|
|
748
|
+
{
|
|
749
|
+
"servicePid": process.pid,
|
|
750
|
+
"processIdentity": identity,
|
|
751
|
+
"startedAt": identity["startedAt"],
|
|
752
|
+
"status": "INCOMPLETE",
|
|
753
|
+
"reasonCode": "PROCESS_TREE_ISOLATION_UNAVAILABLE",
|
|
754
|
+
"stage": "spawn",
|
|
755
|
+
"testProcessStarted": True,
|
|
756
|
+
"processTreeIsolated": False,
|
|
757
|
+
"exitCode": process.poll(),
|
|
758
|
+
"cleanupStatus": (
|
|
759
|
+
"PROCESS_TREE_TERMINATED"
|
|
760
|
+
if terminated
|
|
761
|
+
else "PROCESS_TERMINATION_UNCONFIRMED"
|
|
762
|
+
),
|
|
763
|
+
}
|
|
764
|
+
)
|
|
765
|
+
return 1
|
|
766
|
+
_stage_transition(receipt, "execute")
|
|
767
|
+
receipt.update(
|
|
768
|
+
{
|
|
769
|
+
"servicePid": process.pid,
|
|
770
|
+
"processIdentity": identity,
|
|
771
|
+
"startedAt": identity["startedAt"],
|
|
772
|
+
"status": "RUNNING",
|
|
773
|
+
"reasonCode": "CHILD_RUNNING",
|
|
774
|
+
"testProcessStarted": True,
|
|
775
|
+
"processTreeIsolated": job is not None or os.name != "nt",
|
|
776
|
+
}
|
|
777
|
+
)
|
|
778
|
+
_write_run_receipt(state_root, receipt)
|
|
779
|
+
|
|
780
|
+
timeout_seconds = receipt.get("timeoutSeconds")
|
|
781
|
+
next_heartbeat = time.monotonic()
|
|
782
|
+
termination_reason: str | None = None
|
|
783
|
+
while process.poll() is None:
|
|
784
|
+
now = time.monotonic()
|
|
785
|
+
if timeout_seconds is not None and (
|
|
786
|
+
now - started_monotonic >= float(timeout_seconds)
|
|
787
|
+
):
|
|
788
|
+
termination_reason = "TIMEOUT"
|
|
789
|
+
cancel_path = session_root / "cancel-request.json"
|
|
790
|
+
if cancel_path.is_file():
|
|
791
|
+
termination_reason = "CANCEL_REQUESTED"
|
|
792
|
+
if termination_reason is not None:
|
|
793
|
+
diagnostic_path = session_root / "diagnostic.json"
|
|
794
|
+
identity_session = {
|
|
795
|
+
"pid": process.pid,
|
|
796
|
+
"startedAt": identity["startedAt"],
|
|
797
|
+
"processIdentity": {
|
|
798
|
+
"executable": identity["executable"],
|
|
799
|
+
},
|
|
800
|
+
}
|
|
801
|
+
verified = verify_process_identity(identity_session) is True
|
|
802
|
+
job_owned = job is not None
|
|
803
|
+
diagnostic = {
|
|
804
|
+
"schemaVersion": 1,
|
|
805
|
+
"sessionId": session_id,
|
|
806
|
+
"reasonCode": termination_reason,
|
|
807
|
+
"observedAt": now_iso(),
|
|
808
|
+
"processIdentityVerified": verified,
|
|
809
|
+
"terminationAuthority": (
|
|
810
|
+
"JOB_OBJECT_OWNED"
|
|
811
|
+
if job_owned
|
|
812
|
+
else (
|
|
813
|
+
"PROCESS_IDENTITY_VERIFIED"
|
|
814
|
+
if verified
|
|
815
|
+
else "NONE"
|
|
816
|
+
)
|
|
817
|
+
),
|
|
818
|
+
"pid": process.pid,
|
|
819
|
+
"stdoutBytes": (
|
|
820
|
+
stdout_path.stat().st_size if stdout_path.exists() else 0
|
|
821
|
+
),
|
|
822
|
+
"stderrBytes": (
|
|
823
|
+
stderr_path.stat().st_size if stderr_path.exists() else 0
|
|
824
|
+
),
|
|
825
|
+
}
|
|
826
|
+
atomic_write_json(diagnostic_path, diagnostic)
|
|
827
|
+
receipt["diagnosticPath"] = str(diagnostic_path)
|
|
828
|
+
if job_owned or verified:
|
|
829
|
+
terminated = _terminate_managed_child(process, job)
|
|
830
|
+
job = None
|
|
831
|
+
if not terminated:
|
|
832
|
+
receipt.update(
|
|
833
|
+
{
|
|
834
|
+
"status": "INCOMPLETE",
|
|
835
|
+
"reasonCode": "PROCESS_TERMINATION_UNCONFIRMED",
|
|
836
|
+
"cleanupStatus": "PROCESS_TERMINATION_UNCONFIRMED",
|
|
837
|
+
}
|
|
838
|
+
)
|
|
839
|
+
else:
|
|
840
|
+
receipt.update(
|
|
841
|
+
{
|
|
842
|
+
"status": "INCOMPLETE",
|
|
843
|
+
"reasonCode": "PROCESS_IDENTITY_UNVERIFIED",
|
|
844
|
+
"cleanupStatus": "UNKNOWN_PROCESS_UNTOUCHED",
|
|
845
|
+
}
|
|
846
|
+
)
|
|
847
|
+
break
|
|
848
|
+
if now >= next_heartbeat:
|
|
849
|
+
receipt["lastHeartbeatAt"] = now_iso()
|
|
850
|
+
receipt["activeTimeMs"] = int(
|
|
851
|
+
(now - started_monotonic) * 1000
|
|
852
|
+
)
|
|
853
|
+
receipt["wallClockMs"] = receipt["activeTimeMs"]
|
|
854
|
+
if not _refresh_resource_locks(
|
|
855
|
+
state_root,
|
|
856
|
+
session_id,
|
|
857
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
858
|
+
dict(receipt["workerIdentity"]),
|
|
859
|
+
):
|
|
860
|
+
receipt.update(
|
|
861
|
+
{
|
|
862
|
+
"status": "INCOMPLETE",
|
|
863
|
+
"reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
864
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATION_REQUIRED",
|
|
865
|
+
}
|
|
866
|
+
)
|
|
867
|
+
_terminate_managed_child(process, job)
|
|
868
|
+
job = None
|
|
869
|
+
break
|
|
870
|
+
_write_run_receipt(state_root, receipt)
|
|
871
|
+
next_heartbeat = now + heartbeat_seconds
|
|
872
|
+
time.sleep(min(heartbeat_seconds, 0.05))
|
|
873
|
+
|
|
874
|
+
exit_code = process.poll()
|
|
875
|
+
_stage_transition(receipt, "summarize")
|
|
876
|
+
if termination_reason is not None and receipt["reasonCode"] not in {
|
|
877
|
+
"PROCESS_IDENTITY_UNVERIFIED",
|
|
878
|
+
"PROCESS_TERMINATION_UNCONFIRMED",
|
|
879
|
+
"RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
880
|
+
}:
|
|
881
|
+
receipt.update(
|
|
882
|
+
{
|
|
883
|
+
"status": "CANCELLED",
|
|
884
|
+
"reasonCode": termination_reason,
|
|
885
|
+
"exitCode": exit_code,
|
|
886
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATED",
|
|
887
|
+
}
|
|
888
|
+
)
|
|
889
|
+
elif receipt["reasonCode"] not in {
|
|
890
|
+
"PROCESS_IDENTITY_UNVERIFIED",
|
|
891
|
+
"PROCESS_TERMINATION_UNCONFIRMED",
|
|
892
|
+
"RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
893
|
+
}:
|
|
894
|
+
receipt.update(
|
|
895
|
+
{
|
|
896
|
+
"status": "OK" if exit_code == 0 else "FAIL",
|
|
897
|
+
"reasonCode": (
|
|
898
|
+
"CHILD_EXIT_ZERO"
|
|
899
|
+
if exit_code == 0
|
|
900
|
+
else "CHILD_EXIT_NONZERO"
|
|
901
|
+
),
|
|
902
|
+
"exitCode": exit_code,
|
|
903
|
+
"cleanupStatus": "PROCESS_EXITED",
|
|
904
|
+
}
|
|
905
|
+
)
|
|
906
|
+
except BaseException as exc:
|
|
907
|
+
receipt.update(
|
|
908
|
+
{
|
|
909
|
+
"status": "INCOMPLETE",
|
|
910
|
+
"reasonCode": "WORKER_FAILED",
|
|
911
|
+
"exitCode": None if process is None else process.poll(),
|
|
912
|
+
"cleanupStatus": "WORKER_EXCEPTION",
|
|
913
|
+
"error": str(exc),
|
|
914
|
+
}
|
|
915
|
+
)
|
|
916
|
+
finally:
|
|
917
|
+
if job is not None:
|
|
918
|
+
tree_drained = job.terminate_and_wait()
|
|
919
|
+
if not tree_drained:
|
|
920
|
+
receipt.update(
|
|
921
|
+
{
|
|
922
|
+
"status": "INCOMPLETE",
|
|
923
|
+
"reasonCode": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
|
|
924
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
|
|
925
|
+
}
|
|
926
|
+
)
|
|
927
|
+
elif receipt.get("cleanupStatus") == "PROCESS_EXITED":
|
|
928
|
+
receipt["cleanupStatus"] = "PROCESS_TREE_TERMINATED"
|
|
929
|
+
ended = time.monotonic()
|
|
930
|
+
_stage_transition(receipt, "record")
|
|
931
|
+
receipt["endedAt"] = now_iso()
|
|
932
|
+
receipt["lastHeartbeatAt"] = receipt["endedAt"]
|
|
933
|
+
receipt["activeTimeMs"] = int((ended - started_monotonic) * 1000)
|
|
934
|
+
receipt["wallClockMs"] = receipt["activeTimeMs"] + int(
|
|
935
|
+
receipt.get("resourceWaitMs") or 0
|
|
936
|
+
)
|
|
937
|
+
digest = hashlib.sha256()
|
|
938
|
+
for path in (stdout_path, stderr_path):
|
|
939
|
+
if path.is_file():
|
|
940
|
+
digest.update(path.read_bytes())
|
|
941
|
+
receipt["resultDigest"] = "sha256:" + digest.hexdigest()
|
|
942
|
+
locks_released = _release_resource_locks(
|
|
943
|
+
state_root,
|
|
944
|
+
session_id,
|
|
945
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
946
|
+
)
|
|
947
|
+
if not locks_released:
|
|
948
|
+
receipt.update(
|
|
949
|
+
{
|
|
950
|
+
"status": "INCOMPLETE",
|
|
951
|
+
"reasonCode": "RESOURCE_LOCK_RELEASE_FAILED",
|
|
952
|
+
"cleanupStatus": "RESOURCE_LOCK_RELEASE_FAILED",
|
|
953
|
+
}
|
|
954
|
+
)
|
|
955
|
+
_close_current_stage(receipt)
|
|
956
|
+
_write_run_receipt(state_root, receipt)
|
|
957
|
+
return 0 if receipt.get("status") == "OK" else 1
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _run_session_worker_with_log(state_root: Path, session_id: str) -> int:
|
|
961
|
+
worker_log_path = _run_session_root(state_root, session_id) / "worker.log"
|
|
962
|
+
try:
|
|
963
|
+
worker_log = worker_log_path.open(
|
|
964
|
+
"a",
|
|
965
|
+
encoding="utf-8",
|
|
966
|
+
errors="replace",
|
|
967
|
+
buffering=1,
|
|
968
|
+
)
|
|
969
|
+
except OSError:
|
|
970
|
+
return _run_session_worker(state_root, session_id)
|
|
971
|
+
with worker_log:
|
|
972
|
+
with (
|
|
973
|
+
contextlib.redirect_stdout(worker_log),
|
|
974
|
+
contextlib.redirect_stderr(worker_log),
|
|
975
|
+
):
|
|
976
|
+
return _run_session_worker(state_root, session_id)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def run_session_status(state_root: Path, session_id: str) -> dict[str, Any]:
|
|
980
|
+
from harness_service import is_pid_alive, verify_process_identity
|
|
981
|
+
|
|
982
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
983
|
+
worker_pid = receipt.get("workerPid")
|
|
984
|
+
worker_identity = receipt.get("workerIdentity")
|
|
985
|
+
worker_session = {
|
|
986
|
+
"pid": worker_pid,
|
|
987
|
+
"startedAt": (
|
|
988
|
+
worker_identity.get("startedAt")
|
|
989
|
+
if isinstance(worker_identity, dict)
|
|
990
|
+
else None
|
|
991
|
+
),
|
|
992
|
+
"processIdentity": {
|
|
993
|
+
"executable": (
|
|
994
|
+
worker_identity.get("executable")
|
|
995
|
+
if isinstance(worker_identity, dict)
|
|
996
|
+
else None
|
|
997
|
+
)
|
|
998
|
+
},
|
|
999
|
+
}
|
|
1000
|
+
if receipt.get("status") in RUN_TERMINAL_STATUSES:
|
|
1001
|
+
if (
|
|
1002
|
+
isinstance(worker_pid, int)
|
|
1003
|
+
and worker_pid > 0
|
|
1004
|
+
and is_pid_alive(worker_pid)
|
|
1005
|
+
and verify_process_identity(worker_session) is True
|
|
1006
|
+
):
|
|
1007
|
+
finalizing = dict(receipt)
|
|
1008
|
+
finalizing.update(
|
|
1009
|
+
{
|
|
1010
|
+
"status": "FINALIZING",
|
|
1011
|
+
"reasonCode": "WORKER_FINALIZING",
|
|
1012
|
+
}
|
|
1013
|
+
)
|
|
1014
|
+
return finalizing
|
|
1015
|
+
return receipt
|
|
1016
|
+
heartbeat_text = str(receipt.get("lastHeartbeatAt") or "")
|
|
1017
|
+
try:
|
|
1018
|
+
heartbeat_at = dt.datetime.fromisoformat(
|
|
1019
|
+
heartbeat_text.replace("Z", "+00:00")
|
|
1020
|
+
)
|
|
1021
|
+
if heartbeat_at.tzinfo is None:
|
|
1022
|
+
heartbeat_at = heartbeat_at.astimezone()
|
|
1023
|
+
heartbeat_age = (
|
|
1024
|
+
dt.datetime.now().astimezone() - heartbeat_at
|
|
1025
|
+
).total_seconds()
|
|
1026
|
+
except ValueError:
|
|
1027
|
+
heartbeat_age = float("inf")
|
|
1028
|
+
grace = max(float(receipt.get("heartbeatSeconds") or 30.0) * 4, 2.0)
|
|
1029
|
+
startup_grace = max(grace, 10.0)
|
|
1030
|
+
# The detached worker owns the durable identity record. The launcher can
|
|
1031
|
+
# return before the worker has published it, so STARTING is intentionally
|
|
1032
|
+
# treated as a bounded handshake window rather than an identity failure.
|
|
1033
|
+
if (
|
|
1034
|
+
receipt.get("status") == "STARTING"
|
|
1035
|
+
and receipt.get("workerIdentity") is None
|
|
1036
|
+
and heartbeat_age < startup_grace
|
|
1037
|
+
):
|
|
1038
|
+
return receipt
|
|
1039
|
+
identity_state = verify_process_identity(worker_session)
|
|
1040
|
+
worker_gone = (
|
|
1041
|
+
isinstance(worker_pid, int)
|
|
1042
|
+
and worker_pid > 0
|
|
1043
|
+
and not is_pid_alive(worker_pid)
|
|
1044
|
+
)
|
|
1045
|
+
if worker_gone or identity_state is False or heartbeat_age >= grace:
|
|
1046
|
+
latest = _load_run_receipt(state_root, session_id)
|
|
1047
|
+
if latest.get("status") in RUN_TERMINAL_STATUSES:
|
|
1048
|
+
return latest
|
|
1049
|
+
if heartbeat_age < grace and worker_gone and identity_state is not False:
|
|
1050
|
+
return latest
|
|
1051
|
+
receipt.update(
|
|
1052
|
+
{
|
|
1053
|
+
"status": "INCOMPLETE",
|
|
1054
|
+
"reasonCode": (
|
|
1055
|
+
"WORKER_IDENTITY_MISMATCH"
|
|
1056
|
+
if identity_state is False and not worker_gone
|
|
1057
|
+
else "HEARTBEAT_LOST"
|
|
1058
|
+
),
|
|
1059
|
+
"endedAt": now_iso(),
|
|
1060
|
+
"cleanupStatus": "WORKER_EXITED_WITHOUT_FINAL_RECEIPT",
|
|
1061
|
+
}
|
|
1062
|
+
)
|
|
1063
|
+
_write_run_receipt(state_root, receipt)
|
|
1064
|
+
return receipt
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def read_run_session_log(
|
|
1068
|
+
state_root: Path,
|
|
1069
|
+
session_id: str,
|
|
1070
|
+
*,
|
|
1071
|
+
stream: str = "stdout",
|
|
1072
|
+
cursor: int = 0,
|
|
1073
|
+
max_bytes: int = 64 * 1024,
|
|
1074
|
+
) -> dict[str, Any]:
|
|
1075
|
+
if stream not in {"stdout", "stderr"}:
|
|
1076
|
+
raise ValueError(f"RUN_SESSION_STREAM_INVALID: {stream}")
|
|
1077
|
+
if cursor < 0 or max_bytes <= 0:
|
|
1078
|
+
raise ValueError("RUN_SESSION_CURSOR_INVALID")
|
|
1079
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1080
|
+
path = Path(str(receipt[f"{stream}Path"]))
|
|
1081
|
+
raw = b""
|
|
1082
|
+
size = path.stat().st_size if path.is_file() else 0
|
|
1083
|
+
if path.is_file() and cursor < size:
|
|
1084
|
+
with path.open("rb") as handle:
|
|
1085
|
+
handle.seek(cursor)
|
|
1086
|
+
raw = handle.read(max_bytes)
|
|
1087
|
+
while raw:
|
|
1088
|
+
try:
|
|
1089
|
+
text = raw.decode("utf-8")
|
|
1090
|
+
break
|
|
1091
|
+
except UnicodeDecodeError as exc:
|
|
1092
|
+
if exc.end != len(raw):
|
|
1093
|
+
text = raw.decode("utf-8", errors="replace")
|
|
1094
|
+
break
|
|
1095
|
+
raw = raw[: exc.start]
|
|
1096
|
+
else:
|
|
1097
|
+
text = ""
|
|
1098
|
+
else:
|
|
1099
|
+
text = ""
|
|
1100
|
+
next_cursor = cursor + len(raw)
|
|
1101
|
+
terminal = receipt.get("status") in RUN_TERMINAL_STATUSES
|
|
1102
|
+
return {
|
|
1103
|
+
"ok": True,
|
|
1104
|
+
"sessionId": session_id,
|
|
1105
|
+
"stream": stream,
|
|
1106
|
+
"cursor": cursor,
|
|
1107
|
+
"nextCursor": next_cursor,
|
|
1108
|
+
"text": text,
|
|
1109
|
+
"eof": terminal and next_cursor >= size,
|
|
1110
|
+
"status": receipt.get("status"),
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def cancel_run_session(
|
|
1115
|
+
state_root: Path,
|
|
1116
|
+
session_id: str,
|
|
1117
|
+
*,
|
|
1118
|
+
reason: str = "CANCEL_REQUESTED",
|
|
1119
|
+
) -> dict[str, Any]:
|
|
1120
|
+
from harness_service import verify_process_identity
|
|
1121
|
+
|
|
1122
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1123
|
+
if receipt.get("status") in RUN_TERMINAL_STATUSES:
|
|
1124
|
+
return receipt
|
|
1125
|
+
worker_identity = receipt.get("workerIdentity")
|
|
1126
|
+
worker_session = {
|
|
1127
|
+
"pid": receipt.get("workerPid"),
|
|
1128
|
+
"startedAt": (
|
|
1129
|
+
worker_identity.get("startedAt")
|
|
1130
|
+
if isinstance(worker_identity, dict)
|
|
1131
|
+
else None
|
|
1132
|
+
),
|
|
1133
|
+
"processIdentity": {
|
|
1134
|
+
"executable": (
|
|
1135
|
+
worker_identity.get("executable")
|
|
1136
|
+
if isinstance(worker_identity, dict)
|
|
1137
|
+
else None
|
|
1138
|
+
)
|
|
1139
|
+
},
|
|
1140
|
+
}
|
|
1141
|
+
if verify_process_identity(worker_session) is not True:
|
|
1142
|
+
return {
|
|
1143
|
+
**receipt,
|
|
1144
|
+
"ok": False,
|
|
1145
|
+
"code": "WORKER_IDENTITY_UNVERIFIED",
|
|
1146
|
+
"unknownProcessUntouched": True,
|
|
1147
|
+
}
|
|
1148
|
+
atomic_write_json(
|
|
1149
|
+
_run_session_root(state_root, session_id) / "cancel-request.json",
|
|
1150
|
+
{"reasonCode": reason, "requestedAt": now_iso()},
|
|
1151
|
+
)
|
|
1152
|
+
return {**receipt, "ok": True, "action": "cancel-requested"}
|
|
1153
|
+
|
|
1154
|
+
|
|
54
1155
|
def adapter_worktree(agent: str, change_id: str) -> dict[str, str]:
|
|
55
1156
|
if agent not in _ADAPTERS:
|
|
56
1157
|
raise ValueError(f"ADAPTER_UNKNOWN: {agent}")
|
|
@@ -221,6 +1322,35 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
221
1322
|
p_adapter.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
|
|
222
1323
|
p_adapter.add_argument("--change", required=True)
|
|
223
1324
|
p_adapter.add_argument("--json", action="store_true")
|
|
1325
|
+
p_start = sub.add_parser("run-start", help="start a managed background run")
|
|
1326
|
+
p_start.add_argument("--state-root", required=True)
|
|
1327
|
+
p_start.add_argument("--verification", required=True)
|
|
1328
|
+
p_start.add_argument("--working-directory", required=True)
|
|
1329
|
+
p_start.add_argument("--timeout-seconds", type=float)
|
|
1330
|
+
p_start.add_argument("--heartbeat-seconds", type=float, default=30.0)
|
|
1331
|
+
p_start.add_argument("--expected-duration-seconds", type=float)
|
|
1332
|
+
p_start.add_argument("--product-identity")
|
|
1333
|
+
p_start.add_argument("--resource-lock", action="append", default=[])
|
|
1334
|
+
p_start.add_argument("--json", action="store_true")
|
|
1335
|
+
p_start.add_argument("argv", nargs=argparse.REMAINDER)
|
|
1336
|
+
p_status = sub.add_parser("run-status", help="read a managed run receipt")
|
|
1337
|
+
p_status.add_argument("--state-root", required=True)
|
|
1338
|
+
p_status.add_argument("--session-id", required=True)
|
|
1339
|
+
p_status.add_argument("--json", action="store_true")
|
|
1340
|
+
p_log = sub.add_parser("run-log", help="read an incremental log page")
|
|
1341
|
+
p_log.add_argument("--state-root", required=True)
|
|
1342
|
+
p_log.add_argument("--session-id", required=True)
|
|
1343
|
+
p_log.add_argument("--stream", choices=("stdout", "stderr"), default="stdout")
|
|
1344
|
+
p_log.add_argument("--cursor", type=int, default=0)
|
|
1345
|
+
p_log.add_argument("--max-bytes", type=int, default=64 * 1024)
|
|
1346
|
+
p_log.add_argument("--json", action="store_true")
|
|
1347
|
+
p_cancel = sub.add_parser("run-cancel", help="request identity-safe cancellation")
|
|
1348
|
+
p_cancel.add_argument("--state-root", required=True)
|
|
1349
|
+
p_cancel.add_argument("--session-id", required=True)
|
|
1350
|
+
p_cancel.add_argument("--json", action="store_true")
|
|
1351
|
+
p_worker = sub.add_parser("_worker", help=argparse.SUPPRESS)
|
|
1352
|
+
p_worker.add_argument("--state-root", required=True)
|
|
1353
|
+
p_worker.add_argument("--session-id", required=True)
|
|
224
1354
|
return parser
|
|
225
1355
|
|
|
226
1356
|
|
|
@@ -229,15 +1359,47 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
229
1359
|
try:
|
|
230
1360
|
if args.command == "doctor":
|
|
231
1361
|
result = doctor(Path(args.project), Path(args.change_dir), agent=args.agent)
|
|
232
|
-
|
|
1362
|
+
elif args.command == "adapter":
|
|
233
1363
|
result = {"ok": True, "action": "adapter", **adapter_worktree(args.agent, args.change)}
|
|
1364
|
+
elif args.command == "_worker":
|
|
1365
|
+
return _run_session_worker_with_log(
|
|
1366
|
+
Path(args.state_root),
|
|
1367
|
+
args.session_id,
|
|
1368
|
+
)
|
|
1369
|
+
elif args.command == "run-start":
|
|
1370
|
+
command_argv = list(args.argv)
|
|
1371
|
+
if command_argv and command_argv[0] == "--":
|
|
1372
|
+
command_argv = command_argv[1:]
|
|
1373
|
+
result = start_run_session(
|
|
1374
|
+
state_root=Path(args.state_root),
|
|
1375
|
+
verification=args.verification,
|
|
1376
|
+
argv=command_argv,
|
|
1377
|
+
working_directory=Path(args.working_directory),
|
|
1378
|
+
timeout_seconds=args.timeout_seconds,
|
|
1379
|
+
heartbeat_seconds=args.heartbeat_seconds,
|
|
1380
|
+
expected_duration_seconds=args.expected_duration_seconds,
|
|
1381
|
+
product_identity=args.product_identity,
|
|
1382
|
+
resource_locks=list(args.resource_lock),
|
|
1383
|
+
)
|
|
1384
|
+
elif args.command == "run-status":
|
|
1385
|
+
result = run_session_status(Path(args.state_root), args.session_id)
|
|
1386
|
+
elif args.command == "run-log":
|
|
1387
|
+
result = read_run_session_log(
|
|
1388
|
+
Path(args.state_root),
|
|
1389
|
+
args.session_id,
|
|
1390
|
+
stream=args.stream,
|
|
1391
|
+
cursor=args.cursor,
|
|
1392
|
+
max_bytes=args.max_bytes,
|
|
1393
|
+
)
|
|
1394
|
+
else:
|
|
1395
|
+
result = cancel_run_session(Path(args.state_root), args.session_id)
|
|
234
1396
|
except (OSError, ValueError) as exc:
|
|
235
1397
|
code = str(exc).split(":", 1)[0]
|
|
236
1398
|
result = {"ok": False, "code": code, "error": str(exc)}
|
|
237
1399
|
stream = sys.stderr
|
|
238
1400
|
stream.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
239
1401
|
return 1
|
|
240
|
-
if args
|
|
1402
|
+
if getattr(args, "json", False):
|
|
241
1403
|
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
|
|
242
1404
|
else:
|
|
243
1405
|
sys.stdout.write(str(result.get("action")) + "\n")
|