@hunter-harness/workflow-harness 0.2.52 → 0.2.54

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 (130) 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 +3 -2
  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_context.py +52 -0
  13. package/harness/bundles/general/claude-code/scripts/harness_events.py +26 -0
  14. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +602 -84
  15. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  16. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  17. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  18. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  19. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -2
  21. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/scripts/harness_context.py +52 -0
  27. package/harness/bundles/general/codebuddy/scripts/harness_events.py +26 -0
  28. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +602 -84
  29. package/harness/bundles/general/codex/.harness-build.json +1 -1
  30. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  31. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  32. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  33. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  34. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -2
  35. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  36. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/scripts/harness_context.py +52 -0
  41. package/harness/bundles/general/codex/scripts/harness_events.py +26 -0
  42. package/harness/bundles/general/codex/scripts/harness_events_sync.py +602 -84
  43. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  44. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  45. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  46. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  47. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  48. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -2
  49. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  50. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  51. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  52. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  53. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/scripts/harness_context.py +52 -0
  55. package/harness/bundles/general/cursor/scripts/harness_events.py +26 -0
  56. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +602 -84
  57. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  58. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  59. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  60. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  61. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  62. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  63. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  64. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -2
  65. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  66. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  67. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  68. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  69. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  70. package/harness/bundles/java/claude-code/scripts/harness_context.py +52 -0
  71. package/harness/bundles/java/claude-code/scripts/harness_events.py +26 -0
  72. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +602 -84
  73. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  74. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  75. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  76. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  77. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  78. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  79. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  80. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -2
  81. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  82. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  83. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  84. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  85. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  86. package/harness/bundles/java/codebuddy/scripts/harness_context.py +52 -0
  87. package/harness/bundles/java/codebuddy/scripts/harness_events.py +26 -0
  88. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +602 -84
  89. package/harness/bundles/java/codex/.harness-build.json +1 -1
  90. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  96. package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -2
  97. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  98. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  99. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  100. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  101. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  102. package/harness/bundles/java/codex/scripts/harness_context.py +52 -0
  103. package/harness/bundles/java/codex/scripts/harness_events.py +26 -0
  104. package/harness/bundles/java/codex/scripts/harness_events_sync.py +602 -84
  105. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  106. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  107. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  108. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  109. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  110. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  111. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  112. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -2
  113. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  114. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  115. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  116. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  117. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  118. package/harness/bundles/java/cursor/scripts/harness_context.py +52 -0
  119. package/harness/bundles/java/cursor/scripts/harness_events.py +26 -0
  120. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +602 -84
  121. package/harness/manifests/general/claude-code.json +16 -16
  122. package/harness/manifests/general/codebuddy.json +16 -16
  123. package/harness/manifests/general/codex.json +16 -16
  124. package/harness/manifests/general/cursor.json +16 -16
  125. package/harness/manifests/java/claude-code.json +18 -18
  126. package/harness/manifests/java/codebuddy.json +18 -18
  127. package/harness/manifests/java/codex.json +18 -18
  128. package/harness/manifests/java/cursor.json +18 -18
  129. package/hunter-workflow-family.json +4 -4
  130. package/package.json +1 -1
@@ -9,22 +9,38 @@ 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",
26
41
  "status",
27
42
  "note",
43
+ "message",
28
44
  "attempt",
29
45
  "name",
30
46
  "code",
@@ -34,6 +50,24 @@ WHITELIST = {
34
50
  "exit_code",
35
51
  "duration_ms",
36
52
  "schemaVersion",
53
+ "summary",
54
+ }
55
+
56
+ MAX_BATCH_SIZE = 100
57
+ ACCEPTED_STATUSES = {"accepted", "duplicate_accepted"}
58
+ QUARANTINE_STATUSES = {"id_conflict", "rejected", "rejected_schema"}
59
+ DISPLAY_TITLE_MAX_LENGTH = 200
60
+ EVENT_SUMMARY_MAX_LENGTH = 500
61
+
62
+ PHASE_LABELS = {
63
+ "plan": "计划",
64
+ "run": "编码",
65
+ "test": "测试",
66
+ "review": "评审",
67
+ "package": "打包",
68
+ "apidoc": "接口文档",
69
+ "submit": "提交",
70
+ "archive": "归档",
37
71
  }
38
72
 
39
73
 
@@ -68,32 +102,246 @@ def load_endpoint(project: Path) -> dict[str, str] | None:
68
102
  }
69
103
 
70
104
 
105
+ def _summary_text(value: Any) -> str:
106
+ if not isinstance(value, str):
107
+ return ""
108
+ text = re.sub(r"\s+", " ", value).strip()
109
+ text = "".join(character for character in text if ord(character) >= 32 and ord(character) != 127)
110
+ return text[:EVENT_SUMMARY_MAX_LENGTH].rstrip()
111
+
112
+
113
+ def build_event_summary(event: dict[str, Any]) -> str:
114
+ explicit = _summary_text(event.get("summary"))
115
+ if explicit:
116
+ return explicit
117
+
118
+ event_type = _summary_text(event.get("type"))
119
+ phase = _summary_text(event.get("phase"))
120
+ phase_label = PHASE_LABELS.get(phase, phase or "当前")
121
+ note = _summary_text(event.get("note"))
122
+ message = _summary_text(event.get("message"))
123
+ decision = _summary_text(event.get("decision"))
124
+ reason = _summary_text(event.get("reason"))
125
+ name = _summary_text(event.get("name"))
126
+ code = _summary_text(event.get("code"))
127
+ status = _summary_text(event.get("status"))
128
+
129
+ if event_type == "decision" and decision:
130
+ return _summary_text(f"{decision};原因:{reason}" if reason else decision)
131
+ if event_type == "issue":
132
+ detail = message or note or code
133
+ if detail and reason and reason != detail:
134
+ return _summary_text(f"{detail};原因:{reason}")
135
+ return detail or "发现一项需要处理的问题。"
136
+ if event_type == "issue.resolve":
137
+ detail = message or note or code
138
+ return _summary_text(f"问题已解决:{detail}" if detail else "已解决此前记录的问题。")
139
+ if event_type == "phase.start":
140
+ return _summary_text(
141
+ f"开始执行{phase_label}阶段:{note}" if note else f"开始执行{phase_label}阶段。"
142
+ )
143
+ if event_type in {"phase.end", "phase.auto_sealed"}:
144
+ detail = note or status
145
+ return _summary_text(
146
+ f"{phase_label}阶段已结束:{detail}" if detail else f"{phase_label}阶段已结束。"
147
+ )
148
+ if event_type == "command":
149
+ detail = name or note
150
+ return _summary_text(f"执行步骤:{detail}" if detail else "执行了一项工作步骤。")
151
+ if event_type == "verification":
152
+ detail = name or note or status
153
+ return _summary_text(f"完成验证:{detail}" if detail else "完成了一项结果验证。")
154
+ if event_type == "artifact":
155
+ detail = name or note
156
+ return _summary_text(f"生成或更新产物:{detail}" if detail else "生成或更新了一项交付内容。")
157
+ if event_type == "correction":
158
+ detail = note or reason or message
159
+ return _summary_text(f"调整执行方式:{detail}" if detail else "根据当前结果调整了执行方式。")
160
+ if event_type == "change.rename":
161
+ return note or message or "更新了变更名称。"
162
+ if event_type in {"recovery", "phase.recovery", "attempt.recovery"}:
163
+ return note or reason or message or "恢复了中断的运行状态。"
164
+ if event_type == "heartbeat":
165
+ return "客户端保持在线,并同步了最新运行状态。"
166
+
167
+ return note or message or name or reason or code or "记录了一项运行事件。"
168
+
169
+
71
170
  def sanitize(event: dict[str, Any]) -> dict[str, Any]:
72
- return {key: event[key] for key in WHITELIST if key in event}
171
+ payload = {key: event[key] for key in WHITELIST if key in event}
172
+ payload["summary"] = build_event_summary(event)
173
+ return payload
174
+
175
+
176
+ def _valid_display_title(value: Any) -> str | None:
177
+ if not isinstance(value, str):
178
+ return None
179
+ title = value.strip()
180
+ if (
181
+ not title
182
+ or len(title) > DISPLAY_TITLE_MAX_LENGTH
183
+ or any(ord(character) < 32 or ord(character) == 127 for character in title)
184
+ ):
185
+ return None
186
+ return title
187
+
188
+
189
+ def _safe_file_within(change_dir: Path, path: Path) -> Path | None:
190
+ try:
191
+ root = change_dir.resolve()
192
+ resolved = path.resolve()
193
+ except OSError:
194
+ return None
195
+ if path.is_symlink() or not resolved.is_relative_to(root) or not resolved.is_file():
196
+ return None
197
+ return resolved
198
+
199
+
200
+ def load_display_title(change_dir: Path, change_key: str) -> str:
201
+ metadata_path = _safe_file_within(
202
+ change_dir, change_dir / "meta" / "change-title.json"
203
+ )
204
+ if metadata_path is not None:
205
+ try:
206
+ payload = json.loads(metadata_path.read_text(encoding="utf-8-sig"))
207
+ except (OSError, json.JSONDecodeError):
208
+ payload = None
209
+ if isinstance(payload, dict):
210
+ title = _valid_display_title(payload.get("displayTitle"))
211
+ if title is not None:
212
+ return title
213
+
214
+ # Legacy migration: older Plan runs already wrote a human title into the
215
+ # approved design H1 but had no structured title metadata.
216
+ design_path = _safe_file_within(
217
+ change_dir, change_dir / "spec" / f"{change_key}-design.md"
218
+ )
219
+ if design_path is not None:
220
+ try:
221
+ with design_path.open("r", encoding="utf-8-sig") as stream:
222
+ for _ in range(40):
223
+ line = stream.readline()
224
+ if not line:
225
+ break
226
+ if not line.startswith("# "):
227
+ continue
228
+ candidate = re.sub(
229
+ r"\s+(?:设计文档|设计方案|设计)$", "", line[2:].strip()
230
+ )
231
+ title = _valid_display_title(candidate)
232
+ if title is not None and title != change_key:
233
+ return title
234
+ break
235
+ except OSError:
236
+ pass
237
+ return change_key
73
238
 
74
239
 
75
240
  def cursor_path(change_dir: Path) -> Path:
76
- return change_dir / "meta" / "events-sync-cursor.json"
241
+ return _state_dir(change_dir) / "meta" / "events-sync-cursor.json"
242
+
243
+
244
+ def _state_dir(change_dir: Path) -> Path:
245
+ return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
77
246
 
78
247
 
79
- def load_cursor(change_dir: Path) -> int:
248
+ def load_cursor(
249
+ change_dir: Path,
250
+ *,
251
+ maximum_lines: int | None = None,
252
+ repair: bool = False,
253
+ ) -> int:
80
254
  path = cursor_path(change_dir)
81
255
  if not path.is_file():
82
256
  return 0
257
+ valid = True
83
258
  try:
84
259
  data = json.loads(path.read_text(encoding="utf-8"))
85
260
  except (OSError, json.JSONDecodeError):
86
- return 0
87
- return int(data.get("acked_lines") or 0)
261
+ data = None
262
+ valid = False
263
+ raw = data.get("acked_lines") if isinstance(data, dict) else None
264
+ if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:
265
+ valid = False
266
+ value = 0
267
+ else:
268
+ value = raw
269
+ if maximum_lines is not None:
270
+ if maximum_lines < 0:
271
+ raise ValueError("maximum_lines must be non-negative")
272
+ if value > maximum_lines:
273
+ valid = False
274
+ value = 0
275
+ if not valid and repair:
276
+ save_cursor(change_dir, 0)
277
+ return value
278
+
279
+
280
+ def _fsync_parent(path: Path) -> None:
281
+ if os.name == "nt":
282
+ return
283
+ descriptor = os.open(str(path.parent), os.O_RDONLY)
284
+ try:
285
+ os.fsync(descriptor)
286
+ finally:
287
+ os.close(descriptor)
88
288
 
89
289
 
90
290
  def save_cursor(change_dir: Path, acked_lines: int) -> None:
291
+ if isinstance(acked_lines, bool) or not isinstance(acked_lines, int) or acked_lines < 0:
292
+ raise ValueError("acked_lines must be a non-negative integer")
91
293
  path = cursor_path(change_dir)
92
294
  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",
295
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
296
+ encoded = (
297
+ json.dumps({"schemaVersion": 1, "acked_lines": acked_lines}, indent=2) + "\n"
298
+ ).encode("utf-8")
299
+ descriptor = os.open(
300
+ str(temporary), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
96
301
  )
302
+ try:
303
+ with os.fdopen(descriptor, "wb") as stream:
304
+ descriptor = -1
305
+ stream.write(encoded)
306
+ stream.flush()
307
+ os.fsync(stream.fileno())
308
+ os.replace(temporary, path)
309
+ _fsync_parent(path)
310
+ finally:
311
+ if descriptor >= 0:
312
+ os.close(descriptor)
313
+ try:
314
+ temporary.unlink()
315
+ except FileNotFoundError:
316
+ pass
317
+
318
+
319
+ def quarantine_path(change_dir: Path) -> Path:
320
+ return _state_dir(change_dir) / "meta" / "events-sync-quarantine.ndjson"
321
+
322
+
323
+ def append_quarantine(
324
+ change_dir: Path,
325
+ *,
326
+ event_id: str,
327
+ line_number: int,
328
+ error_code: str,
329
+ status: str,
330
+ ) -> None:
331
+ path = quarantine_path(change_dir)
332
+ path.parent.mkdir(parents=True, exist_ok=True)
333
+ record = {
334
+ "schema_version": 1,
335
+ "event_id": event_id,
336
+ "line_number": line_number,
337
+ "status": status,
338
+ "error_code": error_code,
339
+ "quarantined_at": datetime.now(timezone.utc).isoformat(),
340
+ }
341
+ with path.open("a", encoding="utf-8", newline="\n") as stream:
342
+ stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
343
+ stream.flush()
344
+ os.fsync(stream.fileno())
97
345
 
98
346
 
99
347
  def run_id_for(change_dir: Path) -> str:
@@ -119,6 +367,23 @@ def post_json(endpoint: dict[str, str], path: str, body: dict[str, Any]) -> dict
119
367
  return json.loads(response.read().decode("utf-8"))
120
368
 
121
369
 
370
+ def _event_lines_snapshot(events_file: Path) -> list[str]:
371
+ """Read a complete-line snapshot under the writer's events.ndjson lock."""
372
+ lock = _CrossProcessLock(events_file.with_name(events_file.name + ".lock"))
373
+ lock.acquire(blocking=True)
374
+ try:
375
+ data = events_file.read_bytes()
376
+ finally:
377
+ lock.release()
378
+ # A non-cooperating/external writer may still leave a partial tail. Never
379
+ # quarantine or acknowledge that tail; the next nudge will retry it once a
380
+ # terminating newline makes the physical record durable.
381
+ if data and not data.endswith(b"\n"):
382
+ last_newline = data.rfind(b"\n")
383
+ data = b"" if last_newline < 0 else data[:last_newline + 1]
384
+ return data.decode("utf-8-sig").splitlines()
385
+
386
+
122
387
  def sync_change(
123
388
  project: Path,
124
389
  change_dir: Path,
@@ -133,11 +398,10 @@ def sync_change(
133
398
  resolved_key = change_key or change_dir.name
134
399
  rid = run_id or run_id_for(change_dir)
135
400
  project_id = endpoint["project_id"]
401
+ display_title = load_display_title(change_dir, resolved_key)
136
402
 
137
403
  # Always heartbeat so the console can separate connection vs run status.
138
404
  try:
139
- from datetime import datetime, timezone
140
-
141
405
  client_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
142
406
  post_json(
143
407
  endpoint,
@@ -147,7 +411,7 @@ def sync_change(
147
411
  "run_id": rid,
148
412
  "change_key": resolved_key,
149
413
  "client_time": client_time,
150
- "title": resolved_key,
414
+ "title": display_title,
151
415
  },
152
416
  )
153
417
  except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
@@ -156,7 +420,7 @@ def sync_change(
156
420
  if heartbeat_only:
157
421
  return {"ok": True, "heartbeat": True, "run_id": rid, "change_key": resolved_key}
158
422
 
159
- events_file = change_dir / "events.ndjson"
423
+ events_file = _state_dir(change_dir) / "events.ndjson"
160
424
  if not events_file.is_file():
161
425
  return {
162
426
  "ok": True,
@@ -166,86 +430,145 @@ def sync_change(
166
430
  "reason": "no events.ndjson",
167
431
  }
168
432
 
169
- lines = events_file.read_text(encoding="utf-8").splitlines()
170
- acked = load_cursor(change_dir)
433
+ lines = _event_lines_snapshot(events_file)
434
+ acked = load_cursor(
435
+ change_dir,
436
+ maximum_lines=len(lines),
437
+ repair=True,
438
+ )
171
439
  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
- {
440
+ uploaded = 0
441
+ quarantined = 0
442
+ last_result: dict[str, Any] | None = None
443
+
444
+ # Chunk physical lines, not only valid events, so a malformed line can never
445
+ # strand all later valid events behind the durable cursor.
446
+ for chunk_start in range(0, len(pending_raw), MAX_BATCH_SIZE):
447
+ raw_chunk = pending_raw[chunk_start:chunk_start + MAX_BATCH_SIZE]
448
+ batch: list[dict[str, Any]] = []
449
+ line_by_event_id: dict[str, int] = {}
450
+ local_invalid: list[tuple[str, int, str]] = []
451
+ for offset, raw_line in enumerate(raw_chunk, start=1):
452
+ line_number = acked + chunk_start + offset
453
+ line = raw_line.strip()
454
+ if not line:
455
+ local_invalid.append((f"{resolved_key}:{line_number}", line_number, "EMPTY_LINE"))
456
+ continue
457
+ try:
458
+ event = json.loads(line)
459
+ except json.JSONDecodeError:
460
+ local_invalid.append((f"{resolved_key}:{line_number}", line_number, "INVALID_JSON"))
461
+ continue
462
+ if not isinstance(event, dict):
463
+ local_invalid.append((f"{resolved_key}:{line_number}", line_number, "INVALID_EVENT"))
464
+ continue
465
+ event_id = str(event.get("id") or f"{resolved_key}:{line_number}")
466
+ item: dict[str, Any] = {
187
467
  "event_id": event_id,
188
- "producer_seq": acked + offset,
468
+ "producer_seq": line_number,
189
469
  "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,
470
+ "occurred_at": str(event.get("timestamp") or event.get("ts") or client_time),
471
+ "payload": sanitize(event),
193
472
  }
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)
473
+ if event.get("phase") is not None:
474
+ item["phase"] = event.get("phase")
475
+ batch.append(item)
476
+ line_by_event_id[event_id] = line_number
477
+
478
+ if batch:
479
+ try:
480
+ result = post_json(
481
+ endpoint,
482
+ f"/api/v1/projects/{project_id}/runs/events:batch",
483
+ {
484
+ "protocol_version": "hunter-progress-sync/v1",
485
+ "run_id": rid,
486
+ "change_key": resolved_key,
487
+ "title": display_title,
488
+ "events": batch,
489
+ },
490
+ )
491
+ except urllib.error.HTTPError as exc:
492
+ detail = exc.read().decode("utf-8", errors="replace")
493
+ return {
494
+ "ok": False,
495
+ "error": f"batch HTTP {exc.code}: {detail}",
496
+ "run_id": rid,
497
+ "uploaded": uploaded,
498
+ "quarantined": quarantined,
499
+ "acked_lines": load_cursor(change_dir),
500
+ }
501
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
502
+ return {
503
+ "ok": False,
504
+ "error": f"batch failed: {exc}",
505
+ "run_id": rid,
506
+ "uploaded": uploaded,
507
+ "quarantined": quarantined,
508
+ "acked_lines": load_cursor(change_dir),
509
+ }
510
+ last_result = result
511
+ items = result.get("items") if isinstance(result, dict) else None
512
+ if not isinstance(items, list) or len(items) != len(batch):
513
+ return {
514
+ "ok": False,
515
+ "error": "batch response did not acknowledge every event",
516
+ "run_id": rid,
517
+ "uploaded": uploaded,
518
+ "quarantined": quarantined,
519
+ "acked_lines": load_cursor(change_dir),
520
+ }
521
+ response_by_id = {
522
+ str(item.get("id") or item.get("event_id") or ""): item
523
+ for item in items if isinstance(item, dict)
524
+ }
525
+ for event in batch:
526
+ event_id = str(event["event_id"])
527
+ item = response_by_id.get(event_id)
528
+ status = str((item or {}).get("status") or "")
529
+ if status in ACCEPTED_STATUSES:
530
+ uploaded += 1
531
+ continue
532
+ if status in QUARANTINE_STATUSES:
533
+ append_quarantine(
534
+ change_dir,
535
+ event_id=event_id,
536
+ line_number=line_by_event_id[event_id],
537
+ error_code=str((item or {}).get("error_code") or status.upper()),
538
+ status=status,
539
+ )
540
+ quarantined += 1
541
+ continue
542
+ return {
543
+ "ok": False,
544
+ "error": f"event {event_id} was not durably acknowledged",
545
+ "run_id": rid,
546
+ "uploaded": uploaded,
547
+ "quarantined": quarantined,
548
+ "acked_lines": load_cursor(change_dir),
549
+ "server": result,
550
+ }
551
+
552
+ for event_id, line_number, error_code in local_invalid:
553
+ append_quarantine(
554
+ change_dir,
555
+ event_id=event_id,
556
+ line_number=line_number,
557
+ error_code=error_code,
558
+ status="local_invalid",
559
+ )
560
+ quarantined += 1
561
+ save_cursor(change_dir, acked + chunk_start + len(raw_chunk))
209
562
 
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
563
  return {
242
- "ok": accepted == len(batch),
564
+ "ok": True,
243
565
  "run_id": rid,
244
566
  "change_key": resolved_key,
245
- "uploaded": accepted,
246
- "batch_size": len(batch),
247
- "acked_lines": acked_lines,
248
- "server": result,
567
+ "uploaded": uploaded,
568
+ "quarantined": quarantined,
569
+ "batch_size": min(MAX_BATCH_SIZE, len(pending_raw)),
570
+ "acked_lines": len(lines),
571
+ "server": last_result,
249
572
  }
250
573
 
251
574
 
@@ -299,6 +622,189 @@ def auto_events_sync(
299
622
  }
300
623
 
301
624
 
625
+ def _nudge_path(project_root: Path) -> Path:
626
+ return project_root / ".harness" / "state" / "local" / "events-sync" / "nudge.json"
627
+
628
+
629
+ def _agent_lock_path(project_root: Path) -> Path:
630
+ return project_root / ".harness" / "state" / "local" / "events-sync" / "agent.lock"
631
+
632
+
633
+ def _agent_waiter_lock_path(project_root: Path) -> Path:
634
+ return project_root / ".harness" / "state" / "local" / "events-sync" / "agent-waiter.lock"
635
+
636
+
637
+ class _CrossProcessLock:
638
+ """Cross-platform advisory file lock held by an open descriptor.
639
+
640
+ Lock files are deliberately persistent: ownership belongs to the kernel lock,
641
+ not mtime or file existence, so a slow live worker can never be stolen.
642
+ """
643
+
644
+ def __init__(self, path: Path) -> None:
645
+ self.path = path
646
+ self._stream: Any | None = None
647
+
648
+ def acquire(self, *, blocking: bool) -> bool:
649
+ if self._stream is not None:
650
+ return True
651
+ self.path.parent.mkdir(parents=True, exist_ok=True)
652
+ stream = self.path.open("a+b", buffering=0)
653
+ if stream.seek(0, os.SEEK_END) == 0:
654
+ stream.write(b"\0")
655
+ stream.flush()
656
+ os.fsync(stream.fileno())
657
+ while True:
658
+ stream.seek(0)
659
+ try:
660
+ if os.name == "nt":
661
+ import msvcrt
662
+
663
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
664
+ else:
665
+ import fcntl
666
+
667
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
668
+ self._stream = stream
669
+ return True
670
+ except OSError as exc:
671
+ if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
672
+ stream.close()
673
+ raise
674
+ if not blocking:
675
+ stream.close()
676
+ return False
677
+ time.sleep(0.05)
678
+
679
+ def release(self) -> None:
680
+ stream = self._stream
681
+ if stream is None:
682
+ return
683
+ try:
684
+ stream.seek(0)
685
+ if os.name == "nt":
686
+ import msvcrt
687
+
688
+ msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
689
+ else:
690
+ import fcntl
691
+
692
+ fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
693
+ finally:
694
+ stream.close()
695
+ self._stream = None
696
+
697
+
698
+ def schedule_events_sync(project_root: Path, change_dir: Path) -> bool:
699
+ """Wake a finite, project-local sync worker after an event is durable.
700
+
701
+ The wake-up record contains no credential or event payload. Multiple spawns are
702
+ harmless: a project-local lock lets only one worker perform network requests.
703
+ """
704
+ project_root = project_root.resolve()
705
+ change_dir = change_dir.resolve()
706
+ if load_endpoint(project_root) is None:
707
+ return False
708
+ nudge = _nudge_path(project_root)
709
+ nudge.parent.mkdir(parents=True, exist_ok=True)
710
+ payload = {
711
+ "schema_version": 1,
712
+ "generation": uuid.uuid4().hex,
713
+ "change": change_dir.name,
714
+ "updated_at": datetime.now(timezone.utc).isoformat(),
715
+ }
716
+ temporary = nudge.with_name(f".{nudge.name}.{uuid.uuid4().hex}.tmp")
717
+ temporary.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8")
718
+ os.replace(temporary, nudge)
719
+ command = [
720
+ sys.executable,
721
+ str(Path(__file__).resolve()),
722
+ "--project",
723
+ str(project_root),
724
+ "--agent",
725
+ "--json",
726
+ ]
727
+ kwargs: dict[str, Any] = {
728
+ "stdin": subprocess.DEVNULL,
729
+ "stdout": subprocess.DEVNULL,
730
+ "stderr": subprocess.DEVNULL,
731
+ "close_fds": True,
732
+ }
733
+ if os.name == "nt":
734
+ kwargs["creationflags"] = (
735
+ getattr(subprocess, "CREATE_NO_WINDOW", 0)
736
+ | getattr(subprocess, "DETACHED_PROCESS", 0)
737
+ )
738
+ else:
739
+ kwargs["start_new_session"] = True
740
+ subprocess.Popen(command, **kwargs) # noqa: S603 — fixed local interpreter/script
741
+ return True
742
+
743
+
744
+ def _read_nudge_generation(project_root: Path) -> str:
745
+ try:
746
+ value = json.loads(_nudge_path(project_root).read_text(encoding="utf-8"))
747
+ return str(value.get("generation") or "") if isinstance(value, dict) else ""
748
+ except (OSError, json.JSONDecodeError):
749
+ return ""
750
+
751
+
752
+ def _discover_changes(project_root: Path) -> list[Path]:
753
+ root = project_root / ".harness" / "changes"
754
+ return sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
755
+
756
+
757
+ def _run_agent_session(project_root: Path) -> dict[str, Any]:
758
+ attempts = 0
759
+ uploaded = 0
760
+ quarantined = 0
761
+ while attempts < 6:
762
+ generation = _read_nudge_generation(project_root)
763
+ results = [sync_change(project_root, path) for path in _discover_changes(project_root)]
764
+ uploaded += sum(int(item.get("uploaded") or 0) for item in results)
765
+ quarantined += sum(int(item.get("quarantined") or 0) for item in results)
766
+ if all(item.get("ok") for item in results):
767
+ time.sleep(0.6)
768
+ if generation == _read_nudge_generation(project_root):
769
+ return {
770
+ "ok": True,
771
+ "uploaded": uploaded,
772
+ "quarantined": quarantined,
773
+ "attempts": attempts + 1,
774
+ }
775
+ attempts = 0
776
+ continue
777
+ time.sleep(min(16.0, float(2 ** attempts)))
778
+ attempts += 1
779
+ return {
780
+ "ok": False,
781
+ "uploaded": uploaded,
782
+ "quarantined": quarantined,
783
+ "attempts": attempts,
784
+ "error": "remote sync remains unavailable after bounded retries",
785
+ }
786
+
787
+
788
+ def run_agent(project_root: Path) -> dict[str, Any]:
789
+ """Run a bounded retry session with a lossless one-worker handoff."""
790
+ primary = _CrossProcessLock(_agent_lock_path(project_root))
791
+ waiter: _CrossProcessLock | None = None
792
+ if not primary.acquire(blocking=False):
793
+ # At most one follower waits at the release boundary. Any later nudge is
794
+ # represented by the same atomic generation record and that follower sees it.
795
+ waiter = _CrossProcessLock(_agent_waiter_lock_path(project_root))
796
+ if not waiter.acquire(blocking=False):
797
+ return {"ok": True, "already_running": True}
798
+ try:
799
+ primary.acquire(blocking=True)
800
+ finally:
801
+ waiter.release()
802
+ try:
803
+ return _run_agent_session(project_root)
804
+ finally:
805
+ primary.release()
806
+
807
+
302
808
  def main(argv: list[str] | None = None) -> int:
303
809
  parser = argparse.ArgumentParser(description="Sync events.ndjson to platform Run monitoring")
304
810
  parser.add_argument("--project", default=".", help="Project root")
@@ -306,9 +812,21 @@ def main(argv: list[str] | None = None) -> int:
306
812
  parser.add_argument("--run-id", help="Override stable run_id (archive finalize continuity)")
307
813
  parser.add_argument("--change-key", help="Override change_key sent to the platform")
308
814
  parser.add_argument("--heartbeat-only", action="store_true")
815
+ parser.add_argument("--agent", action="store_true", help=argparse.SUPPRESS)
309
816
  parser.add_argument("--json", action="store_true")
310
817
  args = parser.parse_args(argv)
311
818
  project = Path(args.project).resolve()
819
+ if args.agent:
820
+ result = run_agent(project)
821
+ payload = {
822
+ "schema_version": 1,
823
+ "ok": bool(result.get("ok")),
824
+ "command": "events.sync.agent",
825
+ "exit_code": 0 if result.get("ok") else 1,
826
+ "result": result,
827
+ }
828
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
829
+ return int(payload["exit_code"])
312
830
  if args.change_dir:
313
831
  changes = [Path(args.change_dir).resolve()]
314
832
  else: