@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.
Files changed (122) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/scripts/harness_events.py +26 -0
  13. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +453 -82
  14. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  15. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  16. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  17. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  18. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  19. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/scripts/harness_events.py +26 -0
  26. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +453 -82
  27. package/harness/bundles/general/codex/.harness-build.json +1 -1
  28. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  29. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  30. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  31. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  32. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  33. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  34. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  35. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  36. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/scripts/harness_events.py +26 -0
  39. package/harness/bundles/general/codex/scripts/harness_events_sync.py +453 -82
  40. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  41. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  42. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  43. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  44. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  45. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  46. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  47. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  48. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  49. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  50. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  51. package/harness/bundles/general/cursor/scripts/harness_events.py +26 -0
  52. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +453 -82
  53. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  54. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  55. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  56. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  57. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  58. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  59. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  60. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  61. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  63. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  64. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  66. package/harness/bundles/java/claude-code/scripts/harness_events.py +26 -0
  67. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +453 -82
  68. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  69. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  70. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  71. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  72. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  73. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  74. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  75. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  76. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  77. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  78. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  79. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  80. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  81. package/harness/bundles/java/codebuddy/scripts/harness_events.py +26 -0
  82. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +453 -82
  83. package/harness/bundles/java/codex/.harness-build.json +1 -1
  84. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  85. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  86. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  87. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  88. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  89. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  90. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  91. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  92. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  93. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  94. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  95. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  96. package/harness/bundles/java/codex/scripts/harness_events.py +26 -0
  97. package/harness/bundles/java/codex/scripts/harness_events_sync.py +453 -82
  98. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  99. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  100. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  101. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  102. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  103. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  104. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  105. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  106. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  107. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  108. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  109. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  110. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  111. package/harness/bundles/java/cursor/scripts/harness_events.py +26 -0
  112. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +453 -82
  113. package/harness/manifests/general/claude-code.json +15 -15
  114. package/harness/manifests/general/codebuddy.json +15 -15
  115. package/harness/manifests/general/codex.json +15 -15
  116. package/harness/manifests/general/cursor.json +15 -15
  117. package/harness/manifests/java/claude-code.json +17 -17
  118. package/harness/manifests/java/codebuddy.json +17 -17
  119. package/harness/manifests/java/codex.json +17 -17
  120. package/harness/manifests/java/cursor.json +17 -17
  121. package/hunter-workflow-family.json +4 -4
  122. 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 load_cursor(change_dir: Path) -> int:
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
- return 0
87
- return int(data.get("acked_lines") or 0)
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.write_text(
94
- json.dumps({"schemaVersion": 1, "acked_lines": acked_lines}, indent=2) + "\n",
95
- encoding="utf-8",
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.read_text(encoding="utf-8").splitlines()
170
- acked = load_cursor(change_dir)
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
- batch: list[dict[str, Any]] = []
173
- for offset, line in enumerate(pending_raw, start=1):
174
- line = line.strip()
175
- if not line:
176
- continue
177
- try:
178
- event = json.loads(line)
179
- except json.JSONDecodeError:
180
- continue
181
- if not isinstance(event, dict):
182
- continue
183
- event_id = str(event.get("id") or f"{resolved_key}:{acked + offset}")
184
- payload = sanitize(event)
185
- batch.append(
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": acked + offset,
321
+ "producer_seq": line_number,
189
322
  "event_type": str(event.get("type") or "unknown"),
190
- "phase": event.get("phase"),
191
- "occurred_at": str(event.get("ts") or client_time),
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
- if not batch:
196
- save_cursor(change_dir, len(lines))
197
- return {
198
- "ok": True,
199
- "run_id": rid,
200
- "change_key": resolved_key,
201
- "uploaded": 0,
202
- "acked_lines": len(lines),
203
- }
204
-
205
- # Drop None phase keys for strict schema.
206
- for item in batch:
207
- if item.get("phase") is None:
208
- item.pop("phase", None)
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": accepted == len(batch),
417
+ "ok": True,
243
418
  "run_id": rid,
244
419
  "change_key": resolved_key,
245
- "uploaded": accepted,
246
- "batch_size": len(batch),
247
- "acked_lines": acked_lines,
248
- "server": result,
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:
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "java",
4
4
  "agent": "codebuddy",
5
- "coreHash": "63cbdcb9833a5c07"
5
+ "coreHash": "2a0ec8d40183f654"
6
6
  }
@@ -3,7 +3,7 @@ name: harness-apidoc
3
3
  description: 从 Java 后端代码生成结构化前端 API 文档(Controller/VO/ErrorCode)。仅当用户显式调用
4
4
  /harness-apidoc 时使用,不得自动触发。
5
5
  ---
6
- <!-- generated by harness_deploy.py; core=63cbdcb9833a5c07; overlay=java; agent=codebuddy; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=2a0ec8d40183f654; overlay=java; agent=codebuddy; do not edit -->
7
7
  # harness-apidoc — 前端接口文档(Java overlay)
8
8
 
9
9
  ## Purpose
@@ -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=63cbdcb9833a5c07; overlay=java; agent=codebuddy; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=2a0ec8d40183f654; overlay=java; agent=codebuddy; 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=63cbdcb9833a5c07; overlay=java; agent=codebuddy; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=2a0ec8d40183f654; overlay=java; agent=codebuddy; 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=63cbdcb9833a5c07; overlay=java; agent=codebuddy; do not edit -->
5
+ <!-- generated by harness_deploy.py; core=2a0ec8d40183f654; overlay=java; agent=codebuddy; do not edit -->
6
6
  # harness-knowledge-ingest
7
7
 
8
8
  知识 ingest 完全由 Hunter Platform 负责。归档完成时,客户端生成一个确定性 ZIP;