feat(realism): EditAction read-modify-write of planted files
Stage 3b of the realism migration. A TODO.md planted on Monday gets a checkbox flipped on Tuesday; a notes file grows a follow-up line; a cron log gets a fresh entry tacked on. The synthetic_files row's edit_count, last_modified, and content_hash advance. New surface: - EditAction dataclass (peer of FileAction in scheduler.py): carries decky, path, persona, content_class, previous_body, mtime, and synthetic_file_uuid for the worker's update path. - realism.bodies.next_iteration(cls, persona, prev, rng): per-class deterministic mutators. TODO flips an unchecked box and/or appends; notes/drafts/scripts append; logs are append-only (mirroring real log behaviour). Canary, cache_tmp, email raise KeyError — unsupported. - realism.planner.pick gains an edit branch: 60% create, 30% edit (when an edit_candidate is supplied), 10% leave-alone. Returns None on leave-alone — quiet ticks are realism too. - scheduler.pick_file pre-fetches a single edit candidate via repo.pick_random_synthetic_file_for_edit ~50% of ticks; the planner decides whether to use it. - SSHDriver._run_edit: turns next_iteration output into a plant_file call (mtime-bumped, mode 0o644). Stashes new_body in result.payload so the worker can hash it for synthetic_files. - worker._bump_synthetic_file_after_edit: patches edit_count + 1, last_modified=now, content_hash, last_body for the row UUID. No-op when the row was pruned mid-flight. - events.to_row / topic_for / event_type_for now recognise EditAction (kind="file", action="file:edit").
This commit is contained in:
@@ -231,3 +231,120 @@ def make_body(
|
||||
f"no body generator registered for content_class={content_class!r}"
|
||||
)
|
||||
return gen(persona, rng)
|
||||
|
||||
|
||||
# ── Edit-in-place mutators ─────────────────────────────────────────────────
|
||||
# Stage 3b: deterministic per-class mutations. The contract: take the
|
||||
# previous body bytes, return a plausible *next* iteration (append a
|
||||
# line, flip a checkbox, fix a typo). Append-only for logs; small
|
||||
# in-place edits for user content. LLM enrichment in stage 6 wires
|
||||
# next_iteration to ask "what would <persona> write next" with the
|
||||
# previous body in the prompt; the deterministic path stays as the
|
||||
# fallback.
|
||||
|
||||
|
||||
def _edit_todo(
|
||||
prev: str, persona: str, rng: secrets.SystemRandom,
|
||||
) -> str:
|
||||
"""Flip an unchecked box, append a new item, or both.
|
||||
|
||||
Real TODO files evolve: items get checked off as work happens, new
|
||||
items get added, occasionally a sub-bullet appears under an
|
||||
existing one. We pick one of those mutations per call.
|
||||
"""
|
||||
lines = prev.splitlines()
|
||||
unchecked_indices = [
|
||||
i for i, ln in enumerate(lines) if ln.startswith("- [ ]")
|
||||
]
|
||||
op = rng.choice(("flip", "append", "both") if unchecked_indices else ("append",))
|
||||
if op in ("flip", "both") and unchecked_indices:
|
||||
idx = rng.choice(unchecked_indices)
|
||||
lines[idx] = lines[idx].replace("- [ ]", "- [x]", 1)
|
||||
if op in ("append", "both"):
|
||||
new_item = rng.choice(_TODO_VERBS)
|
||||
marker = "[x]" if rng.random() < 0.15 else "[ ]"
|
||||
lines.append(f"- {marker} {new_item}")
|
||||
return "\n".join(lines) + ("" if prev.endswith("\n") else "\n")
|
||||
|
||||
|
||||
def _edit_note(
|
||||
prev: str, persona: str, rng: secrets.SystemRandom,
|
||||
) -> str:
|
||||
"""Append one new note line or insert a follow-up under an existing one."""
|
||||
new_line = rng.choice(_NOTE_TEMPLATES)
|
||||
if prev.endswith("\n"):
|
||||
return prev + new_line + "\n"
|
||||
return prev + "\n" + new_line + "\n"
|
||||
|
||||
|
||||
def _edit_draft(
|
||||
prev: str, persona: str, rng: secrets.SystemRandom,
|
||||
) -> str:
|
||||
"""Append a new short paragraph to the existing draft."""
|
||||
addition = (
|
||||
"\nFollow-up: I'll send the deck once finance signs off on the numbers.\n",
|
||||
"\nP.S.: Looping in ops on the rollout sequence — they have context I don't.\n",
|
||||
"\nLet me know if any of this needs another pass.\n",
|
||||
)
|
||||
return prev.rstrip() + "\n" + rng.choice(addition)
|
||||
|
||||
|
||||
def _edit_script(
|
||||
prev: str, persona: str, rng: secrets.SystemRandom,
|
||||
) -> str:
|
||||
"""Append a comment line — scripts evolve via comments and small fixes."""
|
||||
comments = (
|
||||
"# TODO: handle the empty-input case\n",
|
||||
"# 2026-04-27: hardened error path after the prod incident\n",
|
||||
"# noqa: shellcheck disagrees but this is what the runbook says\n",
|
||||
)
|
||||
return prev.rstrip() + "\n" + rng.choice(comments)
|
||||
|
||||
|
||||
def _edit_log_cron(
|
||||
prev: str, persona: str, rng: secrets.SystemRandom,
|
||||
) -> str:
|
||||
"""Append one new cron syslog line — logs only ever grow."""
|
||||
extra = _body_log_cron(persona, rng)
|
||||
return prev.rstrip() + "\n" + extra.splitlines()[-1] + "\n"
|
||||
|
||||
|
||||
def _edit_log_daemon(
|
||||
prev: str, persona: str, rng: secrets.SystemRandom,
|
||||
) -> str:
|
||||
extra = _body_log_daemon(persona, rng)
|
||||
return prev.rstrip() + "\n" + extra.splitlines()[-1] + "\n"
|
||||
|
||||
|
||||
_EDITORS: dict[ContentClass, Callable[[str, str, secrets.SystemRandom], str]] = {
|
||||
ContentClass.NOTE: _edit_note,
|
||||
ContentClass.TODO: _edit_todo,
|
||||
ContentClass.DRAFT: _edit_draft,
|
||||
ContentClass.SCRIPT: _edit_script,
|
||||
ContentClass.LOG_CRON: _edit_log_cron,
|
||||
ContentClass.LOG_DAEMON: _edit_log_daemon,
|
||||
}
|
||||
|
||||
|
||||
def next_iteration(
|
||||
content_class: ContentClass,
|
||||
persona: str,
|
||||
previous_body: str,
|
||||
*,
|
||||
rand: Optional[secrets.SystemRandom] = None,
|
||||
) -> str:
|
||||
"""Return the next-iteration body for an edit-in-place mutation.
|
||||
|
||||
Raises :class:`KeyError` for content classes that don't support
|
||||
editing (canary blobs, cache-tmp scratch files, email). The
|
||||
planner filters those out before producing an :class:`EditAction`,
|
||||
so reaching this branch with an unsupported class is a bug worth
|
||||
surfacing loudly.
|
||||
"""
|
||||
rng = rand or secrets.SystemRandom()
|
||||
editor = _EDITORS.get(content_class)
|
||||
if editor is None:
|
||||
raise KeyError(
|
||||
f"content_class={content_class!r} does not support edits"
|
||||
)
|
||||
return editor(previous_body, persona, rng)
|
||||
|
||||
@@ -26,7 +26,7 @@ from typing import Any, Optional, Sequence
|
||||
from decnet.realism import bodies, naming
|
||||
from decnet.realism.diurnal import in_work_hours, sample_mtime
|
||||
from decnet.realism.personas import EmailPersona
|
||||
from decnet.realism.taxonomy import ContentClass, Plan
|
||||
from decnet.realism.taxonomy import ContentClass, Plan, PlanAction # noqa: F401
|
||||
|
||||
|
||||
# Stage-3 weighted sampling:
|
||||
@@ -83,16 +83,23 @@ def pick(
|
||||
deckies: Sequence[dict[str, Any]],
|
||||
now: datetime,
|
||||
*,
|
||||
edit_candidate: Optional[dict[str, Any]] = None,
|
||||
rand: Optional[secrets.SystemRandom] = None,
|
||||
) -> Optional[Plan]:
|
||||
"""Return a single :class:`Plan` for the orchestrator's tick.
|
||||
|
||||
Stage-3 policy: create-only. Stage 3b extends with the
|
||||
create/edit/leave roll and the synthetic_files lookup for edits.
|
||||
Stage-3b policy: weighted action roll — 60% create, 30% edit, 10%
|
||||
"leave alone" (planner returns ``None`` to skip). When the roll
|
||||
is "edit" and *edit_candidate* is set (a row from
|
||||
:meth:`BaseRepository.pick_random_synthetic_file_for_edit`), we
|
||||
return an edit Plan; otherwise we fall through to create.
|
||||
|
||||
Returns ``None`` when no eligible (decky, persona) pair exists —
|
||||
the orchestrator treats that as "skip this tick" the same way the
|
||||
pre-realism scheduler did.
|
||||
The orchestrator scheduler is responsible for fetching the edit
|
||||
candidate before calling — keeps this function pure-of-DB and
|
||||
test-friendly.
|
||||
|
||||
Returns ``None`` when no eligible (decky, persona) pair exists or
|
||||
when the action roll lands on "leave alone."
|
||||
"""
|
||||
rng = rand or secrets.SystemRandom()
|
||||
|
||||
@@ -100,12 +107,18 @@ def pick(
|
||||
if not eligible:
|
||||
return None
|
||||
|
||||
# Action roll. Edit only fires when there's a candidate from the
|
||||
# repo — otherwise we either re-roll to create or skip.
|
||||
roll = rng.random()
|
||||
if roll < 0.10:
|
||||
return None # "leave alone" — quiet tick is realism too
|
||||
if roll < 0.40 and edit_candidate is not None:
|
||||
return _edit_plan(edit_candidate, now, rng)
|
||||
|
||||
decky, persona = rng.choice(eligible)
|
||||
|
||||
# User vs system content — biased toward user (realism wins are
|
||||
# bigger there). Once stage 3b ships edit-in-place, the edit
|
||||
# branch will reuse the same content_class as the existing row;
|
||||
# the create branch picks fresh here.
|
||||
# bigger there).
|
||||
if rng.random() < 0.7:
|
||||
content_class = _weighted_pick(_USER_CLASS_WEIGHTS, rng)
|
||||
else:
|
||||
@@ -130,3 +143,48 @@ def pick(
|
||||
f"window={persona.active_hours}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _edit_plan(
|
||||
candidate: dict[str, Any],
|
||||
now: datetime,
|
||||
rng: secrets.SystemRandom,
|
||||
) -> Optional[Plan]:
|
||||
"""Build an edit-action :class:`Plan` from a synthetic_files row.
|
||||
|
||||
The candidate dict is the shape :meth:`BaseRepository.list_synthetic_files`
|
||||
returns — we only need ``decky_uuid``, ``path``, ``persona``,
|
||||
``content_class``, ``last_body``, ``uuid``. Returns ``None`` if
|
||||
the candidate's content_class is somehow not editable (defensive
|
||||
— the repo query already filters those out).
|
||||
"""
|
||||
try:
|
||||
cls = ContentClass(candidate["content_class"])
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
if cls.is_canary() or cls == ContentClass.CACHE_TMP:
|
||||
return None
|
||||
# mtime: edits bump forward by ~hours-to-days, but never past now.
|
||||
# We model as "the file was edited some time after creation but
|
||||
# before now" — sample_mtime with a tighter cap keeps it recent.
|
||||
edit_mtime = sample_mtime(
|
||||
"00:00-00:00", now, rand=rng,
|
||||
backdate_min_hours=1.0, backdate_max_days=2.0,
|
||||
)
|
||||
return Plan(
|
||||
decky_uuid=candidate["decky_uuid"],
|
||||
decky_name=candidate.get("decky_name", ""),
|
||||
persona=candidate.get("persona", ""),
|
||||
content_class=cls,
|
||||
action="edit",
|
||||
target_path=candidate["path"],
|
||||
mtime=edit_mtime,
|
||||
body_hint=None, # edit uses previous_body, not a fresh hint
|
||||
previous_body=candidate.get("last_body", ""),
|
||||
notes=(
|
||||
f"persona={candidate.get('persona', '')}",
|
||||
f"class={cls.value}",
|
||||
"action=edit",
|
||||
f"synthetic_file_uuid={candidate.get('uuid', '')}",
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user