6Plan inventory storage abstraction.
10Single source of truth for plan state and metadata. A small JSON file
11at ``oc_chat/plans/.inventory.json`` accessed through the stable
12:class:`PlanInventory` interface. Future SQLite/Postgres backends are
13sibling classes; nothing in ``plans.py`` or ``commands.py`` changes.
17- :class:`PlanRecord` — frozen dataclass holding plan_id, status,
18 description, timestamps, and originating session_name.
19- :class:`PlanInventory` (ABC) — register/get/update_status/remove/all/
20 list_by_status/next_prefix.
21- :class:`JsonPlanInventory` — concrete JSON-file implementation with
22 atomic write-temp-then-rename semantics; tolerates missing file by
23 auto-creating an empty inventory.
24- :func:`get_inventory` — process-wide singleton factory.
25- :func:`reconcile` — heal disk/inventory drift (inventory → filesystem
26 direction). Filenames get renamed to mirror inventory.status; orphan
27 files (on disk but not in inventory) get absorbed.
33 L2 — inventory lifecycle events (load, save, reconcile actions)
34 L3 — per-record semantic details (status changes, prefix allocation)
35 L4 — deep tracing (every read/write)
39- All public mutators are atomic at the inventory level (write-to-temp +
40 rename). Crash mid-op leaves the on-disk inventory untouched.
41- ``PlanRecord`` is frozen; updates produce new records via
42 :meth:`PlanRecord.with_status` / :meth:`with_completed_at`.
43- ``status`` is one of ``{"active", "paused", "complete", "aborted"}``.
44- ``next_prefix`` raises :class:`RuntimeError` when 999 is taken
45 (archive-at-999 deferred to a future phase).
46- ``reconcile`` is idempotent.
50- oc_diagnostics (structured debug logging via _dbg)
52from __future__
import annotations
58from abc
import ABC, abstractmethod
59from dataclasses
import asdict, dataclass, field, replace
60from datetime
import datetime
61from pathlib
import Path
64from oc_diagnostics
import _dbg
66VALID_STATUSES = (
"active",
"paused",
"complete",
"aborted")
67TERMINAL_STATUSES = (
"complete",
"aborted")
75DEFAULT_PLANS_DIR = Path(
"oc_chat/plans")
78DEFAULT_INVENTORY_NAME =
".inventory.json"
83NOT_APPLIED =
"NOT_APPLIED"
88_FILENAME_RE = re.compile(
90 r"^(?P<stem>\d{3}-[a-z0-9-]+?)(?:_(?P<state>paused|complete|aborted))?\.md$"
94@dataclass(frozen=True)
96 """All caller-controlled filesystem inputs to one plan operation.
98 Plan 257 P3, in the ``DispatchRequest`` shape the dispatch layer
99 already uses: a frozen dataclass whose defaults are today's values,
100 constructed at the boundary and passed whole.
102 ``plans_dir`` is late-bound through a default factory rather than
103 captured at class-creation time, so a caller that monkeypatches
104 :data:`DEFAULT_PLANS_DIR` is still honoured -- the same reason
105 ``PrepareRequest.interpreter`` uses a factory in
109 plans_dir: Path = field(default_factory=
lambda: DEFAULT_PLANS_DIR)
111 inventory_path_override: Path |
None =
None
115 """The inventory file: the override, else derived from *plans_dir*."""
118 return self.
plans_dir / DEFAULT_INVENTORY_NAME
121 """Where *plan_id*'s markdown file lives, relative to a repo root.
123 Plan 270 P3. ``plan_status.py`` and ``plan_manage.py`` each built
124 this by hand from a literal ``Path("oc_chat") / "plans"``, which
125 is a CONSUMER's layout and cannot be hard-coded in a standalone
126 package. Both now ask here, so the layout is stated once beside
127 the inventory path it sits next to, and a consumer with a
128 different tree overrides it by constructing a :class:`PlanPaths`
129 with its own ``plans_dir``.
131 The value is deliberately RELATIVE: it is recorded in registry
132 rows and in manifests as the plan's identity on disk, where an
133 absolute path would differ per machine and per worktree.
140 """Return a local-with-offset ISO 8601 timestamp.
142 Form: ``YYYY-MM-DDTHH:MM:SS±HH:MM`` (e.g. ``2026-05-17T00:25:42+02:00``).
143 Local wall-clock time + explicit zone offset per the project-wide
144 convention in :doc:`CHANGELOG-format.md` §"Timestamps".
146 return datetime.now().astimezone().replace(microsecond=0).isoformat()
149@dataclass(frozen=True)
151 """A single plan's metadata and current status.
153 Status values: ``active``, ``paused``, ``complete``, ``aborted``.
154 ``plan_id`` is the filename stem without ``.md`` and without state
155 suffix — e.g., ``032-sunny-weaving-honey``.
159 status: str =
"active"
160 created_at: str = field(default_factory=_now_iso)
161 completed_at: str |
None =
None
162 aborted_at: str |
None =
None
163 session_name: str |
None =
None
166 """Serialise for JSON storage. Omits ``plan_id`` (it's the dict key)."""
172 def from_dict(cls, plan_id: str, data: dict[str, Any]) -> PlanRecord:
173 """Reconstruct from JSON. Missing optional fields get defaults."""
176 description=data.get(
"description",
""),
177 status=data.get(
"status",
"active"),
178 created_at=data.get(
"created_at")
or _now_iso(),
179 completed_at=data.get(
"completed_at"),
180 aborted_at=data.get(
"aborted_at"),
181 session_name=data.get(
"session_name"),
186 """Backend-agnostic interface for plan storage."""
193 session_name: str |
None =
None,
195 """Insert a new active record. Raises if plan_id already exists."""
198 def get(self, plan_id: str) -> PlanRecord |
None:
199 """Return the record for plan_id, or None if missing."""
203 """Transition a record's status. Sets completed_at/aborted_at as
204 appropriate. Raises if plan_id is unknown or status is invalid."""
208 """Delete a record. No-op if plan_id is unknown."""
211 def all(self) -> list[PlanRecord]:
212 """Return all records, unsorted."""
216 """Return records with the given status."""
220 """Return the next available 3-digit prefix (1-based). Considers
221 BOTH inventory records and on-disk plan files. Raises
222 :class:`RuntimeError` if 999 is taken."""
225class JsonPlanInventory(PlanInventory):
226 """JSON-file-backed plan inventory.
228 File format (version 1)::
233 "<plan_id>": { "description": str, "status": str,
234 "created_at": str, "completed_at": str|None,
235 "aborted_at": str|None,
236 "session_name": str|None },
241 Writes are atomic: a fresh temp file is written and then renamed
242 over the inventory path. A corrupt or missing file is recovered
243 transparently with an empty inventory.
246 def __init__(self, path: Path, plans_dir: Path |
None =
None) ->
None:
247 """Open (or create) the inventory at *path*.
249 ``plans_dir`` defaults to the parent of *path* and is used to
250 check existing on-disk filenames when allocating new prefixes.
252 func =
"JsonPlanInventory.__init__"
257 _dbg(
"OC_CHAT.MAIN", func,
258 f
"opened {self._path} ({len(self._records)} record(s))", 2)
263 """Load the inventory file. Missing or corrupt → empty inventory."""
264 func =
"JsonPlanInventory._load_or_init"
265 if not self.
_path.exists():
266 _dbg(
"OC_CHAT.MAIN", func, f
"no inventory at {self._path}; starting empty", 2)
270 raw = self.
_path.read_bytes()
271 if raw.startswith(b
"\xef\xbb\xbf"):
273 data = json.loads(raw.decode(
"utf-8"))
274 except (OSError, json.JSONDecodeError)
as exc:
275 _dbg(
"OC_CHAT.MAIN", func,
277 f
"SYSTEM_ERROR: inventory unreadable, starting empty: {exc}",
282 plans = data.get(
"plans", {})
284 pid: PlanRecord.from_dict(pid, rec)
285 for pid, rec
in plans.items()
289 """Atomically persist the current records to disk."""
290 func =
"JsonPlanInventory._write"
292 _dbg(
"OC_CHAT.MAIN", func, f
"wrote {len(self._records)} record(s)", 4)
295 """Durably write *candidate* to disk. Does NOT publish it in memory.
297 The durable half of the owner atomic op (plan 152 R11-05). Splitting it
298 out is what lets callers write BEFORE publishing: previously ``register``
299 and ``update_status`` mutated the live ``_records`` map and only then
300 called this, so a serialization / temp-write / replace failure left
301 memory NEW while disk stayed OLD — and a compensating second write is
302 itself fallible, so wrapping the call sites could not fix it.
304 The temp file is flushed AND fsynced before the replace. Without the
305 fsync, ``Path.replace`` can be durable while the temp file's contents
306 are still only in the page cache, so a crash at the wrong moment leaves
307 the inventory pointing at a truncated or empty file — atomic in name
308 only. Temp cleanup runs in a ``finally`` so a failure never strews
309 ``.inventory_*.tmp`` files.
311 self.
_path.parent.mkdir(parents=
True, exist_ok=
True)
314 "plans": {pid: r.to_dict()
for pid, r
in candidate.items()},
316 tmp_path: Path |
None =
None
318 with tempfile.NamedTemporaryFile(
319 mode=
"w", encoding=
"utf-8", dir=self.
_path.parent,
320 prefix=
".inventory_", suffix=
".tmp", delete=
False,
322 tmp_path = Path(tmp.name)
323 json.dump(payload, tmp, indent=2, ensure_ascii=
False)
325 os.fsync(tmp.fileno())
326 tmp_path.replace(self.
_path)
329 if tmp_path
is not None and tmp_path.exists():
336 """Durably commit *candidate*, publishing in memory ONLY after it lands.
338 The owner atomic op. Returns a typed receipt:
340 - ``NOT_APPLIED`` — the durable write failed; memory AND disk are OLD, so
341 the caller may retry or report cleanly.
342 - ``APPLIED`` — disk committed and memory published; they agree.
343 - ``UNKNOWN`` — the durable write landed but publishing did not complete.
344 Fail closed: the caller must report an indeterminate state rather than
347 Ordering is the whole point: disk first, memory second. The reverse — the
348 pre-existing shape — could leave the in-process view claiming a plan
349 exists that no restart would ever see.
351 func =
"JsonPlanInventory.commit_atomic"
354 except Exception
as exc:
355 _dbg(
"OC_CHAT.MAIN", func,
357 f
"EXTERNAL_ERROR: durable write failed, nothing published: "
358 f
"{type(exc).__name__}: {exc}", 2, override=
True)
362 except Exception
as exc:
363 _dbg(
"OC_CHAT.MAIN", func,
364 f
"SYSTEM_ERROR: durable write landed but publish failed: {exc}",
375 session_name: str |
None =
None,
377 func =
"JsonPlanInventory.register"
379 raise ValueError(f
"plan_id already registered: {plan_id!r}")
382 description=description,
384 session_name=session_name,
389 candidate[plan_id] = rec
391 if receipt != APPLIED:
393 f
"could not durably register plan {plan_id!r} (receipt={receipt})"
395 _dbg(
"OC_CHAT.MAIN", func, f
"registered {plan_id!r}", 3)
398 def get(self, plan_id: str) -> PlanRecord |
None:
402 func =
"JsonPlanInventory.update_status"
403 if new_status
not in VALID_STATUSES:
405 f
"invalid status {new_status!r}; expected one of {VALID_STATUSES}"
409 raise KeyError(f
"unknown plan_id: {plan_id!r}")
411 updates: dict[str, Any] = {
"status": new_status}
412 if new_status ==
"complete" and existing.completed_at
is None:
413 updates[
"completed_at"] =
_now_iso()
414 elif new_status ==
"aborted" and existing.aborted_at
is None:
417 new_rec = replace(existing, **updates)
419 candidate[plan_id] = new_rec
421 if receipt != APPLIED:
423 f
"could not durably update plan {plan_id!r} (receipt={receipt})"
425 _dbg(
"OC_CHAT.MAIN", func,
426 f
"{plan_id!r}: {existing.status} → {new_status}", 3)
430 func =
"JsonPlanInventory.remove"
434 _dbg(
"OC_CHAT.MAIN", func, f
"removed {plan_id!r}", 3)
436 def all(self) -> list[PlanRecord]:
440 return [r
for r
in self.
_records.values()
if r.status == status]
443 """Return the next prefix not used by inventory or on-disk files.
445 TODO(phase-future): archive-and-reset when 999 is reached. For
446 now, raise loudly so we fail rather than silently overflow.
448 used: set[int] = set()
451 used.add(int(plan_id.split(
"-", 1)[0]))
456 m = _FILENAME_RE.match(f.name)
459 used.add(int(m.group(
"stem").split(
"-", 1)[0]))
462 for n
in range(1, 1000):
466 "Plan directory full (999 prefixes used); manual archive required"
472_default_inventory: PlanInventory |
None =
None
476 path: Path |
None =
None,
478 paths: PlanPaths |
None =
None,
480 """Return the process-wide default inventory.
482 First call may pass an explicit ``path`` (used by tests). Subsequent
483 calls return the cached instance regardless of ``path``. Pass
484 ``path`` only on first call; pass ``None`` thereafter.
486 Plan 257 P3: the default location is now read off :class:`PlanPaths`
487 rather than from a second literal, and the unused
488 ``persistence.DEFAULT_SESSIONS_DIR`` import that used to sit on the
489 line above it is gone. That one dead line pulled 43 modules --
490 session, config, defaults, startup and the rest -- into this
491 module's import closure, and importing them ran ``load_dotenv`` and
492 created a log directory as a side effect of asking for a plan list.
494 global _default_inventory
495 if _default_inventory
is None:
497 path = (paths
or PlanPaths()).inventory_path
499 return _default_inventory
503 """Test-only helper to clear the cached singleton."""
504 global _default_inventory
505 _default_inventory =
None
512 """Compute the expected on-disk filename for a record."""
513 if status ==
"active":
514 return f
"{plan_id}.md"
515 return f
"{plan_id}_{status}.md"
518def reconcile(inventory: PlanInventory, plans_dir: Path) -> dict[str, list[str]]:
519 """Heal disk/inventory drift. Inventory wins.
523 1. For every inventory record, ensure exactly one file exists at the
524 expected filename. If the file is at a different suffix, rename
525 it. If no file exists for the plan_id, log a warning (we do not
526 create empty files — the agent may not have written the plan
529 2. For every on-disk file matching the plan-filename regex that
530 isn't in the inventory, absorb it: register a new record with
531 status derived from the suffix and an empty description.
533 Returns a dict with three lists for callers/tests to inspect:
534 ``renamed``, ``orphans_absorbed``, ``missing_files``.
537 result: dict[str, list[str]] = {
539 "orphans_absorbed": [],
542 plans_dir = Path(plans_dir)
543 if not plans_dir.is_dir():
544 _dbg(
"OC_CHAT.MAIN", func, f
"plans_dir missing: {plans_dir}", 2)
548 for rec
in inventory.all():
550 expected_path = plans_dir / expected
551 if expected_path.exists():
555 p
for p
in plans_dir.glob(f
"{rec.plan_id}*.md")
556 if _FILENAME_RE.match(p.name)
557 and _FILENAME_RE.match(p.name).group(
"stem") == rec.plan_id
560 result[
"missing_files"].append(rec.plan_id)
561 _dbg(
"OC_CHAT.MAIN", func,
562 f
"inventory has {rec.plan_id!r} but no file on disk", 2)
566 src.rename(expected_path)
567 result[
"renamed"].append(f
"{src.name} → {expected}")
568 _dbg(
"OC_CHAT.MAIN", func, f
"renamed {src.name} → {expected}", 3)
569 except OSError
as exc:
570 _dbg(
"OC_CHAT.MAIN", func,
571 f
"SYSTEM_ERROR: rename failed for {src}: {exc}",
575 known_ids = {r.plan_id
for r
in inventory.all()}
576 for f
in plans_dir.glob(
"*.md"):
577 m = _FILENAME_RE.match(f.name)
580 stem = m.group(
"stem")
581 if stem
in known_ids:
583 state = m.group(
"state")
or "active"
585 inventory.register(stem, description=
"", session_name=
None)
586 if state !=
"active":
587 inventory.update_status(stem, state)
588 result[
"orphans_absorbed"].append(f.name)
589 _dbg(
"OC_CHAT.MAIN", func,
590 f
"absorbed orphan {f.name} (status={state})", 3)
None __init__(self, Path path, Path|None plans_dir=None)
list[PlanRecord] all(self)
str commit_atomic(self, dict candidate)
None remove(self, str plan_id)
None _commit_records(self, dict candidate)
list[PlanRecord] list_by_status(self, str status)
PlanRecord update_status(self, str plan_id, str new_status)
PlanRecord|None get(self, str plan_id)
PlanRecord register(self, str plan_id, str description, str|None session_name=None)
list[PlanRecord] list_by_status(self, str status)
PlanRecord|None get(self, str plan_id)
list[PlanRecord] all(self)
PlanRecord register(self, str plan_id, str description, str|None session_name=None)
None remove(self, str plan_id)
PlanRecord update_status(self, str plan_id, str new_status)
Path plan_body_relpath(self, str plan_id)
Path inventory_path(self)
Path inventory_path_override
dict[str, Any] to_dict(self)
PlanRecord from_dict(cls, str plan_id, dict[str, Any] data)
dict[str, list[str]] reconcile(PlanInventory inventory, Path plans_dir)
None reset_inventory_for_tests()
PlanInventory get_inventory(Path|None path=None, *, PlanPaths|None paths=None)
str _expected_filename(str plan_id, str status)