oc_orchestrator
Loading...
Searching...
No Matches
plans.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/plans.py
4
5"""
6High-level plan operations on top of the plan inventory.
7
8Purpose
9-------
10Public functions that combine inventory writes with filename mirroring.
11Every state-change operation follows the protocol:
12
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.
17
18Responsibilities
19----------------
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.
31
32Diagnostics
33-----------
34Domain: OC_CHAT.MAIN
35Levels:
36 L2 — high-level operation lifecycle
37 L3 — semantic details (plan_id, prefix, status transition)
38 L4 — deep tracing (filesystem rename attempts, reconcile diffs)
39
40Contracts
41---------
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).
51
52Dependencies
53------------
54- oc_diagnostics (structured debug logging via _dbg)
55- oc_orchestrator.plan_inventory (PlanInventory, get_inventory, reconcile)
56- oc_orchestrator.wordgen (random_plan_filename)
57"""
58from __future__ import annotations
59
60import re
61from pathlib import Path
62
63from oc_diagnostics import _dbg
64
66 DEFAULT_PLANS_DIR,
67 PlanInventory,
68 PlanPaths,
69 PlanRecord,
70 TERMINAL_STATUSES,
71 _FILENAME_RE,
72 _expected_filename,
73 get_inventory,
74 reconcile,
75)
76from oc_orchestrator.wordgen import random_plan_filename
77
78#: Re-exported, not redefined (plan 257 P3). The literal lives beside the
79#: inventory filename it is joined to, so the layout assumption is stated
80#: once. Existing callers -- ``scripts/plan_manage.py`` and
81#: ``core/commands.py`` -- and the test that monkeypatches
82#: ``oc_orchestrator.plans.DEFAULT_PLANS_DIR`` are unaffected: the name is
83#: still bound in this module and still read at call time.
84
85
86def parse_plan_filename(name: str) -> tuple[str, str] | None:
87 """Parse a plan filename into ``(stem, state)``.
88
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.
92 """
93 m = _FILENAME_RE.match(name)
94 if not m:
95 return None
96 state = m.group("state") or "active"
97 return (m.group("stem"), state)
98
99
100def _find_plan_file(plans_dir: Path, plan_id: str) -> Path | None:
101 """Locate the on-disk file for ``plan_id`` (any suffix). Returns None."""
102 for f in plans_dir.glob(f"{plan_id}*.md"):
103 parsed = parse_plan_filename(f.name)
104 if parsed is not None and parsed[0] == plan_id:
105 return f
106 return None
107
108
109def _rename_to_state(plans_dir: Path, plan_id: str, new_status: str) -> None:
110 """Rename the on-disk file to match the new inventory status.
111
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).
115 """
116 func = "_rename_to_state"
117 target_name = _expected_filename(plan_id, new_status)
118 target = plans_dir / target_name
119 src = _find_plan_file(plans_dir, plan_id)
120 if src is None:
121 _dbg("OC_CHAT.MAIN", func,
122 f"no on-disk file for {plan_id!r}; skipping rename", 3)
123 return
124 if src == target:
125 return
126 src.rename(target)
127 _dbg("OC_CHAT.MAIN", func, f"{src.name} → {target_name}", 3)
128
129
131 inventory: PlanInventory | None,
132 plans_dir: Path,
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
138
139
141 description: str,
142 session_name: str | None = None,
143 *,
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.
148
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``.
152
153 Refusal logic (e.g., "another plan is active") is the caller's
154 responsibility — this function does not consult any session state.
155 """
156 func = "create_plan"
157 plans_dir = Path(plans_dir) if plans_dir is not None else DEFAULT_PLANS_DIR
158 inv, plans_dir = _resolve_inventory(inventory, plans_dir)
159 plans_dir.mkdir(parents=True, exist_ok=True)
160
161 # Collision-retry: 5 attempts then 4-digit suffix fallback handled
162 # internally by random_plan_filename; here we just retry against
163 # existing files for the (very rare) double-collision case.
164 for attempt in range(5):
165 prefix = inv.next_prefix()
166 candidate = random_plan_filename(prefix)
167 plan_id = candidate[:-3] # strip ".md"
168 if inv.get(plan_id) is None and not (plans_dir / candidate).exists():
169 break
170 _dbg("OC_CHAT.MAIN", func,
171 f"collision on {plan_id!r}; retrying (attempt {attempt + 1})", 3)
172 else:
173 raise RuntimeError("create_plan: 5 collisions in a row — investigate")
174
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)
178 return plan_id, path
179
180
182 plan_id: str,
183 new_status: str,
184 inventory: PlanInventory | None,
185 plans_dir: Path | None,
186) -> PlanRecord:
187 """Shared implementation: inventory update first, then rename."""
188 func = "_transition"
189 plans_dir = Path(plans_dir) if plans_dir is not None else DEFAULT_PLANS_DIR
190 inv, plans_dir = _resolve_inventory(inventory, plans_dir)
191 rec = inv.update_status(plan_id, new_status)
192 try:
193 _rename_to_state(plans_dir, 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)
198 return rec
199
200
202 plan_id: str,
203 *,
204 inventory: PlanInventory | None = None,
205 plans_dir: Path | None = None,
206) -> PlanRecord:
207 """Pause an active plan. Idempotent on already-paused."""
208 inv, plans_dir = _resolve_inventory(inventory, Path(plans_dir or DEFAULT_PLANS_DIR))
209 rec = inv.get(plan_id)
210 if rec is None:
211 raise KeyError(f"unknown plan_id: {plan_id!r}")
212 if rec.status == "paused":
213 return rec
214 if rec.status in TERMINAL_STATUSES:
215 raise ValueError(
216 f"cannot pause plan in terminal status {rec.status!r}: {plan_id!r}"
217 )
218 return _transition(plan_id, "paused", inv, plans_dir)
219
220
222 plan_id: str,
223 *,
224 inventory: PlanInventory | None = None,
225 plans_dir: Path | None = None,
226) -> PlanRecord:
227 """Resume a paused plan. Refuses if not currently paused."""
228 inv, plans_dir = _resolve_inventory(inventory, Path(plans_dir or DEFAULT_PLANS_DIR))
229 rec = inv.get(plan_id)
230 if rec is None:
231 raise KeyError(f"unknown plan_id: {plan_id!r}")
232 if rec.status != "paused":
233 raise ValueError(
234 f"can only continue a paused plan; {plan_id!r} is {rec.status!r}"
235 )
236 return _transition(plan_id, "active", inv, plans_dir)
237
238
240 plan_id: str,
241 *,
242 inventory: PlanInventory | None = None,
243 plans_dir: Path | None = None,
244) -> PlanRecord:
245 """Mark a plan complete. Terminal."""
246 inv, plans_dir = _resolve_inventory(inventory, Path(plans_dir or DEFAULT_PLANS_DIR))
247 rec = inv.get(plan_id)
248 if rec is None:
249 raise KeyError(f"unknown plan_id: {plan_id!r}")
250 if rec.status in TERMINAL_STATUSES:
251 raise ValueError(
252 f"plan {plan_id!r} is already in terminal status {rec.status!r}"
253 )
254 return _transition(plan_id, "complete", inv, plans_dir)
255
256
258 plan_id: str,
259 *,
260 inventory: PlanInventory | None = None,
261 plans_dir: Path | None = None,
262) -> PlanRecord:
263 """Mark a plan aborted. Terminal."""
264 inv, plans_dir = _resolve_inventory(inventory, Path(plans_dir or DEFAULT_PLANS_DIR))
265 rec = inv.get(plan_id)
266 if rec is None:
267 raise KeyError(f"unknown plan_id: {plan_id!r}")
268 if rec.status in TERMINAL_STATUSES:
269 raise ValueError(
270 f"plan {plan_id!r} is already in terminal status {rec.status!r}"
271 )
272 return _transition(plan_id, "aborted", inv, plans_dir)
273
274
276 *,
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)
282
283
285 plan_id_or_prefix: str,
286 *,
287 inventory: PlanInventory | None = None,
288) -> PlanRecord | None:
289 """Resolve a 3-digit prefix or full stem to a :class:`PlanRecord`.
290
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,
293 return that record.
294 - Returns ``None`` for no match.
295 - Raises :class:`ValueError` for ambiguous prefix (multiple matches).
296 """
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:
300 return direct
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)]
305 if not matches:
306 return None
307 if len(matches) > 1:
308 raise ValueError(
309 f"ambiguous prefix {plan_id_or_prefix!r}: "
310 f"{len(matches)} plans match — provide full stem instead"
311 )
312 return matches[0]
313 return None
314
315
317 plan_id: str,
318 *,
319 inventory: PlanInventory | None = None,
320) -> str | 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)
Definition plans.py:133
PlanRecord abort_plan(str plan_id, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
Definition plans.py:262
PlanRecord pause_plan(str plan_id, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
Definition plans.py:206
str|None get_plan_status(str plan_id, *, PlanInventory|None inventory=None)
Definition plans.py:320
tuple[str, str]|None parse_plan_filename(str name)
Definition plans.py:86
tuple[str, Path] create_plan(str description, str|None session_name=None, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
Definition plans.py:146
PlanRecord continue_plan(str plan_id, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
Definition plans.py:226
PlanRecord complete_plan(str plan_id, *, PlanInventory|None inventory=None, Path|None plans_dir=None)
Definition plans.py:244
Path|None _find_plan_file(Path plans_dir, str plan_id)
Definition plans.py:100
None _rename_to_state(Path plans_dir, str plan_id, str new_status)
Definition plans.py:109
list[PlanRecord] list_plans(*, PlanInventory|None inventory=None)
Definition plans.py:278
PlanRecord _transition(str plan_id, str new_status, PlanInventory|None inventory, Path|None plans_dir)
Definition plans.py:186
PlanRecord|None find_plan_by_id(str plan_id_or_prefix, *, PlanInventory|None inventory=None)
Definition plans.py:288