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.
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/CHANGELOG.md +52 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/PKG-INFO +4 -3
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/README.md +2 -1
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/pyproject.toml +1 -1
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/cli.py +14 -8
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/quotas.py +64 -41
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/state.py +47 -3
- playmaker_cli-0.7.1/tests/test_batch.py +129 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_quotas_antigravity.py +109 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_state.py +78 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/.gitignore +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/LICENSE +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/skills/playmaker-coach/SKILL.md +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/__init__.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/__main__.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/__init__.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/agy.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/base.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/claude.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/codex.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/gemini.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/agents/opencode.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/config.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/notify.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/registry.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/src/playmaker/watcher.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/__init__.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_agy.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_claude.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_codex.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_opencode.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_permissions.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_quotas_zai.py +0 -0
- {playmaker_cli-0.6.0 → playmaker_cli-0.7.1}/tests/test_registry.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: playmaker-cli
|
|
3
|
-
Version: 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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
624
|
+
for pattern in _ANTIGRAVITY_PROC_PATTERNS:
|
|
622
625
|
try:
|
|
623
626
|
proc = subprocess.run(
|
|
624
|
-
["pgrep", "-f",
|
|
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
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|