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.
Files changed (22) hide show
  1. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/PKG-INFO +1 -1
  2. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/channel.py +71 -92
  3. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/runner.py +16 -13
  4. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/PKG-INFO +1 -1
  5. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/pyproject.toml +1 -1
  6. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/README.md +0 -0
  7. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/__init__.py +0 -0
  8. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/__init__.py +0 -0
  9. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/base.py +0 -0
  10. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/cli.py +0 -0
  11. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/__init__.py +0 -0
  12. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/base.py +0 -0
  13. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/bash_agent.py +0 -0
  14. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/claude.py +0 -0
  15. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/_vendored/parsers/codex.py +0 -0
  16. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli/main.py +0 -0
  17. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/SOURCES.txt +0 -0
  18. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/dependency_links.txt +0 -0
  19. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/entry_points.txt +0 -0
  20. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/requires.txt +0 -0
  21. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/baserun_cli.egg-info/top_level.txt +0 -0
  22. {baserun_cli-0.1.3 → baserun_cli-0.1.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: baserun-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: BaseRun agent-side daemon (connects to nchan, spawns CLI agents, publishes run events)
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -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 (guarantees ordering within a run).
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]) -> None:
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
- 1. 订阅 nchan channel,读取已有消息的 seq 集合
182
- 2. 对比本地 JSONL seq 集合
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
- # 读取 nchan channel 当前状态
192
- url = f"{self._publish_base.rstrip('/')}/internal/run/{run_id}"
193
- nchan_seqs: set[int] = set()
194
- has_terminal = False
195
- try:
196
- async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as c:
197
- async with c.stream("GET", url, headers={"Accept": "text/event-stream"}) as resp:
198
- async for line in resp.aiter_lines():
199
- if not line.startswith("data:"):
200
- continue
201
- raw = line[5:].strip()
202
- if not raw:
203
- continue
204
- try:
205
- msg = json.loads(raw)
206
- except json.JSONDecodeError:
207
- continue
208
- data = msg.get("data") if isinstance(msg, dict) else msg
209
- if not isinstance(data, dict):
210
- continue
211
- seq = data.get("seq", 0)
212
- if seq:
213
- nchan_seqs.add(seq)
214
- if data.get("finished") and data.get("kind") in ("final", "error"):
215
- has_terminal = True
216
- break # terminal found = all events present
217
- except Exception as e:
218
- log.warning("verify: failed to read nchan for run %s: %s", run_id, e)
219
- # nchan 读取失败,保守重发所有事件
220
- nchan_seqs = set()
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
- # terminal 已在 nchan = 全部到齐
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
- # nchan 有所有非 terminal 事件但缺 terminal → 重发 terminal
236
- missing_seqs = {ev.get("data", {}).get("seq", 0) for ev in local_events
237
- if ev.get("data", {}).get("finished") is True} - nchan_seqs
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
- log.info("verify: run %s no missing events", run_id)
241
- return
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
- if data.get("finished") is True:
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
- events_iter = self._select_events(connector, mode, prompt, agent_session_id)
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
- # 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
+ 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
- if is_terminal:
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.publish_event_reliably(run_id, payload)
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.publish_event_reliably(run_id, error_payload)
271
- return True, "error", seq
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: baserun-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: BaseRun agent-side daemon (connects to nchan, spawns CLI agents, publishes run events)
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "baserun-cli"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "BaseRun agent-side daemon (connects to nchan, spawns CLI agents, publishes run events)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
File without changes
File without changes