@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.
- 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 +3 -2
- 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_context.py +52 -0
- package/harness/bundles/general/claude-code/scripts/harness_events.py +26 -0
- package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +602 -84
- 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 +3 -2
- 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_context.py +52 -0
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +26 -0
- package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +602 -84
- 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 +3 -2
- 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_context.py +52 -0
- package/harness/bundles/general/codex/scripts/harness_events.py +26 -0
- package/harness/bundles/general/codex/scripts/harness_events_sync.py +602 -84
- 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 +3 -2
- 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_context.py +52 -0
- package/harness/bundles/general/cursor/scripts/harness_events.py +26 -0
- package/harness/bundles/general/cursor/scripts/harness_events_sync.py +602 -84
- 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 +3 -2
- 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_context.py +52 -0
- package/harness/bundles/java/claude-code/scripts/harness_events.py +26 -0
- package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +602 -84
- 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 +3 -2
- 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_context.py +52 -0
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +26 -0
- package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +602 -84
- 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 +3 -2
- 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_context.py +52 -0
- package/harness/bundles/java/codex/scripts/harness_events.py +26 -0
- package/harness/bundles/java/codex/scripts/harness_events_sync.py +602 -84
- 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 +3 -2
- 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_context.py +52 -0
- package/harness/bundles/java/cursor/scripts/harness_events.py +26 -0
- package/harness/bundles/java/cursor/scripts/harness_events_sync.py +602 -84
- package/harness/manifests/general/claude-code.json +16 -16
- package/harness/manifests/general/codebuddy.json +16 -16
- package/harness/manifests/general/codex.json +16 -16
- package/harness/manifests/general/cursor.json +16 -16
- package/harness/manifests/java/claude-code.json +18 -18
- package/harness/manifests/java/codebuddy.json +18 -18
- package/harness/manifests/java/codex.json +18 -18
- package/harness/manifests/java/cursor.json +18 -18
- package/hunter-workflow-family.json +4 -4
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
87
|
-
|
|
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.
|
|
94
|
-
|
|
95
|
-
|
|
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":
|
|
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
|
|
170
|
-
acked = load_cursor(
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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":
|
|
468
|
+
"producer_seq": line_number,
|
|
189
469
|
"event_type": str(event.get("type") or "unknown"),
|
|
190
|
-
"
|
|
191
|
-
"
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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":
|
|
564
|
+
"ok": True,
|
|
243
565
|
"run_id": rid,
|
|
244
566
|
"change_key": resolved_key,
|
|
245
|
-
"uploaded":
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
"
|
|
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:
|