@hunter-harness/workflow-harness 0.2.51 → 0.2.53
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-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-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_events.py +26 -0
- package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +453 -82
- 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-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +26 -0
- package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +453 -82
- 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-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_events.py +26 -0
- package/harness/bundles/general/codex/scripts/harness_events_sync.py +453 -82
- 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-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_events.py +26 -0
- package/harness/bundles/general/cursor/scripts/harness_events_sync.py +453 -82
- 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-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-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_events.py +26 -0
- package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +453 -82
- 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-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +26 -0
- package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +453 -82
- 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-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_events.py +26 -0
- package/harness/bundles/java/codex/scripts/harness_events_sync.py +453 -82
- 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-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_events.py +26 -0
- package/harness/bundles/java/cursor/scripts/harness_events_sync.py +453 -82
- package/harness/manifests/general/claude-code.json +15 -15
- package/harness/manifests/general/codebuddy.json +15 -15
- package/harness/manifests/general/codex.json +15 -15
- package/harness/manifests/general/cursor.json +15 -15
- package/harness/manifests/java/claude-code.json +17 -17
- package/harness/manifests/java/codebuddy.json +17 -17
- package/harness/manifests/java/codex.json +17 -17
- package/harness/manifests/java/cursor.json +17 -17
- package/hunter-workflow-family.json +4 -4
- package/package.json +1 -1
|
@@ -9,17 +9,32 @@ advances on accepted/duplicate_accepted responses.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
|
+
import errno
|
|
12
13
|
import hashlib
|
|
13
14
|
import json
|
|
15
|
+
import os
|
|
14
16
|
import re
|
|
17
|
+
import subprocess
|
|
15
18
|
import sys
|
|
19
|
+
import time
|
|
16
20
|
import urllib.error
|
|
17
21
|
import urllib.request
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import datetime, timezone
|
|
18
24
|
from pathlib import Path
|
|
19
25
|
from typing import Any
|
|
20
26
|
|
|
27
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
28
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
30
|
+
|
|
31
|
+
import harness_paths # noqa: E402
|
|
32
|
+
|
|
21
33
|
WHITELIST = {
|
|
22
34
|
"id",
|
|
35
|
+
"timestamp",
|
|
36
|
+
"schema_version",
|
|
37
|
+
# Legacy aliases remain readable while new writers use the canonical names.
|
|
23
38
|
"ts",
|
|
24
39
|
"type",
|
|
25
40
|
"phase",
|
|
@@ -36,6 +51,10 @@ WHITELIST = {
|
|
|
36
51
|
"schemaVersion",
|
|
37
52
|
}
|
|
38
53
|
|
|
54
|
+
MAX_BATCH_SIZE = 100
|
|
55
|
+
ACCEPTED_STATUSES = {"accepted", "duplicate_accepted"}
|
|
56
|
+
QUARANTINE_STATUSES = {"id_conflict", "rejected", "rejected_schema"}
|
|
57
|
+
|
|
39
58
|
|
|
40
59
|
def _yaml_scalar(text: str, key: str) -> str | None:
|
|
41
60
|
match = re.search(rf"(?m)^[ \t]*{re.escape(key)}:\s*['\"]?([^'\"\n#]+)", text)
|
|
@@ -73,27 +92,110 @@ def sanitize(event: dict[str, Any]) -> dict[str, Any]:
|
|
|
73
92
|
|
|
74
93
|
|
|
75
94
|
def cursor_path(change_dir: Path) -> Path:
|
|
76
|
-
return change_dir / "meta" / "events-sync-cursor.json"
|
|
95
|
+
return _state_dir(change_dir) / "meta" / "events-sync-cursor.json"
|
|
77
96
|
|
|
78
97
|
|
|
79
|
-
def
|
|
98
|
+
def _state_dir(change_dir: Path) -> Path:
|
|
99
|
+
return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_cursor(
|
|
103
|
+
change_dir: Path,
|
|
104
|
+
*,
|
|
105
|
+
maximum_lines: int | None = None,
|
|
106
|
+
repair: bool = False,
|
|
107
|
+
) -> int:
|
|
80
108
|
path = cursor_path(change_dir)
|
|
81
109
|
if not path.is_file():
|
|
82
110
|
return 0
|
|
111
|
+
valid = True
|
|
83
112
|
try:
|
|
84
113
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
85
114
|
except (OSError, json.JSONDecodeError):
|
|
86
|
-
|
|
87
|
-
|
|
115
|
+
data = None
|
|
116
|
+
valid = False
|
|
117
|
+
raw = data.get("acked_lines") if isinstance(data, dict) else None
|
|
118
|
+
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:
|
|
119
|
+
valid = False
|
|
120
|
+
value = 0
|
|
121
|
+
else:
|
|
122
|
+
value = raw
|
|
123
|
+
if maximum_lines is not None:
|
|
124
|
+
if maximum_lines < 0:
|
|
125
|
+
raise ValueError("maximum_lines must be non-negative")
|
|
126
|
+
if value > maximum_lines:
|
|
127
|
+
valid = False
|
|
128
|
+
value = 0
|
|
129
|
+
if not valid and repair:
|
|
130
|
+
save_cursor(change_dir, 0)
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _fsync_parent(path: Path) -> None:
|
|
135
|
+
if os.name == "nt":
|
|
136
|
+
return
|
|
137
|
+
descriptor = os.open(str(path.parent), os.O_RDONLY)
|
|
138
|
+
try:
|
|
139
|
+
os.fsync(descriptor)
|
|
140
|
+
finally:
|
|
141
|
+
os.close(descriptor)
|
|
88
142
|
|
|
89
143
|
|
|
90
144
|
def save_cursor(change_dir: Path, acked_lines: int) -> None:
|
|
145
|
+
if isinstance(acked_lines, bool) or not isinstance(acked_lines, int) or acked_lines < 0:
|
|
146
|
+
raise ValueError("acked_lines must be a non-negative integer")
|
|
91
147
|
path = cursor_path(change_dir)
|
|
92
148
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
-
path.
|
|
94
|
-
|
|
95
|
-
|
|
149
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
150
|
+
encoded = (
|
|
151
|
+
json.dumps({"schemaVersion": 1, "acked_lines": acked_lines}, indent=2) + "\n"
|
|
152
|
+
).encode("utf-8")
|
|
153
|
+
descriptor = os.open(
|
|
154
|
+
str(temporary), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
|
|
96
155
|
)
|
|
156
|
+
try:
|
|
157
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
158
|
+
descriptor = -1
|
|
159
|
+
stream.write(encoded)
|
|
160
|
+
stream.flush()
|
|
161
|
+
os.fsync(stream.fileno())
|
|
162
|
+
os.replace(temporary, path)
|
|
163
|
+
_fsync_parent(path)
|
|
164
|
+
finally:
|
|
165
|
+
if descriptor >= 0:
|
|
166
|
+
os.close(descriptor)
|
|
167
|
+
try:
|
|
168
|
+
temporary.unlink()
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def quarantine_path(change_dir: Path) -> Path:
|
|
174
|
+
return _state_dir(change_dir) / "meta" / "events-sync-quarantine.ndjson"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def append_quarantine(
|
|
178
|
+
change_dir: Path,
|
|
179
|
+
*,
|
|
180
|
+
event_id: str,
|
|
181
|
+
line_number: int,
|
|
182
|
+
error_code: str,
|
|
183
|
+
status: str,
|
|
184
|
+
) -> None:
|
|
185
|
+
path = quarantine_path(change_dir)
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
record = {
|
|
188
|
+
"schema_version": 1,
|
|
189
|
+
"event_id": event_id,
|
|
190
|
+
"line_number": line_number,
|
|
191
|
+
"status": status,
|
|
192
|
+
"error_code": error_code,
|
|
193
|
+
"quarantined_at": datetime.now(timezone.utc).isoformat(),
|
|
194
|
+
}
|
|
195
|
+
with path.open("a", encoding="utf-8", newline="\n") as stream:
|
|
196
|
+
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
197
|
+
stream.flush()
|
|
198
|
+
os.fsync(stream.fileno())
|
|
97
199
|
|
|
98
200
|
|
|
99
201
|
def run_id_for(change_dir: Path) -> str:
|
|
@@ -119,6 +221,23 @@ def post_json(endpoint: dict[str, str], path: str, body: dict[str, Any]) -> dict
|
|
|
119
221
|
return json.loads(response.read().decode("utf-8"))
|
|
120
222
|
|
|
121
223
|
|
|
224
|
+
def _event_lines_snapshot(events_file: Path) -> list[str]:
|
|
225
|
+
"""Read a complete-line snapshot under the writer's events.ndjson lock."""
|
|
226
|
+
lock = _CrossProcessLock(events_file.with_name(events_file.name + ".lock"))
|
|
227
|
+
lock.acquire(blocking=True)
|
|
228
|
+
try:
|
|
229
|
+
data = events_file.read_bytes()
|
|
230
|
+
finally:
|
|
231
|
+
lock.release()
|
|
232
|
+
# A non-cooperating/external writer may still leave a partial tail. Never
|
|
233
|
+
# quarantine or acknowledge that tail; the next nudge will retry it once a
|
|
234
|
+
# terminating newline makes the physical record durable.
|
|
235
|
+
if data and not data.endswith(b"\n"):
|
|
236
|
+
last_newline = data.rfind(b"\n")
|
|
237
|
+
data = b"" if last_newline < 0 else data[:last_newline + 1]
|
|
238
|
+
return data.decode("utf-8-sig").splitlines()
|
|
239
|
+
|
|
240
|
+
|
|
122
241
|
def sync_change(
|
|
123
242
|
project: Path,
|
|
124
243
|
change_dir: Path,
|
|
@@ -136,8 +255,6 @@ def sync_change(
|
|
|
136
255
|
|
|
137
256
|
# Always heartbeat so the console can separate connection vs run status.
|
|
138
257
|
try:
|
|
139
|
-
from datetime import datetime, timezone
|
|
140
|
-
|
|
141
258
|
client_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
142
259
|
post_json(
|
|
143
260
|
endpoint,
|
|
@@ -156,7 +273,7 @@ def sync_change(
|
|
|
156
273
|
if heartbeat_only:
|
|
157
274
|
return {"ok": True, "heartbeat": True, "run_id": rid, "change_key": resolved_key}
|
|
158
275
|
|
|
159
|
-
events_file = change_dir / "events.ndjson"
|
|
276
|
+
events_file = _state_dir(change_dir) / "events.ndjson"
|
|
160
277
|
if not events_file.is_file():
|
|
161
278
|
return {
|
|
162
279
|
"ok": True,
|
|
@@ -166,86 +283,145 @@ def sync_change(
|
|
|
166
283
|
"reason": "no events.ndjson",
|
|
167
284
|
}
|
|
168
285
|
|
|
169
|
-
lines = events_file
|
|
170
|
-
acked = load_cursor(
|
|
286
|
+
lines = _event_lines_snapshot(events_file)
|
|
287
|
+
acked = load_cursor(
|
|
288
|
+
change_dir,
|
|
289
|
+
maximum_lines=len(lines),
|
|
290
|
+
repair=True,
|
|
291
|
+
)
|
|
171
292
|
pending_raw = lines[acked:]
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
293
|
+
uploaded = 0
|
|
294
|
+
quarantined = 0
|
|
295
|
+
last_result: dict[str, Any] | None = None
|
|
296
|
+
|
|
297
|
+
# Chunk physical lines, not only valid events, so a malformed line can never
|
|
298
|
+
# strand all later valid events behind the durable cursor.
|
|
299
|
+
for chunk_start in range(0, len(pending_raw), MAX_BATCH_SIZE):
|
|
300
|
+
raw_chunk = pending_raw[chunk_start:chunk_start + MAX_BATCH_SIZE]
|
|
301
|
+
batch: list[dict[str, Any]] = []
|
|
302
|
+
line_by_event_id: dict[str, int] = {}
|
|
303
|
+
local_invalid: list[tuple[str, int, str]] = []
|
|
304
|
+
for offset, raw_line in enumerate(raw_chunk, start=1):
|
|
305
|
+
line_number = acked + chunk_start + offset
|
|
306
|
+
line = raw_line.strip()
|
|
307
|
+
if not line:
|
|
308
|
+
local_invalid.append((f"{resolved_key}:{line_number}", line_number, "EMPTY_LINE"))
|
|
309
|
+
continue
|
|
310
|
+
try:
|
|
311
|
+
event = json.loads(line)
|
|
312
|
+
except json.JSONDecodeError:
|
|
313
|
+
local_invalid.append((f"{resolved_key}:{line_number}", line_number, "INVALID_JSON"))
|
|
314
|
+
continue
|
|
315
|
+
if not isinstance(event, dict):
|
|
316
|
+
local_invalid.append((f"{resolved_key}:{line_number}", line_number, "INVALID_EVENT"))
|
|
317
|
+
continue
|
|
318
|
+
event_id = str(event.get("id") or f"{resolved_key}:{line_number}")
|
|
319
|
+
item: dict[str, Any] = {
|
|
187
320
|
"event_id": event_id,
|
|
188
|
-
"producer_seq":
|
|
321
|
+
"producer_seq": line_number,
|
|
189
322
|
"event_type": str(event.get("type") or "unknown"),
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"payload": payload,
|
|
323
|
+
"occurred_at": str(event.get("timestamp") or event.get("ts") or client_time),
|
|
324
|
+
"payload": sanitize(event),
|
|
193
325
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
326
|
+
if event.get("phase") is not None:
|
|
327
|
+
item["phase"] = event.get("phase")
|
|
328
|
+
batch.append(item)
|
|
329
|
+
line_by_event_id[event_id] = line_number
|
|
330
|
+
|
|
331
|
+
if batch:
|
|
332
|
+
try:
|
|
333
|
+
result = post_json(
|
|
334
|
+
endpoint,
|
|
335
|
+
f"/api/v1/projects/{project_id}/runs/events:batch",
|
|
336
|
+
{
|
|
337
|
+
"protocol_version": "hunter-progress-sync/v1",
|
|
338
|
+
"run_id": rid,
|
|
339
|
+
"change_key": resolved_key,
|
|
340
|
+
"title": resolved_key,
|
|
341
|
+
"events": batch,
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
except urllib.error.HTTPError as exc:
|
|
345
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
346
|
+
return {
|
|
347
|
+
"ok": False,
|
|
348
|
+
"error": f"batch HTTP {exc.code}: {detail}",
|
|
349
|
+
"run_id": rid,
|
|
350
|
+
"uploaded": uploaded,
|
|
351
|
+
"quarantined": quarantined,
|
|
352
|
+
"acked_lines": load_cursor(change_dir),
|
|
353
|
+
}
|
|
354
|
+
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
355
|
+
return {
|
|
356
|
+
"ok": False,
|
|
357
|
+
"error": f"batch failed: {exc}",
|
|
358
|
+
"run_id": rid,
|
|
359
|
+
"uploaded": uploaded,
|
|
360
|
+
"quarantined": quarantined,
|
|
361
|
+
"acked_lines": load_cursor(change_dir),
|
|
362
|
+
}
|
|
363
|
+
last_result = result
|
|
364
|
+
items = result.get("items") if isinstance(result, dict) else None
|
|
365
|
+
if not isinstance(items, list) or len(items) != len(batch):
|
|
366
|
+
return {
|
|
367
|
+
"ok": False,
|
|
368
|
+
"error": "batch response did not acknowledge every event",
|
|
369
|
+
"run_id": rid,
|
|
370
|
+
"uploaded": uploaded,
|
|
371
|
+
"quarantined": quarantined,
|
|
372
|
+
"acked_lines": load_cursor(change_dir),
|
|
373
|
+
}
|
|
374
|
+
response_by_id = {
|
|
375
|
+
str(item.get("id") or item.get("event_id") or ""): item
|
|
376
|
+
for item in items if isinstance(item, dict)
|
|
377
|
+
}
|
|
378
|
+
for event in batch:
|
|
379
|
+
event_id = str(event["event_id"])
|
|
380
|
+
item = response_by_id.get(event_id)
|
|
381
|
+
status = str((item or {}).get("status") or "")
|
|
382
|
+
if status in ACCEPTED_STATUSES:
|
|
383
|
+
uploaded += 1
|
|
384
|
+
continue
|
|
385
|
+
if status in QUARANTINE_STATUSES:
|
|
386
|
+
append_quarantine(
|
|
387
|
+
change_dir,
|
|
388
|
+
event_id=event_id,
|
|
389
|
+
line_number=line_by_event_id[event_id],
|
|
390
|
+
error_code=str((item or {}).get("error_code") or status.upper()),
|
|
391
|
+
status=status,
|
|
392
|
+
)
|
|
393
|
+
quarantined += 1
|
|
394
|
+
continue
|
|
395
|
+
return {
|
|
396
|
+
"ok": False,
|
|
397
|
+
"error": f"event {event_id} was not durably acknowledged",
|
|
398
|
+
"run_id": rid,
|
|
399
|
+
"uploaded": uploaded,
|
|
400
|
+
"quarantined": quarantined,
|
|
401
|
+
"acked_lines": load_cursor(change_dir),
|
|
402
|
+
"server": result,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for event_id, line_number, error_code in local_invalid:
|
|
406
|
+
append_quarantine(
|
|
407
|
+
change_dir,
|
|
408
|
+
event_id=event_id,
|
|
409
|
+
line_number=line_number,
|
|
410
|
+
error_code=error_code,
|
|
411
|
+
status="local_invalid",
|
|
412
|
+
)
|
|
413
|
+
quarantined += 1
|
|
414
|
+
save_cursor(change_dir, acked + chunk_start + len(raw_chunk))
|
|
209
415
|
|
|
210
|
-
try:
|
|
211
|
-
result = post_json(
|
|
212
|
-
endpoint,
|
|
213
|
-
f"/api/v1/projects/{project_id}/runs/events:batch",
|
|
214
|
-
{
|
|
215
|
-
"protocol_version": "hunter-progress-sync/v1",
|
|
216
|
-
"run_id": rid,
|
|
217
|
-
"change_key": resolved_key,
|
|
218
|
-
"title": resolved_key,
|
|
219
|
-
"events": batch,
|
|
220
|
-
},
|
|
221
|
-
)
|
|
222
|
-
except urllib.error.HTTPError as exc:
|
|
223
|
-
detail = exc.read().decode("utf-8", errors="replace")
|
|
224
|
-
return {"ok": False, "error": f"batch HTTP {exc.code}: {detail}", "run_id": rid}
|
|
225
|
-
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
226
|
-
return {"ok": False, "error": f"batch failed: {exc}", "run_id": rid}
|
|
227
|
-
|
|
228
|
-
items = result.get("items") if isinstance(result, dict) else None
|
|
229
|
-
accepted = 0
|
|
230
|
-
if isinstance(items, list):
|
|
231
|
-
for item in items:
|
|
232
|
-
status = str((item or {}).get("status") or "")
|
|
233
|
-
if status in {"accepted", "duplicate_accepted"}:
|
|
234
|
-
accepted += 1
|
|
235
|
-
# Advance cursor only when the whole batch was accepted/duplicated.
|
|
236
|
-
if accepted == len(batch):
|
|
237
|
-
save_cursor(change_dir, acked + len(pending_raw))
|
|
238
|
-
acked_lines = acked + len(pending_raw)
|
|
239
|
-
else:
|
|
240
|
-
acked_lines = acked
|
|
241
416
|
return {
|
|
242
|
-
"ok":
|
|
417
|
+
"ok": True,
|
|
243
418
|
"run_id": rid,
|
|
244
419
|
"change_key": resolved_key,
|
|
245
|
-
"uploaded":
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
"
|
|
420
|
+
"uploaded": uploaded,
|
|
421
|
+
"quarantined": quarantined,
|
|
422
|
+
"batch_size": min(MAX_BATCH_SIZE, len(pending_raw)),
|
|
423
|
+
"acked_lines": len(lines),
|
|
424
|
+
"server": last_result,
|
|
249
425
|
}
|
|
250
426
|
|
|
251
427
|
|
|
@@ -299,6 +475,189 @@ def auto_events_sync(
|
|
|
299
475
|
}
|
|
300
476
|
|
|
301
477
|
|
|
478
|
+
def _nudge_path(project_root: Path) -> Path:
|
|
479
|
+
return project_root / ".harness" / "state" / "local" / "events-sync" / "nudge.json"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _agent_lock_path(project_root: Path) -> Path:
|
|
483
|
+
return project_root / ".harness" / "state" / "local" / "events-sync" / "agent.lock"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _agent_waiter_lock_path(project_root: Path) -> Path:
|
|
487
|
+
return project_root / ".harness" / "state" / "local" / "events-sync" / "agent-waiter.lock"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class _CrossProcessLock:
|
|
491
|
+
"""Cross-platform advisory file lock held by an open descriptor.
|
|
492
|
+
|
|
493
|
+
Lock files are deliberately persistent: ownership belongs to the kernel lock,
|
|
494
|
+
not mtime or file existence, so a slow live worker can never be stolen.
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
def __init__(self, path: Path) -> None:
|
|
498
|
+
self.path = path
|
|
499
|
+
self._stream: Any | None = None
|
|
500
|
+
|
|
501
|
+
def acquire(self, *, blocking: bool) -> bool:
|
|
502
|
+
if self._stream is not None:
|
|
503
|
+
return True
|
|
504
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
505
|
+
stream = self.path.open("a+b", buffering=0)
|
|
506
|
+
if stream.seek(0, os.SEEK_END) == 0:
|
|
507
|
+
stream.write(b"\0")
|
|
508
|
+
stream.flush()
|
|
509
|
+
os.fsync(stream.fileno())
|
|
510
|
+
while True:
|
|
511
|
+
stream.seek(0)
|
|
512
|
+
try:
|
|
513
|
+
if os.name == "nt":
|
|
514
|
+
import msvcrt
|
|
515
|
+
|
|
516
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
517
|
+
else:
|
|
518
|
+
import fcntl
|
|
519
|
+
|
|
520
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
521
|
+
self._stream = stream
|
|
522
|
+
return True
|
|
523
|
+
except OSError as exc:
|
|
524
|
+
if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
|
|
525
|
+
stream.close()
|
|
526
|
+
raise
|
|
527
|
+
if not blocking:
|
|
528
|
+
stream.close()
|
|
529
|
+
return False
|
|
530
|
+
time.sleep(0.05)
|
|
531
|
+
|
|
532
|
+
def release(self) -> None:
|
|
533
|
+
stream = self._stream
|
|
534
|
+
if stream is None:
|
|
535
|
+
return
|
|
536
|
+
try:
|
|
537
|
+
stream.seek(0)
|
|
538
|
+
if os.name == "nt":
|
|
539
|
+
import msvcrt
|
|
540
|
+
|
|
541
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
542
|
+
else:
|
|
543
|
+
import fcntl
|
|
544
|
+
|
|
545
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
|
546
|
+
finally:
|
|
547
|
+
stream.close()
|
|
548
|
+
self._stream = None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def schedule_events_sync(project_root: Path, change_dir: Path) -> bool:
|
|
552
|
+
"""Wake a finite, project-local sync worker after an event is durable.
|
|
553
|
+
|
|
554
|
+
The wake-up record contains no credential or event payload. Multiple spawns are
|
|
555
|
+
harmless: a project-local lock lets only one worker perform network requests.
|
|
556
|
+
"""
|
|
557
|
+
project_root = project_root.resolve()
|
|
558
|
+
change_dir = change_dir.resolve()
|
|
559
|
+
if load_endpoint(project_root) is None:
|
|
560
|
+
return False
|
|
561
|
+
nudge = _nudge_path(project_root)
|
|
562
|
+
nudge.parent.mkdir(parents=True, exist_ok=True)
|
|
563
|
+
payload = {
|
|
564
|
+
"schema_version": 1,
|
|
565
|
+
"generation": uuid.uuid4().hex,
|
|
566
|
+
"change": change_dir.name,
|
|
567
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
568
|
+
}
|
|
569
|
+
temporary = nudge.with_name(f".{nudge.name}.{uuid.uuid4().hex}.tmp")
|
|
570
|
+
temporary.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8")
|
|
571
|
+
os.replace(temporary, nudge)
|
|
572
|
+
command = [
|
|
573
|
+
sys.executable,
|
|
574
|
+
str(Path(__file__).resolve()),
|
|
575
|
+
"--project",
|
|
576
|
+
str(project_root),
|
|
577
|
+
"--agent",
|
|
578
|
+
"--json",
|
|
579
|
+
]
|
|
580
|
+
kwargs: dict[str, Any] = {
|
|
581
|
+
"stdin": subprocess.DEVNULL,
|
|
582
|
+
"stdout": subprocess.DEVNULL,
|
|
583
|
+
"stderr": subprocess.DEVNULL,
|
|
584
|
+
"close_fds": True,
|
|
585
|
+
}
|
|
586
|
+
if os.name == "nt":
|
|
587
|
+
kwargs["creationflags"] = (
|
|
588
|
+
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
589
|
+
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
590
|
+
)
|
|
591
|
+
else:
|
|
592
|
+
kwargs["start_new_session"] = True
|
|
593
|
+
subprocess.Popen(command, **kwargs) # noqa: S603 — fixed local interpreter/script
|
|
594
|
+
return True
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _read_nudge_generation(project_root: Path) -> str:
|
|
598
|
+
try:
|
|
599
|
+
value = json.loads(_nudge_path(project_root).read_text(encoding="utf-8"))
|
|
600
|
+
return str(value.get("generation") or "") if isinstance(value, dict) else ""
|
|
601
|
+
except (OSError, json.JSONDecodeError):
|
|
602
|
+
return ""
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _discover_changes(project_root: Path) -> list[Path]:
|
|
606
|
+
root = project_root / ".harness" / "changes"
|
|
607
|
+
return sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _run_agent_session(project_root: Path) -> dict[str, Any]:
|
|
611
|
+
attempts = 0
|
|
612
|
+
uploaded = 0
|
|
613
|
+
quarantined = 0
|
|
614
|
+
while attempts < 6:
|
|
615
|
+
generation = _read_nudge_generation(project_root)
|
|
616
|
+
results = [sync_change(project_root, path) for path in _discover_changes(project_root)]
|
|
617
|
+
uploaded += sum(int(item.get("uploaded") or 0) for item in results)
|
|
618
|
+
quarantined += sum(int(item.get("quarantined") or 0) for item in results)
|
|
619
|
+
if all(item.get("ok") for item in results):
|
|
620
|
+
time.sleep(0.6)
|
|
621
|
+
if generation == _read_nudge_generation(project_root):
|
|
622
|
+
return {
|
|
623
|
+
"ok": True,
|
|
624
|
+
"uploaded": uploaded,
|
|
625
|
+
"quarantined": quarantined,
|
|
626
|
+
"attempts": attempts + 1,
|
|
627
|
+
}
|
|
628
|
+
attempts = 0
|
|
629
|
+
continue
|
|
630
|
+
time.sleep(min(16.0, float(2 ** attempts)))
|
|
631
|
+
attempts += 1
|
|
632
|
+
return {
|
|
633
|
+
"ok": False,
|
|
634
|
+
"uploaded": uploaded,
|
|
635
|
+
"quarantined": quarantined,
|
|
636
|
+
"attempts": attempts,
|
|
637
|
+
"error": "remote sync remains unavailable after bounded retries",
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def run_agent(project_root: Path) -> dict[str, Any]:
|
|
642
|
+
"""Run a bounded retry session with a lossless one-worker handoff."""
|
|
643
|
+
primary = _CrossProcessLock(_agent_lock_path(project_root))
|
|
644
|
+
waiter: _CrossProcessLock | None = None
|
|
645
|
+
if not primary.acquire(blocking=False):
|
|
646
|
+
# At most one follower waits at the release boundary. Any later nudge is
|
|
647
|
+
# represented by the same atomic generation record and that follower sees it.
|
|
648
|
+
waiter = _CrossProcessLock(_agent_waiter_lock_path(project_root))
|
|
649
|
+
if not waiter.acquire(blocking=False):
|
|
650
|
+
return {"ok": True, "already_running": True}
|
|
651
|
+
try:
|
|
652
|
+
primary.acquire(blocking=True)
|
|
653
|
+
finally:
|
|
654
|
+
waiter.release()
|
|
655
|
+
try:
|
|
656
|
+
return _run_agent_session(project_root)
|
|
657
|
+
finally:
|
|
658
|
+
primary.release()
|
|
659
|
+
|
|
660
|
+
|
|
302
661
|
def main(argv: list[str] | None = None) -> int:
|
|
303
662
|
parser = argparse.ArgumentParser(description="Sync events.ndjson to platform Run monitoring")
|
|
304
663
|
parser.add_argument("--project", default=".", help="Project root")
|
|
@@ -306,9 +665,21 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
306
665
|
parser.add_argument("--run-id", help="Override stable run_id (archive finalize continuity)")
|
|
307
666
|
parser.add_argument("--change-key", help="Override change_key sent to the platform")
|
|
308
667
|
parser.add_argument("--heartbeat-only", action="store_true")
|
|
668
|
+
parser.add_argument("--agent", action="store_true", help=argparse.SUPPRESS)
|
|
309
669
|
parser.add_argument("--json", action="store_true")
|
|
310
670
|
args = parser.parse_args(argv)
|
|
311
671
|
project = Path(args.project).resolve()
|
|
672
|
+
if args.agent:
|
|
673
|
+
result = run_agent(project)
|
|
674
|
+
payload = {
|
|
675
|
+
"schema_version": 1,
|
|
676
|
+
"ok": bool(result.get("ok")),
|
|
677
|
+
"command": "events.sync.agent",
|
|
678
|
+
"exit_code": 0 if result.get("ok") else 1,
|
|
679
|
+
"result": result,
|
|
680
|
+
}
|
|
681
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
682
|
+
return int(payload["exit_code"])
|
|
312
683
|
if args.change_dir:
|
|
313
684
|
changes = [Path(args.change_dir).resolve()]
|
|
314
685
|
else:
|
|
@@ -3,7 +3,7 @@ name: harness-archive
|
|
|
3
3
|
description: 归档所有变更产出(计划/测试报告/审查/SQL/API文档)到 .harness/archive/,含归档元数据。仅当用户显式调用
|
|
4
4
|
/harness-archive 时使用;不得被其他阶段 skill 自动接续触发。
|
|
5
5
|
---
|
|
6
|
-
<!-- generated by harness_deploy.py; core=
|
|
6
|
+
<!-- generated by harness_deploy.py; core=ce40ca6f73bd0d54; overlay=none; agent=cursor; do not edit -->
|
|
7
7
|
# harness-archive — 变更归档
|
|
8
8
|
|
|
9
9
|
## Purpose
|
|
@@ -3,7 +3,7 @@ name: harness-codebase-map
|
|
|
3
3
|
description: 分析现有代码库并生成 .harness/codebase/map/
|
|
4
4
|
下的结构化代码库地图。用于陌生项目理解、重大改造前梳理、harness-sync 提示 map 过期后的人工刷新。
|
|
5
5
|
---
|
|
6
|
-
<!-- generated by harness_deploy.py; core=
|
|
6
|
+
<!-- generated by harness_deploy.py; core=ce40ca6f73bd0d54; overlay=none; agent=cursor; do not edit -->
|
|
7
7
|
# harness-codebase-map — 代码库地图
|
|
8
8
|
|
|
9
9
|
## Purpose
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: harness-knowledge-ingest
|
|
3
3
|
description: 确认归档 ZIP 已上传并由 Hunter Platform 在服务端解包、校验和入库。客户端不再构建或维护本地知识索引。
|
|
4
4
|
---
|
|
5
|
-
<!-- generated by harness_deploy.py; core=
|
|
5
|
+
<!-- generated by harness_deploy.py; core=ce40ca6f73bd0d54; overlay=none; agent=cursor; do not edit -->
|
|
6
6
|
# harness-knowledge-ingest
|
|
7
7
|
|
|
8
8
|
知识 ingest 完全由 Hunter Platform 负责。归档完成时,客户端生成一个确定性 ZIP;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: harness-knowledge-query
|
|
3
3
|
description: 在规划、实现或排查前,通过 hunter-harness CLI 查询远端项目知识库。远端不可用时直接报告不可用,不建立本地索引或离线回退。
|
|
4
4
|
---
|
|
5
|
-
<!-- generated by harness_deploy.py; core=
|
|
5
|
+
<!-- generated by harness_deploy.py; core=ce40ca6f73bd0d54; overlay=none; agent=cursor; do not edit -->
|
|
6
6
|
# harness-knowledge-query
|
|
7
7
|
|
|
8
8
|
项目知识以 Hunter Platform 的服务端索引为唯一真源。客户端只提交查询并消费结果:
|