From 6f8d685c688b3c549cb0f3bc25a5abc3082a35b6 Mon Sep 17 00:00:00 2001 From: memtomem <118508877+memtomem@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:36:28 +0900 Subject: [PATCH 1/3] Freeze released isolated sessions and project markers (#271) The Sessions and project family of the persisted-contracts D rows. tests/fixtures/persisted/sessions// holds what v0.1.2, v0.2.0 and v0.3.0 wrote with their own CLIs. freeze.py runs the release's `syncmill init` (the marker stays untracked and ignored, syncmill.toml is committed), then `session open codex --no-attach` against a fake interactive codex in a real tmux. A tmux on the fake PATH execs the real one with a private socket and no config, so the user's server is never touched. - project: syncmill.toml and the marker directory's project.json and .gitignore, stored as marker.json and marker.gitignore: from v0.2.0 init's .gitignore names project.json, so Git would ignore the marker under its own name. Its 0600 mode is asserted and recorded. - open, checkpointed (one checkpoint run artifact), closed; - creating, closing, recovery-required: an exit on entry to the tmux launch, an exit right after the closing document, and a cleanup that reports failure, from a .pth hook loaded for the run only. Workspaces are not frozen: they are Git repositories holding absolute paths. The test rebinds each session document's four paths and fingerprint and the checkpoint manifest's root and fingerprint, with exact counts, and asserts every other field unchanged; the tmux name re-derives. tmux is never launched: the store's probe, commands and attachment and the CLI's gates are patched. It reads: - session isolated-list (live from the probe, staleness in the stored fingerprint's version, open sessions only), render_session; - load's path binding: a document under another workspace root, with another tmux name, a worktree outside its run root or another repository is skipped by list and refused by checkpoint; - attach-isolated (not running, a pane elsewhere, the one right pane), refused from every non-open state; checkpoint without its worktree; close of every state with the workspace gone (each ends closed at a pinned revision; a closed one is returned unchanged); - the checkpoint artifact through runs list/show, diff, conversations show/list/close, continue (refused: no provider session), recover (skipped), apply, validate_applicable, discard and prune (pending kept, removed once discarded), and the TUI's render_artifact and _read_artifact_result; - project_id with the released marker untracked (its id), tracked or symlinked (the path hash), load_project_config of the released config, and initialize_project falling back to the marker. 14 injected regressions in isolated_session.py, project.py and artifacts.py each fail an intended assertion. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/persisted-contracts.md | 2 +- tests/fixtures/persisted/sessions/freeze.py | 320 ++++++++++ .../persisted/sessions/v0.1.2/bindings.json | 72 +++ .../manifest.json | 30 + .../result.json | 7 + .../winner.patch | 8 + .../71ca0b08-1bf9-4947-aa90-d86420aa8173.json | 22 + .../manifest.json | 30 + .../result.json | 7 + .../winner.patch | 8 + .../b56d6a0a-a9f0-4f07-8629-b611bb2f76ea.json | 22 + .../e207d0db-7b0e-4e62-8158-a10c870480b0.json | 20 + .../73852047-5d3b-404f-868a-ce8f003bf911.json | 20 + .../c5542802-fb6b-44e2-ac1d-7fcce5135a27.json | 20 + .../sessions/v0.1.2/project/marker.gitignore | 1 + .../sessions/v0.1.2/project/marker.json | 3 + .../sessions/v0.1.2/project/syncmill.toml | 10 + .../335de61e-1fa2-4532-b5ed-57f5f05c0633.json | 20 + .../persisted/sessions/v0.2.0/bindings.json | 72 +++ .../manifest.json | 30 + .../result.json | 7 + .../winner.patch | 8 + .../b77edfa7-844b-4356-82fc-813f62701ab1.json | 22 + .../manifest.json | 30 + .../result.json | 7 + .../winner.patch | 8 + .../74f2b6e8-d34b-4223-b0b8-81ba8a984566.json | 22 + .../85004bd7-9141-46ad-9899-8f658b95b3a8.json | 20 + .../160fca14-4e8b-4881-ae81-6e7b8ce73c67.json | 20 + .../23127f27-a1cc-4212-9e14-c672948975a5.json | 20 + .../sessions/v0.2.0/project/marker.gitignore | 2 + .../sessions/v0.2.0/project/marker.json | 3 + .../sessions/v0.2.0/project/syncmill.toml | 10 + .../fcc8f978-f4c2-46cd-936e-ecc4398de4ac.json | 20 + .../persisted/sessions/v0.3.0/bindings.json | 72 +++ .../manifest.json | 30 + .../result.json | 7 + .../winner.patch | 8 + .../2be035d6-c596-4a41-89e9-899f58d83405.json | 22 + .../manifest.json | 30 + .../result.json | 7 + .../winner.patch | 8 + .../5d175919-72c9-4c03-a994-e8a2e91650aa.json | 22 + .../0928a925-a3d4-4707-98f7-1d5b1214234c.json | 20 + .../3f0564ec-577d-4af7-8377-2c0588ded520.json | 20 + .../dc058eaf-be75-4c09-bdad-c0c1fff90ce1.json | 20 + .../sessions/v0.3.0/project/marker.gitignore | 2 + .../sessions/v0.3.0/project/marker.json | 3 + .../sessions/v0.3.0/project/syncmill.toml | 10 + .../e4a9c0f5-5819-4afb-a208-a2d8e1cf7475.json | 20 + tests/test_frozen_sessions.py | 557 ++++++++++++++++++ 51 files changed, 1780 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/persisted/sessions/freeze.py create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/bindings.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/manifest.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/result.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/winner.patch create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/checkpointed/sessions/71ca0b08-1bf9-4947-aa90-d86420aa8173.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/manifest.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/result.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/winner.patch create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/closed/sessions/b56d6a0a-a9f0-4f07-8629-b611bb2f76ea.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/closing/sessions/e207d0db-7b0e-4e62-8158-a10c870480b0.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/creating/sessions/73852047-5d3b-404f-868a-ce8f003bf911.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/open/sessions/c5542802-fb6b-44e2-ac1d-7fcce5135a27.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/project/marker.gitignore create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/project/marker.json create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/project/syncmill.toml create mode 100644 tests/fixtures/persisted/sessions/v0.1.2/recovery-required/sessions/335de61e-1fa2-4532-b5ed-57f5f05c0633.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/bindings.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/manifest.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/result.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/winner.patch create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/checkpointed/sessions/b77edfa7-844b-4356-82fc-813f62701ab1.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/manifest.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/result.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/winner.patch create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/closed/sessions/74f2b6e8-d34b-4223-b0b8-81ba8a984566.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/closing/sessions/85004bd7-9141-46ad-9899-8f658b95b3a8.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/creating/sessions/160fca14-4e8b-4881-ae81-6e7b8ce73c67.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/open/sessions/23127f27-a1cc-4212-9e14-c672948975a5.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/project/marker.gitignore create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/project/marker.json create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/project/syncmill.toml create mode 100644 tests/fixtures/persisted/sessions/v0.2.0/recovery-required/sessions/fcc8f978-f4c2-46cd-936e-ecc4398de4ac.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/bindings.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/manifest.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/result.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/winner.patch create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/checkpointed/sessions/2be035d6-c596-4a41-89e9-899f58d83405.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/manifest.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/result.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/winner.patch create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/closed/sessions/5d175919-72c9-4c03-a994-e8a2e91650aa.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/closing/sessions/0928a925-a3d4-4707-98f7-1d5b1214234c.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/creating/sessions/3f0564ec-577d-4af7-8377-2c0588ded520.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/open/sessions/dc058eaf-be75-4c09-bdad-c0c1fff90ce1.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/project/marker.gitignore create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/project/marker.json create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/project/syncmill.toml create mode 100644 tests/fixtures/persisted/sessions/v0.3.0/recovery-required/sessions/e4a9c0f5-5819-4afb-a208-a2d8e1cf7475.json create mode 100644 tests/test_frozen_sessions.py diff --git a/docs/architecture/persisted-contracts.md b/docs/architecture/persisted-contracts.md index fc8a5b3..2c97ed9 100644 --- a/docs/architecture/persisted-contracts.md +++ b/docs/architecture/persisted-contracts.md @@ -54,7 +54,7 @@ A golden over the current writer does not protect these. A reader can stop accep | Artifacts | run `manifest.json`, `result.json`, `winner.patch`, apply receipts (`applications/*.json`), in every state a standalone `run` leaves them (room-, session- and workflow-origin artifacts are frozen with those families) | `runs list` / `show` / `prune`, `conversations list` / `show`, `diff`, `apply`, `recover` (`_bound_receipt`), `continue`, the TUI artifact view | `test_frozen_artifacts.py` (v0.1.2, v0.3.0) | | Rooms | the room database (`rooms.sqlite3`, with the WAL a killed process leaves): rooms, rounds, candidates, members, workspaces, events, `sessions_json`, `selection_json` v1, `provenance_json` v1/v2, `conversation_json` v1; candidate `manifest.json`, patch and output; room-bound run artifacts. No release wrote `conversation_json` v2 or judgment rows | `room list` / `rounds` / `show` / `candidates` / `diff` / `compare` / `export`, `select`, `apply`, `judge`, `recover`, `close`, `prune` `RoomStore.member_sessions` (and through these `RoomCandidateStore.load`, `_parse_sessions_json`, `selection_record_status`, `room_outcome_status`, `round_provenance_status`, `round_conversation_status`); the standalone `apply` / `recover` refusal and skip for room-bound artifacts; a HEAD `room round` on a released room (`branch-from` resuming the released sessions on the released base patch, `fresh`, a first round on a draft, and the `continue-own` refusal when the released workspaces are gone); `room attach` over the released member log paths (tmux faked); the `apply --verify` settings notices; raw export (`--include` preview and confirm); the TUI Rooms tab's readers and pure renders (`build_room_evidence`, `render_room`, `render_round_table`, and the Diff, Output and Details views), not the Textual app | `test_frozen_rooms.py` (v0.1.2, v0.2.0, v0.3.0) | | Workflows | the workflow database (`workflows.db`): rows with `spec_json`, manifest `schema_version` 3 with `calls_used`, events, and from v0.3.0 the lineage columns and `file`/`revision` origins; per workflow `spec.json`, planner output, node bundles (`result.json`, `winner.patch`, `commit.json` v3) and stream logs; workflow-origin run artifacts, approved or not, with `plan.final_agent` from v0.3.0. No release wrote manifest v1 or v2 (both normalize to v3 before the insert); those readers stay covered by `test_workflow_durable.py::test_marked_v1_migration_preserves_exact_count_and_control_fields` and `test_workflow.py::test_a_workflow_whose_call_accounting_is_ambiguous_is_refused` | `workflow list` / `show` (`--prompts`) / `status` / `logs` / `lineage` / `export` / `cancel` / `prune`, a dead release-written lease recovered by `show` and `status`, `WorkflowStore.events` and `read_node_bundle`; for the final artifact `diff`, `runs show`, `apply` (approved, after a prune, approval healed from the database, refused for a workflow that never completed, `no_changes`), the standalone `recover` skip. A final artifact naming another run, and an unreadable store: `test_cli.py::test_apply_refuses_an_artifact_the_named_workflow_did_not_finalize` and `test_apply_guard_fails_closed_when_the_workflow_store_cannot_be_read`. HEAD execution (`WorkflowCoordinator.run`, what the job worker calls): `run` of released planned, created and revised rows (accounting, stored prompts, dependency output); `resume` of released crashes and failures against the stored deadline (expired: fails before dispatch; before it: the completed dependency's bundle is reused and the unfinished node's attempt refunded, a published final artifact is reused and approved without backfilling `final_agent`, an exhausted node dispatches nothing), refused from a final or unstarted state and while the owner lives, with the released execution history and events kept; `workflow revise` (a v0.2.0 parent gains a lineage); the launch's team peek and state check; `apply --verify` narrowing (v0.2.0: the gate's list only, with a notice; also after a prune); control plans enabled refuse a released workflow planned without one. Written later by the same releases (`freeze.py --part 2b`, `bindings-2b.json`): a node bundle saved just before the crash, reclaimed on resume through `read_node_result` / `read_node_bundle` with its usage added once (`_record_usage`), and re-run when its patch, its commit record or its digest disagrees; control-plan workflows, whose released `control-plan.json` is the control preflight's request, whose binding and validation topology refuse a changed configuration, and whose stored limits and node timeouts bound a run with control plans on, off, or Toolgraph off (`_execution_limits`), plus `show`, `read_control_plan`, `conversations show` and `apply` of a completed one; an interrupted apply of a final artifact, classified by `recover` (completed, not applied, manual recovery; `_bound_receipt`) without touching its approval or the workflow row. Not covered: the TUI's `_read_artifact_result`; no reader revalidates a released execution's `control-preflight.json` | `test_frozen_workflows.py` (v0.2.0, v0.3.0) | -| Sessions and project | isolated-session documents and their checkpoint run artifacts, project marker `.syncmill/project.json` | `IsolatedSessionStore.load` / `list`, `_marker_project_id`; the artifact readers for checkpoint runs | not yet | +| Sessions and project | isolated-session documents in every state a release leaves (open, checkpointed, closed, and from injected exits creating, closing, recovery_required) and their checkpoint run artifacts; the project marker `.syncmill/project.json` and the `syncmill.toml` that `init` writes | `session isolated-list` (`IsolatedSessionStore.list`: live, source staleness in the stored fingerprint's version), `IsolatedSessionStore.load` path binding (repository, workspace run root, worktree, derived tmux name) through `attach-isolated` (state, liveness, pane cwd), `checkpoint` (a missing worktree) and `close` (each state, a missing workspace), `tui_collaboration.render_session`; for the checkpoint artifact `runs list` / `show`, `diff`, `conversations show` / `list` / `close`, `continue` (refused), `recover` (skipped), `apply` and `validate_applicable`, `discard`, `prune` (pending kept), the TUI `render_artifact` / `_read_artifact_result`; `project_id` with the marker untracked, tracked or symlinked, `load_project_config` of the released config, `initialize_project` falling back to the marker. Not covered: a successful checkpoint or worktree cleanup (a release-written workspace is not relocatable) | `test_frozen_sessions.py` (v0.1.2, v0.2.0, v0.3.0) | | Board | item rows and their evidence and inputs, decisions | `_row_to_item`, `_load_evidence`, `_row_to_decision` | not yet | | Memory backends | Mem0 and LangGraph stored payloads | the backends' read paths | not yet | | TUI jobs | job documents, `schema_version` 1 and 2 | `JobStore.list_jobs` | not yet | diff --git a/tests/fixtures/persisted/sessions/freeze.py b/tests/fixtures/persisted/sessions/freeze.py new file mode 100644 index 0000000..378d88d --- /dev/null +++ b/tests/fixtures/persisted/sessions/freeze.py @@ -0,0 +1,320 @@ +"""Produce the frozen isolated-session documents under this directory (#271). + +Run once per release, with THAT release's interpreter and source, never the +current one. The output directory must not exist; frozen fixtures are never +regenerated. + + git worktree add --detach /tmp/wt-v0.3.0 v0.3.0 + (cd /tmp/wt-v0.3.0 && uv sync --frozen --no-dev) + python tests/fixtures/persisted/sessions/freeze.py \\ + --release-root /tmp/wt-v0.3.0 --out tests/fixtures/persisted/sessions/v0.3.0 + +The release's own `syncmill init` initializes one repository; its marker +(`.syncmill/project.json`) stays untracked and ignored, and `syncmill.toml` is +committed, so every session snapshots the same tree. Each session scenario is +its own project (`SYNCMILL_PROJECT__ID`), made with the release's +`session open codex --no-attach`, `session checkpoint` and `session close`. +`session open` launches a fake interactive `codex` in a real tmux. A `tmux` on +the fake PATH execs the real one with a private socket and no config, so the +user's tmux server is never touched; that private server is killed at the end. + +Three states need a process to stop at one exact point. A `.pth` hook loaded +into the release venv for the run only (active only while HOME holds a +`crash-at` file) provides them: +- `creating`: exit on entry to the launch, after the `creating` document; +- `closing`: exit right after the `closing` document is written; +- `recovery-required`: the worktree cleanup reports failure. + +Frozen for `project`: `syncmill.toml` and the marker directory's two files, +stored as `marker.json` and `marker.gitignore` (the latter, from v0.2.0, names +`project.json`, so Git would ignore the marker under its own name). + +Frozen per session scenario: `//sessions/*.json` and the +project's `runs/` (checkpoint artifacts). Workspaces are not frozen: they are +Git repositories holding absolute paths. `bindings.json` records the values +bound to the generating machine. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +TREE = {"calc.py": "def add(a, b):\n return a - b\n"} +FIXED = "def add(a, b):\n return a + b\n" + +# The interactive agent `session open codex` launches in tmux: it repairs +# calc.py in its workspace, says so under HOME/started, then waits to be killed. +FAKE_CODEX = '''\ +import os, sys, time +from pathlib import Path +from uuid import uuid4 +if sys.argv[1:2] in (["--version"], ["--help"]) or sys.argv[1:3] == ["login", "status"]: + print("codex (fake) - logged in"); sys.exit(0) +Path("calc.py").write_text(FIXED, encoding="utf-8") +started = Path(os.environ["HOME"]) / "started" +started.mkdir(exist_ok=True) +marker = started / uuid4().hex +marker.with_suffix(".tmp").write_text(os.getcwd(), encoding="utf-8") +os.replace(marker.with_suffix(".tmp"), marker) +time.sleep(3600) +'''.replace("FIXED", repr(FIXED)) + +CRASH_HOOK = '''\ +import os +from pathlib import Path + + +def _install(): + flag = Path(os.environ.get("HOME", "/nonexistent")) / "crash-at" + if not flag.is_file(): + return + point = flag.read_text().strip() + import syncmill.isolated_session as isolated + + if point == "create": + def _create_session(*args, **kwargs): + os._exit(0) # the creating document is written; tmux never starts + isolated._create_session = _create_session + elif point == "closing": + original = isolated.IsolatedSessionStore._write + + def _write(self, value, *args, **kwargs): + original(self, value, *args, **kwargs) + if value.get("state") == "closing": + os._exit(0) # closing recorded; tmux and the worktree untouched + isolated.IsolatedSessionStore._write = _write + elif point == "cleanup": + isolated.cleanup_worktree = lambda *args, **kwargs: False + + +_install() +''' +HOOK_MODULE = "_syncmill_freeze_sessions_hook" + +GIT_ENV = { + "GIT_AUTHOR_NAME": "frozen", "GIT_AUTHOR_EMAIL": "frozen@example.invalid", + "GIT_COMMITTER_NAME": "frozen", "GIT_COMMITTER_EMAIL": "frozen@example.invalid", + "GIT_AUTHOR_DATE": "2026-01-01T00:00:00Z", "GIT_COMMITTER_DATE": "2026-01-01T00:00:00Z", +} + +#: scenario -> (state, revision) the release must leave its session in. +EXPECTED = { + "open": ("open", 1), + "checkpointed": ("open", 2), + "closed": ("closed", 4), + "creating": ("creating", 0), + "closing": ("closing", 2), + "recovery-required": ("recovery_required", 3), +} + + +def _site_packages(python: Path) -> Path: + return Path(subprocess.run( + [str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"], + check=True, capture_output=True, text=True, + ).stdout.strip()) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--release-root", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + release = args.release_root.resolve() + out = args.out.resolve() + if out.exists(): + sys.exit(f"{out} exists; frozen fixtures are never regenerated") + real_tmux = shutil.which("tmux") + if real_tmux is None: + sys.exit("a real tmux is required: the release launches the agent in one") + python = release / ".venv" / "bin" / "python" + syncmill = release / ".venv" / "bin" / "syncmill" + version = subprocess.run( + [str(python), "-c", "import importlib.metadata as m; print(m.version('syncmill'))"], + check=True, capture_output=True, text=True, + ).stdout.strip() + site = _site_packages(python) + hook_files = [site / f"{HOOK_MODULE}.py", site / f"zz{HOOK_MODULE}.pth"] + if any(path.exists() for path in hook_files): + sys.exit(f"{site} already holds the freeze hook") + + # A tmux socket path may not exceed ~104 bytes: a short directory of its own. + sockets = Path(tempfile.mkdtemp(prefix="smss.", dir="/tmp")) + socket_path = sockets / "s" + try: + hook_files[0].write_text(CRASH_HOOK, encoding="utf-8") + hook_files[1].write_text(f"import {HOOK_MODULE}\n", encoding="utf-8") + with tempfile.TemporaryDirectory() as tmp: + _generate(Path(tmp).resolve(), real_tmux, socket_path, syncmill, version, out) + finally: + for path in hook_files: + path.unlink(missing_ok=True) + subprocess.run([real_tmux, "-S", str(socket_path), "kill-server"], + capture_output=True) + shutil.rmtree(sockets, ignore_errors=True) + + +def _generate(box: Path, real_tmux: str, socket_path: Path, syncmill: Path, version: str, + out: Path) -> None: + repo, home, state, fakebin = (box / n for n in ("repo", "home", "state", "fakebin")) + for folder in (repo, home, state, fakebin): + folder.mkdir() + (fakebin / "codex").write_text(f"#!{sys.executable}\n" + FAKE_CODEX, encoding="utf-8") + (fakebin / "codex").chmod(0o755) + (fakebin / "tmux").write_text( + f'#!/bin/sh\nexec {real_tmux} -S {socket_path} -f /dev/null "$@"\n', encoding="utf-8" + ) + (fakebin / "tmux").chmod(0o755) + (fakebin / "python3").symlink_to(sys.executable) + base_env = { + "HOME": str(home), + "PATH": os.pathsep.join([str(fakebin), "/usr/bin", "/bin"]), + "TMPDIR": str(box), + "TERM": "xterm-256color", + "GIT_CONFIG_NOSYSTEM": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "SYNCMILL_AGENT_PRIORITY": json.dumps(["codex"]), + "SYNCMILL_RUNS__ROOT": str(state / "runs"), + "SYNCMILL_WORKSPACE__ROOT": str(state / "workspaces"), + "SYNCMILL_MEMORY__DB_PATH": str(state / "memory" / "memtomem.db"), + "SYNCMILL_MEMORY__MEMORY_DIR": str(state / "memory" / "files"), + "SYNCMILL_BREAKER__STATE_PATH": str(state / "breaker.json"), + "SYNCMILL_TUI__STATE_DIR": str(state / "tui"), + **GIT_ENV, + } + + def git(*argv: str) -> str: + return subprocess.run(["git", *argv], cwd=repo, env=base_env, check=True, + capture_output=True, text=True).stdout + + def cli(env: dict, *argv: str, ok: bool = True) -> str: + done = subprocess.run([str(syncmill), *argv], cwd=repo, env=env, + capture_output=True, text=True) + if ok and done.returncode != 0: + sys.exit(f"{argv}: {done.stdout}{done.stderr}") + return done.stdout + done.stderr + + git("init", "-q", "-b", "main") + for name, text in TREE.items(): + (repo / name).write_text(text, encoding="utf-8") + git("add", "-A") + git("commit", "-q", "-m", "frozen tree") + cli(base_env, "init") + marker = repo / ".syncmill" / "project.json" + marker_mode = stat.S_IMODE(marker.stat().st_mode) + if marker_mode != 0o600: + sys.exit(f"init wrote the marker with mode {oct(marker_mode)}") + # The marker stays untracked and ignored; the config is committed. Every + # session then snapshots the same tree, and HEAD trusts the marker. + (repo / ".git" / "info" / "exclude").write_text(".syncmill/\n", encoding="utf-8") + git("add", "syncmill.toml") + git("commit", "-q", "-m", "syncmill init") + if git("status", "--porcelain"): + sys.exit("the initialized repository is not clean") + + out.mkdir(parents=True) + # Stored under names Git acts on neither in the fixture tree: from v0.2.0 + # `init` writes `.syncmill/.gitignore` naming `project.json` itself. + project_dir = out / "project" + project_dir.mkdir() + for relative, stored in (("syncmill.toml", "syncmill.toml"), + (".syncmill/project.json", "marker.json"), + (".syncmill/.gitignore", "marker.gitignore")): + shutil.copyfile(repo / relative, project_dir / stored) + bindings: dict = { + "release": version, + "box_root": str(box), + "tree": {name: (repo / name).read_text(encoding="utf-8") + for name in (*TREE, "syncmill.toml")}, + "marker": {"id": json.loads(marker.read_text())["id"], "mode": oct(marker_mode)}, + "scenarios": {}, + } + + def env_for(name: str) -> dict: + return {**base_env, "SYNCMILL_PROJECT__ID": f"frozen-sessions-{name}"} + + def sessions_dir(name: str) -> Path: + return state / "runs" / f"frozen-sessions-{name}" / "sessions" + + def open_session(name: str, *, crash_at: str | None = None) -> str: + flag = home / "crash-at" + flag.unlink(missing_ok=True) + if crash_at: + flag.write_text(crash_at) + started = set((home / "started").glob("*")) if (home / "started").is_dir() else set() + cli(env_for(name), "session", "open", "codex", "--no-attach", ok=crash_at is None) + flag.unlink(missing_ok=True) + [document] = sessions_dir(name).glob("*.json") + session_id = document.stem + if crash_at is None: + deadline = time.monotonic() + 30 + while not (set((home / "started").glob("*")) - started + if (home / "started").is_dir() else set()): + if time.monotonic() > deadline: + sys.exit(f"{name}: the fake codex never started") + time.sleep(0.1) + return session_id + + def crash(name: str, point: str, *argv: str) -> None: + (home / "crash-at").write_text(point) + cli(env_for(name), *argv, ok=False) + (home / "crash-at").unlink() + + def freeze(name: str, session_id: str) -> None: + source = state / "runs" / f"frozen-sessions-{name}" + target = out / name + (target / "sessions").mkdir(parents=True) + document = sessions_dir(name) / f"{session_id}.json" + shutil.copyfile(document, target / "sessions" / document.name) + if (source / "runs").is_dir(): + shutil.copytree(source / "runs", target / "runs") + value = json.loads(document.read_text()) + expected = EXPECTED[name] + if (value["state"], value["revision"]) != expected: + sys.exit(f"{name}: left {value['state']}/{value['revision']}, not {expected}") + bindings["scenarios"][name] = { + "project_id": f"frozen-sessions-{name}", + "session_id": session_id, + "agent_id": value["agent_id"], + "state": value["state"], + "revision": value["revision"], + "source_fingerprint": value["source_fingerprint"], + "checkpoints": list(value["checkpoints"]), + } + + # ── the lifecycle ──────────────────────────────────────────────────── + freeze("open", open_session("open")) + + session_id = open_session("checkpointed") + cli(env_for("checkpointed"), "session", "checkpoint", session_id) + freeze("checkpointed", session_id) + + session_id = open_session("closed") + cli(env_for("closed"), "session", "checkpoint", session_id) + cli(env_for("closed"), "session", "close", session_id) + freeze("closed", session_id) + + freeze("creating", open_session("creating", crash_at="create")) + + session_id = open_session("closing") + crash("closing", "closing", "session", "close", session_id) + freeze("closing", session_id) + + session_id = open_session("recovery-required") + crash("recovery-required", "cleanup", "session", "close", session_id) + freeze("recovery-required", session_id) + + (out / "bindings.json").write_text(json.dumps(bindings, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/persisted/sessions/v0.1.2/bindings.json b/tests/fixtures/persisted/sessions/v0.1.2/bindings.json new file mode 100644 index 0000000..76965c0 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/bindings.json @@ -0,0 +1,72 @@ +{ + "release": "0.1.2", + "box_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3", + "tree": { + "calc.py": "def add(a, b):\n return a - b\n", + "syncmill.toml": "config_version = 2\nrepo_root = \".\"\ndefault_strategy = \"route\"\n\n[project]\nid = \"36570eff-a2ea-492b-ae5d-427c2ed92f64\"\nmemory_dir = \".syncmill/memory\"\n\n[gate]\ncommands = []\n" + }, + "marker": { + "id": "36570eff-a2ea-492b-ae5d-427c2ed92f64", + "mode": "0o600" + }, + "scenarios": { + "open": { + "project_id": "frozen-sessions-open", + "session_id": "c5542802-fb6b-44e2-ac1d-7fcce5135a27", + "agent_id": "codex", + "state": "open", + "revision": 1, + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "checkpoints": [] + }, + "checkpointed": { + "project_id": "frozen-sessions-checkpointed", + "session_id": "71ca0b08-1bf9-4947-aa90-d86420aa8173", + "agent_id": "codex", + "state": "open", + "revision": 2, + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "checkpoints": [ + "e3d49c5a-1659-4984-9946-4ce0e6963cb7" + ] + }, + "closed": { + "project_id": "frozen-sessions-closed", + "session_id": "b56d6a0a-a9f0-4f07-8629-b611bb2f76ea", + "agent_id": "codex", + "state": "closed", + "revision": 4, + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "checkpoints": [ + "e3337972-fcea-4722-8a32-fe81f859c352" + ] + }, + "creating": { + "project_id": "frozen-sessions-creating", + "session_id": "73852047-5d3b-404f-868a-ce8f003bf911", + "agent_id": "codex", + "state": "creating", + "revision": 0, + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "checkpoints": [] + }, + "closing": { + "project_id": "frozen-sessions-closing", + "session_id": "e207d0db-7b0e-4e62-8158-a10c870480b0", + "agent_id": "codex", + "state": "closing", + "revision": 2, + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "checkpoints": [] + }, + "recovery-required": { + "project_id": "frozen-sessions-recovery-required", + "session_id": "335de61e-1fa2-4532-b5ed-57f5f05c0633", + "agent_id": "codex", + "state": "recovery_required", + "revision": 3, + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "checkpoints": [] + } + } +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/manifest.json b/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/manifest.json new file mode 100644 index 0000000..f22c7a8 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/manifest.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "run_id": "e3d49c5a-1659-4984-9946-4ce0e6963cb7", + "project_id": "frozen-sessions-checkpointed", + "parent_run_id": null, + "created_at": "2026-11-10T01:17:51.840840+00:00", + "updated_at": "2026-11-10T01:17:51.841375+00:00", + "state": "pending", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/repo", + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "snapshot_commit": "74807703b1a8b86574740d09eb416c71205bc309", + "task_id": "3b010a07-c250-4d54-a65f-223a6c0a63e6", + "prompt": "Interactive session checkpoint 71ca0b08-1bf9-4947-aa90-d86420aa8173", + "strategy": "interactive", + "agents": [ + "codex" + ], + "plan": { + "origin": "isolated-session", + "session_id": "71ca0b08-1bf9-4947-aa90-d86420aa8173" + }, + "agent_id": "codex", + "files_changed": [ + "calc.py" + ], + "patch_sha256": "35dc711418e60edc7974186bd853c7ab08db843f16e43066417b07a53d8566ba", + "patch_bytes": 157, + "provider_session_id": null, + "conversation_id": null +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/result.json b/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/result.json new file mode 100644 index 0000000..21df948 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/result.json @@ -0,0 +1,7 @@ +{ + "status": "completed", + "output": "Checkpoint from isolated session 71ca0b08-1bf9-4947-aa90-d86420aa8173", + "error": null, + "exit_code": 0, + "duration_seconds": 0.0 +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/winner.patch b/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/winner.patch new file mode 100644 index 0000000..95e886e --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/runs/e3d49c5a-1659-4984-9946-4ce0e6963cb7/winner.patch @@ -0,0 +1,8 @@ +diff --git a/calc.py b/calc.py +index 12ee743..4693ad3 100644 +--- a/calc.py ++++ b/calc.py +@@ -1,2 +1,2 @@ + def add(a, b): +- return a - b ++ return a + b diff --git a/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/sessions/71ca0b08-1bf9-4947-aa90-d86420aa8173.json b/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/sessions/71ca0b08-1bf9-4947-aa90-d86420aa8173.json new file mode 100644 index 0000000..c005730 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/checkpointed/sessions/71ca0b08-1bf9-4947-aa90-d86420aa8173.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "session_id": "71ca0b08-1bf9-4947-aa90-d86420aa8173", + "project_id": "frozen-sessions-checkpointed", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/repo", + "agent_id": "codex", + "state": "open", + "tmux_name": "syncmill-p-frozen-s-codex-71ca0b08", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-71ca0b08-1bf9-4947-aa90-d86420aa8173", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-71ca0b08-1bf9-4947-aa90-d86420aa8173/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-71ca0b08-1bf9-4947-aa90-d86420aa8173/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "74807703b1a8b86574740d09eb416c71205bc309", + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "source_head": "705c9f393585a2c8ba487630817039ad43752e66", + "checkpoints": [ + "e3d49c5a-1659-4984-9946-4ce0e6963cb7" + ], + "revision": 2, + "created_at": "2026-11-10T01:17:51.325193+00:00", + "updated_at": "2026-11-10T01:17:51.842047+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/manifest.json b/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/manifest.json new file mode 100644 index 0000000..768862a --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/manifest.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "run_id": "e3337972-fcea-4722-8a32-fe81f859c352", + "project_id": "frozen-sessions-closed", + "parent_run_id": null, + "created_at": "2026-11-10T01:17:53.062236+00:00", + "updated_at": "2026-11-10T01:17:53.062775+00:00", + "state": "pending", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/repo", + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "snapshot_commit": "15ad887ba5dc4e620b2f58385fa1f8ccd965c038", + "task_id": "d6815383-afb1-4cda-acfc-d0e87adae37d", + "prompt": "Interactive session checkpoint b56d6a0a-a9f0-4f07-8629-b611bb2f76ea", + "strategy": "interactive", + "agents": [ + "codex" + ], + "plan": { + "origin": "isolated-session", + "session_id": "b56d6a0a-a9f0-4f07-8629-b611bb2f76ea" + }, + "agent_id": "codex", + "files_changed": [ + "calc.py" + ], + "patch_sha256": "35dc711418e60edc7974186bd853c7ab08db843f16e43066417b07a53d8566ba", + "patch_bytes": 157, + "provider_session_id": null, + "conversation_id": null +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/result.json b/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/result.json new file mode 100644 index 0000000..da1acd0 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/result.json @@ -0,0 +1,7 @@ +{ + "status": "completed", + "output": "Checkpoint from isolated session b56d6a0a-a9f0-4f07-8629-b611bb2f76ea", + "error": null, + "exit_code": 0, + "duration_seconds": 0.0 +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/winner.patch b/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/winner.patch new file mode 100644 index 0000000..95e886e --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/closed/runs/e3337972-fcea-4722-8a32-fe81f859c352/winner.patch @@ -0,0 +1,8 @@ +diff --git a/calc.py b/calc.py +index 12ee743..4693ad3 100644 +--- a/calc.py ++++ b/calc.py +@@ -1,2 +1,2 @@ + def add(a, b): +- return a - b ++ return a + b diff --git a/tests/fixtures/persisted/sessions/v0.1.2/closed/sessions/b56d6a0a-a9f0-4f07-8629-b611bb2f76ea.json b/tests/fixtures/persisted/sessions/v0.1.2/closed/sessions/b56d6a0a-a9f0-4f07-8629-b611bb2f76ea.json new file mode 100644 index 0000000..f6040d6 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/closed/sessions/b56d6a0a-a9f0-4f07-8629-b611bb2f76ea.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "session_id": "b56d6a0a-a9f0-4f07-8629-b611bb2f76ea", + "project_id": "frozen-sessions-closed", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/repo", + "agent_id": "codex", + "state": "closed", + "tmux_name": "syncmill-p-frozen-s-codex-b56d6a0a", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-b56d6a0a-a9f0-4f07-8629-b611bb2f76ea", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-b56d6a0a-a9f0-4f07-8629-b611bb2f76ea/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-b56d6a0a-a9f0-4f07-8629-b611bb2f76ea/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "15ad887ba5dc4e620b2f58385fa1f8ccd965c038", + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "source_head": "705c9f393585a2c8ba487630817039ad43752e66", + "checkpoints": [ + "e3337972-fcea-4722-8a32-fe81f859c352" + ], + "revision": 4, + "created_at": "2026-11-10T01:17:52.547804+00:00", + "updated_at": "2026-11-10T01:17:53.551917+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/closing/sessions/e207d0db-7b0e-4e62-8158-a10c870480b0.json b/tests/fixtures/persisted/sessions/v0.1.2/closing/sessions/e207d0db-7b0e-4e62-8158-a10c870480b0.json new file mode 100644 index 0000000..f34d232 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/closing/sessions/e207d0db-7b0e-4e62-8158-a10c870480b0.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "e207d0db-7b0e-4e62-8158-a10c870480b0", + "project_id": "frozen-sessions-closing", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/repo", + "agent_id": "codex", + "state": "closing", + "tmux_name": "syncmill-p-frozen-s-codex-e207d0db", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-e207d0db-7b0e-4e62-8158-a10c870480b0", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-e207d0db-7b0e-4e62-8158-a10c870480b0/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-e207d0db-7b0e-4e62-8158-a10c870480b0/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "64c593248293d861cfaba60f356e5342d9077ac7", + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "source_head": "705c9f393585a2c8ba487630817039ad43752e66", + "checkpoints": [], + "revision": 2, + "created_at": "2026-11-10T01:17:54.944169+00:00", + "updated_at": "2026-11-10T01:17:55.425370+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/creating/sessions/73852047-5d3b-404f-868a-ce8f003bf911.json b/tests/fixtures/persisted/sessions/v0.1.2/creating/sessions/73852047-5d3b-404f-868a-ce8f003bf911.json new file mode 100644 index 0000000..131fbc6 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/creating/sessions/73852047-5d3b-404f-868a-ce8f003bf911.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "73852047-5d3b-404f-868a-ce8f003bf911", + "project_id": "frozen-sessions-creating", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/repo", + "agent_id": "codex", + "state": "creating", + "tmux_name": "syncmill-p-frozen-s-codex-73852047", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-73852047-5d3b-404f-868a-ce8f003bf911", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-73852047-5d3b-404f-868a-ce8f003bf911/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-73852047-5d3b-404f-868a-ce8f003bf911/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "a23048b893335ee4c43e16fb844d42403fda14a8", + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "source_head": "705c9f393585a2c8ba487630817039ad43752e66", + "checkpoints": [], + "revision": 0, + "created_at": "2026-11-10T01:17:54.262624+00:00", + "updated_at": "2026-11-10T01:17:54.263096+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/open/sessions/c5542802-fb6b-44e2-ac1d-7fcce5135a27.json b/tests/fixtures/persisted/sessions/v0.1.2/open/sessions/c5542802-fb6b-44e2-ac1d-7fcce5135a27.json new file mode 100644 index 0000000..37fb8e3 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/open/sessions/c5542802-fb6b-44e2-ac1d-7fcce5135a27.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "c5542802-fb6b-44e2-ac1d-7fcce5135a27", + "project_id": "frozen-sessions-open", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/repo", + "agent_id": "codex", + "state": "open", + "tmux_name": "syncmill-p-frozen-s-codex-c5542802", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-c5542802-fb6b-44e2-ac1d-7fcce5135a27", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-c5542802-fb6b-44e2-ac1d-7fcce5135a27/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-c5542802-fb6b-44e2-ac1d-7fcce5135a27/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "4d312ff74b7c8e2acb2934291d9dd5b95e97d6b7", + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "source_head": "705c9f393585a2c8ba487630817039ad43752e66", + "checkpoints": [], + "revision": 1, + "created_at": "2026-11-10T01:17:50.237909+00:00", + "updated_at": "2026-11-10T01:17:50.499750+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/project/marker.gitignore b/tests/fixtures/persisted/sessions/v0.1.2/project/marker.gitignore new file mode 100644 index 0000000..6349da1 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/project/marker.gitignore @@ -0,0 +1 @@ +memory/ diff --git a/tests/fixtures/persisted/sessions/v0.1.2/project/marker.json b/tests/fixtures/persisted/sessions/v0.1.2/project/marker.json new file mode 100644 index 0000000..63f11ca --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/project/marker.json @@ -0,0 +1,3 @@ +{ + "id": "36570eff-a2ea-492b-ae5d-427c2ed92f64" +} diff --git a/tests/fixtures/persisted/sessions/v0.1.2/project/syncmill.toml b/tests/fixtures/persisted/sessions/v0.1.2/project/syncmill.toml new file mode 100644 index 0000000..fa1ce50 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/project/syncmill.toml @@ -0,0 +1,10 @@ +config_version = 2 +repo_root = "." +default_strategy = "route" + +[project] +id = "36570eff-a2ea-492b-ae5d-427c2ed92f64" +memory_dir = ".syncmill/memory" + +[gate] +commands = [] diff --git a/tests/fixtures/persisted/sessions/v0.1.2/recovery-required/sessions/335de61e-1fa2-4532-b5ed-57f5f05c0633.json b/tests/fixtures/persisted/sessions/v0.1.2/recovery-required/sessions/335de61e-1fa2-4532-b5ed-57f5f05c0633.json new file mode 100644 index 0000000..d072758 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.1.2/recovery-required/sessions/335de61e-1fa2-4532-b5ed-57f5f05c0633.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "335de61e-1fa2-4532-b5ed-57f5f05c0633", + "project_id": "frozen-sessions-recovery-required", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/repo", + "agent_id": "codex", + "state": "recovery_required", + "tmux_name": "syncmill-p-frozen-s-codex-335de61e", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-335de61e-1fa2-4532-b5ed-57f5f05c0633", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-335de61e-1fa2-4532-b5ed-57f5f05c0633/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpmktquzd3/state/workspaces/session-335de61e-1fa2-4532-b5ed-57f5f05c0633/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "3525ec63111f370df5771448042c531d0d5b708c", + "source_fingerprint": "8868703dabc72e8fe3597cc38fca3b8958e86257d1456e68b1676561c292c954", + "source_head": "705c9f393585a2c8ba487630817039ad43752e66", + "checkpoints": [], + "revision": 3, + "created_at": "2026-11-10T01:17:56.109480+00:00", + "updated_at": "2026-11-10T01:17:56.609454+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/bindings.json b/tests/fixtures/persisted/sessions/v0.2.0/bindings.json new file mode 100644 index 0000000..ce38f66 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/bindings.json @@ -0,0 +1,72 @@ +{ + "release": "0.2.0", + "box_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz", + "tree": { + "calc.py": "def add(a, b):\n return a - b\n", + "syncmill.toml": "config_version = 2\nrepo_root = \".\"\ndefault_strategy = \"route\"\n\n[project]\nid = \"bba2632e-b782-4603-93e4-d39a67960f7c\"\nmemory_dir = \".syncmill/memory\"\n\n[gate]\ncommands = []\n" + }, + "marker": { + "id": "bba2632e-b782-4603-93e4-d39a67960f7c", + "mode": "0o600" + }, + "scenarios": { + "open": { + "project_id": "frozen-sessions-open", + "session_id": "23127f27-a1cc-4212-9e14-c672948975a5", + "agent_id": "codex", + "state": "open", + "revision": 1, + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "checkpoints": [] + }, + "checkpointed": { + "project_id": "frozen-sessions-checkpointed", + "session_id": "b77edfa7-844b-4356-82fc-813f62701ab1", + "agent_id": "codex", + "state": "open", + "revision": 2, + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "checkpoints": [ + "fe64c7cf-4267-46f0-84c6-7decaeb8b747" + ] + }, + "closed": { + "project_id": "frozen-sessions-closed", + "session_id": "74f2b6e8-d34b-4223-b0b8-81ba8a984566", + "agent_id": "codex", + "state": "closed", + "revision": 4, + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "checkpoints": [ + "0cee9417-5b05-42bb-8bde-7c63723a586d" + ] + }, + "creating": { + "project_id": "frozen-sessions-creating", + "session_id": "160fca14-4e8b-4881-ae81-6e7b8ce73c67", + "agent_id": "codex", + "state": "creating", + "revision": 0, + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "checkpoints": [] + }, + "closing": { + "project_id": "frozen-sessions-closing", + "session_id": "85004bd7-9141-46ad-9899-8f658b95b3a8", + "agent_id": "codex", + "state": "closing", + "revision": 2, + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "checkpoints": [] + }, + "recovery-required": { + "project_id": "frozen-sessions-recovery-required", + "session_id": "fcc8f978-f4c2-46cd-936e-ecc4398de4ac", + "agent_id": "codex", + "state": "recovery_required", + "revision": 3, + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "checkpoints": [] + } + } +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/manifest.json b/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/manifest.json new file mode 100644 index 0000000..8dfc72b --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/manifest.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "run_id": "fe64c7cf-4267-46f0-84c6-7decaeb8b747", + "project_id": "frozen-sessions-checkpointed", + "parent_run_id": null, + "created_at": "2026-11-10T01:17:58.475747+00:00", + "updated_at": "2026-11-10T01:17:58.476887+00:00", + "state": "pending", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/repo", + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "snapshot_commit": "46a594a84f7c4d5a897c7a1574dceb92ecacd9b5", + "task_id": "81d8e41c-62f2-4ff1-a902-bd6d983e9cdd", + "prompt": "Interactive session checkpoint b77edfa7-844b-4356-82fc-813f62701ab1", + "strategy": "interactive", + "agents": [ + "codex" + ], + "plan": { + "origin": "isolated-session", + "session_id": "b77edfa7-844b-4356-82fc-813f62701ab1" + }, + "agent_id": "codex", + "files_changed": [ + "calc.py" + ], + "patch_sha256": "35dc711418e60edc7974186bd853c7ab08db843f16e43066417b07a53d8566ba", + "patch_bytes": 157, + "provider_session_id": null, + "conversation_id": null +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/result.json b/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/result.json new file mode 100644 index 0000000..12c12aa --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/result.json @@ -0,0 +1,7 @@ +{ + "status": "completed", + "output": "Checkpoint from isolated session b77edfa7-844b-4356-82fc-813f62701ab1", + "error": null, + "exit_code": 0, + "duration_seconds": 0.0 +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/winner.patch b/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/winner.patch new file mode 100644 index 0000000..95e886e --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/runs/fe64c7cf-4267-46f0-84c6-7decaeb8b747/winner.patch @@ -0,0 +1,8 @@ +diff --git a/calc.py b/calc.py +index 12ee743..4693ad3 100644 +--- a/calc.py ++++ b/calc.py +@@ -1,2 +1,2 @@ + def add(a, b): +- return a - b ++ return a + b diff --git a/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/sessions/b77edfa7-844b-4356-82fc-813f62701ab1.json b/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/sessions/b77edfa7-844b-4356-82fc-813f62701ab1.json new file mode 100644 index 0000000..515a65f --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/checkpointed/sessions/b77edfa7-844b-4356-82fc-813f62701ab1.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "session_id": "b77edfa7-844b-4356-82fc-813f62701ab1", + "project_id": "frozen-sessions-checkpointed", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/repo", + "agent_id": "codex", + "state": "open", + "tmux_name": "syncmill-p-frozen-s-codex-b77edfa7", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-b77edfa7-844b-4356-82fc-813f62701ab1", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-b77edfa7-844b-4356-82fc-813f62701ab1/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-b77edfa7-844b-4356-82fc-813f62701ab1/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "46a594a84f7c4d5a897c7a1574dceb92ecacd9b5", + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "source_head": "fae6f00bb5ada4b8aa8f1f7b4b6756ac21618caf", + "checkpoints": [ + "fe64c7cf-4267-46f0-84c6-7decaeb8b747" + ], + "revision": 2, + "created_at": "2026-11-10T01:17:58.216179+00:00", + "updated_at": "2026-11-10T01:17:58.478146+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/manifest.json b/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/manifest.json new file mode 100644 index 0000000..977a78e --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/manifest.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "run_id": "0cee9417-5b05-42bb-8bde-7c63723a586d", + "project_id": "frozen-sessions-closed", + "parent_run_id": null, + "created_at": "2026-11-10T01:17:59.181256+00:00", + "updated_at": "2026-11-10T01:17:59.182352+00:00", + "state": "pending", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/repo", + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "snapshot_commit": "64956ea9ad256d624b9622f28900d3c6eb25c138", + "task_id": "54f7402f-ca50-4eb5-b01a-d7208f3a0892", + "prompt": "Interactive session checkpoint 74f2b6e8-d34b-4223-b0b8-81ba8a984566", + "strategy": "interactive", + "agents": [ + "codex" + ], + "plan": { + "origin": "isolated-session", + "session_id": "74f2b6e8-d34b-4223-b0b8-81ba8a984566" + }, + "agent_id": "codex", + "files_changed": [ + "calc.py" + ], + "patch_sha256": "35dc711418e60edc7974186bd853c7ab08db843f16e43066417b07a53d8566ba", + "patch_bytes": 157, + "provider_session_id": null, + "conversation_id": null +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/result.json b/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/result.json new file mode 100644 index 0000000..fa2308d --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/result.json @@ -0,0 +1,7 @@ +{ + "status": "completed", + "output": "Checkpoint from isolated session 74f2b6e8-d34b-4223-b0b8-81ba8a984566", + "error": null, + "exit_code": 0, + "duration_seconds": 0.0 +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/winner.patch b/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/winner.patch new file mode 100644 index 0000000..95e886e --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/closed/runs/0cee9417-5b05-42bb-8bde-7c63723a586d/winner.patch @@ -0,0 +1,8 @@ +diff --git a/calc.py b/calc.py +index 12ee743..4693ad3 100644 +--- a/calc.py ++++ b/calc.py +@@ -1,2 +1,2 @@ + def add(a, b): +- return a - b ++ return a + b diff --git a/tests/fixtures/persisted/sessions/v0.2.0/closed/sessions/74f2b6e8-d34b-4223-b0b8-81ba8a984566.json b/tests/fixtures/persisted/sessions/v0.2.0/closed/sessions/74f2b6e8-d34b-4223-b0b8-81ba8a984566.json new file mode 100644 index 0000000..090d6ef --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/closed/sessions/74f2b6e8-d34b-4223-b0b8-81ba8a984566.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "session_id": "74f2b6e8-d34b-4223-b0b8-81ba8a984566", + "project_id": "frozen-sessions-closed", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/repo", + "agent_id": "codex", + "state": "closed", + "tmux_name": "syncmill-p-frozen-s-codex-74f2b6e8", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-74f2b6e8-d34b-4223-b0b8-81ba8a984566", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-74f2b6e8-d34b-4223-b0b8-81ba8a984566/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-74f2b6e8-d34b-4223-b0b8-81ba8a984566/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "64956ea9ad256d624b9622f28900d3c6eb25c138", + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "source_head": "fae6f00bb5ada4b8aa8f1f7b4b6756ac21618caf", + "checkpoints": [ + "0cee9417-5b05-42bb-8bde-7c63723a586d" + ], + "revision": 4, + "created_at": "2026-11-10T01:17:58.915318+00:00", + "updated_at": "2026-11-10T01:17:59.416920+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/closing/sessions/85004bd7-9141-46ad-9899-8f658b95b3a8.json b/tests/fixtures/persisted/sessions/v0.2.0/closing/sessions/85004bd7-9141-46ad-9899-8f658b95b3a8.json new file mode 100644 index 0000000..040feb1 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/closing/sessions/85004bd7-9141-46ad-9899-8f658b95b3a8.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "85004bd7-9141-46ad-9899-8f658b95b3a8", + "project_id": "frozen-sessions-closing", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/repo", + "agent_id": "codex", + "state": "closing", + "tmux_name": "syncmill-p-frozen-s-codex-85004bd7", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-85004bd7-9141-46ad-9899-8f658b95b3a8", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-85004bd7-9141-46ad-9899-8f658b95b3a8/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-85004bd7-9141-46ad-9899-8f658b95b3a8/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "a5fb6f5cdcc9c620d2fb54d0ca1ceee22049ba5b", + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "source_head": "fae6f00bb5ada4b8aa8f1f7b4b6756ac21618caf", + "checkpoints": [], + "revision": 2, + "created_at": "2026-11-10T01:18:00.282334+00:00", + "updated_at": "2026-11-10T01:18:00.509210+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/creating/sessions/160fca14-4e8b-4881-ae81-6e7b8ce73c67.json b/tests/fixtures/persisted/sessions/v0.2.0/creating/sessions/160fca14-4e8b-4881-ae81-6e7b8ce73c67.json new file mode 100644 index 0000000..dcf8b3d --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/creating/sessions/160fca14-4e8b-4881-ae81-6e7b8ce73c67.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "160fca14-4e8b-4881-ae81-6e7b8ce73c67", + "project_id": "frozen-sessions-creating", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/repo", + "agent_id": "codex", + "state": "creating", + "tmux_name": "syncmill-p-frozen-s-codex-160fca14", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-160fca14-4e8b-4881-ae81-6e7b8ce73c67", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-160fca14-4e8b-4881-ae81-6e7b8ce73c67/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-160fca14-4e8b-4881-ae81-6e7b8ce73c67/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "f460ea6d5cb46913efc0cc8a16b108aa93f36688", + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "source_head": "fae6f00bb5ada4b8aa8f1f7b4b6756ac21618caf", + "checkpoints": [], + "revision": 0, + "created_at": "2026-11-10T01:17:59.874289+00:00", + "updated_at": "2026-11-10T01:17:59.874767+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/open/sessions/23127f27-a1cc-4212-9e14-c672948975a5.json b/tests/fixtures/persisted/sessions/v0.2.0/open/sessions/23127f27-a1cc-4212-9e14-c672948975a5.json new file mode 100644 index 0000000..6df4665 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/open/sessions/23127f27-a1cc-4212-9e14-c672948975a5.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "23127f27-a1cc-4212-9e14-c672948975a5", + "project_id": "frozen-sessions-open", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/repo", + "agent_id": "codex", + "state": "open", + "tmux_name": "syncmill-p-frozen-s-codex-23127f27", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-23127f27-a1cc-4212-9e14-c672948975a5", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-23127f27-a1cc-4212-9e14-c672948975a5/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-23127f27-a1cc-4212-9e14-c672948975a5/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "4221c528ed33a256c552a5e7b4d2a2094b173325", + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "source_head": "fae6f00bb5ada4b8aa8f1f7b4b6756ac21618caf", + "checkpoints": [], + "revision": 1, + "created_at": "2026-11-10T01:17:57.405812+00:00", + "updated_at": "2026-11-10T01:17:57.682425+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/project/marker.gitignore b/tests/fixtures/persisted/sessions/v0.2.0/project/marker.gitignore new file mode 100644 index 0000000..6dfa913 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/project/marker.gitignore @@ -0,0 +1,2 @@ +memory/ +project.json diff --git a/tests/fixtures/persisted/sessions/v0.2.0/project/marker.json b/tests/fixtures/persisted/sessions/v0.2.0/project/marker.json new file mode 100644 index 0000000..9cc21ba --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/project/marker.json @@ -0,0 +1,3 @@ +{ + "id": "bba2632e-b782-4603-93e4-d39a67960f7c" +} diff --git a/tests/fixtures/persisted/sessions/v0.2.0/project/syncmill.toml b/tests/fixtures/persisted/sessions/v0.2.0/project/syncmill.toml new file mode 100644 index 0000000..243d757 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/project/syncmill.toml @@ -0,0 +1,10 @@ +config_version = 2 +repo_root = "." +default_strategy = "route" + +[project] +id = "bba2632e-b782-4603-93e4-d39a67960f7c" +memory_dir = ".syncmill/memory" + +[gate] +commands = [] diff --git a/tests/fixtures/persisted/sessions/v0.2.0/recovery-required/sessions/fcc8f978-f4c2-46cd-936e-ecc4398de4ac.json b/tests/fixtures/persisted/sessions/v0.2.0/recovery-required/sessions/fcc8f978-f4c2-46cd-936e-ecc4398de4ac.json new file mode 100644 index 0000000..bdb4cdd --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.2.0/recovery-required/sessions/fcc8f978-f4c2-46cd-936e-ecc4398de4ac.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "fcc8f978-f4c2-46cd-936e-ecc4398de4ac", + "project_id": "frozen-sessions-recovery-required", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/repo", + "agent_id": "codex", + "state": "recovery_required", + "tmux_name": "syncmill-p-frozen-s-codex-fcc8f978", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-fcc8f978-f4c2-46cd-936e-ecc4398de4ac", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-fcc8f978-f4c2-46cd-936e-ecc4398de4ac/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmpfeuw_bsz/state/workspaces/session-fcc8f978-f4c2-46cd-936e-ecc4398de4ac/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "c90194afe36347932ace323f220d38b93cd39c18", + "source_fingerprint": "c605ecdc0a3f4df5822c04269e4815c7d3395a26938e4dce93fe3fb0ef1a0d59", + "source_head": "fae6f00bb5ada4b8aa8f1f7b4b6756ac21618caf", + "checkpoints": [], + "revision": 3, + "created_at": "2026-11-10T01:18:00.902727+00:00", + "updated_at": "2026-11-10T01:18:01.150132+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/bindings.json b/tests/fixtures/persisted/sessions/v0.3.0/bindings.json new file mode 100644 index 0000000..bbec56b --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/bindings.json @@ -0,0 +1,72 @@ +{ + "release": "0.3.0", + "box_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi", + "tree": { + "calc.py": "def add(a, b):\n return a - b\n", + "syncmill.toml": "config_version = 2\nrepo_root = \".\"\ndefault_strategy = \"route\"\n\n[project]\nid = \"7d66bc69-1bf8-442c-8332-22fddc302ded\"\nmemory_dir = \".syncmill/memory\"\n\n[gate]\ncommands = []\n" + }, + "marker": { + "id": "7d66bc69-1bf8-442c-8332-22fddc302ded", + "mode": "0o600" + }, + "scenarios": { + "open": { + "project_id": "frozen-sessions-open", + "session_id": "dc058eaf-be75-4c09-bdad-c0c1fff90ce1", + "agent_id": "codex", + "state": "open", + "revision": 1, + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "checkpoints": [] + }, + "checkpointed": { + "project_id": "frozen-sessions-checkpointed", + "session_id": "2be035d6-c596-4a41-89e9-899f58d83405", + "agent_id": "codex", + "state": "open", + "revision": 2, + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "checkpoints": [ + "9981a2fe-6428-4fa0-bc06-cbde114bba0b" + ] + }, + "closed": { + "project_id": "frozen-sessions-closed", + "session_id": "5d175919-72c9-4c03-a994-e8a2e91650aa", + "agent_id": "codex", + "state": "closed", + "revision": 4, + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "checkpoints": [ + "070b81f0-44cf-414e-9d06-1b98a5a9bf68" + ] + }, + "creating": { + "project_id": "frozen-sessions-creating", + "session_id": "3f0564ec-577d-4af7-8377-2c0588ded520", + "agent_id": "codex", + "state": "creating", + "revision": 0, + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "checkpoints": [] + }, + "closing": { + "project_id": "frozen-sessions-closing", + "session_id": "0928a925-a3d4-4707-98f7-1d5b1214234c", + "agent_id": "codex", + "state": "closing", + "revision": 2, + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "checkpoints": [] + }, + "recovery-required": { + "project_id": "frozen-sessions-recovery-required", + "session_id": "e4a9c0f5-5819-4afb-a208-a2d8e1cf7475", + "agent_id": "codex", + "state": "recovery_required", + "revision": 3, + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "checkpoints": [] + } + } +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/manifest.json b/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/manifest.json new file mode 100644 index 0000000..d003407 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/manifest.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "run_id": "9981a2fe-6428-4fa0-bc06-cbde114bba0b", + "project_id": "frozen-sessions-checkpointed", + "parent_run_id": null, + "created_at": "2026-11-10T01:18:04.484323+00:00", + "updated_at": "2026-11-10T01:18:04.485342+00:00", + "state": "pending", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/repo", + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "snapshot_commit": "3f1a86cac8bcf53bfcc38fccd122a6db96200505", + "task_id": "71c6bb26-9ff1-43c5-adbb-11627c91a95b", + "prompt": "Interactive session checkpoint 2be035d6-c596-4a41-89e9-899f58d83405", + "strategy": "interactive", + "agents": [ + "codex" + ], + "plan": { + "origin": "isolated-session", + "session_id": "2be035d6-c596-4a41-89e9-899f58d83405" + }, + "agent_id": "codex", + "files_changed": [ + "calc.py" + ], + "patch_sha256": "35dc711418e60edc7974186bd853c7ab08db843f16e43066417b07a53d8566ba", + "patch_bytes": 157, + "provider_session_id": null, + "conversation_id": null +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/result.json b/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/result.json new file mode 100644 index 0000000..39ad6f4 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/result.json @@ -0,0 +1,7 @@ +{ + "status": "completed", + "output": "Checkpoint from isolated session 2be035d6-c596-4a41-89e9-899f58d83405", + "error": null, + "exit_code": 0, + "duration_seconds": 0.0 +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/winner.patch b/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/winner.patch new file mode 100644 index 0000000..95e886e --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/runs/9981a2fe-6428-4fa0-bc06-cbde114bba0b/winner.patch @@ -0,0 +1,8 @@ +diff --git a/calc.py b/calc.py +index 12ee743..4693ad3 100644 +--- a/calc.py ++++ b/calc.py +@@ -1,2 +1,2 @@ + def add(a, b): +- return a - b ++ return a + b diff --git a/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/sessions/2be035d6-c596-4a41-89e9-899f58d83405.json b/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/sessions/2be035d6-c596-4a41-89e9-899f58d83405.json new file mode 100644 index 0000000..20c85c1 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/checkpointed/sessions/2be035d6-c596-4a41-89e9-899f58d83405.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "session_id": "2be035d6-c596-4a41-89e9-899f58d83405", + "project_id": "frozen-sessions-checkpointed", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/repo", + "agent_id": "codex", + "state": "open", + "tmux_name": "syncmill-p-frozen-s-codex-2be035d6", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-2be035d6-c596-4a41-89e9-899f58d83405", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-2be035d6-c596-4a41-89e9-899f58d83405/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-2be035d6-c596-4a41-89e9-899f58d83405/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "3f1a86cac8bcf53bfcc38fccd122a6db96200505", + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "source_head": "14038757ec6e6a6a192990e4a9d05ccdd002e46e", + "checkpoints": [ + "9981a2fe-6428-4fa0-bc06-cbde114bba0b" + ], + "revision": 2, + "created_at": "2026-11-10T01:18:03.872463+00:00", + "updated_at": "2026-11-10T01:18:04.486533+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/manifest.json b/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/manifest.json new file mode 100644 index 0000000..a2a1f97 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/manifest.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "run_id": "070b81f0-44cf-414e-9d06-1b98a5a9bf68", + "project_id": "frozen-sessions-closed", + "parent_run_id": null, + "created_at": "2026-11-10T01:18:05.917929+00:00", + "updated_at": "2026-11-10T01:18:05.919023+00:00", + "state": "pending", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/repo", + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "snapshot_commit": "e59ad52086cf1bd7f2d3f7ebf03e78dde9b78337", + "task_id": "61a6168b-1845-4db7-94c0-a83b4234cf74", + "prompt": "Interactive session checkpoint 5d175919-72c9-4c03-a994-e8a2e91650aa", + "strategy": "interactive", + "agents": [ + "codex" + ], + "plan": { + "origin": "isolated-session", + "session_id": "5d175919-72c9-4c03-a994-e8a2e91650aa" + }, + "agent_id": "codex", + "files_changed": [ + "calc.py" + ], + "patch_sha256": "35dc711418e60edc7974186bd853c7ab08db843f16e43066417b07a53d8566ba", + "patch_bytes": 157, + "provider_session_id": null, + "conversation_id": null +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/result.json b/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/result.json new file mode 100644 index 0000000..b6ce085 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/result.json @@ -0,0 +1,7 @@ +{ + "status": "completed", + "output": "Checkpoint from isolated session 5d175919-72c9-4c03-a994-e8a2e91650aa", + "error": null, + "exit_code": 0, + "duration_seconds": 0.0 +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/winner.patch b/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/winner.patch new file mode 100644 index 0000000..95e886e --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/closed/runs/070b81f0-44cf-414e-9d06-1b98a5a9bf68/winner.patch @@ -0,0 +1,8 @@ +diff --git a/calc.py b/calc.py +index 12ee743..4693ad3 100644 +--- a/calc.py ++++ b/calc.py +@@ -1,2 +1,2 @@ + def add(a, b): +- return a - b ++ return a + b diff --git a/tests/fixtures/persisted/sessions/v0.3.0/closed/sessions/5d175919-72c9-4c03-a994-e8a2e91650aa.json b/tests/fixtures/persisted/sessions/v0.3.0/closed/sessions/5d175919-72c9-4c03-a994-e8a2e91650aa.json new file mode 100644 index 0000000..6a0d713 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/closed/sessions/5d175919-72c9-4c03-a994-e8a2e91650aa.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "session_id": "5d175919-72c9-4c03-a994-e8a2e91650aa", + "project_id": "frozen-sessions-closed", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/repo", + "agent_id": "codex", + "state": "closed", + "tmux_name": "syncmill-p-frozen-s-codex-5d175919", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-5d175919-72c9-4c03-a994-e8a2e91650aa", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-5d175919-72c9-4c03-a994-e8a2e91650aa/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-5d175919-72c9-4c03-a994-e8a2e91650aa/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "e59ad52086cf1bd7f2d3f7ebf03e78dde9b78337", + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "source_head": "14038757ec6e6a6a192990e4a9d05ccdd002e46e", + "checkpoints": [ + "070b81f0-44cf-414e-9d06-1b98a5a9bf68" + ], + "revision": 4, + "created_at": "2026-11-10T01:18:05.279316+00:00", + "updated_at": "2026-11-10T01:18:06.525657+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/closing/sessions/0928a925-a3d4-4707-98f7-1d5b1214234c.json b/tests/fixtures/persisted/sessions/v0.3.0/closing/sessions/0928a925-a3d4-4707-98f7-1d5b1214234c.json new file mode 100644 index 0000000..ab22631 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/closing/sessions/0928a925-a3d4-4707-98f7-1d5b1214234c.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "0928a925-a3d4-4707-98f7-1d5b1214234c", + "project_id": "frozen-sessions-closing", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/repo", + "agent_id": "codex", + "state": "closing", + "tmux_name": "syncmill-p-frozen-s-codex-0928a925", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-0928a925-a3d4-4707-98f7-1d5b1214234c", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-0928a925-a3d4-4707-98f7-1d5b1214234c/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-0928a925-a3d4-4707-98f7-1d5b1214234c/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "ce63d788c4a2ecd224b129604b3a10ec96c48145", + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "source_head": "14038757ec6e6a6a192990e4a9d05ccdd002e46e", + "checkpoints": [], + "revision": 2, + "created_at": "2026-11-10T01:18:08.119617+00:00", + "updated_at": "2026-11-10T01:18:08.703417+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/creating/sessions/3f0564ec-577d-4af7-8377-2c0588ded520.json b/tests/fixtures/persisted/sessions/v0.3.0/creating/sessions/3f0564ec-577d-4af7-8377-2c0588ded520.json new file mode 100644 index 0000000..0ab6c60 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/creating/sessions/3f0564ec-577d-4af7-8377-2c0588ded520.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "3f0564ec-577d-4af7-8377-2c0588ded520", + "project_id": "frozen-sessions-creating", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/repo", + "agent_id": "codex", + "state": "creating", + "tmux_name": "syncmill-p-frozen-s-codex-3f0564ec", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-3f0564ec-577d-4af7-8377-2c0588ded520", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-3f0564ec-577d-4af7-8377-2c0588ded520/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-3f0564ec-577d-4af7-8377-2c0588ded520/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "407a3bff9baa00b3a181e9c8912049e01a8938cf", + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "source_head": "14038757ec6e6a6a192990e4a9d05ccdd002e46e", + "checkpoints": [], + "revision": 0, + "created_at": "2026-11-10T01:18:07.336656+00:00", + "updated_at": "2026-11-10T01:18:07.337162+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/open/sessions/dc058eaf-be75-4c09-bdad-c0c1fff90ce1.json b/tests/fixtures/persisted/sessions/v0.3.0/open/sessions/dc058eaf-be75-4c09-bdad-c0c1fff90ce1.json new file mode 100644 index 0000000..0b08c7f --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/open/sessions/dc058eaf-be75-4c09-bdad-c0c1fff90ce1.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "dc058eaf-be75-4c09-bdad-c0c1fff90ce1", + "project_id": "frozen-sessions-open", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/repo", + "agent_id": "codex", + "state": "open", + "tmux_name": "syncmill-p-frozen-s-codex-dc058eaf", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-dc058eaf-be75-4c09-bdad-c0c1fff90ce1", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-dc058eaf-be75-4c09-bdad-c0c1fff90ce1/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-dc058eaf-be75-4c09-bdad-c0c1fff90ce1/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "2e225657bea9677372e64df712e82422f2152e53", + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "source_head": "14038757ec6e6a6a192990e4a9d05ccdd002e46e", + "checkpoints": [], + "revision": 1, + "created_at": "2026-11-10T01:18:02.692161+00:00", + "updated_at": "2026-11-10T01:18:02.978852+00:00" +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/project/marker.gitignore b/tests/fixtures/persisted/sessions/v0.3.0/project/marker.gitignore new file mode 100644 index 0000000..6dfa913 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/project/marker.gitignore @@ -0,0 +1,2 @@ +memory/ +project.json diff --git a/tests/fixtures/persisted/sessions/v0.3.0/project/marker.json b/tests/fixtures/persisted/sessions/v0.3.0/project/marker.json new file mode 100644 index 0000000..4de69f1 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/project/marker.json @@ -0,0 +1,3 @@ +{ + "id": "7d66bc69-1bf8-442c-8332-22fddc302ded" +} diff --git a/tests/fixtures/persisted/sessions/v0.3.0/project/syncmill.toml b/tests/fixtures/persisted/sessions/v0.3.0/project/syncmill.toml new file mode 100644 index 0000000..92b30bd --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/project/syncmill.toml @@ -0,0 +1,10 @@ +config_version = 2 +repo_root = "." +default_strategy = "route" + +[project] +id = "7d66bc69-1bf8-442c-8332-22fddc302ded" +memory_dir = ".syncmill/memory" + +[gate] +commands = [] diff --git a/tests/fixtures/persisted/sessions/v0.3.0/recovery-required/sessions/e4a9c0f5-5819-4afb-a208-a2d8e1cf7475.json b/tests/fixtures/persisted/sessions/v0.3.0/recovery-required/sessions/e4a9c0f5-5819-4afb-a208-a2d8e1cf7475.json new file mode 100644 index 0000000..f930351 --- /dev/null +++ b/tests/fixtures/persisted/sessions/v0.3.0/recovery-required/sessions/e4a9c0f5-5819-4afb-a208-a2d8e1cf7475.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "session_id": "e4a9c0f5-5819-4afb-a208-a2d8e1cf7475", + "project_id": "frozen-sessions-recovery-required", + "repo_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/repo", + "agent_id": "codex", + "state": "recovery_required", + "tmux_name": "syncmill-p-frozen-s-codex-e4a9c0f5", + "run_root": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-e4a9c0f5-5819-4afb-a208-a2d8e1cf7475", + "control_repo": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-e4a9c0f5-5819-4afb-a208-a2d8e1cf7475/control", + "worktree_path": "/private/var/folders/4n/mt3km5rs1bn_b0w_d6hvlmhc0000gn/T/tmp6z2ca2fi/state/workspaces/session-e4a9c0f5-5819-4afb-a208-a2d8e1cf7475/worktrees/codex-session--0", + "worktree_branch": "syncmill/codex/session--0", + "base_commit": "86ca80d99c70edd6ddc07ee769f6c2b4d8a72cbf", + "source_fingerprint": "53c966b60bfbf5b8ce599a63f3798c468fb862b463df5c15f488a176b1d04c0a", + "source_head": "14038757ec6e6a6a192990e4a9d05ccdd002e46e", + "checkpoints": [], + "revision": 3, + "created_at": "2026-11-10T01:18:09.472679+00:00", + "updated_at": "2026-11-10T01:18:10.071579+00:00" +} diff --git a/tests/test_frozen_sessions.py b/tests/test_frozen_sessions.py new file mode 100644 index 0000000..70d3914 --- /dev/null +++ b/tests/test_frozen_sessions.py @@ -0,0 +1,557 @@ +"""Released isolated sessions and project markers, read by the current readers +(#271, persisted contracts D). + +The documents under ``fixtures/persisted/sessions//`` were written +by that release's own ``syncmill init`` and ``syncmill session`` commands +(see ``freeze.py`` there) and are never regenerated by the current writer. +``project/`` holds what ``init`` wrote: ``syncmill.toml`` and the marker +directory's ``project.json`` and ``.gitignore``, stored as ``marker.json`` and +``marker.gitignore``. Every other scenario is one project: its session +document and any checkpoint run artifact. Workspaces were not frozen. + +Only machine-bound values are rebound, each in a named place: a session +document's four paths (repository, workspace run root, control clone, +worktree) and its source fingerprint (recomputed in the stored value's own +version), and a checkpoint artifact's repository root and fingerprint. The +tmux name is derived from the project, agent and session id, so it is never +rebound. ``Frozen`` asserts every other byte is unchanged. + +tmux is never launched: the store's probe and command seams and the CLI's +gates are patched. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest +from click.testing import CliRunner + +from syncmill.cli import cli +from syncmill.worktree import fingerprint_version, repository_fingerprint + +FROZEN = Path(__file__).parent / "fixtures" / "persisted" / "sessions" +RELEASES = sorted(p.name for p in FROZEN.iterdir() if p.is_dir()) + +#: scenario -> (state, revision) the release left its session in. +SCENARIOS = { + "open": ("open", 1), + "checkpointed": ("open", 2), + "closed": ("closed", 4), + "creating": ("creating", 0), + "closing": ("closing", 2), + "recovery-required": ("recovery_required", 3), +} +#: The four paths a session document binds to the generating machine. +PATH_FIELDS = ("repo_root", "run_root", "control_repo", "worktree_path") +FIXED = "def add(a, b):\n return a + b\n" + + +def _bindings(release: str) -> dict: + return json.loads((FROZEN / release / "bindings.json").read_text()) + + +@pytest.fixture(params=RELEASES) +def release(request) -> str: + return request.param + + +def _repo(root: Path, tree: dict) -> Path: + """The generating repository's shape: the tree and ``syncmill.toml`` + committed, ``.syncmill/`` ignored.""" + repo = root / "repo" + repo.mkdir() + for name, text in tree.items(): + (repo / name).write_text(text, encoding="utf-8") + for argv in ( + ["init", "-q"], + ["add", "-A"], + ["-c", "user.name=t", "-c", "user.email=t@t.test", "-c", + "commit.gpgsign=false", "commit", "-q", "-m", "frozen tree"], + ): + subprocess.run(["git", *argv], cwd=repo, check=True, capture_output=True) + (repo / ".git" / "info" / "exclude").write_text(".syncmill/\n", encoding="utf-8") + return repo + + +class Frozen: + """One release session scenario installed as a project bound to ``repo``.""" + + def __init__(self, release: str, scenario: str, tmp_path: Path, monkeypatch): + self.release = release + self.scenario = scenario + self.bindings = _bindings(release) + self.facts = self.bindings["scenarios"][scenario] + self.dir = FROZEN / release / scenario + self.root = tmp_path.resolve() + self.repo = _repo(self.root, self.bindings["tree"]) + self.session_id = self.facts["session_id"] + self.runs_root = self.root / "state" / "runs" + self.workspace_root = self.root / "state" / "workspaces" + self.project_dir = self.runs_root / self.facts["project_id"] + monkeypatch.setenv("SYNCMILL_RUNS__ROOT", str(self.runs_root)) + monkeypatch.setenv("SYNCMILL_WORKSPACE__ROOT", str(self.workspace_root)) + monkeypatch.setenv("SYNCMILL_PROJECT__ID", self.facts["project_id"]) + stored = self.facts["source_fingerprint"] + self.fingerprint = repository_fingerprint(self.repo, like=stored) + self._install() + + def _rebound(self, data: bytes, *, paths: int) -> bytes: + stored = self.facts["source_fingerprint"].encode() + box = self.bindings["box_root"].encode() + assert data.count(stored) == 1 + assert data.count(box) == paths + return data.replace(stored, self.fingerprint.encode()).replace( + box, str(self.root).encode() + ) + + def _install(self) -> None: + sessions = self.project_dir / "sessions" + sessions.mkdir(parents=True) + [document] = (self.dir / "sessions").glob("*.json") + assert document.stem == self.session_id + installed = sessions / document.name + installed.write_bytes(self._rebound(document.read_bytes(), paths=len(PATH_FIELDS))) + # Only the bound fields moved. + released, now = json.loads(document.read_text()), json.loads(installed.read_text()) + bound = {*PATH_FIELDS, "source_fingerprint"} + assert {k: v for k, v in now.items() if k not in bound} == { + k: v for k, v in released.items() if k not in bound + } + if (self.dir / "runs").is_dir(): + import shutil + + shutil.copytree(self.dir / "runs", self.project_dir / "runs") + for manifest in self.project_dir.glob("runs/*/manifest.json"): + manifest.write_bytes(self._rebound(manifest.read_bytes(), paths=1)) + + def document(self) -> dict: + return json.loads( + (self.project_dir / "sessions" / f"{self.session_id}.json").read_text() + ) + + def released(self) -> dict: + [document] = (self.dir / "sessions").glob("*.json") + return json.loads(document.read_text()) + + def store(self): + from syncmill.isolated_session import IsolatedSessionStore + from syncmill.project import load_project_config + + _, config = load_project_config(self.repo) + return IsolatedSessionStore(config, self.repo) + + def cli(self, *argv: str): + return CliRunner().invoke(cli, [*argv, "--repo", str(self.repo)]) + + +@pytest.fixture +def no_tmux(monkeypatch): + """No real tmux anywhere: the store's probe and commands, the CLI's gates + and attachment. ``live`` says what the probe answers.""" + state = SimpleNamespace(live=False, attached=[], commands=[]) + + def tmux(*args, **kwargs): + state.commands.append(args) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr("syncmill.isolated_session.session_exists", lambda name: state.live) + monkeypatch.setattr("syncmill.isolated_session._tmux", tmux) + monkeypatch.setattr( + "syncmill.isolated_session._attach_and_report", + lambda name, agent: state.attached.append((name, agent)) or 0, + ) + monkeypatch.setattr("syncmill.cli.tmux_available", lambda: True) + monkeypatch.setattr("syncmill.cli._stdout_isatty", lambda: True) + return state + + +# ── inventory ───────────────────────────────────────────────────────────── + + +def test_every_release_freezes_every_scenario(): + assert RELEASES == ["v0.1.2", "v0.2.0", "v0.3.0"] + for release in RELEASES: + bindings = _bindings(release) + assert bindings["release"] == release.removeprefix("v") + assert sorted(bindings["scenarios"]) == sorted(SCENARIOS) + assert bindings["marker"]["mode"] == "0o600" + for name, facts in bindings["scenarios"].items(): + assert (facts["state"], facts["revision"]) == SCENARIOS[name], (release, name) + assert fingerprint_version(facts["source_fingerprint"]) == 1 + document = json.loads(next((FROZEN / release / name / "sessions").glob("*.json")) + .read_text()) + assert document["schema_version"] == 1 + assert (document["state"], document["revision"]) == SCENARIOS[name] + assert document["checkpoints"] == facts["checkpoints"] + runs = sorted(p.name for p in (FROZEN / release / name / "runs").glob("*")) \ + if (FROZEN / release / name / "runs").is_dir() else [] + assert runs == sorted(facts["checkpoints"]) + project = FROZEN / release / "project" + assert sorted(p.name for p in project.iterdir()) == [ + "marker.gitignore", "marker.json", "syncmill.toml" + ] + + +@pytest.mark.parametrize("scenario", list(SCENARIOS)) +def test_the_tmux_name_is_derived_not_bound(release, scenario): + from syncmill.isolated_session import session_tmux_name + + facts = _bindings(release)["scenarios"][scenario] + document = json.loads(next((FROZEN / release / scenario / "sessions").glob("*.json")) + .read_text()) + assert document["tmux_name"] == session_tmux_name( + facts["project_id"], facts["agent_id"], facts["session_id"] + ) + + +# ── listing and loading every released session ──────────────────────────── + + +@pytest.mark.parametrize("scenario", list(SCENARIOS)) +def test_isolated_list_reads_a_released_session(release, scenario, tmp_path, monkeypatch, no_tmux): + frozen = Frozen(release, scenario, tmp_path, monkeypatch) + state, _ = SCENARIOS[scenario] + listed = frozen.cli("session", "isolated-list") + assert listed.exit_code == 0, listed.output + [line] = listed.output.splitlines() + assert line.split() == [frozen.session_id, "codex", state, "stopped"] + + # Live is the probe's answer; staleness is the stored fingerprint + # recomputed in its own version, and only an open session is judged. + no_tmux.live = True + (frozen.repo / "calc.py").write_text(FIXED, encoding="utf-8") + [value] = frozen.store().list() + assert value["live"] is True + assert value["source_stale"] is (state == "open") + for field in PATH_FIELDS: + assert value[field].startswith(str(frozen.root) + "/") + + +@pytest.mark.parametrize("scenario", list(SCENARIOS)) +def test_render_session_shows_a_released_session(release, scenario, tmp_path, monkeypatch, no_tmux): + from syncmill.tui_collaboration import render_session + + frozen = Frozen(release, scenario, tmp_path, monkeypatch) + [value] = frozen.store().list() + text = render_session(value).splitlines() + assert text[0] == f"Session: {frozen.session_id}" + assert text[2] == f"State: {SCENARIOS[scenario][0]} | live: False" + assert text[3] == "Source: clean snapshot" + assert text[4] == f"Worktree: {frozen.document()['worktree_path']}" + assert text[5] == f"Checkpoints: {', '.join(frozen.facts['checkpoints']) or '-'}" + + +def _edit(frozen: Frozen, **fields) -> None: + path = frozen.project_dir / "sessions" / f"{frozen.session_id}.json" + value = json.loads(path.read_text()) + value.update(fields) + path.write_text(json.dumps(value, indent=2), encoding="utf-8") + + +@pytest.mark.parametrize("unsafe", ["workspace-root", "tmux-name", "worktree", "repository"]) +def test_a_released_session_with_unsafe_paths_is_refused( + release, unsafe, tmp_path, monkeypatch, no_tmux +): + frozen = Frozen(release, "open", tmp_path, monkeypatch) + # Cleanup and attachment are bound to paths and a name derived from the + # session id; a document that disagrees is never acted on. + if unsafe == "workspace-root": + monkeypatch.setenv("SYNCMILL_WORKSPACE__ROOT", str(frozen.root / "elsewhere")) + elif unsafe == "tmux-name": + _edit(frozen, tmux_name="syncmill-p-someone-else") + elif unsafe == "worktree": + _edit(frozen, worktree_path=str(frozen.root / "elsewhere")) + else: + (frozen.root / "other").mkdir() + other = _repo(frozen.root / "other", frozen.bindings["tree"]) + _edit(frozen, repo_root=str(other)) + before = frozen.document() + assert frozen.store().list() == [] + result = frozen.cli("session", "checkpoint", frozen.session_id) + error = ( + "session belongs to a different repository" if unsafe == "repository" + else "session metadata contains unsafe paths" + ) + assert (result.exit_code, result.output.splitlines()[-1]) == (1, f"Error: {error}") + assert frozen.document() == before + + +# ── the session commands ────────────────────────────────────────────────── + + +def test_attach_a_released_open_session(release, tmp_path, monkeypatch, no_tmux): + frozen = Frozen(release, "open", tmp_path, monkeypatch) + refused = frozen.cli("session", "attach-isolated", frozen.session_id) + assert (refused.exit_code, refused.output.splitlines()[-1]) == ( + 1, "Error: session process is no longer running" + ) + assert no_tmux.attached == [] + + # Running: its one pane must sit in the rebound worktree. + no_tmux.live = True + worktree = frozen.document()["worktree_path"] + Path(worktree).mkdir(parents=True) + + def panes(*args, **kwargs): + return subprocess.CompletedProcess(args, 0, stdout=worktree + "\n", stderr="") + + monkeypatch.setattr("syncmill.isolated_session._tmux", panes) + attached = frozen.cli("session", "attach-isolated", frozen.session_id) + assert attached.exit_code == 0, attached.output + assert no_tmux.attached == [(frozen.document()["tmux_name"], "codex")] + + # A pane anywhere else is never attached to. + monkeypatch.setattr("syncmill.isolated_session._PANE_CWD_SETTLE_SECONDS", 0) + + def elsewhere(*args, **kwargs): + return subprocess.CompletedProcess(args, 0, stdout=str(frozen.root) + "\n", stderr="") + + monkeypatch.setattr("syncmill.isolated_session._tmux", elsewhere) + refused = frozen.cli("session", "attach-isolated", frozen.session_id) + assert refused.exit_code == 1, refused.output + assert refused.output.splitlines()[-1] == ( + "Error: session cwd does not match its isolated workspace" + ) + assert len(no_tmux.attached) == 1 + + +@pytest.mark.parametrize("scenario", ["closed", "creating", "closing", "recovery-required"]) +def test_attach_refuses_a_released_session_that_is_not_open( + release, scenario, tmp_path, monkeypatch, no_tmux +): + frozen = Frozen(release, scenario, tmp_path, monkeypatch) + refused = frozen.cli("session", "attach-isolated", frozen.session_id) + assert (refused.exit_code, refused.output.splitlines()[-1]) == ( + 1, f"Error: session is {SCENARIOS[scenario][0]}, not open" + ) + + +@pytest.mark.parametrize("scenario", ["open", "checkpointed"]) +def test_checkpoint_refuses_a_released_session_without_its_workspace( + release, scenario, tmp_path, monkeypatch, no_tmux +): + frozen = Frozen(release, scenario, tmp_path, monkeypatch) + before = frozen.document() + result = frozen.cli("session", "checkpoint", frozen.session_id) + assert (result.exit_code, result.output.splitlines()[-1]) == ( + 1, "Error: isolated worktree is missing" + ) + assert frozen.document() == before + + +#: scenario -> the revision HEAD's close leaves, the workspace being absent. +CLOSED_AT = { + "open": 3, "checkpointed": 4, "closed": 4, "creating": 1, "closing": 3, + "recovery-required": 4, +} + + +@pytest.mark.parametrize("scenario", list(SCENARIOS)) +def test_close_a_released_session(release, scenario, tmp_path, monkeypatch, no_tmux): + frozen = Frozen(release, scenario, tmp_path, monkeypatch) + before = frozen.document() + result = frozen.cli("session", "close", frozen.session_id) + assert result.exit_code == 0, result.output + assert result.stdout.splitlines() == [f"Closed isolated session {frozen.session_id}"] + after = frozen.document() + assert (after["state"], after["revision"]) == ("closed", CLOSED_AT[scenario]) + if scenario == "closed": + assert after == before + else: + # The lifecycle fields move; the checkpoints and bindings never do. + moving = {"state", "revision", "updated_at"} + assert {k: v for k, v in after.items() if k not in moving} == { + k: v for k, v in before.items() if k not in moving + } + assert no_tmux.commands == [] # no process to kill: nothing was sent to tmux + + +# ── the checkpoint run artifact ─────────────────────────────────────────── + + +def _checkpoint(frozen: Frozen) -> tuple[str, Path]: + [run_id] = frozen.facts["checkpoints"] + return run_id, frozen.project_dir / "runs" / run_id + + +@pytest.mark.parametrize("scenario", ["checkpointed", "closed"]) +def test_a_released_checkpoint_reads_as_written(release, scenario, tmp_path, monkeypatch, no_tmux): + frozen = Frozen(release, scenario, tmp_path, monkeypatch) + run_id, run_dir = _checkpoint(frozen) + manifest = json.loads((run_dir / "manifest.json").read_text()) + assert manifest["state"] == "pending" + assert manifest["plan"] == {"origin": "isolated-session", "session_id": frozen.session_id} + listed = frozen.cli("runs", "list") + assert listed.exit_code == 0, listed.output + assert any(line.split()[:1] == [run_id] for line in listed.output.splitlines()) + shown = frozen.cli("runs", "show", run_id) + assert shown.exit_code == 0, shown.output + assert json.loads(shown.stdout) == manifest + diff = frozen.cli("diff", run_id) + assert diff.exit_code == 0, diff.output + assert diff.stdout == (run_dir / "winner.patch").read_text() + assert "+ return a + b" in diff.stdout + conversation = frozen.cli("conversations", "show", run_id) + assert conversation.exit_code == 0, conversation.output + assert json.loads(conversation.stdout) == { + "manifest": manifest, "result": json.loads((frozen.dir / "runs" / run_id / "result.json") + .read_text()), + } + # No provider session: not a conversation, and not continuable. + conversations = frozen.cli("conversations", "list") + assert conversations.exit_code == 0, conversations.output + assert run_id not in conversations.output + continued = frozen.cli("continue", run_id, "and more") + assert (continued.exit_code, continued.output.splitlines()[-1]) == ( + 1, "Error: run has no resumable provider session" + ) + recovered = frozen.cli("recover", run_id) + assert recovered.exit_code == 0, recovered.output + assert recovered.output.splitlines() == [ + f"Run {run_id}: skipped \N{EM DASH} run is 'pending', not applying" + ] + assert frozen.document()["checkpoints"] == [run_id] + + +def test_the_tui_renders_a_released_checkpoint(release, tmp_path, monkeypatch, no_tmux): + from syncmill.artifacts import ArtifactStore + from syncmill.tui import _read_artifact_result, render_artifact + + frozen = Frozen(release, "checkpointed", tmp_path, monkeypatch) + run_id, run_dir = _checkpoint(frozen) + store = ArtifactStore(frozen.runs_root, frozen.facts["project_id"]) + result = _read_artifact_result(run_id, store=store) + assert result == json.loads((run_dir / "result.json").read_text()) + text = render_artifact(json.loads((run_dir / "manifest.json").read_text()), result) + assert run_id in text and result["output"] in text + + +def test_apply_a_released_checkpoint(release, tmp_path, monkeypatch, no_tmux): + from syncmill.artifacts import ArtifactStore + + frozen = Frozen(release, "checkpointed", tmp_path, monkeypatch) + run_id, _ = _checkpoint(frozen) + store = ArtifactStore(frozen.runs_root, frozen.facts["project_id"]) + # The detached apply's preflight accepts it, without launching anything. + assert store.validate_applicable(run_id).run_id == run_id + applied = frozen.cli("apply", run_id) + assert applied.exit_code == 0, applied.output + assert (frozen.repo / "calc.py").read_text() == FIXED + assert frozen.document()["checkpoints"] == [run_id] + + +def test_prune_keeps_a_released_pending_checkpoint(release, tmp_path, monkeypatch, no_tmux): + frozen = Frozen(release, "closed", tmp_path, monkeypatch) + run_id, run_dir = _checkpoint(frozen) + # Pending: never pruned, even with nothing kept, although its session is closed. + kept = frozen.cli("runs", "prune", "--keep", "0") + assert kept.exit_code == 0, kept.output + assert run_dir.is_dir() + discarded = frozen.cli("discard", run_id) + assert discarded.exit_code == 0, discarded.output + assert json.loads((run_dir / "manifest.json").read_text())["state"] == "discarded" + pruned = frozen.cli("runs", "prune", "--keep", "0") + assert pruned.exit_code == 0, pruned.output + assert not run_dir.exists() + assert frozen.document()["checkpoints"] == [run_id] + + +def test_close_the_conversation_of_a_released_checkpoint(release, tmp_path, monkeypatch, no_tmux): + frozen = Frozen(release, "checkpointed", tmp_path, monkeypatch) + run_id, run_dir = _checkpoint(frozen) + before = json.loads((run_dir / "manifest.json").read_text()) + closed = frozen.cli("conversations", "close", run_id) + assert closed.exit_code == 0, closed.output + after = json.loads((run_dir / "manifest.json").read_text()) + # Closing needs no provider session: only the flag and the stamp move. + changed = {k for k in {*before, *after} if before.get(k) != after.get(k)} + assert changed == {"conversation_closed", "updated_at"} + assert (after["conversation_closed"], after["state"]) == (True, "pending") + assert frozen.document()["checkpoints"] == [run_id] + + +# ── the project marker and config ───────────────────────────────────────── + + +def _project(release: str, tmp_path: Path, monkeypatch) -> tuple[Path, dict]: + """A repository holding the released marker directory and config, with + no configured project id anywhere but the released ``syncmill.toml``.""" + monkeypatch.delenv("SYNCMILL_PROJECT__ID", raising=False) + bindings = _bindings(release) + repo = _repo(tmp_path.resolve(), bindings["tree"]) + stored = FROZEN / release / "project" + assert (repo / "syncmill.toml").read_bytes() == (stored / "syncmill.toml").read_bytes() + marker_dir = repo / ".syncmill" + marker_dir.mkdir() + (marker_dir / "project.json").write_bytes((stored / "marker.json").read_bytes()) + os.chmod(marker_dir / "project.json", int(bindings["marker"]["mode"], 8)) + (marker_dir / ".gitignore").write_bytes((stored / "marker.gitignore").read_bytes()) + return repo, bindings + + +def _path_hash(repo: Path) -> str: + import hashlib + + return hashlib.sha256(str(repo.resolve()).encode()).hexdigest()[:24] + + +def test_the_released_marker_names_the_project(release, tmp_path, monkeypatch): + from syncmill.project import project_id + + repo, bindings = _project(release, tmp_path, monkeypatch) + assert project_id(repo) == bindings["marker"]["id"] + + +def test_a_tracked_released_marker_is_not_trusted(release, tmp_path, monkeypatch, caplog): + from syncmill.project import project_id + + repo, bindings = _project(release, tmp_path, monkeypatch) + subprocess.run(["git", "add", "-f", ".syncmill/project.json"], cwd=repo, check=True) + with caplog.at_level(logging.WARNING): + assert project_id(repo) == _path_hash(repo) + assert any("project.json" in record.getMessage() for record in caplog.records) + + +def test_a_released_marker_through_a_symlink_is_not_trusted(release, tmp_path, monkeypatch): + from syncmill.project import project_id + + repo, _ = _project(release, tmp_path, monkeypatch) + elsewhere = tmp_path / "elsewhere" + (repo / ".syncmill").rename(elsewhere) + (repo / ".syncmill").symlink_to(elsewhere) + assert project_id(repo) == _path_hash(repo) + + +def test_the_released_config_loads_as_written(release, tmp_path, monkeypatch): + import tomllib + + from syncmill.project import load_project_config + + from syncmill.config import SyncmillConfig + + repo, bindings = _project(release, tmp_path, monkeypatch) + # The suite neutralizes cwd-relative discovery; this test is about it. + monkeypatch.setitem(SyncmillConfig.model_config, "toml_file", "syncmill.toml") + written = tomllib.loads((repo / "syncmill.toml").read_text()) + _, config = load_project_config(repo) + assert config.project.id == written["project"]["id"] == bindings["marker"]["id"] + assert config.default_strategy == written["default_strategy"] + assert config.gate.commands == written["gate"]["commands"] + assert Path(config.repo_root).resolve() == repo.resolve() + + +def test_reinit_keeps_the_released_marker_id(release, tmp_path, monkeypatch): + from syncmill.project import initialize_project + + repo, bindings = _project(release, tmp_path, monkeypatch) + # No configured id left: the released marker is the only identity. + (repo / "syncmill.toml").unlink() + _, pid = initialize_project(repo) + assert pid == bindings["marker"]["id"] + assert json.loads((repo / ".syncmill" / "project.json").read_text())["id"] == pid From 56535a07d4e5781a448b98cb4fd8ea3cb316e89f Mon Sep 17 00:00:00 2001 From: memtomem <118508877+memtomem@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:55:02 +0900 Subject: [PATCH 2/3] Bind the session tests to the version and apply they claim (#271) Review round 1: - the installed fingerprint is now asserted to keep the released v1 version (a recompute that ignored like= upgraded it to v2 before any reader saw it, and every test still passed); - applying the released checkpoint must leave the artifact applied with its receipt bound to the apply (skipping the applied flip passed); - release discovery takes only v*/ directories holding bindings.json (importing freeze.py made __pycache__ a "release"); - freeze.py stops its private tmux server before the box is removed and proves it gone; if it cannot, the socket is kept and its kill command printed. Generation output is unchanged. 16 injected regressions now each fail an intended assertion. Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/persisted/sessions/freeze.py | 31 ++++++++++++++++++--- tests/test_frozen_sessions.py | 9 +++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/persisted/sessions/freeze.py b/tests/fixtures/persisted/sessions/freeze.py index 378d88d..dfb581f 100644 --- a/tests/fixtures/persisted/sessions/freeze.py +++ b/tests/fixtures/persisted/sessions/freeze.py @@ -150,17 +150,40 @@ def main() -> None: # A tmux socket path may not exceed ~104 bytes: a short directory of its own. sockets = Path(tempfile.mkdtemp(prefix="smss.", dir="/tmp")) socket_path = sockets / "s" + stopped = False try: hook_files[0].write_text(CRASH_HOOK, encoding="utf-8") hook_files[1].write_text(f"import {HOOK_MODULE}\n", encoding="utf-8") with tempfile.TemporaryDirectory() as tmp: - _generate(Path(tmp).resolve(), real_tmux, socket_path, syncmill, version, out) + try: + _generate(Path(tmp).resolve(), real_tmux, socket_path, syncmill, version, out) + finally: + # Before the box goes: the agents run inside it. + stopped = _stop_private_server(real_tmux, socket_path) finally: for path in hook_files: path.unlink(missing_ok=True) - subprocess.run([real_tmux, "-S", str(socket_path), "kill-server"], - capture_output=True) - shutil.rmtree(sockets, ignore_errors=True) + if stopped: + shutil.rmtree(sockets, ignore_errors=True) + else: + sys.stderr.write( + f"the private tmux server may still run; stop it with " + f"`tmux -S {socket_path} kill-server`\n" + ) + + +def _stop_private_server(real_tmux: str, socket_path: Path) -> bool: + """Kill the private server and prove it is gone: a server that answers + `list-sessions` afterwards still runs agents.""" + subprocess.run([real_tmux, "-S", str(socket_path), "kill-server"], capture_output=True) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + probe = subprocess.run([real_tmux, "-S", str(socket_path), "list-sessions"], + capture_output=True) + if probe.returncode != 0: + return True + time.sleep(0.2) + return False def _generate(box: Path, real_tmux: str, socket_path: Path, syncmill: Path, version: str, diff --git a/tests/test_frozen_sessions.py b/tests/test_frozen_sessions.py index 70d3914..8c80492 100644 --- a/tests/test_frozen_sessions.py +++ b/tests/test_frozen_sessions.py @@ -36,7 +36,7 @@ from syncmill.worktree import fingerprint_version, repository_fingerprint FROZEN = Path(__file__).parent / "fixtures" / "persisted" / "sessions" -RELEASES = sorted(p.name for p in FROZEN.iterdir() if p.is_dir()) +RELEASES = sorted(p.parent.name for p in FROZEN.glob("v*/bindings.json")) #: scenario -> (state, revision) the release left its session in. SCENARIOS = { @@ -99,6 +99,8 @@ def __init__(self, release: str, scenario: str, tmp_path: Path, monkeypatch): monkeypatch.setenv("SYNCMILL_PROJECT__ID", self.facts["project_id"]) stored = self.facts["source_fingerprint"] self.fingerprint = repository_fingerprint(self.repo, like=stored) + # Recomputed in the stored value's own version, never upgraded. + assert fingerprint_version(self.fingerprint) == fingerprint_version(stored) == 1 self._install() def _rebound(self, data: bytes, *, paths: int) -> bytes: @@ -443,6 +445,11 @@ def test_apply_a_released_checkpoint(release, tmp_path, monkeypatch, no_tmux): applied = frozen.cli("apply", run_id) assert applied.exit_code == 0, applied.output assert (frozen.repo / "calc.py").read_text() == FIXED + manifest = store.load(run_id).manifest + assert manifest["state"] == "applied" + [receipt] = (store.root / run_id / "applications").glob("*.json") + assert json.loads(receipt.read_text())["run_id"] == run_id + assert json.loads(receipt.read_text())["apply_id"] == manifest["apply_id"] assert frozen.document()["checkpoints"] == [run_id] From 1eacaf9ffc32a389fc543617284015056f92972d Mon Sep 17 00:00:00 2001 From: memtomem <118508877+memtomem@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:16:48 +0900 Subject: [PATCH 3/3] Keep a session box whose tmux server will not stop (#271) Review round 2: - freeze.py now removes neither the box nor the socket, and exits nonzero, when it cannot prove its private tmux server stopped (round 1's fix still deleted the box and returned success); checked with the shutdown reported as failed; - the released config is also checked for its config_version and project.memory_dir; - conversations close is checked against a pinned clock instead of a timestamp that merely differs. 16 injected regressions still each fail an intended assertion. Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/persisted/sessions/freeze.py | 24 +++++++++++---------- tests/test_frozen_sessions.py | 8 ++++++- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/fixtures/persisted/sessions/freeze.py b/tests/fixtures/persisted/sessions/freeze.py index dfb581f..8199f2e 100644 --- a/tests/fixtures/persisted/sessions/freeze.py +++ b/tests/fixtures/persisted/sessions/freeze.py @@ -150,26 +150,28 @@ def main() -> None: # A tmux socket path may not exceed ~104 bytes: a short directory of its own. sockets = Path(tempfile.mkdtemp(prefix="smss.", dir="/tmp")) socket_path = sockets / "s" + box = Path(tempfile.mkdtemp()).resolve() stopped = False try: hook_files[0].write_text(CRASH_HOOK, encoding="utf-8") hook_files[1].write_text(f"import {HOOK_MODULE}\n", encoding="utf-8") - with tempfile.TemporaryDirectory() as tmp: - try: - _generate(Path(tmp).resolve(), real_tmux, socket_path, syncmill, version, out) - finally: - # Before the box goes: the agents run inside it. - stopped = _stop_private_server(real_tmux, socket_path) + try: + _generate(box, real_tmux, socket_path, syncmill, version, out) + finally: + # Before the box goes: the agents run inside it. + stopped = _stop_private_server(real_tmux, socket_path) finally: for path in hook_files: path.unlink(missing_ok=True) if stopped: + shutil.rmtree(box, ignore_errors=True) shutil.rmtree(sockets, ignore_errors=True) - else: - sys.stderr.write( - f"the private tmux server may still run; stop it with " - f"`tmux -S {socket_path} kill-server`\n" - ) + if not stopped: + # Nothing is removed while agents may still run in the box. + sys.exit( + f"the private tmux server did not stop; stop it with " + f"`tmux -S {socket_path} kill-server`, then remove {box} and {sockets}" + ) def _stop_private_server(real_tmux: str, socket_path: Path) -> bool: diff --git a/tests/test_frozen_sessions.py b/tests/test_frozen_sessions.py index 8c80492..2ae1c05 100644 --- a/tests/test_frozen_sessions.py +++ b/tests/test_frozen_sessions.py @@ -473,13 +473,17 @@ def test_close_the_conversation_of_a_released_checkpoint(release, tmp_path, monk frozen = Frozen(release, "checkpointed", tmp_path, monkeypatch) run_id, run_dir = _checkpoint(frozen) before = json.loads((run_dir / "manifest.json").read_text()) + stamp = "2031-01-01T00:00:00+00:00" + monkeypatch.setattr("syncmill.artifacts._now", lambda: stamp) closed = frozen.cli("conversations", "close", run_id) assert closed.exit_code == 0, closed.output after = json.loads((run_dir / "manifest.json").read_text()) # Closing needs no provider session: only the flag and the stamp move. changed = {k for k in {*before, *after} if before.get(k) != after.get(k)} assert changed == {"conversation_closed", "updated_at"} - assert (after["conversation_closed"], after["state"]) == (True, "pending") + assert (after["conversation_closed"], after["state"], after["updated_at"]) == ( + True, "pending", stamp + ) assert frozen.document()["checkpoints"] == [run_id] @@ -548,6 +552,8 @@ def test_the_released_config_loads_as_written(release, tmp_path, monkeypatch): written = tomllib.loads((repo / "syncmill.toml").read_text()) _, config = load_project_config(repo) assert config.project.id == written["project"]["id"] == bindings["marker"]["id"] + assert config.config_version == written["config_version"] + assert config.project.memory_dir == written["project"]["memory_dir"] assert config.default_strategy == written["default_strategy"] assert config.gate.commands == written["gate"]["commands"] assert Path(config.repo_root).resolve() == repo.resolve()