playmaker-cli 0.6.0__tar.gz → 0.7.1__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 (35) hide show
  1. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/CHANGELOG.md +52 -0
  2. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/PKG-INFO +4 -3
  3. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/README.md +2 -1
  4. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/pyproject.toml +1 -1
  5. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/cli.py +14 -8
  6. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/quotas.py +64 -41
  7. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/state.py +47 -3
  8. playmaker_cli-0.7.1/tests/test_batch.py +129 -0
  9. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_quotas_antigravity.py +109 -0
  10. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_state.py +78 -0
  11. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/.gitignore +0 -0
  12. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/LICENSE +0 -0
  13. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/skills/playmaker-coach/SKILL.md +0 -0
  14. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/__init__.py +0 -0
  15. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/__main__.py +0 -0
  16. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/__init__.py +0 -0
  17. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/agy.py +0 -0
  18. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/base.py +0 -0
  19. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/claude.py +0 -0
  20. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/codex.py +0 -0
  21. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/gemini.py +0 -0
  22. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/opencode.py +0 -0
  23. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/config.py +0 -0
  24. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/notify.py +0 -0
  25. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/registry.py +0 -0
  26. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/watcher.py +0 -0
  27. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/__init__.py +0 -0
  28. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_agy.py +0 -0
  29. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_claude.py +0 -0
  30. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_codex.py +0 -0
  31. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_opencode.py +0 -0
  32. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_permissions.py +0 -0
  33. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_quotas_zai.py +0 -0
  34. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_registry.py +0 -0
  35. {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_skill.py +0 -0
@@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.1] - 2026-08-18
9
+
10
+ ### Fixed
11
+
12
+ - **The Antigravity quota probe knocked on every port on the machine, and one
13
+ of them answered in TLS.** `playmaker quotas` had shown
14
+ `Antigravity (agy) error: BadStatusLine` for a week: the daemon lookup ran
15
+ `lsof -p <pid> -iTCP -sTCP:LISTEN` without `-a`, and lsof ORs its selectors
16
+ unless told otherwise — so "agy's listening sockets" was actually every
17
+ LISTEN socket on the box (hence the old `!= 5432` Postgres carve-out). The
18
+ probe then POSTed the quota RPC to Steam, chromedriver, a Logi plugin, …
19
+ until a TLS-only listener sorted below agy's ports answered a plaintext
20
+ request with a TLS alert record. urllib raises that as
21
+ `http.client.BadStatusLine`, which is not an `OSError`, so it escaped the
22
+ per-port `except`, escaped `antigravity_probe`, and the aggregator recorded
23
+ the whole provider as an error — with the working local daemon two ports
24
+ away. lsof now gets `-a` (one call, all pids), the `pgrep` for `agy` is
25
+ anchored so playmaker's own dispatches with `playmaker-agy-*.log` in their
26
+ arguments don't count as the daemon, anything a port says only disqualifies
27
+ that port, and any failure on the local path falls back to the remote
28
+ Gemini-only probe rather than to `error`. The refresh also stopped spending
29
+ ~30 s in timeouts on strangers' ports (1.3 s now).
30
+
31
+ ## [0.7.0] - 2026-08-05
32
+
33
+ ### Fixed
34
+
35
+ - **A batch label is a name you reuse, so the summary now belongs to the
36
+ fan-out rather than the label.** `--batch dashboard` twice used to mean one
37
+ batch forever: the second fan-out never pinged at all, because exactly-once
38
+ was guarded by an `O_EXCL` sentinel in `logs/` that nothing ever removed —
39
+ and with the sentinel gone it would have counted yesterday's sessions too,
40
+ `4/4 done · codex ✓ · claude ✓ · codex ✓ · agy ✓` for a fan-out of two, with
41
+ the stale outputs pasted into the combined `/tmp` file. The claim now lives in
42
+ `state.db` as a `batch_notified` column: `list_batch` returns the sessions not
43
+ yet reported, and the finisher that wins the claim releases the label for the
44
+ next fan-out. Cross-process safety is unchanged — the loser's `UPDATE` finds
45
+ its set already claimed, rolls back and stays quiet.
46
+
47
+ - **`playmaker kill` drains the batch it empties.** `killed` is terminal like
48
+ `done` and `failed`, but `kill` never finalised, so killing a fan-out's last
49
+ live session left nobody to notice the batch had drained: no summary, ever.
50
+
51
+ ### Changed
52
+
53
+ - **The combined batch file moved out of `/tmp`.** It is what a batch
54
+ notification opens on click, so it now lands with the outputs it quotes —
55
+ `~/.playmaker/outputs/batch-<label>.md` rather than
56
+ `/tmp/playmaker-batch-<label>.md`, a predictable name in a world-writable
57
+ directory built from a label the user chose. It also meant the test suite
58
+ wrote outside `tmp_path`.
59
+
8
60
  ## [0.6.0] - 2026-07-27
9
61
 
10
62
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: playmaker-cli
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity and opencode sub-agents in parallel.
5
5
  Project-URL: Homepage, https://github.com/vladsafedev/playmaker
6
6
  Project-URL: Repository, https://github.com/vladsafedev/playmaker
@@ -263,7 +263,8 @@ bookkeeping in between.
263
263
  ├── state.db SQLite — sessions, status, pids, models, output paths
264
264
  ├── config.toml
265
265
  ├── agents/ optional agent profile markdown (claude.md, codex.md, agy.md…)
266
- ├── outputs/ final output per session — .md, or .json if the agent returned JSON
266
+ ├── outputs/ final output per session — .md, or .json if the agent returned JSON,
267
+ │ plus batch-<label>.md, every output in one fan-out combined
267
268
  ├── logs/ subprocess stdout for detached runs
268
269
  ├── opencode/ pointer per opencode session (its transcript lives in SQLite)
269
270
  └── quotas.json latest capacity snapshot
@@ -236,7 +236,8 @@ bookkeeping in between.
236
236
  ├── state.db SQLite — sessions, status, pids, models, output paths
237
237
  ├── config.toml
238
238
  ├── agents/ optional agent profile markdown (claude.md, codex.md, agy.md…)
239
- ├── outputs/ final output per session — .md, or .json if the agent returned JSON
239
+ ├── outputs/ final output per session — .md, or .json if the agent returned JSON,
240
+ │ plus batch-<label>.md, every output in one fan-out combined
240
241
  ├── logs/ subprocess stdout for detached runs
241
242
  ├── opencode/ pointer per opencode session (its transcript lives in SQLite)
242
243
  └── quotas.json latest capacity snapshot
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "playmaker-cli"
3
- version = "0.6.0"
3
+ version = "0.7.1"
4
4
  description = "Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity and opencode sub-agents in parallel."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -311,7 +311,9 @@ def _maybe_finalize_batch(batch_id: str | None) -> None:
311
311
  """Fire one summary notification when every session in a batch is terminal.
312
312
 
313
313
  Cross-process safe: each detached dispatch calls this on completion; only
314
- the finisher that wins the O_EXCL sentinel actually notifies.
314
+ the finisher that wins the claim in state.db actually notifies. The claim
315
+ also releases the label — dispatching `--batch dash` again tomorrow is a
316
+ new fan-out with its own summary, not a repeat of this one.
315
317
  """
316
318
  if not batch_id:
317
319
  return
@@ -322,11 +324,7 @@ def _maybe_finalize_batch(batch_id: str | None) -> None:
322
324
  if any(s["status"] not in terminal for s in siblings):
323
325
  return # not the last to finish
324
326
 
325
- sentinel = state.LOGS_DIR / f".batch-{_batch_slug(batch_id)}.done"
326
- try:
327
- fd = os.open(str(sentinel), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
328
- os.close(fd)
329
- except FileExistsError:
327
+ if not state.claim_batch([s["id"] for s in siblings]):
330
328
  return # another finisher already fired the summary
331
329
 
332
330
  ok = [s for s in siblings if s["status"] == "done"]
@@ -342,7 +340,12 @@ def _maybe_finalize_batch(batch_id: str | None) -> None:
342
340
 
343
341
 
344
342
  def _render_batch_file(batch_id: str, siblings: list) -> Path | None:
345
- """Write a combined markdown view of all batch outputs to /tmp for review."""
343
+ """Write a combined markdown view of all batch outputs, for review.
344
+
345
+ It lands in `outputs/` beside the per-session files it quotes: it is what
346
+ the batch notification opens on click, and its name is derived from a label
347
+ the user chose, which is not something to hand to a shared /tmp.
348
+ """
346
349
  lines = [f"# playmaker batch: {batch_id}", ""]
347
350
  for s in siblings:
348
351
  lines.append(f"## {s['agent']} — {s['status']} ({s['id'][:8]})")
@@ -355,7 +358,7 @@ def _render_batch_file(batch_id: str, siblings: list) -> Path | None:
355
358
  except OSError:
356
359
  content = "_(no output captured — see `playmaker logs " + s["id"][:8] + "`)_"
357
360
  lines += ["", content or "_(empty)_", ""]
358
- target = Path("/tmp") / f"playmaker-batch-{_batch_slug(batch_id)}.md"
361
+ target = state.OUTPUTS_DIR / f"batch-{_batch_slug(batch_id)}.md"
359
362
  try:
360
363
  target.write_text("\n".join(lines), encoding="utf-8")
361
364
  return target
@@ -791,6 +794,9 @@ def kill(
791
794
  state.update_session(
792
795
  row["id"], status="killed", finished_at=state.now_iso(), exit_code=143
793
796
  )
797
+ # killed is terminal too: if this was the batch's last live session, nobody
798
+ # else is left to notice the fan-out drained.
799
+ _maybe_finalize_batch(row.get("batch_id"))
794
800
  console.print(f"[magenta]killed[/magenta] {row['id']} (pid {pid})")
795
801
 
796
802
 
@@ -612,39 +612,48 @@ def gemini_probe() -> dict:
612
612
  _ANTIGRAVITY_QUOTA_SUMMARY_PATH = (
613
613
  "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary"
614
614
  )
615
- _ANTIGRAVITY_PROC_NAMES = ("agy", "language_server")
615
+ # `pgrep -f` regexes over the full command line. The agy one is anchored so a
616
+ # process whose *arguments* merely mention agy — playmaker's own dispatch, with
617
+ # its `--log-file .../playmaker-agy-*.log` — doesn't count as the daemon.
618
+ _ANTIGRAVITY_PROC_PATTERNS = (r"(^|/)agy( |$)", r"language_server")
616
619
 
617
620
 
618
621
  def _antigravity_daemon_ports() -> list[int]:
619
622
  """Local TCP ports that a running agy/language_server daemon is listening on."""
620
623
  pids: set[int] = set()
621
- for name in _ANTIGRAVITY_PROC_NAMES:
624
+ for pattern in _ANTIGRAVITY_PROC_PATTERNS:
622
625
  try:
623
626
  proc = subprocess.run(
624
- ["pgrep", "-f", name], capture_output=True, text=True, timeout=5
627
+ ["pgrep", "-f", pattern], capture_output=True, text=True, timeout=5
625
628
  )
626
629
  except (OSError, subprocess.SubprocessError):
627
630
  continue
628
631
  for line in proc.stdout.split():
629
632
  if line.isdigit():
630
633
  pids.add(int(line))
634
+ if not pids:
635
+ return []
636
+ # `-a` ANDs lsof's selectors. Without it they are ORed, and the listing is
637
+ # every LISTEN socket on the machine — Steam, chromedriver, whatever else
638
+ # sits on 127.0.0.1 — so the probe went knocking on strangers' ports.
639
+ try:
640
+ proc = subprocess.run(
641
+ [
642
+ "lsof", "-nP", "-a",
643
+ "-p", ",".join(str(pid) for pid in sorted(pids)),
644
+ "-iTCP", "-sTCP:LISTEN",
645
+ ],
646
+ capture_output=True,
647
+ text=True,
648
+ timeout=5,
649
+ )
650
+ except (OSError, subprocess.SubprocessError):
651
+ return []
631
652
  ports: set[int] = set()
632
- for pid in pids:
633
- try:
634
- proc = subprocess.run(
635
- ["lsof", "-nP", "-p", str(pid), "-iTCP", "-sTCP:LISTEN"],
636
- capture_output=True,
637
- text=True,
638
- timeout=5,
639
- )
640
- except (OSError, subprocess.SubprocessError):
641
- continue
642
- for line in proc.stdout.splitlines():
643
- m = re.search(r"127\.0\.0\.1:(\d+)", line)
644
- if m:
645
- port = int(m.group(1))
646
- if port != 5432: # skip an unrelated Postgres LISTEN
647
- ports.add(port)
653
+ for line in proc.stdout.splitlines():
654
+ m = re.search(r"127\.0\.0\.1:(\d+)", line)
655
+ if m:
656
+ ports.add(int(m.group(1)))
648
657
  return sorted(ports)
649
658
 
650
659
 
@@ -664,12 +673,19 @@ def _antigravity_local_summary(ports: list[int], timeout: float = 5.0) -> dict |
664
673
  method="POST",
665
674
  headers={"Content-Type": "application/json", "Connect-Protocol-Version": "1"},
666
675
  )
676
+ # Whatever a port says, it only ever disqualifies that port. Not
677
+ # every refusal is an OSError: a TLS-only listener answers a
678
+ # plaintext POST with a TLS alert record, which urllib raises as
679
+ # http.client.BadStatusLine — an HTTPException that used to escape
680
+ # this loop and sink the whole probe.
667
681
  try:
668
682
  with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
669
683
  data = json.loads(resp.read().decode("utf-8"))
670
- except (urllib.error.URLError, ssl.SSLError, OSError, json.JSONDecodeError):
684
+ if not isinstance(data, dict):
685
+ continue
686
+ payload = data.get("response") or data.get("summary") or data
687
+ except Exception:
671
688
  continue
672
- payload = data.get("response") or data.get("summary") or data
673
689
  if isinstance(payload, dict) and payload.get("groups"):
674
690
  return payload
675
691
  return None
@@ -756,29 +772,36 @@ def antigravity_probe() -> dict:
756
772
  back to the OAuth retrieveUserQuota, which only surfaces coarse Gemini daily
757
773
  buckets (source: "remote") — the Claude/GPT windows are simply not available
758
774
  to a plain OAuth token. The local path needs a running agy/CodexBar daemon.
775
+
776
+ The local path is a bonus, so it degrades to remote rather than to an
777
+ error: whatever goes wrong there — a stray listener, a daemon mid-start,
778
+ a payload we don't recognise — the user still gets a quota row.
759
779
  """
760
- ports = _antigravity_daemon_ports()
761
- if ports:
762
- payload = _antigravity_local_summary(ports)
780
+ windows: list[dict] = []
781
+ try:
782
+ ports = _antigravity_daemon_ports()
783
+ payload = _antigravity_local_summary(ports) if ports else None
763
784
  if payload:
764
785
  windows = _antigravity_windows_from_summary(payload)
765
- if windows:
766
- out = {
767
- "status": "ok",
768
- "account_email": None,
769
- "tier": None,
770
- "windows": windows,
771
- "source": "local",
772
- }
773
- # Enrich email/tier from the cheap OAuth loadCodeAssist call;
774
- # never let that failure sink the rich local windows.
775
- try:
776
- meta = _antigravity_account_meta()
777
- out["account_email"] = meta.get("email")
778
- out["tier"] = meta.get("tier")
779
- except Exception:
780
- pass
781
- return out
786
+ except Exception:
787
+ windows = []
788
+ if windows:
789
+ out = {
790
+ "status": "ok",
791
+ "account_email": None,
792
+ "tier": None,
793
+ "windows": windows,
794
+ "source": "local",
795
+ }
796
+ # Enrich email/tier from the cheap OAuth loadCodeAssist call;
797
+ # never let that failure sink the rich local windows.
798
+ try:
799
+ meta = _antigravity_account_meta()
800
+ out["account_email"] = meta.get("email")
801
+ out["tier"] = meta.get("tier")
802
+ except Exception:
803
+ pass
804
+ return out
782
805
 
783
806
  result = _google_code_assist_probe(_ANTIGRAVITY_CLOUDCODE_BASE, "ANTIGRAVITY")
784
807
  result["source"] = "remote"
@@ -37,7 +37,8 @@ CREATE TABLE IF NOT EXISTS sessions (
37
37
  parent_id TEXT,
38
38
  pid INTEGER,
39
39
  model TEXT,
40
- batch_id TEXT
40
+ batch_id TEXT,
41
+ batch_notified INTEGER
41
42
  );
42
43
  CREATE INDEX IF NOT EXISTS idx_status ON sessions(status);
43
44
  CREATE INDEX IF NOT EXISTS idx_agent ON sessions(agent);
@@ -78,6 +79,16 @@ def init_db() -> None:
78
79
  c.execute("ALTER TABLE sessions ADD COLUMN model TEXT")
79
80
  if "batch_id" not in existing_cols:
80
81
  c.execute("ALTER TABLE sessions ADD COLUMN batch_id TEXT")
82
+ if "batch_notified" not in existing_cols:
83
+ c.execute("ALTER TABLE sessions ADD COLUMN batch_notified INTEGER")
84
+ # Only on the upgrade run: sessions that finished before this column
85
+ # existed are history — their summary already fired, or never will.
86
+ # Left unclaimed they would land inside the next summary for the
87
+ # same label. Sessions still in flight keep theirs.
88
+ c.execute(
89
+ "UPDATE sessions SET batch_notified = 1 "
90
+ "WHERE status IN ('done', 'failed', 'killed')"
91
+ )
81
92
  # Created after migration so it works on pre-existing tables too.
82
93
  c.execute("CREATE INDEX IF NOT EXISTS idx_batch ON sessions(batch_id)")
83
94
  c.commit()
@@ -121,15 +132,48 @@ def insert_session(
121
132
 
122
133
 
123
134
  def list_batch(batch_id: str) -> list[dict[str, Any]]:
124
- """All sessions in a dispatch batch, oldest first."""
135
+ """The not-yet-summarised sessions of a dispatch batch, oldest first.
136
+
137
+ A batch label is a name the user reuses, not a one-off id, so it is the
138
+ unreported sessions — not every session ever tagged — that make up "the
139
+ batch" a summary is about.
140
+ """
125
141
  with connect() as c:
126
142
  rows = c.execute(
127
- "SELECT * FROM sessions WHERE batch_id = ? ORDER BY started_at ASC",
143
+ "SELECT * FROM sessions "
144
+ "WHERE batch_id = ? AND batch_notified IS NULL "
145
+ "ORDER BY started_at ASC",
128
146
  (batch_id,),
129
147
  ).fetchall()
130
148
  return [dict(r) for r in rows]
131
149
 
132
150
 
151
+ def claim_batch(session_ids: list[str]) -> bool:
152
+ """Mark these sessions summarised; True if this caller is the one that did.
153
+
154
+ Every detached dispatch finalises its batch as it lands, so two of them can
155
+ see the same drained fan-out. SQLite serialises the write, so the loser
156
+ finds part of its set already claimed; it rolls back and stays quiet rather
157
+ than reporting sessions somebody else has already reported. Sessions
158
+ dispatched into the label after the read are left unclaimed, and become the
159
+ next fan-out.
160
+ """
161
+ if not session_ids:
162
+ return False
163
+ placeholders = ", ".join("?" for _ in session_ids)
164
+ with connect() as c:
165
+ cur = c.execute(
166
+ f"UPDATE sessions SET batch_notified = 1 "
167
+ f"WHERE id IN ({placeholders}) AND batch_notified IS NULL",
168
+ session_ids,
169
+ )
170
+ if cur.rowcount != len(session_ids):
171
+ c.rollback()
172
+ return False
173
+ c.commit()
174
+ return True
175
+
176
+
133
177
  def update_session(session_id: str, **fields: Any) -> None:
134
178
  if not fields:
135
179
  return
@@ -0,0 +1,129 @@
1
+ """One `--batch` label, many fan-outs: which sessions each summary covers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ import playmaker.state as state
13
+ from playmaker import cli
14
+
15
+
16
+ @pytest.fixture
17
+ def db(monkeypatch, tmp_path: Path):
18
+ """Point the module-level paths at a throwaway home and initialise it."""
19
+ home = tmp_path / ".playmaker"
20
+ monkeypatch.setattr(state, "PLAYMAKER_HOME", home)
21
+ monkeypatch.setattr(state, "DB_PATH", home / "state.db")
22
+ monkeypatch.setattr(state, "LOGS_DIR", home / "logs")
23
+ monkeypatch.setattr(state, "OUTPUTS_DIR", home / "outputs")
24
+ monkeypatch.setattr(state, "AGENTS_DIR", home / "agents")
25
+ state.init_db()
26
+ return home
27
+
28
+
29
+ @pytest.fixture
30
+ def summaries(monkeypatch) -> list[str]:
31
+ """Capture the message of every notification the batch code fires."""
32
+ fired: list[str] = []
33
+ monkeypatch.setattr(
34
+ cli.notify, "notify", lambda title, message, **kwargs: fired.append(message)
35
+ )
36
+ return fired
37
+
38
+
39
+ def _finish(session_id: str, status: str = "done") -> None:
40
+ state.update_session(session_id, status=status, finished_at=state.now_iso())
41
+
42
+
43
+ def _fan_out(label: str, agents: list[str]) -> list[str]:
44
+ """Dispatch a batch, then let each member finish in order."""
45
+ ids = [state.insert_session(agent=a, prompt="p", cwd="/repo", batch_id=label) for a in agents]
46
+ for sid in ids:
47
+ _finish(sid)
48
+ cli._maybe_finalize_batch(label)
49
+ return ids
50
+
51
+
52
+ def test_the_summary_waits_for_the_whole_fan_out(db: Path, summaries: list[str]) -> None:
53
+ first = state.insert_session(agent="codex", prompt="p", cwd="/repo", batch_id="dash")
54
+ state.insert_session(agent="claude", prompt="p", cwd="/repo", batch_id="dash")
55
+
56
+ _finish(first)
57
+ cli._maybe_finalize_batch("dash")
58
+
59
+ assert summaries == []
60
+
61
+
62
+ def test_the_summary_fires_exactly_once_per_fan_out(db: Path, summaries: list[str]) -> None:
63
+ # Every detached dispatch calls the finaliser as it lands, so the last two
64
+ # to finish can both see an all-terminal batch.
65
+ _fan_out("dash", ["codex", "claude"])
66
+
67
+ cli._maybe_finalize_batch("dash")
68
+
69
+ assert summaries == ["2/2 done · codex ✓ · claude ✓"]
70
+
71
+
72
+ def test_a_reused_batch_label_summarises_each_fan_out_separately(
73
+ db: Path, summaries: list[str]
74
+ ) -> None:
75
+ # The README teaches `B=dashboard` as a shell variable you keep around, so
76
+ # the same label comes back tomorrow with a different set of agents.
77
+ _fan_out("dash", ["codex", "claude"])
78
+
79
+ _fan_out("dash", ["agy", "opencode"])
80
+
81
+ assert summaries == [
82
+ "2/2 done · codex ✓ · claude ✓",
83
+ "2/2 done · agy ✓ · opencode ✓",
84
+ ]
85
+
86
+
87
+ def test_killing_the_last_running_member_still_drains_the_batch(
88
+ db: Path, summaries: list[str], monkeypatch
89
+ ) -> None:
90
+ # `kill` is a terminal state like any other; if it does not finalise, the
91
+ # batch it emptied never reports at all.
92
+ done = state.insert_session(agent="codex", prompt="p", cwd="/repo", batch_id="dash")
93
+ doomed = state.insert_session(agent="claude", prompt="p", cwd="/repo", batch_id="dash")
94
+ _finish(done)
95
+ cli._maybe_finalize_batch("dash")
96
+ state.update_session(doomed, status="running", pid=4242)
97
+ monkeypatch.setattr(cli.os, "getpgid", lambda pid: pid)
98
+ monkeypatch.setattr(cli.os, "killpg", lambda pgid, sig: None)
99
+
100
+ cli.kill(doomed)
101
+
102
+ assert summaries == ["1/2 done · codex ✓ · claude ✗"]
103
+
104
+
105
+ def test_the_combined_batch_file_lands_next_to_the_outputs_it_quotes(db: Path, monkeypatch) -> None:
106
+ # It is the file the notification click opens, so it belongs with the rest
107
+ # of a run's artefacts rather than in a world-writable /tmp under a name
108
+ # anyone can predict — and a hard-coded /tmp escapes tmp_path in tests.
109
+ opened: list[str | None] = []
110
+ monkeypatch.setattr(
111
+ cli.notify,
112
+ "notify",
113
+ lambda title, message, **kwargs: opened.append(kwargs.get("open_path")),
114
+ )
115
+
116
+ _fan_out("dash", ["codex", "claude"])
117
+
118
+ combined = db / "outputs" / "batch-dash.md"
119
+ assert opened == [str(combined)]
120
+ assert combined.read_text(encoding="utf-8").count("\n## ") == 2
121
+
122
+
123
+ def test_a_batch_of_one_that_failed_reports_the_failure(db: Path, summaries: list[str]) -> None:
124
+ sid = state.insert_session(agent="agy", prompt="p", cwd="/repo", batch_id="solo")
125
+
126
+ _finish(sid, status="failed")
127
+ cli._maybe_finalize_batch("solo")
128
+
129
+ assert summaries == ["0/1 done · agy ✗"]
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import http.client
4
+ import json
3
5
  import sys
4
6
  from pathlib import Path
5
7
 
@@ -98,3 +100,110 @@ def test_local_summary_returns_none_when_no_port_answers(monkeypatch) -> None:
98
100
  monkeypatch.setattr(quotas.urllib.request, "urlopen", boom)
99
101
 
100
102
  assert quotas._antigravity_local_summary([49999]) is None
103
+
104
+
105
+ class _Response:
106
+ def __init__(self, body: bytes) -> None:
107
+ self._body = body
108
+
109
+ def read(self) -> bytes:
110
+ return self._body
111
+
112
+ def __enter__(self):
113
+ return self
114
+
115
+ def __exit__(self, *exc) -> None:
116
+ return None
117
+
118
+
119
+ def test_local_summary_skips_a_port_that_does_not_speak_http(monkeypatch) -> None:
120
+ # A TLS-only listener answers a plaintext POST with a TLS alert record;
121
+ # urllib surfaces that as http.client.BadStatusLine, which is not an
122
+ # OSError. It disqualifies that port — it must not sink the probe.
123
+ tls_alert = "\x15\x03\x03\x00\x02\x022"
124
+
125
+ def urlopen(req, timeout=None, context=None):
126
+ if ":50652/" in req.full_url:
127
+ if req.full_url.startswith("https"):
128
+ raise TimeoutError("The read operation timed out")
129
+ raise http.client.BadStatusLine(tls_alert)
130
+ return _Response(json.dumps({"response": _SUMMARY}).encode())
131
+
132
+ monkeypatch.setattr(quotas.urllib.request, "urlopen", urlopen)
133
+
134
+ assert quotas._antigravity_local_summary([50652, 51802]) == _SUMMARY
135
+
136
+
137
+ def test_local_summary_ignores_a_port_that_answers_non_object_json(monkeypatch) -> None:
138
+ def urlopen(req, timeout=None, context=None):
139
+ return _Response(b"[]")
140
+
141
+ monkeypatch.setattr(quotas.urllib.request, "urlopen", urlopen)
142
+
143
+ assert quotas._antigravity_local_summary([49999]) is None
144
+
145
+
146
+ def test_daemon_ports_lists_only_the_daemon_sockets(monkeypatch) -> None:
147
+ # lsof ORs its selectors unless told otherwise, so `-p PID -iTCP` without
148
+ # `-a` is every LISTEN socket on the machine — that is how the probe ended
149
+ # up POSTing to Steam and chromedriver. The lookup must AND them and ask
150
+ # for exactly the pids pgrep found.
151
+ calls: list[list[str]] = []
152
+
153
+ class _Proc:
154
+ def __init__(self, stdout: str) -> None:
155
+ self.stdout = stdout
156
+ self.returncode = 0
157
+
158
+ def run(cmd, **kwargs):
159
+ calls.append(cmd)
160
+ if cmd[0] == "pgrep":
161
+ return _Proc("8577\n" if "agy" in cmd[-1] else "")
162
+ assert cmd[0] == "lsof"
163
+ return _Proc(
164
+ "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n"
165
+ "agy 8577 me 10u IPv4 0x1 0t0 TCP 127.0.0.1:51802 (LISTEN)\n"
166
+ "agy 8577 me 11u IPv4 0x2 0t0 TCP 127.0.0.1:51803 (LISTEN)\n"
167
+ )
168
+
169
+ monkeypatch.setattr(quotas.subprocess, "run", run)
170
+
171
+ assert quotas._antigravity_daemon_ports() == [51802, 51803]
172
+ lsof = [c for c in calls if c[0] == "lsof"]
173
+ assert len(lsof) == 1
174
+ assert "-a" in lsof[0]
175
+ assert lsof[0][lsof[0].index("-p") + 1] == "8577"
176
+
177
+
178
+ def test_daemon_ports_is_empty_without_a_daemon(monkeypatch) -> None:
179
+ calls: list[list[str]] = []
180
+
181
+ class _Proc:
182
+ stdout = ""
183
+ returncode = 1
184
+
185
+ def run(cmd, **kwargs):
186
+ calls.append(cmd)
187
+ return _Proc()
188
+
189
+ monkeypatch.setattr(quotas.subprocess, "run", run)
190
+
191
+ assert quotas._antigravity_daemon_ports() == []
192
+ # No pids → no lsof at all (an unfiltered lsof is the whole-machine listing).
193
+ assert all(c[0] == "pgrep" for c in calls)
194
+
195
+
196
+ def test_antigravity_probe_falls_back_to_remote_when_the_local_path_blows_up(
197
+ monkeypatch,
198
+ ) -> None:
199
+ def boom():
200
+ raise http.client.BadStatusLine("\x15\x03\x03\x00\x02\x022")
201
+
202
+ monkeypatch.setattr(quotas, "_antigravity_daemon_ports", boom)
203
+ remote = {"status": "ok", "windows": [{"name": "Flash", "pct_left": 100}]}
204
+ monkeypatch.setattr(quotas, "_google_code_assist_probe", lambda base_url, ide_type: remote)
205
+
206
+ result = quotas.antigravity_probe()
207
+
208
+ assert result["status"] == "ok"
209
+ assert result["source"] == "remote"
@@ -117,6 +117,44 @@ def test_list_batch_returns_only_that_batch_oldest_first(db: Path) -> None:
117
117
  assert state.list_batch("nothing") == []
118
118
 
119
119
 
120
+ def test_list_batch_forgets_a_fan_out_that_was_already_summarised(db: Path) -> None:
121
+ # A batch label is reusable, so "the batch" means the fan-out that has not
122
+ # been reported yet — not every session ever tagged with that label.
123
+ first = state.insert_session(agent="codex", prompt="a", cwd="/r", batch_id="dash")
124
+ state.claim_batch([first])
125
+
126
+ second = state.insert_session(agent="agy", prompt="b", cwd="/r", batch_id="dash")
127
+
128
+ assert [r["id"] for r in state.list_batch("dash")] == [second]
129
+
130
+
131
+ def test_claim_batch_lets_exactly_one_caller_win(db: Path) -> None:
132
+ # Each detached dispatch finalises its own batch as it lands, so two
133
+ # processes can race to report the same fan-out.
134
+ ids = [
135
+ state.insert_session(agent=a, prompt="p", cwd="/r", batch_id="dash")
136
+ for a in ("codex", "agy")
137
+ ]
138
+
139
+ assert state.claim_batch(ids) is True
140
+ assert state.claim_batch(ids) is False
141
+
142
+
143
+ def test_claim_batch_refuses_a_set_that_is_already_partly_claimed(db: Path) -> None:
144
+ # A partial overlap means someone else's summary covered these sessions;
145
+ # reporting them again would double-count.
146
+ first = state.insert_session(agent="codex", prompt="a", cwd="/r", batch_id="dash")
147
+ second = state.insert_session(agent="agy", prompt="b", cwd="/r", batch_id="dash")
148
+ state.claim_batch([first])
149
+
150
+ assert state.claim_batch([first, second]) is False
151
+ assert [r["id"] for r in state.list_batch("dash")] == [second]
152
+
153
+
154
+ def test_claim_batch_of_nothing_is_not_a_win(db: Path) -> None:
155
+ assert state.claim_batch([]) is False
156
+
157
+
120
158
  def test_init_db_migrates_a_database_without_model_and_batch_columns(
121
159
  monkeypatch, tmp_path: Path
122
160
  ) -> None:
@@ -155,3 +193,43 @@ def test_init_db_migrates_a_database_without_model_and_batch_columns(
155
193
  assert row["model"] is None
156
194
  assert row["batch_id"] is None
157
195
  assert state.insert_session(agent="agy", prompt="p", cwd="/r", batch_id="b")
196
+
197
+
198
+ def test_init_db_treats_finished_sessions_as_already_summarised(
199
+ monkeypatch, tmp_path: Path
200
+ ) -> None:
201
+ # Reusing a batch label predates this column, so on the upgrade run every
202
+ # finished session is history: sweeping it into the next fan-out's summary
203
+ # would report last week's agents alongside today's.
204
+ home = tmp_path / ".playmaker"
205
+ home.mkdir()
206
+ monkeypatch.setattr(state, "PLAYMAKER_HOME", home)
207
+ monkeypatch.setattr(state, "DB_PATH", home / "state.db")
208
+ monkeypatch.setattr(state, "LOGS_DIR", home / "logs")
209
+ monkeypatch.setattr(state, "OUTPUTS_DIR", home / "outputs")
210
+ monkeypatch.setattr(state, "AGENTS_DIR", home / "agents")
211
+ legacy = sqlite3.connect(home / "state.db")
212
+ legacy.execute(
213
+ """
214
+ CREATE TABLE sessions (
215
+ id TEXT PRIMARY KEY, agent TEXT NOT NULL, agent_session_id TEXT,
216
+ prompt TEXT NOT NULL, cwd TEXT NOT NULL, files TEXT,
217
+ status TEXT NOT NULL, started_at TEXT NOT NULL, finished_at TEXT,
218
+ exit_code INTEGER, cost_usd REAL, duration_seconds REAL,
219
+ output_path TEXT, session_file_path TEXT, parent_id TEXT, pid INTEGER,
220
+ model TEXT, batch_id TEXT
221
+ )
222
+ """
223
+ )
224
+ legacy.executemany(
225
+ "INSERT INTO sessions (id, agent, prompt, cwd, status, started_at, batch_id) VALUES "
226
+ "(?, 'codex', 'p', '/repo', ?, '2020-01-01T00:00:00', 'dash')",
227
+ [("old-done", "done"), ("old-running", "running")],
228
+ )
229
+ legacy.commit()
230
+ legacy.close()
231
+
232
+ state.init_db()
233
+
234
+ # A batch still in flight when the upgrade lands keeps its summary.
235
+ assert [r["id"] for r in state.list_batch("dash")] == ["old-running"]
File without changes
File without changes