baserun-cli 0.1.2__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.
Files changed (22) hide show
  1. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/PKG-INFO +1 -1
  2. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/channel.py +102 -110
  3. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/runner.py +8 -7
  4. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli.egg-info/PKG-INFO +1 -1
  5. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/pyproject.toml +1 -1
  6. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/README.md +0 -0
  7. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/__init__.py +0 -0
  8. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/_vendored/__init__.py +0 -0
  9. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/_vendored/base.py +0 -0
  10. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/_vendored/cli.py +0 -0
  11. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/__init__.py +0 -0
  12. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/base.py +0 -0
  13. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/bash_agent.py +0 -0
  14. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/claude.py +0 -0
  15. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/_vendored/parsers/codex.py +0 -0
  16. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli/main.py +0 -0
  17. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli.egg-info/SOURCES.txt +0 -0
  18. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli.egg-info/dependency_links.txt +0 -0
  19. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli.egg-info/entry_points.txt +0 -0
  20. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli.egg-info/requires.txt +0 -0
  21. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/baserun_cli.egg-info/top_level.txt +0 -0
  22. {baserun_cli-0.1.2 → baserun_cli-0.1.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: baserun-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -82,73 +82,49 @@ class ChannelClient:
82
82
  }
83
83
 
84
84
  async def ensure_run_pub(self, run_id: str) -> None:
85
- """Pre-connect the WS publisher for a run (fire-and-forget).
85
+ """Deprecated no-op.
86
86
 
87
- Called at run start so the WS handshake completes before the first
88
- event arrives. If this fails, publish_event will retry on demand.
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
- if run_id in self._run_pubs:
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 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.
94
+ """Publish a run event via HTTP and require an acknowledged response.
105
95
 
106
- Returns True if published successfully.
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.error(
145
- "http publish fallback failed for run %s seq=%s: %s",
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,87 +151,103 @@ 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]) -> None:
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
- 这比盲目重发 failed_events 更可靠——能捕获 ws.send() 返回成功
186
- 但数据实际未到达 nchan 的情况(TCP buffer 问题)。
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
- # 读取 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()
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
- # terminal 已在 nchan = 全部到齐
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
- # 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
-
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
- log.info("verify: run %s no missing events", run_id)
241
- return
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
- ok = await self.publish_event(run_id, ev)
230
+ data = ev.get("data", {})
231
+ if data.get("finished") is True:
232
+ ok = await self.publish_event_reliably(run_id, ev)
233
+ else:
234
+ ok = await self.publish_event(run_id, ev)
257
235
  if not ok:
258
- log.error("verify: replay still failed for run %s seq=%s", run_id, ev["data"]["seq"])
236
+ replay_ok = False
237
+ log.error("verify: replay still failed for run %s seq=%s", run_id, data.get("seq"))
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
259
251
 
260
252
  async def run(self) -> None:
261
253
  """Main loop: WS subscriber + HTTP claim-task poller, running in parallel.
@@ -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 True, "error", seq
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: baserun-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
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.2"
3
+ version = "0.1.4"
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