# Plan: Fix `.swival/` write races under parallel A2A contexts

Reported in [#7 (comment)](https://github.com/Swival/swival/issues/7#issuecomment-4066584968):
multiple A2A contexts share the same `base_dir`, so all sessions read and
write the same `.swival/` directory concurrently.

## Problem

`A2aServer._create_session()` passes identical `base_dir` to every Session.
The per-context asyncio lock only serializes calls *within* a single context —
different contexts run `ask()` in parallel on separate worker threads, all
hitting the same files.

## Affected files ranked by severity

| File | Writer | Risk | Failure mode |
|---|---|---|---|
| `todo.md` | `TodoState._save()` | **High** | Read-modify-write cycle across tool calls; last writer wins, items silently lost |
| `trash/` | `_delete_file()`, `_cleanup_trash()` | **High** | Size-cap enforcement can delete another context's just-added entry; `exclude` arg is local to one call |
| `HISTORY.md` | `append_history()` | **Medium** | Append-mode writes are atomic at the syscall level, but the size-cap check is TOCTOU — multiple contexts can all pass the check and overshoot the cap |
| `continue.md` | `write_continue_file()` | **Medium** | Two-phase write (deterministic then LLM-enhanced) can interleave with another context's write or a `load_continue_file(delete=True)` |
| `cmd_output_*.txt` | `_save_large_output()` | **Medium** | Files are UUID-named so writes don't collide, but the cleanup timer can delete a file while another context is still reading it |
| `cache.db` | `LLMCache.put()` | **Low** | Multiple connections to the same WAL-mode DB is fine; SQLite serializes writers internally. Main risk is busy-timeout under very high concurrency |

`memory/MEMORY.md` and `repl_history` are read-only or REPL-only — not at risk.

## Strategy

Use **per-context scratch directories** to separate files that are logically
per-conversation, preventing write races between contexts. This is race
separation, not access isolation — other contexts can still reach these paths
via normal file tools (`read_file`, `write_file`), which is acceptable because
the goal is preventing *uncoordinated concurrent writes*, not enforcing a
security boundary.

Keep genuinely shared state (cache, memory, history, trash) at the project
level with proper concurrency hardening.

### Phase 1: Per-context scratch directories

Give each A2A context its own scratch area under
`.swival/contexts/<context_id>/` for files that are logically per-conversation.

1. **New `scratch_dir` parameter** — add to `Session.__init__()`. When set,
   modules that write per-conversation temp files use it instead of
   `.swival/`. When unset (CLI, REPL), behavior is unchanged.

2. **`A2aServer._create_session()`** — compute
   `.swival/contexts/<context_id>/` and pass it as `scratch_dir`.

3. **`todo.md`** — `TodoState` takes `notes_dir` (not a direct path) and
   derives `.swival/todo.md` via `_safe_todo_path()` at `todo.py:21`.
   `Session` passes `notes_dir=self.base_dir` at `session.py:333`. To make
   this per-context:
   - Add a `todo_dir` parameter to `TodoState.__init__()` that, when set,
     is used directly as the todo file's parent instead of deriving
     `notes_dir/.swival/todo.md`. Refactor `_safe_todo_path()` to accept
     this override.
   - In `Session._make_per_run_state()`, pass `todo_dir=scratch_dir` when
     `scratch_dir` is set, otherwise fall back to existing `notes_dir`
     behavior.
   - Update the tool description at `tools.py:338` to not hardcode the
     path (say "persistent checklist" instead of ".swival/todo.md").

4. **`continue.md`** — disable continue-here when `--serve` is active.
   Continue files exist for humans resuming interrupted CLI sessions; A2A
   clients retry via the protocol. No point writing them per-context.

5. **`cmd_output_*.txt`** — write to `scratch_dir/` instead of `.swival/`.
   Each context's cleanup timer only touches its own directory. Add a
   try/except around reads so a missing file (cleaned up by eviction) returns
   a clear error instead of a traceback.

6. **Propagation surface** — `scratch_dir` must be threaded through more than
   just `session.py` and `a2a_server.py`:
   - `dispatch()` and `handle_tool_call()` in `agent.py` (currently only
     pass `base_dir` — `scratch_dir` needs to be a parallel parameter)
   - `_save_large_output()` in `tools.py` (called directly with `base_dir`)
   - `fetch.py:289` (calls `_save_large_output(output, base_dir)` directly)
   - `TodoState` construction in `session.py`
   - Any test that constructs tool args with an expected `.swival/` output
     path (e.g. `tests/test_run_command.py:562`)

7. **Cleanup on eviction** — when `_remove_context()` evicts a session,
   delete its `scratch_dir`. This is safe because scratch only contains
   transient per-conversation state (todo, cmd_output). No user-recoverable
   data lives here.

### Phase 2: Harden shared state

Files that are intentionally shared across contexts stay at the project level
and get concurrency fixes.

1. **`trash/`** — stays shared at `.swival/trash/`. Trash has retention-based
   semantics (`TRASH_MAX_AGE`, `TRASH_MAX_BYTES`) that must survive context
   eviction — tying trash lifetime to session lifetime would break the
   recoverability contract. Instead, harden `_delete_file()`:
   - Acquire `fcntl.flock` on `.swival/trash/.lock` in `_delete_file()`
     *before* the pre-move cleanup (line 1689) and hold it through the
     move-to-trash (line 1700–1704), the post-move cleanup (line 1713),
     and the index append (line 1728). Release after the index write.
   - This is necessary because locking only `_cleanup_trash()` is not
     sufficient: context B could run a locked cleanup between context A's
     move and A's post-move `cleanup(exclude=trash_id)`, deleting A's
     fresh entry since B has no knowledge of A's `exclude` id.
   - The lock serializes the entire trash critical section (cleanup + move +
     cleanup + index) per caller, not just cleanup in isolation.
   - Standalone `_cleanup_trash()` calls (e.g. at startup) should also
     acquire the same lock for consistency.

2. **`HISTORY.md`** — wrap the size check + append in `fcntl.flock` on
   `.swival/HISTORY.md.lock`. This makes the capacity guard atomic with the
   write. On platforms without `fcntl` (Windows), fall back to skip-on-error.

3. **`cache.db`** — already safe under WAL. Add `PRAGMA busy_timeout = 5000`
   in `LLMCache.open()` so concurrent writers wait instead of failing
   immediately.

4. **`memory/`** — read-only from the agent. No change needed. If `/learn`
   is ever exposed over A2A, it would need a file lock around the write.

Files touched: `tools.py` (trash lock, `_save_large_output` scratch_dir),
`agent.py` (history lock, handle_tool_call plumbing), `cache.py`
(busy_timeout), `fetch.py` (scratch_dir passthrough).

### Phase 3: Tests

Tests should validate the *multi-context separation* design, not shared-path
thread safety (Phase 1 prevents shared paths, so testing concurrent writes to
a single path is testing unsupported behavior).

1. **Multi-context todo isolation** — create two `TodoState` instances with
   different `todo_dir` paths. Both add items concurrently. Assert each
   checklist contains only its own items and neither is corrupted.

2. **Multi-context cmd_output isolation** — two sessions write large output
   concurrently with different scratch dirs. Assert each session reads back
   its own output. Evict one session and assert its cmd_output files are
   cleaned up while the other's remain.

3. **Vanished cmd_output read** — write a `cmd_output_*.txt` file, delete it
   (simulating eviction or timer cleanup), then call `_read_file()` with the
   now-stale path. Assert the result starts with `"error: "` and contains a
   meaningful message, not a traceback. This pins the behavior promised by
   Phase 1 step 5, since the current `_read_file()` existence check at
   `tools.py:1042` would pass if the file disappears between check and open.

4. **Trash cleanup serialization** — spawn multiple threads calling
   `_cleanup_trash()` on the same shared trash directory concurrently. Assert
   no `FileNotFoundError` and that the size cap is respected within a
   reasonable margin.

5. **History append under contention** — spawn threads calling
   `append_history()` concurrently. Assert final file contains all entries
   without corruption and size stays within cap (within one entry's worth of
   tolerance).

6. **A2A integration test** — start a serve instance with a mock LLM, send
   parallel requests on different context IDs, assert all responses complete
   without errors. Verify each context's todo state is independent. Verify
   trash entries from all contexts survive in the shared trash directory.

## Alternatives considered

**Global lock around all `.swival/` writes.** Simple but serializes unrelated
contexts, defeating the point of parallel sessions.

**SQLite for all shared state (todo, history, etc.).** More robust but heavy
refactor; todo and continue are simple text files that don't need a database.
Could be worth it for history long-term.

**Per-context `base_dir`.** Would isolate everything including the working
directory, but then tool calls (read_file, write_file, run_command) operate on
separate copies of the project, which is wrong.

**Access-isolating scratch dirs (outside base_dir or gated by per-context
checks).** Overkill for this problem. The threat model is uncoordinated
concurrent writes, not malicious cross-context access. Agents within the same
project already share the full filesystem via `safe_resolve(base_dir)`. If
true multi-tenancy is ever needed, it belongs at the `base_dir` level with
separate project roots.

## Order of work

1. Add `scratch_dir` to `Session.__init__()`, default `None` (no behavior
   change for CLI/REPL)
2. Wire `scratch_dir` through `dispatch()`, `handle_tool_call()`, and
   `_save_large_output()` (including `fetch.py` call site)
3. Refactor `TodoState`: add `todo_dir` parameter, update `_safe_todo_path()`
   to accept the override, update `Session._make_per_run_state()` to pass it
4. Move `cmd_output_*.txt` to `scratch_dir` when set
5. `A2aServer._create_session()` computes and passes `scratch_dir`
6. Disable continue-here in serve mode
7. Add `fcntl.flock` around the full trash critical section in
   `_delete_file()` (pre-cleanup through index append) and in standalone
   `_cleanup_trash()` calls
8. Add `fcntl.flock` to `append_history()`
9. Add `PRAGMA busy_timeout` to `cache.py`
10. Delete scratch dir on context eviction in `_remove_context()`
11. Update tool description for todo (remove hardcoded path)
12. Write Phase 3 tests
13. Update CLAUDE.md architecture notes
