playmaker-cli 0.6.0__tar.gz → 0.7.0__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.0}/CHANGELOG.md +29 -0
  2. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/PKG-INFO +3 -2
  3. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/README.md +2 -1
  4. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/pyproject.toml +1 -1
  5. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/cli.py +14 -8
  6. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/state.py +47 -3
  7. playmaker_cli-0.7.0/tests/test_batch.py +129 -0
  8. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_state.py +78 -0
  9. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/.gitignore +0 -0
  10. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/LICENSE +0 -0
  11. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/skills/playmaker-coach/SKILL.md +0 -0
  12. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/__init__.py +0 -0
  13. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/__main__.py +0 -0
  14. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/agents/__init__.py +0 -0
  15. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/agents/agy.py +0 -0
  16. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/agents/base.py +0 -0
  17. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/agents/claude.py +0 -0
  18. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/agents/codex.py +0 -0
  19. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/agents/gemini.py +0 -0
  20. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/agents/opencode.py +0 -0
  21. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/config.py +0 -0
  22. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/notify.py +0 -0
  23. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/quotas.py +0 -0
  24. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/registry.py +0 -0
  25. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/src/playmaker/watcher.py +0 -0
  26. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/__init__.py +0 -0
  27. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_agy.py +0 -0
  28. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_claude.py +0 -0
  29. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_codex.py +0 -0
  30. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_opencode.py +0 -0
  31. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_permissions.py +0 -0
  32. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_quotas_antigravity.py +0 -0
  33. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_quotas_zai.py +0 -0
  34. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_registry.py +0 -0
  35. {playmaker_cli-0.6.0 → playmaker_cli-0.7.0}/tests/test_skill.py +0 -0
@@ -5,6 +5,35 @@ 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.0] - 2026-08-05
9
+
10
+ ### Fixed
11
+
12
+ - **A batch label is a name you reuse, so the summary now belongs to the
13
+ fan-out rather than the label.** `--batch dashboard` twice used to mean one
14
+ batch forever: the second fan-out never pinged at all, because exactly-once
15
+ was guarded by an `O_EXCL` sentinel in `logs/` that nothing ever removed —
16
+ and with the sentinel gone it would have counted yesterday's sessions too,
17
+ `4/4 done · codex ✓ · claude ✓ · codex ✓ · agy ✓` for a fan-out of two, with
18
+ the stale outputs pasted into the combined `/tmp` file. The claim now lives in
19
+ `state.db` as a `batch_notified` column: `list_batch` returns the sessions not
20
+ yet reported, and the finisher that wins the claim releases the label for the
21
+ next fan-out. Cross-process safety is unchanged — the loser's `UPDATE` finds
22
+ its set already claimed, rolls back and stays quiet.
23
+
24
+ - **`playmaker kill` drains the batch it empties.** `killed` is terminal like
25
+ `done` and `failed`, but `kill` never finalised, so killing a fan-out's last
26
+ live session left nobody to notice the batch had drained: no summary, ever.
27
+
28
+ ### Changed
29
+
30
+ - **The combined batch file moved out of `/tmp`.** It is what a batch
31
+ notification opens on click, so it now lands with the outputs it quotes —
32
+ `~/.playmaker/outputs/batch-<label>.md` rather than
33
+ `/tmp/playmaker-batch-<label>.md`, a predictable name in a world-writable
34
+ directory built from a label the user chose. It also meant the test suite
35
+ wrote outside `tmp_path`.
36
+
8
37
  ## [0.6.0] - 2026-07-27
9
38
 
10
39
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: playmaker-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
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.0"
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
 
@@ -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 ✗"]
@@ -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