machina-cli 0.4.2__tar.gz → 0.4.3__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 (93) hide show
  1. {machina_cli-0.4.2 → machina_cli-0.4.3}/CHANGELOG.md +7 -0
  2. {machina_cli-0.4.2 → machina_cli-0.4.3}/PKG-INFO +1 -1
  3. {machina_cli-0.4.2 → machina_cli-0.4.3}/pyproject.toml +1 -1
  4. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/org.py +84 -20
  5. {machina_cli-0.4.2 → machina_cli-0.4.3}/tests/test_org_usage.py +40 -7
  6. {machina_cli-0.4.2 → machina_cli-0.4.3}/.github/workflows/ci.yml +0 -0
  7. {machina_cli-0.4.2 → machina_cli-0.4.3}/.github/workflows/release.yml +0 -0
  8. {machina_cli-0.4.2 → machina_cli-0.4.3}/.gitignore +0 -0
  9. {machina_cli-0.4.2 → machina_cli-0.4.3}/CLAUDE.md +0 -0
  10. {machina_cli-0.4.2 → machina_cli-0.4.3}/README.md +0 -0
  11. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs/agentic-harness-loop.md +0 -0
  12. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs/credentials.md +0 -0
  13. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs/harness-loop-kit/PLAYBOOK-SCORECARD.md +0 -0
  14. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs/harness-loop-kit/README.md +0 -0
  15. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs/harness-loop-kit/VALIDATION.md +0 -0
  16. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs/harness-loop-kit/context-verify.py +0 -0
  17. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs/harness-loop-kit/provision.py +0 -0
  18. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs/loop-tools-connector.py +0 -0
  19. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/.gitignore +0 -0
  20. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/.vitepress/config.mts +0 -0
  21. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/.vitepress/theme/custom.css +0 -0
  22. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/.vitepress/theme/index.ts +0 -0
  23. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/README.md +0 -0
  24. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/agent.md +0 -0
  25. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/config.md +0 -0
  26. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/connector.md +0 -0
  27. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/credentials.md +0 -0
  28. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/deploy.md +0 -0
  29. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/document.md +0 -0
  30. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/execution.md +0 -0
  31. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/factory.md +0 -0
  32. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/login.md +0 -0
  33. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/loop.md +0 -0
  34. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/mapping.md +0 -0
  35. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/org.md +0 -0
  36. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/project.md +0 -0
  37. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/prompt.md +0 -0
  38. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/skills.md +0 -0
  39. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/sports.md +0 -0
  40. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/template.md +0 -0
  41. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/update.md +0 -0
  42. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/commands/workflow.md +0 -0
  43. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/guide/authentication.md +0 -0
  44. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/guide/configuration.md +0 -0
  45. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/guide/installation.md +0 -0
  46. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/guide/quickstart.md +0 -0
  47. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/guide/repl.md +0 -0
  48. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/guide/running.md +0 -0
  49. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/index.md +0 -0
  50. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/package-lock.json +0 -0
  51. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/package.json +0 -0
  52. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/public/favicon.ico +0 -0
  53. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/public/machina-logo-dark.png +0 -0
  54. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/public/machina-logo-light.png +0 -0
  55. {machina_cli-0.4.2 → machina_cli-0.4.3}/docs-site/vercel.json +0 -0
  56. {machina_cli-0.4.2 → machina_cli-0.4.3}/install.ps1 +0 -0
  57. {machina_cli-0.4.2 → machina_cli-0.4.3}/install.sh +0 -0
  58. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/__init__.py +0 -0
  59. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/browser_auth.py +0 -0
  60. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/client.py +0 -0
  61. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/__init__.py +0 -0
  62. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/agent.py +0 -0
  63. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/auth.py +0 -0
  64. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/config_cmd.py +0 -0
  65. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/connect.py +0 -0
  66. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/connector.py +0 -0
  67. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/credentials.py +0 -0
  68. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/deploy.py +0 -0
  69. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/document.py +0 -0
  70. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/execution.py +0 -0
  71. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/factory.py +0 -0
  72. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/loop.py +0 -0
  73. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/mapping.py +0 -0
  74. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/mcp.py +0 -0
  75. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/project.py +0 -0
  76. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/prompt.py +0 -0
  77. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/skills.py +0 -0
  78. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/sports.py +0 -0
  79. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/template.py +0 -0
  80. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/commands/workflow.py +0 -0
  81. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/config.py +0 -0
  82. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/factory_client.py +0 -0
  83. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/loop_client.py +0 -0
  84. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/main.py +0 -0
  85. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/models/__init__.py +0 -0
  86. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/project_client.py +0 -0
  87. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/repl.py +0 -0
  88. {machina_cli-0.4.2 → machina_cli-0.4.3}/src/machina_cli/updater.py +0 -0
  89. {machina_cli-0.4.2 → machina_cli-0.4.3}/tests/__init__.py +0 -0
  90. {machina_cli-0.4.2 → machina_cli-0.4.3}/tests/test_cli_output.py +0 -0
  91. {machina_cli-0.4.2 → machina_cli-0.4.3}/tests/test_connect.py +0 -0
  92. {machina_cli-0.4.2 → machina_cli-0.4.3}/tests/test_mcp_url.py +0 -0
  93. {machina_cli-0.4.2 → machina_cli-0.4.3}/tests/test_sports_integration.py +0 -0
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to machina-cli are documented here.
4
4
 
5
+ ## [0.4.3] - 2026-06-30
6
+
7
+ ### Fixed
8
+ - **`machina org usage` no longer silently drops a project on a transient error.** The big collections (e.g. `sbot-prd`) return intermittent `500`s under load; the previous build caught that as "unreachable" and skipped the project, producing a complete-looking but wildly undercounted total (e.g. 73M instead of ~440M when SBOT Prd dropped). Now:
9
+ - Each project session + execution page is **retried** on transient failures (`500`/timeout) with backoff, so the heavy projects scan through.
10
+ - A reachable project whose scan still fails is reported as **errored** and the run is flagged **INCOMPLETE / PARTIAL** in red (with the affected projects listed) — distinct from a benign **skip** of an undeployed/no-access project. A partial total is never presented as if it were whole. `--json` gains `incomplete`, `projects_errored`, `projects_skipped`.
11
+
5
12
  ## [0.4.2] - 2026-06-29
6
13
 
7
14
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: machina-cli
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: CLI for the Machina AI Agent platform
5
5
  Project-URL: Homepage, https://machina.gg
6
6
  Project-URL: Repository, https://github.com/machina-sports/machina-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "machina-cli"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "CLI for the Machina AI Agent platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -143,6 +143,14 @@ def use(
143
143
  # we paginate and sum client-side. A frozen upper date bound keeps pagination stable
144
144
  # as new executions arrive mid-scan.
145
145
  _TOKEN_FILTER_KEY = "execution_tokens.total_tokens"
146
+ _SCAN_RETRIES = 3 # big collections (e.g. sbot-prd) 500 intermittently under load; retry recovers
147
+ _RETRY_BACKOFF = 0.8
148
+
149
+
150
+ class _ScanIncomplete(Exception):
151
+ """A reachable project's scan kept failing after retries. Its tokens are missing,
152
+ so the org total must be flagged PARTIAL — never silently dropped (that would turn
153
+ a transient 500 on the biggest project into a plausible-looking but wrong total)."""
146
154
 
147
155
 
148
156
  def _resolve_org_projects(org_id: str) -> list[tuple[str, str]]:
@@ -180,10 +188,30 @@ def _sum_project_tokens(project_id: str, token_filter: dict, page_size: int, on_
180
188
  ProjectClient) on auth/connection failure — callers should catch and skip.
181
189
  `on_page(count, total_tokens)` is invoked after each page for progress display.
182
190
  """
191
+ import time
183
192
  from collections import defaultdict
184
193
  from email.utils import parsedate_to_datetime
185
194
 
186
- client = ProjectClient(project_id)
195
+ def _retry(fn, *, tries, incomplete_on_fail):
196
+ # Retry transient failures: ProjectClient raises SystemExit on a 500, httpx
197
+ # raises on a timeout. On give-up, either re-raise (caller skips an undeployed
198
+ # project) or raise _ScanIncomplete (caller flags a reachable project's partial
199
+ # scan loudly instead of silently dropping it).
200
+ last = None
201
+ for attempt in range(tries):
202
+ try:
203
+ return fn()
204
+ except (SystemExit, httpx.HTTPError) as exc:
205
+ last = exc
206
+ if attempt == tries - 1:
207
+ if incomplete_on_fail:
208
+ raise _ScanIncomplete(project_id) from last
209
+ raise
210
+ time.sleep(_RETRY_BACKOFF * (attempt + 1))
211
+
212
+ # Project session. A persistent failure means undeployed/no-access — let it
213
+ # propagate so the caller records a benign skip.
214
+ client = _retry(lambda: ProjectClient(project_id), tries=2, incomplete_on_fail=False)
187
215
  proj = {"prompt": 0, "completion": 0, "total": 0, "count": 0}
188
216
  by_agent: dict = defaultdict(lambda: {"prompt": 0, "completion": 0, "total": 0, "count": 0})
189
217
  by_day: dict = defaultdict(lambda: {"total": 0, "count": 0})
@@ -192,9 +220,20 @@ def _sum_project_tokens(project_id: str, token_filter: dict, page_size: int, on_
192
220
  max_pages = 400 # safety net; the `total` check below normally breaks first
193
221
  truncated = False
194
222
  while True:
195
- res = client.post(
196
- "execution/agent-search",
197
- {"filters": token_filter, "page": page, "page_size": page_size, "sorters": ["_id", -1]},
223
+ # The search on a reachable project: a persistent failure (e.g. sbot-prd's
224
+ # heavy collection 500ing) means the scan is INCOMPLETE, not skippable.
225
+ res = _retry(
226
+ lambda: client.post(
227
+ "execution/agent-search",
228
+ {
229
+ "filters": token_filter,
230
+ "page": page,
231
+ "page_size": page_size,
232
+ "sorters": ["_id", -1],
233
+ },
234
+ ),
235
+ tries=_SCAN_RETRIES,
236
+ incomplete_on_fail=True,
198
237
  )
199
238
  rows = res.get("data", [])
200
239
  if not rows:
@@ -297,7 +336,8 @@ def usage(
297
336
  by_project: dict = {}
298
337
  by_agent: dict = defaultdict(lambda: {"prompt": 0, "completion": 0, "total": 0, "count": 0})
299
338
  by_day: dict = defaultdict(lambda: {"total": 0, "count": 0})
300
- unreachable: list[str] = []
339
+ skipped: list[str] = [] # undeployed / no access — expected, excluded
340
+ errored: list[str] = [] # reachable but scan failed after retries — total is PARTIAL
301
341
  truncated_projects: list[str] = []
302
342
 
303
343
  status_cm = (
@@ -308,7 +348,7 @@ def usage(
308
348
  n = len(targets)
309
349
  # _quiet_clients silences the core/project HTTP clients: undeployed or
310
350
  # inaccessible projects print a red error and raise SystemExit, which we catch
311
- # and record as unreachable — the leaked error would just be noise mid-scan.
351
+ # and record as skipped/errored — the leaked error would just be noise mid-scan.
312
352
  with status_cm as status, _quiet_clients():
313
353
  for i, (pid, pname) in enumerate(targets, 1):
314
354
 
@@ -322,11 +362,14 @@ def usage(
322
362
  proj, p_agents, p_days, truncated = _sum_project_tokens(
323
363
  pid, token_filter, page_size, on_page=_progress
324
364
  )
325
- # SystemExit: auth/lookup failure (undeployed/inaccessible project).
326
- # httpx.HTTPError: the client-api is slow, so a page can time out — skip
327
- # that project rather than aborting the whole multi-project scan.
365
+ except _ScanIncomplete:
366
+ # reachable project whose search kept failing — its tokens are MISSING,
367
+ # so the org total is partial (flagged loudly below), not silently dropped.
368
+ errored.append(pname)
369
+ continue
328
370
  except (SystemExit, httpx.HTTPError):
329
- unreachable.append(pname)
371
+ # login/lookup failed after retries -> undeployed or no access; expected.
372
+ skipped.append(pname)
330
373
  continue
331
374
  if proj["count"] == 0:
332
375
  continue
@@ -351,19 +394,27 @@ def usage(
351
394
  "by_project": by_project,
352
395
  "by_agent": dict(sorted(by_agent.items(), key=lambda kv: -kv[1]["total"])),
353
396
  "by_day": dict(sorted(by_day.items())),
354
- "projects_unreachable": unreachable,
397
+ "incomplete": bool(errored),
398
+ "projects_errored": errored,
399
+ "projects_skipped": skipped,
355
400
  "projects_truncated": truncated_projects,
356
401
  }
357
402
  console.print_json(_json.dumps(payload, default=str))
358
403
  return
359
404
 
360
405
  if grand["count"] == 0:
361
- console.print(
362
- f"[yellow]No token-bearing agent executions in the last {days} day(s) "
363
- f"for organization {org_id}.[/yellow]"
364
- )
365
- if unreachable:
366
- console.print(f"[dim]Unreachable projects: {', '.join(unreachable)}[/dim]")
406
+ if errored:
407
+ console.print(
408
+ f"[bold red]⚠ {len(errored)} project(s) failed to scan: "
409
+ f"{', '.join(errored)} — re-run to retry (results would be partial).[/bold red]"
410
+ )
411
+ else:
412
+ console.print(
413
+ f"[yellow]No token-bearing agent executions in the last {days} day(s) "
414
+ f"for organization {org_id}.[/yellow]"
415
+ )
416
+ if skipped:
417
+ console.print(f"[dim]Skipped (undeployed/no access): {', '.join(skipped)}[/dim]")
367
418
  return
368
419
 
369
420
  total = grand["total"]
@@ -373,11 +424,19 @@ def usage(
373
424
 
374
425
  from rich.panel import Panel
375
426
 
427
+ if errored:
428
+ console.print(
429
+ f"[bold red]⚠ INCOMPLETE — {len(errored)} reachable project(s) failed to scan "
430
+ f"and are NOT in the totals below:[/bold red] {', '.join(errored)}\n"
431
+ f"[red]The numbers below are PARTIAL. Re-run to retry those projects.[/red]\n"
432
+ )
433
+ total_label = "Total tokens (PARTIAL):" if errored else "Total tokens:"
434
+
376
435
  console.print(
377
436
  Panel.fit(
378
437
  f"[bold]Organization:[/bold] {org_id}\n"
379
438
  f"[bold]Window:[/bold] last {days}d ({date_from[:10]} → {date_to[:10]})\n"
380
- f"[bold]Total tokens:[/bold] {total:,}\n"
439
+ f"[bold]{total_label}[/bold] {total:,}\n"
381
440
  f"[bold] prompt:[/bold] {grand['prompt']:,} ({prompt_pct:.1f}%) "
382
441
  f"[bold]completion:[/bold] {grand['completion']:,} ({completion_pct:.1f}%)\n"
383
442
  f"[bold]Executions:[/bold] {grand['count']:,} "
@@ -427,8 +486,13 @@ def usage(
427
486
  dt.add_row(day, f"{agg['count']:,}", f"{agg['total']:,}")
428
487
  console.print(dt)
429
488
 
430
- if unreachable:
431
- console.print(f"\n[dim]Skipped (unreachable/undeployed): {', '.join(unreachable)}[/dim]")
489
+ if errored:
490
+ console.print(
491
+ f"\n[bold red]⚠ Excluded (scan failed): {', '.join(errored)} — totals are PARTIAL; "
492
+ "re-run to retry.[/bold red]"
493
+ )
494
+ if skipped:
495
+ console.print(f"[dim]Skipped (undeployed/no access): {', '.join(skipped)}[/dim]")
432
496
  if truncated_projects:
433
497
  console.print(
434
498
  f"[yellow]Note:[/yellow] hit the page cap for {', '.join(truncated_projects)} — "
@@ -155,33 +155,66 @@ def test_usage_resolves_projects_for_org():
155
155
  assert payload["totals"]["total"] == _TOTAL
156
156
 
157
157
 
158
- def test_usage_skips_unreachable_project_without_failing():
159
- bad = MagicMock(side_effect=SystemExit(1)) # ProjectClient() raises -> project skipped
158
+ def test_usage_skips_undeployed_project_as_benign():
159
+ # ProjectClient() (session login) fails persistently -> undeployed/no access ->
160
+ # benign skip, NOT marked incomplete.
161
+ bad = MagicMock(side_effect=SystemExit(1))
160
162
  with (
161
163
  patch("machina_cli.commands.org.get_config", return_value=None),
164
+ patch("time.sleep", lambda *a: None), # don't wait on retry backoff
162
165
  patch("machina_cli.commands.org.ProjectClient", bad),
163
166
  ):
164
167
  result = runner.invoke(app, ["org", "usage", "--project", "p1", "--json"])
165
168
  assert result.exit_code == 0, result.output
166
169
  payload = json.loads(result.output)
167
170
  assert payload["totals"]["count"] == 0
168
- assert payload["projects_unreachable"] == ["p1"]
171
+ assert payload["projects_skipped"] == ["p1"]
172
+ assert payload["projects_errored"] == []
173
+ assert payload["incomplete"] is False
169
174
 
170
175
 
171
- def test_usage_skips_project_on_network_error():
172
- # the client-api is slow; a page can time out (httpx.HTTPError) — skip the
173
- # project rather than aborting the whole multi-project scan
176
+ def test_usage_flags_incomplete_when_reachable_project_scan_fails():
177
+ # session opens fine (reachable) but the search keeps 500ing/timing out -> the
178
+ # project is ERRORED and the total must be flagged PARTIAL, never silently dropped.
174
179
  client = MagicMock()
175
180
  client.return_value.post.side_effect = httpx.ReadTimeout("slow")
176
181
  with (
177
182
  patch("machina_cli.commands.org.get_config", return_value=None),
183
+ patch("time.sleep", lambda *a: None),
178
184
  patch("machina_cli.commands.org.ProjectClient", client),
179
185
  ):
180
186
  result = runner.invoke(app, ["org", "usage", "--project", "p1", "--json"])
181
187
  assert result.exit_code == 0, result.output
182
188
  payload = json.loads(result.output)
183
189
  assert payload["totals"]["count"] == 0
184
- assert payload["projects_unreachable"] == ["p1"]
190
+ assert payload["projects_errored"] == ["p1"]
191
+ assert payload["projects_skipped"] == []
192
+ assert payload["incomplete"] is True
193
+
194
+
195
+ def test_usage_retries_then_succeeds():
196
+ # transient failures (sbot-prd-style 500s) recover on retry -> project IS counted
197
+ client = MagicMock()
198
+ calls = {"n": 0}
199
+
200
+ def _post(path, payload=None):
201
+ calls["n"] += 1
202
+ if calls["n"] <= 2: # fail the first two attempts, succeed on the third
203
+ raise httpx.ReadTimeout("transient 500")
204
+ return {"data": _ROWS, "total_documents": len(_ROWS), "status": True}
205
+
206
+ client.return_value.post.side_effect = _post
207
+ with (
208
+ patch("machina_cli.commands.org.get_config", return_value=None),
209
+ patch("time.sleep", lambda *a: None),
210
+ patch("machina_cli.commands.org.ProjectClient", client),
211
+ ):
212
+ result = runner.invoke(app, ["org", "usage", "--project", "p1", "--json"])
213
+ assert result.exit_code == 0, result.output
214
+ payload = json.loads(result.output)
215
+ assert payload["totals"]["total"] == _TOTAL # recovered, fully counted
216
+ assert payload["incomplete"] is False
217
+ assert payload["projects_errored"] == []
185
218
 
186
219
 
187
220
  def test_usage_no_org_no_project_errors():
File without changes
File without changes
File without changes
File without changes
File without changes