baserun-cli 0.1.3__tar.gz → 0.1.4__tar.gz
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.
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/PKG-INFO +1 -1
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/channel.py +96 -108
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/runner.py +8 -7
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli.egg-info/PKG-INFO +1 -1
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/pyproject.toml +1 -1
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/README.md +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/__init__.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/_vendored/__init__.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/_vendored/base.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/_vendored/cli.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/__init__.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/base.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/bash_agent.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/claude.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/codex.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli/main.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli.egg-info/SOURCES.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli.egg-info/dependency_links.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli.egg-info/entry_points.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli.egg-info/requires.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/baserun_cli.egg-info/top_level.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.4}/setup.cfg +0 -0
|
@@ -82,73 +82,49 @@ class ChannelClient:
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
async def ensure_run_pub(self, run_id: str) -> None:
|
|
85
|
-
"""
|
|
85
|
+
"""Deprecated no-op.
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
WS publisher sends can succeed locally while nchan receives nothing
|
|
88
|
+
when a proxy/WebSocket keepalive breaks. Run events now use HTTP
|
|
89
|
+
publish as the authoritative, acknowledged path.
|
|
89
90
|
"""
|
|
90
|
-
|
|
91
|
-
return
|
|
92
|
-
try:
|
|
93
|
-
ws = await self._open_run_pub(run_id)
|
|
94
|
-
self._run_pubs[run_id] = ws
|
|
95
|
-
except Exception as e:
|
|
96
|
-
log.debug("pre-connect ws pub for run %s failed (will retry on first event): %s", run_id, e)
|
|
91
|
+
return
|
|
97
92
|
|
|
98
93
|
async def publish_event(self, run_id: str, payload: dict[str, Any]) -> bool:
|
|
99
|
-
"""Publish a run event via
|
|
100
|
-
|
|
101
|
-
All events (including terminal) go through the same WS connection to
|
|
102
|
-
preserve event order in the nchan channel buffer. Mixing WS and HTTP
|
|
103
|
-
can cause ordering issues because HTTP requests may be processed by
|
|
104
|
-
nchan at different times relative to buffered WS messages.
|
|
94
|
+
"""Publish a run event via HTTP and require an acknowledged response.
|
|
105
95
|
|
|
106
|
-
|
|
96
|
+
We intentionally avoid the WebSocket publisher for run events: send()
|
|
97
|
+
only confirms that bytes entered the local socket buffer, not that nchan
|
|
98
|
+
accepted and buffered the event. HTTP gives per-event success/failure.
|
|
107
99
|
"""
|
|
108
|
-
data = json.dumps(payload, ensure_ascii=False)
|
|
109
|
-
is_terminal = (payload.get("data", {}).get("finished") is True)
|
|
110
|
-
max_retries = 4 if is_terminal else 2
|
|
111
|
-
|
|
112
|
-
for attempt in range(max_retries):
|
|
113
|
-
ws = self._run_pubs.get(run_id)
|
|
114
|
-
if ws is None:
|
|
115
|
-
try:
|
|
116
|
-
ws = await self._open_run_pub(run_id)
|
|
117
|
-
self._run_pubs[run_id] = ws
|
|
118
|
-
except Exception as e:
|
|
119
|
-
log.warning("ws pub connect for run %s failed (attempt %d): %s", run_id, attempt + 1, e)
|
|
120
|
-
if attempt < max_retries - 1:
|
|
121
|
-
await asyncio.sleep(1.0)
|
|
122
|
-
continue
|
|
123
|
-
try:
|
|
124
|
-
await ws.send(data)
|
|
125
|
-
return True
|
|
126
|
-
except Exception as e:
|
|
127
|
-
log.warning("ws publish to run %s failed (attempt %d): %s", run_id, attempt + 1, e)
|
|
128
|
-
self._run_pubs.pop(run_id, None)
|
|
129
|
-
if attempt < max_retries - 1:
|
|
130
|
-
await asyncio.sleep(1.0)
|
|
131
|
-
|
|
132
|
-
log.error("publish failed for run %s seq=%s after %d attempts",
|
|
133
|
-
run_id, payload.get("data", {}).get("seq"), max_retries)
|
|
134
|
-
return False
|
|
135
|
-
|
|
136
|
-
async def publish_event_reliably(self, run_id: str, payload: dict[str, Any]) -> bool:
|
|
137
|
-
"""Publish a critical run event, falling back to HTTP if WS fails."""
|
|
138
|
-
if await self.publish_event(run_id, payload):
|
|
139
|
-
return True
|
|
140
100
|
try:
|
|
141
101
|
await self._http_publish(run_id, payload)
|
|
142
102
|
return True
|
|
143
103
|
except Exception as e:
|
|
144
|
-
log.
|
|
145
|
-
"http publish
|
|
104
|
+
log.warning(
|
|
105
|
+
"http publish to run %s failed seq=%s: %s",
|
|
146
106
|
run_id,
|
|
147
107
|
payload.get("data", {}).get("seq"),
|
|
148
108
|
e,
|
|
149
109
|
)
|
|
150
110
|
return False
|
|
151
111
|
|
|
112
|
+
async def publish_event_reliably(self, run_id: str, payload: dict[str, Any]) -> bool:
|
|
113
|
+
"""Publish a critical run event with HTTP retries."""
|
|
114
|
+
max_retries = 5
|
|
115
|
+
for attempt in range(max_retries):
|
|
116
|
+
if await self.publish_event(run_id, payload):
|
|
117
|
+
return True
|
|
118
|
+
if attempt < max_retries - 1:
|
|
119
|
+
await asyncio.sleep(1.0)
|
|
120
|
+
log.error(
|
|
121
|
+
"reliable publish failed for run %s seq=%s after %d attempts",
|
|
122
|
+
run_id,
|
|
123
|
+
payload.get("data", {}).get("seq"),
|
|
124
|
+
max_retries,
|
|
125
|
+
)
|
|
126
|
+
return False
|
|
127
|
+
|
|
152
128
|
async def _open_run_pub(self, run_id: str) -> Any:
|
|
153
129
|
"""Open a WS publisher connection for run:{run_id}."""
|
|
154
130
|
base = self.nchan_url
|
|
@@ -175,83 +151,81 @@ class ChannelClient:
|
|
|
175
151
|
except Exception:
|
|
176
152
|
pass
|
|
177
153
|
|
|
178
|
-
async def verify_and_replay(self, run_id: str, local_events: list[dict]) ->
|
|
179
|
-
"""对比 nchan channel 实际状态与本地 events
|
|
180
|
-
|
|
181
|
-
1. 订阅 nchan channel,读取已有消息的 seq 集合
|
|
182
|
-
2. 对比本地 JSONL 的 seq 集合
|
|
183
|
-
3. 缺失的事件按 seq 顺序通过 WS 重新发送
|
|
154
|
+
async def verify_and_replay(self, run_id: str, local_events: list[dict]) -> bool:
|
|
155
|
+
"""对比 nchan channel 实际状态与本地 events,补发缺失事件。
|
|
184
156
|
|
|
185
|
-
|
|
186
|
-
|
|
157
|
+
返回 True 表示已确认 nchan 中存在 terminal;返回 False 表示仍未确认,
|
|
158
|
+
runner 不能把该 run 记为 recently-completed。
|
|
187
159
|
"""
|
|
188
160
|
if not local_events:
|
|
189
|
-
return
|
|
161
|
+
return False
|
|
190
162
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
async with
|
|
198
|
-
async
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
163
|
+
async def read_nchan() -> tuple[set[int], bool, bool]:
|
|
164
|
+
"""Return (seqs, has_terminal, read_ok)."""
|
|
165
|
+
url = f"{self._publish_base.rstrip('/')}/internal/run/{run_id}"
|
|
166
|
+
seqs: set[int] = set()
|
|
167
|
+
terminal = False
|
|
168
|
+
try:
|
|
169
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as c:
|
|
170
|
+
async with c.stream("GET", url, headers={"Accept": "text/event-stream"}) as resp:
|
|
171
|
+
resp.raise_for_status()
|
|
172
|
+
async for line in resp.aiter_lines():
|
|
173
|
+
if not line.startswith("data:"):
|
|
174
|
+
continue
|
|
175
|
+
raw = line[5:].strip()
|
|
176
|
+
if not raw:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
msg = json.loads(raw)
|
|
180
|
+
except json.JSONDecodeError:
|
|
181
|
+
continue
|
|
182
|
+
data = msg.get("data") if isinstance(msg, dict) else msg
|
|
183
|
+
if not isinstance(data, dict):
|
|
184
|
+
continue
|
|
185
|
+
seq = data.get("seq", 0)
|
|
186
|
+
if seq:
|
|
187
|
+
seqs.add(seq)
|
|
188
|
+
if data.get("finished") and data.get("kind") in ("final", "error"):
|
|
189
|
+
terminal = True
|
|
190
|
+
break
|
|
191
|
+
return seqs, terminal, True
|
|
192
|
+
except Exception as e:
|
|
193
|
+
log.warning("verify: failed to read nchan for run %s: %s", run_id, e)
|
|
194
|
+
return set(), False, False
|
|
221
195
|
|
|
222
|
-
|
|
196
|
+
nchan_seqs, has_terminal, read_ok = await read_nchan()
|
|
223
197
|
if has_terminal:
|
|
224
198
|
log.info("verify: run %s terminal present in nchan, all good", run_id)
|
|
225
|
-
return
|
|
199
|
+
return True
|
|
226
200
|
|
|
227
|
-
# 找出缺失的事件
|
|
228
201
|
local_seqs = {ev.get("data", {}).get("seq", 0) for ev in local_events}
|
|
229
202
|
missing_seqs = local_seqs - nchan_seqs
|
|
230
|
-
|
|
231
|
-
if not missing_seqs and has_terminal:
|
|
232
|
-
return
|
|
233
|
-
|
|
234
203
|
if not missing_seqs:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
204
|
+
missing_seqs = {
|
|
205
|
+
ev.get("data", {}).get("seq", 0)
|
|
206
|
+
for ev in local_events
|
|
207
|
+
if ev.get("data", {}).get("finished") is True
|
|
208
|
+
} - nchan_seqs
|
|
209
|
+
|
|
210
|
+
if not missing_seqs and read_ok:
|
|
211
|
+
log.error("verify: run %s has all seqs but no terminal", run_id)
|
|
212
|
+
return False
|
|
239
213
|
if not missing_seqs:
|
|
240
|
-
|
|
241
|
-
|
|
214
|
+
# 无法读取 nchan,保守重放所有事件。
|
|
215
|
+
missing_seqs = local_seqs
|
|
242
216
|
|
|
243
|
-
# 按 seq 排序重发
|
|
244
217
|
missing_events = sorted(
|
|
245
218
|
[ev for ev in local_events if ev.get("data", {}).get("seq", 0) in missing_seqs],
|
|
246
219
|
key=lambda ev: ev.get("data", {}).get("seq", 0),
|
|
247
220
|
)
|
|
221
|
+
seq_preview = [ev["data"]["seq"] for ev in missing_events[:50]]
|
|
222
|
+
suffix = "..." if len(missing_events) > 50 else ""
|
|
248
223
|
log.info(
|
|
249
|
-
"verify: run %s replaying %d missing events (seqs: %s, nchan has %d/%d)",
|
|
250
|
-
run_id, len(missing_events),
|
|
251
|
-
[ev["data"]["seq"] for ev in missing_events],
|
|
252
|
-
len(nchan_seqs), len(local_seqs),
|
|
224
|
+
"verify: run %s replaying %d missing events (seqs: %s%s, nchan has %d/%d)",
|
|
225
|
+
run_id, len(missing_events), seq_preview, suffix, len(nchan_seqs), len(local_seqs),
|
|
253
226
|
)
|
|
254
227
|
|
|
228
|
+
replay_ok = True
|
|
255
229
|
for ev in missing_events:
|
|
256
230
|
data = ev.get("data", {})
|
|
257
231
|
if data.get("finished") is True:
|
|
@@ -259,8 +233,22 @@ class ChannelClient:
|
|
|
259
233
|
else:
|
|
260
234
|
ok = await self.publish_event(run_id, ev)
|
|
261
235
|
if not ok:
|
|
236
|
+
replay_ok = False
|
|
262
237
|
log.error("verify: replay still failed for run %s seq=%s", run_id, data.get("seq"))
|
|
263
238
|
|
|
239
|
+
if not replay_ok:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
nchan_seqs, has_terminal, read_ok = await read_nchan()
|
|
243
|
+
if has_terminal:
|
|
244
|
+
log.info("verify: run %s terminal present after replay, all good", run_id)
|
|
245
|
+
return True
|
|
246
|
+
log.error(
|
|
247
|
+
"verify: run %s terminal still missing after replay (read_ok=%s, nchan has %d/%d)",
|
|
248
|
+
run_id, read_ok, len(nchan_seqs), len(local_seqs),
|
|
249
|
+
)
|
|
250
|
+
return False
|
|
251
|
+
|
|
264
252
|
async def run(self) -> None:
|
|
265
253
|
"""Main loop: WS subscriber + HTTP claim-task poller, running in parallel.
|
|
266
254
|
|
|
@@ -165,10 +165,6 @@ class TaskRunner:
|
|
|
165
165
|
|
|
166
166
|
events_iter = self._select_events(connector, mode, prompt, agent_session_id)
|
|
167
167
|
|
|
168
|
-
# pre-connect WS publisher (don't wait for first event — avoids 10s+ delay
|
|
169
|
-
# where early events accumulate and arrive as a burst, killing streaming UX)
|
|
170
|
-
asyncio.create_task(self.channel.ensure_run_pub(run_id))
|
|
171
|
-
|
|
172
168
|
# local persistence: raw event log for debugging + retry
|
|
173
169
|
log_dir = os.path.join(os.path.expanduser("~"), ".lark-agent-hub-logs")
|
|
174
170
|
os.makedirs(log_dir, exist_ok=True)
|
|
@@ -242,8 +238,13 @@ class TaskRunner:
|
|
|
242
238
|
except Exception:
|
|
243
239
|
pass
|
|
244
240
|
|
|
241
|
+
verified = False
|
|
245
242
|
if all_local:
|
|
246
|
-
await self.channel.verify_and_replay(run_id, all_local)
|
|
243
|
+
verified = await self.channel.verify_and_replay(run_id, all_local)
|
|
244
|
+
|
|
245
|
+
if not verified:
|
|
246
|
+
log.error("run %s finished locally but terminal was not confirmed in nchan", run_id)
|
|
247
|
+
return False, terminal_status, seq
|
|
247
248
|
|
|
248
249
|
log.info("run %s completed (%d events, log: %s)", run_id, seq, log_path)
|
|
249
250
|
return True, terminal_status, seq
|
|
@@ -267,8 +268,8 @@ class TaskRunner:
|
|
|
267
268
|
log_file.write(json.dumps(error_payload["data"], ensure_ascii=False) + "\n")
|
|
268
269
|
except Exception:
|
|
269
270
|
pass
|
|
270
|
-
await self.channel.publish_event_reliably(run_id, error_payload)
|
|
271
|
-
return
|
|
271
|
+
ok = await self.channel.publish_event_reliably(run_id, error_payload)
|
|
272
|
+
return ok, "error", seq
|
|
272
273
|
|
|
273
274
|
finally:
|
|
274
275
|
await self.channel.close_run_pub(run_id)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|