baserun-cli 0.1.3__tar.gz → 0.1.5__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.5}/PKG-INFO +1 -1
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/channel.py +71 -92
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/runner.py +16 -13
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/PKG-INFO +1 -1
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/pyproject.toml +1 -1
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/README.md +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/__init__.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/__init__.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/base.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/cli.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/__init__.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/base.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/bash_agent.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/claude.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/codex.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/main.py +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/SOURCES.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/dependency_links.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/entry_points.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/requires.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/top_level.txt +0 -0
- {baserun_cli-0.1.3 → baserun_cli-0.1.5}/setup.cfg +0 -0
|
@@ -96,15 +96,7 @@ class ChannelClient:
|
|
|
96
96
|
log.debug("pre-connect ws pub for run %s failed (will retry on first event): %s", run_id, e)
|
|
97
97
|
|
|
98
98
|
async def publish_event(self, run_id: str, payload: dict[str, Any]) -> bool:
|
|
99
|
-
"""Publish a run event via WebSocket (
|
|
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.
|
|
105
|
-
|
|
106
|
-
Returns True if published successfully.
|
|
107
|
-
"""
|
|
99
|
+
"""Publish a run event via WebSocket (ordered streaming path)."""
|
|
108
100
|
data = json.dumps(payload, ensure_ascii=False)
|
|
109
101
|
is_terminal = (payload.get("data", {}).get("finished") is True)
|
|
110
102
|
max_retries = 4 if is_terminal else 2
|
|
@@ -133,22 +125,6 @@ class ChannelClient:
|
|
|
133
125
|
run_id, payload.get("data", {}).get("seq"), max_retries)
|
|
134
126
|
return False
|
|
135
127
|
|
|
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
|
-
try:
|
|
141
|
-
await self._http_publish(run_id, payload)
|
|
142
|
-
return True
|
|
143
|
-
except Exception as e:
|
|
144
|
-
log.error(
|
|
145
|
-
"http publish fallback failed for run %s seq=%s: %s",
|
|
146
|
-
run_id,
|
|
147
|
-
payload.get("data", {}).get("seq"),
|
|
148
|
-
e,
|
|
149
|
-
)
|
|
150
|
-
return False
|
|
151
|
-
|
|
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
|
|
@@ -160,12 +136,6 @@ class ChannelClient:
|
|
|
160
136
|
log.info("opening ws publisher for run %s", run_id)
|
|
161
137
|
return await connect(url, ping_interval=30, open_timeout=30)
|
|
162
138
|
|
|
163
|
-
async def _http_publish(self, run_id: str, payload: dict[str, Any]) -> None:
|
|
164
|
-
"""HTTP fallback for publishing events (legacy, not used in normal flow)."""
|
|
165
|
-
url = f"{self._publish_base.rstrip('/')}/internal/run/{run_id}/publish"
|
|
166
|
-
resp = await self._http.post(url, content=json.dumps(payload, ensure_ascii=False))
|
|
167
|
-
resp.raise_for_status()
|
|
168
|
-
|
|
169
139
|
async def close_run_pub(self, run_id: str) -> None:
|
|
170
140
|
"""Close the WS publisher for a run."""
|
|
171
141
|
ws = self._run_pubs.pop(run_id, None)
|
|
@@ -175,92 +145,101 @@ class ChannelClient:
|
|
|
175
145
|
except Exception:
|
|
176
146
|
pass
|
|
177
147
|
|
|
178
|
-
async def verify_and_replay(self, run_id: str, local_events: list[dict]) ->
|
|
179
|
-
"""对比 nchan channel 实际状态与本地 events
|
|
148
|
+
async def verify_and_replay(self, run_id: str, local_events: list[dict]) -> bool:
|
|
149
|
+
"""对比 nchan channel 实际状态与本地 events,补发缺失事件。
|
|
180
150
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
3. 缺失的事件按 seq 顺序通过 WS 重新发送
|
|
184
|
-
|
|
185
|
-
这比盲目重发 failed_events 更可靠——能捕获 ws.send() 返回成功
|
|
186
|
-
但数据实际未到达 nchan 的情况(TCP buffer 问题)。
|
|
151
|
+
返回 True 表示已确认 nchan 中存在 terminal;返回 False 表示仍未确认,
|
|
152
|
+
runner 不能把该 run 记为 recently-completed。
|
|
187
153
|
"""
|
|
188
154
|
if not local_events:
|
|
189
|
-
return
|
|
155
|
+
return False
|
|
190
156
|
|
|
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
|
-
|
|
157
|
+
async def read_nchan() -> tuple[set[int], bool, bool]:
|
|
158
|
+
"""Return (seqs, has_terminal, read_ok)."""
|
|
159
|
+
url = f"{self._publish_base.rstrip('/')}/internal/run/{run_id}"
|
|
160
|
+
seqs: set[int] = set()
|
|
161
|
+
terminal = False
|
|
162
|
+
try:
|
|
163
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as c:
|
|
164
|
+
async with c.stream("GET", url, headers={"Accept": "text/event-stream"}) as resp:
|
|
165
|
+
resp.raise_for_status()
|
|
166
|
+
async for line in resp.aiter_lines():
|
|
167
|
+
if not line.startswith("data:"):
|
|
168
|
+
continue
|
|
169
|
+
raw = line[5:].strip()
|
|
170
|
+
if not raw:
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
msg = json.loads(raw)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
continue
|
|
176
|
+
data = msg.get("data") if isinstance(msg, dict) else msg
|
|
177
|
+
if not isinstance(data, dict):
|
|
178
|
+
continue
|
|
179
|
+
seq = data.get("seq", 0)
|
|
180
|
+
if seq:
|
|
181
|
+
seqs.add(seq)
|
|
182
|
+
if data.get("finished") and data.get("kind") in ("final", "error"):
|
|
183
|
+
terminal = True
|
|
184
|
+
break
|
|
185
|
+
return seqs, terminal, True
|
|
186
|
+
except Exception as e:
|
|
187
|
+
log.warning("verify: failed to read nchan for run %s: %s", run_id, e)
|
|
188
|
+
return set(), False, False
|
|
221
189
|
|
|
222
|
-
|
|
190
|
+
nchan_seqs, has_terminal, read_ok = await read_nchan()
|
|
223
191
|
if has_terminal:
|
|
224
192
|
log.info("verify: run %s terminal present in nchan, all good", run_id)
|
|
225
|
-
return
|
|
193
|
+
return True
|
|
226
194
|
|
|
227
|
-
# 找出缺失的事件
|
|
228
195
|
local_seqs = {ev.get("data", {}).get("seq", 0) for ev in local_events}
|
|
229
196
|
missing_seqs = local_seqs - nchan_seqs
|
|
230
|
-
|
|
231
|
-
if not missing_seqs and has_terminal:
|
|
232
|
-
return
|
|
233
|
-
|
|
234
197
|
if not missing_seqs:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
198
|
+
missing_seqs = {
|
|
199
|
+
ev.get("data", {}).get("seq", 0)
|
|
200
|
+
for ev in local_events
|
|
201
|
+
if ev.get("data", {}).get("finished") is True
|
|
202
|
+
} - nchan_seqs
|
|
203
|
+
|
|
204
|
+
if not missing_seqs and read_ok:
|
|
205
|
+
log.error("verify: run %s has all seqs but no terminal", run_id)
|
|
206
|
+
return False
|
|
239
207
|
if not missing_seqs:
|
|
240
|
-
|
|
241
|
-
|
|
208
|
+
# 无法读取 nchan,保守重放所有事件。
|
|
209
|
+
missing_seqs = local_seqs
|
|
242
210
|
|
|
243
|
-
# 按 seq 排序重发
|
|
244
211
|
missing_events = sorted(
|
|
245
212
|
[ev for ev in local_events if ev.get("data", {}).get("seq", 0) in missing_seqs],
|
|
246
213
|
key=lambda ev: ev.get("data", {}).get("seq", 0),
|
|
247
214
|
)
|
|
215
|
+
seq_preview = [ev["data"]["seq"] for ev in missing_events[:50]]
|
|
216
|
+
suffix = "..." if len(missing_events) > 50 else ""
|
|
248
217
|
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),
|
|
218
|
+
"verify: run %s replaying %d missing events (seqs: %s%s, nchan has %d/%d)",
|
|
219
|
+
run_id, len(missing_events), seq_preview, suffix, len(nchan_seqs), len(local_seqs),
|
|
253
220
|
)
|
|
254
221
|
|
|
222
|
+
replay_ok = True
|
|
255
223
|
for ev in missing_events:
|
|
256
224
|
data = ev.get("data", {})
|
|
257
|
-
|
|
258
|
-
ok = await self.publish_event_reliably(run_id, ev)
|
|
259
|
-
else:
|
|
260
|
-
ok = await self.publish_event(run_id, ev)
|
|
225
|
+
ok = await self.publish_event(run_id, ev)
|
|
261
226
|
if not ok:
|
|
227
|
+
replay_ok = False
|
|
262
228
|
log.error("verify: replay still failed for run %s seq=%s", run_id, data.get("seq"))
|
|
263
229
|
|
|
230
|
+
if not replay_ok:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
nchan_seqs, has_terminal, read_ok = await read_nchan()
|
|
234
|
+
if has_terminal:
|
|
235
|
+
log.info("verify: run %s terminal present after replay, all good", run_id)
|
|
236
|
+
return True
|
|
237
|
+
log.error(
|
|
238
|
+
"verify: run %s terminal still missing after replay (read_ok=%s, nchan has %d/%d)",
|
|
239
|
+
run_id, read_ok, len(nchan_seqs), len(local_seqs),
|
|
240
|
+
)
|
|
241
|
+
return False
|
|
242
|
+
|
|
264
243
|
async def run(self) -> None:
|
|
265
244
|
"""Main loop: WS subscriber + HTTP claim-task poller, running in parallel.
|
|
266
245
|
|
|
@@ -163,11 +163,12 @@ class TaskRunner:
|
|
|
163
163
|
connector = get_connector(connector_type, config)
|
|
164
164
|
log.info("run %s mode=%s connector=%s session_id=%s", run_id, mode, connector.spec.bin, agent_session_id or "new")
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
# Open the run publisher before connector starts. Otherwise a short task
|
|
167
|
+
# can finish before the run channel/subscriber is ready, leaving the
|
|
168
|
+
# server in RUNNING even though the client process completed locally.
|
|
169
|
+
await self.channel.ensure_run_pub(run_id)
|
|
167
170
|
|
|
168
|
-
|
|
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
|
+
events_iter = self._select_events(connector, mode, prompt, agent_session_id)
|
|
171
172
|
|
|
172
173
|
# local persistence: raw event log for debugging + retry
|
|
173
174
|
log_dir = os.path.join(os.path.expanduser("~"), ".lark-agent-hub-logs")
|
|
@@ -203,11 +204,8 @@ class TaskRunner:
|
|
|
203
204
|
|
|
204
205
|
log.debug("run %s event seq=%d kind=%s", run_id, seq, kind)
|
|
205
206
|
|
|
206
|
-
# publish
|
|
207
|
-
|
|
208
|
-
await self.channel.publish_event_reliably(run_id, payload)
|
|
209
|
-
else:
|
|
210
|
-
await self.channel.publish_event(run_id, payload)
|
|
207
|
+
# publish via WebSocket only
|
|
208
|
+
await self.channel.publish_event(run_id, payload)
|
|
211
209
|
|
|
212
210
|
if is_terminal:
|
|
213
211
|
saw_terminal = True
|
|
@@ -228,7 +226,7 @@ class TaskRunner:
|
|
|
228
226
|
}
|
|
229
227
|
log_file.write(json.dumps(payload["data"], ensure_ascii=False) + "\n")
|
|
230
228
|
log_file.flush()
|
|
231
|
-
await self.channel.
|
|
229
|
+
await self.channel.publish_event(run_id, payload)
|
|
232
230
|
log.info("run %s synthesized terminal (seq=%d)", run_id, seq)
|
|
233
231
|
|
|
234
232
|
# verify-and-replay:对比 nchan 实际状态,精确补发缺失事件
|
|
@@ -242,8 +240,13 @@ class TaskRunner:
|
|
|
242
240
|
except Exception:
|
|
243
241
|
pass
|
|
244
242
|
|
|
243
|
+
verified = False
|
|
245
244
|
if all_local:
|
|
246
|
-
await self.channel.verify_and_replay(run_id, all_local)
|
|
245
|
+
verified = await self.channel.verify_and_replay(run_id, all_local)
|
|
246
|
+
|
|
247
|
+
if not verified:
|
|
248
|
+
log.error("run %s finished locally but terminal was not confirmed in nchan", run_id)
|
|
249
|
+
return False, terminal_status, seq
|
|
247
250
|
|
|
248
251
|
log.info("run %s completed (%d events, log: %s)", run_id, seq, log_path)
|
|
249
252
|
return True, terminal_status, seq
|
|
@@ -267,8 +270,8 @@ class TaskRunner:
|
|
|
267
270
|
log_file.write(json.dumps(error_payload["data"], ensure_ascii=False) + "\n")
|
|
268
271
|
except Exception:
|
|
269
272
|
pass
|
|
270
|
-
await self.channel.
|
|
271
|
-
return
|
|
273
|
+
ok = await self.channel.publish_event(run_id, error_payload)
|
|
274
|
+
return ok, "error", seq
|
|
272
275
|
|
|
273
276
|
finally:
|
|
274
277
|
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
|