6High-level plan operations on top of the plan inventory.
10Public functions that combine inventory writes with filename mirroring.
11Every state-change operation follows the protocol:
13 1. Inventory write FIRST (atomic).
14 2. Filename rename SECOND.
15 3. On filename failure, inventory still correct; next call's
16 reconcile() heals the filename.
20- :func:`create_plan` — allocate next prefix, generate kebab-case stem,
21 register inventory record, return ``(plan_id, path)``. Does NOT
22 create the file on disk; the agent is expected to ``write_file`` it.
23- :func:`pause_plan`, :func:`continue_plan`, :func:`complete_plan`,
24 :func:`abort_plan` — lifecycle transitions.
25- :func:`list_plans` — return all records (from inventory), grouped or
26 not by status as the caller wishes.
27- :func:`find_plan_by_id` — resolve a 3-digit prefix OR full stem to a
28 ``PlanRecord``. Returns ``None`` for ambiguous prefix.
29- :func:`get_plan_status` — convenience wrapper for ``inventory.get(id).status``.
30- :func:`parse_plan_filename` — regex helper exposed for callers.
36 L2 — high-level operation lifecycle
37 L3 — semantic details (plan_id, prefix, status transition)
38 L4 — deep tracing (filesystem rename attempts, reconcile diffs)
42- All public mutators raise :class:`KeyError` for unknown plan_ids.
43- :func:`continue_plan` raises :class:`ValueError` if the plan is not
44 currently paused. :func:`complete_plan` / :func:`abort_plan` raise on
45 terminal-to-terminal transitions.
46- :func:`create_plan` writes to inventory and chooses a filename, but
47 does NOT create the file on disk.
48- :func:`find_plan_by_id` returns the matching record or ``None``.
49 Raises :class:`ValueError` only when the input is ambiguous
50 (multiple matches by prefix).
54- oc_diagnostics (structured debug logging via _dbg)
55- oc_orchestrator.plan_inventory (PlanInventory, get_inventory, reconcile)
56- oc_orchestrator.wordgen (random_plan_filename)
58from __future__
import annotations
61from pathlib
import Path
63from oc_diagnostics
import _dbg
87 """Parse a plan filename into ``(stem, state)``.
89 State is ``"active"`` when no suffix is present; otherwise one of
90 ``"paused"``, ``"complete"``, ``"aborted"``. Returns ``None`` for
91 filenames that don't match the plan-file pattern.
93 m = _FILENAME_RE.match(name)
96 state = m.group(
"state")
or "active"
97 return (m.group(
"stem"), state)
101 """Locate the on-disk file for ``plan_id`` (any suffix). Returns None."""
102 for f
in plans_dir.glob(f
"{plan_id}*.md"):
104 if parsed
is not None and parsed[0] == plan_id:
110 """Rename the on-disk file to match the new inventory status.
112 No-op if the file is missing or already at the expected name.
113 Raises :class:`OSError` if the rename fails (caller may swallow and
114 rely on :func:`reconcile` to heal later).
116 func =
"_rename_to_state"
117 target_name = _expected_filename(plan_id, new_status)
118 target = plans_dir / target_name
121 _dbg(
"OC_CHAT.MAIN", func,
122 f
"no on-disk file for {plan_id!r}; skipping rename", 3)
127 _dbg(
"OC_CHAT.MAIN", func, f
"{src.name} → {target_name}", 3)
131 inventory: PlanInventory |
None,
133) -> tuple[PlanInventory, Path]:
134 """Internal: pick the active inventory and resolve plans_dir."""
135 if inventory
is None:
136 inventory = get_inventory()
137 return inventory, plans_dir
142 session_name: str |
None =
None,
144 inventory: PlanInventory |
None =
None,
145 plans_dir: Path |
None =
None,
146) -> tuple[str, Path]:
147 """Allocate a new plan and register its inventory record.
149 Returns ``(plan_id, path)``. The on-disk file is **not** created;
150 the caller (typically the agent via ``write_file``) writes the
151 Markdown content to ``path``.
153 Refusal logic (e.g., "another plan is active") is the caller's
154 responsibility — this function does not consult any session state.
157 plans_dir = Path(plans_dir)
if plans_dir
is not None else DEFAULT_PLANS_DIR
159 plans_dir.mkdir(parents=
True, exist_ok=
True)
164 for attempt
in range(5):
165 prefix = inv.next_prefix()
166 candidate = random_plan_filename(prefix)
167 plan_id = candidate[:-3]
168 if inv.get(plan_id)
is None and not (plans_dir / candidate).exists():
170 _dbg(
"OC_CHAT.MAIN", func,
171 f
"collision on {plan_id!r}; retrying (attempt {attempt + 1})", 3)
173 raise RuntimeError(
"create_plan: 5 collisions in a row — investigate")
175 inv.register(plan_id, description=description, session_name=session_name)
176 path = plans_dir / candidate
177 _dbg(
"OC_CHAT.MAIN", func, f
"created {plan_id!r} → {path}", 2)
184 inventory: PlanInventory |
None,
185 plans_dir: Path |
None,
187 """Shared implementation: inventory update first, then rename."""
189 plans_dir = Path(plans_dir)
if plans_dir
is not None else DEFAULT_PLANS_DIR
191 rec = inv.update_status(plan_id, new_status)
194 except OSError
as exc:
195 _dbg(
"OC_CHAT.MAIN", func,
196 f
"SYSTEM_ERROR: rename failed for {plan_id!r}: {exc}; "
197 "inventory is correct, reconcile will heal", 1, override=
True)
204 inventory: PlanInventory |
None =
None,
205 plans_dir: Path |
None =
None,
207 """Pause an active plan. Idempotent on already-paused."""
209 rec = inv.get(plan_id)
211 raise KeyError(f
"unknown plan_id: {plan_id!r}")
212 if rec.status ==
"paused":
214 if rec.status
in TERMINAL_STATUSES:
216 f
"cannot pause plan in terminal status {rec.status!r}: {plan_id!r}"
218 return _transition(plan_id,
"paused", inv, plans_dir)
224 inventory: PlanInventory |
None =
None,
225 plans_dir: Path |
None =
None,
227 """Resume a paused plan. Refuses if not currently paused."""
229 rec = inv.get(plan_id)
231 raise KeyError(f
"unknown plan_id: {plan_id!r}")
232 if rec.status !=
"paused":
234 f
"can only continue a paused plan; {plan_id!r} is {rec.status!r}"
236 return _transition(plan_id,
"active", inv, plans_dir)
242 inventory: PlanInventory |
None =
None,
243 plans_dir: Path |
None =
None,
245 """Mark a plan complete. Terminal."""
247 rec = inv.get(plan_id)
249 raise KeyError(f
"unknown plan_id: {plan_id!r}")
250 if rec.status
in TERMINAL_STATUSES:
252 f
"plan {plan_id!r} is already in terminal status {rec.status!r}"
254 return _transition(plan_id,
"complete", inv, plans_dir)
260 inventory: PlanInventory |
None =
None,
261 plans_dir: Path |
None =
None,
263 """Mark a plan aborted. Terminal."""
265 rec = inv.get(plan_id)
267 raise KeyError(f
"unknown plan_id: {plan_id!r}")
268 if rec.status
in TERMINAL_STATUSES:
270 f
"plan {plan_id!r} is already in terminal status {rec.status!r}"
272 return _transition(plan_id,
"aborted", inv, plans_dir)
277 inventory: PlanInventory |
None =
None,
278) -> list[PlanRecord]:
279 """Return all records, sorted by ``created_at`` descending."""
280 inv = inventory
if inventory
is not None else get_inventory()
281 return sorted(inv.all(), key=
lambda r: r.created_at, reverse=
True)
285 plan_id_or_prefix: str,
287 inventory: PlanInventory |
None =
None,
288) -> PlanRecord |
None:
289 """Resolve a 3-digit prefix or full stem to a :class:`PlanRecord`.
291 - If the input matches a full plan_id exactly, return that record.
292 - If the input is a 3-digit prefix and exactly one record matches,
294 - Returns ``None`` for no match.
295 - Raises :class:`ValueError` for ambiguous prefix (multiple matches).
297 inv = inventory
if inventory
is not None else get_inventory()
298 direct = inv.get(plan_id_or_prefix)
299 if direct
is not None:
301 if re.fullmatch(
r"\d{1,3}", plan_id_or_prefix):
302 prefix_num = int(plan_id_or_prefix)
303 wanted_prefix = f
"{prefix_num:03d}-"
304 matches = [r
for r
in inv.all()
if r.plan_id.startswith(wanted_prefix)]
309 f
"ambiguous prefix {plan_id_or_prefix!r}: "
310 f
"{len(matches)} plans match — provide full stem instead"
319 inventory: PlanInventory |
None =
None,
321 """Return the status of ``plan_id`` from the inventory, or ``None``."""
322 inv = inventory
if inventory
is not None else get_inventory()
323 rec = inv.get(plan_id)
324 return rec.status
if rec
is not None else None
tuple[PlanInventory, Path] _resolve_inventory(PlanInventory|None inventory, Path plans_dir)
PlanRecord abort_plan(str plan_id, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
PlanRecord pause_plan(str plan_id, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
str|None get_plan_status(str plan_id, *, PlanInventory|None inventory=None)
tuple[str, str]|None parse_plan_filename(str name)
tuple[str, Path] create_plan(str description, str|None session_name=None, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
PlanRecord continue_plan(str plan_id, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
PlanRecord complete_plan(str plan_id, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
Path|None _find_plan_file(Path plans_dir, str plan_id)
None _rename_to_state(Path plans_dir, str plan_id, str new_status)
list[PlanRecord] list_plans(*, PlanInventory|None inventory=None)
PlanRecord _transition(str plan_id, str new_status, PlanInventory|None inventory, Path|None plans_dir)
PlanRecord|None find_plan_by_id(str plan_id_or_prefix, *, PlanInventory|None inventory=None)